@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
@@ -8,10 +8,16 @@ export { default as DomDialog } from '../../components/dialog/DomDialog.vue';
8
8
  export { default as DomDialogStack } from '../../components/dialog/DomDialogStack.vue';
9
9
  export { default as DomDialogSchemaForm } from '../../components/dialog/DomDialogSchemaForm.vue';
10
10
  export { default as DomLightbox } from '../../components/lightbox/DomLightbox.vue';
11
+ export { default as DomVideoFeed } from '../../components/video-feed/DomVideoFeed.vue';
12
+ export { default as DomAudioControls } from '../../components/audio-controls/DomAudioControls.vue';
13
+ export { default as DomAudioInput } from '../../components/audio-input/DomAudioInput.vue';
14
+ export { default as DomCameraInput } from '../../components/camera-input/DomCameraInput.vue';
15
+ export { default as DomAudioVisualizer } from '../../visual/audio-visualizer/DomAudioVisualizer.vue';
11
16
  export { default as DomTabs } from '../../components/tabs/DomTabs.vue';
12
17
  export { default as DomTooltip } from '../../components/tooltip/DomTooltip.vue';
13
18
  export { vDomTooltip } from '../../components/tooltip/vDomTooltip.js';
14
19
  export { default as DomAccordion } from '../../components/accordion/DomAccordion.vue';
20
+ export { default as DomMenuStack } from '../../components/menu-stack/DomMenuStack.vue';
15
21
  export { default as DomMenu } from '../../components/menu/DomMenu.vue';
16
22
  export { default as DomMenuItem } from '../../components/menu/DomMenuItem.vue';
17
23
  export { default as MenuItem } from '../../components/menu/DomMenuItem.vue';
@@ -43,6 +49,7 @@ export { default as DomPopover } from '../../components/popover/DomPopover.vue';
43
49
  export { default as DomDrawer } from '../../components/drawer/DomDrawer.vue';
44
50
  export { default as DomToastStack } from '../../components/toast/DomToastStack.vue';
45
51
  export { default as DomToastItem } from '../../components/toast/DomToastItem.vue';
52
+ export { default as DomNotificationInbox } from '../../data/notification-inbox/DomNotificationInbox.vue';
46
53
  export { default as DomCommandPalette } from '../../components/command-palette/DomCommandPalette.vue';
47
54
  export { default as DomContextMenu } from '../../components/context-menu/DomContextMenu.vue';
48
55
  export { default as DomDataGrid } from '../../data/data-grid/DomDataGrid.vue';
@@ -51,6 +58,8 @@ export { default as DomActivityTimeline } from '../../data/activity-timeline/Dom
51
58
  export { default as DomKanban } from '../../data/kanban/DomKanban.vue';
52
59
  export { default as DomMonthCalendar } from '../../data/month-calendar/DomMonthCalendar.vue';
53
60
  export { default as DomWeekPlanner } from '../../data/week-planner/DomWeekPlanner.vue';
61
+ export { default as DomGantt } from '../../data/gantt/DomGantt.vue';
62
+ export { ganttProps } from '../../data/gantt/DomGantt.vue';
54
63
  export { default as DomMediaBrowser } from '../../data/media-browser/DomMediaBrowser.vue';
55
64
  export { default as DomDocumentEditor } from '../../data/document-editor/DomDocumentEditor.vue';
56
65
  export { default as DomJsonViewer } from '../../dev/json-viewer/DomJsonViewer.vue';
@@ -76,6 +85,7 @@ export { monthCalendarProps } from '../../data/month-calendar/DomMonthCalendar.v
76
85
  export { weekPlannerProps } from '../../data/week-planner/DomWeekPlanner.vue';
77
86
  export { popoverProps } from '../../components/popover/DomPopover.vue';
78
87
  export { useToasts } from '../../components/toast/useToasts.js';
88
+ export { notificationTone, notificationToast } from '../../data/notification-inbox/notificationPresentation.js';
79
89
  export { useDialogs } from '../../components/dialog/useDialogs.js';
80
90
  export {
81
91
  THEME_MODE_STORAGE_KEY,
@@ -207,12 +217,45 @@ export {
207
217
  // Mobile primitives
208
218
  export { default as DomAppBottomNav } from '../../mobile/bottom-nav/DomAppBottomNav.vue';
209
219
  export { default as DomAppListItem } from '../../mobile/list-item/DomAppListItem.vue';
220
+ export { default as DomAppProfile } from '../../mobile/profile/DomAppProfile.vue';
210
221
  export { default as DomAppShell } from '../../mobile/shell/DomAppShell.vue';
211
222
  export { default as DomAppTopBar } from '../../mobile/top-bar/DomAppTopBar.vue';
212
223
  export { default as DomActionSheet } from '../../mobile/action-sheet/DomActionSheet.vue';
224
+ export { default as DomBottomSheet } from '../../mobile/bottom-sheet/DomBottomSheet.vue';
225
+ export { default as DomSwipeActions } from '../../mobile/swipe-actions/DomSwipeActions.vue';
213
226
  export { default as DomSafeArea } from '../../mobile/safe-area/DomSafeArea.vue';
214
227
  export { default as DomAppStack } from '../../mobile/stack/DomAppStack.vue';
215
228
  export { default as DomAppStackScreen } from '../../mobile/stack/AppStackScreen.vue';
216
229
 
217
230
  // Rehydration
218
231
  export { default as DomRenderer } from '../../components/playground/DomRenderer.vue';
232
+
233
+ // Data visualisation
234
+ export { default as DomLineChart } from '../../charts/line-chart/DomLineChart.vue';
235
+ export { default as DomAreaChart } from '../../charts/area-chart/DomAreaChart.vue';
236
+ export { default as DomBarChart } from '../../charts/bar-chart/DomBarChart.vue';
237
+ export { default as DomDonutChart } from '../../charts/donut-chart/DomDonutChart.vue';
238
+ export { default as DomSparkline } from '../../charts/sparkline/DomSparkline.vue';
239
+ export { default as DomMetricCard } from '../../charts/metric-card/DomMetricCard.vue';
240
+ export { default as DomDateRangePicker } from '../../charts/date-range-picker/DomDateRangePicker.vue';
241
+ export { default as DomChartContainer } from '../../charts/_shared/ChartContainer.vue';
242
+ export { default as DomChartLegend } from '../../charts/_shared/ChartLegend.vue';
243
+ export { default as DomChartTooltip } from '../../charts/_shared/ChartTooltip.vue';
244
+
245
+ // Beta UI designer primitives.
246
+ export { default as DomDesignerFrame } from '../../designer/frame/DomDesignerFrame.vue';
247
+ export { default as DomFrameLayoutEditor } from '../../designer/frame/DomFrameLayoutEditor.vue';
248
+ export { createFrameLayout, frameLayoutStyle } from '../../designer/frame/frameLayout.js';
249
+ export { default as DomColorSwatch } from '../../designer/color-swatch/DomColorSwatch.vue';
250
+ export { default as DomShadowEditor } from '../../designer/shadow-editor/DomShadowEditor.vue';
251
+ export { default as DomDesignerLayers } from '../../designer/layers/DomDesignerLayers.vue';
252
+ export { default as DomDesignerStage } from '../../designer/stage/DomDesignerStage.vue';
253
+ export { createShadow, shadowListToCss } from '../../designer/shadow-editor/shadowModel.js';
254
+
255
+ export { default as DomBorderEditor } from '../../designer/border-editor/DomBorderEditor.vue';
256
+ export { createDesignerBorder, borderToCss } from '../../designer/border-editor/borderModel.js';
257
+
258
+ export { default as DomBackgroundEditor } from '../../designer/background-editor/DomBackgroundEditor.vue';
259
+ export { createBackgroundState, createFill, backgroundImageToCss } from '../../designer/background-editor/backgroundModel.js';
260
+ export { default as DomBackdropEditor } from '../../designer/backdrop-editor/DomBackdropEditor.vue';
261
+ export { createBackdrop, backdropToCss } from '../../designer/backdrop-editor/backdropModel.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Returns sorted, unique sheet fractions, retaining zero only as a dismiss stop.
3
+ * Invalid input falls back to a usable half-height and expanded sheet.
4
+ * @param {number[]} values Requested fractions of the available height.
5
+ * @returns {number[]} Normalized stops in the inclusive range zero to one.
6
+ */
7
+ export function normalizeSnapPoints(values) {
8
+ const points = [...new Set((Array.isArray(values) ? values : []).filter((value) => Number.isFinite(value) && value >= 0 && value <= 1))].sort((a, b) => a - b);
9
+ return points.some((point) => point > 0) ? points : [0, 0.5, 0.9];
10
+ }
11
+
12
+ /**
13
+ * Chooses the nearest sheet stop after a short projection of release velocity.
14
+ * @param {number[]} points Normalized stops.
15
+ * @param {number} position Current height as a fraction of the available height.
16
+ * @param {number} velocity Height change per millisecond, in fractional units.
17
+ * @returns {number} Destination stop.
18
+ */
19
+ export function resolveSnapPoint(points, position, velocity = 0) {
20
+ const projected = Math.max(0, Math.min(1, position + velocity * 160));
21
+ return points.reduce((nearest, point) => Math.abs(point - projected) < Math.abs(nearest - projected) ? point : nearest, points[0]);
22
+ }
23
+
24
+ /**
25
+ * Determines whether a released swipe closes, reveals buttons, or commits an action.
26
+ * Full swipes require explicit opt-in and deliberate travel, then project release
27
+ * velocity over 180ms so a shorter outward flick can cross the commit threshold.
28
+ * @param {object} options Measured gesture and action availability.
29
+ * @returns {'close'|'reveal'|'commit'} Release outcome.
30
+ */
31
+ export function resolveSwipeRelease({ distance, velocity = 0, actionWidth, rowWidth, fullSwipe = false, cancelled = false }) {
32
+ if (cancelled || !actionWidth) return 'close';
33
+ const projected = distance + velocity * 180;
34
+ if (fullSwipe && distance >= Math.max(48, rowWidth * 0.16) && projected >= Math.max(actionWidth + 48, rowWidth * 0.72)) return 'commit';
35
+ return distance + velocity * 120 >= Math.min(48, actionWidth * 0.5) ? 'reveal' : 'close';
36
+ }
37
+
38
+ /**
39
+ * Separates a likely software keyboard from browser chrome and pinch zoom.
40
+ * @param {object} options Current viewport and editable-focus measurements.
41
+ * @returns {number} Estimated keyboard height, or zero when not detected.
42
+ */
43
+ export function keyboardHeight({ baseline, viewportHeight, scale = 1, editing = false }) {
44
+ const difference = Math.max(0, baseline - viewportHeight);
45
+ return editing && Math.abs(scale - 1) < 0.05 && difference > 120 ? Math.round(difference) : 0;
46
+ }
@@ -0,0 +1,91 @@
1
+ import { onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
2
+ import { keyboardHeight } from './interactionMath.js';
3
+
4
+ /**
5
+ * Observes the owning window's keyboard-sized viewport changes without treating
6
+ * pinch zoom as a keyboard. An optional app-supplied inset supports native
7
+ * keyboards that overlay the webview without resizing its visual viewport.
8
+ * @param {import('vue').Ref<HTMLElement|null>} target Surface containing editable controls.
9
+ * @param {Function} enabled Returns whether the surface should adapt.
10
+ * @param {Function} inset Returns a native keyboard inset, or null for detection.
11
+ * @returns {{ open: import('vue').Ref<boolean>, height: import('vue').Ref<number>, available: import('vue').Ref<number>, top: import('vue').Ref<number> }} Reactive geometry.
12
+ */
13
+ export function useKeyboardViewport(target, enabled, inset = () => null) {
14
+ const open = ref(false);
15
+ const height = ref(0);
16
+ const available = ref(0);
17
+ const top = ref(0);
18
+ let view;
19
+ let doc;
20
+ let baseline = 0;
21
+ let baselineWidth = 0;
22
+ let frame = 0;
23
+ let blurTimer = 0;
24
+
25
+ /** Measures only the viewport of the document containing this surface. */
26
+ function measure() {
27
+ frame = 0;
28
+ if (!view || !target.value) return;
29
+ const viewport = view.visualViewport;
30
+ const visible = viewport?.height || view.innerHeight;
31
+ const layout = Math.max(view.innerHeight, doc.documentElement.clientHeight);
32
+ const active = doc.activeElement;
33
+ const editable = active?.matches('textarea, input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="range"]):not([type="file"]), [contenteditable="true"], [contenteditable=""]');
34
+ const editing = Boolean(editable && target.value.contains(active) && !active.readOnly && !active.disabled);
35
+ if (Math.abs(view.innerWidth - baselineWidth) > 80) {
36
+ baseline = layout;
37
+ baselineWidth = view.innerWidth;
38
+ }
39
+ if (!editing && !open.value) baseline = layout;
40
+ baseline = Math.max(baseline, layout);
41
+ const supplied = toValue(inset);
42
+ const detected = keyboardHeight({ baseline, viewportHeight: visible, scale: viewport?.scale || 1, editing });
43
+ height.value = toValue(enabled) ? (supplied == null ? detected : Math.max(0, Number(supplied) || 0)) : 0;
44
+ open.value = height.value > 0;
45
+ top.value = viewport?.offsetTop || 0;
46
+ const origin = Math.max(0, target.value.getBoundingClientRect().top - top.value);
47
+ available.value = Math.max(0, Math.min(visible, baseline - height.value) - origin);
48
+ }
49
+
50
+ /** Coalesces focus, scroll, and resize notifications into a single frame. */
51
+ function schedule() {
52
+ if (view && !frame) frame = view.requestAnimationFrame(measure);
53
+ }
54
+
55
+ /** Waits for the next focus target before deciding that editing ended. */
56
+ function onBlur() {
57
+ view.clearTimeout(blurTimer);
58
+ blurTimer = view.setTimeout(schedule, 0);
59
+ }
60
+
61
+ /** Binds after mount so adopted iframe nodes use their actual owner window. */
62
+ function mount() {
63
+ doc = target.value?.ownerDocument;
64
+ view = doc?.defaultView;
65
+ if (!view) return;
66
+ baseline = Math.max(view.innerHeight, doc.documentElement.clientHeight);
67
+ baselineWidth = view.innerWidth;
68
+ view.addEventListener('resize', schedule);
69
+ view.visualViewport?.addEventListener('resize', schedule);
70
+ view.visualViewport?.addEventListener('scroll', schedule);
71
+ doc.addEventListener('focusin', schedule);
72
+ doc.addEventListener('focusout', onBlur);
73
+ measure();
74
+ }
75
+
76
+ /** Releases listeners and pending callbacks when a surface is removed. */
77
+ function unmount() {
78
+ view?.cancelAnimationFrame(frame);
79
+ view?.clearTimeout(blurTimer);
80
+ view?.removeEventListener('resize', schedule);
81
+ view?.visualViewport?.removeEventListener('resize', schedule);
82
+ view?.visualViewport?.removeEventListener('scroll', schedule);
83
+ doc?.removeEventListener('focusin', schedule);
84
+ doc?.removeEventListener('focusout', onBlur);
85
+ }
86
+
87
+ watch([enabled, inset], schedule);
88
+ onMounted(mount);
89
+ onBeforeUnmount(unmount);
90
+ return { open, height, available, top };
91
+ }
@@ -1,11 +1,11 @@
1
1
  <script setup>
2
- import { computed, onMounted, ref, useId, watch } from 'vue';
2
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
3
3
 
4
4
  defineOptions({
5
5
  __doc: {
6
6
  name: 'Action sheet',
7
7
  tag: '<DomActionSheet>',
8
- description: 'A bottom action sheet for app choices, destructive actions, and quick commands.',
8
+ description: 'A bottom action sheet for mobile and desktop, with bounded scrolling, keyboard dismissal, focus return, and drag dismissal. Works standalone as a viewport overlay. In DomAppShell, place it in the overlay slot and it stays inside that app.',
9
9
  events: [
10
10
  { name: 'update:modelValue', payload: 'boolean', description: 'Fired when the sheet opens or closes.' },
11
11
  { name: 'select', payload: 'object', description: 'Fired with the selected action.' },
@@ -17,9 +17,9 @@ defineOptions({
17
17
  title: 'Project actions',
18
18
  description: 'Use for app commands that should stay near the thumb.',
19
19
  actions: [
20
- { label: 'Share project', description: 'Open the native share sheet', value: 'share' },
21
- { label: 'Duplicate screen', value: 'duplicate' },
22
- { label: 'Delete draft', value: 'delete', variant: 'danger' },
20
+ { label: 'Share project', description: 'Open the native share sheet', value: 'share', variant: '', disabled: false },
21
+ { label: 'Duplicate screen', description: '', value: 'duplicate', variant: '', disabled: false },
22
+ { label: 'Delete draft', description: '', value: 'delete', variant: 'danger', disabled: false },
23
23
  ],
24
24
  },
25
25
  },
@@ -27,6 +27,11 @@ defineOptions({
27
27
  });
28
28
 
29
29
  const props = defineProps({
30
+ contained: {
31
+ type: Boolean,
32
+ default: undefined,
33
+ _edit: { description: 'Defaults to contained inside DomAppShell, viewport otherwise. Explicit true requires a positioned, sized parent; prefer the shell overlay slot.' },
34
+ },
30
35
  modelValue: {
31
36
  type: Boolean,
32
37
  default: false,
@@ -55,6 +60,7 @@ const props = defineProps({
55
60
  items: {
56
61
  type: 'DomForm',
57
62
  properties: {
63
+ disabled: { type: 'boolean', label: 'Disabled', default: false },
58
64
  label: {
59
65
  type: 'string',
60
66
  label: 'Label',
@@ -83,7 +89,7 @@ const props = defineProps({
83
89
  },
84
90
  },
85
91
  },
86
- description: 'Action rows: { label, description, value, variant }.',
92
+ description: 'Action rows: { label, description, value, variant, disabled }.',
87
93
  },
88
94
  },
89
95
  cancelLabel: {
@@ -95,92 +101,123 @@ const props = defineProps({
95
101
  const emit = defineEmits(['update:modelValue', 'select']);
96
102
 
97
103
  const drawer = ref(null);
104
+ const shell = inject('dom-app-shell', null);
105
+ const isContained = computed(() => props.contained ?? Boolean(shell));
106
+ const teleportTarget = ref(null);
107
+ let ready = false;
108
+ let removeListeners = null;
98
109
  const uniqueId = useId();
99
110
  const titleId = computed(() => (props.title ? `dom-action-sheet-${uniqueId}-title` : ''));
111
+ const descriptionId = `dom-action-sheet-${uniqueId}-description`;
100
112
 
113
+ /** Requests dismissal of the controlled action sheet. */
101
114
  function close() {
102
115
  emit('update:modelValue', false);
103
116
  }
104
117
 
118
+ /** Emits an enabled action and then requests sheet dismissal. */
105
119
  function selectAction(action) {
120
+ if (action.disabled) return;
106
121
  emit('select', action);
107
122
  close();
108
123
  }
109
124
 
125
+ /** Synchronizes Vue state after the headless drawer has upgraded. */
110
126
  function setOpen(value) {
111
- if (drawer.value) drawer.value.open = value;
127
+ if (ready && drawer.value) drawer.value.open = value;
112
128
  }
113
129
 
114
130
  onMounted(async () => {
115
131
  await import('../../lib/headless/drawer.js');
116
- drawer.value?.addEventListener('dom:open', () => {
117
- emit('update:modelValue', true);
118
- });
119
- drawer.value?.addEventListener('dom:close', () => {
120
- emit('update:modelValue', false);
121
- });
122
- if (props.modelValue) setOpen(true);
132
+ if (!drawer.value) return;
133
+ const element = drawer.value;
134
+ const onOpen = () => emit('update:modelValue', true);
135
+ const onClose = () => emit('update:modelValue', false);
136
+ const onState = (event) => emit('update:modelValue', event.detail.open);
137
+ element.addEventListener('dom:open', onOpen);
138
+ element.addEventListener('dom:close', onClose);
139
+ element.addEventListener('dom:state', onState);
140
+ removeListeners = () => {
141
+ element.removeEventListener('dom:open', onOpen);
142
+ element.removeEventListener('dom:close', onClose);
143
+ element.removeEventListener('dom:state', onState);
144
+ };
145
+ ready = true;
146
+ teleportTarget.value = element.ownerDocument.body;
147
+ await nextTick();
148
+ setOpen(props.modelValue);
123
149
  });
124
150
 
125
151
  watch(() => props.modelValue, setOpen);
152
+ onBeforeUnmount(() => {
153
+ ready = false;
154
+ removeListeners?.();
155
+ });
126
156
  </script>
127
157
 
128
158
  <template>
129
- <dom-drawer
130
- ref="drawer"
131
- side="bottom"
132
- class="absolute inset-0 z-30 block pointer-events-none data-[state=open]:pointer-events-auto"
133
- :aria-labelledby="titleId || null"
134
- >
135
- <div
136
- data-overlay
137
- data-no-defaults
138
- class="absolute inset-0 pointer-events-none data-[state=open]:pointer-events-auto"
159
+ <Teleport :to="teleportTarget || 'body'" :disabled="isContained || !teleportTarget">
160
+ <dom-drawer
161
+ ref="drawer"
162
+ side="bottom"
163
+ class="inset-0 z-50 block pointer-events-none data-[state=open]:pointer-events-auto"
164
+ :class="isContained ? 'absolute' : 'fixed'"
165
+ :aria-labelledby="titleId || null"
166
+ :aria-label="title ? null : 'Actions'"
167
+ :aria-describedby="description ? descriptionId : null"
139
168
  >
140
169
  <div
141
- data-backdrop
170
+ data-overlay
142
171
  data-no-defaults
143
- class="absolute inset-0 bg-black/40 opacity-0 backdrop-blur-sm transition-opacity duration-300 data-[state=open]:opacity-100"
144
- ></div>
145
- <section
146
- data-panel
147
- data-no-defaults
148
- class="absolute inset-x-3 bottom-3 translate-y-full outline-none transition-transform duration-300 ease-out data-[state=open]:translate-y-0"
149
- :class="'pb-[env(safe-area-inset-bottom,0px)]'"
172
+ class="absolute inset-0 overflow-hidden pointer-events-none data-[state=open]:pointer-events-auto"
173
+ hidden
150
174
  >
151
- <button
152
- data-drag-handle
153
- type="button"
154
- class="group mx-auto mb-1 flex h-8 w-full items-center justify-center"
155
- aria-label="Drag to close action sheet"
175
+ <div
176
+ data-backdrop
177
+ data-no-defaults
178
+ class="absolute inset-0 bg-black/40 opacity-0 backdrop-blur-sm transition-opacity duration-300 data-[state=open]:opacity-100 motion-reduce:transition-none"
179
+ ></div>
180
+ <section
181
+ data-panel
182
+ data-no-defaults
183
+ class="absolute left-[max(0.75rem,var(--dom-safe-area-left,env(safe-area-inset-left,0px)))] right-[max(0.75rem,var(--dom-safe-area-right,env(safe-area-inset-right,0px)))] bottom-0 mx-auto flex max-h-[calc(100%-var(--dom-safe-area-top,env(safe-area-inset-top,0px))-0.75rem)] max-w-md flex-col pb-[max(0.75rem,var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px)))] translate-y-full outline-none transition-transform duration-300 ease-out data-[state=open]:translate-y-0 motion-reduce:transition-none"
156
184
  >
157
- <span class="block h-1.5 w-12 rounded-full bg-white/70 shadow-sm transition group-hover:bg-white"></span>
158
- </button>
159
- <div class="skin-overlay overflow-hidden rounded-[1.75rem]">
160
- <div v-if="title || description" class="border-b border-border px-5 py-4 text-center">
161
- <h2 v-if="title" :id="titleId" class="text-sm font-semibold text-canvas-fg">{{ title }}</h2>
162
- <p v-if="description" class="mt-1 text-xs leading-5 text-muted-fg">{{ description }}</p>
163
- </div>
164
- <div>
165
- <button
166
- v-for="action in actions"
167
- :key="action.value || action.label"
168
- type="button"
169
- class="block min-h-14 w-full border-b border-border px-5 py-3 text-center transition last:border-b-0 hover:bg-secondary/70 active:bg-secondary"
170
- :class="action.variant === 'danger' ? 'text-destructive' : 'text-canvas-fg'"
171
- @click="selectAction(action)"
172
- >
173
- <span class="block text-sm font-medium">{{ action.label }}</span>
174
- <span v-if="action.description" class="mt-0.5 block text-xs text-muted-fg">{{ action.description }}</span>
175
- </button>
185
+ <button
186
+ data-drag-handle
187
+ type="button"
188
+ class="group mx-auto flex h-11 w-full shrink-0 touch-none items-center justify-center focus-visible:outline-2 focus-visible:outline-ring"
189
+ aria-label="Close action sheet"
190
+ @click="close"
191
+ >
192
+ <span class="block h-1.5 w-12 rounded-full bg-white/70 shadow-sm transition group-hover:bg-white"></span>
193
+ </button>
194
+ <div class="skin-overlay min-h-0 overflow-y-auto overscroll-contain rounded-2xl">
195
+ <div v-if="title || description" class="border-b border-border px-5 py-4 text-center">
196
+ <h2 v-if="title" :id="titleId" class="text-sm font-semibold text-canvas-fg">{{ title }}</h2>
197
+ <p v-if="description" :id="descriptionId" class="mt-1 text-xs leading-5 text-muted-fg">{{ description }}</p>
198
+ </div>
199
+ <div>
200
+ <button
201
+ v-for="action in actions"
202
+ :key="action.value || action.label"
203
+ type="button"
204
+ class="block min-h-14 w-full border-b border-border px-5 py-3 text-center transition last:border-b-0 hover:bg-secondary/70 active:bg-secondary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring disabled:opacity-40 disabled:pointer-events-none"
205
+ :disabled="action.disabled"
206
+ :class="action.variant === 'danger' ? 'text-destructive' : 'text-canvas-fg'"
207
+ @click="selectAction(action)"
208
+ >
209
+ <span class="block text-sm font-medium">{{ action.label }}</span>
210
+ <span v-if="action.description" class="mt-0.5 block text-xs text-muted-fg">{{ action.description }}</span>
211
+ </button>
212
+ </div>
176
213
  </div>
177
- </div>
178
- <button
179
- data-close
180
- type="button"
181
- class="skin-control mt-2 min-h-14 w-full rounded-[1.5rem] px-5 text-sm font-semibold transition hover:brightness-[0.98] active:brightness-[0.96]"
182
- >{{ cancelLabel }}</button>
183
- </section>
184
- </div>
185
- </dom-drawer>
214
+ <button
215
+ data-close
216
+ type="button"
217
+ class="skin-control mt-2 min-h-14 w-full shrink-0 rounded-2xl px-5 text-sm font-semibold transition hover:brightness-[0.98] active:brightness-[0.96] focus-visible:outline-2 focus-visible:outline-ring"
218
+ >{{ cancelLabel }}</button>
219
+ </section>
220
+ </div>
221
+ </dom-drawer>
222
+ </Teleport>
186
223
  </template>
@@ -50,19 +50,21 @@ const activeValue = computed(() => props.modelValue || props.items[0]?.value ||
50
50
  * @returns {void}
51
51
  */
52
52
  function selectItem(item) {
53
+ if (item.disabled) return;
53
54
  emit('update:modelValue', item.value);
54
55
  emit('change', item);
55
56
  }
56
57
  </script>
57
58
 
58
59
  <template>
59
- <nav class="skin-card px-2 py-2" aria-label="Primary">
60
+ <nav class="shrink-0 border-t border-border bg-canvas/95 pl-[max(0.5rem,var(--dom-safe-area-left,env(safe-area-inset-left,0px)))] pr-[max(0.5rem,var(--dom-safe-area-right,env(safe-area-inset-right,0px)))] pt-1 pb-[max(0.25rem,var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px)))]" aria-label="Primary">
60
61
  <div class="grid auto-cols-fr grid-flow-col gap-1">
61
62
  <button
62
63
  v-for="item in items"
63
64
  :key="item.value"
64
65
  type="button"
65
- class="relative flex min-h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-2xl px-2 text-xs font-medium text-muted-fg transition active:scale-95"
66
+ class="relative flex min-h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-2 text-xs font-medium text-muted-fg transition active:scale-95 focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50 disabled:pointer-events-none"
67
+ :disabled="item.disabled"
66
68
  :class="item.value === activeValue && 'bg-secondary text-canvas-fg'"
67
69
  :aria-current="item.value === activeValue ? 'page' : undefined"
68
70
  @click="selectItem(item)"