@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,316 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, watch } from 'vue';
3
+ import { booleanProp, normalizeOption, optionKey, searchableText, splitTokens as splitOptionTokens, uniqueValues as uniqueOptionValues } from '../_shared/options.js';
4
+ import { useOptionMenu } from '../_shared/useOptionMenu.js';
5
+ import DomField from '../field/DomField.vue';
6
+ import { fieldProps } from '../field/fieldProps.js';
7
+ import { useField } from '../field/useField.js';
8
+
9
+ defineOptions({
10
+ __doc: {
11
+ name: 'Class toggle input',
12
+ tag: '<DomClassToggleInput>',
13
+ description: 'A class-name autocomplete that keeps every class as a checkbox so utilities can be toggled without being forgotten.',
14
+ icon: 'M4 7h4l2 2 4-5 2 2-6 7-4-4H4V7Zm0 10h4l2 2 4-5 2 2-6 7-4-4H4v-2Z',
15
+ events: [
16
+ { name: 'update:modelValue', payload: 'string', description: 'Emitted with the enabled class string.' },
17
+ { name: 'update:inactiveValues', payload: 'string[]', description: 'Emitted with known classes that are currently toggled off.' },
18
+ { name: 'query', payload: 'string', description: 'Fired as the user types.' },
19
+ { name: 'toggle', payload: '({ value, enabled, classes, inactiveValues })', description: 'Fired when a class checkbox is toggled.' },
20
+ { name: 'add', payload: '({ value, classes, inactiveValues })', description: 'Fired when a typed or suggested class is added.' },
21
+ ],
22
+ },
23
+ });
24
+
25
+ const props = defineProps({
26
+ ...fieldProps,
27
+ modelValue: {
28
+ type: String,
29
+ default: '',
30
+ _edit: { group: 'Control props', description: 'Enabled class names as a space-separated string.' },
31
+ },
32
+ inactiveValues: {
33
+ type: Array,
34
+ default: () => [],
35
+ _edit: { component: 'DomJsonInput', group: 'Control props', description: 'Known class names that are shown but toggled off.' },
36
+ },
37
+ options: {
38
+ type: Array,
39
+ default: () => [],
40
+ _edit: {
41
+ component: 'DomJsonListInput',
42
+ group: 'Control props',
43
+ description: 'Autocomplete options. Strings are treated as both label and value.',
44
+ props: {
45
+ compact: true,
46
+ addLabel: '+ Add class',
47
+ schema: [
48
+ { key: 'label', label: 'Label', placeholder: 'Class label', default: (index) => `Class ${index + 1}` },
49
+ { key: 'value', label: 'Value', placeholder: 'text-sm', default: (index) => `class-${index + 1}` },
50
+ { key: 'description', label: 'Description', placeholder: 'Optional helper text' },
51
+ ],
52
+ },
53
+ },
54
+ },
55
+ placeholder: {
56
+ type: String,
57
+ default: 'Add class',
58
+ _edit: { group: 'Control props', description: 'Placeholder for the class entry field.' },
59
+ },
60
+ allowCustom: {
61
+ type: [Boolean, String],
62
+ default: true,
63
+ _edit: { group: 'Control props', description: 'Allow typed classes that are not present in options.' },
64
+ },
65
+ filterOptions: {
66
+ type: [Boolean, String],
67
+ default: true,
68
+ _edit: { group: 'Control props', description: 'Filter options locally. Turn off when options are already filtered.' },
69
+ },
70
+ maxOptions: {
71
+ type: Number,
72
+ default: 8,
73
+ _edit: { group: 'Control props', description: 'Maximum matching suggestions to show.' },
74
+ },
75
+ emptyText: {
76
+ type: String,
77
+ default: 'No matching classes',
78
+ _edit: { group: 'Control props', description: 'Text shown when no option matches.' },
79
+ },
80
+ });
81
+
82
+ const emit = defineEmits(['update:modelValue', 'update:inactiveValues', 'query', 'add', 'toggle', 'change', 'focus', 'blur']);
83
+
84
+ const inputEl = ref(null);
85
+ const query = ref('');
86
+ const knownValues = ref([]);
87
+ const field = useField(props, emit, { idPrefix: 'dom-class-toggle-input' });
88
+ const classKey = optionKey;
89
+
90
+ const enabledClasses = computed(() => splitClassNames(field.value.value));
91
+ const inactiveClasses = computed(() => uniqueValues(props.inactiveValues));
92
+ const normalizedOptions = computed(() => props.options.map((option) => normalizeOption(option, { stringifyValue: true })).filter(Boolean));
93
+ const enabledSet = computed(() => new Set(enabledClasses.value.map(classKey)));
94
+ const inactiveSet = computed(() => new Set(inactiveClasses.value.map(classKey)));
95
+ const knownItems = computed(() => {
96
+ const optionMap = new Map(normalizedOptions.value.map((option) => [classKey(option.value), option]));
97
+ return uniqueValues([
98
+ ...knownValues.value,
99
+ ...enabledClasses.value,
100
+ ...inactiveClasses.value,
101
+ ]).map((value) => {
102
+ const option = optionMap.get(classKey(value));
103
+ return {
104
+ value,
105
+ label: option?.label ?? value,
106
+ description: option?.description || '',
107
+ enabled: enabledSet.value.has(classKey(value)),
108
+ };
109
+ });
110
+ });
111
+ const trimmedQuery = computed(() => query.value.trim());
112
+ const canCreateQuery = computed(() => {
113
+ if (!booleanProp(props.allowCustom, true) || !trimmedQuery.value) return false;
114
+ return !knownItems.value.some((item) => classKey(item.value) === classKey(trimmedQuery.value));
115
+ });
116
+ const matchingOptions = computed(() => {
117
+ const needle = trimmedQuery.value.toLowerCase();
118
+ return normalizedOptions.value
119
+ .filter((option) => !knownItems.value.some((item) => classKey(item.value) === classKey(option.value)))
120
+ .filter((option) => {
121
+ if (!needle || !booleanProp(props.filterOptions, true)) return true;
122
+ return searchableText(option).includes(needle);
123
+ })
124
+ .slice(0, Math.max(1, props.maxOptions));
125
+ });
126
+ const menuItems = computed(() => {
127
+ const items = matchingOptions.value.map((option) => ({ ...option, custom: false }));
128
+ if (canCreateQuery.value) items.push({ value: trimmedQuery.value, label: trimmedQuery.value, description: 'Add custom class', custom: true });
129
+ return items;
130
+ });
131
+ const optionMenu = useOptionMenu(menuItems, {
132
+ query,
133
+ emitQuery: (value) => emit('query', value),
134
+ });
135
+ const { activeIndex, isOpen } = optionMenu;
136
+ const showMenu = computed(() => isOpen.value && !field.disabled.value && !field.readOnly.value && (menuItems.value.length || trimmedQuery.value));
137
+ const hasEmptyState = computed(() => showMenu.value && !menuItems.value.length);
138
+ const inputAttrs = computed(() => {
139
+ const { value, placeholder, ...attrs } = field.inputAttrs.value;
140
+ return attrs;
141
+ });
142
+
143
+ watch([enabledClasses, inactiveClasses], ([enabled, inactive]) => {
144
+ rememberValues([...enabled, ...inactive]);
145
+ }, { immediate: true });
146
+
147
+ function splitClassNames(value) {
148
+ return uniqueValues(splitOptionTokens(value, [' ']));
149
+ }
150
+
151
+ function joinClassNames(values) {
152
+ return uniqueValues(values).join(' ');
153
+ }
154
+
155
+ function uniqueValues(values) {
156
+ return uniqueOptionValues(values, {
157
+ transform: (value) => String(value || '').trim(),
158
+ key: classKey,
159
+ });
160
+ }
161
+
162
+ function rememberValues(values) {
163
+ const next = uniqueValues([...knownValues.value, ...values]);
164
+ if (next.length !== knownValues.value.length) knownValues.value = next;
165
+ }
166
+
167
+ function addClass(value) {
168
+ const className = String(value || '').trim();
169
+ if (!className || field.disabled.value || field.readOnly.value) return;
170
+ if (!booleanProp(props.allowCustom, true) && !normalizedOptions.value.some((option) => classKey(option.value) === classKey(className))) return;
171
+ rememberValues([className]);
172
+ const nextEnabled = uniqueValues([...enabledClasses.value, className]);
173
+ const nextInactive = inactiveClasses.value.filter((item) => classKey(item) !== classKey(className));
174
+ commitClasses(nextEnabled, nextInactive);
175
+ emit('add', { value: className, classes: joinClassNames(nextEnabled), inactiveValues: nextInactive });
176
+ resetQuery();
177
+ nextTick(() => inputEl.value?.focus());
178
+ }
179
+
180
+ function toggleClass(value, enabled) {
181
+ const className = String(value || '').trim();
182
+ if (!className || field.disabled.value || field.readOnly.value) return;
183
+ rememberValues([className]);
184
+ const nextEnabled = enabled
185
+ ? uniqueValues([...enabledClasses.value, className])
186
+ : enabledClasses.value.filter((item) => classKey(item) !== classKey(className));
187
+ const nextInactive = enabled
188
+ ? inactiveClasses.value.filter((item) => classKey(item) !== classKey(className))
189
+ : uniqueValues([...inactiveClasses.value, className]);
190
+ commitClasses(nextEnabled, nextInactive);
191
+ emit('toggle', {
192
+ value: className,
193
+ enabled,
194
+ classes: joinClassNames(nextEnabled),
195
+ inactiveValues: nextInactive,
196
+ });
197
+ }
198
+
199
+ function commitClasses(nextEnabled, nextInactive) {
200
+ const nextClassString = joinClassNames(nextEnabled);
201
+ field.setValue(nextClassString);
202
+ emit('update:inactiveValues', nextInactive);
203
+ emit('change', { classes: nextClassString, inactiveValues: nextInactive });
204
+ }
205
+
206
+ function resetQuery() {
207
+ optionMenu.reset();
208
+ }
209
+
210
+ function onFocus(event) {
211
+ field.onFocus(event);
212
+ isOpen.value = true;
213
+ }
214
+
215
+ function onBlur(event) {
216
+ optionMenu.closeSoon();
217
+ field.onBlur(event);
218
+ }
219
+
220
+ function onKeydown(event) {
221
+ if (event.key === 'ArrowDown') {
222
+ event.preventDefault();
223
+ optionMenu.move(1);
224
+ return;
225
+ }
226
+ if (event.key === 'ArrowUp') {
227
+ event.preventDefault();
228
+ optionMenu.move(-1);
229
+ return;
230
+ }
231
+ if (event.key === 'Enter') {
232
+ event.preventDefault();
233
+ const item = menuItems.value[activeIndex.value];
234
+ if (item) addClass(item.value);
235
+ else addClass(trimmedQuery.value);
236
+ return;
237
+ }
238
+ if (event.key === 'Escape') {
239
+ event.preventDefault();
240
+ isOpen.value = false;
241
+ }
242
+ }
243
+
244
+ function onPaste(event) {
245
+ const values = splitClassNames(event.clipboardData?.getData('text') || '');
246
+ if (values.length <= 1) return;
247
+ event.preventDefault();
248
+ for (const value of values) addClass(value);
249
+ }
250
+
251
+ </script>
252
+
253
+ <template>
254
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
255
+ <div class="relative">
256
+ <input
257
+ v-bind="inputAttrs"
258
+ ref="inputEl"
259
+ v-model="query"
260
+ type="text"
261
+ role="combobox"
262
+ :aria-expanded="showMenu"
263
+ :aria-controls="`${field.id.value}-listbox`"
264
+ :placeholder="placeholder"
265
+ class="skin-input h-9 rounded-md font-mono text-sm"
266
+ @focus="onFocus"
267
+ @blur="onBlur"
268
+ @keydown="onKeydown"
269
+ @paste="onPaste"
270
+ />
271
+
272
+ <ul
273
+ v-if="showMenu"
274
+ :id="`${field.id.value}-listbox`"
275
+ role="listbox"
276
+ class="skin-popover absolute z-40 mt-2 max-h-72 w-full overflow-auto rounded-2xl p-1"
277
+ >
278
+ <li v-if="hasEmptyState" class="rounded-xl px-3 py-2 text-sm text-muted-fg">
279
+ {{ emptyText }}
280
+ </li>
281
+ <li
282
+ v-for="(item, index) in menuItems"
283
+ :key="`${item.custom ? 'custom' : 'option'}-${classKey(item.value)}`"
284
+ role="option"
285
+ :aria-selected="activeIndex === index"
286
+ class="cursor-pointer rounded-xl px-3 py-2 text-sm transition"
287
+ :class="activeIndex === index ? 'bg-accent text-accent-fg' : 'hover:bg-accent/70'"
288
+ @mouseenter="activeIndex = index"
289
+ @mousedown.prevent
290
+ @click="addClass(item.value)"
291
+ >
292
+ <span class="block truncate font-mono">{{ item.custom ? `Add ${item.label}` : item.label }}</span>
293
+ <span v-if="item.description" class="block truncate text-xs text-muted-fg">{{ item.description }}</span>
294
+ </li>
295
+ </ul>
296
+
297
+ <div v-if="knownItems.length" class="mt-2 flex flex-wrap gap-x-4 gap-y-2">
298
+ <label
299
+ v-for="item in knownItems"
300
+ :key="classKey(item.value)"
301
+ class="inline-flex min-w-0 items-center gap-1.5 font-mono text-sm text-canvas-fg"
302
+ :class="item.enabled ? '' : 'opacity-50'"
303
+ >
304
+ <input
305
+ type="checkbox"
306
+ class="size-4 rounded border-input text-primary focus:ring-ring/60"
307
+ :checked="item.enabled"
308
+ :disabled="field.disabled.value || field.readOnly.value"
309
+ @change="toggleClass(item.value, $event.target.checked)"
310
+ >
311
+ <span class="truncate">{{ item.label }}</span>
312
+ </label>
313
+ </div>
314
+ </div>
315
+ </DomField>
316
+ </template>
@@ -0,0 +1,202 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, 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
+ const CODEMIRROR_URL = 'https://esm.sh/codemirror@6.0.1';
8
+ const JSON_LANG_URL = 'https://esm.sh/@codemirror/lang-json@6.0.2';
9
+ const JS_LANG_URL = 'https://esm.sh/@codemirror/lang-javascript@6.2.2';
10
+
11
+ defineOptions({
12
+ __doc: {
13
+ name: 'Code input',
14
+ tag: '<DomCodeInput>',
15
+ description: 'A form input for code or structured text. It progressively enhances to CodeMirror from a CDN and falls back to a textarea if loading fails.',
16
+ events: [
17
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the code changes.' },
18
+ { name: 'focus', description: 'Fired when the input receives focus.' },
19
+ { name: 'blur', description: 'Fired when the input loses focus.' },
20
+ ],
21
+ },
22
+ });
23
+
24
+ const props = defineProps({
25
+ ...fieldProps,
26
+ modelValue: {
27
+ type: String,
28
+ default: '',
29
+ _edit: { component: 'DomTextareaInput', props: { rows: 8 }, description: 'Code text.' },
30
+ },
31
+ lang: {
32
+ type: String,
33
+ default: 'text',
34
+ _edit: { options: ['text', 'json', 'js', 'html', 'css', 'vue', 'md'], description: 'Language hint for the optional enhanced editor.' },
35
+ },
36
+ rows: {
37
+ type: Number,
38
+ default: 10,
39
+ _edit: { description: 'Textarea fallback rows.' },
40
+ },
41
+ editor: {
42
+ type: Boolean,
43
+ default: true,
44
+ _edit: { description: 'Attempt to load the enhanced editor from the CDN.' },
45
+ },
46
+ _registerField: {
47
+ type: Boolean,
48
+ default: true,
49
+ },
50
+ });
51
+
52
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
53
+ const mountEl = ref(null);
54
+ const textareaEl = ref(null);
55
+ const enhanced = ref(false);
56
+ const field = useField(props, emit, { idPrefix: 'dom-code-input', register: props._registerField });
57
+ const labelId = computed(() => (field.fieldAttrs.value.label ? `${field.id.value}-label` : ''));
58
+ let editorView = null;
59
+
60
+ function update(value) {
61
+ field.onInput(value);
62
+ }
63
+
64
+ function applyEditorAttrs() {
65
+ if (!editorView?.dom) return;
66
+ editorView.dom.id = field.id.value;
67
+ editorView.dom.setAttribute('role', 'textbox');
68
+ editorView.dom.setAttribute('aria-multiline', 'true');
69
+ if (labelId.value) editorView.dom.setAttribute('aria-labelledby', labelId.value);
70
+ else editorView.dom.removeAttribute('aria-labelledby');
71
+ if (field.describedBy.value) editorView.dom.setAttribute('aria-describedby', field.describedBy.value);
72
+ else editorView.dom.removeAttribute('aria-describedby');
73
+ if (field.invalid.value) editorView.dom.setAttribute('aria-invalid', 'true');
74
+ else editorView.dom.removeAttribute('aria-invalid');
75
+ }
76
+
77
+ function focusControl() {
78
+ if (editorView) {
79
+ editorView.focus();
80
+ return;
81
+ }
82
+ textareaEl.value?.focus?.();
83
+ }
84
+
85
+ async function languageExtension(lang) {
86
+ if (lang === 'json') {
87
+ const mod = await import(/* @vite-ignore */ JSON_LANG_URL);
88
+ return mod.json();
89
+ }
90
+ if (['js', 'vue'].includes(lang)) {
91
+ const mod = await import(/* @vite-ignore */ JS_LANG_URL);
92
+ return mod.javascript();
93
+ }
94
+ return [];
95
+ }
96
+
97
+ async function mountEditor() {
98
+ if (!props.editor || field.disabled.value || typeof window === 'undefined') return;
99
+ try {
100
+ if (!mountEl.value) return;
101
+ const cm = await import(/* @vite-ignore */ CODEMIRROR_URL);
102
+ const lang = await languageExtension(props.lang);
103
+ const theme = cm.EditorView.theme({
104
+ '&': {
105
+ backgroundColor: 'var(--canvas)',
106
+ color: 'var(--canvas-fg)',
107
+ fontSize: '12.5px',
108
+ borderRadius: '0.75rem',
109
+ outline: 'none',
110
+ },
111
+ '.cm-content': {
112
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
113
+ padding: '0.75rem',
114
+ },
115
+ '.cm-scroller': {
116
+ fontFamily: 'inherit',
117
+ },
118
+ '.cm-focused': {
119
+ outline: 'none',
120
+ },
121
+ '.cm-gutters': {
122
+ backgroundColor: 'color-mix(in oklch, var(--secondary) 55%, transparent)',
123
+ color: 'var(--muted-fg)',
124
+ borderRight: '1px solid var(--border)',
125
+ },
126
+ '.cm-activeLineGutter, .cm-activeLine': {
127
+ backgroundColor: 'color-mix(in oklch, var(--secondary) 55%, transparent)',
128
+ },
129
+ });
130
+ editorView = new cm.EditorView({
131
+ doc: field.value.value,
132
+ parent: mountEl.value,
133
+ extensions: [
134
+ cm.basicSetup,
135
+ lang,
136
+ theme,
137
+ cm.EditorView.editable.of(!field.disabled.value && !field.readOnly.value),
138
+ cm.EditorView.lineWrapping,
139
+ cm.EditorView.updateListener.of((viewUpdate) => {
140
+ if (viewUpdate.docChanged) update(viewUpdate.state.doc.toString());
141
+ if (viewUpdate.focusChanged) {
142
+ if (viewUpdate.view.hasFocus) field.onFocus();
143
+ else field.onBlur();
144
+ }
145
+ }),
146
+ ],
147
+ });
148
+ applyEditorAttrs();
149
+ enhanced.value = true;
150
+ } catch {
151
+ enhanced.value = false;
152
+ }
153
+ }
154
+
155
+ watch([labelId, field.describedBy, field.invalid], applyEditorAttrs);
156
+
157
+ watch(field.value, (value) => {
158
+ if (!editorView) return;
159
+ const current = editorView.state.doc.toString();
160
+ if (current === value) return;
161
+ editorView.dispatch({
162
+ changes: { from: 0, to: current.length, insert: value || '' },
163
+ });
164
+ });
165
+
166
+ onMounted(mountEditor);
167
+ onBeforeUnmount(() => {
168
+ editorView?.destroy();
169
+ editorView = null;
170
+ });
171
+ </script>
172
+
173
+ <template>
174
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
175
+ <template #label="{ label, required, htmlFor }">
176
+ <label
177
+ v-if="label"
178
+ :id="labelId || undefined"
179
+ :for="enhanced ? undefined : htmlFor"
180
+ class="block cursor-pointer text-[11px] font-medium uppercase tracking-wider text-muted-fg"
181
+ @click="enhanced ? focusControl() : undefined"
182
+ >
183
+ {{ label }}
184
+ <span v-if="required" class="text-destructive">*</span>
185
+ </label>
186
+ </template>
187
+ <div class="overflow-hidden rounded-xl border border-input bg-canvas focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 data-[invalid]:border-destructive data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive/25" :data-invalid="field.invalid.value ? '' : undefined">
188
+ <div v-show="enhanced" ref="mountEl"></div>
189
+ <textarea
190
+ v-if="!enhanced"
191
+ ref="textareaEl"
192
+ v-bind="field.inputAttrs.value"
193
+ :rows="rows"
194
+ spellcheck="false"
195
+ class="block min-h-40 w-full resize-y bg-canvas p-3 font-mono text-[12.5px] leading-relaxed text-canvas-fg outline-none placeholder:text-muted-fg disabled:opacity-50"
196
+ @input="update($event.target.value)"
197
+ @focus="field.onFocus"
198
+ @blur="field.onBlur"
199
+ ></textarea>
200
+ </div>
201
+ </DomField>
202
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref } from 'vue';
3
+ import DomField from '../field/DomField.vue';
4
+ import { fieldProps } from '../field/fieldProps.js';
5
+ import { useField } from '../field/useField.js';
6
+ import { cssColorToHex, hexToOklch } from '../../lib/css/color.js';
7
+
8
+ defineOptions({
9
+ __doc: {
10
+ name: 'Color input',
11
+ tag: '<DomColorInput>',
12
+ description: 'A compact colour swatch that opens the native colour picker while preserving CSS colour values.',
13
+ events: [
14
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the colour changes.' },
15
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when either control receives focus.' },
16
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when either control loses focus.' },
17
+ ],
18
+ },
19
+ });
20
+
21
+ const props = defineProps({
22
+ ...fieldProps,
23
+ modelValue: {
24
+ type: String,
25
+ default: '#000000',
26
+ _edit: { group: 'Control props', description: 'CSS colour value. Hex and OKLCH values are supported.' },
27
+ },
28
+ });
29
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
30
+ const field = useField(props, emit, { idPrefix: 'dom-color-input' });
31
+ const canResolveBrowserColors = ref(false);
32
+
33
+ const displayColor = computed(() => String(field.value.value || 'transparent').trim() || 'transparent');
34
+ const swatchValue = computed(() => cssColorToHex(displayColor.value));
35
+ const nativeColorValue = computed(() => {
36
+ if (!canResolveBrowserColors.value && needsBrowserColorResolution(displayColor.value)) return '#000000';
37
+ return swatchValue.value;
38
+ });
39
+ const nativeColorAttrs = computed(() => ({
40
+ id: field.id.value,
41
+ name: field.htmlName.value,
42
+ value: nativeColorValue.value,
43
+ disabled: field.disabled.value || field.readOnly.value || undefined,
44
+ required: props.required || undefined,
45
+ 'aria-invalid': field.invalid.value || undefined,
46
+ 'aria-describedby': field.describedBy.value || undefined,
47
+ 'aria-errormessage': field.errorId.value || undefined,
48
+ 'data-invalid': field.invalid.value ? '' : undefined,
49
+ 'aria-label': props.label ? undefined : 'Change colour',
50
+ title: displayColor.value,
51
+ }));
52
+ const swatchStyle = computed(() => ({
53
+ '--dom-color-swatch': displayColor.value,
54
+ }));
55
+
56
+ function onNativeColorInput(value) {
57
+ const currentValue = String(field.value.value || '').trim();
58
+ field.onInput(/^oklch\(/i.test(currentValue) ? hexToOklch(value) : value);
59
+ }
60
+
61
+ function needsBrowserColorResolution(value) {
62
+ const color = String(value || '').trim();
63
+ if (!color || color === 'transparent') return false;
64
+ return !(
65
+ /^#[0-9a-f]{3,8}$/i.test(color)
66
+ || /^rgba?\(/i.test(color)
67
+ || /^oklch\(/i.test(color)
68
+ || /^color\(\s*srgb\s+/i.test(color)
69
+ );
70
+ }
71
+
72
+ onMounted(() => {
73
+ canResolveBrowserColors.value = true;
74
+ });
75
+ </script>
76
+
77
+ <template>
78
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
79
+ <div
80
+ class="dom-color-control relative h-10 w-full overflow-hidden rounded-lg border border-border shadow-sm transition focus-within:ring-2 focus-within:ring-ring/50"
81
+ :class="(field.disabled.value || field.readOnly.value) && 'cursor-not-allowed opacity-50'"
82
+ >
83
+ <span class="dom-color-swatch absolute inset-0" :style="swatchStyle"></span>
84
+ <span class="sr-only">{{ displayColor }}</span>
85
+ <input
86
+ v-bind="nativeColorAttrs"
87
+ type="color"
88
+ class="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
89
+ @input="onNativeColorInput($event.target.value)"
90
+ @focus="field.onFocus"
91
+ @blur="field.onBlur"
92
+ />
93
+ </div>
94
+ </DomField>
95
+ </template>
96
+
97
+ <style scoped>
98
+ .dom-color-control[data-invalid],
99
+ .dom-color-control:has(input[data-invalid]) {
100
+ border-color: var(--destructive);
101
+ }
102
+
103
+ .dom-color-swatch {
104
+ background:
105
+ linear-gradient(var(--dom-color-swatch), var(--dom-color-swatch)),
106
+ conic-gradient(
107
+ from 45deg,
108
+ color-mix(in oklch, var(--border) 60%, white) 0 25%,
109
+ transparent 0 50%,
110
+ color-mix(in oklch, var(--border) 60%, white) 0 75%,
111
+ transparent 0
112
+ );
113
+ background-color: var(--dom-color-swatch);
114
+ background-size:
115
+ 100% 100%,
116
+ 14px 14px;
117
+ }
118
+ </style>