VMenu.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { mergeProps as _mergeProps, createVNode as _createVNode } from "vue";
  2. // Styles
  3. import "./VMenu.css";
  4. // Components
  5. import { VDialogTransition } from "../transitions/index.mjs";
  6. import { VDefaultsProvider } from "../VDefaultsProvider/index.mjs";
  7. import { VOverlay } from "../VOverlay/index.mjs";
  8. import { makeVOverlayProps } from "../VOverlay/VOverlay.mjs"; // Composables
  9. import { forwardRefs } from "../../composables/forwardRefs.mjs";
  10. import { useRtl } from "../../composables/locale.mjs";
  11. import { useProxiedModel } from "../../composables/proxiedModel.mjs";
  12. import { useScopeId } from "../../composables/scopeId.mjs"; // Utilities
  13. import { computed, inject, mergeProps, nextTick, onBeforeUnmount, onDeactivated, provide, ref, shallowRef, watch } from 'vue';
  14. import { VMenuSymbol } from "./shared.mjs";
  15. import { focusableChildren, focusChild, genericComponent, getNextElement, getUid, IN_BROWSER, isClickInsideElement, omit, propsFactory, useRender } from "../../util/index.mjs"; // Types
  16. export const makeVMenuProps = propsFactory({
  17. // TODO
  18. // disableKeys: Boolean,
  19. id: String,
  20. submenu: Boolean,
  21. ...omit(makeVOverlayProps({
  22. closeDelay: 250,
  23. closeOnContentClick: true,
  24. locationStrategy: 'connected',
  25. location: undefined,
  26. openDelay: 300,
  27. scrim: false,
  28. scrollStrategy: 'reposition',
  29. transition: {
  30. component: VDialogTransition
  31. }
  32. }), ['absolute'])
  33. }, 'VMenu');
  34. export const VMenu = genericComponent()({
  35. name: 'VMenu',
  36. props: makeVMenuProps(),
  37. emits: {
  38. 'update:modelValue': value => true
  39. },
  40. setup(props, _ref) {
  41. let {
  42. slots
  43. } = _ref;
  44. const isActive = useProxiedModel(props, 'modelValue');
  45. const {
  46. scopeId
  47. } = useScopeId();
  48. const {
  49. isRtl
  50. } = useRtl();
  51. const uid = getUid();
  52. const id = computed(() => props.id || `v-menu-${uid}`);
  53. const overlay = ref();
  54. const parent = inject(VMenuSymbol, null);
  55. const openChildren = shallowRef(new Set());
  56. provide(VMenuSymbol, {
  57. register() {
  58. openChildren.value.add(uid);
  59. },
  60. unregister() {
  61. openChildren.value.delete(uid);
  62. },
  63. closeParents(e) {
  64. setTimeout(() => {
  65. if (!openChildren.value.size && !props.persistent && (e == null || overlay.value?.contentEl && !isClickInsideElement(e, overlay.value.contentEl))) {
  66. isActive.value = false;
  67. parent?.closeParents();
  68. }
  69. }, 40);
  70. }
  71. });
  72. onBeforeUnmount(() => {
  73. parent?.unregister();
  74. document.removeEventListener('focusin', onFocusIn);
  75. });
  76. onDeactivated(() => isActive.value = false);
  77. async function onFocusIn(e) {
  78. const before = e.relatedTarget;
  79. const after = e.target;
  80. await nextTick();
  81. if (isActive.value && before !== after && overlay.value?.contentEl &&
  82. // We're the topmost menu
  83. overlay.value?.globalTop &&
  84. // It isn't the document or the menu body
  85. ![document, overlay.value.contentEl].includes(after) &&
  86. // It isn't inside the menu body
  87. !overlay.value.contentEl.contains(after)) {
  88. const focusable = focusableChildren(overlay.value.contentEl);
  89. focusable[0]?.focus();
  90. }
  91. }
  92. watch(isActive, val => {
  93. if (val) {
  94. parent?.register();
  95. if (IN_BROWSER) {
  96. document.addEventListener('focusin', onFocusIn, {
  97. once: true
  98. });
  99. }
  100. } else {
  101. parent?.unregister();
  102. if (IN_BROWSER) {
  103. document.removeEventListener('focusin', onFocusIn);
  104. }
  105. }
  106. }, {
  107. immediate: true
  108. });
  109. function onClickOutside(e) {
  110. parent?.closeParents(e);
  111. }
  112. function onKeydown(e) {
  113. if (props.disabled) return;
  114. if (e.key === 'Tab' || e.key === 'Enter' && !props.closeOnContentClick) {
  115. if (e.key === 'Enter' && (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement && !!e.target.closest('form'))) return;
  116. if (e.key === 'Enter') e.preventDefault();
  117. const nextElement = getNextElement(focusableChildren(overlay.value?.contentEl, false), e.shiftKey ? 'prev' : 'next', el => el.tabIndex >= 0);
  118. if (!nextElement) {
  119. isActive.value = false;
  120. overlay.value?.activatorEl?.focus();
  121. }
  122. } else if (props.submenu && e.key === (isRtl.value ? 'ArrowRight' : 'ArrowLeft')) {
  123. isActive.value = false;
  124. overlay.value?.activatorEl?.focus();
  125. }
  126. }
  127. function onActivatorKeydown(e) {
  128. if (props.disabled) return;
  129. const el = overlay.value?.contentEl;
  130. if (el && isActive.value) {
  131. if (e.key === 'ArrowDown') {
  132. e.preventDefault();
  133. e.stopImmediatePropagation();
  134. focusChild(el, 'next');
  135. } else if (e.key === 'ArrowUp') {
  136. e.preventDefault();
  137. e.stopImmediatePropagation();
  138. focusChild(el, 'prev');
  139. } else if (props.submenu) {
  140. if (e.key === (isRtl.value ? 'ArrowRight' : 'ArrowLeft')) {
  141. isActive.value = false;
  142. } else if (e.key === (isRtl.value ? 'ArrowLeft' : 'ArrowRight')) {
  143. e.preventDefault();
  144. focusChild(el, 'first');
  145. }
  146. }
  147. } else if (props.submenu ? e.key === (isRtl.value ? 'ArrowLeft' : 'ArrowRight') : ['ArrowDown', 'ArrowUp'].includes(e.key)) {
  148. isActive.value = true;
  149. e.preventDefault();
  150. setTimeout(() => setTimeout(() => onActivatorKeydown(e)));
  151. }
  152. }
  153. const activatorProps = computed(() => mergeProps({
  154. 'aria-haspopup': 'menu',
  155. 'aria-expanded': String(isActive.value),
  156. 'aria-owns': id.value,
  157. onKeydown: onActivatorKeydown
  158. }, props.activatorProps));
  159. useRender(() => {
  160. const overlayProps = VOverlay.filterProps(props);
  161. return _createVNode(VOverlay, _mergeProps({
  162. "ref": overlay,
  163. "id": id.value,
  164. "class": ['v-menu', props.class],
  165. "style": props.style
  166. }, overlayProps, {
  167. "modelValue": isActive.value,
  168. "onUpdate:modelValue": $event => isActive.value = $event,
  169. "absolute": true,
  170. "activatorProps": activatorProps.value,
  171. "location": props.location ?? (props.submenu ? 'end' : 'bottom'),
  172. "onClick:outside": onClickOutside,
  173. "onKeydown": onKeydown
  174. }, scopeId), {
  175. activator: slots.activator,
  176. default: function () {
  177. for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
  178. args[_key] = arguments[_key];
  179. }
  180. return _createVNode(VDefaultsProvider, {
  181. "root": "VMenu"
  182. }, {
  183. default: () => [slots.default?.(...args)]
  184. });
  185. }
  186. });
  187. });
  188. return forwardRefs({
  189. id,
  190. ΨopenChildren: openChildren
  191. }, overlay);
  192. }
  193. });
  194. //# sourceMappingURL=VMenu.mjs.map