@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.
- package/CHANGELOG.md +54 -0
- package/README.md +31 -0
- package/SKILL.md +21 -1
- package/package.json +10 -4
- package/public/llms.txt +13 -2
- package/src/pages/_layout/componentCatalog.generated.js +1185 -44
- package/src/pages/_layout/componentLoaders.js +41 -0
- package/src/pages/_layout/componentManager.js +38 -27
- package/src/pages/_layout/componentRuntimeRecords.js +3 -1
- package/src/pages/charts/_shared/ChartContainer.vue +67 -0
- package/src/pages/charts/_shared/ChartLegend.vue +22 -0
- package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
- package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
- package/src/pages/charts/_shared/XYChart.vue +140 -0
- package/src/pages/charts/_shared/XYRenderer.vue +90 -0
- package/src/pages/charts/_shared/chartProps.js +23 -0
- package/src/pages/charts/_shared/chartUtils.js +95 -0
- package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
- package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
- package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
- package/src/pages/charts/date-range-picker/dateRange.js +69 -0
- package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
- package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
- package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
- package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
- package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
- package/src/pages/components/_shared/media/useMediaInput.js +200 -0
- package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
- package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
- package/src/pages/components/button/DomButton.vue +10 -4
- package/src/pages/components/button/buttonStyles.js +5 -0
- package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
- package/src/pages/components/dialog/DomDialog.vue +338 -36
- package/src/pages/components/icon-button/DomIconButton.vue +8 -2
- package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
- package/src/pages/components/menu-stack/menuTree.js +35 -0
- package/src/pages/components/popover/DomPopover.vue +1 -53
- package/src/pages/components/popover/popover.css +50 -0
- package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
- package/src/pages/data/gantt/DomGantt.vue +788 -0
- package/src/pages/data/gantt/ganttTime.js +223 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
- package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
- package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
- package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
- package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
- package/src/pages/designer/_shared/cssEditorContext.js +19 -0
- package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
- package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
- package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
- package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
- package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
- package/src/pages/designer/background-editor/backgroundModel.js +102 -0
- package/src/pages/designer/border-editor/BorderControl.vue +72 -0
- package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
- package/src/pages/designer/border-editor/borderModel.js +21 -0
- package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
- package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
- package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
- package/src/pages/designer/frame/frameLayout.js +93 -0
- package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
- package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
- package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
- package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
- package/src/pages/designer/stage/DesignerBounds.vue +31 -0
- package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
- package/src/pages/designer/stage/SpacingGuides.vue +33 -0
- package/src/pages/designer/stage/resizeGeometry.js +15 -0
- package/src/pages/designer/stage/snapGeometry.js +69 -0
- package/src/pages/designer/stage/snapPipeline.js +17 -0
- package/src/pages/designer/stage/spacingGeometry.js +129 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
- package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
- package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
- package/src/pages/forms/toggle/DomToggle.vue +1 -1
- package/src/pages/lib/headless/base.js +10 -5
- package/src/pages/lib/headless/dialog.js +25 -6
- package/src/pages/lib/headless/drawer.js +49 -21
- package/src/pages/lib/vue/index.d.ts +1161 -14
- package/src/pages/lib/vue/index.js +43 -0
- package/src/pages/mobile/_shared/interactionMath.js +46 -0
- package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
- package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
- package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
- package/src/pages/mobile/shell/DomAppShell.vue +60 -9
- package/src/pages/mobile/stack/DomAppStack.vue +214 -55
- package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
- package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
- 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
|
|
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
|
|
30
|
-
sm: '
|
|
31
|
-
md: '
|
|
32
|
-
lg: '
|
|
33
|
-
xl: '
|
|
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
|
|
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="
|
|
213
|
-
:class="
|
|
214
|
-
:
|
|
384
|
+
class="dom-dialog-shell"
|
|
385
|
+
:class="{ 'dom-dialog-shell--open': companionActive }"
|
|
386
|
+
:data-companion-mode="companionMode"
|
|
387
|
+
:style="dialogShellStyle"
|
|
215
388
|
>
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
:
|
|
220
|
-
:
|
|
221
|
-
:
|
|
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
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
+
}
|