@getdom/studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,1561 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useSlots, watch } from 'vue';
3
+ import DomIconButton from '../icon-button/DomIconButton.vue';
4
+
5
+ const DEFAULT_DURATION_MS = 420;
6
+ const TRANSITION_BUFFER_MS = 80;
7
+ const STAGE_HANDOFF_MS = 180;
8
+ const FRAME_TIMEOUT_MS = 80;
9
+ const VIEWPORT_MARGIN_PX = 24;
10
+ const MIN_STAGE_WIDTH_PX = 220;
11
+ const MIN_STAGE_HEIGHT_PX = 160;
12
+ const DEFAULT_STAGE_WIDTH_PX = 960;
13
+ const DEFAULT_ASPECT_RATIO = 16 / 10;
14
+ const STAGE_VERTICAL_CHROME_PX = 176;
15
+ const CLOSE_ICON = 'M6 6l12 12M18 6L6 18';
16
+ const PREVIOUS_ICON = 'M15 18l-6-6l6-6';
17
+ const NEXT_ICON = 'M9 6l6 6l-6 6';
18
+
19
+ const DEFAULT_RECTS = {
20
+ from: createMotionRect(0, 0, 1, 1),
21
+ to: createMotionRect(0, 0, 1, 1),
22
+ };
23
+
24
+ /**
25
+ * @typedef {{ left: number, top: number, width: number, height: number }} LightboxMotionRect
26
+ */
27
+
28
+ defineOptions({
29
+ __doc: {
30
+ name: 'Lightbox',
31
+ tag: '<DomLightbox>',
32
+ description: 'A gallery overlay that opens from a thumbnail into a stable preview stage, then flies back to the origin on close.',
33
+ slots: [
34
+ { name: '(default)', payload: '{ item, index, close, next, previous, goTo, phase }', description: 'Optional custom stage content. Omit for the built-in image preview.' },
35
+ { name: 'caption', payload: '{ item, index }', description: 'Optional caption content below the stage.' },
36
+ { name: 'thumbnail', payload: '{ item, index, active }', description: 'Optional thumbnail strip rendering.' },
37
+ ],
38
+ events: [
39
+ { name: 'update:modelValue', payload: '(open: boolean)', description: 'Emitted when the lightbox requests an open-state change.' },
40
+ { name: 'update:index', payload: '(index: number)', description: 'Emitted when navigation changes the active item.' },
41
+ { name: 'close', description: 'Emitted when the close transition begins.' },
42
+ { name: 'after-enter', description: 'Emitted when the thumbnail-to-stage transition completes.' },
43
+ { name: 'after-leave', description: 'Emitted when the fly-back transition completes and retained data is released.' },
44
+ { name: 'navigate', payload: '({ index, item, direction })', description: 'Emitted after arrow or thumbnail navigation.' },
45
+ ],
46
+ keyboard: [
47
+ { keys: 'Esc', action: 'Closes the lightbox.' },
48
+ { keys: 'Left / Right', action: 'Moves between gallery items.' },
49
+ { keys: 'Click backdrop', action: 'Closes the lightbox when closeOnBackdrop is true.' },
50
+ ],
51
+ },
52
+ });
53
+
54
+ const props = defineProps({
55
+ modelValue: {
56
+ type: Boolean,
57
+ default: false,
58
+ _edit: { description: 'Whether the lightbox overlay is open.' },
59
+ },
60
+ items: {
61
+ type: Array,
62
+ default: () => [],
63
+ _edit: { description: 'Gallery items. Image items usually provide src, thumbnail, alt, title, width, and height.' },
64
+ },
65
+ index: {
66
+ type: Number,
67
+ default: 0,
68
+ _edit: { description: 'Active gallery item index.' },
69
+ },
70
+ origin: {
71
+ type: [Object, String, Function],
72
+ default: null,
73
+ _edit: { description: 'Element, Vue ref, selector, DOMRect, or resolver used as the thumbnail origin.' },
74
+ },
75
+ label: {
76
+ type: String,
77
+ default: 'Image preview',
78
+ _edit: { description: 'Accessible label for the overlay.' },
79
+ },
80
+ showThumbnails: {
81
+ type: Boolean,
82
+ default: true,
83
+ _edit: { description: 'Show a thumbnail navigation strip when multiple items are available.' },
84
+ },
85
+ loop: {
86
+ type: Boolean,
87
+ default: true,
88
+ _edit: { description: 'Wrap previous/next navigation at the ends of the gallery.' },
89
+ },
90
+ closeOnBackdrop: {
91
+ type: Boolean,
92
+ default: true,
93
+ _edit: { description: 'Close when the dimmed backdrop is clicked.' },
94
+ },
95
+ duration: {
96
+ type: Number,
97
+ default: 420,
98
+ _edit: { description: 'Thumbnail flyout transition duration in milliseconds.' },
99
+ },
100
+ });
101
+ const emit = defineEmits(['update:modelValue', 'update:index', 'close', 'after-enter', 'after-leave', 'navigate']);
102
+ const slots = useSlots();
103
+
104
+ const root = ref(null);
105
+ const rendered = ref(false);
106
+ const phase = ref('idle');
107
+ const stageVisible = ref(false);
108
+ const ghostVisible = ref(false);
109
+ const retainedItems = shallowRef([]);
110
+ const retainedIndex = ref(0);
111
+ const lastOriginRect = ref(null);
112
+ const motionRects = ref(DEFAULT_RECTS);
113
+ const closeEmitted = ref(false);
114
+ let motionRun = 0;
115
+ let phaseTimer = null;
116
+ let previousActiveElement = null;
117
+ let restoreBodyOverflow = '';
118
+ let restoreBodyPaddingRight = '';
119
+
120
+ const uniqueId = useId();
121
+ const captionId = computed(getCaptionId);
122
+ const activeItems = computed(getActiveItems);
123
+ const activeIndex = computed(getActiveIndex);
124
+ const activeItem = computed(getActiveItem);
125
+ const hasItems = computed(getHasItems);
126
+ const hasMultipleItems = computed(getHasMultipleItems);
127
+ const activeSrc = computed(getActiveSrc);
128
+ const activeThumbnail = computed(getActiveThumbnail);
129
+ const activeAlt = computed(getActiveAlt);
130
+ const activeTitle = computed(getActiveTitle);
131
+ const activeDescription = computed(getActiveDescription);
132
+ const shouldShowCaption = computed(getShouldShowCaption);
133
+ const shouldShowThumbnails = computed(getShouldShowThumbnails);
134
+ const lightboxStyle = computed(getLightboxStyle);
135
+ const slotState = computed(getSlotState);
136
+
137
+ watch(readModelValue, syncModelValue);
138
+ watch(readIndex, syncIndex);
139
+ watch(readItems, syncItems, { deep: true });
140
+
141
+ onMounted(handleMounted);
142
+ onBeforeUnmount(handleBeforeUnmount);
143
+
144
+ /**
145
+ * Synchronize initial controlled state once the component reaches the browser.
146
+ *
147
+ * @returns {void}
148
+ */
149
+ function handleMounted() {
150
+ if (props.modelValue) openLightbox();
151
+ }
152
+
153
+ /**
154
+ * Release timers, listeners, and scroll locks before the component is removed.
155
+ *
156
+ * @returns {void}
157
+ */
158
+ function handleBeforeUnmount() {
159
+ clearPhaseTimer();
160
+ removeGlobalListeners();
161
+ unlockBodyScroll();
162
+ }
163
+
164
+ /**
165
+ * Read the controlled open state for the model watcher.
166
+ *
167
+ * @returns {boolean} Current open state.
168
+ */
169
+ function readModelValue() {
170
+ return props.modelValue;
171
+ }
172
+
173
+ /**
174
+ * Read the controlled active index for the index watcher.
175
+ *
176
+ * @returns {number} Current active index.
177
+ */
178
+ function readIndex() {
179
+ return props.index;
180
+ }
181
+
182
+ /**
183
+ * Read the item collection for the item watcher.
184
+ *
185
+ * @returns {Array<unknown>} Current item collection.
186
+ */
187
+ function readItems() {
188
+ return props.items;
189
+ }
190
+
191
+ /**
192
+ * Apply controlled model changes to the lightbox lifecycle.
193
+ *
194
+ * @param {boolean} open Whether the lightbox should be open.
195
+ * @returns {void}
196
+ */
197
+ function syncModelValue(open) {
198
+ if (open) openLightbox();
199
+ else closeLightbox('model');
200
+ }
201
+
202
+ /**
203
+ * Keep the retained index aligned while the overlay is visible.
204
+ *
205
+ * @param {number} index Next controlled index.
206
+ * @returns {void}
207
+ */
208
+ function syncIndex(index) {
209
+ if (!rendered.value) return;
210
+ retainedIndex.value = normalizeIndex(index, activeItems.value.length);
211
+ updateTargetGeometry();
212
+ }
213
+
214
+ /**
215
+ * Refresh retained gallery items while the lightbox is not leaving.
216
+ *
217
+ * @returns {void}
218
+ */
219
+ function syncItems() {
220
+ if (!rendered.value || isClosingPhase()) return;
221
+ retainLiveItems();
222
+ updateTargetGeometry();
223
+ }
224
+
225
+ /**
226
+ * Build the generated caption id when caption content exists.
227
+ *
228
+ * @returns {string} Caption element id or an empty string.
229
+ */
230
+ function getCaptionId() {
231
+ return shouldShowCaption.value ? `dom-lightbox-${uniqueId}-caption` : '';
232
+ }
233
+
234
+ /**
235
+ * Return the live or retained item collection.
236
+ *
237
+ * @returns {Array<unknown>} Items available to the overlay.
238
+ */
239
+ function getActiveItems() {
240
+ return rendered.value ? retainedItems.value : normalizedItems();
241
+ }
242
+
243
+ /**
244
+ * Return the live or retained active index.
245
+ *
246
+ * @returns {number} Active item index.
247
+ */
248
+ function getActiveIndex() {
249
+ return rendered.value ? retainedIndex.value : normalizeIndex(props.index, normalizedItems().length);
250
+ }
251
+
252
+ /**
253
+ * Return the active item record.
254
+ *
255
+ * @returns {unknown|null} Current gallery item.
256
+ */
257
+ function getActiveItem() {
258
+ return activeItems.value[activeIndex.value] || null;
259
+ }
260
+
261
+ /**
262
+ * Check whether there is at least one lightbox item.
263
+ *
264
+ * @returns {boolean} Whether an item is available.
265
+ */
266
+ function getHasItems() {
267
+ return activeItems.value.length > 0;
268
+ }
269
+
270
+ /**
271
+ * Check whether navigation controls should be available.
272
+ *
273
+ * @returns {boolean} Whether more than one item exists.
274
+ */
275
+ function getHasMultipleItems() {
276
+ return activeItems.value.length > 1;
277
+ }
278
+
279
+ /**
280
+ * Resolve the active item image source.
281
+ *
282
+ * @returns {string} Image source.
283
+ */
284
+ function getActiveSrc() {
285
+ return imageSource(activeItem.value);
286
+ }
287
+
288
+ /**
289
+ * Resolve the active item thumbnail source.
290
+ *
291
+ * @returns {string} Thumbnail image source.
292
+ */
293
+ function getActiveThumbnail() {
294
+ return thumbnailSource(activeItem.value);
295
+ }
296
+
297
+ /**
298
+ * Resolve the active item alt text.
299
+ *
300
+ * @returns {string} Image alt text.
301
+ */
302
+ function getActiveAlt() {
303
+ return stringField(activeItem.value, 'alt') || stringField(activeItem.value, 'title') || '';
304
+ }
305
+
306
+ /**
307
+ * Resolve the active item title.
308
+ *
309
+ * @returns {string} Item title.
310
+ */
311
+ function getActiveTitle() {
312
+ return stringField(activeItem.value, 'title');
313
+ }
314
+
315
+ /**
316
+ * Resolve the active item description.
317
+ *
318
+ * @returns {string} Item description.
319
+ */
320
+ function getActiveDescription() {
321
+ return stringField(activeItem.value, 'description');
322
+ }
323
+
324
+ /**
325
+ * Check whether caption content should render.
326
+ *
327
+ * @returns {boolean} Whether the caption region should show.
328
+ */
329
+ function getShouldShowCaption() {
330
+ return Boolean(slots.caption || activeTitle.value || activeDescription.value);
331
+ }
332
+
333
+ /**
334
+ * Check whether the thumbnail strip should render.
335
+ *
336
+ * @returns {boolean} Whether the thumbnail strip should show.
337
+ */
338
+ function getShouldShowThumbnails() {
339
+ return Boolean(props.showThumbnails && hasMultipleItems.value);
340
+ }
341
+
342
+ /**
343
+ * Build overlay-level CSS variables for the active motion run.
344
+ *
345
+ * @returns {Record<string, string>} CSS custom properties.
346
+ */
347
+ function getLightboxStyle() {
348
+ const from = motionRects.value.from;
349
+ const to = motionRects.value.to;
350
+
351
+ return {
352
+ '--dom-lightbox-duration': `${transitionDuration()}ms`,
353
+ '--dom-lightbox-from-left': `${from.left}px`,
354
+ '--dom-lightbox-from-top': `${from.top}px`,
355
+ '--dom-lightbox-from-width': `${from.width}px`,
356
+ '--dom-lightbox-from-height': `${from.height}px`,
357
+ '--dom-lightbox-to-left': `${to.left}px`,
358
+ '--dom-lightbox-to-top': `${to.top}px`,
359
+ '--dom-lightbox-to-width': `${to.width}px`,
360
+ '--dom-lightbox-to-height': `${to.height}px`,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Build slot helpers for custom stage, caption, and thumbnail content.
366
+ *
367
+ * @returns {Record<string, unknown>} Slot state.
368
+ */
369
+ function getSlotState() {
370
+ return {
371
+ item: activeItem.value,
372
+ index: activeIndex.value,
373
+ phase: phase.value,
374
+ close: closeFromSlot,
375
+ next: goToNext,
376
+ previous: goToPrevious,
377
+ goTo: goToIndex,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Start the thumbnail-to-stage lightbox transition.
383
+ *
384
+ * @returns {Promise<void>}
385
+ */
386
+ async function openLightbox() {
387
+ const run = ++motionRun;
388
+ clearPhaseTimer();
389
+ retainLiveItems();
390
+ if (!hasItems.value) return;
391
+
392
+ closeEmitted.value = false;
393
+ rendered.value = true;
394
+ stageVisible.value = false;
395
+ ghostVisible.value = true;
396
+ phase.value = 'enter-from';
397
+ previousActiveElement = activeElement();
398
+
399
+ await nextTick();
400
+ if (!isCurrentRun(run)) return;
401
+
402
+ lockBodyScroll();
403
+ addGlobalListeners();
404
+ measureMotionRects();
405
+ focusRoot();
406
+
407
+ await waitForPaint();
408
+ if (!isCurrentRun(run)) return;
409
+
410
+ phase.value = 'opening';
411
+ phaseTimer = window.setTimeout(handleEnterTimer, transitionDuration() + TRANSITION_BUFFER_MS);
412
+
413
+ /**
414
+ * Complete the enter transition scheduled for this motion run.
415
+ *
416
+ * @returns {void}
417
+ */
418
+ function handleEnterTimer() {
419
+ void finishEnter(run);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Start the handoff from the moving thumbnail clone to the stable stage.
425
+ *
426
+ * @param {number} run Motion sequence id.
427
+ * @returns {Promise<void>}
428
+ */
429
+ async function finishEnter(run) {
430
+ if (!isCurrentRun(run)) return;
431
+ clearPhaseTimer();
432
+ stageVisible.value = true;
433
+ await nextTick();
434
+ await waitForPaint();
435
+ if (!isCurrentRun(run)) return;
436
+
437
+ phaseTimer = window.setTimeout(handleStageHandoffTimer, stageHandoffDuration());
438
+
439
+ /**
440
+ * Complete the stage handoff scheduled for this motion run.
441
+ *
442
+ * @returns {void}
443
+ */
444
+ function handleStageHandoffTimer() {
445
+ void finishEnterHandoff(run);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Remove the moving clone after the stable image has painted underneath it.
451
+ *
452
+ * @param {number} run Motion sequence id.
453
+ * @returns {Promise<void>}
454
+ */
455
+ async function finishEnterHandoff(run) {
456
+ if (!isCurrentRun(run)) return;
457
+ clearPhaseTimer();
458
+ ghostVisible.value = false;
459
+ phase.value = 'open';
460
+ await nextTick();
461
+ if (!isCurrentRun(run)) return;
462
+ emit('after-enter');
463
+ }
464
+
465
+ /**
466
+ * Request a user-driven close from slotted content.
467
+ *
468
+ * @returns {void}
469
+ */
470
+ function closeFromSlot() {
471
+ requestClose('slot');
472
+ }
473
+
474
+ /**
475
+ * Request a controlled close and notify the parent model.
476
+ *
477
+ * @param {string} reason Close interaction source.
478
+ * @returns {void}
479
+ */
480
+ function requestClose(reason = 'user') {
481
+ emit('update:modelValue', false);
482
+ closeLightbox(reason);
483
+ }
484
+
485
+ /**
486
+ * Start the stage-to-thumbnail close transition.
487
+ *
488
+ * @param {string} reason Close interaction source.
489
+ * @returns {Promise<void>}
490
+ */
491
+ async function closeLightbox(reason = 'model') {
492
+ if (!rendered.value || isClosingPhase()) return;
493
+
494
+ const run = ++motionRun;
495
+ clearPhaseTimer();
496
+ void reason;
497
+ retainLiveItems();
498
+ measureMotionRects();
499
+ ghostVisible.value = true;
500
+ stageVisible.value = false;
501
+ phase.value = 'closing-from';
502
+ emitCloseOnce();
503
+
504
+ await nextTick();
505
+ await waitForPaint();
506
+ if (!isCurrentRun(run)) return;
507
+
508
+ phase.value = 'closing';
509
+ phaseTimer = window.setTimeout(handleLeaveTimer, transitionDuration() + TRANSITION_BUFFER_MS);
510
+
511
+ /**
512
+ * Complete the leave transition scheduled for this motion run.
513
+ *
514
+ * @returns {void}
515
+ */
516
+ function handleLeaveTimer() {
517
+ void finishLeave(run);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Complete the close transition and release retained content.
523
+ *
524
+ * @param {number} run Motion sequence id.
525
+ * @returns {Promise<void>}
526
+ */
527
+ async function finishLeave(run) {
528
+ if (!isCurrentRun(run)) return;
529
+ clearPhaseTimer();
530
+ rendered.value = false;
531
+ stageVisible.value = false;
532
+ ghostVisible.value = false;
533
+ phase.value = 'idle';
534
+ retainedItems.value = [];
535
+ removeGlobalListeners();
536
+ unlockBodyScroll();
537
+ restoreFocus();
538
+ await nextTick();
539
+ if (!isCurrentRun(run)) return;
540
+ emit('after-leave');
541
+ }
542
+
543
+ /**
544
+ * Emit the close event once per visible lightbox cycle.
545
+ *
546
+ * @returns {void}
547
+ */
548
+ function emitCloseOnce() {
549
+ if (closeEmitted.value) return;
550
+ closeEmitted.value = true;
551
+ emit('close');
552
+ }
553
+
554
+ /**
555
+ * Move to the previous gallery item.
556
+ *
557
+ * @returns {void}
558
+ */
559
+ function goToPrevious() {
560
+ goToIndex(activeIndex.value - 1, 'previous');
561
+ }
562
+
563
+ /**
564
+ * Move to the next gallery item.
565
+ *
566
+ * @returns {void}
567
+ */
568
+ function goToNext() {
569
+ goToIndex(activeIndex.value + 1, 'next');
570
+ }
571
+
572
+ /**
573
+ * Navigate to a specific gallery item.
574
+ *
575
+ * @param {number} nextIndex Requested item index.
576
+ * @param {string} direction Navigation direction.
577
+ * @returns {void}
578
+ */
579
+ function goToIndex(nextIndex, direction = 'select') {
580
+ const normalized = normalizeNavigationIndex(nextIndex, activeItems.value.length);
581
+ if (normalized === activeIndex.value || normalized < 0) return;
582
+ retainedIndex.value = normalized;
583
+ emit('update:index', normalized);
584
+ emit('navigate', {
585
+ index: normalized,
586
+ item: activeItems.value[normalized] || null,
587
+ direction,
588
+ });
589
+ updateTargetGeometry();
590
+ }
591
+
592
+ /**
593
+ * Handle backdrop clicks.
594
+ *
595
+ * @returns {void}
596
+ */
597
+ function onBackdropClick() {
598
+ if (!props.closeOnBackdrop) return;
599
+ requestClose('backdrop');
600
+ }
601
+
602
+ /**
603
+ * Handle key presses while focus is inside the overlay.
604
+ *
605
+ * @param {KeyboardEvent} event Keyboard event.
606
+ * @returns {void}
607
+ */
608
+ function onRootKeydown(event) {
609
+ event.stopPropagation();
610
+ handleLightboxKeydown(event);
611
+ }
612
+
613
+ /**
614
+ * Handle key presses registered on the window while the overlay is open.
615
+ *
616
+ * @param {KeyboardEvent} event Keyboard event.
617
+ * @returns {void}
618
+ */
619
+ function onWindowKeydown(event) {
620
+ handleLightboxKeydown(event);
621
+ }
622
+
623
+ /**
624
+ * Apply keyboard shortcuts for close and gallery navigation.
625
+ *
626
+ * @param {KeyboardEvent} event Keyboard event.
627
+ * @returns {void}
628
+ */
629
+ function handleLightboxKeydown(event) {
630
+ if (!rendered.value) return;
631
+ if (event.key === 'Escape') {
632
+ event.preventDefault();
633
+ requestClose('escape');
634
+ return;
635
+ }
636
+ if (event.key === 'ArrowLeft') {
637
+ event.preventDefault();
638
+ goToPrevious();
639
+ return;
640
+ }
641
+ if (event.key === 'ArrowRight') {
642
+ event.preventDefault();
643
+ goToNext();
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Update viewport geometry when the window size changes.
649
+ *
650
+ * @returns {void}
651
+ */
652
+ function onWindowResize() {
653
+ updateTargetGeometry();
654
+ }
655
+
656
+ /**
657
+ * Add global listeners used while the lightbox is visible.
658
+ *
659
+ * @returns {void}
660
+ */
661
+ function addGlobalListeners() {
662
+ if (typeof window === 'undefined') return;
663
+ removeGlobalListeners();
664
+ window.addEventListener('keydown', onWindowKeydown);
665
+ window.addEventListener('resize', onWindowResize);
666
+ }
667
+
668
+ /**
669
+ * Remove global listeners used while the lightbox is visible.
670
+ *
671
+ * @returns {void}
672
+ */
673
+ function removeGlobalListeners() {
674
+ if (typeof window === 'undefined') return;
675
+ window.removeEventListener('keydown', onWindowKeydown);
676
+ window.removeEventListener('resize', onWindowResize);
677
+ }
678
+
679
+ /**
680
+ * Retain the current items and index so parent resets do not break close motion.
681
+ *
682
+ * @returns {void}
683
+ */
684
+ function retainLiveItems() {
685
+ const items = normalizedItems();
686
+ retainedItems.value = items.slice();
687
+ retainedIndex.value = normalizeIndex(props.index, items.length);
688
+ }
689
+
690
+ /**
691
+ * Normalize the incoming item prop.
692
+ *
693
+ * @returns {Array<unknown>} Safe item list.
694
+ */
695
+ function normalizedItems() {
696
+ return Array.isArray(props.items) ? props.items : [];
697
+ }
698
+
699
+ /**
700
+ * Normalize an index against a gallery length.
701
+ *
702
+ * @param {number} index Requested index.
703
+ * @param {number} length Gallery length.
704
+ * @returns {number} Safe index.
705
+ */
706
+ function normalizeIndex(index, length) {
707
+ if (length <= 0) return 0;
708
+ const numeric = Number(index);
709
+ if (!Number.isFinite(numeric)) return 0;
710
+ return Math.min(length - 1, Math.max(0, Math.trunc(numeric)));
711
+ }
712
+
713
+ /**
714
+ * Normalize navigation with optional looping.
715
+ *
716
+ * @param {number} index Requested index.
717
+ * @param {number} length Gallery length.
718
+ * @returns {number} Safe index, or -1 when navigation should not move.
719
+ */
720
+ function normalizeNavigationIndex(index, length) {
721
+ if (length <= 0) return -1;
722
+ if (props.loop) return (Math.trunc(index) + length) % length;
723
+ if (index < 0 || index >= length) return -1;
724
+ return Math.trunc(index);
725
+ }
726
+
727
+ /**
728
+ * Measure the current origin and viewport target rectangles.
729
+ *
730
+ * @returns {void}
731
+ */
732
+ function measureMotionRects() {
733
+ const to = targetRectForItem(activeItem.value);
734
+ const from = resolveOriginRect(props.origin) || lastOriginRect.value || centeredFallbackOrigin(to);
735
+ lastOriginRect.value = from;
736
+ motionRects.value = { from, to };
737
+ }
738
+
739
+ /**
740
+ * Update only the target stage geometry after resize or navigation.
741
+ *
742
+ * @returns {void}
743
+ */
744
+ function updateTargetGeometry() {
745
+ if (!rendered.value || typeof window === 'undefined') return;
746
+ const to = targetRectForItem(activeItem.value);
747
+ motionRects.value = {
748
+ from: motionRects.value.from,
749
+ to,
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Resolve any supported origin prop into a viewport rectangle.
755
+ *
756
+ * @param {unknown} value Origin prop value.
757
+ * @returns {LightboxMotionRect|null} Origin rectangle.
758
+ */
759
+ function resolveOriginRect(value) {
760
+ const resolved = unwrapOriginValue(value);
761
+ if (!resolved || typeof window === 'undefined') return null;
762
+ if (typeof resolved === 'string') return normalizeMotionRect(document.querySelector(resolved)?.getBoundingClientRect());
763
+ if (isElementLike(resolved)) return normalizeMotionRect(resolved.getBoundingClientRect());
764
+ if (isRectLike(resolved)) return normalizeMotionRect(resolved);
765
+ return null;
766
+ }
767
+
768
+ /**
769
+ * Resolve functions and Vue refs before measuring an origin.
770
+ *
771
+ * @param {unknown} value Origin prop value.
772
+ * @returns {unknown} Resolved origin value.
773
+ */
774
+ function unwrapOriginValue(value) {
775
+ if (typeof value === 'function') return unwrapOriginValue(value(originContext()));
776
+ if (value && typeof value === 'object' && 'value' in value) return unwrapOriginValue(value.value);
777
+ return value;
778
+ }
779
+
780
+ /**
781
+ * Build the current origin resolver context.
782
+ *
783
+ * @returns {{ item: unknown|null, index: number }} Origin resolver context.
784
+ */
785
+ function originContext() {
786
+ return {
787
+ item: activeItem.value,
788
+ index: activeIndex.value,
789
+ };
790
+ }
791
+
792
+ /**
793
+ * Check whether a value behaves like a DOM element for measurement.
794
+ *
795
+ * @param {unknown} value Candidate value.
796
+ * @returns {value is Element} Whether the value is an element.
797
+ */
798
+ function isElementLike(value) {
799
+ return Boolean(value && typeof value === 'object' && typeof value.getBoundingClientRect === 'function');
800
+ }
801
+
802
+ /**
803
+ * Check whether a value contains rectangle geometry.
804
+ *
805
+ * @param {unknown} value Candidate value.
806
+ * @returns {boolean} Whether the value is rect-like.
807
+ */
808
+ function isRectLike(value) {
809
+ return Boolean(
810
+ value
811
+ && typeof value === 'object'
812
+ && Number.isFinite(value.left)
813
+ && Number.isFinite(value.top)
814
+ && Number.isFinite(value.width)
815
+ && Number.isFinite(value.height),
816
+ );
817
+ }
818
+
819
+ /**
820
+ * Normalize a measured rectangle into motion-safe geometry.
821
+ *
822
+ * @param {unknown} rect Candidate rectangle.
823
+ * @returns {LightboxMotionRect|null} Motion rectangle.
824
+ */
825
+ function normalizeMotionRect(rect) {
826
+ if (!isRectLike(rect)) return null;
827
+ return createMotionRect(rect.left, rect.top, rect.width, rect.height);
828
+ }
829
+
830
+ /**
831
+ * Build a small centered origin when no source thumbnail is available.
832
+ *
833
+ * @param {LightboxMotionRect} target Target stage rectangle.
834
+ * @returns {LightboxMotionRect} Fallback origin.
835
+ */
836
+ function centeredFallbackOrigin(target) {
837
+ return createMotionRect(
838
+ target.left + target.width / 2 - 1,
839
+ target.top + target.height / 2 - 1,
840
+ 2,
841
+ 2,
842
+ );
843
+ }
844
+
845
+ /**
846
+ * Calculate the viewport stage rectangle for an item.
847
+ *
848
+ * @param {unknown} item Active item.
849
+ * @returns {LightboxMotionRect} Target stage rectangle.
850
+ */
851
+ function targetRectForItem(item) {
852
+ if (typeof window === 'undefined') return createMotionRect(0, 0, DEFAULT_STAGE_WIDTH_PX, DEFAULT_STAGE_WIDTH_PX / DEFAULT_ASPECT_RATIO);
853
+
854
+ const viewportWidth = Math.max(MIN_STAGE_WIDTH_PX, window.innerWidth || DEFAULT_STAGE_WIDTH_PX);
855
+ const viewportHeight = Math.max(MIN_STAGE_HEIGHT_PX, window.innerHeight || DEFAULT_STAGE_WIDTH_PX / DEFAULT_ASPECT_RATIO);
856
+ const maxWidth = Math.max(MIN_STAGE_WIDTH_PX, viewportWidth - VIEWPORT_MARGIN_PX * 2);
857
+ const reservedChrome = chromeHeight();
858
+ const maxHeight = Math.max(MIN_STAGE_HEIGHT_PX, viewportHeight - VIEWPORT_MARGIN_PX * 2 - reservedChrome);
859
+ const ratio = aspectRatioForItem(item);
860
+ let width = Math.min(maxWidth, DEFAULT_STAGE_WIDTH_PX);
861
+ let height = width / ratio;
862
+
863
+ if (height > maxHeight) {
864
+ height = maxHeight;
865
+ width = height * ratio;
866
+ }
867
+
868
+ return createMotionRect(
869
+ Math.max(VIEWPORT_MARGIN_PX, (viewportWidth - width) / 2),
870
+ Math.max(VIEWPORT_MARGIN_PX + 52, (viewportHeight - height) / 2 - (shouldShowThumbnails.value ? 28 : 0)),
871
+ width,
872
+ height,
873
+ );
874
+ }
875
+
876
+ /**
877
+ * Resolve the amount of vertical UI chrome to reserve around the stage.
878
+ *
879
+ * @returns {number} Vertical chrome height in pixels.
880
+ */
881
+ function chromeHeight() {
882
+ let height = 104;
883
+ if (shouldShowCaption.value) height += 44;
884
+ if (shouldShowThumbnails.value) height += 72;
885
+ return Math.min(STAGE_VERTICAL_CHROME_PX, height);
886
+ }
887
+
888
+ /**
889
+ * Resolve an item aspect ratio.
890
+ *
891
+ * @param {unknown} item Gallery item.
892
+ * @returns {number} Positive aspect ratio.
893
+ */
894
+ function aspectRatioForItem(item) {
895
+ const explicit = numberField(item, 'aspectRatio');
896
+ if (explicit && explicit > 0) return explicit;
897
+ const width = numberField(item, 'width');
898
+ const height = numberField(item, 'height');
899
+ if (width && height && width > 0 && height > 0) return width / height;
900
+ return DEFAULT_ASPECT_RATIO;
901
+ }
902
+
903
+ /**
904
+ * Create a motion rectangle with positive dimensions.
905
+ *
906
+ * @param {number} left Left coordinate.
907
+ * @param {number} top Top coordinate.
908
+ * @param {number} width Rectangle width.
909
+ * @param {number} height Rectangle height.
910
+ * @returns {LightboxMotionRect} Motion rectangle.
911
+ */
912
+ function createMotionRect(left, top, width, height) {
913
+ return {
914
+ left,
915
+ top,
916
+ width: Math.max(1, width),
917
+ height: Math.max(1, height),
918
+ };
919
+ }
920
+
921
+ /**
922
+ * Resolve an image source from a gallery item.
923
+ *
924
+ * @param {unknown} item Gallery item.
925
+ * @returns {string} Image source.
926
+ */
927
+ function imageSource(item) {
928
+ return stringField(item, 'src') || stringField(item, 'href') || '';
929
+ }
930
+
931
+ /**
932
+ * Resolve a thumbnail source from a gallery item.
933
+ *
934
+ * @param {unknown} item Gallery item.
935
+ * @returns {string} Thumbnail source.
936
+ */
937
+ function thumbnailSource(item) {
938
+ return stringField(item, 'thumbnail') || imageSource(item);
939
+ }
940
+
941
+ /**
942
+ * Resolve a stable key for a gallery item.
943
+ *
944
+ * @param {unknown} item Gallery item.
945
+ * @param {number} index Item index.
946
+ * @returns {string|number} Stable key.
947
+ */
948
+ function itemKey(item, index) {
949
+ return stringField(item, 'id') || imageSource(item) || index;
950
+ }
951
+
952
+ /**
953
+ * Resolve a string field from an item.
954
+ *
955
+ * @param {unknown} item Gallery item.
956
+ * @param {string} key Field key.
957
+ * @returns {string} Field value.
958
+ */
959
+ function stringField(item, key) {
960
+ if (!item || typeof item !== 'object') return '';
961
+ const value = item[key];
962
+ return typeof value === 'string' ? value : '';
963
+ }
964
+
965
+ /**
966
+ * Resolve a number field from an item.
967
+ *
968
+ * @param {unknown} item Gallery item.
969
+ * @param {string} key Field key.
970
+ * @returns {number} Field value.
971
+ */
972
+ function numberField(item, key) {
973
+ if (!item || typeof item !== 'object') return 0;
974
+ const value = item[key];
975
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
976
+ if (typeof value === 'string') {
977
+ const parsed = Number.parseFloat(value);
978
+ return Number.isFinite(parsed) ? parsed : 0;
979
+ }
980
+ return 0;
981
+ }
982
+
983
+ /**
984
+ * Lock body scroll while the fixed overlay is visible.
985
+ *
986
+ * @returns {void}
987
+ */
988
+ function lockBodyScroll() {
989
+ if (typeof document === 'undefined' || restoreBodyOverflow) return;
990
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
991
+ restoreBodyOverflow = document.body.style.overflow || ' ';
992
+ restoreBodyPaddingRight = document.body.style.paddingRight || ' ';
993
+ document.body.style.overflow = 'hidden';
994
+ if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
995
+ }
996
+
997
+ /**
998
+ * Restore body scroll styles after the overlay closes.
999
+ *
1000
+ * @returns {void}
1001
+ */
1002
+ function unlockBodyScroll() {
1003
+ if (typeof document === 'undefined' || !restoreBodyOverflow) return;
1004
+ document.body.style.overflow = restoreBodyOverflow === ' ' ? '' : restoreBodyOverflow;
1005
+ document.body.style.paddingRight = restoreBodyPaddingRight === ' ' ? '' : restoreBodyPaddingRight;
1006
+ restoreBodyOverflow = '';
1007
+ restoreBodyPaddingRight = '';
1008
+ }
1009
+
1010
+ /**
1011
+ * Return the currently focused element.
1012
+ *
1013
+ * @returns {Element|null} Active element.
1014
+ */
1015
+ function activeElement() {
1016
+ if (typeof document === 'undefined') return null;
1017
+ return document.activeElement;
1018
+ }
1019
+
1020
+ /**
1021
+ * Move focus into the overlay root.
1022
+ *
1023
+ * @returns {void}
1024
+ */
1025
+ function focusRoot() {
1026
+ root.value?.focus?.({ preventScroll: true });
1027
+ }
1028
+
1029
+ /**
1030
+ * Restore focus to the element active before opening.
1031
+ *
1032
+ * @returns {void}
1033
+ */
1034
+ function restoreFocus() {
1035
+ if (!previousActiveElement || typeof previousActiveElement.focus !== 'function') return;
1036
+ try {
1037
+ previousActiveElement.focus({ preventScroll: true });
1038
+ } catch {
1039
+ previousActiveElement.focus();
1040
+ }
1041
+ previousActiveElement = null;
1042
+ }
1043
+
1044
+ /**
1045
+ * Wait until the browser has committed two paints.
1046
+ *
1047
+ * @returns {Promise<void>} Paint completion promise.
1048
+ */
1049
+ async function waitForPaint() {
1050
+ await nextAnimationFrame();
1051
+ await nextAnimationFrame();
1052
+ }
1053
+
1054
+ /**
1055
+ * Wait for the next animation frame, with a timeout fallback.
1056
+ *
1057
+ * @returns {Promise<void>} Animation frame promise.
1058
+ */
1059
+ function nextAnimationFrame() {
1060
+ if (typeof window === 'undefined') return Promise.resolve();
1061
+ return new Promise(resolveNextAnimationFrame);
1062
+ }
1063
+
1064
+ /**
1065
+ * Resolve a promise on the next animation frame.
1066
+ *
1067
+ * @param {() => void} resolve Promise resolver.
1068
+ * @returns {void}
1069
+ */
1070
+ function resolveNextAnimationFrame(resolve) {
1071
+ let settled = false;
1072
+ let timeoutId = 0;
1073
+ const frameId = window.requestAnimationFrame(finishFrame);
1074
+ timeoutId = window.setTimeout(cancelFrame, FRAME_TIMEOUT_MS);
1075
+
1076
+ /**
1077
+ * Finish the animation-frame promise once.
1078
+ *
1079
+ * @returns {void}
1080
+ */
1081
+ function finishFrame() {
1082
+ if (settled) return;
1083
+ settled = true;
1084
+ window.clearTimeout(timeoutId);
1085
+ resolve();
1086
+ }
1087
+
1088
+ /**
1089
+ * Cancel the pending animation frame if the timeout wins.
1090
+ *
1091
+ * @returns {void}
1092
+ */
1093
+ function cancelFrame() {
1094
+ window.cancelAnimationFrame?.(frameId);
1095
+ finishFrame();
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Resolve the effective transition duration.
1101
+ *
1102
+ * @returns {number} Duration in milliseconds.
1103
+ */
1104
+ function transitionDuration() {
1105
+ if (prefersReducedMotion()) return 80;
1106
+ const duration = Number(props.duration);
1107
+ if (!Number.isFinite(duration)) return DEFAULT_DURATION_MS;
1108
+ return Math.max(0, duration);
1109
+ }
1110
+
1111
+ /**
1112
+ * Resolve the clone-to-stage handoff delay.
1113
+ *
1114
+ * @returns {number} Handoff duration in milliseconds.
1115
+ */
1116
+ function stageHandoffDuration() {
1117
+ return prefersReducedMotion() ? 0 : STAGE_HANDOFF_MS;
1118
+ }
1119
+
1120
+ /**
1121
+ * Check the user's reduced-motion preference.
1122
+ *
1123
+ * @returns {boolean} Whether reduced motion is preferred.
1124
+ */
1125
+ function prefersReducedMotion() {
1126
+ return Boolean(typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
1127
+ }
1128
+
1129
+ /**
1130
+ * Clear any scheduled phase timer.
1131
+ *
1132
+ * @returns {void}
1133
+ */
1134
+ function clearPhaseTimer() {
1135
+ if (!phaseTimer || typeof window === 'undefined') return;
1136
+ window.clearTimeout(phaseTimer);
1137
+ phaseTimer = null;
1138
+ }
1139
+
1140
+ /**
1141
+ * Check whether the current phase is a leave phase.
1142
+ *
1143
+ * @returns {boolean} Whether the overlay is closing.
1144
+ */
1145
+ function isClosingPhase() {
1146
+ return phase.value === 'closing-from' || phase.value === 'closing';
1147
+ }
1148
+
1149
+ /**
1150
+ * Check whether an async motion step belongs to the latest run.
1151
+ *
1152
+ * @param {number} run Motion sequence id.
1153
+ * @returns {boolean} Whether the run is current.
1154
+ */
1155
+ function isCurrentRun(run) {
1156
+ return run === motionRun;
1157
+ }
1158
+
1159
+ defineExpose({
1160
+ open: openLightbox,
1161
+ close: requestClose,
1162
+ next: goToNext,
1163
+ previous: goToPrevious,
1164
+ goTo: goToIndex,
1165
+ });
1166
+ </script>
1167
+
1168
+ <template>
1169
+ <Teleport to="body">
1170
+ <div
1171
+ v-if="rendered"
1172
+ ref="root"
1173
+ class="dom-lightbox"
1174
+ :data-phase="phase"
1175
+ :style="lightboxStyle"
1176
+ role="dialog"
1177
+ aria-modal="true"
1178
+ :aria-label="label"
1179
+ :aria-describedby="captionId || null"
1180
+ tabindex="-1"
1181
+ @keydown="onRootKeydown"
1182
+ >
1183
+ <div class="dom-lightbox__backdrop" @click="onBackdropClick"></div>
1184
+
1185
+ <header class="dom-lightbox__topbar">
1186
+ <p class="dom-lightbox__counter">{{ activeIndex + 1 }} / {{ activeItems.length }}</p>
1187
+ <DomIconButton class="dom-lightbox__close" label="Close lightbox" variant="secondary" size="md" :icon="CLOSE_ICON" @click="requestClose('close-button')" />
1188
+ </header>
1189
+
1190
+ <main class="dom-lightbox__stage" aria-live="polite">
1191
+ <DomIconButton
1192
+ v-if="hasMultipleItems"
1193
+ class="dom-lightbox__nav dom-lightbox__nav--previous"
1194
+ label="Previous item"
1195
+ variant="secondary"
1196
+ size="lg"
1197
+ :icon="PREVIOUS_ICON"
1198
+ @click="goToPrevious"
1199
+ />
1200
+
1201
+ <div class="dom-lightbox__stage-frame">
1202
+ <div class="dom-lightbox__stage-content" :data-visible="stageVisible ? 'true' : 'false'">
1203
+ <slot v-bind="slotState">
1204
+ <img
1205
+ v-if="activeSrc"
1206
+ :key="activeSrc"
1207
+ class="dom-lightbox__image"
1208
+ :src="activeSrc"
1209
+ :alt="activeAlt"
1210
+ draggable="false"
1211
+ />
1212
+ </slot>
1213
+ </div>
1214
+ </div>
1215
+
1216
+ <DomIconButton
1217
+ v-if="hasMultipleItems"
1218
+ class="dom-lightbox__nav dom-lightbox__nav--next"
1219
+ label="Next item"
1220
+ variant="secondary"
1221
+ size="lg"
1222
+ :icon="NEXT_ICON"
1223
+ @click="goToNext"
1224
+ />
1225
+ </main>
1226
+
1227
+ <footer v-if="shouldShowCaption" :id="captionId" class="dom-lightbox__caption">
1228
+ <slot name="caption" :item="activeItem" :index="activeIndex">
1229
+ <p v-if="activeTitle" class="dom-lightbox__caption-title">{{ activeTitle }}</p>
1230
+ <p v-if="activeDescription" class="dom-lightbox__caption-description">{{ activeDescription }}</p>
1231
+ </slot>
1232
+ </footer>
1233
+
1234
+ <nav v-if="shouldShowThumbnails" class="dom-lightbox__thumbs" aria-label="Gallery thumbnails">
1235
+ <button
1236
+ v-for="(item, itemIndex) in activeItems"
1237
+ :key="itemKey(item, itemIndex)"
1238
+ type="button"
1239
+ class="dom-lightbox__thumb"
1240
+ :data-active="itemIndex === activeIndex ? 'true' : 'false'"
1241
+ :aria-current="itemIndex === activeIndex ? 'true' : null"
1242
+ :aria-label="`Show item ${itemIndex + 1}`"
1243
+ @click="goToIndex(itemIndex)"
1244
+ >
1245
+ <slot name="thumbnail" :item="item" :index="itemIndex" :active="itemIndex === activeIndex">
1246
+ <img
1247
+ v-if="thumbnailSource(item)"
1248
+ :src="thumbnailSource(item)"
1249
+ :alt="stringField(item, 'alt') || ''"
1250
+ draggable="false"
1251
+ />
1252
+ </slot>
1253
+ </button>
1254
+ </nav>
1255
+
1256
+ <img
1257
+ v-if="ghostVisible && activeThumbnail"
1258
+ class="dom-lightbox__ghost"
1259
+ :src="activeThumbnail"
1260
+ alt=""
1261
+ aria-hidden="true"
1262
+ draggable="false"
1263
+ />
1264
+ <div v-else-if="ghostVisible" class="dom-lightbox__ghost dom-lightbox__ghost--fallback" aria-hidden="true"></div>
1265
+ </div>
1266
+ </Teleport>
1267
+ </template>
1268
+
1269
+ <style>
1270
+ .dom-lightbox {
1271
+ position: fixed;
1272
+ inset: 0;
1273
+ z-index: 999;
1274
+ color: white;
1275
+ outline: none;
1276
+ pointer-events: auto;
1277
+ }
1278
+
1279
+ .dom-lightbox__backdrop {
1280
+ position: absolute;
1281
+ inset: 0;
1282
+ background: rgb(0 0 0 / 0.88);
1283
+ opacity: 0;
1284
+ transition: opacity var(--dom-lightbox-duration, 420ms) ease;
1285
+ }
1286
+
1287
+ .dom-lightbox[data-phase="opening"] .dom-lightbox__backdrop,
1288
+ .dom-lightbox[data-phase="open"] .dom-lightbox__backdrop,
1289
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__backdrop {
1290
+ opacity: 1;
1291
+ }
1292
+
1293
+ .dom-lightbox[data-phase="closing"] .dom-lightbox__backdrop {
1294
+ opacity: 0;
1295
+ }
1296
+
1297
+ .dom-lightbox__topbar {
1298
+ position: fixed;
1299
+ z-index: 4;
1300
+ top: 0;
1301
+ left: 0;
1302
+ right: 0;
1303
+ display: flex;
1304
+ align-items: center;
1305
+ justify-content: space-between;
1306
+ gap: 1rem;
1307
+ padding: max(0.75rem, env(safe-area-inset-top)) 1rem 0.75rem;
1308
+ opacity: 0;
1309
+ transform: translateY(-0.5rem);
1310
+ transition:
1311
+ opacity 160ms ease,
1312
+ transform 180ms ease;
1313
+ }
1314
+
1315
+ .dom-lightbox[data-phase="open"] .dom-lightbox__topbar,
1316
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__topbar {
1317
+ opacity: 1;
1318
+ transform: none;
1319
+ }
1320
+
1321
+ .dom-lightbox__counter {
1322
+ border-radius: 999px;
1323
+ background: rgb(255 255 255 / 0.12);
1324
+ padding: 0.45rem 0.7rem;
1325
+ font-size: 0.75rem;
1326
+ font-weight: 600;
1327
+ letter-spacing: 0;
1328
+ color: rgb(255 255 255 / 0.86);
1329
+ backdrop-filter: blur(12px);
1330
+ -webkit-backdrop-filter: blur(12px);
1331
+ }
1332
+
1333
+ .dom-lightbox__close {
1334
+ color: white;
1335
+ background: rgb(255 255 255 / 0.13);
1336
+ backdrop-filter: blur(14px);
1337
+ -webkit-backdrop-filter: blur(14px);
1338
+ }
1339
+
1340
+ .dom-lightbox__stage {
1341
+ position: fixed;
1342
+ z-index: 2;
1343
+ inset: 0;
1344
+ display: grid;
1345
+ grid-template-columns: minmax(3rem, 1fr) auto minmax(3rem, 1fr);
1346
+ align-items: center;
1347
+ justify-items: center;
1348
+ pointer-events: none;
1349
+ }
1350
+
1351
+ .dom-lightbox__stage-frame {
1352
+ position: fixed;
1353
+ left: var(--dom-lightbox-to-left);
1354
+ top: var(--dom-lightbox-to-top);
1355
+ width: var(--dom-lightbox-to-width);
1356
+ height: var(--dom-lightbox-to-height);
1357
+ display: grid;
1358
+ place-items: center;
1359
+ pointer-events: auto;
1360
+ }
1361
+
1362
+ .dom-lightbox__stage-content {
1363
+ width: 100%;
1364
+ height: 100%;
1365
+ display: grid;
1366
+ place-items: center;
1367
+ opacity: 0;
1368
+ transform: none;
1369
+ transition: none;
1370
+ }
1371
+
1372
+ .dom-lightbox__stage-content[data-visible="true"] {
1373
+ opacity: 1;
1374
+ }
1375
+
1376
+ .dom-lightbox__image {
1377
+ width: 100%;
1378
+ height: 100%;
1379
+ object-fit: cover;
1380
+ border-radius: 0.75rem;
1381
+ box-shadow: 0 2rem 5rem rgb(0 0 0 / 0.42);
1382
+ }
1383
+
1384
+ .dom-lightbox__nav {
1385
+ position: fixed;
1386
+ z-index: 5;
1387
+ top: 50%;
1388
+ pointer-events: auto;
1389
+ color: white;
1390
+ background: rgb(255 255 255 / 0.13);
1391
+ backdrop-filter: blur(14px);
1392
+ -webkit-backdrop-filter: blur(14px);
1393
+ }
1394
+
1395
+ .dom-lightbox__nav--previous {
1396
+ left: max(0.75rem, env(safe-area-inset-left));
1397
+ }
1398
+
1399
+ .dom-lightbox__nav--next {
1400
+ right: max(0.75rem, env(safe-area-inset-right));
1401
+ }
1402
+
1403
+ .dom-lightbox__caption {
1404
+ position: fixed;
1405
+ z-index: 4;
1406
+ left: 50%;
1407
+ bottom: calc(5.25rem + env(safe-area-inset-bottom));
1408
+ width: min(calc(100vw - 2rem), var(--dom-lightbox-to-width));
1409
+ transform: translateX(-50%);
1410
+ text-align: center;
1411
+ opacity: 0;
1412
+ transition: opacity 160ms ease;
1413
+ }
1414
+
1415
+ .dom-lightbox[data-phase="open"] .dom-lightbox__caption,
1416
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__caption {
1417
+ opacity: 1;
1418
+ }
1419
+
1420
+ .dom-lightbox__caption-title {
1421
+ font-size: 0.95rem;
1422
+ font-weight: 700;
1423
+ letter-spacing: 0;
1424
+ color: white;
1425
+ }
1426
+
1427
+ .dom-lightbox__caption-description {
1428
+ margin-top: 0.25rem;
1429
+ font-size: 0.82rem;
1430
+ line-height: 1.5;
1431
+ color: rgb(255 255 255 / 0.72);
1432
+ }
1433
+
1434
+ .dom-lightbox__thumbs {
1435
+ position: fixed;
1436
+ z-index: 5;
1437
+ left: 50%;
1438
+ bottom: calc(0.9rem + env(safe-area-inset-bottom));
1439
+ display: flex;
1440
+ max-width: min(calc(100vw - 2rem), 44rem);
1441
+ gap: 0.5rem;
1442
+ overflow-x: auto;
1443
+ padding: 0.25rem;
1444
+ transform: translateX(-50%);
1445
+ opacity: 0;
1446
+ transition: opacity 160ms ease;
1447
+ }
1448
+
1449
+ .dom-lightbox[data-phase="open"] .dom-lightbox__thumbs,
1450
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__thumbs {
1451
+ opacity: 1;
1452
+ }
1453
+
1454
+ .dom-lightbox__thumb {
1455
+ width: 4rem;
1456
+ height: 3rem;
1457
+ flex: 0 0 auto;
1458
+ overflow: hidden;
1459
+ border: 2px solid transparent;
1460
+ border-radius: 0.5rem;
1461
+ background: rgb(255 255 255 / 0.12);
1462
+ opacity: 0.62;
1463
+ padding: 0;
1464
+ transition:
1465
+ border-color 140ms ease,
1466
+ opacity 140ms ease,
1467
+ transform 140ms ease;
1468
+ }
1469
+
1470
+ .dom-lightbox__thumb:hover,
1471
+ .dom-lightbox__thumb[data-active="true"] {
1472
+ opacity: 1;
1473
+ }
1474
+
1475
+ .dom-lightbox__thumb[data-active="true"] {
1476
+ border-color: rgb(255 255 255 / 0.92);
1477
+ transform: translateY(-0.125rem);
1478
+ }
1479
+
1480
+ .dom-lightbox__thumb img {
1481
+ width: 100%;
1482
+ height: 100%;
1483
+ object-fit: cover;
1484
+ }
1485
+
1486
+ .dom-lightbox__ghost {
1487
+ position: fixed;
1488
+ z-index: 3;
1489
+ left: var(--dom-lightbox-from-left);
1490
+ top: var(--dom-lightbox-from-top);
1491
+ width: var(--dom-lightbox-from-width);
1492
+ height: var(--dom-lightbox-from-height);
1493
+ object-fit: cover;
1494
+ border-radius: 0.625rem;
1495
+ box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
1496
+ pointer-events: none;
1497
+ transition:
1498
+ left var(--dom-lightbox-duration, 420ms) cubic-bezier(0.16, 1, 0.3, 1),
1499
+ top var(--dom-lightbox-duration, 420ms) cubic-bezier(0.16, 1, 0.3, 1),
1500
+ width var(--dom-lightbox-duration, 420ms) cubic-bezier(0.16, 1, 0.3, 1),
1501
+ height var(--dom-lightbox-duration, 420ms) cubic-bezier(0.16, 1, 0.3, 1),
1502
+ border-radius var(--dom-lightbox-duration, 420ms) cubic-bezier(0.16, 1, 0.3, 1);
1503
+ }
1504
+
1505
+ .dom-lightbox__ghost--fallback {
1506
+ background: var(--canvas);
1507
+ }
1508
+
1509
+ .dom-lightbox[data-phase="opening"] .dom-lightbox__ghost,
1510
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__ghost {
1511
+ left: var(--dom-lightbox-to-left);
1512
+ top: var(--dom-lightbox-to-top);
1513
+ width: var(--dom-lightbox-to-width);
1514
+ height: var(--dom-lightbox-to-height);
1515
+ border-radius: 0.75rem;
1516
+ }
1517
+
1518
+ .dom-lightbox[data-phase="enter-from"] .dom-lightbox__ghost,
1519
+ .dom-lightbox[data-phase="closing-from"] .dom-lightbox__ghost {
1520
+ transition: none;
1521
+ }
1522
+
1523
+ .dom-lightbox[data-phase="closing"] .dom-lightbox__ghost {
1524
+ left: var(--dom-lightbox-from-left);
1525
+ top: var(--dom-lightbox-from-top);
1526
+ width: var(--dom-lightbox-from-width);
1527
+ height: var(--dom-lightbox-from-height);
1528
+ border-radius: 0.625rem;
1529
+ }
1530
+
1531
+ @media (max-width: 640px) {
1532
+ .dom-lightbox__stage {
1533
+ grid-template-columns: 1fr;
1534
+ }
1535
+
1536
+ .dom-lightbox__nav {
1537
+ top: auto;
1538
+ bottom: calc(4.75rem + env(safe-area-inset-bottom));
1539
+ }
1540
+
1541
+ .dom-lightbox__caption {
1542
+ bottom: calc(4.5rem + env(safe-area-inset-bottom));
1543
+ }
1544
+
1545
+ .dom-lightbox__thumb {
1546
+ width: 3.25rem;
1547
+ height: 2.5rem;
1548
+ }
1549
+ }
1550
+
1551
+ @media (prefers-reduced-motion: reduce) {
1552
+ .dom-lightbox__backdrop,
1553
+ .dom-lightbox__topbar,
1554
+ .dom-lightbox__stage-content,
1555
+ .dom-lightbox__caption,
1556
+ .dom-lightbox__thumbs,
1557
+ .dom-lightbox__ghost {
1558
+ transition-duration: 80ms;
1559
+ }
1560
+ }
1561
+ </style>