@getdom/studio 0.1.1 → 0.2.1

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 (99) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +10 -4
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/theme.css +13 -0
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, onMounted, ref, useId, watch } from 'vue';
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, useSlots, watch } from 'vue';
3
3
 
4
4
  defineOptions({
5
5
  __doc: {
@@ -9,28 +9,30 @@ defineOptions({
9
9
  slots: [
10
10
  { name: 'trigger', description: 'Optional. Element that opens the dialog when clicked — omit when using v-model, a template ref, or commandfor on an external button.' },
11
11
  { name: '(default)', description: 'Dialog body — rendered inside the card below the title and description.' },
12
- { name: 'header', payload: '{ title, description, titleId, descriptionId }', description: 'Replace the default title and description block. Apply the provided IDs to custom heading and description elements to preserve the dialog accessibility relationships.' },
12
+ { name: 'header', payload: '{ title, description, titleId, descriptionId, companionOpen, companionMode, companionTriggerProps, toggleCompanion }', description: 'Replace the default title and description block. Apply the provided IDs to custom heading and description elements to preserve the dialog accessibility relationships. Companion helpers wire an in-dialog toggle when the companion slot is present.' },
13
13
  { name: 'footer', description: 'Action buttons. Add `data-close` on a control to dismiss the dialog.' },
14
+ { name: 'companion', payload: '{ open, mode, closeCompanion, toggleCompanion }', description: 'Optional contextual surface shown beside the dialog on wide screens and over it below companionBreakpoint.' },
14
15
  ],
15
16
  events: [
16
17
  { name: 'update:modelValue', payload: '(open: boolean)', description: 'Emitted when the dialog opens or closes.' },
17
18
  { name: 'before-open', description: 'Emitted immediately before the dialog opens; prepare draft data or item snapshots here.' },
19
+ { name: 'update:companionOpen', payload: '(open: boolean)', description: 'Emitted when the companion surface opens or closes.' },
18
20
  { name: 'close', description: 'Emitted when the dialog is dismissed.' },
19
21
  { name: 'close-complete', description: 'Emitted after the close transition completes; clear retained dialog data here.' },
20
22
  ],
21
23
  keyboard: [
22
- { keys: 'Esc', action: 'Closes the topmost open dialog unless `static` is set.' },
24
+ { keys: 'Esc', action: 'Closes an open companion first, then closes the dialog unless `static` is set.' },
23
25
  { keys: 'Tab / Shift+Tab', action: 'Focus stays within the dialog while open (native top-layer).' },
24
26
  { keys: 'Click backdrop', action: 'Dismisses the dialog unless `static` is set.' },
25
27
  ],
26
28
  },
27
29
  });
28
30
 
29
- const dialogWidthClasses = {
30
- sm: 'w-[min(calc(100vw-2rem),24rem)]',
31
- md: 'w-[min(calc(100vw-2rem),28rem)]',
32
- lg: 'w-[min(calc(100vw-2rem),44rem)]',
33
- xl: 'w-[min(calc(100vw-2rem),56rem)]',
31
+ const dialogWidthValues = {
32
+ sm: '24rem',
33
+ md: '28rem',
34
+ lg: '44rem',
35
+ xl: '56rem',
34
36
  };
35
37
 
36
38
  const props = defineProps({
@@ -83,24 +85,62 @@ const props = defineProps({
83
85
  default: true,
84
86
  _edit: { description: 'Show the footer region and default Close action when no footer slot is provided.' },
85
87
  },
88
+ companionOpen: {
89
+ type: Boolean,
90
+ default: false,
91
+ _edit: { description: 'Whether the optional companion slot is visible.' },
92
+ },
93
+ companionWidth: {
94
+ type: [String, Number],
95
+ default: '24rem',
96
+ _edit: { description: 'Desktop width of the companion surface.' },
97
+ },
98
+ companionBreakpoint: {
99
+ type: [String, Number],
100
+ default: '56.25rem',
101
+ _edit: { description: 'Viewport width at which the companion switches from a side-by-side surface to a covering dialog.' },
102
+ },
103
+ companionLabel: {
104
+ type: String,
105
+ default: 'Companion panel',
106
+ _edit: { description: 'Accessible name for the companion surface.' },
107
+ },
86
108
  });
87
- const emit = defineEmits(['update:modelValue', 'before-open', 'close', 'close-complete']);
109
+ const emit = defineEmits(['update:modelValue', 'update:companionOpen', 'before-open', 'close', 'close-complete']);
88
110
 
89
111
  const root = ref(null);
112
+ const mainPanel = ref(null);
113
+ const companionPanel = ref(null);
114
+ const slots = useSlots();
90
115
  const uniqueId = useId();
116
+ const companionId = `dom-dialog-${uniqueId}-companion`;
91
117
  const titleId = computed(() => (props.title ? `dom-dialog-${uniqueId}-title` : ''));
92
118
  const descriptionId = computed(() => (props.description ? `dom-dialog-${uniqueId}-description` : ''));
93
- const dialogWidthClass = computed(() => dialogWidthClasses[props.size] || dialogWidthClasses.md);
119
+ const hasCompanion = computed(() => Boolean(slots.companion));
120
+ const companionActive = computed(() => hasCompanion.value && props.companionOpen);
121
+ const companionOverlay = ref(false);
122
+ const companionMode = computed(() => companionOverlay.value ? 'overlay' : 'beside');
123
+ const companionTriggerProps = computed(() => ({
124
+ 'aria-controls': companionId,
125
+ 'aria-expanded': String(companionActive.value),
126
+ 'aria-haspopup': companionOverlay.value ? 'dialog' : undefined,
127
+ }));
128
+ const dialogShellStyle = computed(() => ({
129
+ '--dom-dialog-panel-width': normalizeDialogDimension(props.width)
130
+ || dialogWidthValues[props.size]
131
+ || dialogWidthValues.md,
132
+ '--dom-dialog-companion-width': normalizeDialogDimension(props.companionWidth) || '24rem',
133
+ }));
94
134
  const dialogPanelStyle = computed(() => {
95
- const width = normalizeDialogDimension(props.width);
96
135
  const height = normalizeDialogDimension(props.height);
97
136
 
98
137
  return {
99
- width: width || undefined,
100
138
  height: height || undefined,
101
139
  maxHeight: height || undefined,
102
140
  };
103
141
  });
142
+ let companionMediaQuery = null;
143
+ let companionReturnFocus = null;
104
144
 
105
145
  /**
106
146
  * Normalize a CSS dimension prop into an inline style value.
@@ -114,6 +154,105 @@ function normalizeDialogDimension(value) {
114
154
  return '';
115
155
  }
116
156
 
157
+ /**
158
+ * Build the viewport query used to switch the companion into overlay mode.
159
+ *
160
+ * @returns {string} A max-width media query.
161
+ */
162
+ function companionMediaQueryText() {
163
+ const breakpoint = normalizeDialogDimension(props.companionBreakpoint) || '56.25rem';
164
+ return `(max-width: ${breakpoint})`;
165
+ }
166
+
167
+ /**
168
+ * Synchronize the responsive companion mode from a MediaQueryList.
169
+ *
170
+ * @param {MediaQueryList|MediaQueryListEvent} query Current media-query state.
171
+ * @returns {void}
172
+ */
173
+ function updateCompanionMode(query) {
174
+ companionOverlay.value = Boolean(query?.matches);
175
+ }
176
+
177
+ /**
178
+ * Replace the active companion breakpoint listener.
179
+ *
180
+ * @returns {void}
181
+ */
182
+ function setupCompanionMediaQuery() {
183
+ teardownCompanionMediaQuery();
184
+ if (typeof window === 'undefined') return;
185
+ companionMediaQuery = window.matchMedia(companionMediaQueryText());
186
+ updateCompanionMode(companionMediaQuery);
187
+ companionMediaQuery.addEventListener('change', updateCompanionMode);
188
+ }
189
+
190
+ /**
191
+ * Remove the active companion breakpoint listener.
192
+ *
193
+ * @returns {void}
194
+ */
195
+ function teardownCompanionMediaQuery() {
196
+ companionMediaQuery?.removeEventListener('change', updateCompanionMode);
197
+ companionMediaQuery = null;
198
+ }
199
+
200
+ /**
201
+ * Focus the preferred control inside the companion surface.
202
+ *
203
+ * @returns {void}
204
+ */
205
+ function focusCompanion() {
206
+ const panel = companionPanel.value;
207
+ if (!panel) return;
208
+ const preferred = panel.querySelector('[autofocus], [data-companion-focus], textarea, input, select, button, [href], [tabindex]:not([tabindex="-1"])');
209
+ (preferred || panel).focus({ preventScroll: true });
210
+ }
211
+
212
+ /**
213
+ * Restore focus to the element that opened the companion when possible.
214
+ *
215
+ * @returns {void}
216
+ */
217
+ function restoreCompanionFocus() {
218
+ const target = companionReturnFocus;
219
+ companionReturnFocus = null;
220
+ if (target instanceof HTMLElement && target.isConnected && !target.closest('[inert]')) {
221
+ target.focus({ preventScroll: true });
222
+ return;
223
+ }
224
+ mainPanel.value?.focus({ preventScroll: true });
225
+ }
226
+
227
+ /**
228
+ * Emit a controlled companion visibility update.
229
+ *
230
+ * @param {boolean} open Desired companion visibility.
231
+ * @returns {void}
232
+ */
233
+ function setCompanionOpen(open) {
234
+ if (!hasCompanion.value) return;
235
+ emit('update:companionOpen', Boolean(open));
236
+ }
237
+
238
+ /**
239
+ * Close the companion without closing the parent dialog.
240
+ *
241
+ * @returns {void}
242
+ */
243
+ function closeCompanion() {
244
+ setCompanionOpen(false);
245
+ }
246
+
247
+ /**
248
+ * Toggle the companion surface.
249
+ *
250
+ * @returns {void}
251
+ */
252
+ function toggleCompanion() {
253
+ setCompanionOpen(!companionActive.value);
254
+ }
255
+
117
256
  /**
118
257
  * Synchronize the headless dialog host with the controlled Vue model.
119
258
  *
@@ -156,6 +295,7 @@ function onHeadlessOpen(event) {
156
295
  */
157
296
  function onHeadlessClose(event) {
158
297
  if (event.target !== root.value) return;
298
+ if (companionActive.value) setCompanionOpen(false);
159
299
  emit('update:modelValue', false);
160
300
  emit('close');
161
301
  }
@@ -171,21 +311,53 @@ function onHeadlessCloseComplete(event) {
171
311
  emit('close-complete');
172
312
  }
173
313
 
314
+ /**
315
+ * Let the companion consume Escape before the parent dialog closes.
316
+ *
317
+ * @param {CustomEvent<{ reason: string }>} event Cancellable headless close request.
318
+ * @returns {void}
319
+ */
320
+ function onHeadlessRequestClose(event) {
321
+ if (event.target !== root.value) return;
322
+ if (event.detail?.reason !== 'escape' || !companionActive.value) return;
323
+ event.preventDefault();
324
+ closeCompanion();
325
+ }
326
+
174
327
  onMounted(async () => {
175
328
  await import('../../lib/headless/dialog.js');
329
+ setupCompanionMediaQuery();
176
330
  root.value?.addEventListener('dom:before-open', onHeadlessBeforeOpen);
177
331
  root.value?.addEventListener('dom:open', onHeadlessOpen);
332
+ root.value?.addEventListener('dom:request-close', onHeadlessRequestClose);
178
333
  root.value?.addEventListener('dom:close', onHeadlessClose);
179
334
  root.value?.addEventListener('dom:close-complete', onHeadlessCloseComplete);
180
335
  if (props.modelValue) setOpen(true);
181
336
  });
182
337
 
183
338
  watch(() => props.modelValue, setOpen);
339
+ watch(() => props.companionBreakpoint, setupCompanionMediaQuery);
340
+ watch(() => props.companionOpen, async (open, previousOpen) => {
341
+ if (!hasCompanion.value || open === previousOpen) return;
342
+ if (open) {
343
+ companionReturnFocus = document.activeElement;
344
+ await nextTick();
345
+ focusCompanion();
346
+ } else {
347
+ await nextTick();
348
+ restoreCompanionFocus();
349
+ }
350
+ });
351
+
352
+ onBeforeUnmount(teardownCompanionMediaQuery);
184
353
 
185
354
  defineExpose({
186
355
  open: () => setOpen(true),
187
356
  close: () => setOpen(false),
188
357
  toggle: () => root.value?.toggle(),
358
+ openCompanion: () => setCompanionOpen(true),
359
+ closeCompanion,
360
+ toggleCompanion,
189
361
  get element() { return root.value; },
190
362
  });
191
363
  </script>
@@ -209,40 +381,76 @@ defineExpose({
209
381
  <slot name="trigger" />
210
382
  </span>
211
383
  <div
212
- class="skin-overlay flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden rounded-2xl p-6 outline-none"
213
- :class="dialogWidthClass"
214
- :style="dialogPanelStyle"
384
+ class="dom-dialog-shell"
385
+ :class="{ 'dom-dialog-shell--open': companionActive }"
386
+ :data-companion-mode="companionMode"
387
+ :style="dialogShellStyle"
215
388
  >
216
- <slot
217
- name="header"
218
- :title="title"
219
- :description="description"
220
- :title-id="titleId"
221
- :description-id="descriptionId"
389
+ <div
390
+ ref="mainPanel"
391
+ class="skin-overlay flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl p-6 outline-none"
392
+ :style="dialogPanelStyle"
393
+ :inert="companionOverlay && companionActive"
394
+ :aria-hidden="companionOverlay && companionActive ? 'true' : undefined"
395
+ tabindex="-1"
222
396
  >
223
- <div v-if="title || description" class="shrink-0 space-y-2">
224
- <h2 v-if="title" :id="titleId" class="text-lg font-semibold tracking-tight">{{ title }}</h2>
225
- <p v-if="description" :id="descriptionId" class="text-sm text-muted-fg">{{ description }}</p>
226
- </div>
227
- </slot>
228
- <div class="-mx-1 -mb-1 min-h-0 overflow-y-auto px-1 pb-1" :class="($slots.header || title || description) ? 'mt-3 pt-1' : '-mt-1 pt-1'">
229
- <slot />
230
- </div>
231
- <div v-if="footer || $slots.footer" class="mt-6 flex shrink-0 items-center justify-end gap-2">
232
- <slot name="footer">
233
- <button
234
- data-close
235
- type="button"
236
- class="skin-action inline-flex h-9 items-center rounded-full px-4 text-sm font-medium hover:opacity-90"
237
- >Close</button>
397
+ <slot
398
+ name="header"
399
+ :title="title"
400
+ :description="description"
401
+ :title-id="titleId"
402
+ :description-id="descriptionId"
403
+ :companion-open="companionActive"
404
+ :companion-mode="companionMode"
405
+ :companion-trigger-props="companionTriggerProps"
406
+ :toggle-companion="toggleCompanion"
407
+ >
408
+ <div v-if="title || description" class="shrink-0 space-y-2">
409
+ <h2 v-if="title" :id="titleId" class="text-lg font-semibold tracking-tight">{{ title }}</h2>
410
+ <p v-if="description" :id="descriptionId" class="text-sm text-muted-fg">{{ description }}</p>
411
+ </div>
238
412
  </slot>
413
+ <div class="-mx-1 -mb-1 min-h-0 flex-1 overflow-y-auto px-1 pb-1" :class="($slots.header || title || description) ? 'mt-3 pt-1' : '-mt-1 pt-1'">
414
+ <slot />
415
+ </div>
416
+ <div v-if="footer || $slots.footer" class="mt-6 flex shrink-0 items-center justify-end gap-2">
417
+ <slot name="footer">
418
+ <button
419
+ data-close
420
+ type="button"
421
+ class="skin-action inline-flex h-9 items-center rounded-full px-4 text-sm font-medium hover:opacity-90"
422
+ >Close</button>
423
+ </slot>
424
+ </div>
239
425
  </div>
426
+ <Transition name="dom-dialog-companion">
427
+ <div v-if="hasCompanion" v-show="companionActive" class="dom-dialog-companion-track">
428
+ <aside
429
+ :id="companionId"
430
+ ref="companionPanel"
431
+ class="dom-dialog-companion skin-overlay"
432
+ :role="companionOverlay ? 'dialog' : 'region'"
433
+ :aria-modal="companionOverlay ? 'true' : undefined"
434
+ :aria-label="companionLabel"
435
+ tabindex="-1"
436
+ >
437
+ <slot
438
+ name="companion"
439
+ :open="companionActive"
440
+ :mode="companionMode"
441
+ :close-companion="closeCompanion"
442
+ :toggle-companion="toggleCompanion"
443
+ />
444
+ </aside>
445
+ </div>
446
+ </Transition>
240
447
  </div>
241
448
  </dom-dialog>
242
449
  </template>
243
450
 
244
451
  <style scoped>
245
452
  .dom-dialog::part(dialog) {
453
+ width: 100vw;
246
454
  opacity: 0;
247
455
  transform: translateY(10px) scale(0.96);
248
456
  transform-origin: center;
@@ -255,6 +463,100 @@ defineExpose({
255
463
  display 240ms allow-discrete;
256
464
  }
257
465
 
466
+ .dom-dialog-shell {
467
+ position: relative;
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 0;
471
+ width: 100%;
472
+ max-width: var(--dom-dialog-panel-width);
473
+ margin-inline: auto;
474
+ transition: max-width 280ms cubic-bezier(0.16, 1, 0.3, 1);
475
+ }
476
+
477
+ .dom-dialog-shell--open[data-companion-mode="beside"] {
478
+ max-width: calc(var(--dom-dialog-panel-width) + var(--dom-dialog-companion-width) + 0.875rem);
479
+ }
480
+
481
+ .dom-dialog-companion-track {
482
+ display: flex;
483
+ flex: 0 0 calc(var(--dom-dialog-companion-width) + 0.875rem);
484
+ min-width: 0;
485
+ overflow: clip;
486
+ }
487
+
488
+ .dom-dialog-companion {
489
+ display: flex;
490
+ flex: 0 0 var(--dom-dialog-companion-width);
491
+ width: var(--dom-dialog-companion-width);
492
+ min-height: 20rem;
493
+ max-height: calc(100dvh - 2rem);
494
+ margin-left: 0.875rem;
495
+ overflow: hidden;
496
+ border-radius: 1rem;
497
+ outline: none;
498
+ }
499
+
500
+ .dom-dialog-companion-enter-active,
501
+ .dom-dialog-companion-leave-active {
502
+ transition: flex-basis 280ms cubic-bezier(0.16, 1, 0.3, 1);
503
+ }
504
+
505
+ .dom-dialog-companion-enter-active .dom-dialog-companion,
506
+ .dom-dialog-companion-leave-active .dom-dialog-companion {
507
+ transition:
508
+ opacity 180ms ease,
509
+ transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
510
+ }
511
+
512
+ .dom-dialog-companion-enter-from,
513
+ .dom-dialog-companion-leave-to {
514
+ flex-basis: 0;
515
+ }
516
+
517
+ .dom-dialog-companion-enter-from .dom-dialog-companion,
518
+ .dom-dialog-companion-leave-to .dom-dialog-companion {
519
+ opacity: 0;
520
+ transform: translateX(2rem);
521
+ }
522
+
523
+ .dom-dialog-shell--open[data-companion-mode="overlay"] {
524
+ gap: 0;
525
+ max-width: var(--dom-dialog-panel-width);
526
+ }
527
+
528
+ [data-companion-mode="overlay"] .dom-dialog-companion-track {
529
+ position: absolute;
530
+ inset: 0;
531
+ z-index: 1;
532
+ display: flex;
533
+ overflow: hidden;
534
+ }
535
+
536
+ [data-companion-mode="overlay"] .dom-dialog-companion {
537
+ flex-basis: 100%;
538
+ width: auto;
539
+ min-height: 0;
540
+ max-height: none;
541
+ margin-left: 0;
542
+ }
543
+
544
+ [data-companion-mode="overlay"] .dom-dialog-companion-enter-from .dom-dialog-companion,
545
+ [data-companion-mode="overlay"] .dom-dialog-companion-leave-to .dom-dialog-companion {
546
+ opacity: 1;
547
+ transform: translateX(100%);
548
+ }
549
+
550
+ @media (prefers-reduced-motion: reduce) {
551
+ .dom-dialog-shell,
552
+ .dom-dialog-companion-enter-active,
553
+ .dom-dialog-companion-leave-active,
554
+ .dom-dialog-companion-enter-active .dom-dialog-companion,
555
+ .dom-dialog-companion-leave-active .dom-dialog-companion {
556
+ transition-duration: 0s;
557
+ }
558
+ }
559
+
258
560
  .dom-dialog[open]::part(dialog) {
259
561
  opacity: 1;
260
562
  transform: none;
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, useAttrs } from 'vue';
3
+ import { buttonInteraction } from '../button/buttonStyles.js';
3
4
 
4
5
  defineOptions({
5
6
  inheritAttrs: false,
@@ -55,6 +56,11 @@ const props = defineProps({
55
56
  default: false,
56
57
  _edit: { description: 'Show active/selected state.' },
57
58
  },
59
+ pressed: {
60
+ type: Boolean,
61
+ default: false,
62
+ _edit: { description: 'Show native press feedback for an external activation, such as a keyboard shortcut. Does not represent toggle selection.' },
63
+ },
58
64
  loading: {
59
65
  type: Boolean,
60
66
  default: false,
@@ -67,7 +73,6 @@ const props = defineProps({
67
73
  },
68
74
  });
69
75
  const attrs = useAttrs();
70
- const interaction = 'select-none active:bg-[linear-gradient(rgb(0_0_0/6%)_0_0)] active:scale-[0.97] active:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] motion-reduce:active:scale-100';
71
76
 
72
77
  const variants = {
73
78
  ghost: 'text-muted-fg hover:bg-secondary hover:text-canvas-fg',
@@ -98,7 +103,7 @@ const iconSizes = {
98
103
  };
99
104
 
100
105
  const classes = computed(() => [
101
- interaction,
106
+ buttonInteraction,
102
107
  'inline-grid shrink-0 place-items-center rounded-full outline-none transition',
103
108
  'focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas',
104
109
  props.active
@@ -137,6 +142,7 @@ const iconComponent = computed(() => (typeof props.icon === 'string' ? null : pr
137
142
  :aria-pressed="active ? 'true' : undefined"
138
143
  :aria-disabled="!isNativeButton && disabledState ? 'true' : undefined"
139
144
  :data-active="active ? 'true' : null"
145
+ :data-pressed="pressed ? '' : undefined"
140
146
  :disabled="isNativeButton && disabledState ? true : undefined"
141
147
  :type="isNativeButton ? 'button' : undefined"
142
148
  >
@@ -0,0 +1,111 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { RouterLink } from 'vue-router';
4
+ import DomAppStack from '../../mobile/stack/DomAppStack.vue';
5
+ import DomAppStackScreen from '../../mobile/stack/AppStackScreen.vue';
6
+ import DomAppListItem from '../../mobile/list-item/DomAppListItem.vue';
7
+ import { buildMenuScreens, hasMenuPage } from './menuTree.js';
8
+
9
+ defineOptions({
10
+ __doc: {
11
+ name: 'Menu stack',
12
+ tag: '<DomMenuStack>',
13
+ description: 'Nested navigation that slides inside its container on mobile and desktop. Reuses App stack transitions, retained page state, scrolling, and focus. Each item has a unique id and label, plus children, a component and props, or a named slot for richer pages. Leaf items accept to or href, or emit select for application actions.',
14
+ slots: [
15
+ { name: 'root', payload: '{ screen, stack }', description: 'Content above the root menu rows.' },
16
+ { name: '(item.slot)', payload: '{ screen, stack }', description: 'Page content above child rows. Set an item’s slot to this slot name; components receive screen and stack props too.' },
17
+ ],
18
+ events: [
19
+ { name: 'select', payload: '(item)', description: 'A leaf was selected. Close the containing popover here if desired.' },
20
+ { name: 'update:path', payload: '(path: string[])', description: 'Two-way stack path, starting with root.' },
21
+ { name: 'navigate', payload: '({ path, previousPath, direction, screen, reason })', description: 'A contained page opened or returned.' },
22
+ ],
23
+ keyboard: [
24
+ { keys: 'Tab / Shift+Tab', action: 'Move between visible links, rows, Back, and custom page controls.' },
25
+ { keys: 'Enter / Space', action: 'Activate a focused button. Enter follows links.' },
26
+ ],
27
+ playground: { initial: { title: 'Workspace', items: [
28
+ { id: 'account', label: 'Account', children: [{ id: 'preferences', label: 'Preferences', children: [{ id: 'language', label: 'English (UK)' }] }] },
29
+ { id: 'help', label: 'Help' },
30
+ ] } },
31
+ },
32
+ });
33
+
34
+ const props = defineProps({
35
+ items: { type: Array, default: () => [], _edit: { description: 'Nested items: unique id, label, description, meta, disabled, children, component, props, slot, to, or href. Page items open their children; use a leaf for a route or action.' } },
36
+ title: { type: String, default: 'Menu', _edit: { description: 'Root page heading.' } },
37
+ height: { type: String, default: 'min(24rem, calc(100dvh - 6rem))', _edit: { description: 'Bounded container height. Use 100% in an explicitly sized parent.' } },
38
+ path: { type: Array, default: null, _edit: { description: 'Controlled path of page ids starting with root. Bind with v-model:path.' } },
39
+ animate: { type: Boolean, default: true, _edit: { description: 'Enable sliding transitions; reduced-motion preferences are respected.' } },
40
+ });
41
+ const emit = defineEmits(['select', 'update:path', 'navigate']);
42
+ const screens = computed(() => buildMenuScreens(props.items, props.title));
43
+
44
+ /**
45
+ * Opens a contained page or reports a leaf selection to the application.
46
+ * @param {object} item Menu item activated by pointer or keyboard.
47
+ * @param {object} stack Shared App stack navigation helpers.
48
+ */
49
+ function activate(item, stack) {
50
+ if (item.disabled) return;
51
+ if (hasMenuPage(item)) stack.push(item.id);
52
+ else emit('select', item);
53
+ }
54
+
55
+ /** Returns a native button for pages/actions or the appropriate link component. */
56
+ function rowElement(item) {
57
+ if (item.disabled || hasMenuPage(item)) return 'button';
58
+ return item.to ? RouterLink : item.href ? 'a' : 'button';
59
+ }
60
+ /**
61
+ * Supplies only attributes owned by the chosen row element. In particular, an
62
+ * undefined href must not fall through to RouterLink and erase its resolved URL.
63
+ */
64
+ function rowAttributes(item) {
65
+ if (item.disabled || hasMenuPage(item)) return { type: 'button', disabled: item.disabled || undefined };
66
+ if (item.to) return { to: item.to };
67
+ if (item.href) return { href: item.href };
68
+ return { type: 'button' };
69
+ }
70
+
71
+ </script>
72
+
73
+ <template>
74
+ <DomAppStack
75
+ data-dom-menu-stack
76
+ class="rounded-lg border border-border [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]"
77
+ root="root"
78
+ layout="mobile"
79
+ :height="height"
80
+ :path="path"
81
+ :animate="animate"
82
+ mobile-panel-class="p-2!"
83
+ @update:path="emit('update:path', $event)"
84
+ @navigate="emit('navigate', $event)"
85
+ >
86
+ <DomAppStackScreen v-for="screen in screens" :key="screen.id" :id="screen.id" :title="screen.title" :parent="screen.parent">
87
+ <template #default="stack">
88
+ <slot v-if="screen.slot" :name="screen.slot" :screen="screen" :stack="stack">
89
+ <component v-if="screen.component" :is="screen.component" v-bind="screen.props" :screen="screen" :stack="stack" />
90
+ </slot>
91
+ <component v-else-if="screen.component" :is="screen.component" v-bind="screen.props" :screen="screen" :stack="stack" />
92
+ <nav v-if="screen.items.length" :aria-label="screen.title">
93
+ <ul class="space-y-1">
94
+ <li v-for="item in screen.items" :key="item.id">
95
+ <component
96
+ :is="rowElement(item)"
97
+ v-bind="rowAttributes(item)"
98
+ :aria-label="item.label || item.title || item.id"
99
+ class="block w-full overflow-hidden rounded-lg text-left hover:bg-secondary focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50"
100
+ @click="activate(item, stack)"
101
+ >
102
+ <DomAppListItem as="div" class="bg-transparent!" :label="item.label || item.title || item.id" :description="item.description" :meta="item.meta" :chevron="hasMenuPage(item)" />
103
+ </component>
104
+ </li>
105
+ </ul>
106
+ </nav>
107
+ <p v-else-if="!screen.component && (!screen.slot || screen.id === 'root') && !$slots[screen.slot]" class="p-4 text-sm text-muted-fg">No menu items.</p>
108
+ </template>
109
+ </DomAppStackScreen>
110
+ </DomAppStack>
111
+ </template>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Reports whether an item opens a contained page instead of selecting a leaf.
3
+ * @param {object} item Application-owned menu item.
4
+ * @returns {boolean} Whether the item supplies child navigation or page content.
5
+ */
6
+ export function hasMenuPage(item) {
7
+ return Array.isArray(item.children) || !!item.component || !!item.slot;
8
+ }
9
+
10
+ /**
11
+ * Flattens nested menu pages for DomAppStack without mutating application data.
12
+ * Every item needs a unique, non-empty string id; "root" is reserved.
13
+ * @param {Array<object>} items Nested links, actions, and pages.
14
+ * @param {string} title Root page title.
15
+ * @returns {Array<object>} Stack screen registrations with original child items.
16
+ */
17
+ export function buildMenuScreens(items, title) {
18
+ const screens = [{ id: 'root', title, items, slot: 'root' }];
19
+ const ids = new Set(['root']);
20
+ /** Validates each item once and registers its page under its parent. */
21
+ function visit(children, parent) {
22
+ for (const item of children) {
23
+ if (!item || typeof item.id !== 'string' || !item.id.trim() || ids.has(item.id)) {
24
+ throw new Error('DomMenuStack items require unique non-empty string ids; "root" is reserved.');
25
+ }
26
+ ids.add(item.id);
27
+ if (!hasMenuPage(item)) continue;
28
+ const nested = Array.isArray(item.children) ? item.children : [];
29
+ screens.push({ ...item, title: item.title || item.label || item.id, parent, items: nested });
30
+ visit(nested, item.id);
31
+ }
32
+ }
33
+ visit(items, 'root');
34
+ return screens;
35
+ }