@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,237 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref, shallowRef, useId, watch } from 'vue';
3
+ import { resolveFloatingTeleportTarget } from '../../lib/vue/floatingTeleportTarget.js';
4
+ import DomFieldLoadingSpinner from '../_shared/DomFieldLoadingSpinner.vue';
5
+ import { booleanProp, normalizeOption } from '../_shared/options.js';
6
+ import DomField from '../field/DomField.vue';
7
+ import { fieldProps } from '../field/fieldProps.js';
8
+ import { useField } from '../field/useField.js';
9
+
10
+ const listId = `dom-combobox-${useId()}`;
11
+
12
+ defineOptions({
13
+ __doc: {
14
+ name: 'Combobox',
15
+ tag: '<DomCombobox>',
16
+ description: 'Select-like combobox with a styled input, floating list, keyboard navigation, and query events for async lookups.',
17
+ slots: [
18
+ { name: 'item', payload: '{ item, index }', description: 'Replaces the rendering of each option.' },
19
+ ],
20
+ events: [
21
+ { name: 'query', payload: '(query: string)', description: 'Fired as the user types. Use it to fetch or replace options.' },
22
+ { name: 'select', payload: '({ item, value, label })', description: 'Fired when an option is selected. item is the original option object.' },
23
+ ],
24
+ },
25
+ });
26
+
27
+ const props = defineProps({
28
+ ...fieldProps,
29
+ modelValue: {
30
+ type: [String, Number],
31
+ default: '',
32
+ _edit: { group: 'Control props', description: 'Committed selected value.' },
33
+ },
34
+ options: {
35
+ type: Array,
36
+ required: true,
37
+ _edit: {
38
+ component: 'DomJsonListInput',
39
+ description: 'Available options. May be replaced by async lookup results.',
40
+ props: {
41
+ compact: true,
42
+ addLabel: '+ Add option',
43
+ schema: [
44
+ { key: 'label', label: 'Label', placeholder: 'Option label', default: (index) => `Option ${index + 1}` },
45
+ { key: 'value', label: 'Value', placeholder: 'option-value', default: (index) => `option-${index + 1}` },
46
+ ],
47
+ },
48
+ },
49
+ },
50
+ placeholder: {
51
+ type: String,
52
+ default: 'Search...',
53
+ _edit: { group: 'Control props', description: 'Placeholder shown when the input is empty.' },
54
+ },
55
+ placement: {
56
+ type: String,
57
+ default: 'bottom',
58
+ _edit: { group: 'Control props', options: ['bottom', 'top', 'right', 'left'], description: 'Preferred side before collision handling.' },
59
+ },
60
+ floatingMode: {
61
+ type: String,
62
+ default: 'viewport',
63
+ _edit: { group: 'Control props', options: ['viewport', 'anchor'], description: 'viewport keeps the list inside the browser; anchor keeps it attached while scrolling.' },
64
+ },
65
+ clearable: {
66
+ type: [Boolean, String],
67
+ default: true,
68
+ _edit: { group: 'Control props', description: 'Show a clear button when an option has been selected.' },
69
+ },
70
+ loading: {
71
+ type: [Boolean, String],
72
+ default: false,
73
+ _edit: { group: 'Control props', description: 'Show an inline spinner while async options are being fetched.' },
74
+ },
75
+ });
76
+ const emit = defineEmits(['update:modelValue', 'query', 'select', 'focus', 'blur']);
77
+
78
+ const root = ref(null);
79
+ const inputEl = ref(null);
80
+ const isMounted = ref(false);
81
+ const teleportTarget = shallowRef(null);
82
+ const field = useField(props, emit, { idPrefix: 'dom-combobox' });
83
+ const isClearable = computed(() => booleanProp(props.clearable, true));
84
+ const isLoading = computed(() => booleanProp(props.loading, false));
85
+ const hasSelection = computed(() => field.value.value !== null && field.value.value !== undefined && field.value.value !== '');
86
+ const inputAttrs = computed(() => {
87
+ const { value: _value, ...attrs } = field.inputAttrs.value;
88
+ return attrs;
89
+ });
90
+ const inputPaddingClass = computed(() => {
91
+ if (isClearable.value && hasSelection.value && isLoading.value) return 'pr-28';
92
+ if (isClearable.value && hasSelection.value) return 'pr-20';
93
+ if (isLoading.value) return 'pr-20';
94
+ return 'pr-10';
95
+ });
96
+ const loadingPositionClass = computed(() => (isClearable.value && hasSelection.value ? 'right-[4.25rem]' : 'right-9'));
97
+
98
+ const optionList = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
99
+
100
+ /**
101
+ * Resolve the user-facing text shown for a committed value.
102
+ *
103
+ * @param {string|number|null|undefined} value Current model value.
104
+ * @returns {string|number} Option label or raw value fallback.
105
+ */
106
+ function displayForValue(value) {
107
+ const selected = optionList.value.find((option) => String(option.value ?? option.label) === String(value));
108
+ return selected ? (selected.label ?? selected.value ?? '') : (value ?? '');
109
+ }
110
+
111
+ /**
112
+ * Synchronize the headless host and visible input from the Vue model value.
113
+ *
114
+ * @returns {void}
115
+ */
116
+ function syncInputFromModel() {
117
+ if (root.value && root.value.value !== String(field.value.value ?? '')) {
118
+ root.value.value = field.value.value ?? '';
119
+ }
120
+ if (inputEl.value) inputEl.value.value = displayForValue(field.value.value);
121
+ }
122
+
123
+ /**
124
+ * Refresh the input label after async option records change.
125
+ *
126
+ * @returns {void}
127
+ */
128
+ function syncInputAfterOptionsChange() {
129
+ if (!hasSelection.value || field.focused.value) return;
130
+ syncInputFromModel();
131
+ }
132
+
133
+ onMounted(async () => {
134
+ teleportTarget.value = resolveFloatingTeleportTarget(root.value);
135
+ isMounted.value = true;
136
+ await import('../../lib/headless/combobox.js');
137
+ syncInputFromModel();
138
+ root.value?.addEventListener('dom:change', (event) => field.onInput(event.detail.value));
139
+ root.value?.addEventListener('dom:select', (event) => {
140
+ const index = Number(event.detail.option?.dataset.index);
141
+ emit('select', {
142
+ item: Number.isFinite(index) ? optionList.value[index] : null,
143
+ value: event.detail.value,
144
+ label: event.detail.label,
145
+ option: event.detail.option,
146
+ });
147
+ });
148
+ root.value?.addEventListener('dom:query', (event) => {
149
+ emit('query', event.detail.query);
150
+ });
151
+ });
152
+
153
+ watch(field.value, syncInputFromModel);
154
+ watch(optionList, syncInputAfterOptionsChange);
155
+
156
+ /**
157
+ * Clear the selected value and close any open suggestion list.
158
+ *
159
+ * @returns {void}
160
+ */
161
+ function clearSelection() {
162
+ field.setValue('');
163
+ if (root.value) root.value.value = '';
164
+ if (inputEl.value) inputEl.value.value = '';
165
+ root.value?.removeAttribute('open');
166
+ emit('select', { item: null, value: '', label: '', option: null });
167
+ }
168
+
169
+ </script>
170
+
171
+ <template>
172
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
173
+ <dom-combobox
174
+ ref="root"
175
+ :data-menu-id="listId"
176
+ :placement="placement"
177
+ :floating-mode="floatingMode"
178
+ class="relative block"
179
+ >
180
+ <input
181
+ v-bind="inputAttrs"
182
+ ref="inputEl"
183
+ slot="input"
184
+ type="text"
185
+ class="skin-input h-10 rounded-full px-4 focus:ring-ring/60"
186
+ :class="inputPaddingClass"
187
+ @focus="field.onFocus"
188
+ @blur="field.onBlur"
189
+ />
190
+ <DomFieldLoadingSpinner
191
+ v-if="isLoading"
192
+ class="absolute top-1"
193
+ :class="loadingPositionClass"
194
+ />
195
+ <button
196
+ v-if="isClearable && hasSelection"
197
+ type="button"
198
+ class="absolute right-9 top-1 inline-flex size-8 items-center justify-center rounded-full text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
199
+ aria-label="Clear selection"
200
+ @click.stop="clearSelection"
201
+ @mousedown.prevent
202
+ >
203
+ <svg viewBox="0 0 20 20" class="size-4" fill="none">
204
+ <circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5" />
205
+ <path d="M7.5 7.5l5 5M12.5 7.5l-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
206
+ </svg>
207
+ </button>
208
+ <button
209
+ slot="toggle"
210
+ type="button"
211
+ class="absolute right-1 top-1 inline-flex size-8 items-center justify-center rounded-full text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
212
+ aria-label="Show options"
213
+ >
214
+ <svg viewBox="0 0 20 20" class="size-4" fill="none">
215
+ <path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
216
+ </svg>
217
+ </button>
218
+ <Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
219
+ <ul
220
+ :id="listId"
221
+ class="dom-floating-transition skin-popover z-50 max-h-[min(15rem,var(--dom-floating-available-height))] overflow-auto rounded-2xl p-1"
222
+ >
223
+ <li
224
+ v-for="(option, index) in optionList"
225
+ :key="option.value ?? index"
226
+ :data-value="option.value"
227
+ :data-label="option.label"
228
+ :data-index="index"
229
+ class="cursor-pointer rounded-xl px-3 py-2 text-sm transition data-[active]:bg-accent aria-selected:bg-accent"
230
+ >
231
+ <slot name="item" :item="option" :index="index">{{ option.label }}</slot>
232
+ </li>
233
+ </ul>
234
+ </Teleport>
235
+ </dom-combobox>
236
+ </DomField>
237
+ </template>
@@ -0,0 +1,345 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, watch } from 'vue';
3
+ import DomField from '../field/DomField.vue';
4
+ import { fieldProps } from '../field/fieldProps.js';
5
+ import { useField } from '../field/useField.js';
6
+
7
+ defineOptions({
8
+ __doc: {
9
+ name: 'Confirmation code input',
10
+ tag: '<DomConfirmationCodeInput>',
11
+ description: 'A segmented confirmation-code field with one character per box, automatic focus movement, and full-code paste support.',
12
+ icon: 'M4 8h3v3H4V8Zm6 0h3v3h-3V8Zm6 0h3v3h-3V8ZM4 14h3v3H4v-3Zm6 0h3v3h-3v-3Zm6 0h3v3h-3v-3Z',
13
+ events: [
14
+ { name: 'update:modelValue', payload: 'string', description: 'Fired whenever the entered code changes.' },
15
+ { name: 'complete', payload: 'string', description: 'Fired when every character box contains a value.' },
16
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when focus enters the segmented control.' },
17
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when focus leaves the segmented control.' },
18
+ ],
19
+ keyboard: [
20
+ { keys: 'Character', action: 'Enter one character and move focus to the next box.' },
21
+ { keys: 'Backspace', action: 'Clear the current box, or move back and clear the previous box when empty.' },
22
+ { keys: 'Delete', action: 'Clear the current box.' },
23
+ { keys: 'Left / Right', action: 'Move between character boxes.' },
24
+ { keys: 'Home / End', action: 'Move to the first or last character box.' },
25
+ { keys: 'Paste', action: 'Distribute a complete or partial code across the boxes.' },
26
+ ],
27
+ },
28
+ });
29
+
30
+ const props = defineProps({
31
+ ...fieldProps,
32
+ modelValue: {
33
+ type: String,
34
+ default: '',
35
+ _edit: { group: 'Control props', description: 'Confirmation code without the visual separator.' },
36
+ },
37
+ length: {
38
+ type: Number,
39
+ default: 6,
40
+ _edit: { group: 'Control props', min: 1, max: 12, description: 'Number of character boxes.' },
41
+ },
42
+ separatorAfter: {
43
+ type: Number,
44
+ default: 3,
45
+ _edit: { group: 'Control props', min: 0, max: 11, description: 'Box position after which to show a visual separator. Use 0 to hide it.' },
46
+ },
47
+ characterSet: {
48
+ type: String,
49
+ default: 'alphanumeric',
50
+ _edit: { group: 'Control props', options: ['alphanumeric', 'numeric'], description: 'Characters accepted by the control.' },
51
+ },
52
+ uppercase: {
53
+ type: Boolean,
54
+ default: true,
55
+ _edit: { group: 'Control props', description: 'Convert entered letters to uppercase.' },
56
+ },
57
+ autocomplete: {
58
+ type: String,
59
+ default: 'one-time-code',
60
+ _edit: { group: 'Control props', description: 'Native autocomplete hint applied to the first box.' },
61
+ },
62
+ });
63
+
64
+ const emit = defineEmits(['update:modelValue', 'complete', 'focus', 'blur']);
65
+ const field = useField(props, emit, { idPrefix: 'dom-confirmation-code-input' });
66
+ const group = ref(null);
67
+ const codeLength = computed(() => Math.min(12, Math.max(1, Math.round(props.length || 6))));
68
+ const separatorPosition = computed(() => {
69
+ const requestedPosition = Math.round(props.separatorAfter || 0);
70
+ return requestedPosition > 0 && requestedPosition < codeLength.value ? requestedPosition : 0;
71
+ });
72
+ const inputMode = computed(() => (props.characterSet === 'numeric' ? 'numeric' : 'text'));
73
+ const inputPattern = computed(() => (props.characterSet === 'numeric' ? '[0-9]' : '[A-Za-z0-9]'));
74
+ const segments = ref(createSegments(field.value.value));
75
+ const code = computed(() => segments.value.join(''));
76
+ let lastEmittedValue = sanitizeCode(field.value.value);
77
+
78
+ watch(field.value, (value) => {
79
+ const normalizedValue = sanitizeCode(value);
80
+ if (normalizedValue === lastEmittedValue) return;
81
+ segments.value = createSegments(normalizedValue);
82
+ lastEmittedValue = normalizedValue;
83
+ });
84
+
85
+ watch(
86
+ [codeLength, () => props.characterSet, () => props.uppercase],
87
+ () => {
88
+ segments.value = createSegments(field.value.value);
89
+ lastEmittedValue = sanitizeCode(field.value.value);
90
+ },
91
+ );
92
+
93
+ /**
94
+ * Removes unsupported characters and applies the configured letter casing.
95
+ *
96
+ * @param {unknown} value Raw code value.
97
+ * @returns {string} Normalized confirmation code.
98
+ */
99
+ function sanitizeCode(value) {
100
+ const pattern = props.characterSet === 'numeric' ? /[^0-9]/g : /[^a-z0-9]/gi;
101
+ const normalizedValue = String(value ?? '').replace(pattern, '');
102
+ return (props.uppercase ? normalizedValue.toUpperCase() : normalizedValue).slice(0, codeLength.value);
103
+ }
104
+
105
+ /**
106
+ * Builds the fixed-length character array used by the segmented inputs.
107
+ *
108
+ * @param {unknown} value Raw or normalized code value.
109
+ * @returns {string[]} Character slots padded with empty strings.
110
+ */
111
+ function createSegments(value) {
112
+ const nextSegments = Array.from({ length: codeLength.value }, () => '');
113
+ for (const [index, character] of Array.from(sanitizeCode(value)).entries()) {
114
+ nextSegments[index] = character;
115
+ }
116
+ return nextSegments;
117
+ }
118
+
119
+ /**
120
+ * Resolves a character input from its stable data attribute.
121
+ *
122
+ * @param {number} index Zero-based character position.
123
+ * @returns {HTMLInputElement|null} Matching character input.
124
+ */
125
+ function inputAt(index) {
126
+ return group.value?.querySelector(`[data-code-index="${index}"]`) || null;
127
+ }
128
+
129
+ /**
130
+ * Moves focus to a character input and selects its existing value.
131
+ *
132
+ * @param {number} index Zero-based character position.
133
+ * @returns {Promise<void>} Promise resolved after Vue updates the inputs.
134
+ */
135
+ async function focusInput(index) {
136
+ const boundedIndex = Math.min(codeLength.value - 1, Math.max(0, index));
137
+ await nextTick();
138
+ const input = inputAt(boundedIndex);
139
+ input?.focus();
140
+ input?.select();
141
+ }
142
+
143
+ /**
144
+ * Writes the local character slots through the shared field contract.
145
+ *
146
+ * @returns {void}
147
+ */
148
+ function commitCode() {
149
+ const nextCode = code.value;
150
+ lastEmittedValue = nextCode;
151
+ field.onInput(nextCode);
152
+ if (segments.value.every(Boolean)) emit('complete', nextCode);
153
+ }
154
+
155
+ /**
156
+ * Distributes one or more characters from a starting box.
157
+ *
158
+ * @param {string} value Characters to insert.
159
+ * @param {number} startIndex Zero-based insertion position.
160
+ * @returns {void}
161
+ */
162
+ function insertCharacters(value, startIndex) {
163
+ const characters = Array.from(sanitizeCode(value));
164
+ if (!characters.length) return;
165
+ const nextSegments = [...segments.value];
166
+ let nextIndex = startIndex;
167
+ for (const character of characters) {
168
+ if (nextIndex >= codeLength.value) break;
169
+ nextSegments[nextIndex] = character;
170
+ nextIndex += 1;
171
+ }
172
+ segments.value = nextSegments;
173
+ commitCode();
174
+ if (nextIndex < codeLength.value) void focusInput(nextIndex);
175
+ }
176
+
177
+ /**
178
+ * Handles native text input, including browser-provided one-time-code values.
179
+ *
180
+ * @param {InputEvent} event Native input event.
181
+ * @param {number} index Zero-based character position.
182
+ * @returns {void}
183
+ */
184
+ function onInput(event, index) {
185
+ if (field.disabled.value || field.readOnly.value) return;
186
+ const value = sanitizeCode(event.target.value);
187
+ if (value) {
188
+ insertCharacters(value, index);
189
+ return;
190
+ }
191
+ const nextSegments = [...segments.value];
192
+ nextSegments[index] = '';
193
+ segments.value = nextSegments;
194
+ commitCode();
195
+ }
196
+
197
+ /**
198
+ * Distributes pasted confirmation-code text from the active box.
199
+ *
200
+ * @param {ClipboardEvent} event Native paste event.
201
+ * @param {number} index Zero-based character position.
202
+ * @returns {void}
203
+ */
204
+ function onPaste(event, index) {
205
+ if (field.disabled.value || field.readOnly.value) return;
206
+ const value = sanitizeCode(event.clipboardData?.getData('text'));
207
+ if (!value) return;
208
+ event.preventDefault();
209
+ insertCharacters(value, index);
210
+ }
211
+
212
+ /**
213
+ * Applies segmented-input navigation and deletion keys.
214
+ *
215
+ * @param {KeyboardEvent} event Native keyboard event.
216
+ * @param {number} index Zero-based character position.
217
+ * @returns {void}
218
+ */
219
+ function onKeydown(event, index) {
220
+ if (field.disabled.value || field.readOnly.value) return;
221
+ if (event.key === 'ArrowLeft') {
222
+ event.preventDefault();
223
+ void focusInput(index - 1);
224
+ return;
225
+ }
226
+ if (event.key === 'ArrowRight') {
227
+ event.preventDefault();
228
+ void focusInput(index + 1);
229
+ return;
230
+ }
231
+ if (event.key === 'Home') {
232
+ event.preventDefault();
233
+ void focusInput(0);
234
+ return;
235
+ }
236
+ if (event.key === 'End') {
237
+ event.preventDefault();
238
+ void focusInput(codeLength.value - 1);
239
+ return;
240
+ }
241
+ if (event.key !== 'Backspace' && event.key !== 'Delete') return;
242
+
243
+ event.preventDefault();
244
+ const nextSegments = [...segments.value];
245
+ if (event.key === 'Backspace' && !nextSegments[index] && index > 0) {
246
+ nextSegments[index - 1] = '';
247
+ segments.value = nextSegments;
248
+ commitCode();
249
+ void focusInput(index - 1);
250
+ return;
251
+ }
252
+ nextSegments[index] = '';
253
+ segments.value = nextSegments;
254
+ commitCode();
255
+ }
256
+
257
+ /**
258
+ * Selects a box's current character when it receives focus.
259
+ *
260
+ * @param {FocusEvent} event Native focus event.
261
+ * @returns {void}
262
+ */
263
+ function onCellFocus(event) {
264
+ event.target.select();
265
+ }
266
+
267
+ /**
268
+ * Emits one field-level focus event when focus enters the segmented control.
269
+ *
270
+ * @param {FocusEvent} event Native focus-in event.
271
+ * @returns {void}
272
+ */
273
+ function onGroupFocus(event) {
274
+ if (!field.focused.value) field.onFocus(event);
275
+ }
276
+
277
+ /**
278
+ * Emits one field-level blur event after focus leaves every character box.
279
+ *
280
+ * @param {FocusEvent} event Native focus-out event.
281
+ * @returns {void}
282
+ */
283
+ function onGroupBlur(event) {
284
+ if (group.value?.contains(event.relatedTarget)) return;
285
+ void field.onBlur(event);
286
+ }
287
+ </script>
288
+
289
+ <template>
290
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
291
+ <input
292
+ v-if="field.htmlName.value"
293
+ type="hidden"
294
+ :name="field.htmlName.value"
295
+ :value="code"
296
+ />
297
+ <div
298
+ ref="group"
299
+ role="group"
300
+ :aria-label="label || 'Confirmation code'"
301
+ :aria-describedby="field.describedBy.value || undefined"
302
+ :aria-errormessage="field.errorId.value || undefined"
303
+ :aria-invalid="field.invalid.value || undefined"
304
+ :data-invalid="field.invalid.value ? '' : undefined"
305
+ class="flex w-fit max-w-full items-center gap-1.5"
306
+ @focusin="onGroupFocus"
307
+ @focusout="onGroupBlur"
308
+ >
309
+ <template v-for="(_, index) in segments" :key="index">
310
+ <input
311
+ :id="index === 0 ? field.id.value : `${field.id.value}-${index + 1}`"
312
+ type="text"
313
+ :value="segments[index]"
314
+ :inputmode="inputMode"
315
+ :pattern="inputPattern"
316
+ :maxlength="index === 0 ? codeLength : 1"
317
+ :autocomplete="index === 0 ? autocomplete : 'off'"
318
+ :autocapitalize="uppercase ? 'characters' : 'off'"
319
+ :disabled="field.disabled.value || undefined"
320
+ :readonly="field.readOnly.value || undefined"
321
+ :required="required || undefined"
322
+ :aria-label="`Character ${index + 1} of ${codeLength}`"
323
+ :aria-invalid="field.invalid.value || undefined"
324
+ :aria-describedby="field.describedBy.value || undefined"
325
+ :aria-errormessage="field.errorId.value || undefined"
326
+ :data-code-index="index"
327
+ :data-invalid="field.invalid.value ? '' : undefined"
328
+ :spellcheck="false"
329
+ class="skin-input size-11 max-w-11 flex-none px-0 py-0 text-center font-mono text-lg font-semibold tracking-wide sm:size-12 sm:max-w-12"
330
+ @focus="onCellFocus"
331
+ @input="onInput($event, index)"
332
+ @keydown="onKeydown($event, index)"
333
+ @paste="onPaste($event, index)"
334
+ />
335
+ <span
336
+ v-if="separatorPosition && index + 1 === separatorPosition"
337
+ class="mx-0.5 text-sm font-semibold text-muted-fg"
338
+ aria-hidden="true"
339
+ >
340
+ -
341
+ </span>
342
+ </template>
343
+ </div>
344
+ </DomField>
345
+ </template>