@thomasfosterau/components 0.0.0 → 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.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/canvas/VectorCanvas.svelte +239 -0
- package/dist/canvas/VectorCanvas.svelte.d.ts +87 -0
- package/dist/canvas/history.d.ts +105 -0
- package/dist/canvas/history.js +147 -0
- package/dist/canvas/index.d.ts +15 -0
- package/dist/canvas/index.js +14 -0
- package/dist/canvas/types.d.ts +91 -0
- package/dist/canvas/types.js +13 -0
- package/dist/canvas/utils.d.ts +66 -0
- package/dist/canvas/utils.js +114 -0
- package/dist/charts/Chart.svelte +300 -0
- package/dist/charts/Chart.svelte.d.ts +50 -0
- package/dist/charts/index.d.ts +11 -0
- package/dist/charts/index.js +10 -0
- package/dist/charts/types.d.ts +28 -0
- package/dist/charts/types.js +6 -0
- package/dist/citations/BookCitation.svelte +53 -0
- package/dist/citations/BookCitation.svelte.d.ts +41 -0
- package/dist/citations/ChapterCitation.svelte +38 -0
- package/dist/citations/ChapterCitation.svelte.d.ts +50 -0
- package/dist/citations/Citation.svelte +47 -0
- package/dist/citations/Citation.svelte.d.ts +24 -0
- package/dist/citations/CreditList.svelte +69 -0
- package/dist/citations/CreditList.svelte.d.ts +47 -0
- package/dist/citations/DOILink.svelte +28 -0
- package/dist/citations/DOILink.svelte.d.ts +19 -0
- package/dist/citations/Footnote.svelte +39 -0
- package/dist/citations/Footnote.svelte.d.ts +17 -0
- package/dist/citations/FootnoteList.svelte +36 -0
- package/dist/citations/FootnoteList.svelte.d.ts +21 -0
- package/dist/citations/ISBNLink.svelte +29 -0
- package/dist/citations/ISBNLink.svelte.d.ts +19 -0
- package/dist/citations/ISSNLink.svelte +29 -0
- package/dist/citations/ISSNLink.svelte.d.ts +19 -0
- package/dist/citations/Publisher.svelte +34 -0
- package/dist/citations/Publisher.svelte.d.ts +14 -0
- package/dist/citations/ReferenceList.svelte +53 -0
- package/dist/citations/ReferenceList.svelte.d.ts +21 -0
- package/dist/citations/ReportCitation.svelte +41 -0
- package/dist/citations/ReportCitation.svelte.d.ts +36 -0
- package/dist/citations/ScholarlyArticleCitation.svelte +64 -0
- package/dist/citations/ScholarlyArticleCitation.svelte.d.ts +41 -0
- package/dist/citations/ThesisCitation.svelte +35 -0
- package/dist/citations/ThesisCitation.svelte.d.ts +29 -0
- package/dist/citations/bibliography.svelte.d.ts +55 -0
- package/dist/citations/bibliography.svelte.js +78 -0
- package/dist/citations/citation.d.ts +71 -0
- package/dist/citations/citation.js +121 -0
- package/dist/citations/csl.d.ts +102 -0
- package/dist/citations/csl.js +20 -0
- package/dist/citations/index.d.ts +31 -0
- package/dist/citations/index.js +36 -0
- package/dist/citations/list-format.d.ts +34 -0
- package/dist/citations/list-format.js +38 -0
- package/dist/context.svelte.d.ts +46 -0
- package/dist/context.svelte.js +51 -0
- package/dist/data/DataGrid.svelte +150 -0
- package/dist/data/DataGrid.svelte.d.ts +118 -0
- package/dist/data/DescriptionList.svelte +47 -0
- package/dist/data/DescriptionList.svelte.d.ts +26 -0
- package/dist/data/DescriptionRow.svelte +55 -0
- package/dist/data/DescriptionRow.svelte.d.ts +21 -0
- package/dist/data/DiffText.svelte +29 -0
- package/dist/data/DiffText.svelte.d.ts +19 -0
- package/dist/data/ItemList.svelte +80 -0
- package/dist/data/ItemList.svelte.d.ts +52 -0
- package/dist/data/LinkListItem.svelte +46 -0
- package/dist/data/LinkListItem.svelte.d.ts +21 -0
- package/dist/data/Pagination.svelte +136 -0
- package/dist/data/Pagination.svelte.d.ts +71 -0
- package/dist/data/PaginationFooter.svelte +41 -0
- package/dist/data/PaginationFooter.svelte.d.ts +59 -0
- package/dist/data/ResultCount.svelte +27 -0
- package/dist/data/ResultCount.svelte.d.ts +18 -0
- package/dist/data/Table.svelte +145 -0
- package/dist/data/Table.svelte.d.ts +75 -0
- package/dist/data/TreeList.svelte +41 -0
- package/dist/data/TreeList.svelte.d.ts +17 -0
- package/dist/data/TreeListItem.svelte +29 -0
- package/dist/data/TreeListItem.svelte.d.ts +10 -0
- package/dist/data/description-context.d.ts +33 -0
- package/dist/data/description-context.js +35 -0
- package/dist/data/diff.d.ts +14 -0
- package/dist/data/diff.js +10 -0
- package/dist/data/index.d.ts +35 -0
- package/dist/data/index.js +35 -0
- package/dist/data/list-item.d.ts +30 -0
- package/dist/data/list-item.js +19 -0
- package/dist/data/pagination.d.ts +81 -0
- package/dist/data/pagination.js +79 -0
- package/dist/data/table-types.d.ts +64 -0
- package/dist/data/table-types.js +35 -0
- package/dist/feedback/ConfirmDialog.svelte +79 -0
- package/dist/feedback/ConfirmDialog.svelte.d.ts +82 -0
- package/dist/feedback/Dialog.svelte +77 -0
- package/dist/feedback/Dialog.svelte.d.ts +37 -0
- package/dist/feedback/LoadingBoundary.svelte +61 -0
- package/dist/feedback/LoadingBoundary.svelte.d.ts +34 -0
- package/dist/feedback/ProgressBar.svelte +46 -0
- package/dist/feedback/ProgressBar.svelte.d.ts +28 -0
- package/dist/feedback/SegmentBar.svelte +83 -0
- package/dist/feedback/SegmentBar.svelte.d.ts +36 -0
- package/dist/feedback/StatTile.svelte +77 -0
- package/dist/feedback/StatTile.svelte.d.ts +49 -0
- package/dist/feedback/Stats.svelte +18 -0
- package/dist/feedback/Stats.svelte.d.ts +16 -0
- package/dist/feedback/Timeline.svelte +86 -0
- package/dist/feedback/Timeline.svelte.d.ts +52 -0
- package/dist/feedback/Toaster.svelte +78 -0
- package/dist/feedback/Toaster.svelte.d.ts +32 -0
- package/dist/feedback/index.d.ts +31 -0
- package/dist/feedback/index.js +27 -0
- package/dist/feedback/sizes.d.ts +27 -0
- package/dist/feedback/sizes.js +41 -0
- package/dist/feedback/toaster.d.ts +48 -0
- package/dist/feedback/toaster.js +29 -0
- package/dist/fields/Checkbox.svelte +33 -0
- package/dist/fields/Checkbox.svelte.d.ts +24 -0
- package/dist/fields/CheckboxGroup.svelte +41 -0
- package/dist/fields/CheckboxGroup.svelte.d.ts +40 -0
- package/dist/fields/Combobox.svelte +222 -0
- package/dist/fields/Combobox.svelte.d.ts +98 -0
- package/dist/fields/DateInput.svelte +31 -0
- package/dist/fields/DateInput.svelte.d.ts +29 -0
- package/dist/fields/FieldErrors.svelte +30 -0
- package/dist/fields/FieldErrors.svelte.d.ts +11 -0
- package/dist/fields/Fieldset.svelte +48 -0
- package/dist/fields/Fieldset.svelte.d.ts +26 -0
- package/dist/fields/FileInput.svelte +67 -0
- package/dist/fields/FileInput.svelte.d.ts +33 -0
- package/dist/fields/FormField.svelte +54 -0
- package/dist/fields/FormField.svelte.d.ts +29 -0
- package/dist/fields/ImageInput.svelte +87 -0
- package/dist/fields/ImageInput.svelte.d.ts +39 -0
- package/dist/fields/PushToggle.svelte +93 -0
- package/dist/fields/PushToggle.svelte.d.ts +55 -0
- package/dist/fields/RadioGroup.svelte +85 -0
- package/dist/fields/RadioGroup.svelte.d.ts +41 -0
- package/dist/fields/SearchInput.svelte +57 -0
- package/dist/fields/SearchInput.svelte.d.ts +33 -0
- package/dist/fields/Select.svelte +36 -0
- package/dist/fields/Select.svelte.d.ts +29 -0
- package/dist/fields/TextInput.svelte +26 -0
- package/dist/fields/TextInput.svelte.d.ts +30 -0
- package/dist/fields/Textarea.svelte +22 -0
- package/dist/fields/Textarea.svelte.d.ts +25 -0
- package/dist/fields/Toggle.svelte +72 -0
- package/dist/fields/Toggle.svelte.d.ts +32 -0
- package/dist/fields/VideoInput.svelte +35 -0
- package/dist/fields/VideoInput.svelte.d.ts +40 -0
- package/dist/fields/field-types.d.ts +51 -0
- package/dist/fields/field-types.js +60 -0
- package/dist/fields/index.d.ts +34 -0
- package/dist/fields/index.js +34 -0
- package/dist/formatting/DateTime.svelte +35 -0
- package/dist/formatting/DateTime.svelte.d.ts +26 -0
- package/dist/formatting/FormatList.svelte +31 -0
- package/dist/formatting/FormatList.svelte.d.ts +46 -0
- package/dist/formatting/Highlight.svelte +47 -0
- package/dist/formatting/Highlight.svelte.d.ts +15 -0
- package/dist/formatting/Number.svelte +35 -0
- package/dist/formatting/Number.svelte.d.ts +43 -0
- package/dist/formatting/RelativeTime.svelte +69 -0
- package/dist/formatting/RelativeTime.svelte.d.ts +26 -0
- package/dist/formatting/Year.svelte +39 -0
- package/dist/formatting/Year.svelte.d.ts +21 -0
- package/dist/formatting/index.d.ts +12 -0
- package/dist/formatting/index.js +14 -0
- package/dist/forms/EntityForm.svelte +177 -0
- package/dist/forms/EntityForm.svelte.d.ts +57 -0
- package/dist/forms/FormActions.svelte +29 -0
- package/dist/forms/FormActions.svelte.d.ts +37 -0
- package/dist/forms/FormErrors.svelte +21 -0
- package/dist/forms/FormErrors.svelte.d.ts +16 -0
- package/dist/forms/HiddenField.svelte +22 -0
- package/dist/forms/HiddenField.svelte.d.ts +11 -0
- package/dist/forms/SearchForm.svelte +43 -0
- package/dist/forms/SearchForm.svelte.d.ts +36 -0
- package/dist/forms/entity-form.d.ts +12 -0
- package/dist/forms/entity-form.js +12 -0
- package/dist/forms/index.d.ts +14 -0
- package/dist/forms/index.js +14 -0
- package/dist/hotkeys/HotkeyBadge.svelte +39 -0
- package/dist/hotkeys/HotkeyBadge.svelte.d.ts +25 -0
- package/dist/hotkeys/HotkeyHelpDialog.svelte +68 -0
- package/dist/hotkeys/HotkeyHelpDialog.svelte.d.ts +35 -0
- package/dist/hotkeys/HotkeyProvider.svelte +92 -0
- package/dist/hotkeys/HotkeyProvider.svelte.d.ts +4 -0
- package/dist/hotkeys/context.svelte.d.ts +47 -0
- package/dist/hotkeys/context.svelte.js +72 -0
- package/dist/hotkeys/index.d.ts +23 -0
- package/dist/hotkeys/index.js +22 -0
- package/dist/hotkeys/match.d.ts +65 -0
- package/dist/hotkeys/match.js +133 -0
- package/dist/hotkeys/registry.svelte.d.ts +55 -0
- package/dist/hotkeys/registry.svelte.js +110 -0
- package/dist/hotkeys/types.d.ts +72 -0
- package/dist/hotkeys/types.js +18 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +36 -0
- package/dist/infobox/Infobox.svelte +79 -0
- package/dist/infobox/Infobox.svelte.d.ts +26 -0
- package/dist/infobox/InfoboxHeader.svelte +27 -0
- package/dist/infobox/InfoboxHeader.svelte.d.ts +11 -0
- package/dist/infobox/InfoboxImage.svelte +38 -0
- package/dist/infobox/InfoboxImage.svelte.d.ts +21 -0
- package/dist/infobox/InfoboxRow.svelte +50 -0
- package/dist/infobox/InfoboxRow.svelte.d.ts +25 -0
- package/dist/infobox/InfoboxSection.svelte +24 -0
- package/dist/infobox/InfoboxSection.svelte.d.ts +12 -0
- package/dist/infobox/index.d.ts +21 -0
- package/dist/infobox/index.js +20 -0
- package/dist/infobox/types.d.ts +86 -0
- package/dist/infobox/types.js +27 -0
- package/dist/layout/Breadcrumbs.svelte +52 -0
- package/dist/layout/Breadcrumbs.svelte.d.ts +18 -0
- package/dist/layout/DetailLayout.svelte +50 -0
- package/dist/layout/DetailLayout.svelte.d.ts +19 -0
- package/dist/layout/FilterBar.svelte +40 -0
- package/dist/layout/FilterBar.svelte.d.ts +12 -0
- package/dist/layout/Footer.svelte +32 -0
- package/dist/layout/Footer.svelte.d.ts +12 -0
- package/dist/layout/Main.svelte +30 -0
- package/dist/layout/Main.svelte.d.ts +15 -0
- package/dist/layout/Navbar.svelte +68 -0
- package/dist/layout/Navbar.svelte.d.ts +31 -0
- package/dist/layout/PageHeader.svelte +77 -0
- package/dist/layout/PageHeader.svelte.d.ts +33 -0
- package/dist/layout/Section.svelte +72 -0
- package/dist/layout/Section.svelte.d.ts +40 -0
- package/dist/layout/Sidebar.svelte +54 -0
- package/dist/layout/Sidebar.svelte.d.ts +27 -0
- package/dist/layout/Tabs.svelte +85 -0
- package/dist/layout/Tabs.svelte.d.ts +42 -0
- package/dist/layout/index.d.ts +29 -0
- package/dist/layout/index.js +29 -0
- package/dist/layout/types.d.ts +52 -0
- package/dist/layout/types.js +27 -0
- package/dist/links/ContextLinks.svelte +28 -0
- package/dist/links/ContextLinks.svelte.d.ts +20 -0
- package/dist/links/EmailLink.svelte +26 -0
- package/dist/links/EmailLink.svelte.d.ts +20 -0
- package/dist/links/index.d.ts +7 -0
- package/dist/links/index.js +7 -0
- package/dist/media/Figure.svelte +26 -0
- package/dist/media/Figure.svelte.d.ts +12 -0
- package/dist/media/Image.svelte +76 -0
- package/dist/media/Image.svelte.d.ts +41 -0
- package/dist/media/Video.svelte +65 -0
- package/dist/media/Video.svelte.d.ts +29 -0
- package/dist/media/image-dimensions.d.ts +28 -0
- package/dist/media/image-dimensions.js +34 -0
- package/dist/media/index.d.ts +10 -0
- package/dist/media/index.js +10 -0
- package/dist/search/CommandPalette.svelte +257 -0
- package/dist/search/CommandPalette.svelte.d.ts +59 -0
- package/dist/search/index.d.ts +13 -0
- package/dist/search/index.js +13 -0
- package/dist/search/types.d.ts +37 -0
- package/dist/search/types.js +20 -0
- package/dist/seo/Head.svelte +190 -0
- package/dist/seo/Head.svelte.d.ts +86 -0
- package/dist/seo/JsonLd.svelte +30 -0
- package/dist/seo/JsonLd.svelte.d.ts +20 -0
- package/dist/seo/index.d.ts +18 -0
- package/dist/seo/index.js +17 -0
- package/dist/seo/json-ld.d.ts +33 -0
- package/dist/seo/json-ld.js +29 -0
- package/dist/seo/og-image.d.ts +71 -0
- package/dist/seo/og-image.js +81 -0
- package/dist/seo/types.d.ts +43 -0
- package/dist/seo/types.js +6 -0
- package/dist/theme.css +161 -0
- package/dist/tips/PopoverTip.svelte +80 -0
- package/dist/tips/PopoverTip.svelte.d.ts +33 -0
- package/dist/tips/Tip.svelte +91 -0
- package/dist/tips/Tip.svelte.d.ts +25 -0
- package/dist/tips/index.d.ts +15 -0
- package/dist/tips/index.js +14 -0
- package/dist/tips/tip-stack.d.ts +25 -0
- package/dist/tips/tip-stack.js +37 -0
- package/dist/tips/tip-store.svelte.d.ts +74 -0
- package/dist/tips/tip-store.svelte.js +158 -0
- package/dist/tips/types.d.ts +46 -0
- package/dist/tips/types.js +6 -0
- package/dist/ui/Alert.svelte +61 -0
- package/dist/ui/Alert.svelte.d.ts +37 -0
- package/dist/ui/Badge.svelte +39 -0
- package/dist/ui/Badge.svelte.d.ts +32 -0
- package/dist/ui/Button.svelte +43 -0
- package/dist/ui/Button.svelte.d.ts +29 -0
- package/dist/ui/Card.svelte +49 -0
- package/dist/ui/Card.svelte.d.ts +44 -0
- package/dist/ui/Chip.svelte +58 -0
- package/dist/ui/Chip.svelte.d.ts +21 -0
- package/dist/ui/ConfirmButton.svelte +75 -0
- package/dist/ui/ConfirmButton.svelte.d.ts +48 -0
- package/dist/ui/Disclosure.svelte +39 -0
- package/dist/ui/Disclosure.svelte.d.ts +22 -0
- package/dist/ui/EmptyState.svelte +37 -0
- package/dist/ui/EmptyState.svelte.d.ts +22 -0
- package/dist/ui/ExternalLink.svelte +38 -0
- package/dist/ui/ExternalLink.svelte.d.ts +34 -0
- package/dist/ui/Heading.svelte +58 -0
- package/dist/ui/Heading.svelte.d.ts +22 -0
- package/dist/ui/Link.svelte +34 -0
- package/dist/ui/Link.svelte.d.ts +20 -0
- package/dist/ui/LinkButton.svelte +51 -0
- package/dist/ui/LinkButton.svelte.d.ts +33 -0
- package/dist/ui/Menu.svelte +54 -0
- package/dist/ui/Menu.svelte.d.ts +34 -0
- package/dist/ui/MenuItem.svelte +75 -0
- package/dist/ui/MenuItem.svelte.d.ts +21 -0
- package/dist/ui/MenuSeparator.svelte +26 -0
- package/dist/ui/MenuSeparator.svelte.d.ts +8 -0
- package/dist/ui/Popover.svelte +63 -0
- package/dist/ui/Popover.svelte.d.ts +40 -0
- package/dist/ui/ShareButton.svelte +64 -0
- package/dist/ui/ShareButton.svelte.d.ts +39 -0
- package/dist/ui/Skeleton.svelte +43 -0
- package/dist/ui/Skeleton.svelte.d.ts +22 -0
- package/dist/ui/ThemeToggle.svelte +65 -0
- package/dist/ui/ThemeToggle.svelte.d.ts +24 -0
- package/dist/ui/Tooltip.svelte +54 -0
- package/dist/ui/Tooltip.svelte.d.ts +35 -0
- package/dist/ui/VisuallyHidden.svelte +23 -0
- package/dist/ui/VisuallyHidden.svelte.d.ts +10 -0
- package/dist/ui/colour-token.d.ts +41 -0
- package/dist/ui/colour-token.js +49 -0
- package/dist/ui/index.d.ts +44 -0
- package/dist/ui/index.js +44 -0
- package/dist/ui/menu-context.d.ts +22 -0
- package/dist/ui/menu-context.js +26 -0
- package/dist/ui/slug.d.ts +19 -0
- package/dist/ui/slug.js +25 -0
- package/dist/ui/tones.d.ts +68 -0
- package/dist/ui/tones.js +150 -0
- package/package.json +25 -25
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
A single-select typeahead over a static list or an asynchronous search, on bits-ui’s `Combobox`.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
Combobox — a single-select typeahead over a static list OR an asynchronous
|
|
6
|
+
search, on bits-ui's `Combobox`.
|
|
7
|
+
|
|
8
|
+
The previous generation of this component hand-rolled the APG combobox
|
|
9
|
+
pattern: an `<input role="combobox">`, a `<ul role="listbox">`, an
|
|
10
|
+
`activeIndex`, wrap-around arrow keys, Home/End, and a blur timeout to let a
|
|
11
|
+
pointerdown on an option land before the list closed. All of that is
|
|
12
|
+
bits-ui's here. What is NOT bits-ui's, and is what this file is actually
|
|
13
|
+
about, is the SEARCH LIFECYCLE.
|
|
14
|
+
|
|
15
|
+
The search lifecycle, in the order the problems bite:
|
|
16
|
+
|
|
17
|
+
debounce a keystroke does not start a request; `debounceMs` of
|
|
18
|
+
quiet does.
|
|
19
|
+
latest-wins each request gets an `AbortController`, and starting a
|
|
20
|
+
new one aborts the last. A superseded request is
|
|
21
|
+
cancelled rather than merely ignored, so a slow first
|
|
22
|
+
response cannot overwrite a fast second one.
|
|
23
|
+
stale-while- the previous results stay on screen while the next
|
|
24
|
+
revalidate request is in flight, with `pending` true. Clearing the
|
|
25
|
+
list on every keystroke makes the dropdown strobe and
|
|
26
|
+
costs the reader the thing they were about to pick.
|
|
27
|
+
|
|
28
|
+
ARIA contract: bits-ui gives the input `role="combobox"`,
|
|
29
|
+
`aria-autocomplete="list"`, `aria-expanded` and `aria-activedescendant`
|
|
30
|
+
pointing at the highlighted option; the panel is `role="listbox"` and each
|
|
31
|
+
row `role="option"` with `aria-selected`. The visible `<label>` names the
|
|
32
|
+
input through `for`/`id`; `errorMessage` is a `role="alert"` paragraph the
|
|
33
|
+
input is NOT described by, because a search failure is about the list rather
|
|
34
|
+
than about the value.
|
|
35
|
+
|
|
36
|
+
One behaviour is preserved from the hand-rolled version on purpose:
|
|
37
|
+
**Enter with nothing highlighted must not prevent a wrapping `<form>` from
|
|
38
|
+
submitting.** A search box that sits in a plain GET form has to keep working
|
|
39
|
+
when the list is empty or the reader never opened it. bits-ui's input
|
|
40
|
+
`preventDefault`s Enter whenever the panel is open, so the handler below
|
|
41
|
+
closes the panel first when nothing is highlighted: with the panel closed,
|
|
42
|
+
bits-ui treats Enter as an interaction key and returns without touching the
|
|
43
|
+
event, and the form submits. `highlighted` is tracked through the items'
|
|
44
|
+
own `onHighlight` / `onUnhighlight` callbacks, which is the only place
|
|
45
|
+
bits-ui surfaces it.
|
|
46
|
+
|
|
47
|
+
Note what that means in practice: bits-ui puts the cursor on the first
|
|
48
|
+
candidate as soon as typing produces one, so with matches on screen Enter
|
|
49
|
+
COMMITS. The fall-through happens where it matters — a query that matched
|
|
50
|
+
nothing, or a panel the reader never opened — which is exactly the case a
|
|
51
|
+
no-JS search form depends on.
|
|
52
|
+
-->
|
|
53
|
+
<script lang="ts" generics="T">import { Combobox } from "bits-ui";
|
|
54
|
+
import { inputSize } from "./field-types.js";
|
|
55
|
+
let { value = $bindable(), items, onSearch, debounceMs = 150, getId, getLabel, getBadge, label, placeholder, name, emptyLabel = "No matches", pendingLabel = "Searching…", errorMessage, id: idProp, describedBy = "", invalid = false, required = false, disabled = false, size = "md", class: className, item, footer, empty } = $props();
|
|
56
|
+
const uid = $props.id();
|
|
57
|
+
const inputId = $derived(idProp ?? uid);
|
|
58
|
+
let query = $state("");
|
|
59
|
+
let open = $state(false);
|
|
60
|
+
let results = $state([]);
|
|
61
|
+
let pending = $state(false);
|
|
62
|
+
let searchError = $state();
|
|
63
|
+
/** The id of the row bits-ui currently has under the cursor, if any. */
|
|
64
|
+
let highlighted = $state();
|
|
65
|
+
let timer;
|
|
66
|
+
let inFlight;
|
|
67
|
+
/** Static mode filters locally; async mode shows whatever last settled. */
|
|
68
|
+
const visible = $derived.by(() => {
|
|
69
|
+
if (onSearch !== undefined) return results;
|
|
70
|
+
const all = items ?? [];
|
|
71
|
+
const needle = query.trim().toLowerCase();
|
|
72
|
+
if (needle === "") return all;
|
|
73
|
+
return all.filter((entry) => getLabel(entry).toLowerCase().includes(needle));
|
|
74
|
+
});
|
|
75
|
+
const selectedLabel = $derived.by(() => {
|
|
76
|
+
if (value === undefined || items === undefined) return undefined;
|
|
77
|
+
const match = items.find((entry) => getId(entry) === value);
|
|
78
|
+
return match === undefined ? undefined : getLabel(match);
|
|
79
|
+
});
|
|
80
|
+
const visibleError = $derived(errorMessage ?? searchError);
|
|
81
|
+
const search = (text) => {
|
|
82
|
+
if (onSearch === undefined) return;
|
|
83
|
+
inFlight?.abort();
|
|
84
|
+
const own = new AbortController();
|
|
85
|
+
inFlight = own;
|
|
86
|
+
pending = true;
|
|
87
|
+
searchError = undefined;
|
|
88
|
+
void onSearch(text, own.signal).then((next) => {
|
|
89
|
+
// A superseded request must not write: `inFlight` is the identity
|
|
90
|
+
// check, `signal.aborted` catches the caller aborting its own work.
|
|
91
|
+
if (own.signal.aborted || inFlight !== own) return;
|
|
92
|
+
results = next;
|
|
93
|
+
pending = false;
|
|
94
|
+
}).catch((error) => {
|
|
95
|
+
if (own.signal.aborted || inFlight !== own) return;
|
|
96
|
+
pending = false;
|
|
97
|
+
searchError = error instanceof Error ? error.message : String(error);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const schedule = (text) => {
|
|
101
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
timer = undefined;
|
|
104
|
+
search(text);
|
|
105
|
+
}, debounceMs);
|
|
106
|
+
};
|
|
107
|
+
$effect(() => () => {
|
|
108
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
109
|
+
inFlight?.abort();
|
|
110
|
+
});
|
|
111
|
+
const typed = (event) => {
|
|
112
|
+
query = event.currentTarget.value;
|
|
113
|
+
if (onSearch === undefined) {
|
|
114
|
+
open = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (query.trim() === "") {
|
|
118
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
119
|
+
inFlight?.abort();
|
|
120
|
+
inFlight = undefined;
|
|
121
|
+
pending = false;
|
|
122
|
+
results = [];
|
|
123
|
+
open = false;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
open = true;
|
|
127
|
+
schedule(query);
|
|
128
|
+
};
|
|
129
|
+
const keydown = (event) => {
|
|
130
|
+
if (event.key !== "Enter" || highlighted !== undefined) return;
|
|
131
|
+
// Nothing to commit. Close first so bits-ui's own Enter branch takes the
|
|
132
|
+
// closed path, which returns without `preventDefault` — that is what lets
|
|
133
|
+
// a wrapping form submit.
|
|
134
|
+
open = false;
|
|
135
|
+
};
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<div class={['fieldset w-full', className]}>
|
|
139
|
+
<label class="label" for={inputId}>{label}</label>
|
|
140
|
+
|
|
141
|
+
<Combobox.Root
|
|
142
|
+
type="single"
|
|
143
|
+
bind:open
|
|
144
|
+
value={value ?? ''}
|
|
145
|
+
onValueChange={(next) => {
|
|
146
|
+
value = next === '' ? undefined : next;
|
|
147
|
+
}}
|
|
148
|
+
{disabled}
|
|
149
|
+
{required}
|
|
150
|
+
>
|
|
151
|
+
<Combobox.Input
|
|
152
|
+
id={inputId}
|
|
153
|
+
{...selectedLabel === undefined ? {} : { defaultValue: selectedLabel }}
|
|
154
|
+
{placeholder}
|
|
155
|
+
autocomplete="off"
|
|
156
|
+
aria-describedby={describedBy === '' ? undefined : describedBy}
|
|
157
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
158
|
+
oninput={typed}
|
|
159
|
+
onkeydown={keydown}
|
|
160
|
+
class={['input w-full', inputSize[size], invalid && 'input-error']}
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<Combobox.Portal>
|
|
164
|
+
<Combobox.Content
|
|
165
|
+
sideOffset={4}
|
|
166
|
+
class="menu bg-base-100 rounded-box border-base-300 z-50 max-h-72 min-w-64 overflow-auto border p-2 shadow-md"
|
|
167
|
+
>
|
|
168
|
+
<Combobox.Viewport>
|
|
169
|
+
{#each visible as entry (getId(entry))}
|
|
170
|
+
{@const entryId = getId(entry)}
|
|
171
|
+
{@const badge = getBadge?.(entry)}
|
|
172
|
+
<Combobox.Item
|
|
173
|
+
value={entryId}
|
|
174
|
+
label={getLabel(entry)}
|
|
175
|
+
onHighlight={() => (highlighted = entryId)}
|
|
176
|
+
onUnhighlight={() => {
|
|
177
|
+
if (highlighted === entryId) highlighted = undefined;
|
|
178
|
+
}}
|
|
179
|
+
class="rounded-field data-highlighted:bg-base-200 data-disabled:opacity-50 flex cursor-pointer items-center justify-between gap-3 px-3 py-2"
|
|
180
|
+
>
|
|
181
|
+
{#if item}
|
|
182
|
+
{@render item(entry)}
|
|
183
|
+
{:else}
|
|
184
|
+
<span>{getLabel(entry)}</span>
|
|
185
|
+
{#if badge !== undefined}
|
|
186
|
+
<span class="badge badge-ghost badge-sm">{badge}</span>
|
|
187
|
+
{/if}
|
|
188
|
+
{/if}
|
|
189
|
+
</Combobox.Item>
|
|
190
|
+
{/each}
|
|
191
|
+
|
|
192
|
+
{#if visible.length === 0}
|
|
193
|
+
{#if pending}
|
|
194
|
+
<p class="fieldset-label px-3 py-2">{pendingLabel}</p>
|
|
195
|
+
{:else if empty}
|
|
196
|
+
{@render empty({ query })}
|
|
197
|
+
{:else}
|
|
198
|
+
<p class="fieldset-label px-3 py-2">{emptyLabel}</p>
|
|
199
|
+
{/if}
|
|
200
|
+
{/if}
|
|
201
|
+
|
|
202
|
+
{#if footer}
|
|
203
|
+
{@render footer({ query, pending })}
|
|
204
|
+
{/if}
|
|
205
|
+
</Combobox.Viewport>
|
|
206
|
+
</Combobox.Content>
|
|
207
|
+
</Combobox.Portal>
|
|
208
|
+
</Combobox.Root>
|
|
209
|
+
|
|
210
|
+
<!--
|
|
211
|
+
The posted value is the id, not the typed text: bits-ui's own hidden input
|
|
212
|
+
is not used, because `name` here means "post the selection" and nothing
|
|
213
|
+
else about the widget should depend on it.
|
|
214
|
+
-->
|
|
215
|
+
{#if name !== undefined}
|
|
216
|
+
<input type="hidden" {name} value={value ?? ''} />
|
|
217
|
+
{/if}
|
|
218
|
+
|
|
219
|
+
{#if visibleError !== undefined}
|
|
220
|
+
<p class="text-error pt-1 text-sm" role="alert">{visibleError}</p>
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Combobox } from 'bits-ui';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { type FieldSize } from './field-types.ts';
|
|
4
|
+
declare function $$render<T>(): {
|
|
5
|
+
props: {
|
|
6
|
+
/** The selected item's id. */
|
|
7
|
+
value?: string | undefined;
|
|
8
|
+
/** A static list, filtered here by label. Ignored when `onSearch` is set. */
|
|
9
|
+
items?: readonly T[] | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Fetch matches for a query. Debounced by `debounceMs`; `signal` aborts
|
|
12
|
+
* when a newer query supersedes this one, so pass it to `fetch`.
|
|
13
|
+
*/
|
|
14
|
+
onSearch?: ((query: string, signal: AbortSignal) => Promise<readonly T[]>) | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Quiet period before a search starts, in milliseconds.
|
|
17
|
+
* @defaultValue `150`
|
|
18
|
+
*/
|
|
19
|
+
debounceMs?: number | undefined;
|
|
20
|
+
/** The item's stable id — what `value` holds and what the hidden input posts. */
|
|
21
|
+
getId: (item: T) => string;
|
|
22
|
+
/** The item's human-readable text. */
|
|
23
|
+
getLabel: (item: T) => string;
|
|
24
|
+
/** Optional short chip on the right of a row. */
|
|
25
|
+
getBadge?: ((item: T) => string | undefined) | undefined;
|
|
26
|
+
/** Visible label; names the input. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** Placeholder in the input. */
|
|
29
|
+
placeholder?: string | undefined;
|
|
30
|
+
/** Posted field name. Set it to render a hidden input carrying the id. */
|
|
31
|
+
name?: string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Shown when a settled search returned nothing.
|
|
34
|
+
* @defaultValue `'No matches'`
|
|
35
|
+
*/
|
|
36
|
+
emptyLabel?: string | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Shown while the first search for a query is in flight.
|
|
39
|
+
* @defaultValue `'Searching…'`
|
|
40
|
+
*/
|
|
41
|
+
pendingLabel?: string | undefined;
|
|
42
|
+
/** A caller-owned failure, rendered as an alert under the input. */
|
|
43
|
+
errorMessage?: string | undefined;
|
|
44
|
+
/** `FieldControl`: the input's id. */
|
|
45
|
+
id?: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* `FieldControl`: ids for `aria-describedby`.
|
|
48
|
+
* @defaultValue `''`
|
|
49
|
+
*/
|
|
50
|
+
describedBy?: string | undefined;
|
|
51
|
+
/** `FieldControl`: the field is in error. */
|
|
52
|
+
invalid?: boolean | undefined;
|
|
53
|
+
/** `FieldControl`: the field must be filled. */
|
|
54
|
+
required?: boolean | undefined;
|
|
55
|
+
/** Disables the input. */
|
|
56
|
+
disabled?: boolean | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* daisyUI size step.
|
|
59
|
+
* @defaultValue `'md'`
|
|
60
|
+
*/
|
|
61
|
+
size?: FieldSize | undefined;
|
|
62
|
+
/** Extra classes, merged last. */
|
|
63
|
+
class?: string | undefined;
|
|
64
|
+
/** Replace a row's contents. */
|
|
65
|
+
item?: Snippet<[T]> | undefined;
|
|
66
|
+
/** Rendered after the rows — a "create this" row, a "see all" link. */
|
|
67
|
+
footer?: Snippet<[{
|
|
68
|
+
query: string;
|
|
69
|
+
pending: boolean;
|
|
70
|
+
}]> | undefined;
|
|
71
|
+
/** Replaces `emptyLabel` when a settled search returned nothing. */
|
|
72
|
+
empty?: Snippet<[{
|
|
73
|
+
query: string;
|
|
74
|
+
}]> | undefined;
|
|
75
|
+
};
|
|
76
|
+
exports: {};
|
|
77
|
+
bindings: "value";
|
|
78
|
+
slots: {};
|
|
79
|
+
events: {};
|
|
80
|
+
};
|
|
81
|
+
declare class __sveltets_Render<T> {
|
|
82
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
83
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
84
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
85
|
+
bindings(): "value";
|
|
86
|
+
exports(): {};
|
|
87
|
+
}
|
|
88
|
+
interface $$IsomorphicComponent {
|
|
89
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
90
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
91
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
92
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
93
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
94
|
+
}
|
|
95
|
+
/** A single-select typeahead over a static list or an asynchronous search, on bits-ui’s `Combobox`. */
|
|
96
|
+
declare const Combobox: $$IsomorphicComponent;
|
|
97
|
+
type Combobox<T> = InstanceType<typeof Combobox<T>>;
|
|
98
|
+
export default Combobox;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
A native `<input type="date">` in daisyUI `input` styling, with `FieldControl` wiring.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
DateInput — the native `<input type="date">` in daisyUI `input` styling.
|
|
6
|
+
|
|
7
|
+
`value` holds the ISO calendar date (`YYYY-MM-DD`) the control speaks
|
|
8
|
+
natively, which is also the shape a JSON API's date fields take, so no
|
|
9
|
+
conversion sits between the two.
|
|
10
|
+
|
|
11
|
+
Native on purpose, and the reason is worth stating: bits-ui's `DateField`
|
|
12
|
+
is a segmented control with its own locale handling and its own keyboard
|
|
13
|
+
contract, which is the right answer when a design needs a calendar popover
|
|
14
|
+
and the wrong one for a single date on a form — the platform control is
|
|
15
|
+
keyboard-complete, localises itself, and on a phone opens the system date
|
|
16
|
+
picker that people already know.
|
|
17
|
+
-->
|
|
18
|
+
<script lang="ts">import { inputSize } from "./field-types.js";
|
|
19
|
+
let { value = $bindable(""), min, max, size = "md", invalid = false, describedBy = "", class: className, ...rest } = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<input
|
|
23
|
+
{...rest}
|
|
24
|
+
type="date"
|
|
25
|
+
bind:value
|
|
26
|
+
{min}
|
|
27
|
+
{max}
|
|
28
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
29
|
+
aria-describedby={describedBy === '' ? undefined : describedBy}
|
|
30
|
+
class={['input w-full', inputSize[size], invalid && 'input-error', className]}
|
|
31
|
+
/>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
2
|
+
import { type FieldSize } from './field-types.ts';
|
|
3
|
+
interface Props extends Omit<HTMLInputAttributes, 'type' | 'value' | 'size' | 'min' | 'max'> {
|
|
4
|
+
/**
|
|
5
|
+
* ISO calendar date, `YYYY-MM-DD`; empty when unset.
|
|
6
|
+
* @defaultValue `''`
|
|
7
|
+
*/
|
|
8
|
+
value?: string | undefined;
|
|
9
|
+
/** Earliest selectable date, ISO. */
|
|
10
|
+
min?: string | undefined;
|
|
11
|
+
/** Latest selectable date, ISO. */
|
|
12
|
+
max?: string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* daisyUI size step.
|
|
15
|
+
* @defaultValue `'md'`
|
|
16
|
+
*/
|
|
17
|
+
size?: FieldSize | undefined;
|
|
18
|
+
/** `FieldControl`: the field is in error — sets `aria-invalid` and the error style. */
|
|
19
|
+
invalid?: boolean | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* `FieldControl`: space-separated ids for `aria-describedby`.
|
|
22
|
+
* @defaultValue `''`
|
|
23
|
+
*/
|
|
24
|
+
describedBy?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** A native `<input type="date">` in daisyUI `input` styling, with `FieldControl` wiring. */
|
|
27
|
+
declare const DateInput: import("svelte").Component<Props, {}, "value">;
|
|
28
|
+
type DateInput = ReturnType<typeof DateInput>;
|
|
29
|
+
export default DateInput;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
The validation messages under one control: one `<p>` per message inside a single container.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
FieldErrors — the validation messages under one control: one `<p>` per
|
|
6
|
+
message inside a single container.
|
|
7
|
+
|
|
8
|
+
ARIA contract: the container is the live region (`role="alert"`), not each
|
|
9
|
+
paragraph — a field that fails three rules should interrupt the reader once
|
|
10
|
+
and then read all three, which is what one region holding three paragraphs
|
|
11
|
+
does and what three regions do not. It carries the `id` a control points
|
|
12
|
+
`aria-describedby` at, so the messages are also read on demand when focus
|
|
13
|
+
reaches the control.
|
|
14
|
+
|
|
15
|
+
Nothing is rendered while `errors` is empty. That is deliberate: a live
|
|
16
|
+
region has to be IN the document before its content changes for the change
|
|
17
|
+
to be announced, and inserting a populated region announces it, so the
|
|
18
|
+
cheapest correct thing is to mount it only when there is something to say.
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts">let { errors, id, class: className, ...rest } = $props();
|
|
21
|
+
export {};
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if errors.length > 0}
|
|
25
|
+
<div {...rest} {id} role="alert" class={className}>
|
|
26
|
+
{#each errors as message, index (index)}
|
|
27
|
+
<p class="fieldset-label text-error">{message}</p>
|
|
28
|
+
{/each}
|
|
29
|
+
</div>
|
|
30
|
+
{/if}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
/** Messages to render, one paragraph each. */
|
|
4
|
+
errors: readonly string[];
|
|
5
|
+
/** The id a control's `aria-describedby` points at. */
|
|
6
|
+
id?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** The validation messages under one control: one `<p>` per message inside a single container. */
|
|
9
|
+
declare const FieldErrors: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type FieldErrors = ReturnType<typeof FieldErrors>;
|
|
11
|
+
export default FieldErrors;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
The `FormField` frame — legend, hint, errors — for a group of controls rather than one.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
Fieldset — the same frame as `FormField`, for a GROUP of controls rather
|
|
6
|
+
than one.
|
|
7
|
+
|
|
8
|
+
A group of related checkboxes or radios is a `<fieldset>` with a `<legend>`,
|
|
9
|
+
not a label pointing at one input: there is no single control for a `for`
|
|
10
|
+
attribute to name, and the legend is what assistive technology repeats as it
|
|
11
|
+
moves between the members. So the hint and the errors hang off the
|
|
12
|
+
`<fieldset>` itself through `aria-describedby`. There is no `aria-invalid`:
|
|
13
|
+
the implicit `group` role does not support it, so the invalid state is a
|
|
14
|
+
`data-invalid` hook for styling and the error region is what announces it.
|
|
15
|
+
|
|
16
|
+
`CheckboxGroup` is built on this. `RadioGroup` is not — bits-ui's radio
|
|
17
|
+
group renders its own `role="radiogroup"` element, and a role inside a
|
|
18
|
+
`<fieldset>` would be named by nothing, so it names itself from a heading it
|
|
19
|
+
renders instead. That is the one place the two frames diverge.
|
|
20
|
+
-->
|
|
21
|
+
<script lang="ts">import FieldErrors from "./FieldErrors.svelte";
|
|
22
|
+
let { legend, hint, errors = [], required = false, requiredLabel = "(required)", children, class: className, ...rest } = $props();
|
|
23
|
+
const uid = $props.id();
|
|
24
|
+
const hintId = `${uid}-hint`;
|
|
25
|
+
const errorId = `${uid}-error`;
|
|
26
|
+
const invalid = $derived(errors.length > 0);
|
|
27
|
+
const describedBy = $derived([hint === undefined ? "" : hintId, invalid ? errorId : ""].filter((part) => part !== "").join(" "));
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<fieldset
|
|
31
|
+
{...rest}
|
|
32
|
+
aria-describedby={describedBy === '' ? undefined : describedBy}
|
|
33
|
+
data-invalid={invalid ? '' : undefined}
|
|
34
|
+
class={['fieldset', className]}
|
|
35
|
+
>
|
|
36
|
+
<legend class="fieldset-legend">
|
|
37
|
+
{legend}
|
|
38
|
+
{#if required}
|
|
39
|
+
<span class="text-error" aria-hidden="true">*</span>
|
|
40
|
+
<span class="sr-only">{requiredLabel}</span>
|
|
41
|
+
{/if}
|
|
42
|
+
</legend>
|
|
43
|
+
{@render children()}
|
|
44
|
+
{#if hint !== undefined}
|
|
45
|
+
<p class="fieldset-label" id={hintId}>{hint}</p>
|
|
46
|
+
{/if}
|
|
47
|
+
<FieldErrors {errors} id={errorId} />
|
|
48
|
+
</fieldset>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children'> {
|
|
4
|
+
/** The group's `<legend>`; names every control inside it. */
|
|
5
|
+
legend: string;
|
|
6
|
+
/** Guidance rendered under the legend. */
|
|
7
|
+
hint?: string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Validation messages; any at all marks the group invalid.
|
|
10
|
+
* @defaultValue `[]`
|
|
11
|
+
*/
|
|
12
|
+
errors?: readonly string[] | undefined;
|
|
13
|
+
/** Marks the group required and reads `requiredLabel` after the legend. */
|
|
14
|
+
required?: boolean | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Read out after the legend when `required`.
|
|
17
|
+
* @defaultValue `'(required)'`
|
|
18
|
+
*/
|
|
19
|
+
requiredLabel?: string | undefined;
|
|
20
|
+
/** The grouped controls. */
|
|
21
|
+
children: Snippet<[]>;
|
|
22
|
+
}
|
|
23
|
+
/** The `FormField` frame — legend, hint, errors — for a group of controls rather than one. */
|
|
24
|
+
declare const Fieldset: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type Fieldset = ReturnType<typeof Fieldset>;
|
|
26
|
+
export default Fieldset;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
A native file input in daisyUI `file-input` styling, with the chosen `File` bindable and a client-side size cap.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
FileInput — a native `<input type="file">` in daisyUI `file-input` styling,
|
|
6
|
+
with the chosen `File` exposed as a bindable prop and a client-side size cap.
|
|
7
|
+
|
|
8
|
+
It does NOT upload anything. The transfer belongs to the caller — a form
|
|
9
|
+
POST, a direct-to-storage PUT, a resumable client — and a component every
|
|
10
|
+
page imports has no business carrying one. `ImageInput` and `VideoInput`
|
|
11
|
+
build on this: the selection and the messaging are shared, the transfer is
|
|
12
|
+
not.
|
|
13
|
+
|
|
14
|
+
`maxBytes` is enforced here as well as on the server because a file rejected
|
|
15
|
+
after the transfer is a uniquely expensive way to say no. An over-size file
|
|
16
|
+
is cleared from the control rather than left sitting in it, so what the
|
|
17
|
+
control shows and what `file` holds cannot disagree.
|
|
18
|
+
|
|
19
|
+
ARIA contract: a native file input. `aria-invalid` is set when the field is
|
|
20
|
+
invalid OR the chosen file was too large, and the too-large message is an
|
|
21
|
+
`alert` region the input's `aria-describedby` also points at, so the reason
|
|
22
|
+
is announced and is available again on focus.
|
|
23
|
+
-->
|
|
24
|
+
<script lang="ts">import FieldErrors from "./FieldErrors.svelte";
|
|
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();
|
|
27
|
+
const uid = $props.id();
|
|
28
|
+
const tooLargeId = `${uid}-too-large`;
|
|
29
|
+
let tooLarge = $state(false);
|
|
30
|
+
const described = $derived([describedBy, tooLarge ? tooLargeId : ""].filter((part) => part !== "").join(" "));
|
|
31
|
+
const choose = (control) => {
|
|
32
|
+
const chosen = control.files?.[0];
|
|
33
|
+
tooLarge = false;
|
|
34
|
+
if (chosen === undefined) {
|
|
35
|
+
file = undefined;
|
|
36
|
+
onSelect?.(undefined);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (maxBytes !== undefined && chosen.size > maxBytes) {
|
|
40
|
+
tooLarge = true;
|
|
41
|
+
// Clear the control too: leaving the name of a file that was refused
|
|
42
|
+
// showing in the field is how someone submits and wonders what happened.
|
|
43
|
+
control.value = "";
|
|
44
|
+
file = undefined;
|
|
45
|
+
onSelect?.(undefined);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
file = chosen;
|
|
49
|
+
onSelect?.(chosen);
|
|
50
|
+
};
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<input
|
|
54
|
+
{...rest}
|
|
55
|
+
type="file"
|
|
56
|
+
{accept}
|
|
57
|
+
onchange={(event) => choose(event.currentTarget)}
|
|
58
|
+
aria-invalid={invalid || tooLarge ? 'true' : undefined}
|
|
59
|
+
aria-describedby={described === '' ? undefined : described}
|
|
60
|
+
class={[
|
|
61
|
+
'file-input w-full',
|
|
62
|
+
fileInputSize[size],
|
|
63
|
+
(invalid || tooLarge) && 'file-input-error',
|
|
64
|
+
className,
|
|
65
|
+
]}
|
|
66
|
+
/>
|
|
67
|
+
<FieldErrors errors={tooLarge ? [tooLargeMessage] : []} id={tooLargeId} />
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
2
|
+
import { type FieldSize } from './field-types.ts';
|
|
3
|
+
interface Props extends Omit<HTMLInputAttributes, 'type' | 'value' | 'files' | 'size'> {
|
|
4
|
+
/** The chosen file; `undefined` when the control is empty. */
|
|
5
|
+
file?: File | undefined;
|
|
6
|
+
/** `accept` attribute value. */
|
|
7
|
+
accept?: string | undefined;
|
|
8
|
+
/** Largest file to accept, in bytes. Omitted, there is no cap. */
|
|
9
|
+
maxBytes?: number | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Shown when the chosen file exceeds `maxBytes`.
|
|
12
|
+
* @defaultValue `'That file is too large.'`
|
|
13
|
+
*/
|
|
14
|
+
tooLargeMessage?: string | undefined;
|
|
15
|
+
/** Called with the accepted file, or `undefined` when the choice cleared it. */
|
|
16
|
+
onSelect?: ((file: File | undefined) => void) | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* daisyUI size step.
|
|
19
|
+
* @defaultValue `'md'`
|
|
20
|
+
*/
|
|
21
|
+
size?: FieldSize | undefined;
|
|
22
|
+
/** `FieldControl`: the field is in error — sets `aria-invalid` and the error style. */
|
|
23
|
+
invalid?: boolean | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* `FieldControl`: space-separated ids for `aria-describedby`.
|
|
26
|
+
* @defaultValue `''`
|
|
27
|
+
*/
|
|
28
|
+
describedBy?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/** A native file input in daisyUI `file-input` styling, with the chosen `File` bindable and a client-side size cap. */
|
|
31
|
+
declare const FileInput: import("svelte").Component<Props, {}, "file">;
|
|
32
|
+
type FileInput = ReturnType<typeof FileInput>;
|
|
33
|
+
export default FileInput;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!-- @component
|
|
2
|
+
The label, hint and errors frame around one control, handing it the `FieldControl` wiring.
|
|
3
|
+
-->
|
|
4
|
+
<!--
|
|
5
|
+
FormField — the label + hint + errors frame around one control.
|
|
6
|
+
|
|
7
|
+
The control is a snippet that receives a `FieldControl` (`id`,
|
|
8
|
+
`describedBy`, `invalid`, `required`) to spread onto the primitive, so the
|
|
9
|
+
label's `for`, the control's `aria-describedby` and `aria-invalid`, and the
|
|
10
|
+
error region's `role="alert"` all stay wired together from a single `id`.
|
|
11
|
+
Neither side has to know the other's markup, which is what lets a caller
|
|
12
|
+
drop any of the field primitives — or their own `<input>` — into the slot.
|
|
13
|
+
|
|
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
|
|
16
|
+
reading order, and is empty when there is neither; `invalid` is true exactly
|
|
17
|
+
when there is at least one error. The required marker is a `*` that is
|
|
18
|
+
`aria-hidden` beside a visually-hidden `requiredLabel`, both inside the
|
|
19
|
+
`<label>`, so the accessible name reads "Scientific name (required)" rather
|
|
20
|
+
than "Scientific name star".
|
|
21
|
+
|
|
22
|
+
`errors` is a LIST. A field that fails several rules says so; collapsing
|
|
23
|
+
them to the first message is a decision the call site should make, not one
|
|
24
|
+
this frame makes for it.
|
|
25
|
+
-->
|
|
26
|
+
<script lang="ts">import FieldErrors from "./FieldErrors.svelte";
|
|
27
|
+
let { id: idProp, label, hint, errors = [], required = false, requiredLabel = "(required)", children, class: className, ...rest } = $props();
|
|
28
|
+
const uid = $props.id();
|
|
29
|
+
const id = $derived(idProp ?? uid);
|
|
30
|
+
const hintId = $derived(`${id}-hint`);
|
|
31
|
+
const errorId = $derived(`${id}-error`);
|
|
32
|
+
const invalid = $derived(errors.length > 0);
|
|
33
|
+
const control = $derived({
|
|
34
|
+
id,
|
|
35
|
+
describedBy: [hint === undefined ? "" : hintId, invalid ? errorId : ""].filter((part) => part !== "").join(" "),
|
|
36
|
+
invalid,
|
|
37
|
+
required
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<div {...rest} class={['fieldset', className]}>
|
|
42
|
+
<label class="label" for={id}>
|
|
43
|
+
<span>{label}</span>
|
|
44
|
+
{#if required}
|
|
45
|
+
<span class="text-error" aria-hidden="true">*</span>
|
|
46
|
+
<span class="sr-only">{requiredLabel}</span>
|
|
47
|
+
{/if}
|
|
48
|
+
</label>
|
|
49
|
+
{@render children(control)}
|
|
50
|
+
{#if hint !== undefined}
|
|
51
|
+
<p class="fieldset-label" id={hintId}>{hint}</p>
|
|
52
|
+
{/if}
|
|
53
|
+
<FieldErrors {errors} id={errorId} />
|
|
54
|
+
</div>
|