@getdom/studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,359 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
3
+ import { provideMenuContext, useParentMenuContext } from './menuContext.js';
4
+ import { positionSubmenuPanel } from './menuPlacement.js';
5
+ import {
6
+ createSubmenuCloseTimer,
7
+ focusFirstMenuItem,
8
+ isLeavingSubmenu,
9
+ isSubmenuCloseKey,
10
+ isSubmenuOpenKey,
11
+ submenuOptions,
12
+ } from './submenu.js';
13
+
14
+ defineOptions({
15
+ __doc: {
16
+ name: 'Menu',
17
+ tag: '<DomMenu>',
18
+ description: 'A roving-focus action menu for command lists, settings menus, and static menu panels.',
19
+ slots: [
20
+ { name: '(default)', description: 'Custom <MenuItem> rows when you want markup-driven menus instead of the items prop.' },
21
+ { name: 'item', payload: '{ item, index, value, label, checked, open, hasChildren }', description: 'Custom label/body content inside the default menu row.' },
22
+ { name: 'item-accessory', payload: '{ item, index, value, label, checked, open, hasChildren }', description: 'Custom right-side content for rows that expose shortcuts, children, checks, or accessorySlot.' },
23
+ { name: 'item-row', payload: '{ item, index, value, label, checked, open, hasChildren }', description: 'Replace the full inner row markup while DomMenu keeps the interactive wrapper.' },
24
+ { name: 'item-label', payload: '{ item, index, value, label }', description: 'Replace the markup for non-selectable label/static rows.' },
25
+ { name: 'item.slot', payload: '{ item, index, value, label, checked, open, hasChildren }', description: 'Set item.slot to a named slot for per-item row markup.' },
26
+ { name: 'item.accessorySlot', payload: '{ item, index, value, label, checked, open, hasChildren }', description: 'Set item.accessorySlot to a named slot for per-item right-side accessory content.' },
27
+ ],
28
+ events: [
29
+ { name: 'select', payload: '({ value, item })', description: 'Fired when an item is selected.' },
30
+ { name: 'change', payload: '({ value, checked, item })', description: 'Fired when a checkbox or radio item changes.' },
31
+ ],
32
+ },
33
+ });
34
+
35
+ const props = defineProps({
36
+ items: {
37
+ type: Array,
38
+ default: null,
39
+ _edit: { component: 'DomJsonListInput', description: 'Menu rows. Use separator: true for dividers.', props: { compact: true } },
40
+ },
41
+ orientation: {
42
+ type: String,
43
+ default: 'vertical',
44
+ _edit: { options: ['vertical', 'horizontal'], description: 'Arrow key direction.' },
45
+ },
46
+ skin: {
47
+ type: Boolean,
48
+ default: true,
49
+ _edit: { description: 'Render menu with the floating skin. Disable when embedding inside a popover or dropdown.' },
50
+ },
51
+ });
52
+ const emit = defineEmits(['select', 'change']);
53
+ const root = ref(null);
54
+ const openPath = ref('');
55
+ const submenuPanel = ref(null);
56
+ const submenuTrigger = ref(null);
57
+ const parentMenu = useParentMenuContext();
58
+ const hasItemPropRows = computed(() => Array.isArray(props.items) && props.items.length > 0);
59
+ let parentPopover = null;
60
+ let parentPopoverToggleOff = null;
61
+ const closeTimer = createSubmenuCloseTimer((value, focusBack = false) => closeSubmenu(value, focusBack));
62
+
63
+ provideMenuContext({
64
+ select(event) {
65
+ emit('select', event);
66
+ parentMenu?.select?.(event);
67
+ },
68
+ change(event) {
69
+ emit('change', event);
70
+ parentMenu?.change?.(event);
71
+ },
72
+ });
73
+
74
+ onMounted(async () => {
75
+ await import('../../lib/headless/menu.js');
76
+ root.value?.addEventListener('dom:select', (event) => emitSelect({
77
+ value: event.detail.value,
78
+ item: itemForValue(event.detail.value) || event.detail.item,
79
+ }));
80
+ root.value?.addEventListener('dom:change', (event) => emitChange({
81
+ value: event.detail.value,
82
+ checked: event.detail.checked,
83
+ item: itemForValue(event.detail.value) || event.detail.item,
84
+ }));
85
+ root.value?.addEventListener('dom:submenu', (event) => {
86
+ if (!hasItemPropRows.value) return;
87
+ openSubmenu(event.detail.value, event.detail.item, true);
88
+ });
89
+ parentPopover = root.value?.closest?.('[popover]');
90
+ if (parentPopover) {
91
+ parentPopoverToggleOff = (event) => {
92
+ if (event.newState === 'closed') closeActiveSubmenu(false);
93
+ };
94
+ parentPopover.addEventListener('toggle', parentPopoverToggleOff);
95
+ }
96
+ });
97
+
98
+ const labelOf = (item) => (item && typeof item === 'object' ? (item.label ?? item.value) : item);
99
+ const valueOf = (item) => String(item && typeof item === 'object' ? (item.value ?? item.label) : item);
100
+ const hasChildren = (item) => Array.isArray(item?.children) && item.children.length > 0;
101
+ const isCheckable = (item) => item?.type === 'checkbox' || item?.type === 'radio';
102
+ const isStaticItem = (item) => item?.type === 'label' || item?.type === 'static' || item?.static === true || item?.selectable === false;
103
+ const hasCheckAccessory = (item) => isCheckable(item) && item?.checkPlacement !== 'start';
104
+ const hasCustomAccessory = (item) => Boolean(item?.accessorySlot || item?.accessory);
105
+ const hasAccessory = (item) => hasCustomAccessory(item) || hasChildren(item) || hasCheckAccessory(item) || Boolean(item?.shortcut);
106
+ const itemTag = (item) => item?.as || (item?.href ? 'a' : 'button');
107
+ const slotNameOf = (item) => item?.slot || 'item-row';
108
+ const accessorySlotNameOf = (item) => item?.accessorySlot || item?.accessory || 'item-accessory';
109
+ const slotProps = (item, index) => {
110
+ const value = valueOf(item);
111
+ return {
112
+ item,
113
+ index,
114
+ value,
115
+ label: labelOf(item),
116
+ checked: Boolean(item?.checked),
117
+ open: openPath.value === value,
118
+ hasChildren: hasChildren(item),
119
+ };
120
+ };
121
+ const roleOf = (item) => {
122
+ if (hasChildren(item)) return 'menuitem';
123
+ if (item?.type === 'checkbox') return 'menuitemcheckbox';
124
+ if (item?.type === 'radio') return 'menuitemradio';
125
+ return 'menuitem';
126
+ };
127
+
128
+ function itemForValue(value) {
129
+ return props.items?.find?.((item) => valueOf(item) === value) || null;
130
+ }
131
+
132
+ function emitSelect(event) {
133
+ emit('select', event);
134
+ parentMenu?.select?.(event);
135
+ }
136
+
137
+ function emitChange(event) {
138
+ emit('change', event);
139
+ parentMenu?.change?.(event);
140
+ }
141
+
142
+ function activeChildItems() {
143
+ return props.items?.find?.((item) => valueOf(item) === openPath.value)?.children || [];
144
+ }
145
+
146
+ function placeSubmenu() {
147
+ if (!submenuPanel.value || !submenuTrigger.value) return;
148
+ positionSubmenuPanel(submenuPanel.value, submenuTrigger.value, submenuOptions());
149
+ }
150
+
151
+ function openSubmenu(value, trigger = null, focusFirst = false) {
152
+ cancelSubmenuClose();
153
+ openPath.value = value;
154
+ if (trigger) submenuTrigger.value = trigger;
155
+ nextTick(() => {
156
+ if (!submenuTrigger.value) {
157
+ submenuTrigger.value = root.value?.querySelector(`[data-value="${CSS.escape(value)}"]`);
158
+ }
159
+ placeSubmenu();
160
+ if (focusFirst) focusFirstMenuItem(submenuPanel.value);
161
+ });
162
+ }
163
+
164
+ function closeSubmenu(value, focusBack = true) {
165
+ closeTimer.cancel();
166
+ if (openPath.value === value) openPath.value = '';
167
+ if (focusBack) root.value?.querySelector(`[data-value="${CSS.escape(value)}"]`)?.focus();
168
+ }
169
+
170
+ function closeActiveSubmenu(focusBack = true) {
171
+ if (!openPath.value) return;
172
+ closeSubmenu(openPath.value, focusBack);
173
+ }
174
+
175
+ function cancelSubmenuClose() {
176
+ closeTimer.cancel();
177
+ }
178
+
179
+ function scheduleCloseSubmenu(value, focusBack = false) {
180
+ closeTimer.schedule(value, focusBack);
181
+ }
182
+
183
+ function maybeCloseSubmenu(event, value) {
184
+ if (!isLeavingSubmenu(event, null, submenuPanel.value)) return;
185
+ scheduleCloseSubmenu(value, false);
186
+ }
187
+
188
+ function maybeCloseSubmenuPanel(event) {
189
+ if (!openPath.value) return;
190
+ if (!isLeavingSubmenu(event, submenuTrigger.value, null)) return;
191
+ scheduleCloseSubmenu(openPath.value, false);
192
+ }
193
+
194
+ function onItemKeydown(event, item) {
195
+ if (!hasChildren(item)) return;
196
+ const value = valueOf(item);
197
+ if (isSubmenuOpenKey(event)) {
198
+ event.preventDefault();
199
+ openSubmenu(value, event.currentTarget, true);
200
+ }
201
+ }
202
+
203
+ function onSubmenuKeydown(event, item) {
204
+ if (!isSubmenuCloseKey(event)) return;
205
+ event.preventDefault();
206
+ closeSubmenu(valueOf(item));
207
+ }
208
+
209
+ function onSelect(event) {
210
+ if (isCheckable(event.item)) return;
211
+ openPath.value = '';
212
+ }
213
+
214
+ function onChange() {
215
+ // Nested DomMenu instances propagate change through menuContext.
216
+ }
217
+
218
+ function onAccessoryKeydown(event) {
219
+ if (event.key === 'Tab' || event.key === 'Escape') return;
220
+ event.stopPropagation();
221
+ }
222
+
223
+ function setSubmenuPanel(element) {
224
+ submenuPanel.value = element;
225
+ if (!element) return;
226
+ nextTick(placeSubmenu);
227
+ }
228
+
229
+ function focusableAttrs(item) {
230
+ const tag = itemTag(item);
231
+ return {
232
+ type: tag === 'button' ? 'button' : null,
233
+ href: tag === 'a' && !item?.disabled ? item?.href : null,
234
+ tabindex: tag === 'a' && item?.disabled ? -1 : null,
235
+ };
236
+ }
237
+
238
+ onBeforeUnmount(() => {
239
+ closeTimer.cancel();
240
+ parentPopover?.removeEventListener('toggle', parentPopoverToggleOff);
241
+ closeActiveSubmenu(false);
242
+ });
243
+ </script>
244
+
245
+ <template>
246
+ <dom-menu
247
+ ref="root"
248
+ :orientation="orientation"
249
+ class="block w-full"
250
+ :class="skin ? 'skin-popover rounded-2xl p-1' : ''"
251
+ >
252
+ <slot v-if="!hasItemPropRows" />
253
+ <template v-for="(item, index) in items" v-else :key="valueOf(item) || index">
254
+ <hr v-if="item && item.separator" class="my-1 border-t border-border" />
255
+ <div
256
+ v-else-if="isStaticItem(item)"
257
+ role="presentation"
258
+ class="rounded-xl px-3 py-2 text-sm text-muted-fg"
259
+ >
260
+ <slot :name="slotNameOf(item)" v-bind="slotProps(item, index)">
261
+ <slot name="item-label" v-bind="slotProps(item, index)">
262
+ <span class="block truncate text-xs font-medium text-muted-fg">{{ labelOf(item) }}</span>
263
+ <span v-if="item?.description" class="mt-0.5 block truncate text-sm font-medium text-canvas-fg">
264
+ {{ item.description }}
265
+ </span>
266
+ </slot>
267
+ </slot>
268
+ </div>
269
+ <div
270
+ v-else
271
+ class="relative"
272
+ @mouseenter="hasChildren(item) && openSubmenu(valueOf(item), $event.currentTarget.querySelector('[role=menuitem]'))"
273
+ @mouseleave="hasChildren(item) && maybeCloseSubmenu($event, valueOf(item))"
274
+ >
275
+ <div
276
+ v-if="hasCustomAccessory(item)"
277
+ class="flex w-full items-center gap-2 rounded-xl transition hover:bg-secondary focus-within:bg-secondary"
278
+ >
279
+ <component
280
+ :is="itemTag(item)"
281
+ v-bind="focusableAttrs(item)"
282
+ :role="roleOf(item)"
283
+ :data-value="valueOf(item)"
284
+ :aria-haspopup="hasChildren(item) ? 'menu' : null"
285
+ :aria-expanded="hasChildren(item) ? String(openPath === valueOf(item)) : null"
286
+ :aria-checked="item?.checked == null ? null : String(item.checked)"
287
+ :aria-disabled="item?.disabled ? 'true' : null"
288
+ :disabled="item?.disabled || null"
289
+ class="group/menu-item flex min-w-0 flex-1 items-center gap-3 rounded-xl py-2 pl-3 pr-1 text-left text-sm text-canvas-fg outline-none transition focus-visible:ring-2 focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[tone=danger]:text-destructive"
290
+ :data-tone="item?.tone || null"
291
+ @keydown="onItemKeydown($event, item)"
292
+ >
293
+ <slot :name="slotNameOf(item)" v-bind="slotProps(item, index)">
294
+ <span class="min-w-0">
295
+ <slot name="item" v-bind="slotProps(item, index)">{{ labelOf(item) }}</slot>
296
+ </span>
297
+ </slot>
298
+ </component>
299
+ <span data-dom-menu-accessory class="mr-2 shrink-0" @pointerdown.stop @click.stop @keydown="onAccessoryKeydown">
300
+ <slot :name="accessorySlotNameOf(item)" v-bind="slotProps(item, index)">
301
+ <slot name="item-accessory" v-bind="slotProps(item, index)" />
302
+ </slot>
303
+ </span>
304
+ </div>
305
+ <component
306
+ v-else
307
+ :is="itemTag(item)"
308
+ v-bind="focusableAttrs(item)"
309
+ :role="roleOf(item)"
310
+ :data-value="valueOf(item)"
311
+ :aria-haspopup="hasChildren(item) ? 'menu' : null"
312
+ :aria-expanded="hasChildren(item) ? String(openPath === valueOf(item)) : null"
313
+ :aria-checked="item?.checked == null ? null : String(item.checked)"
314
+ :aria-disabled="item?.disabled ? 'true' : null"
315
+ :disabled="item?.disabled || null"
316
+ class="group/menu-item flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm text-canvas-fg outline-none transition hover:bg-secondary focus:bg-secondary aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[tone=danger]:text-destructive"
317
+ :data-tone="item?.tone || null"
318
+ @keydown="onItemKeydown($event, item)"
319
+ >
320
+ <slot :name="slotNameOf(item)" v-bind="slotProps(item, index)">
321
+ <span class="min-w-0">
322
+ <slot name="item" v-bind="slotProps(item, index)">{{ labelOf(item) }}</slot>
323
+ </span>
324
+ <span v-if="hasAccessory(item)" class="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-fg">
325
+ <slot :name="accessorySlotNameOf(item)" v-bind="slotProps(item, index)">
326
+ <slot name="item-accessory" v-bind="slotProps(item, index)">
327
+ <span v-if="item?.shortcut">{{ item.shortcut }}</span>
328
+ <span v-if="hasChildren(item)" aria-hidden="true">›</span>
329
+ <span
330
+ v-else-if="hasCheckAccessory(item)"
331
+ aria-hidden="true"
332
+ class="transition"
333
+ :class="item?.checked ? 'opacity-100' : 'opacity-0'"
334
+ >✓</span>
335
+ </slot>
336
+ </slot>
337
+ </span>
338
+ </slot>
339
+ </component>
340
+ </div>
341
+ </template>
342
+ </dom-menu>
343
+
344
+ <div
345
+ v-if="openPath"
346
+ :ref="setSubmenuPanel"
347
+ data-dom-menu-submenu-panel
348
+ class="dom-popover-panel skin-popover absolute z-50 min-w-40 rounded-2xl p-1 outline-none"
349
+ @keydown="onSubmenuKeydown($event, { value: openPath })"
350
+ @mouseenter="cancelSubmenuClose"
351
+ @mouseleave="maybeCloseSubmenuPanel"
352
+ >
353
+ <DomMenu :items="activeChildItems()" :skin="false" @select="onSelect" @change="onChange">
354
+ <template v-for="(_, slotName) in $slots" #[slotName]="childSlotProps">
355
+ <slot :name="slotName" v-bind="childSlotProps" />
356
+ </template>
357
+ </DomMenu>
358
+ </div>
359
+ </template>
@@ -0,0 +1,233 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, ref, useSlots } from 'vue';
3
+ import DomMenu from './DomMenu.vue';
4
+ import { positionSubmenuPanel } from './menuPlacement.js';
5
+ import {
6
+ createSubmenuCloseTimer,
7
+ focusFirstMenuItem,
8
+ isLeavingSubmenu,
9
+ isSubmenuCloseKey,
10
+ isSubmenuOpenKey,
11
+ submenuOptions,
12
+ } from './submenu.js';
13
+
14
+ defineOptions({
15
+ name: 'DomMenuItem',
16
+ __doc: {
17
+ name: 'Menu item',
18
+ tag: '<MenuItem>',
19
+ description: 'Markup-driven menu row for DomMenu, including optional submenu content through the submenu slot.',
20
+ slots: [
21
+ { name: '(default)', description: 'Custom row markup.' },
22
+ { name: 'accessory', description: 'Right-side content rendered beside the menu item, useful for controls, shortcuts, and status.' },
23
+ { name: 'submenu', description: 'Nested <MenuItem> rows shown as a submenu.' },
24
+ ],
25
+ },
26
+ });
27
+
28
+ const props = defineProps({
29
+ value: {
30
+ type: [String, Number],
31
+ required: true,
32
+ },
33
+ as: {
34
+ type: String,
35
+ default: 'button',
36
+ },
37
+ href: {
38
+ type: String,
39
+ default: null,
40
+ },
41
+ type: {
42
+ type: String,
43
+ default: 'item',
44
+ validator: (value) => ['item', 'checkbox', 'radio'].includes(value),
45
+ },
46
+ checked: {
47
+ type: Boolean,
48
+ default: null,
49
+ },
50
+ disabled: {
51
+ type: Boolean,
52
+ default: false,
53
+ },
54
+ tone: {
55
+ type: String,
56
+ default: null,
57
+ },
58
+ });
59
+
60
+ const slots = useSlots();
61
+ const open = ref(false);
62
+ const trigger = ref(null);
63
+ const submenuPanel = ref(null);
64
+ const hasAccessory = computed(() => Boolean(slots.accessory));
65
+ const hasSubmenu = computed(() => Boolean(slots.submenu));
66
+ const closeTimer = createSubmenuCloseTimer((focusBack = false) => closeSubmenu(focusBack));
67
+
68
+ const tag = computed(() => props.href ? 'a' : props.as);
69
+ const role = computed(() => {
70
+ if (hasSubmenu.value) return 'menuitem';
71
+ if (props.type === 'checkbox') return 'menuitemcheckbox';
72
+ if (props.type === 'radio') return 'menuitemradio';
73
+ return 'menuitem';
74
+ });
75
+
76
+ const focusableAttrs = computed(() => ({
77
+ type: tag.value === 'button' ? 'button' : null,
78
+ href: tag.value === 'a' && !props.disabled ? props.href : null,
79
+ tabindex: tag.value === 'a' && props.disabled ? -1 : null,
80
+ }));
81
+
82
+ function placeSubmenu() {
83
+ if (!submenuPanel.value || !trigger.value) return;
84
+ positionSubmenuPanel(submenuPanel.value, trigger.value, submenuOptions());
85
+ }
86
+
87
+ function openSubmenu(focusFirst = false) {
88
+ if (!hasSubmenu.value || props.disabled) return;
89
+ cancelSubmenuClose();
90
+ open.value = true;
91
+ nextTick(() => {
92
+ placeSubmenu();
93
+ if (focusFirst) focusFirstMenuItem(submenuPanel.value);
94
+ });
95
+ }
96
+
97
+ function closeSubmenu(focusBack = false) {
98
+ closeTimer.cancel();
99
+ open.value = false;
100
+ if (focusBack) trigger.value?.focus();
101
+ }
102
+
103
+ function cancelSubmenuClose() {
104
+ closeTimer.cancel();
105
+ }
106
+
107
+ function scheduleCloseSubmenu(focusBack = false) {
108
+ closeTimer.schedule(focusBack);
109
+ }
110
+
111
+ function maybeCloseSubmenu(event) {
112
+ if (!isLeavingSubmenu(event, null, submenuPanel.value)) return;
113
+ scheduleCloseSubmenu(false);
114
+ }
115
+
116
+ function maybeCloseSubmenuPanel(event) {
117
+ if (!isLeavingSubmenu(event, trigger.value, null)) return;
118
+ scheduleCloseSubmenu(false);
119
+ }
120
+
121
+ function setSubmenuPanel(element) {
122
+ submenuPanel.value = element;
123
+ if (!element) return;
124
+ nextTick(placeSubmenu);
125
+ }
126
+
127
+ function onKeydown(event) {
128
+ if (!hasSubmenu.value) return;
129
+ if (isSubmenuOpenKey(event)) {
130
+ event.preventDefault();
131
+ event.stopPropagation();
132
+ openSubmenu(true);
133
+ }
134
+ }
135
+
136
+ function onClick() {
137
+ openSubmenu(true);
138
+ }
139
+
140
+ function onSubmenuKeydown(event) {
141
+ if (!isSubmenuCloseKey(event)) return;
142
+ event.preventDefault();
143
+ closeSubmenu(true);
144
+ }
145
+
146
+ function onNestedSelect(event) {
147
+ const role = event?.item?.getAttribute?.('role');
148
+ if (role === 'menuitemcheckbox' || role === 'menuitemradio') return;
149
+ closeSubmenu(false);
150
+ }
151
+
152
+ function onAccessoryKeydown(event) {
153
+ if (event.key === 'Tab' || event.key === 'Escape') return;
154
+ event.stopPropagation();
155
+ }
156
+
157
+ onBeforeUnmount(() => {
158
+ closeTimer.cancel();
159
+ closeSubmenu(false);
160
+ });
161
+ </script>
162
+
163
+ <template>
164
+ <div
165
+ class="relative"
166
+ @mouseenter="openSubmenu(false)"
167
+ @mouseleave="maybeCloseSubmenu"
168
+ >
169
+ <div
170
+ v-if="hasAccessory"
171
+ class="flex w-full items-center gap-2 rounded-xl transition hover:bg-secondary focus-within:bg-secondary"
172
+ >
173
+ <component
174
+ :is="tag"
175
+ ref="trigger"
176
+ v-bind="focusableAttrs"
177
+ :role="role"
178
+ :data-value="String(value)"
179
+ :data-custom-submenu="hasSubmenu ? 'true' : null"
180
+ :aria-haspopup="hasSubmenu ? 'menu' : null"
181
+ :aria-expanded="hasSubmenu ? String(open) : null"
182
+ :aria-checked="checked == null ? null : String(checked)"
183
+ :aria-disabled="disabled ? 'true' : null"
184
+ :disabled="disabled || null"
185
+ class="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-xl py-2 pl-3 pr-1 text-left text-sm text-canvas-fg outline-none transition focus-visible:ring-2 focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[tone=danger]:text-destructive"
186
+ :data-tone="tone"
187
+ @click="onClick"
188
+ @keydown="onKeydown"
189
+ >
190
+ <slot />
191
+ <span v-if="hasSubmenu" aria-hidden="true" class="text-xs text-muted-fg">›</span>
192
+ </component>
193
+ <span data-dom-menu-accessory class="mr-2 shrink-0" @pointerdown.stop @click.stop @keydown="onAccessoryKeydown">
194
+ <slot name="accessory" />
195
+ </span>
196
+ </div>
197
+ <component
198
+ v-else
199
+ :is="tag"
200
+ ref="trigger"
201
+ v-bind="focusableAttrs"
202
+ :role="role"
203
+ :data-value="String(value)"
204
+ :data-custom-submenu="hasSubmenu ? 'true' : null"
205
+ :aria-haspopup="hasSubmenu ? 'menu' : null"
206
+ :aria-expanded="hasSubmenu ? String(open) : null"
207
+ :aria-checked="checked == null ? null : String(checked)"
208
+ :aria-disabled="disabled ? 'true' : null"
209
+ :disabled="disabled || null"
210
+ class="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm text-canvas-fg outline-none transition hover:bg-secondary focus:bg-secondary aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[tone=danger]:text-destructive"
211
+ :data-tone="tone"
212
+ @click="onClick"
213
+ @keydown="onKeydown"
214
+ >
215
+ <slot />
216
+ <span v-if="hasSubmenu" aria-hidden="true" class="text-xs text-muted-fg">›</span>
217
+ </component>
218
+ </div>
219
+
220
+ <div
221
+ v-if="open"
222
+ :ref="setSubmenuPanel"
223
+ data-dom-menu-submenu-panel
224
+ class="dom-popover-panel skin-popover absolute z-50 min-w-48 rounded-2xl p-1 outline-none"
225
+ @keydown="onSubmenuKeydown"
226
+ @mouseenter="cancelSubmenuClose"
227
+ @mouseleave="maybeCloseSubmenuPanel"
228
+ >
229
+ <DomMenu :skin="false" @select="onNestedSelect" @change="onNestedSelect">
230
+ <slot name="submenu" />
231
+ </DomMenu>
232
+ </div>
233
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup>
2
+ defineOptions({
3
+ name: 'DomMenuLabel',
4
+ __doc: {
5
+ name: 'Menu label',
6
+ tag: '<MenuLabel>',
7
+ description: 'A non-selectable menu row for labels, account summaries, and other static custom content.',
8
+ slots: [
9
+ { name: '(default)', description: 'Static content shown inside the menu.' },
10
+ ],
11
+ },
12
+ });
13
+ </script>
14
+
15
+ <template>
16
+ <div role="presentation" class="rounded-xl px-3 py-2 text-sm text-muted-fg">
17
+ <slot />
18
+ </div>
19
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup>
2
+ defineOptions({
3
+ name: 'DomMenuSeparator',
4
+ __doc: {
5
+ name: 'Menu separator',
6
+ tag: '<MenuSeparator>',
7
+ description: 'A non-focusable divider for separating groups inside DomMenu.',
8
+ },
9
+ });
10
+ </script>
11
+
12
+ <template>
13
+ <hr role="separator" class="my-1 border-t border-border" />
14
+ </template>
@@ -0,0 +1,11 @@
1
+ import { inject, provide } from 'vue';
2
+
3
+ export const menuContextKey = Symbol('DomMenuContext');
4
+
5
+ export function useParentMenuContext() {
6
+ return inject(menuContextKey, null);
7
+ }
8
+
9
+ export function provideMenuContext(context) {
10
+ provide(menuContextKey, context);
11
+ }
@@ -0,0 +1,26 @@
1
+ import { computeFloatingPosition } from '../../lib/headless/floating.js';
2
+
3
+ export function positionSubmenuPanel(panel, trigger, options = {}) {
4
+ const result = computeFloatingPosition(trigger, panel, options);
5
+ if (!result) return null;
6
+
7
+ const offsetParent = panel.offsetParent instanceof Element ? panel.offsetParent : null;
8
+ const offsetRect = offsetParent?.getBoundingClientRect?.() || { left: 0, top: 0 };
9
+
10
+ panel.style.position = 'absolute';
11
+ panel.style.top = `${Math.round(result.y - offsetRect.top)}px`;
12
+ panel.style.left = `${Math.round(result.x - offsetRect.left)}px`;
13
+ panel.style.right = 'auto';
14
+ panel.style.bottom = 'auto';
15
+ panel.style.margin = '0';
16
+ panel.style.transform = '';
17
+ panel.style.setProperty('--dom-floating-available-width', `${Math.round(result.availableWidth)}px`);
18
+ panel.style.setProperty('--dom-floating-available-height', `${Math.round(result.availableHeight)}px`);
19
+ panel.dataset.placement = result.placement;
20
+ panel.dataset.floatingMode = result.mode;
21
+ panel.dataset.side = result.side;
22
+ panel.dataset.align = result.align;
23
+ panel.toggleAttribute('data-flipped', result.flipped);
24
+
25
+ return result;
26
+ }