@getdom/studio 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +53 -1
- package/SKILL.md +21 -1
- package/package.json +6 -2
- 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/style.css +2 -445
- package/src/tailwind.css +351 -0
- package/src/theme.css +99 -0
|
@@ -15,10 +15,11 @@ const StackScreenRenderer = defineComponent({
|
|
|
15
15
|
emptyTitle: { type: String, default: 'Select a setting' },
|
|
16
16
|
emptyDescription: { type: String, default: 'Choose an item to continue.' },
|
|
17
17
|
},
|
|
18
|
+
/** Returns the renderer for a resolved, loading, or empty screen. */
|
|
18
19
|
setup(props) {
|
|
19
20
|
return () => {
|
|
20
21
|
if (props.loading) return renderLoading(props.screen, props.loadingLabel);
|
|
21
|
-
if (props.error) return renderError(props.error);
|
|
22
|
+
if (props.error) return renderError(props.error, props.helpers.retry);
|
|
22
23
|
if (!props.screen) return renderEmpty(props.emptyTitle, props.emptyDescription);
|
|
23
24
|
if (props.screen.slot) return props.screen.slot({ ...props.helpers, screen: props.screen });
|
|
24
25
|
if (props.component) {
|
|
@@ -35,6 +36,9 @@ const StackScreenRenderer = defineComponent({
|
|
|
35
36
|
},
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Builds accessible navigation rows for the current screen children.
|
|
41
|
+
*/
|
|
38
42
|
function renderGeneratedMenu(screen, childScreens, helpers) {
|
|
39
43
|
return h('div', { class: 'overflow-hidden rounded-[1.5rem] border border-border bg-canvas' }, childScreens.map((child) => {
|
|
40
44
|
const slots = child.icon ? {
|
|
@@ -59,6 +63,9 @@ function renderGeneratedMenu(screen, childScreens, helpers) {
|
|
|
59
63
|
}));
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Renders the loading state while a screen module resolves.
|
|
68
|
+
*/
|
|
62
69
|
function renderLoading(screen, fallbackLabel) {
|
|
63
70
|
return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
|
|
64
71
|
h('div', { class: 'space-y-3' }, [
|
|
@@ -71,15 +78,22 @@ function renderLoading(screen, fallbackLabel) {
|
|
|
71
78
|
]);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Renders a recoverable screen-loading failure.
|
|
83
|
+
*/
|
|
84
|
+
function renderError(error, retry) {
|
|
75
85
|
return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
|
|
76
86
|
h('div', { class: 'max-w-sm space-y-2 rounded-2xl border border-destructive/30 bg-destructive/10 p-5' }, [
|
|
77
87
|
h('p', { class: 'text-sm font-semibold text-destructive' }, 'Unable to load this setting'),
|
|
78
88
|
h('p', { class: 'text-xs leading-5 text-muted-fg' }, error?.message || 'The screen loader rejected.'),
|
|
89
|
+
h('button', { type: 'button', class: 'min-h-11 rounded-lg px-4 font-medium text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring', onClick: retry }, 'Try again'),
|
|
79
90
|
]),
|
|
80
91
|
]);
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Renders the fallback when a screen has no content.
|
|
96
|
+
*/
|
|
83
97
|
function renderEmpty(title, description) {
|
|
84
98
|
return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
|
|
85
99
|
h('div', { class: 'max-w-sm' }, [
|
|
@@ -93,10 +107,11 @@ defineOptions({
|
|
|
93
107
|
__doc: {
|
|
94
108
|
name: 'App stack',
|
|
95
109
|
tag: '<DomAppStack>',
|
|
96
|
-
description: 'A
|
|
110
|
+
description: 'A bounded screen stack with an iOS-style back button and sliding title on mobile, desktop breadcrumbs, and lazy screens. Fills the viewport by default. Inside DomAppShell, set height="100%" and disable the shell content-scroll so each screen owns scrolling.',
|
|
97
111
|
slots: [
|
|
98
112
|
{ name: '(default)', description: 'Optional DomAppStackScreen children used to register slot-driven screens.' },
|
|
99
|
-
{ name: '
|
|
113
|
+
{ name: 'header', payload: '{ activeScreen, canGoBack, back, path }', description: 'Replace the mobile header; the header transitions with its screen.' },
|
|
114
|
+
{ name: 'breadcrumb', payload: '{ crumbs, canGoBack, back, goTo, push, path }', description: 'Replace the desktop breadcrumb and back control while keeping the stack state.' },
|
|
100
115
|
],
|
|
101
116
|
events: [
|
|
102
117
|
{ name: 'update:path', payload: '(path: Array<string>)', description: 'Fired when the active stack path changes.' },
|
|
@@ -105,12 +120,18 @@ defineOptions({
|
|
|
105
120
|
{ name: 'error', payload: '(payload: { screen, error })', description: 'Fired when a lazy screen component rejects.' },
|
|
106
121
|
],
|
|
107
122
|
playground: {
|
|
108
|
-
mobileSlot: '
|
|
123
|
+
mobileSlot: 'stack',
|
|
109
124
|
initial: {
|
|
125
|
+
height: '100%',
|
|
110
126
|
root: 'settings',
|
|
111
127
|
screens: [
|
|
112
|
-
{ id: 'settings', title: 'Settings', children: ['account', 'notifications'] },
|
|
113
|
-
{ id: 'account', title: 'Account', description: 'Profile
|
|
128
|
+
{ id: 'settings', title: 'Settings', children: ['account', 'notifications'], description: '' },
|
|
129
|
+
{ id: 'account', title: 'Account', description: 'Profile and preferences.', children: ['general', 'apps'] },
|
|
130
|
+
{ id: 'general', title: 'General', children: ['language'], description: '' },
|
|
131
|
+
{ id: 'language', title: 'Language & Region', description: 'English (UK), United Kingdom.' },
|
|
132
|
+
{ id: 'apps', title: 'Apps', children: ['calendar'], description: '' },
|
|
133
|
+
{ id: 'calendar', title: 'Calendar', children: ['calendar-notifications'], description: '' },
|
|
134
|
+
{ id: 'calendar-notifications', title: 'Notifications', description: 'Event reminders, invitations, and shared calendars.' },
|
|
114
135
|
{ id: 'notifications', title: 'Notifications', description: 'Push, email, and quiet hours.' },
|
|
115
136
|
],
|
|
116
137
|
},
|
|
@@ -119,6 +140,16 @@ defineOptions({
|
|
|
119
140
|
});
|
|
120
141
|
|
|
121
142
|
const props = defineProps({
|
|
143
|
+
height: {
|
|
144
|
+
type: String,
|
|
145
|
+
default: '100dvh',
|
|
146
|
+
_edit: { description: 'Bounded stack height. Use 100% inside DomAppShell with content-scroll disabled, or a CSS length for an embedded stack.' },
|
|
147
|
+
},
|
|
148
|
+
showHeader: {
|
|
149
|
+
type: Boolean,
|
|
150
|
+
default: true,
|
|
151
|
+
_edit: { description: 'Show the mobile back button and current screen title.' },
|
|
152
|
+
},
|
|
122
153
|
path: {
|
|
123
154
|
type: Array,
|
|
124
155
|
default: null,
|
|
@@ -157,7 +188,7 @@ const props = defineProps({
|
|
|
157
188
|
showBreadcrumbs: {
|
|
158
189
|
type: Boolean,
|
|
159
190
|
default: true,
|
|
160
|
-
_edit: { description: '
|
|
191
|
+
_edit: { description: 'Show desktop breadcrumbs. Mobile uses the current title and back button; showHeader controls that header.' },
|
|
161
192
|
},
|
|
162
193
|
animate: {
|
|
163
194
|
type: Boolean,
|
|
@@ -226,6 +257,8 @@ const internalPath = ref([]);
|
|
|
226
257
|
const direction = ref('forward');
|
|
227
258
|
const isDesktopMedia = ref(false);
|
|
228
259
|
const screenPanel = ref(null);
|
|
260
|
+
const visitedPaths = reactive(new Map());
|
|
261
|
+
const hasMounted = ref(false);
|
|
229
262
|
|
|
230
263
|
let removeDesktopMediaListener = null;
|
|
231
264
|
|
|
@@ -277,7 +310,6 @@ const crumbs = computed(() => resolvedPath.value.map((id, index) => {
|
|
|
277
310
|
const canGoBack = computed(() => resolvedPath.value.length > 1);
|
|
278
311
|
const isDesktopLayout = computed(() => props.layout === 'desktop' || (props.layout === 'auto' && isDesktopMedia.value));
|
|
279
312
|
const transitionName = computed(() => (props.animate ? `dom-app-stack-${direction.value}` : ''));
|
|
280
|
-
const activeComponent = computed(() => componentForScreen(activeScreen.value));
|
|
281
313
|
const helpers = computed(() => ({
|
|
282
314
|
activeId: activeId.value,
|
|
283
315
|
back,
|
|
@@ -291,6 +323,7 @@ const helpers = computed(() => ({
|
|
|
291
323
|
push,
|
|
292
324
|
replace,
|
|
293
325
|
reset,
|
|
326
|
+
retry: retryActiveScreen,
|
|
294
327
|
}));
|
|
295
328
|
const breadcrumbSlotProps = computed(() => ({
|
|
296
329
|
...helpers.value,
|
|
@@ -304,6 +337,7 @@ watch(rootId, (id) => {
|
|
|
304
337
|
}, { immediate: true });
|
|
305
338
|
|
|
306
339
|
watch(activeScreen, (screen) => {
|
|
340
|
+
if (screen) visitedPaths.set(screen.id, [...resolvedPath.value]);
|
|
307
341
|
ensureLoaded(screen);
|
|
308
342
|
}, { immediate: true });
|
|
309
343
|
|
|
@@ -313,6 +347,9 @@ watch(() => props.path, (next, previous) => {
|
|
|
313
347
|
}, { deep: true });
|
|
314
348
|
|
|
315
349
|
onMounted(() => {
|
|
350
|
+
// Each first visit mounts a new Transition. Animate those appearances, but
|
|
351
|
+
// keep the initial screen still during the stack's own mount and hydration.
|
|
352
|
+
hasMounted.value = true;
|
|
316
353
|
configureDesktopMedia();
|
|
317
354
|
});
|
|
318
355
|
|
|
@@ -324,6 +361,9 @@ onBeforeUnmount(() => {
|
|
|
324
361
|
removeDesktopMediaListener?.();
|
|
325
362
|
});
|
|
326
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Normalizes an application-owned screen record without mutating it.
|
|
366
|
+
*/
|
|
327
367
|
function normalizeScreen(screen) {
|
|
328
368
|
if (!screen || !screen.id) return null;
|
|
329
369
|
return {
|
|
@@ -334,6 +374,9 @@ function normalizeScreen(screen) {
|
|
|
334
374
|
};
|
|
335
375
|
}
|
|
336
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Collects declarative screen registrations from the default slot.
|
|
379
|
+
*/
|
|
337
380
|
function extractSlotScreens(vnodes) {
|
|
338
381
|
const screens = [];
|
|
339
382
|
|
|
@@ -357,11 +400,17 @@ function extractSlotScreens(vnodes) {
|
|
|
357
400
|
return screens;
|
|
358
401
|
}
|
|
359
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Identifies a declarative screen registration vnode.
|
|
405
|
+
*/
|
|
360
406
|
function isStackScreenVNode(vnode) {
|
|
361
407
|
const type = vnode?.type;
|
|
362
408
|
return type?.name === 'DomAppStackScreen' || type?.__name === 'AppStackScreen';
|
|
363
409
|
}
|
|
364
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Resolves child records and identifiers into stable string ids.
|
|
413
|
+
*/
|
|
365
414
|
function normalizeChildren(children) {
|
|
366
415
|
if (!Array.isArray(children)) return [];
|
|
367
416
|
return children
|
|
@@ -372,23 +421,35 @@ function normalizeChildren(children) {
|
|
|
372
421
|
.filter(Boolean);
|
|
373
422
|
}
|
|
374
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Normalizes a controlled path and supplies the root when empty.
|
|
426
|
+
*/
|
|
375
427
|
function normalizePath(path) {
|
|
376
428
|
const next = Array.isArray(path) ? path.map((item) => String(item)).filter(Boolean) : [];
|
|
377
429
|
if (!next.length && rootId.value) return [rootId.value];
|
|
378
430
|
return next;
|
|
379
431
|
}
|
|
380
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Compares navigation paths without changing either array.
|
|
435
|
+
*/
|
|
381
436
|
function samePath(a, b) {
|
|
382
437
|
if (a.length !== b.length) return false;
|
|
383
438
|
return a.every((item, index) => item === b[index]);
|
|
384
439
|
}
|
|
385
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Determines whether navigation pushes forward or returns to an ancestor.
|
|
443
|
+
*/
|
|
386
444
|
function compareDirection(next, previous = []) {
|
|
387
445
|
if (next.length > previous.length && previous.every((id, index) => next[index] === id)) return 'forward';
|
|
388
446
|
if (next.length < previous.length && next.every((id, index) => previous[index] === id)) return 'back';
|
|
389
447
|
return next.length >= previous.length ? 'forward' : 'back';
|
|
390
448
|
}
|
|
391
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Builds an ancestry path and rejects cycles or disconnected parents.
|
|
452
|
+
*/
|
|
392
453
|
function pathToScreen(id) {
|
|
393
454
|
const target = String(id || '');
|
|
394
455
|
if (!target) return null;
|
|
@@ -409,6 +470,9 @@ function pathToScreen(id) {
|
|
|
409
470
|
return path;
|
|
410
471
|
}
|
|
411
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Commits a navigation change and reports its direction to consumers.
|
|
475
|
+
*/
|
|
412
476
|
function commitPath(path, reason = 'navigate') {
|
|
413
477
|
const previousPath = resolvedPath.value;
|
|
414
478
|
const nextPath = normalizePath(path);
|
|
@@ -428,6 +492,9 @@ function commitPath(path, reason = 'navigate') {
|
|
|
428
492
|
nextTick(focusActivePanel);
|
|
429
493
|
}
|
|
430
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Opens a screen, reusing its existing ancestry where available.
|
|
497
|
+
*/
|
|
431
498
|
function push(id) {
|
|
432
499
|
const target = String(id || '');
|
|
433
500
|
if (!target) return;
|
|
@@ -439,18 +506,27 @@ function push(id) {
|
|
|
439
506
|
commitPath(pathToScreen(target) || [...resolvedPath.value, target], 'push');
|
|
440
507
|
}
|
|
441
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Replaces the active destination with the requested screen.
|
|
511
|
+
*/
|
|
442
512
|
function replace(id) {
|
|
443
513
|
const target = String(id || '');
|
|
444
514
|
if (!target) return;
|
|
445
515
|
commitPath(pathToScreen(target) || [...resolvedPath.value.slice(0, -1), target], 'replace');
|
|
446
516
|
}
|
|
447
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Returns the requested number of levels without leaving the root.
|
|
520
|
+
*/
|
|
448
521
|
function back(steps = 1) {
|
|
449
522
|
const count = Math.max(1, Number(steps) || 1);
|
|
450
523
|
if (resolvedPath.value.length <= 1) return;
|
|
451
524
|
commitPath(resolvedPath.value.slice(0, Math.max(1, resolvedPath.value.length - count)), 'back');
|
|
452
525
|
}
|
|
453
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Navigates to a breadcrumb index or registered screen id.
|
|
529
|
+
*/
|
|
454
530
|
function goTo(target) {
|
|
455
531
|
if (typeof target === 'number') {
|
|
456
532
|
const index = Math.max(0, Math.min(target, resolvedPath.value.length - 1));
|
|
@@ -468,16 +544,25 @@ function goTo(target) {
|
|
|
468
544
|
commitPath(pathToScreen(id) || [...resolvedPath.value, id], 'breadcrumb');
|
|
469
545
|
}
|
|
470
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Resets navigation to the root or another registered destination.
|
|
549
|
+
*/
|
|
471
550
|
function reset(id = rootId.value) {
|
|
472
551
|
const target = String(id || '');
|
|
473
552
|
commitPath(pathToScreen(target) || (target ? [target] : []), 'reset');
|
|
474
553
|
}
|
|
475
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Opens a child under its declared parent path.
|
|
557
|
+
*/
|
|
476
558
|
function openChild(parentId, childId) {
|
|
477
559
|
const parentPath = pathToScreen(parentId) || resolvedPath.value;
|
|
478
560
|
commitPath([...parentPath, String(childId)], 'push');
|
|
479
561
|
}
|
|
480
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Resolves the unique child records belonging to a screen.
|
|
565
|
+
*/
|
|
481
566
|
function childScreensFor(screen) {
|
|
482
567
|
if (!screen) return [];
|
|
483
568
|
const childIds = [
|
|
@@ -489,10 +574,16 @@ function childScreensFor(screen) {
|
|
|
489
574
|
.filter(Boolean);
|
|
490
575
|
}
|
|
491
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Detects the documented function-based lazy screen loader.
|
|
579
|
+
*/
|
|
492
580
|
function isLazyScreenComponent(component) {
|
|
493
581
|
return typeof component === 'function';
|
|
494
582
|
}
|
|
495
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Returns a resolved component without invoking its loader during render.
|
|
586
|
+
*/
|
|
496
587
|
function componentForScreen(screen) {
|
|
497
588
|
if (!screen) return null;
|
|
498
589
|
if (loadedComponents.has(screen.id)) return loadedComponents.get(screen.id);
|
|
@@ -500,6 +591,9 @@ function componentForScreen(screen) {
|
|
|
500
591
|
return screen.component || null;
|
|
501
592
|
}
|
|
502
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Loads each asynchronous screen once and records success or failure.
|
|
596
|
+
*/
|
|
503
597
|
async function ensureLoaded(screen) {
|
|
504
598
|
if (!screen || screen.slot || loadedComponents.has(screen.id) || loadingScreens.has(screen.id)) return;
|
|
505
599
|
const loader = screen.loader || (isLazyScreenComponent(screen.component) ? screen.component : null);
|
|
@@ -521,64 +615,88 @@ async function ensureLoaded(screen) {
|
|
|
521
615
|
}
|
|
522
616
|
}
|
|
523
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Reports whether the requested screen has a pending loader.
|
|
620
|
+
*/
|
|
524
621
|
function isScreenLoading(screen) {
|
|
525
622
|
return !!screen && loadingScreens.has(screen.id);
|
|
526
623
|
}
|
|
527
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Returns the most recent loader error for a screen.
|
|
627
|
+
*/
|
|
528
628
|
function screenError(screen) {
|
|
529
629
|
return screen ? loadErrors.get(screen.id) : null;
|
|
530
630
|
}
|
|
531
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Focuses the visible screen without moving its retained scroll position.
|
|
634
|
+
*/
|
|
532
635
|
function focusActivePanel() {
|
|
533
636
|
if (!props.focusOnNavigate) return;
|
|
534
|
-
screenPanel.value?.focus
|
|
637
|
+
screenPanel.value?.querySelector('[data-dom-app-stack-screen]:not([inert]) [data-dom-app-stack-pane]')?.focus({ preventScroll: true });
|
|
535
638
|
}
|
|
536
639
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (text.startsWith('(')) return text;
|
|
541
|
-
return `(min-width: ${text})`;
|
|
640
|
+
/** Retries the active loader after a recoverable failure. */
|
|
641
|
+
function retryActiveScreen() {
|
|
642
|
+
ensureLoaded(activeScreen.value);
|
|
542
643
|
}
|
|
543
644
|
|
|
645
|
+
/** Observes the actual stack width, including when embedded in a desktop pane or iframe. */
|
|
544
646
|
function configureDesktopMedia() {
|
|
545
|
-
|
|
647
|
+
const element = screenPanel.value;
|
|
648
|
+
const view = element?.ownerDocument.defaultView;
|
|
649
|
+
if (!element || !view) return;
|
|
546
650
|
removeDesktopMediaListener?.();
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
651
|
+
const breakpoint = String(props.desktopBreakpoint || '768px').trim();
|
|
652
|
+
if (breakpoint.startsWith('(')) {
|
|
653
|
+
const query = view.matchMedia(breakpoint);
|
|
654
|
+
/** Synchronizes an explicitly supplied media-query breakpoint. */
|
|
655
|
+
const update = () => { isDesktopMedia.value = query.matches; };
|
|
656
|
+
update();
|
|
657
|
+
query.addEventListener('change', update);
|
|
658
|
+
removeDesktopMediaListener = () => query.removeEventListener('change', update);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const probe = element.ownerDocument.createElement('span');
|
|
662
|
+
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;height:0;';
|
|
663
|
+
probe.style.width = breakpoint;
|
|
664
|
+
element.appendChild(probe);
|
|
665
|
+
/** Resolves a CSS-length breakpoint against the component's own container. */
|
|
666
|
+
const update = () => { isDesktopMedia.value = element.clientWidth >= (probe.getBoundingClientRect().width || 768); };
|
|
667
|
+
const observer = new view.ResizeObserver(update);
|
|
668
|
+
observer.observe(element);
|
|
669
|
+
observer.observe(probe);
|
|
553
670
|
update();
|
|
554
|
-
if (query.addEventListener) query.addEventListener('change', update);
|
|
555
|
-
else query.addListener(update);
|
|
556
671
|
removeDesktopMediaListener = () => {
|
|
557
|
-
|
|
558
|
-
|
|
672
|
+
observer.disconnect();
|
|
673
|
+
probe.remove();
|
|
559
674
|
};
|
|
560
675
|
}
|
|
676
|
+
|
|
561
677
|
</script>
|
|
562
678
|
|
|
563
679
|
<template>
|
|
564
680
|
<div
|
|
565
681
|
data-dom-app-stack
|
|
566
|
-
|
|
682
|
+
ref="screenPanel"
|
|
683
|
+
class="[&_input]:text-base [&_textarea]:text-base [&_select]:text-base relative flex min-h-0 min-w-0 w-full flex-col overflow-hidden bg-canvas text-canvas-fg pt-[var(--dom-safe-area-top,env(safe-area-inset-top,0px))] pb-[var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px))] pl-[var(--dom-safe-area-left,env(safe-area-inset-left,0px))] pr-[var(--dom-safe-area-right,env(safe-area-inset-right,0px))]"
|
|
684
|
+
:style="{ height }"
|
|
567
685
|
>
|
|
568
686
|
<slot
|
|
569
|
-
v-if="showBreadcrumbs"
|
|
687
|
+
v-if="isDesktopLayout && showBreadcrumbs"
|
|
570
688
|
name="breadcrumb"
|
|
571
689
|
v-bind="breadcrumbSlotProps"
|
|
572
690
|
>
|
|
573
691
|
<nav
|
|
574
|
-
class="flex min-h-12 items-center gap-2 border-b border-border bg-canvas/95 px-2 backdrop-blur"
|
|
692
|
+
class="flex min-h-12 shrink-0 items-center gap-2 border-b border-border bg-canvas/95 px-2 backdrop-blur"
|
|
575
693
|
:class="breadcrumbClass"
|
|
576
694
|
aria-label="Settings path"
|
|
577
695
|
>
|
|
578
696
|
<button
|
|
579
697
|
v-if="canGoBack"
|
|
580
698
|
type="button"
|
|
581
|
-
class="inline-flex h-
|
|
699
|
+
class="inline-flex min-h-11 shrink-0 items-center gap-1 rounded-full px-2.5 text-sm font-medium text-canvas-fg transition hover:bg-secondary active:scale-95"
|
|
582
700
|
:class="backButtonClass"
|
|
583
701
|
@click="back()"
|
|
584
702
|
>
|
|
@@ -597,7 +715,7 @@ function configureDesktopMedia() {
|
|
|
597
715
|
<button
|
|
598
716
|
v-if="index < crumbs.length - 1"
|
|
599
717
|
type="button"
|
|
600
|
-
class="max-w-36 truncate rounded-full px-2.5 py-1.5 font-medium text-muted-fg transition hover:bg-secondary hover:text-canvas-fg"
|
|
718
|
+
class="min-h-11 max-w-36 truncate rounded-full px-2.5 py-1.5 font-medium text-muted-fg transition hover:bg-secondary hover:text-canvas-fg"
|
|
601
719
|
:class="breadcrumbItemClass"
|
|
602
720
|
@click="goTo(index)"
|
|
603
721
|
>{{ crumb.title }}</button>
|
|
@@ -621,45 +739,86 @@ function configureDesktopMedia() {
|
|
|
621
739
|
</nav>
|
|
622
740
|
</slot>
|
|
623
741
|
|
|
624
|
-
<div class="relative min-h-0 flex-1 overflow-hidden">
|
|
625
|
-
<Transition
|
|
742
|
+
<div class="relative min-h-0 flex-1 overflow-hidden [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]">
|
|
743
|
+
<Transition
|
|
744
|
+
v-for="[id, screenPath] in visitedPaths"
|
|
745
|
+
:key="id"
|
|
746
|
+
:name="transitionName"
|
|
747
|
+
:appear="hasMounted && animate"
|
|
748
|
+
@after-enter="focusActivePanel"
|
|
749
|
+
>
|
|
626
750
|
<section
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
class="absolute inset-0 min-h-0 overflow-y-auto outline-none [-webkit-overflow-scrolling:touch]"
|
|
632
|
-
:class="[isDesktopLayout ? 'p-4 md:p-6' : 'p-4', isDesktopLayout ? desktopContentClass : mobilePanelClass]"
|
|
633
|
-
:aria-busy="isScreenLoading(activeScreen) ? 'true' : undefined"
|
|
751
|
+
v-show="id === activeId"
|
|
752
|
+
:data-dom-app-stack-screen="id"
|
|
753
|
+
:inert="id !== activeId"
|
|
754
|
+
class="absolute inset-0 flex min-h-0 flex-col bg-canvas"
|
|
634
755
|
>
|
|
635
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
756
|
+
<slot v-if="!isDesktopLayout && showHeader" name="header" v-bind="{ ...breadcrumbSlotProps, activeScreen: screenById.get(id), path: screenPath, canGoBack: screenPath.length > 1 }">
|
|
757
|
+
<header class="grid min-h-11 shrink-0 grid-cols-[1fr_minmax(0,2fr)_1fr] items-center border-b border-border bg-canvas/95 px-2">
|
|
758
|
+
<button
|
|
759
|
+
v-if="screenPath.length > 1"
|
|
760
|
+
type="button"
|
|
761
|
+
class="inline-flex min-h-11 min-w-11 items-center gap-0.5 justify-self-start rounded-lg pr-2 text-sm text-primary active:opacity-60 focus-visible:outline-2 focus-visible:outline-ring"
|
|
762
|
+
:class="backButtonClass"
|
|
763
|
+
:aria-label="`Back to ${screenById.get(screenPath.at(-2))?.title || 'previous page'}`"
|
|
764
|
+
@click="back()"
|
|
765
|
+
>
|
|
766
|
+
<svg class="size-6 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
767
|
+
<path d="M12.7 4.3a1 1 0 0 1 0 1.4L8.42 10l4.3 4.3a1 1 0 0 1-1.42 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.42 0Z" />
|
|
768
|
+
</svg>
|
|
769
|
+
<span>Back</span>
|
|
770
|
+
</button>
|
|
771
|
+
<span v-else aria-hidden="true"></span>
|
|
772
|
+
<h1 class="truncate text-center text-base font-semibold tracking-tight">{{ screenById.get(id)?.title || emptyTitle }}</h1>
|
|
773
|
+
</header>
|
|
774
|
+
</slot>
|
|
775
|
+
<div
|
|
776
|
+
data-dom-app-stack-pane
|
|
777
|
+
tabindex="-1"
|
|
778
|
+
:aria-label="screenById.get(id)?.title"
|
|
779
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none"
|
|
780
|
+
:class="[isDesktopLayout ? 'p-6' : 'p-3', isDesktopLayout ? desktopContentClass : mobilePanelClass]"
|
|
781
|
+
:aria-busy="isScreenLoading(screenById.get(id)) ? 'true' : undefined"
|
|
782
|
+
>
|
|
783
|
+
<StackScreenRenderer
|
|
784
|
+
:screen="screenById.get(id)"
|
|
785
|
+
:component="componentForScreen(screenById.get(id))"
|
|
786
|
+
:helpers="helpers"
|
|
787
|
+
:child-screens="childScreensFor(screenById.get(id))"
|
|
788
|
+
:loading="isScreenLoading(screenById.get(id))"
|
|
789
|
+
:error="screenError(screenById.get(id))"
|
|
790
|
+
:loading-label="loadingLabel"
|
|
791
|
+
:empty-title="emptyTitle"
|
|
792
|
+
:empty-description="emptyDescription"
|
|
793
|
+
/>
|
|
794
|
+
</div>
|
|
646
795
|
</section>
|
|
647
796
|
</Transition>
|
|
797
|
+
<div v-if="!activeScreen" class="p-6 text-center text-sm text-muted-fg">{{ emptyTitle }}</div>
|
|
648
798
|
</div>
|
|
649
799
|
</div>
|
|
650
800
|
</template>
|
|
651
801
|
|
|
652
802
|
<style scoped>
|
|
653
|
-
[data-dom-app-stack-
|
|
803
|
+
[data-dom-app-stack-screen] {
|
|
654
804
|
backface-visibility: hidden;
|
|
655
|
-
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.dom-app-stack-forward-enter-active,
|
|
808
|
+
.dom-app-stack-back-leave-active {
|
|
809
|
+
z-index: 2;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.dom-app-stack-forward-leave-active,
|
|
813
|
+
.dom-app-stack-back-enter-active {
|
|
814
|
+
z-index: 1;
|
|
656
815
|
}
|
|
657
816
|
|
|
658
817
|
.dom-app-stack-forward-enter-active,
|
|
659
818
|
.dom-app-stack-back-enter-active {
|
|
660
819
|
pointer-events: none;
|
|
661
820
|
transition:
|
|
662
|
-
transform
|
|
821
|
+
transform 320ms cubic-bezier(0.32, 0.72, 0, 1),
|
|
663
822
|
opacity 220ms ease;
|
|
664
823
|
}
|
|
665
824
|
|
|
@@ -667,7 +826,7 @@ function configureDesktopMedia() {
|
|
|
667
826
|
.dom-app-stack-back-leave-active {
|
|
668
827
|
pointer-events: none;
|
|
669
828
|
transition:
|
|
670
|
-
transform
|
|
829
|
+
transform 320ms cubic-bezier(0.32, 0.72, 0, 1),
|
|
671
830
|
opacity 140ms ease 180ms;
|
|
672
831
|
}
|
|
673
832
|
|