@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,358 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref } from 'vue';
3
+ import DomFieldLoadingSpinner from '../_shared/DomFieldLoadingSpinner.vue';
4
+ import { booleanProp, normalizeOption, optionKey, searchableText, splitTokens as splitOptionTokens } from '../_shared/options.js';
5
+ import { useOptionMenu } from '../_shared/useOptionMenu.js';
6
+ import DomField from '../field/DomField.vue';
7
+ import { fieldProps } from '../field/fieldProps.js';
8
+ import { useField } from '../field/useField.js';
9
+
10
+ defineOptions({
11
+ __doc: {
12
+ name: 'Tag combobox',
13
+ tag: '<DomTagCombobox>',
14
+ description: 'A tokenizing multi-select combobox for class lists, recipients, labels, and other values that need autocomplete plus optional custom entries.',
15
+ icon: 'M7 5h5l5 5-7 7-5-5 7-7Zm1.5 4.5h.01M15 15h4M17 13v4',
16
+ slots: [
17
+ { name: 'item', payload: '{ item, index, active, custom }', description: 'Replaces each suggestion row.' },
18
+ { name: 'tag', payload: '{ item, value, label, remove }', description: 'Replaces each selected token.' },
19
+ ],
20
+ events: [
21
+ { name: 'query', payload: 'string', description: 'Fired as the user types.' },
22
+ { name: 'add', payload: '({ item, value, label, custom })', description: 'Fired when a tag is added.' },
23
+ { name: 'remove', payload: '({ item, value, label })', description: 'Fired when a tag is removed.' },
24
+ { name: 'select', payload: '({ item, value, label, custom })', description: 'Fired when a suggestion or custom value is committed.' },
25
+ { name: 'change', payload: 'Array', description: 'Fired after the selected values array changes.' },
26
+ ],
27
+ },
28
+ });
29
+
30
+ const props = defineProps({
31
+ ...fieldProps,
32
+ modelValue: {
33
+ type: Array,
34
+ default: () => [],
35
+ _edit: {
36
+ component: 'DomJsonInput',
37
+ description: 'Selected tag values.',
38
+ },
39
+ },
40
+ options: {
41
+ type: Array,
42
+ default: () => [],
43
+ _edit: {
44
+ component: 'DomJsonListInput',
45
+ description: 'Autocomplete options. Strings are treated as both label and value.',
46
+ props: {
47
+ compact: true,
48
+ addLabel: '+ Add option',
49
+ schema: [
50
+ { key: 'label', label: 'Label', placeholder: 'Option label', default: (index) => `Option ${index + 1}` },
51
+ { key: 'value', label: 'Value', placeholder: 'option-value', default: (index) => `option-${index + 1}` },
52
+ { key: 'description', label: 'Description', placeholder: 'Optional helper text' },
53
+ { key: 'group', label: 'Group', placeholder: 'Optional group' },
54
+ ],
55
+ },
56
+ },
57
+ },
58
+ placeholder: {
59
+ type: String,
60
+ default: 'Add tags...',
61
+ _edit: { group: 'Control props', description: 'Placeholder shown when no query is active.' },
62
+ },
63
+ allowCustom: {
64
+ type: [Boolean, String],
65
+ default: false,
66
+ _edit: { group: 'Control props', description: 'Allow values that do not exist in options.' },
67
+ },
68
+ filterOptions: {
69
+ type: [Boolean, String],
70
+ default: true,
71
+ _edit: { group: 'Control props', description: 'Filter options locally. Turn off when options are already server-filtered.' },
72
+ },
73
+ loading: {
74
+ type: [Boolean, String],
75
+ default: false,
76
+ _edit: { group: 'Control props', description: 'Show an inline loading spinner while async options are being fetched.' },
77
+ },
78
+ emptyText: {
79
+ type: String,
80
+ default: 'No matches',
81
+ _edit: { group: 'Control props', description: 'Message shown when no option matches and custom values are not available.' },
82
+ },
83
+ maxOptions: {
84
+ type: Number,
85
+ default: 8,
86
+ _edit: { group: 'Control props', description: 'Maximum matching suggestions to show.' },
87
+ },
88
+ tokenSeparators: {
89
+ type: Array,
90
+ default: () => [' ', ','],
91
+ _edit: { component: 'DomJsonInput', group: 'Control props', description: 'Characters that split pasted text into tags.' },
92
+ },
93
+ clearable: {
94
+ type: [Boolean, String],
95
+ default: false,
96
+ _edit: { group: 'Control props', description: 'Show a clear-all button when tags are selected.' },
97
+ },
98
+ });
99
+
100
+ const emit = defineEmits(['update:modelValue', 'query', 'add', 'remove', 'select', 'change', 'focus', 'blur']);
101
+
102
+ const inputEl = ref(null);
103
+ const query = ref('');
104
+ const field = useField(props, emit, { idPrefix: 'dom-tag-combobox' });
105
+
106
+ const selectedValues = computed(() => Array.isArray(field.value.value) ? field.value.value : []);
107
+ const normalizedOptions = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
108
+ const selectedItems = computed(() => selectedValues.value.map((value) => optionForValue(value) || customOption(value)));
109
+ const selectedValueKeys = computed(() => new Set(selectedValues.value.map(optionKey)));
110
+ const isClearable = computed(() => booleanProp(props.clearable, false));
111
+ const isCustomAllowed = computed(() => booleanProp(props.allowCustom, false));
112
+ const isFilteringOptions = computed(() => booleanProp(props.filterOptions, true));
113
+ const isLoading = computed(() => booleanProp(props.loading, false));
114
+ const controlInputAttrs = computed(() => {
115
+ const { value, placeholder, ...attrs } = field.inputAttrs.value;
116
+ return attrs;
117
+ });
118
+ const trimmedQuery = computed(() => query.value.trim());
119
+ const exactQueryOption = computed(() => normalizedOptions.value.find((option) => optionKey(option.value) === optionKey(trimmedQuery.value)));
120
+ const canCreateQuery = computed(() => {
121
+ if (!isCustomAllowed.value || !trimmedQuery.value) return false;
122
+ if (selectedValueKeys.value.has(optionKey(trimmedQuery.value))) return false;
123
+ return !exactQueryOption.value;
124
+ });
125
+ const matchingOptions = computed(() => {
126
+ const needle = trimmedQuery.value.toLowerCase();
127
+ return normalizedOptions.value
128
+ .filter((option) => !option.disabled)
129
+ .filter((option) => !selectedValueKeys.value.has(optionKey(option.value)))
130
+ .filter((option) => {
131
+ if (!needle || !isFilteringOptions.value) return true;
132
+ return searchableText(option).includes(needle);
133
+ })
134
+ .slice(0, Math.max(1, props.maxOptions));
135
+ });
136
+ const menuItems = computed(() => {
137
+ const items = matchingOptions.value.map((item) => ({ item, value: item.value, label: item.label, custom: false }));
138
+ if (canCreateQuery.value) {
139
+ items.push({
140
+ item: customOption(trimmedQuery.value),
141
+ value: trimmedQuery.value,
142
+ label: trimmedQuery.value,
143
+ custom: true,
144
+ });
145
+ }
146
+ return items;
147
+ });
148
+ const optionMenu = useOptionMenu(menuItems, {
149
+ query,
150
+ emitQuery: (value) => emit('query', value),
151
+ });
152
+ const { activeIndex, isOpen } = optionMenu;
153
+ const hasEmptyState = computed(() => !isLoading.value && !menuItems.value.length && trimmedQuery.value && !isCustomAllowed.value);
154
+ const showMenu = computed(() => isOpen.value && (menuItems.value.length > 0 || hasEmptyState.value) && !field.disabled.value && !field.readOnly.value);
155
+ const hasTags = computed(() => selectedValues.value.length > 0);
156
+
157
+ function customOption(value) {
158
+ return { value, label: String(value), custom: true };
159
+ }
160
+
161
+ function optionForValue(value) {
162
+ return normalizedOptions.value.find((option) => optionKey(option.value) === optionKey(value));
163
+ }
164
+
165
+ function commitItem(entry) {
166
+ if (!entry) return;
167
+ commitValue(entry.value, entry.item, entry.custom);
168
+ }
169
+
170
+ function commitValue(value, item = null, custom = false) {
171
+ if (field.disabled.value || field.readOnly.value) return;
172
+ if (value == null || value === '') return;
173
+ if (selectedValueKeys.value.has(optionKey(value))) {
174
+ resetQuery();
175
+ return;
176
+ }
177
+ if (!custom && !optionForValue(value) && !isCustomAllowed.value) return;
178
+
179
+ const nextValue = [...selectedValues.value, value];
180
+ field.setValue(nextValue);
181
+ emit('add', { item, value, label: item?.label ?? String(value), custom });
182
+ emit('select', { item, value, label: item?.label ?? String(value), custom });
183
+ emit('change', nextValue);
184
+ resetQuery();
185
+ nextTick(() => inputEl.value?.focus());
186
+ }
187
+
188
+ function removeValue(value) {
189
+ if (field.disabled.value || field.readOnly.value) return;
190
+ const item = optionForValue(value) || customOption(value);
191
+ const nextValue = selectedValues.value.filter((candidate) => optionKey(candidate) !== optionKey(value));
192
+ field.setValue(nextValue);
193
+ emit('remove', { item, value, label: item.label });
194
+ emit('change', nextValue);
195
+ nextTick(() => inputEl.value?.focus());
196
+ }
197
+
198
+ function clearAll() {
199
+ if (field.disabled.value || field.readOnly.value) return;
200
+ field.setValue([]);
201
+ emit('change', []);
202
+ nextTick(() => inputEl.value?.focus());
203
+ }
204
+
205
+ function resetQuery() {
206
+ optionMenu.reset();
207
+ }
208
+
209
+ function onFocus(event) {
210
+ field.onFocus(event);
211
+ isOpen.value = true;
212
+ }
213
+
214
+ function onBlur(event) {
215
+ optionMenu.closeSoon();
216
+ field.onBlur(event);
217
+ }
218
+
219
+ function onKeydown(event) {
220
+ if (event.key === 'ArrowDown') {
221
+ event.preventDefault();
222
+ optionMenu.move(1);
223
+ return;
224
+ }
225
+ if (event.key === 'ArrowUp') {
226
+ event.preventDefault();
227
+ optionMenu.move(-1);
228
+ return;
229
+ }
230
+ if (event.key === 'Enter') {
231
+ event.preventDefault();
232
+ if (menuItems.value[activeIndex.value]) commitItem(menuItems.value[activeIndex.value]);
233
+ else if (isCustomAllowed.value) commitValue(trimmedQuery.value, customOption(trimmedQuery.value), true);
234
+ return;
235
+ }
236
+ if (event.key === 'Escape') {
237
+ event.preventDefault();
238
+ isOpen.value = false;
239
+ return;
240
+ }
241
+ if (event.key === 'Backspace' && !query.value && selectedValues.value.length) {
242
+ removeValue(selectedValues.value[selectedValues.value.length - 1]);
243
+ }
244
+ }
245
+
246
+ function onPaste(event) {
247
+ const text = event.clipboardData?.getData('text') || '';
248
+ const tokens = splitTokens(text);
249
+ if (tokens.length <= 1) return;
250
+ event.preventDefault();
251
+ for (const token of tokens) {
252
+ const option = optionForValue(token);
253
+ if (option) commitValue(option.value, option, false);
254
+ else if (isCustomAllowed.value) commitValue(token, customOption(token), true);
255
+ }
256
+ }
257
+
258
+ function splitTokens(value) {
259
+ return splitOptionTokens(value, props.tokenSeparators);
260
+ }
261
+ </script>
262
+
263
+ <template>
264
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
265
+ <div class="relative">
266
+ <div
267
+ class="flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-2xl border border-input bg-canvas px-2 py-1.5 text-sm shadow-sm transition focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20 data-[invalid]:border-destructive"
268
+ :data-invalid="field.invalid.value ? '' : undefined"
269
+ @click="inputEl?.focus()"
270
+ >
271
+ <span
272
+ v-for="entry in selectedItems"
273
+ :key="optionKey(entry.value)"
274
+ class="inline-flex max-w-full items-center gap-1 rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-fg ring-1 ring-border"
275
+ >
276
+ <slot name="tag" :item="entry" :value="entry.value" :label="entry.label" :remove="() => removeValue(entry.value)">
277
+ <span class="truncate">{{ entry.label }}</span>
278
+ <button
279
+ type="button"
280
+ class="-mr-1 inline-flex size-4 items-center justify-center rounded-full text-muted-fg hover:bg-canvas hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
281
+ :aria-label="`Remove ${entry.label}`"
282
+ @click.stop="removeValue(entry.value)"
283
+ @mousedown.prevent
284
+ >
285
+ <svg viewBox="0 0 16 16" class="size-3" fill="none" aria-hidden="true">
286
+ <path d="M5 5l6 6M11 5l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
287
+ </svg>
288
+ </button>
289
+ </slot>
290
+ </span>
291
+ <input
292
+ v-bind="controlInputAttrs"
293
+ ref="inputEl"
294
+ v-model="query"
295
+ type="text"
296
+ role="combobox"
297
+ :aria-expanded="showMenu"
298
+ :aria-controls="`${field.id.value}-listbox`"
299
+ :placeholder="hasTags ? '' : placeholder"
300
+ class="min-w-28 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-fg disabled:cursor-not-allowed"
301
+ @focus="onFocus"
302
+ @blur="onBlur"
303
+ @keydown="onKeydown"
304
+ @paste="onPaste"
305
+ />
306
+ <DomFieldLoadingSpinner
307
+ v-if="isLoading"
308
+ />
309
+ <button
310
+ v-if="isClearable && hasTags"
311
+ type="button"
312
+ class="inline-flex size-7 shrink-0 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"
313
+ aria-label="Clear tags"
314
+ @click.stop="clearAll"
315
+ @mousedown.prevent
316
+ >
317
+ <svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
318
+ <path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
319
+ </svg>
320
+ </button>
321
+ </div>
322
+
323
+ <ul
324
+ v-if="showMenu"
325
+ :id="`${field.id.value}-listbox`"
326
+ role="listbox"
327
+ class="skin-popover absolute z-40 mt-2 max-h-72 w-full overflow-auto rounded-2xl p-1"
328
+ >
329
+ <li v-if="hasEmptyState" class="rounded-xl px-3 py-2 text-sm text-muted-fg">
330
+ {{ emptyText }}
331
+ </li>
332
+ <li
333
+ v-for="(entry, index) in menuItems"
334
+ :key="`${entry.custom ? 'custom' : 'option'}-${optionKey(entry.value)}`"
335
+ role="option"
336
+ :aria-selected="activeIndex === index"
337
+ class="cursor-pointer rounded-xl px-3 py-2 text-sm transition"
338
+ :class="activeIndex === index ? 'bg-accent text-accent-fg' : 'hover:bg-accent/70'"
339
+ @mouseenter="activeIndex = index"
340
+ @mousedown.prevent
341
+ @click="commitItem(entry)"
342
+ >
343
+ <slot name="item" :item="entry.item" :index="index" :active="activeIndex === index" :custom="entry.custom">
344
+ <div class="flex min-w-0 items-center justify-between gap-3">
345
+ <span class="min-w-0">
346
+ <span class="block truncate font-medium">{{ entry.custom ? `Add "${entry.label}"` : entry.label }}</span>
347
+ <span v-if="entry.item.description || entry.item.group" class="block truncate text-xs text-muted-fg">
348
+ {{ entry.item.description || entry.item.group }}
349
+ </span>
350
+ </span>
351
+ <span v-if="entry.custom" class="shrink-0 rounded-full bg-canvas/70 px-2 py-0.5 text-[11px] text-muted-fg ring-1 ring-border">custom</span>
352
+ </div>
353
+ </slot>
354
+ </li>
355
+ </ul>
356
+ </div>
357
+ </DomField>
358
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup>
2
+ import DomField from '../field/DomField.vue';
3
+ import { fieldProps } from '../field/fieldProps.js';
4
+ import { useField } from '../field/useField.js';
5
+
6
+ defineOptions({
7
+ __doc: {
8
+ name: 'Text input',
9
+ tag: '<DomTextInput>',
10
+ description: 'A styled text field that uses the shared field contract for labels, validation state, and optional form-provider integration.',
11
+ events: [
12
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the text changes.' },
13
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the input receives focus.' },
14
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the input loses focus.' },
15
+ ],
16
+ },
17
+ });
18
+
19
+ const props = defineProps({
20
+ ...fieldProps,
21
+ type: {
22
+ type: String,
23
+ default: 'text',
24
+ _edit: { description: 'Native input type.' },
25
+ },
26
+ autocomplete: {
27
+ type: String,
28
+ default: '',
29
+ _edit: { description: 'Native autocomplete hint.' },
30
+ },
31
+ });
32
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
33
+ const field = useField(props, emit, { idPrefix: 'dom-text-input' });
34
+ </script>
35
+
36
+ <template>
37
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
38
+ <input
39
+ v-bind="field.inputAttrs.value"
40
+ :type="type"
41
+ :autocomplete="autocomplete || undefined"
42
+ class="skin-input h-10"
43
+ @input="field.onInput($event.target.value)"
44
+ @focusin="field.onFocus"
45
+ @focusout="field.onBlur"
46
+ />
47
+ </DomField>
48
+ </template>
@@ -0,0 +1,234 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import DomIconButton from '../../components/icon-button/DomIconButton.vue';
4
+ import DomField from '../field/DomField.vue';
5
+ import { fieldProps } from '../field/fieldProps.js';
6
+ import { useField } from '../field/useField.js';
7
+ import { useTextareaAutosize } from '../textarea-input/useTextareaAutosize.js';
8
+
9
+ defineOptions({
10
+ __doc: {
11
+ name: 'Textarea composer',
12
+ tag: '<DomTextareaComposer>',
13
+ description: 'An autosizing multiline field with attachment content, leading controls, trailing actions, and submit behavior inside one visual input.',
14
+ icon: 'M5 6h14v10H9l-4 3V6Zm3 4h8M8 13h5',
15
+ events: [
16
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the composer text changes.' },
17
+ { name: 'submit', payload: 'string', description: 'Fired with the current text when the built-in submit action is used.' },
18
+ { name: 'keydown', payload: 'KeyboardEvent', description: 'Fired before the built-in Enter key behavior runs.' },
19
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the textarea receives focus.' },
20
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the textarea loses focus.' },
21
+ ],
22
+ slots: [
23
+ { name: 'attachments', payload: '{ value, disabled, readOnly }', description: 'File, image, reference, or context previews shown above the text.' },
24
+ { name: 'leading', payload: '{ value, submit, disabled }', description: 'Controls at the start of the bottom action row, such as add or tool buttons.' },
25
+ { name: 'actions', payload: '{ value, submit, disabled }', description: 'Additional controls before the built-in submit action.' },
26
+ { name: 'submit', payload: '{ submit, disabled, submitting, label }', description: 'Replace the built-in circular submit button.' },
27
+ ],
28
+ keyboard: [
29
+ { keys: 'Enter', action: 'Submit when submitOnEnter is enabled.' },
30
+ { keys: 'Shift + Enter', action: 'Insert a new line.' },
31
+ ],
32
+ },
33
+ });
34
+
35
+ const props = defineProps({
36
+ ...fieldProps,
37
+ rows: {
38
+ type: Number,
39
+ default: 1,
40
+ _edit: { group: 'Control props', min: 1, description: 'Minimum visible text rows.' },
41
+ },
42
+ autosize: {
43
+ type: Boolean,
44
+ default: true,
45
+ _edit: { group: 'Control props', description: 'Grow and shrink the text area to match its content.' },
46
+ },
47
+ maxRows: {
48
+ type: Number,
49
+ default: 8,
50
+ _edit: { group: 'Control props', min: 0, description: 'Maximum rows before text scrolls. Use 0 for no maximum.' },
51
+ },
52
+ ariaLabel: {
53
+ type: String,
54
+ default: 'Message',
55
+ _edit: { group: 'Control props', description: 'Accessible textarea label used when no visible field label is supplied.' },
56
+ },
57
+ showSubmit: {
58
+ type: Boolean,
59
+ default: true,
60
+ _edit: { group: 'Submit props', description: 'Show the built-in circular submit action.' },
61
+ },
62
+ submitLabel: {
63
+ type: String,
64
+ default: 'Send message',
65
+ _edit: { group: 'Submit props', description: 'Accessible label for the built-in submit action.' },
66
+ },
67
+ submitDisabled: {
68
+ type: Boolean,
69
+ default: false,
70
+ _edit: { group: 'Submit props', description: 'Disable submission in addition to field and empty-value checks.' },
71
+ },
72
+ submitting: {
73
+ type: Boolean,
74
+ default: false,
75
+ _edit: { group: 'Submit props', description: 'Show progress and block repeated submission.' },
76
+ },
77
+ submitOnEnter: {
78
+ type: Boolean,
79
+ default: true,
80
+ _edit: { group: 'Submit props', description: 'Submit on Enter while Shift + Enter continues to add a new line.' },
81
+ },
82
+ allowEmptySubmit: {
83
+ type: Boolean,
84
+ default: false,
85
+ _edit: { group: 'Submit props', description: 'Allow attachment-only or control-defined submission when the text is empty.' },
86
+ },
87
+ });
88
+ const emit = defineEmits(['update:modelValue', 'submit', 'keydown', 'focus', 'blur']);
89
+ const field = useField(props, emit, { idPrefix: 'dom-textarea-composer' });
90
+ const autosize = computed(() => props.autosize);
91
+ const rows = computed(() => props.rows);
92
+ const maxRows = computed(() => props.maxRows);
93
+ const text = computed(() => String(field.value.value ?? ''));
94
+ const controlsDisabled = computed(() => (
95
+ field.disabled.value
96
+ || field.readOnly.value
97
+ || props.submitting
98
+ ));
99
+ const canSubmit = computed(() => (
100
+ !controlsDisabled.value
101
+ && !props.submitDisabled
102
+ && (props.allowEmptySubmit || text.value.trim().length > 0)
103
+ ));
104
+ const { textarea, resize } = useTextareaAutosize({
105
+ value: field.value,
106
+ autosize,
107
+ rows,
108
+ maxRows,
109
+ });
110
+
111
+ /**
112
+ * Writes native composer input through the shared form-field contract.
113
+ *
114
+ * @param {InputEvent} event Native input event.
115
+ * @returns {void}
116
+ */
117
+ function onInput(event) {
118
+ field.onInput(event.target.value);
119
+ resize();
120
+ }
121
+
122
+ /**
123
+ * Emits the current value when submission is available.
124
+ *
125
+ * @param {Event} [event] Native keyboard or click event that requested submission.
126
+ * @returns {void}
127
+ */
128
+ function submit(event) {
129
+ if (!canSubmit.value) return;
130
+ emit('submit', text.value, event);
131
+ }
132
+
133
+ /**
134
+ * Exposes keyboard activity and applies the composer Enter convention.
135
+ *
136
+ * @param {KeyboardEvent} event Native keydown event.
137
+ * @returns {void}
138
+ */
139
+ function onKeydown(event) {
140
+ emit('keydown', event);
141
+ if (
142
+ event.defaultPrevented
143
+ || !props.submitOnEnter
144
+ || event.isComposing
145
+ || event.key !== 'Enter'
146
+ || event.shiftKey
147
+ ) return;
148
+ event.preventDefault();
149
+ submit(event);
150
+ }
151
+
152
+ /**
153
+ * Moves focus to the native textarea.
154
+ *
155
+ * @returns {void}
156
+ */
157
+ function focus() {
158
+ textarea.value?.focus();
159
+ }
160
+
161
+ defineExpose({ focus, resize });
162
+ </script>
163
+
164
+ <template>
165
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
166
+ <div
167
+ class="skin-input overflow-hidden p-0 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 data-[invalid]:focus-within:border-destructive data-[invalid]:focus-within:ring-destructive/25"
168
+ :data-invalid="field.invalid.value ? '' : undefined"
169
+ :aria-busy="submitting || undefined"
170
+ >
171
+ <div v-if="$slots.attachments" class="flex flex-wrap gap-2 px-3 pt-3">
172
+ <slot
173
+ name="attachments"
174
+ :value="text"
175
+ :disabled="field.disabled.value"
176
+ :read-only="field.readOnly.value"
177
+ />
178
+ </div>
179
+
180
+ <textarea
181
+ ref="textarea"
182
+ :id="field.id.value"
183
+ :name="field.htmlName.value"
184
+ :value="text"
185
+ :placeholder="placeholder"
186
+ :rows="rows"
187
+ :disabled="field.disabled.value || undefined"
188
+ :readonly="field.readOnly.value || undefined"
189
+ :required="required || undefined"
190
+ :aria-label="label ? undefined : ariaLabel"
191
+ :aria-invalid="field.invalid.value || undefined"
192
+ :aria-describedby="field.describedBy.value || undefined"
193
+ :aria-errormessage="field.errorId.value || undefined"
194
+ :class="[
195
+ 'block w-full border-0 bg-transparent px-3 pb-2 pt-3 text-base leading-6 text-canvas-fg outline-none placeholder:text-muted-fg disabled:cursor-not-allowed disabled:opacity-50',
196
+ autosize ? 'resize-none overflow-y-hidden' : 'resize-y',
197
+ ]"
198
+ @input="onInput"
199
+ @keydown="onKeydown"
200
+ @focus="field.onFocus"
201
+ @blur="field.onBlur"
202
+ ></textarea>
203
+
204
+ <div
205
+ v-if="$slots.leading || $slots.actions || showSubmit"
206
+ class="flex min-h-10 items-end justify-between gap-2 px-2 pb-2"
207
+ >
208
+ <div class="flex min-w-0 flex-1 items-center gap-1">
209
+ <slot name="leading" :value="text" :submit="submit" :disabled="controlsDisabled" />
210
+ </div>
211
+ <div class="flex shrink-0 items-center gap-1">
212
+ <slot name="actions" :value="text" :submit="submit" :disabled="controlsDisabled" />
213
+ <slot
214
+ v-if="showSubmit"
215
+ name="submit"
216
+ :submit="submit"
217
+ :disabled="!canSubmit"
218
+ :submitting="submitting"
219
+ :label="submitLabel"
220
+ >
221
+ <DomIconButton
222
+ :label="submitLabel"
223
+ :disabled="!canSubmit"
224
+ :loading="submitting"
225
+ variant="primary"
226
+ icon="M12 19V5m0 0-6 6m6-6 6 6"
227
+ @click="submit"
228
+ />
229
+ </slot>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </DomField>
234
+ </template>