@thomasfosterau/components 0.1.0 → 0.2.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 (240) hide show
  1. package/README.md +241 -56
  2. package/dist/canvas/VectorCanvas.svelte +89 -8
  3. package/dist/canvas/VectorCanvas.svelte.d.ts +19 -1
  4. package/dist/canvas/types.d.ts +1 -1
  5. package/dist/charts/types.d.ts +1 -1
  6. package/dist/citations/list-format.js +3 -1
  7. package/dist/data/ChatBubble.svelte +48 -0
  8. package/dist/data/ChatBubble.svelte.d.ts +33 -0
  9. package/dist/data/DescriptionList.svelte +9 -3
  10. package/dist/data/DescriptionList.svelte.d.ts +1 -1
  11. package/dist/data/DescriptionRow.svelte +6 -5
  12. package/dist/data/MessageThread.svelte +29 -0
  13. package/dist/data/MessageThread.svelte.d.ts +17 -0
  14. package/dist/data/Pagination.svelte +5 -5
  15. package/dist/data/Pagination.svelte.d.ts +8 -0
  16. package/dist/data/PaginationFooter.svelte +2 -1
  17. package/dist/data/PaginationFooter.svelte.d.ts +2 -0
  18. package/dist/data/Table.svelte +9 -3
  19. package/dist/data/description-context.d.ts +12 -6
  20. package/dist/data/description-context.js +6 -5
  21. package/dist/data/index.d.ts +2 -0
  22. package/dist/data/index.js +2 -0
  23. package/dist/data/table-types.d.ts +7 -0
  24. package/dist/feedback/Dialog.svelte +19 -49
  25. package/dist/feedback/Dialog.svelte.d.ts +0 -1
  26. package/dist/feedback/DialogFrame.svelte +64 -0
  27. package/dist/feedback/DialogFrame.svelte.d.ts +23 -0
  28. package/dist/feedback/Drawer.svelte +43 -0
  29. package/dist/feedback/Drawer.svelte.d.ts +43 -0
  30. package/dist/feedback/SegmentBar.svelte.d.ts +1 -0
  31. package/dist/feedback/StatTile.svelte.d.ts +1 -0
  32. package/dist/feedback/Steps.svelte +72 -0
  33. package/dist/feedback/Steps.svelte.d.ts +61 -0
  34. package/dist/feedback/Timeline.svelte.d.ts +3 -0
  35. package/dist/feedback/index.d.ts +3 -0
  36. package/dist/feedback/index.js +2 -0
  37. package/dist/feedback/sizes.d.ts +14 -0
  38. package/dist/feedback/sizes.js +28 -0
  39. package/dist/fields/Calendar.svelte +60 -0
  40. package/dist/fields/Calendar.svelte.d.ts +11 -0
  41. package/dist/fields/CalendarMonths.svelte +86 -0
  42. package/dist/fields/CalendarMonths.svelte.d.ts +30 -0
  43. package/dist/fields/Checkbox.svelte +1 -1
  44. package/dist/fields/Checkbox.svelte.d.ts +6 -0
  45. package/dist/fields/Combobox.svelte +61 -54
  46. package/dist/fields/Combobox.svelte.d.ts +38 -3
  47. package/dist/fields/DateField.svelte +37 -0
  48. package/dist/fields/DateField.svelte.d.ts +20 -0
  49. package/dist/fields/DateInput.svelte +1 -1
  50. package/dist/fields/DateInput.svelte.d.ts +6 -0
  51. package/dist/fields/DatePicker.svelte +47 -0
  52. package/dist/fields/DatePicker.svelte.d.ts +25 -0
  53. package/dist/fields/DateRangePicker.svelte +70 -0
  54. package/dist/fields/DateRangePicker.svelte.d.ts +43 -0
  55. package/dist/fields/Dropzone.svelte +232 -0
  56. package/dist/fields/Dropzone.svelte.d.ts +103 -0
  57. package/dist/fields/Fieldset.svelte +7 -2
  58. package/dist/fields/Fieldset.svelte.d.ts +3 -2
  59. package/dist/fields/FileInput.svelte +3 -2
  60. package/dist/fields/FileInput.svelte.d.ts +6 -0
  61. package/dist/fields/FormField.svelte +27 -3
  62. package/dist/fields/ImageInput.svelte.d.ts +6 -0
  63. package/dist/fields/MultiSelect.svelte +112 -0
  64. package/dist/fields/MultiSelect.svelte.d.ts +75 -0
  65. package/dist/fields/NumberField.svelte +190 -0
  66. package/dist/fields/NumberField.svelte.d.ts +76 -0
  67. package/dist/fields/PasswordInput.svelte +95 -0
  68. package/dist/fields/PasswordInput.svelte.d.ts +58 -0
  69. package/dist/fields/PickerPopover.svelte +47 -0
  70. package/dist/fields/PickerPopover.svelte.d.ts +16 -0
  71. package/dist/fields/PinInput.svelte +83 -0
  72. package/dist/fields/PinInput.svelte.d.ts +66 -0
  73. package/dist/fields/RadioGroup.svelte +3 -0
  74. package/dist/fields/RangeCalendar.svelte +51 -0
  75. package/dist/fields/RangeCalendar.svelte.d.ts +23 -0
  76. package/dist/fields/Rating.svelte +127 -0
  77. package/dist/fields/Rating.svelte.d.ts +86 -0
  78. package/dist/fields/SearchInput.svelte +2 -1
  79. package/dist/fields/SegmentGroup.svelte +91 -0
  80. package/dist/fields/SegmentGroup.svelte.d.ts +20 -0
  81. package/dist/fields/Select.svelte +1 -1
  82. package/dist/fields/Select.svelte.d.ts +6 -0
  83. package/dist/fields/Slider.svelte +194 -0
  84. package/dist/fields/Slider.svelte.d.ts +100 -0
  85. package/dist/fields/TagsInput.svelte +256 -0
  86. package/dist/fields/TagsInput.svelte.d.ts +102 -0
  87. package/dist/fields/TextInput.svelte +1 -1
  88. package/dist/fields/TextInput.svelte.d.ts +6 -0
  89. package/dist/fields/Textarea.svelte +1 -1
  90. package/dist/fields/Textarea.svelte.d.ts +6 -0
  91. package/dist/fields/TimeField.svelte +30 -0
  92. package/dist/fields/TimeField.svelte.d.ts +19 -0
  93. package/dist/fields/Toggle.svelte +1 -1
  94. package/dist/fields/Toggle.svelte.d.ts +6 -0
  95. package/dist/fields/VideoInput.svelte.d.ts +6 -0
  96. package/dist/fields/calendar-props.d.ts +119 -0
  97. package/dist/fields/calendar-props.js +51 -0
  98. package/dist/fields/dates.d.ts +28 -0
  99. package/dist/fields/dates.js +26 -0
  100. package/dist/fields/field-types.d.ts +29 -0
  101. package/dist/fields/field-types.js +32 -0
  102. package/dist/fields/file-validation.d.ts +30 -0
  103. package/dist/fields/file-validation.js +51 -0
  104. package/dist/fields/index.d.ts +9 -0
  105. package/dist/fields/index.js +8 -0
  106. package/dist/fields/latest-search.svelte.d.ts +38 -0
  107. package/dist/fields/latest-search.svelte.js +85 -0
  108. package/dist/fields/number-format.d.ts +52 -0
  109. package/dist/fields/number-format.js +89 -0
  110. package/dist/fields/segmented-field.d.ts +128 -0
  111. package/dist/fields/segmented-field.js +52 -0
  112. package/dist/fields/slider-keys.d.ts +21 -0
  113. package/dist/fields/slider-keys.js +37 -0
  114. package/dist/formatting/Countdown.svelte +110 -0
  115. package/dist/formatting/Countdown.svelte.d.ts +35 -0
  116. package/dist/formatting/Duration.svelte +18 -0
  117. package/dist/formatting/Duration.svelte.d.ts +20 -0
  118. package/dist/formatting/Highlight.svelte +4 -0
  119. package/dist/formatting/Highlight.svelte.d.ts +4 -0
  120. package/dist/formatting/duration.d.ts +44 -0
  121. package/dist/formatting/duration.js +125 -0
  122. package/dist/formatting/index.d.ts +3 -0
  123. package/dist/formatting/index.js +3 -0
  124. package/dist/forms/EntityForm.svelte +53 -45
  125. package/dist/forms/EntityForm.svelte.d.ts +12 -3
  126. package/dist/hotkeys/index.d.ts +5 -0
  127. package/dist/hotkeys/index.js +5 -0
  128. package/dist/hotkeys/match.js +12 -10
  129. package/dist/hotkeys/types.d.ts +4 -2
  130. package/dist/index.d.ts +2 -1
  131. package/dist/index.js +2 -1
  132. package/dist/infobox/Infobox.svelte +2 -2
  133. package/dist/infobox/Infobox.svelte.d.ts +7 -0
  134. package/dist/infobox/InfoboxHeader.svelte +15 -7
  135. package/dist/infobox/InfoboxHeader.svelte.d.ts +7 -0
  136. package/dist/infobox/types.d.ts +5 -5
  137. package/dist/layout/AppShell.svelte +50 -0
  138. package/dist/layout/AppShell.svelte.d.ts +28 -0
  139. package/dist/layout/Cluster.svelte +28 -0
  140. package/dist/layout/Cluster.svelte.d.ts +27 -0
  141. package/dist/layout/Fits.svelte +27 -0
  142. package/dist/layout/Fits.svelte.d.ts +13 -0
  143. package/dist/layout/Flow.svelte +46 -0
  144. package/dist/layout/Flow.svelte.d.ts +16 -0
  145. package/dist/layout/Grid.svelte +23 -0
  146. package/dist/layout/Grid.svelte.d.ts +26 -0
  147. package/dist/layout/HStack.svelte +12 -0
  148. package/dist/layout/HStack.svelte.d.ts +34 -0
  149. package/dist/layout/Hero.svelte +76 -0
  150. package/dist/layout/Hero.svelte.d.ts +36 -0
  151. package/dist/layout/Navbar.svelte +2 -2
  152. package/dist/layout/Sidebar.svelte +1 -1
  153. package/dist/layout/Spacer.svelte +12 -0
  154. package/dist/layout/Spacer.svelte.d.ts +6 -0
  155. package/dist/layout/VStack.svelte +12 -0
  156. package/dist/layout/VStack.svelte.d.ts +29 -0
  157. package/dist/layout/index.d.ts +9 -0
  158. package/dist/layout/index.js +9 -0
  159. package/dist/layout/space.d.ts +44 -0
  160. package/dist/layout/space.js +76 -0
  161. package/dist/search/types.d.ts +3 -0
  162. package/dist/seo/og-image.d.ts +4 -3
  163. package/dist/seo/og-image.js +4 -3
  164. package/dist/tips/tip-stack.d.ts +1 -0
  165. package/dist/tips/tip-store.svelte.d.ts +2 -1
  166. package/dist/ui/Accordion.svelte +75 -0
  167. package/dist/ui/Accordion.svelte.d.ts +50 -0
  168. package/dist/ui/AccordionItem.svelte +73 -0
  169. package/dist/ui/AccordionItem.svelte.d.ts +18 -0
  170. package/dist/ui/Button.svelte +3 -2
  171. package/dist/ui/ButtonGroup.svelte +24 -0
  172. package/dist/ui/ButtonGroup.svelte.d.ts +17 -0
  173. package/dist/ui/ContextMenu.svelte +75 -0
  174. package/dist/ui/ContextMenu.svelte.d.ts +25 -0
  175. package/dist/ui/Disclosure.svelte +7 -0
  176. package/dist/ui/Disclosure.svelte.d.ts +2 -2
  177. package/dist/ui/Divider.svelte +47 -0
  178. package/dist/ui/Divider.svelte.d.ts +28 -0
  179. package/dist/ui/DropdownMenu.svelte +44 -0
  180. package/dist/ui/DropdownMenu.svelte.d.ts +30 -0
  181. package/dist/ui/Menu.svelte +17 -47
  182. package/dist/ui/Menu.svelte.d.ts +3 -24
  183. package/dist/ui/MenuCheckboxItem.svelte +33 -0
  184. package/dist/ui/MenuCheckboxItem.svelte.d.ts +20 -0
  185. package/dist/ui/MenuItem.svelte +29 -31
  186. package/dist/ui/MenuItem.svelte.d.ts +2 -2
  187. package/dist/ui/MenuRadioGroup.svelte +25 -0
  188. package/dist/ui/MenuRadioGroup.svelte.d.ts +20 -0
  189. package/dist/ui/MenuRadioItem.svelte +20 -0
  190. package/dist/ui/MenuRadioItem.svelte.d.ts +15 -0
  191. package/dist/ui/MenuSeparator.svelte +12 -11
  192. package/dist/ui/MenuSub.svelte +24 -0
  193. package/dist/ui/MenuSub.svelte.d.ts +15 -0
  194. package/dist/ui/Spinner.svelte +41 -0
  195. package/dist/ui/Spinner.svelte.d.ts +35 -0
  196. package/dist/ui/Stack.svelte +36 -0
  197. package/dist/ui/Stack.svelte.d.ts +48 -0
  198. package/dist/ui/ToggleButton.svelte +58 -0
  199. package/dist/ui/ToggleButton.svelte.d.ts +45 -0
  200. package/dist/ui/ToggleGroup.svelte +142 -0
  201. package/dist/ui/ToggleGroup.svelte.d.ts +86 -0
  202. package/dist/ui/Toolbar.svelte +40 -0
  203. package/dist/ui/Toolbar.svelte.d.ts +20 -0
  204. package/dist/ui/ToolbarButton.svelte +29 -0
  205. package/dist/ui/ToolbarButton.svelte.d.ts +21 -0
  206. package/dist/ui/ToolbarLink.svelte +21 -0
  207. package/dist/ui/ToolbarLink.svelte.d.ts +12 -0
  208. package/dist/ui/ToolbarSeparator.svelte +22 -0
  209. package/dist/ui/ToolbarSeparator.svelte.d.ts +12 -0
  210. package/dist/ui/ToolbarToggleGroup.svelte +66 -0
  211. package/dist/ui/ToolbarToggleGroup.svelte.d.ts +52 -0
  212. package/dist/ui/accordion-context.d.ts +26 -0
  213. package/dist/ui/accordion-context.js +23 -0
  214. package/dist/ui/context-menu.d.ts +13 -0
  215. package/dist/ui/context-menu.js +13 -0
  216. package/dist/ui/index.d.ts +19 -0
  217. package/dist/ui/index.js +18 -0
  218. package/dist/ui/menu-context.d.ts +13 -15
  219. package/dist/ui/menu-context.js +14 -18
  220. package/dist/ui/menu-parts/CheckboxItemPart.svelte +26 -0
  221. package/dist/ui/menu-parts/CheckboxItemPart.svelte.d.ts +5 -0
  222. package/dist/ui/menu-parts/ItemPart.svelte +29 -0
  223. package/dist/ui/menu-parts/ItemPart.svelte.d.ts +5 -0
  224. package/dist/ui/menu-parts/RadioGroupPart.svelte +24 -0
  225. package/dist/ui/menu-parts/RadioGroupPart.svelte.d.ts +5 -0
  226. package/dist/ui/menu-parts/RadioItemPart.svelte +22 -0
  227. package/dist/ui/menu-parts/RadioItemPart.svelte.d.ts +5 -0
  228. package/dist/ui/menu-parts/SeparatorPart.svelte +16 -0
  229. package/dist/ui/menu-parts/SeparatorPart.svelte.d.ts +5 -0
  230. package/dist/ui/menu-parts/SubPart.svelte +28 -0
  231. package/dist/ui/menu-parts/SubPart.svelte.d.ts +5 -0
  232. package/dist/ui/menu-parts/menu-parts.d.ts +104 -0
  233. package/dist/ui/menu-parts/menu-parts.js +31 -0
  234. package/dist/ui/menu-parts/parts.d.ts +12 -0
  235. package/dist/ui/menu-parts/parts.js +27 -0
  236. package/dist/ui/toggle-classes.d.ts +39 -0
  237. package/dist/ui/toggle-classes.js +36 -0
  238. package/dist/ui/tones.d.ts +35 -0
  239. package/dist/ui/tones.js +90 -0
  240. package/package.json +34 -18
@@ -0,0 +1,43 @@
1
+ import type { DateValue } from '@internationalized/date';
2
+ import { DateRangePicker } from 'bits-ui';
3
+ import { type PickerProps, type SegmentedFieldProps } from './segmented-field.ts';
4
+ /** A start–end span; either end `undefined` while incomplete. */
5
+ interface DateRangeValue {
6
+ start: DateValue | undefined;
7
+ end: DateValue | undefined;
8
+ }
9
+ interface Props extends Omit<SegmentedFieldProps, 'name'>, PickerProps {
10
+ /** The span. Bindable. */
11
+ value?: DateRangeValue | undefined;
12
+ /** Earliest valid date. */
13
+ minValue?: DateValue | undefined;
14
+ /** Latest valid date. */
15
+ maxValue?: DateValue | undefined;
16
+ /** Shortest span in days. */
17
+ minDays?: number | undefined;
18
+ /** Longest span in days. */
19
+ maxDays?: number | undefined;
20
+ /**
21
+ * Close the popover once the span is complete.
22
+ * @defaultValue `true`
23
+ */
24
+ closeOnRangeSelect?: boolean | undefined;
25
+ /** Form field name of the start date (ISO), posted through a hidden input. */
26
+ startName?: string | undefined;
27
+ /** Form field name of the end date (ISO), posted through a hidden input. */
28
+ endName?: string | undefined;
29
+ /**
30
+ * Name of the start half.
31
+ * @defaultValue `'Start date'`
32
+ */
33
+ startLabel?: string | undefined;
34
+ /**
35
+ * Name of the end half.
36
+ * @defaultValue `'End date'`
37
+ */
38
+ endLabel?: string | undefined;
39
+ }
40
+ /** Two segmented date fields, a start and an end, with a range-calendar popover, on bits-ui `DateRangePicker`. */
41
+ declare const DateRangePicker: import("svelte").Component<Props, {}, "value" | "open">;
42
+ type DateRangePicker = ReturnType<typeof DateRangePicker>;
43
+ export default DateRangePicker;
@@ -0,0 +1,232 @@
1
+ <!-- @component
2
+ Choose one or many files by dropping them on a target or through a "Choose files" button, with a removable file list and explained rejections; never uploads.
3
+ -->
4
+ <!--
5
+ Dropzone — a drop target plus a real "Choose files" button, a list of the
6
+ chosen files (name, size, optional thumbnail) each with a remove button,
7
+ and client-side `accept` / `maxBytes` / `maxFiles` checks whose rejections
8
+ are explained. It binds a `File[]` and never uploads: the transfer is the
9
+ caller's, as with `FileInput`.
10
+
11
+ Hand-rolled, and why (AGENTS.md): no bits-ui primitive exists for file
12
+ drops, and the contract is small — one button, one list, one alert —
13
+ pinned by `tests/fields/Dropzone.test.ts`. The checks are the pure helpers
14
+ in `file-validation.ts`, shared with `FileInput`.
15
+
16
+ A real `<input type="file">` sits inside, visually hidden, carrying
17
+ `name`, `accept`, `multiple` and `required`. Every change to `files` — a
18
+ pick, a drop, a removal, the caller's own write — is synced into it through
19
+ a `DataTransfer`, so a plain form POST posts exactly the listed files.
20
+ A drop is NOT filtered by the browser against `accept`, which is why the
21
+ checks run on drops too.
22
+
23
+ ARIA contract (no APG pattern; these are the guarantees):
24
+ - The drop area is never the only way in. The "Choose files" `<button>`
25
+ opens the native picker, and it carries the `FieldControl` id, so
26
+ `FormField`'s `<label for>` names it, plus `aria-describedby`
27
+ (`describedBy`, then the rejection alert). It takes no `aria-invalid` —
28
+ the `button` role does not support it — so `invalid` reaches assistive
29
+ technology through `FormField`'s error text in that description. The drop
30
+ area itself is not focusable; `promptLabel` sits in front of the button.
31
+ - Drag-over is visual only (`data-dragging`), never announced.
32
+ - The files are a `<ul>` of `<li>`; each remove button is named by
33
+ `removeLabel(file)`. After a removal, focus moves to the next remove
34
+ button, else the previous one, else the choose button.
35
+ - Rejections from the last selection render in one `role="alert"`
36
+ region (`FieldErrors`), cleared by the next selection.
37
+ - A polite live region says how many files are selected after each
38
+ selection or drop, since a drop otherwise produces no announcement.
39
+
40
+ Previews: an image file gets an object-URL thumbnail when `previews` is
41
+ on; every URL is revoked when its file leaves the list and when the
42
+ component is destroyed.
43
+ -->
44
+ <script lang="ts">import { tick, untrack } from "svelte";
45
+ import { SvelteMap } from "svelte/reactivity";
46
+ import { getComponentsContext } from "../context.svelte.js";
47
+ import FieldErrors from "./FieldErrors.svelte";
48
+ import { acceptMatches, exceedsSize, formatFileSize } from "./file-validation.js";
49
+ let { files = $bindable([]), accept, maxBytes, maxFiles, multiple = true, name, onSelect, previews = true, promptLabel = "Drop files here or", chooseLabel = "Choose files", removeLabel = (file) => `Remove ${file.name}`, tooLargeMessage = (file) => `${file.name} is too large.`, wrongTypeMessage = (file) => `${file.name} is not an accepted file type.`, tooManyMessage = (max) => `Choose at most ${max} files.`, selectedMessage = (count) => count === 1 ? "1 file selected" : `${count} files selected`, locales = getComponentsContext().locale(), file: fileRow, disabled = false, id: idProp, labelId: _labelId, describedBy = "", invalid = false, required = false, class: className, ...rest } = $props();
50
+ const uid = $props.id();
51
+ const buttonId = $derived(idProp ?? uid);
52
+ const errorsId = $derived(`${uid}-errors`);
53
+ let input = $state(null);
54
+ let chooseButton = $state(null);
55
+ let root = $state(null);
56
+ let dragging = $state(false);
57
+ let rejections = $state([]);
58
+ let announcement = $state("");
59
+ const previewUrls = new SvelteMap();
60
+ const cap = $derived(multiple ? maxFiles : 1);
61
+ const messages = $derived(rejectionMessages(rejections));
62
+ const described = $derived([describedBy, messages.length > 0 ? errorsId : ""].filter((part) => part !== "").join(" "));
63
+ function rejectionMessages(list) {
64
+ const out = list.filter((rejection) => rejection.reason !== "too-many").map((rejection) => rejection.reason === "too-large" ? tooLargeMessage(rejection.file) : wrongTypeMessage(rejection.file));
65
+ if (cap !== undefined && list.some((rejection) => rejection.reason === "too-many")) {
66
+ out.push(tooManyMessage(cap));
67
+ }
68
+ return out;
69
+ }
70
+ /** Which rule, if any, refuses `candidate` once `taken` files are already in. */
71
+ function refusal(candidate, taken) {
72
+ if (!acceptMatches(candidate, accept)) return "wrong-type";
73
+ if (exceedsSize(candidate, maxBytes)) return "too-large";
74
+ if (cap !== undefined && taken >= cap) return "too-many";
75
+ return undefined;
76
+ }
77
+ /** Check a pick or a drop, keep what passes, explain what does not. */
78
+ function take(incoming) {
79
+ const base = multiple ? files : [];
80
+ const accepted = [];
81
+ const rejected = [];
82
+ for (const candidate of incoming) {
83
+ const reason = refusal(candidate, base.length + accepted.length);
84
+ if (reason === undefined) accepted.push(candidate);
85
+ else rejected.push({
86
+ file: candidate,
87
+ reason
88
+ });
89
+ }
90
+ if (accepted.length > 0 || !multiple) files = [...base, ...accepted];
91
+ rejections = rejected;
92
+ announcement = selectedMessage(files.length);
93
+ onSelect?.(accepted, rejected);
94
+ }
95
+ async function remove(index) {
96
+ files = files.filter((_, at) => at !== index);
97
+ announcement = selectedMessage(files.length);
98
+ // Let the list re-render before choosing where focus goes.
99
+ await tick();
100
+ const left = [...root?.querySelectorAll("[data-dropzone-remove]") ?? []];
101
+ (left[index] ?? left[index - 1] ?? chooseButton)?.focus();
102
+ }
103
+ /** Mirror `files` into the native input, so a form POST sends exactly the listed files. */
104
+ function syncInput(current) {
105
+ if (input === null) return;
106
+ const transfer = new DataTransfer();
107
+ for (const entry of current) transfer.items.add(entry);
108
+ input.files = transfer.files;
109
+ }
110
+ $effect(() => {
111
+ const current = files;
112
+ untrack(() => {
113
+ for (const entry of current) {
114
+ if (previews && entry.type.startsWith("image/") && !previewUrls.has(entry)) {
115
+ previewUrls.set(entry, URL.createObjectURL(entry));
116
+ }
117
+ }
118
+ for (const [entry, url] of previewUrls) {
119
+ if (!current.includes(entry)) {
120
+ URL.revokeObjectURL(url);
121
+ previewUrls.delete(entry);
122
+ }
123
+ }
124
+ syncInput(current);
125
+ });
126
+ });
127
+ $effect(() => () => {
128
+ for (const url of previewUrls.values()) URL.revokeObjectURL(url);
129
+ });
130
+ function dragOver(event) {
131
+ event.preventDefault();
132
+ if (disabled) return;
133
+ dragging = true;
134
+ if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy";
135
+ }
136
+ function dragLeave(event) {
137
+ const target = event.currentTarget;
138
+ if (event.relatedTarget instanceof Node && target.contains(event.relatedTarget)) return;
139
+ dragging = false;
140
+ }
141
+ function drop(event) {
142
+ event.preventDefault();
143
+ dragging = false;
144
+ if (disabled) return;
145
+ take([...event.dataTransfer?.files ?? []]);
146
+ }
147
+ function picked(event) {
148
+ const chosen = [...event.currentTarget.files ?? []];
149
+ // The native input now holds only the new pick; `take` decides the list,
150
+ // and the sync effect writes it back.
151
+ take(chosen);
152
+ syncInput(files);
153
+ }
154
+ </script>
155
+
156
+ {#snippet row(entry: File, index: number)}
157
+ {@const preview = previewUrls.get(entry)}
158
+ {#if preview !== undefined}
159
+ <img src={preview} alt="" class="rounded-field size-10 object-cover" />
160
+ {/if}
161
+ <span class="min-w-0 grow truncate">{entry.name}</span>
162
+ <span class="text-base-content/70 text-sm tabular-nums">{formatFileSize(entry.size, locales)}</span>
163
+ <button
164
+ data-dropzone-remove
165
+ type="button"
166
+ {disabled}
167
+ class="btn btn-ghost btn-sm"
168
+ onclick={() => void remove(index)}
169
+ >
170
+ {removeLabel(entry)}
171
+ </button>
172
+ {/snippet}
173
+
174
+ <div
175
+ {...rest}
176
+ bind:this={root}
177
+ data-dragging={dragging ? '' : undefined}
178
+ ondragenter={dragOver}
179
+ ondragover={dragOver}
180
+ ondragleave={dragLeave}
181
+ ondrop={drop}
182
+ class={[
183
+ 'rounded-box bg-base-100 border-2 border-dashed p-4',
184
+ invalid || messages.length > 0 ? 'border-error' : 'border-base-300',
185
+ 'data-[dragging]:border-primary data-[dragging]:bg-base-200',
186
+ disabled && 'opacity-50',
187
+ className,
188
+ ]}
189
+ >
190
+ <div class="flex flex-wrap items-center justify-center gap-2 py-4 text-center">
191
+ <span>{promptLabel}</span>
192
+ <button
193
+ bind:this={chooseButton}
194
+ id={buttonId}
195
+ type="button"
196
+ {disabled}
197
+ aria-describedby={described === '' ? undefined : described}
198
+ class="btn btn-sm"
199
+ onclick={() => input?.click()}
200
+ >
201
+ {chooseLabel}
202
+ </button>
203
+ </div>
204
+ <input
205
+ bind:this={input}
206
+ type="file"
207
+ class="sr-only"
208
+ tabindex="-1"
209
+ aria-hidden="true"
210
+ {name}
211
+ {accept}
212
+ {multiple}
213
+ {required}
214
+ {disabled}
215
+ onchange={picked}
216
+ />
217
+ {#if files.length > 0}
218
+ <ul class="mt-2 flex flex-col gap-2">
219
+ {#each files as entry, index (entry)}
220
+ <li class="flex items-center gap-3">
221
+ {#if fileRow}
222
+ {@render fileRow(entry, { remove: () => void remove(index) })}
223
+ {:else}
224
+ {@render row(entry, index)}
225
+ {/if}
226
+ </li>
227
+ {/each}
228
+ </ul>
229
+ {/if}
230
+ <FieldErrors errors={messages} id={errorsId} />
231
+ <p class="sr-only" aria-live="polite">{announcement}</p>
232
+ </div>
@@ -0,0 +1,103 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type FileRejection } from './file-validation.ts';
4
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'ondrop' | 'id'> {
5
+ /**
6
+ * The chosen files. Bindable.
7
+ * @defaultValue `[]`
8
+ */
9
+ files?: File[] | undefined;
10
+ /** The native `accept` syntax (`image/*`, `.pdf`, `application/json`); also enforced on drop. */
11
+ accept?: string | undefined;
12
+ /** Per-file size cap, in bytes. Omitted, there is no cap. */
13
+ maxBytes?: number | undefined;
14
+ /** Cap on the number of files; extra files are rejected with `tooManyMessage`. */
15
+ maxFiles?: number | undefined;
16
+ /**
17
+ * Allow more than one file. Off, a new choice replaces the current file.
18
+ * @defaultValue `true`
19
+ */
20
+ multiple?: boolean | undefined;
21
+ /** Posts the files under this name through the inner native input. */
22
+ name?: string | undefined;
23
+ /** Called after each pick or drop with the files accepted and the ones refused. */
24
+ onSelect?: ((accepted: readonly File[], rejected: readonly FileRejection[]) => void) | undefined;
25
+ /**
26
+ * Show a thumbnail for image files.
27
+ * @defaultValue `true`
28
+ */
29
+ previews?: boolean | undefined;
30
+ /**
31
+ * Text in front of the choose button.
32
+ * @defaultValue `'Drop files here or'`
33
+ */
34
+ promptLabel?: string | undefined;
35
+ /**
36
+ * The choose button's text.
37
+ * @defaultValue `'Choose files'`
38
+ */
39
+ chooseLabel?: string | undefined;
40
+ /**
41
+ * Accessible name of a file's remove button.
42
+ * @defaultValue `(file) => \`Remove ${file.name}\``
43
+ */
44
+ removeLabel?: ((file: File) => string) | undefined;
45
+ /**
46
+ * Shown for a file over `maxBytes`.
47
+ * @defaultValue `(file) => \`${file.name} is too large.\``
48
+ */
49
+ tooLargeMessage?: ((file: File) => string) | undefined;
50
+ /**
51
+ * Shown for a file `accept` refuses.
52
+ * @defaultValue `(file) => \`${file.name} is not an accepted file type.\``
53
+ */
54
+ wrongTypeMessage?: ((file: File) => string) | undefined;
55
+ /**
56
+ * Shown once when a selection would pass `maxFiles`.
57
+ * @defaultValue `(max) => \`Choose at most ${max} files.\``
58
+ */
59
+ tooManyMessage?: ((max: number) => string) | undefined;
60
+ /**
61
+ * Live-region text after a selection or drop.
62
+ * @defaultValue `(count) => count === 1 ? '1 file selected' : \`${count} files selected\``
63
+ */
64
+ selectedMessage?: ((count: number) => string) | undefined;
65
+ /** BCP 47 locale(s) for the file sizes; falls back to the context's `locale()`. */
66
+ locales?: string | readonly string[] | undefined;
67
+ /** Replaces a file row's body, e.g. to add upload progress the caller tracks. */
68
+ file?: Snippet<[File, {
69
+ remove: () => void;
70
+ }]> | undefined;
71
+ /**
72
+ * Disables the button, the drop target and the remove buttons.
73
+ * @defaultValue `false`
74
+ */
75
+ disabled?: boolean | undefined;
76
+ /** `FieldControl`: the choose button's `id`, which `FormField`'s label points at. Minted when omitted. */
77
+ id?: string | undefined;
78
+ /**
79
+ * `FieldControl`: the visible label's id. Not used — the choose button is
80
+ * labelable, so the `FormField`'s `<label for>` already names it; taken
81
+ * here so a spread `FieldControl` does not leak it as an attribute.
82
+ */
83
+ labelId?: string | undefined;
84
+ /**
85
+ * `FieldControl`: space-separated ids for the button's `aria-describedby`.
86
+ * @defaultValue `''`
87
+ */
88
+ describedBy?: string | undefined;
89
+ /**
90
+ * `FieldControl`: the field is in error — draws the error border (the message reaches the button through `describedBy`).
91
+ * @defaultValue `false`
92
+ */
93
+ invalid?: boolean | undefined;
94
+ /**
95
+ * `FieldControl`: at least one file is required (on the native input).
96
+ * @defaultValue `false`
97
+ */
98
+ required?: boolean | undefined;
99
+ }
100
+ /** Choose one or many files by dropping them on a target or through a "Choose files" button, with a removable file list and explained rejections; never uploads. */
101
+ declare const Dropzone: import("svelte").Component<Props, {}, "files">;
102
+ type Dropzone = ReturnType<typeof Dropzone>;
103
+ export default Dropzone;
@@ -13,6 +13,10 @@ The `FormField` frame — legend, hint, errors — for a group of controls rathe
13
13
  the implicit `group` role does not support it, so the invalid state is a
14
14
  `data-invalid` hook for styling and the error region is what announces it.
15
15
 
16
+ The `children` snippet receives a `FieldsetControl`, the `<legend>`'s id as
17
+ `labelId`, for a role-based control inside the group to point
18
+ `aria-labelledby` at.
19
+
16
20
  `CheckboxGroup` is built on this. `RadioGroup` is not — bits-ui's radio
17
21
  group renders its own `role="radiogroup"` element, and a role inside a
18
22
  `<fieldset>` would be named by nothing, so it names itself from a heading it
@@ -21,6 +25,7 @@ The `FormField` frame — legend, hint, errors — for a group of controls rathe
21
25
  <script lang="ts">import FieldErrors from "./FieldErrors.svelte";
22
26
  let { legend, hint, errors = [], required = false, requiredLabel = "(required)", children, class: className, ...rest } = $props();
23
27
  const uid = $props.id();
28
+ const labelId = `${uid}-legend`;
24
29
  const hintId = `${uid}-hint`;
25
30
  const errorId = `${uid}-error`;
26
31
  const invalid = $derived(errors.length > 0);
@@ -33,14 +38,14 @@ const describedBy = $derived([hint === undefined ? "" : hintId, invalid ? errorI
33
38
  data-invalid={invalid ? '' : undefined}
34
39
  class={['fieldset', className]}
35
40
  >
36
- <legend class="fieldset-legend">
41
+ <legend class="fieldset-legend" id={labelId}>
37
42
  {legend}
38
43
  {#if required}
39
44
  <span class="text-error" aria-hidden="true">*</span>
40
45
  <span class="sr-only">{requiredLabel}</span>
41
46
  {/if}
42
47
  </legend>
43
- {@render children()}
48
+ {@render children({ labelId })}
44
49
  {#if hint !== undefined}
45
50
  <p class="fieldset-label" id={hintId}>{hint}</p>
46
51
  {/if}
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { FieldsetControl } from './field-types.ts';
3
4
  interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children'> {
4
5
  /** The group's `<legend>`; names every control inside it. */
5
6
  legend: string;
@@ -17,8 +18,8 @@ interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children'> {
17
18
  * @defaultValue `'(required)'`
18
19
  */
19
20
  requiredLabel?: string | undefined;
20
- /** The grouped controls. */
21
- children: Snippet<[]>;
21
+ /** The grouped controls; receives the legend's id as `labelId`. */
22
+ children: Snippet<[FieldsetControl]>;
22
23
  }
23
24
  /** The `FormField` frame — legend, hint, errors — for a group of controls rather than one. */
24
25
  declare const Fieldset: import("svelte").Component<Props, {}, "">;
@@ -23,7 +23,8 @@ A native file input in daisyUI `file-input` styling, with the chosen `File` bind
23
23
  -->
24
24
  <script lang="ts">import FieldErrors from "./FieldErrors.svelte";
25
25
  import { fileInputSize } from "./field-types.js";
26
- let { file = $bindable(), accept, maxBytes, tooLargeMessage = "That file is too large.", onSelect, size = "md", invalid = false, describedBy = "", class: className, ...rest } = $props();
26
+ import { exceedsSize } from "./file-validation.js";
27
+ let { file = $bindable(), accept, maxBytes, tooLargeMessage = "That file is too large.", onSelect, size = "md", invalid = false, describedBy = "", labelId: _labelId, class: className, ...rest } = $props();
27
28
  const uid = $props.id();
28
29
  const tooLargeId = `${uid}-too-large`;
29
30
  let tooLarge = $state(false);
@@ -36,7 +37,7 @@ const choose = (control) => {
36
37
  onSelect?.(undefined);
37
38
  return;
38
39
  }
39
- if (maxBytes !== undefined && chosen.size > maxBytes) {
40
+ if (exceedsSize(chosen, maxBytes)) {
40
41
  tooLarge = true;
41
42
  // Clear the control too: leaving the name of a file that was refused
42
43
  // showing in the field is how someone submits and wonders what happened.
@@ -26,6 +26,12 @@ interface Props extends Omit<HTMLInputAttributes, 'type' | 'value' | 'files' | '
26
26
  * @defaultValue `''`
27
27
  */
28
28
  describedBy?: string | undefined;
29
+ /**
30
+ * `FieldControl`: the visible label's id. Not used — this control is
31
+ * labelable, so the `FormField`'s `<label for>` already names it; taken
32
+ * here so a spread `FieldControl` does not leak it as an attribute.
33
+ */
34
+ labelId?: string | undefined;
29
35
  }
30
36
  /** A native file input in daisyUI `file-input` styling, with the chosen `File` bindable and a client-side size cap. */
31
37
  declare const FileInput: import("svelte").Component<Props, {}, "file">;
@@ -4,7 +4,7 @@ The label, hint and errors frame around one control, handing it the `FieldContro
4
4
  <!--
5
5
  FormField — the label + hint + errors frame around one control.
6
6
 
7
- The control is a snippet that receives a `FieldControl` (`id`,
7
+ The control is a snippet that receives a `FieldControl` (`id`, `labelId`,
8
8
  `describedBy`, `invalid`, `required`) to spread onto the primitive, so the
9
9
  label's `for`, the control's `aria-describedby` and `aria-invalid`, and the
10
10
  error region's `role="alert"` all stay wired together from a single `id`.
@@ -12,7 +12,13 @@ The label, hint and errors frame around one control, handing it the `FieldContro
12
12
  drop any of the field primitives — or their own `<input>` — into the slot.
13
13
 
14
14
  ARIA contract: `<label for>` names the control (so clicking the text focuses
15
- it); `describedBy` lists the hint's id then the error region's id, in that
15
+ it). `<label for>` only reaches a labelable element (`input`, `select`,
16
+ `textarea`, `button`, `meter`, `output`, `progress`), so the label also
17
+ carries an id — `labelId` — for a role-based control (a slider thumb, a
18
+ `group` / `radiogroup` root) to point `aria-labelledby` at, and a click on
19
+ the label is forwarded to such a control: it focuses the control itself if
20
+ it is focusable, else its first focusable descendant (a roving group's
21
+ current tab stop). `describedBy` lists the hint's id then the error region's id, in that
16
22
  reading order, and is empty when there is neither; `invalid` is true exactly
17
23
  when there is at least one error. The required marker is a `*` that is
18
24
  `aria-hidden` beside a visually-hidden `requiredLabel`, both inside the
@@ -27,19 +33,37 @@ The label, hint and errors frame around one control, handing it the `FieldContro
27
33
  let { id: idProp, label, hint, errors = [], required = false, requiredLabel = "(required)", children, class: className, ...rest } = $props();
28
34
  const uid = $props.id();
29
35
  const id = $derived(idProp ?? uid);
36
+ const labelId = $derived(`${id}-label`);
30
37
  const hintId = $derived(`${id}-hint`);
31
38
  const errorId = $derived(`${id}-error`);
32
39
  const invalid = $derived(errors.length > 0);
33
40
  const control = $derived({
34
41
  id,
42
+ labelId,
35
43
  describedBy: [hint === undefined ? "" : hintId, invalid ? errorId : ""].filter((part) => part !== "").join(" "),
36
44
  invalid,
37
45
  required
38
46
  });
47
+ // The elements a `<label for>` can name; the platform handles those.
48
+ const LABELABLE = "button, input, meter, output, progress, select, textarea";
49
+ // `<label for>` does nothing for a role-based control, so forward the click:
50
+ // focus the control if it is focusable, else its first focusable descendant.
51
+ const forwardClick = () => {
52
+ const control = document.getElementById(id);
53
+ if (control === null || control.matches(LABELABLE)) return;
54
+ const focusable = (element) => element.tabIndex >= 0 && !element.hasAttribute("disabled");
55
+ const target = focusable(control) ? control : Array.from(control.querySelectorAll("*")).find(focusable);
56
+ target?.focus();
57
+ };
39
58
  </script>
40
59
 
41
60
  <div {...rest} class={['fieldset', className]}>
42
- <label class="label" for={id}>
61
+ <!-- The click handler restores what `<label for>` does natively — a pointer
62
+ shortcut to the control — for a control `for` cannot reach. Keyboard
63
+ users reach the control itself with Tab, as with any label, so there is
64
+ no key handler to add and the label stays non-interactive. -->
65
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
66
+ <label class="label" for={id} id={labelId} onclick={forwardClick}>
43
67
  <span>{label}</span>
44
68
  {#if required}
45
69
  <span class="text-error" aria-hidden="true">*</span>
@@ -32,6 +32,12 @@ interface Props extends Omit<HTMLInputAttributes, 'type' | 'value' | 'files' | '
32
32
  invalid?: boolean | undefined;
33
33
  /** `FieldControl`: space-separated ids for `aria-describedby`. */
34
34
  describedBy?: string | undefined;
35
+ /**
36
+ * `FieldControl`: the visible label's id. Not used — the file input is
37
+ * labelable, so `<label for>` names it; passed through to `FileInput`,
38
+ * which drops it.
39
+ */
40
+ labelId?: string | undefined;
35
41
  }
36
42
  /** A `FileInput` that also measures the chosen image and previews it. */
37
43
  declare const ImageInput: import("svelte").Component<Props, {}, "height" | "width" | "file">;
@@ -0,0 +1,112 @@
1
+ <!-- @component
2
+ Several choices from a known list in a compact trigger that summarises them, on bits-ui's `Select` (`type="multiple"`).
3
+ -->
4
+ <!--
5
+ MultiSelect — any number of options from a medium-sized known list: the
6
+ trigger summarises the selection ("Full sun, Well-drained, +1"), a listbox
7
+ popover holds the options with checkmarks. Built on bits-ui's `Select`
8
+ with `type="multiple"`, bound to a `string[]`.
9
+
10
+ When to use which: `CheckboxGroup` puts every option on screen (right for
11
+ about seven or fewer); `Select` is the native single-choice `<select>`;
12
+ `MultiSelect` is for a longer list where several may be chosen. It takes
13
+ the same `options` shape as `Select`, so the two swap at the call site. It
14
+ is a separate component rather than a mode on `Select` because `Select`'s
15
+ point is to be the native control, with no script and the platform picker.
16
+
17
+ ARIA contract (APG select-only combobox / listbox): bits-ui gives the
18
+ trigger `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded` and
19
+ `aria-controls`; the content `role="listbox"` with `aria-multiselectable`;
20
+ each option `role="option"` with `aria-selected` (and `aria-disabled`,
21
+ which bits-ui 2.20 leaves out, added here); typeahead (fed from
22
+ `options` through bits-ui's `items`), Home / End, and Escape / Tab closing
23
+ with focus back on the trigger. Selecting an option keeps the listbox open.
24
+ This file adds the trigger's name (the `FieldControl` id goes on the
25
+ trigger, so `FormField`'s `<label for>` names it), `aria-describedby`,
26
+ `aria-invalid`, `aria-required`, the summary text and the `aria-hidden`
27
+ check icon.
28
+
29
+ The summary is plain text, not removable badges: focusable children inside
30
+ a button-like trigger are an accessibility trap.
31
+
32
+ `values` is always kept in `options` order, whatever order the reader
33
+ clicked in, so the summary is stable. `name` posts one hidden input per
34
+ value, in option order (and nothing at all while none is selected).
35
+ -->
36
+ <script lang="ts">import { Check } from "@lucide/svelte";
37
+ import { Select } from "bits-ui";
38
+ import { selectSize, toFieldOptions } from "./field-types.js";
39
+ let { options, values = $bindable([]), placeholder = "Select…", name, maxShown = 2, formatOverflow = (hidden) => `+${hidden}`, option, portal = true, disabled = false, size = "md", id, labelId: _labelId, describedBy = "", invalid = false, required = false, class: className, ...rest } = $props();
40
+ const uid = $props.id();
41
+ const triggerId = $derived(id ?? uid);
42
+ const entries = $derived(toFieldOptions(options));
43
+ const items = $derived(entries.map((entry) => ({
44
+ ...entry,
45
+ disabled: entry.disabled === true
46
+ })));
47
+ const selectedEntries = $derived(entries.filter((entry) => values.includes(entry.value)));
48
+ const selectedLabels = $derived(selectedEntries.map((entry) => entry.label));
49
+ const summary = $derived(selectedLabels.length <= maxShown ? selectedLabels.join(", ") : [...selectedLabels.slice(0, maxShown), formatOverflow(selectedLabels.length - maxShown)].join(", "));
50
+ const triggerText = $derived(selectedLabels.length === 0 ? placeholder : summary);
51
+ const ariaDescribedBy = $derived(describedBy === "" ? undefined : describedBy);
52
+ /** Keep `values` in option order, whatever order they were clicked in. */
53
+ function setValues(next) {
54
+ values = entries.map((entry) => entry.value).filter((value) => next.includes(value));
55
+ }
56
+ </script>
57
+
58
+ {#snippet optionBody(entry: FieldOption, selected: boolean)}
59
+ <Check class={['size-4 shrink-0', !selected && 'invisible']} aria-hidden="true" />
60
+ {#if option}
61
+ {@render option(entry, { selected })}
62
+ {:else}
63
+ <span>{entry.label}</span>
64
+ {/if}
65
+ {/snippet}
66
+
67
+ <div {...rest} class={['w-full', className]}>
68
+ <Select.Root
69
+ type="multiple"
70
+ bind:value={() => values, setValues}
71
+ {items}
72
+ {disabled}
73
+ {required}
74
+ >
75
+ <Select.Trigger
76
+ id={triggerId}
77
+ aria-describedby={ariaDescribedBy}
78
+ aria-invalid={invalid ? 'true' : undefined}
79
+ aria-required={required ? 'true' : undefined}
80
+ class={['select w-full text-start', selectSize[size], invalid && 'select-error']}
81
+ >
82
+ <span class={['truncate', selectedLabels.length === 0 && 'text-base-content/60']}>
83
+ {triggerText}
84
+ </span>
85
+ </Select.Trigger>
86
+ <Select.Portal disabled={!portal}>
87
+ <Select.Content
88
+ sideOffset={4}
89
+ class="menu bg-base-100 rounded-box border-base-300 z-50 max-h-72 w-(--bits-select-anchor-width) min-w-48 overflow-auto border p-2 shadow-lg"
90
+ >
91
+ <Select.Viewport>
92
+ {#each entries as entry (entry.value)}
93
+ <Select.Item
94
+ value={entry.value}
95
+ label={entry.label}
96
+ disabled={entry.disabled === true}
97
+ aria-disabled={entry.disabled === true ? 'true' : undefined}
98
+ class="rounded-field data-highlighted:bg-base-200 data-disabled:opacity-50 flex cursor-pointer items-center gap-2 px-3 py-2"
99
+ >
100
+ {#snippet children({ selected })}{@render optionBody(entry, selected)}{/snippet}
101
+ </Select.Item>
102
+ {/each}
103
+ </Select.Viewport>
104
+ </Select.Content>
105
+ </Select.Portal>
106
+ </Select.Root>
107
+ {#if name !== undefined}
108
+ {#each selectedEntries as entry (entry.value)}
109
+ <input type="hidden" {name} value={entry.value} {disabled} />
110
+ {/each}
111
+ {/if}
112
+ </div>