@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,361 @@
1
+ <script setup>
2
+ import { computed, 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: 'File upload',
10
+ tag: '<DomFileUpload>',
11
+ description: 'A generic local file picker and dropzone for forms. Uploading, persistence, and media APIs stay parent-owned.',
12
+ icon: 'M12 16V4M8 8l4-4 4 4M5 16v3h14v-3',
13
+ events: [
14
+ { name: 'update:modelValue', payload: 'Array<File | DomUploadedFile>', description: 'Fired when the local file list changes.' },
15
+ { name: 'add', payload: 'Array<File>', description: 'Fired with accepted local File objects.' },
16
+ { name: 'remove', payload: '{ file, index }', description: 'Fired when a file row is removed.' },
17
+ { name: 'reject', payload: 'Array<DomFileUploadRejection>', description: 'Fired when files fail accept, size, or count checks.' },
18
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the native file input receives focus.' },
19
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the native file input loses focus.' },
20
+ ],
21
+ playground: {
22
+ initial: {
23
+ label: 'Receipt',
24
+ description: 'Attach images or PDFs. Uploading is handled by the parent.',
25
+ accept: 'image/*,.pdf',
26
+ maxFiles: 3,
27
+ },
28
+ },
29
+ },
30
+ });
31
+
32
+ const props = defineProps({
33
+ ...fieldProps,
34
+ modelValue: {
35
+ type: Array,
36
+ default: () => [],
37
+ _edit: { description: 'Selected local files or existing uploaded file metadata.' },
38
+ },
39
+ accept: {
40
+ type: String,
41
+ default: '',
42
+ _edit: { description: 'Native accept string, such as image/*,.pdf.' },
43
+ },
44
+ multiple: {
45
+ type: Boolean,
46
+ default: true,
47
+ _edit: { description: 'Allow multiple files.' },
48
+ },
49
+ maxFiles: {
50
+ type: Number,
51
+ default: 0,
52
+ _edit: { description: 'Maximum file count. Use 0 for no explicit limit.' },
53
+ },
54
+ maxSize: {
55
+ type: Number,
56
+ default: 0,
57
+ _edit: { description: 'Maximum file size in bytes. Use 0 for no explicit limit.' },
58
+ },
59
+ browseLabel: {
60
+ type: String,
61
+ default: 'Choose files',
62
+ _edit: { description: 'Browse button label.' },
63
+ },
64
+ emptyTitle: {
65
+ type: String,
66
+ default: 'Drop files here',
67
+ _edit: { description: 'Title shown when no files are selected.' },
68
+ },
69
+ emptyDescription: {
70
+ type: String,
71
+ default: 'or browse from your device',
72
+ _edit: { description: 'Helper copy shown in the dropzone.' },
73
+ },
74
+ });
75
+
76
+ const emit = defineEmits(['update:modelValue', 'add', 'remove', 'reject', 'focus', 'blur']);
77
+ const field = useField(props, emit, { idPrefix: 'dom-file-upload' });
78
+ const input = ref(null);
79
+ const dragging = ref(false);
80
+ const lastRejections = ref([]);
81
+
82
+ const files = computed(() => toArray(field.value.value).map(normalizeFileRecord));
83
+ const hasFiles = computed(() => files.value.length > 0);
84
+ const disabled = computed(() => field.disabled.value || field.readOnly.value);
85
+ const rejectionErrors = computed(() => uniqueMessages(
86
+ lastRejections.value.map((rejection) => {
87
+ const name = rejection.file?.name;
88
+ return name ? `${name}: ${rejection.message}` : rejection.message;
89
+ }),
90
+ ));
91
+ const displayedErrors = computed(() => uniqueMessages([
92
+ ...field.errors.value,
93
+ ...rejectionErrors.value,
94
+ ]));
95
+ const fieldAttrs = computed(() => ({
96
+ ...field.fieldAttrs.value,
97
+ errors: displayedErrors.value,
98
+ invalid: field.invalid.value || rejectionErrors.value.length > 0,
99
+ errorId: displayedErrors.value.length ? `${field.id.value}-error` : field.fieldAttrs.value.errorId,
100
+ }));
101
+ const describedBy = computed(() => [
102
+ field.descriptionId.value,
103
+ fieldAttrs.value.errorId,
104
+ ].filter(Boolean).join(' '));
105
+ const dropzoneClass = computed(() => [
106
+ 'rounded-lg border border-dashed bg-canvas px-4 py-5 text-center shadow-sm transition',
107
+ fieldAttrs.value.invalid ? 'border-destructive' : 'border-border',
108
+ dragging.value && !disabled.value && 'border-primary bg-primary/5',
109
+ disabled.value ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-primary/45 hover:bg-secondary/40',
110
+ ]);
111
+
112
+ watch(() => props.modelValue, () => {
113
+ lastRejections.value = [];
114
+ });
115
+
116
+ function toArray(value) {
117
+ if (!value) return [];
118
+ if (Array.isArray(value)) return value.filter(Boolean);
119
+ return [value].filter(Boolean);
120
+ }
121
+
122
+ function uniqueMessages(messages) {
123
+ const seen = new Set();
124
+ return messages
125
+ .map((message) => String(message || '').trim())
126
+ .filter((message) => {
127
+ if (!message || seen.has(message)) return false;
128
+ seen.add(message);
129
+ return true;
130
+ });
131
+ }
132
+
133
+ function isNativeFile(value) {
134
+ return typeof File !== 'undefined' && value instanceof File;
135
+ }
136
+
137
+ function normalizeFileRecord(file) {
138
+ if (isNativeFile(file)) {
139
+ return {
140
+ raw: file,
141
+ name: file.name,
142
+ size: file.size,
143
+ type: file.type,
144
+ local: true,
145
+ };
146
+ }
147
+
148
+ if (file && typeof file === 'object') {
149
+ return {
150
+ raw: file,
151
+ name: file.name || file.filename || file.id || 'Untitled file',
152
+ size: file.size,
153
+ type: file.type || file.mimeType || '',
154
+ url: file.url || file.previewUrl || '',
155
+ local: false,
156
+ };
157
+ }
158
+
159
+ return {
160
+ raw: file,
161
+ name: String(file),
162
+ size: null,
163
+ type: '',
164
+ local: false,
165
+ };
166
+ }
167
+
168
+ function openPicker() {
169
+ if (disabled.value) return;
170
+ input.value?.click();
171
+ }
172
+
173
+ function onInputChange(event) {
174
+ acceptFiles(Array.from(event.target.files || []));
175
+ event.target.value = '';
176
+ }
177
+
178
+ function onDragEnter() {
179
+ if (!disabled.value) dragging.value = true;
180
+ }
181
+
182
+ function onDragLeave(event) {
183
+ if (!event.currentTarget.contains(event.relatedTarget)) dragging.value = false;
184
+ }
185
+
186
+ function onDrop(event) {
187
+ dragging.value = false;
188
+ if (disabled.value) return;
189
+ acceptFiles(Array.from(event.dataTransfer?.files || []));
190
+ }
191
+
192
+ function acceptFiles(nextFiles) {
193
+ if (disabled.value || !nextFiles.length) return;
194
+
195
+ lastRejections.value = [];
196
+ const accepted = [];
197
+ const rejected = [];
198
+ const current = props.multiple ? toArray(field.value.value) : [];
199
+ const maxFiles = Number(props.maxFiles);
200
+ const limit = props.multiple
201
+ ? Number.isFinite(maxFiles) && maxFiles > 0 ? maxFiles : Infinity
202
+ : 1;
203
+ let available = Math.max(0, limit - current.length);
204
+
205
+ for (const file of nextFiles) {
206
+ const rejection = rejectionForFile(file);
207
+ if (rejection) {
208
+ rejected.push(rejection);
209
+ continue;
210
+ }
211
+ if (available <= 0) {
212
+ rejected.push(rejectionPayload(file, 'max-files', `Add ${limit} file${limit === 1 ? '' : 's'} or fewer.`));
213
+ continue;
214
+ }
215
+
216
+ accepted.push(file);
217
+ available -= 1;
218
+ }
219
+
220
+ if (accepted.length) {
221
+ const value = props.multiple ? [...current, ...accepted] : accepted.slice(0, 1);
222
+ field.onInput(value);
223
+ emit('add', accepted);
224
+ }
225
+ if (rejected.length) {
226
+ lastRejections.value = rejected;
227
+ emit('reject', rejected);
228
+ }
229
+ }
230
+
231
+ function rejectionForFile(file) {
232
+ const maxSize = Number(props.maxSize);
233
+ if (Number.isFinite(maxSize) && maxSize > 0 && file.size > maxSize) {
234
+ return rejectionPayload(file, 'max-size', `Use files smaller than ${formatSize(maxSize)}.`);
235
+ }
236
+ if (!matchesAccept(file)) {
237
+ return rejectionPayload(file, 'accept', 'Choose an allowed file type.');
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function rejectionPayload(file, reason, message) {
243
+ return { file, reason, message };
244
+ }
245
+
246
+ function matchesAccept(file) {
247
+ const accept = String(props.accept || '').trim();
248
+ if (!accept) return true;
249
+
250
+ const rules = accept.split(',').map((rule) => rule.trim().toLowerCase()).filter(Boolean);
251
+ const name = String(file.name || '').toLowerCase();
252
+ const type = String(file.type || '').toLowerCase();
253
+
254
+ return rules.some((rule) => {
255
+ if (rule.startsWith('.')) return name.endsWith(rule);
256
+ if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1));
257
+ return type === rule;
258
+ });
259
+ }
260
+
261
+ function removeFile(index) {
262
+ if (disabled.value) return;
263
+
264
+ lastRejections.value = [];
265
+ const current = toArray(field.value.value);
266
+ const removed = current[index];
267
+ const next = current.filter((_, itemIndex) => itemIndex !== index);
268
+ field.onInput(next);
269
+ emit('remove', { file: removed, index });
270
+ }
271
+
272
+ function formatSize(size) {
273
+ const value = Number(size);
274
+ if (!Number.isFinite(value) || value <= 0) return '';
275
+ if (value < 1024) return `${value} B`;
276
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
277
+ return `${(value / 1024 / 1024).toFixed(1)} MB`;
278
+ }
279
+
280
+ function fileKey(file, index) {
281
+ return file.raw?.id || file.raw?.url || `${file.name}-${file.size || 0}-${index}`;
282
+ }
283
+ </script>
284
+
285
+ <template>
286
+ <DomField v-bind="fieldAttrs" :chrome="chrome">
287
+ <div class="space-y-3">
288
+ <div
289
+ :class="dropzoneClass"
290
+ @dragenter.prevent="onDragEnter"
291
+ @dragover.prevent="onDragEnter"
292
+ @dragleave="onDragLeave"
293
+ @drop.prevent="onDrop"
294
+ @click="openPicker"
295
+ >
296
+ <input
297
+ ref="input"
298
+ :id="field.id.value"
299
+ :name="field.htmlName.value"
300
+ type="file"
301
+ class="sr-only"
302
+ :accept="accept || undefined"
303
+ :multiple="multiple"
304
+ :disabled="disabled || undefined"
305
+ :required="required && !hasFiles || undefined"
306
+ :aria-invalid="fieldAttrs.invalid || undefined"
307
+ :aria-describedby="describedBy || undefined"
308
+ :aria-errormessage="fieldAttrs.errorId || undefined"
309
+ @change="onInputChange"
310
+ @focus="field.onFocus"
311
+ @blur="field.onBlur"
312
+ />
313
+ <div class="mx-auto grid size-10 place-items-center rounded-full bg-secondary text-muted-fg" aria-hidden="true">
314
+ <svg viewBox="0 0 24 24" class="size-5" fill="none">
315
+ <path d="M12 15V5M8 9l4-4 4 4M5 16v3h14v-3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
316
+ </svg>
317
+ </div>
318
+ <p class="mt-3 text-sm font-semibold text-canvas-fg">{{ hasFiles ? browseLabel : emptyTitle }}</p>
319
+ <p class="mt-1 text-sm text-muted-fg">{{ emptyDescription }}</p>
320
+ <button
321
+ type="button"
322
+ class="mt-4 rounded-md border border-border bg-canvas px-3 py-1.5 text-sm font-medium text-canvas-fg shadow-sm transition hover:border-primary/40 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-50"
323
+ :disabled="disabled || undefined"
324
+ @click.stop="openPicker"
325
+ >
326
+ {{ browseLabel }}
327
+ </button>
328
+ </div>
329
+
330
+ <ul v-if="hasFiles" class="divide-y divide-border overflow-hidden rounded-lg border border-border">
331
+ <li v-for="(file, index) in files" :key="fileKey(file, index)" class="flex items-center gap-3 bg-canvas px-3 py-2">
332
+ <div class="grid size-9 shrink-0 place-items-center rounded-md bg-secondary text-muted-fg" aria-hidden="true">
333
+ <svg viewBox="0 0 24 24" class="size-4" fill="none">
334
+ <path d="M7 4h7l3 3v13H7V4Zm7 0v4h4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
335
+ </svg>
336
+ </div>
337
+ <div class="min-w-0 flex-1">
338
+ <p class="truncate text-sm font-medium text-canvas-fg">{{ file.name }}</p>
339
+ <p class="text-xs text-muted-fg">
340
+ <span v-if="file.size">{{ formatSize(file.size) }}</span>
341
+ <span v-if="file.size && file.type"> / </span>
342
+ <span v-if="file.type">{{ file.type }}</span>
343
+ <span v-if="!file.size && !file.type">{{ file.local ? 'Local file' : 'Existing file' }}</span>
344
+ </p>
345
+ </div>
346
+ <button
347
+ type="button"
348
+ class="grid size-8 shrink-0 place-items-center rounded-md text-muted-fg transition hover:bg-secondary hover:text-canvas-fg disabled:cursor-not-allowed disabled:opacity-50"
349
+ :disabled="disabled || undefined"
350
+ :aria-label="`Remove ${file.name}`"
351
+ @click="removeFile(index)"
352
+ >
353
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
354
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
355
+ </svg>
356
+ </button>
357
+ </li>
358
+ </ul>
359
+ </div>
360
+ </DomField>
361
+ </template>