zabi-components 8.0.0 → 8.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/CHANGELOG.md +1924 -0
- package/README.md +243 -10
- package/THEME.md +149 -332
- package/THEMING.md +1192 -266
- package/dist/atoms/ActionPanel.svelte +15 -5
- package/dist/atoms/Avatar.svelte +104 -0
- package/dist/atoms/Avatar.svelte.d.ts +34 -0
- package/dist/atoms/Badge.svelte +36 -7
- package/dist/atoms/Badge.svelte.d.ts +2 -2
- package/dist/atoms/Button.svelte +177 -27
- package/dist/atoms/Button.svelte.d.ts +30 -2
- package/dist/atoms/Card.svelte +4 -4
- package/dist/atoms/CardHeader.svelte +16 -4
- package/dist/atoms/Checkbox.svelte +48 -21
- package/dist/atoms/Checkbox.svelte.d.ts +4 -2
- package/dist/atoms/CodeBlock.svelte +20 -2
- package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
- package/dist/atoms/ColorPicker.svelte +259 -49
- package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
- package/dist/atoms/Container.svelte +16 -8
- package/dist/atoms/DateField.svelte +107 -0
- package/dist/atoms/DateField.svelte.d.ts +53 -0
- package/dist/atoms/FieldMessages.svelte +69 -0
- package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
- package/dist/atoms/FloatingActionButton.svelte +174 -0
- package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
- package/dist/atoms/Heading.svelte +37 -5
- package/dist/atoms/Heading.svelte.d.ts +7 -0
- package/dist/atoms/IconButton.svelte +220 -24
- package/dist/atoms/IconButton.svelte.d.ts +49 -4
- package/dist/atoms/Input.svelte +253 -52
- package/dist/atoms/Input.svelte.d.ts +32 -2
- package/dist/atoms/List.svelte +4 -1
- package/dist/atoms/ListItem.svelte +44 -11
- package/dist/atoms/ListItem.svelte.d.ts +5 -0
- package/dist/atoms/Progress.svelte +27 -8
- package/dist/atoms/Progress.svelte.d.ts +4 -0
- package/dist/atoms/Radio.svelte +19 -3
- package/dist/atoms/Radio.svelte.d.ts +4 -2
- package/dist/atoms/Rating.svelte +494 -0
- package/dist/atoms/Rating.svelte.d.ts +56 -0
- package/dist/atoms/Select.svelte +433 -91
- package/dist/atoms/Select.svelte.d.ts +49 -3
- package/dist/atoms/SelectionControl.svelte +94 -17
- package/dist/atoms/SelectionControl.svelte.d.ts +4 -3
- package/dist/atoms/Skeleton.svelte +1 -1
- package/dist/atoms/Slider.svelte +309 -0
- package/dist/atoms/Slider.svelte.d.ts +40 -0
- package/dist/atoms/Spinner.svelte +57 -0
- package/dist/atoms/Spinner.svelte.d.ts +19 -0
- package/dist/atoms/Table.svelte +151 -6
- package/dist/atoms/Table.svelte.d.ts +15 -3
- package/dist/atoms/TemporalField.svelte +140 -0
- package/dist/atoms/TemporalField.svelte.d.ts +32 -0
- package/dist/atoms/Text.svelte +23 -11
- package/dist/atoms/Text.svelte.d.ts +6 -3
- package/dist/atoms/Textarea.svelte +55 -44
- package/dist/atoms/Textarea.svelte.d.ts +14 -2
- package/dist/atoms/ThemeToggle.svelte +223 -44
- package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
- package/dist/atoms/TimeField.svelte +108 -0
- package/dist/atoms/TimeField.svelte.d.ts +54 -0
- package/dist/atoms/Toast.svelte +17 -4
- package/dist/atoms/Toast.svelte.d.ts +2 -0
- package/dist/atoms/Toggle.svelte +41 -8
- package/dist/atoms/Toggle.svelte.d.ts +11 -2
- package/dist/atoms/Tooltip.svelte +508 -23
- package/dist/atoms/Tooltip.svelte.d.ts +11 -0
- package/dist/atoms/ZabiStringsProvider.svelte +45 -0
- package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
- package/dist/atoms/index.d.ts +17 -0
- package/dist/atoms/index.js +12 -0
- package/dist/atoms/selection-control.styles.d.ts +36 -5
- package/dist/atoms/selection-control.styles.js +40 -9
- package/dist/components/atoms/index.d.ts +17 -0
- package/dist/components/atoms/index.d.ts.map +1 -1
- package/dist/components/atoms/selection-control.styles.d.ts +36 -5
- package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
- package/dist/components/index.d.ts +80 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/molecules/index.d.ts +46 -0
- package/dist/components/molecules/index.d.ts.map +1 -1
- package/dist/components/molecules/toast-store.d.ts +53 -7
- package/dist/components/molecules/toast-store.d.ts.map +1 -1
- package/dist/components/organisms/index.d.ts +5 -0
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/components/types/variants.d.ts +15 -1
- package/dist/components/types/variants.d.ts.map +1 -1
- package/dist/components/util/app-shell.d.ts +28 -0
- package/dist/components/util/app-shell.d.ts.map +1 -0
- package/dist/components/util/avatar.d.ts +36 -0
- package/dist/components/util/avatar.d.ts.map +1 -0
- package/dist/components/util/bottom-sheet.d.ts +30 -0
- package/dist/components/util/bottom-sheet.d.ts.map +1 -0
- package/dist/components/util/bottom-tab-bar.d.ts +35 -0
- package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
- package/dist/components/util/calendar.d.ts +56 -0
- package/dist/components/util/calendar.d.ts.map +1 -0
- package/dist/components/util/collapsible.d.ts +58 -0
- package/dist/components/util/collapsible.d.ts.map +1 -0
- package/dist/components/util/confirm-dialog.d.ts +12 -0
- package/dist/components/util/confirm-dialog.d.ts.map +1 -0
- package/dist/components/util/date.d.ts +96 -0
- package/dist/components/util/date.d.ts.map +1 -0
- package/dist/components/util/drawer.d.ts +13 -0
- package/dist/components/util/drawer.d.ts.map +1 -0
- package/dist/components/util/dropdown.d.ts +47 -0
- package/dist/components/util/dropdown.d.ts.map +1 -0
- package/dist/components/util/field.d.ts +39 -0
- package/dist/components/util/field.d.ts.map +1 -0
- package/dist/components/util/fit-in-viewport.d.ts +201 -0
- package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
- package/dist/components/util/focus-utils.d.ts +75 -2
- package/dist/components/util/focus-utils.d.ts.map +1 -1
- package/dist/components/util/hydration.d.ts +45 -0
- package/dist/components/util/hydration.d.ts.map +1 -0
- package/dist/components/util/keyboard-inset.d.ts +18 -0
- package/dist/components/util/keyboard-inset.d.ts.map +1 -0
- package/dist/components/util/media-grid.d.ts +51 -0
- package/dist/components/util/media-grid.d.ts.map +1 -0
- package/dist/components/util/media.d.ts +6 -0
- package/dist/components/util/media.d.ts.map +1 -0
- package/dist/components/util/overlay-chrome.d.ts +53 -0
- package/dist/components/util/overlay-chrome.d.ts.map +1 -0
- package/dist/components/util/overlay.d.ts +72 -0
- package/dist/components/util/overlay.d.ts.map +1 -0
- package/dist/components/util/photo.d.ts +215 -0
- package/dist/components/util/photo.d.ts.map +1 -0
- package/dist/components/util/portal.d.ts +23 -0
- package/dist/components/util/portal.d.ts.map +1 -0
- package/dist/components/util/pull-to-refresh.d.ts +27 -0
- package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
- package/dist/components/util/radio-keys.d.ts +25 -0
- package/dist/components/util/radio-keys.d.ts.map +1 -0
- package/dist/components/util/rating.d.ts +23 -0
- package/dist/components/util/rating.d.ts.map +1 -0
- package/dist/components/util/ready-made-strings.d.ts +77 -0
- package/dist/components/util/ready-made-strings.d.ts.map +1 -0
- package/dist/components/util/scroll-padding.d.ts +20 -0
- package/dist/components/util/scroll-padding.d.ts.map +1 -0
- package/dist/components/util/segmented-control.d.ts +16 -0
- package/dist/components/util/segmented-control.d.ts.map +1 -0
- package/dist/components/util/select.d.ts +41 -0
- package/dist/components/util/select.d.ts.map +1 -0
- package/dist/components/util/sheet-drag.d.ts +60 -0
- package/dist/components/util/sheet-drag.d.ts.map +1 -0
- package/dist/components/util/sidebar.d.ts +63 -0
- package/dist/components/util/sidebar.d.ts.map +1 -0
- package/dist/components/util/sortable-list.d.ts +65 -0
- package/dist/components/util/sortable-list.d.ts.map +1 -0
- package/dist/components/util/stepper.d.ts +40 -0
- package/dist/components/util/stepper.d.ts.map +1 -0
- package/dist/components/util/swipeable-list-item.d.ts +36 -0
- package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
- package/dist/components/util/theme-mode.d.ts +80 -0
- package/dist/components/util/theme-mode.d.ts.map +1 -0
- package/dist/components/util/toaster.d.ts +93 -0
- package/dist/components/util/toaster.d.ts.map +1 -0
- package/dist/components/util/tooltip.d.ts +10 -0
- package/dist/components/util/tooltip.d.ts.map +1 -0
- package/dist/components/util/top-navbar.d.ts +11 -0
- package/dist/components/util/top-navbar.d.ts.map +1 -0
- package/dist/components/util/touch-target.d.ts +31 -0
- package/dist/components/util/touch-target.d.ts.map +1 -0
- package/dist/components/util/zabi-strings.d.ts +133 -0
- package/dist/components/util/zabi-strings.d.ts.map +1 -0
- package/dist/create-theme/cli.js +169 -0
- package/dist/create-theme/index.d.ts +176 -0
- package/dist/create-theme/index.js +817 -0
- package/dist/create-theme/lib/ramp-math.js +128 -0
- package/dist/create-theme/lib/resolve.js +141 -0
- package/dist/create-theme/theme-data.js +1116 -0
- package/dist/index.d.ts +80 -2
- package/dist/index.js +57 -2
- package/dist/molecules/Alert.svelte +33 -5
- package/dist/molecules/Alert.svelte.d.ts +2 -0
- package/dist/molecules/AppBar.svelte +415 -0
- package/dist/molecules/AppBar.svelte.d.ts +69 -0
- package/dist/molecules/AvatarGroup.svelte +116 -0
- package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
- package/dist/molecules/BottomSheet.svelte +540 -0
- package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
- package/dist/molecules/BottomTabBar.svelte +297 -0
- package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
- package/dist/molecules/Calendar.svelte +597 -0
- package/dist/molecules/Calendar.svelte.d.ts +72 -0
- package/dist/molecules/Collapsible.svelte +243 -0
- package/dist/molecules/Collapsible.svelte.d.ts +44 -0
- package/dist/molecules/CollapsibleGroup.svelte +126 -0
- package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
- package/dist/molecules/ComponentDemo.svelte +16 -4
- package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
- package/dist/molecules/ConfirmDialog.svelte +253 -0
- package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
- package/dist/molecules/ContactForm.svelte +34 -17
- package/dist/molecules/ContactForm.svelte.d.ts +8 -0
- package/dist/molecules/Drawer.svelte +394 -0
- package/dist/molecules/Drawer.svelte.d.ts +55 -0
- package/dist/molecules/Dropdown.svelte +509 -48
- package/dist/molecules/Dropdown.svelte.d.ts +45 -6
- package/dist/molecules/DropdownItem.svelte +122 -0
- package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
- package/dist/molecules/EmptyState.svelte +55 -10
- package/dist/molecules/EmptyState.svelte.d.ts +8 -0
- package/dist/molecules/FormField.svelte +9 -1
- package/dist/molecules/FormField.svelte.d.ts +2 -0
- package/dist/molecules/Header.svelte +1 -1
- package/dist/molecules/ImageUpload.svelte +383 -70
- package/dist/molecules/ImageUpload.svelte.d.ts +47 -2
- package/dist/molecules/MediaGrid.svelte +527 -0
- package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
- package/dist/molecules/Modal.svelte +288 -72
- package/dist/molecules/Modal.svelte.d.ts +51 -2
- package/dist/molecules/NavigationMenuContent.svelte +58 -1
- package/dist/molecules/NavigationMenuItem.svelte +0 -1
- package/dist/molecules/NavigationMenuLink.svelte +1 -1
- package/dist/molecules/NavigationMenuList.svelte +10 -3
- package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
- package/dist/molecules/Page.svelte +32 -2
- package/dist/molecules/Page.svelte.d.ts +11 -0
- package/dist/molecules/PhotoGrid.svelte +433 -0
- package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
- package/dist/molecules/PhotoViewer.svelte +1116 -0
- package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
- package/dist/molecules/PropsTable.svelte +22 -10
- package/dist/molecules/PropsTable.svelte.d.ts +3 -0
- package/dist/molecules/PullToRefresh.svelte +263 -0
- package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
- package/dist/molecules/RadioGroup.svelte +52 -14
- package/dist/molecules/RadioGroup.svelte.d.ts +14 -3
- package/dist/molecules/Section.svelte +1 -1
- package/dist/molecules/SegmentedControl.svelte +323 -0
- package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
- package/dist/molecules/SidebarBrandHeader.svelte +11 -1
- package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
- package/dist/molecules/SidebarFooter.svelte +29 -6
- package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
- package/dist/molecules/SlideUp.svelte +220 -61
- package/dist/molecules/SlideUp.svelte.d.ts +26 -0
- package/dist/molecules/SortableList.svelte +632 -0
- package/dist/molecules/SortableList.svelte.d.ts +69 -0
- package/dist/molecules/Stepper.svelte +548 -0
- package/dist/molecules/Stepper.svelte.d.ts +39 -0
- package/dist/molecules/StickyActionBar.svelte +191 -0
- package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
- package/dist/molecules/SwipeableListItem.svelte +334 -0
- package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
- package/dist/molecules/Tabs.svelte +233 -9
- package/dist/molecules/Tabs.svelte.d.ts +7 -0
- package/dist/molecules/Toaster.svelte +350 -5
- package/dist/molecules/Toaster.svelte.d.ts +95 -2
- package/dist/molecules/ToasterToast.svelte +253 -67
- package/dist/molecules/ToasterToast.svelte.d.ts +9 -0
- package/dist/molecules/UnsavedChangesBar.svelte +212 -0
- package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
- package/dist/molecules/index.d.ts +46 -0
- package/dist/molecules/index.js +27 -0
- package/dist/molecules/toast-store.d.ts +53 -7
- package/dist/molecules/toast-store.js +21 -0
- package/dist/organisms/AppShell.svelte +196 -0
- package/dist/organisms/AppShell.svelte.d.ts +61 -0
- package/dist/organisms/SidebarAccountPanel.svelte +107 -12
- package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
- package/dist/organisms/SidebarNavigation.svelte +96 -15
- package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
- package/dist/organisms/SidebarPanel.svelte +31 -10
- package/dist/organisms/SidebarShell.svelte +162 -11
- package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
- package/dist/organisms/TopNavbar.svelte +256 -31
- package/dist/organisms/TopNavbar.svelte.d.ts +26 -0
- package/dist/organisms/index.d.ts +5 -0
- package/dist/organisms/index.js +3 -0
- package/dist/types/components.d.ts +4 -0
- package/dist/types/components.d.ts.map +1 -1
- package/dist/types/index.d.ts +605 -25
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/variants.ts +17 -1
- package/dist/util/app-shell.d.ts +27 -0
- package/dist/util/app-shell.js +72 -0
- package/dist/util/avatar.d.ts +35 -0
- package/dist/util/avatar.js +52 -0
- package/dist/util/bottom-sheet.d.ts +29 -0
- package/dist/util/bottom-sheet.js +43 -0
- package/dist/util/bottom-tab-bar.d.ts +34 -0
- package/dist/util/bottom-tab-bar.js +54 -0
- package/dist/util/calendar.d.ts +55 -0
- package/dist/util/calendar.js +74 -0
- package/dist/util/collapsible.d.ts +57 -0
- package/dist/util/collapsible.js +23 -0
- package/dist/util/confirm-dialog.d.ts +11 -0
- package/dist/util/confirm-dialog.js +2 -0
- package/dist/util/date.d.ts +95 -0
- package/dist/util/date.js +237 -0
- package/dist/util/drawer.d.ts +12 -0
- package/dist/util/drawer.js +7 -0
- package/dist/util/dropdown.d.ts +46 -0
- package/dist/util/dropdown.js +21 -0
- package/dist/util/field.d.ts +38 -0
- package/dist/util/field.js +38 -0
- package/dist/util/fit-in-viewport.d.ts +200 -0
- package/dist/util/fit-in-viewport.js +399 -0
- package/dist/util/focus-utils.d.ts +75 -2
- package/dist/util/focus-utils.js +182 -9
- package/dist/util/hydration.d.ts +44 -0
- package/dist/util/hydration.js +58 -0
- package/dist/util/keyboard-inset.d.ts +17 -0
- package/dist/util/keyboard-inset.js +53 -0
- package/dist/util/media-grid.d.ts +50 -0
- package/dist/util/media-grid.js +81 -0
- package/dist/util/media.d.ts +5 -0
- package/dist/util/media.js +10 -0
- package/dist/util/overlay-chrome.d.ts +52 -0
- package/dist/util/overlay-chrome.js +56 -0
- package/dist/util/overlay.d.ts +71 -0
- package/dist/util/overlay.js +240 -0
- package/dist/util/photo.d.ts +214 -0
- package/dist/util/photo.js +206 -0
- package/dist/util/portal.d.ts +22 -0
- package/dist/util/portal.js +50 -0
- package/dist/util/pull-to-refresh.d.ts +26 -0
- package/dist/util/pull-to-refresh.js +30 -0
- package/dist/util/radio-keys.d.ts +24 -0
- package/dist/util/radio-keys.js +49 -0
- package/dist/util/rating.d.ts +22 -0
- package/dist/util/rating.js +26 -0
- package/dist/util/ready-made-strings.d.ts +76 -0
- package/dist/util/ready-made-strings.js +67 -0
- package/dist/util/scroll-padding.d.ts +19 -0
- package/dist/util/scroll-padding.js +44 -0
- package/dist/util/segmented-control.d.ts +15 -0
- package/dist/util/segmented-control.js +1 -0
- package/dist/util/select.d.ts +40 -0
- package/dist/util/select.js +63 -0
- package/dist/util/sheet-drag.d.ts +59 -0
- package/dist/util/sheet-drag.js +241 -0
- package/dist/util/sidebar.d.ts +62 -0
- package/dist/util/sidebar.js +38 -0
- package/dist/util/sortable-list.d.ts +64 -0
- package/dist/util/sortable-list.js +53 -0
- package/dist/util/stepper.d.ts +39 -0
- package/dist/util/stepper.js +38 -0
- package/dist/util/swipeable-list-item.d.ts +35 -0
- package/dist/util/swipeable-list-item.js +32 -0
- package/dist/util/theme-mode.d.ts +79 -0
- package/dist/util/theme-mode.js +121 -0
- package/dist/util/toaster.d.ts +92 -0
- package/dist/util/toaster.js +75 -0
- package/dist/util/tooltip.d.ts +9 -0
- package/dist/util/tooltip.js +18 -0
- package/dist/util/top-navbar.d.ts +10 -0
- package/dist/util/top-navbar.js +5 -0
- package/dist/util/touch-target.d.ts +30 -0
- package/dist/util/touch-target.js +30 -0
- package/dist/util/zabi-strings.d.ts +132 -0
- package/dist/util/zabi-strings.js +105 -0
- package/dist/zabi-components-colors.css +5 -2
- package/dist/zabi-components-theme-dark-only.css +543 -241
- package/dist/zabi-components-theme-dark.css +543 -241
- package/dist/zabi-components-theme-only.css +1584 -367
- package/dist/zabi-components-theme.css +1584 -367
- package/dist/zabi-components.css +3885 -518
- package/docs/lucide-icons.md +40 -17
- package/docs/theme-imports.md +81 -11
- package/package.json +35 -8
package/dist/atoms/Select.svelte
CHANGED
|
@@ -1,18 +1,54 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { mergeStrings } from "../util/ready-made-strings.js";
|
|
3
|
+
import { zabiCommonStrings, zabiStringsFor } from "../util/zabi-strings.js";
|
|
2
4
|
import Dropdown from "../molecules/Dropdown.svelte";
|
|
3
5
|
import Input from "./Input.svelte";
|
|
6
|
+
import Check from "@lucide/svelte/icons/check";
|
|
7
|
+
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
|
8
|
+
import { onMount, tick, untrack } from "svelte";
|
|
9
|
+
import type { HTMLButtonAttributes } from "svelte/elements";
|
|
4
10
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
DEFAULT_SELECT_STRINGS,
|
|
12
|
+
NATIVE_SELECT_ATTRIBUTE,
|
|
13
|
+
choiceBeforeMount,
|
|
14
|
+
type SelectPresentation,
|
|
15
|
+
type SelectStrings,
|
|
16
|
+
} from "../util/select.js";
|
|
10
17
|
import { generateId } from "../util/ssr-safe.js";
|
|
18
|
+
import { isInsideToastRegion } from "../util/focus-utils.js";
|
|
11
19
|
import { cn } from "../util/cn.js";
|
|
20
|
+
import { fieldDescribedBy, fieldMessageState } from "../util/field.js";
|
|
21
|
+
import FieldMessages from "./FieldMessages.svelte";
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Other attributes (`data-*`, `aria-*`, `onfocus`, ...) land on the
|
|
25
|
+
* trigger, which is a `<button>`: it is the control that takes focus and
|
|
26
|
+
* that the label names.
|
|
27
|
+
*
|
|
28
|
+
* The server's HTML also carries a native `<select>` with the same
|
|
29
|
+
* options. Until the component has mounted, and for good where there are
|
|
30
|
+
* no scripts, that is the control: it is what is seen, pressed and
|
|
31
|
+
* submitted. Once mounted the trigger and the list take over, and the
|
|
32
|
+
* native select stays, out of sight and out of reach, as the form control
|
|
33
|
+
* (`name`, `required`), kept at the same value. With
|
|
34
|
+
* `presentation="native"` it is the control throughout.
|
|
35
|
+
*/
|
|
36
|
+
type Props = Omit<
|
|
37
|
+
HTMLButtonAttributes,
|
|
38
|
+
| "class"
|
|
39
|
+
| "value"
|
|
40
|
+
| "name"
|
|
41
|
+
| "disabled"
|
|
42
|
+
| "type"
|
|
43
|
+
| "onchange"
|
|
44
|
+
| "onclick"
|
|
45
|
+
| "aria-describedby"
|
|
46
|
+
| "children"
|
|
47
|
+
> & {
|
|
14
48
|
/** Extra classes for the host element. */
|
|
15
49
|
class?: string;
|
|
50
|
+
/** Id of the trigger. Omit to auto-generate. */
|
|
51
|
+
id?: string;
|
|
16
52
|
value?: string | number | undefined;
|
|
17
53
|
options?: Array<{
|
|
18
54
|
value: string | number;
|
|
@@ -20,9 +56,26 @@
|
|
|
20
56
|
disabled?: boolean;
|
|
21
57
|
}>;
|
|
22
58
|
searchable?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The component's own texts, for another language. Whatever is left
|
|
61
|
+
* out keeps its English default. The six older props for single texts
|
|
62
|
+
* (`placeholder`, `searchPlaceholder`, `noResultsText`,
|
|
63
|
+
* `loadingText`, `emptyStateTitle`, `emptyStateDescription`) win
|
|
64
|
+
* over it.
|
|
65
|
+
*/
|
|
66
|
+
strings?: Partial<SelectStrings>;
|
|
23
67
|
searchPlaceholder?: string;
|
|
24
68
|
maxMenuHeight?: string;
|
|
25
69
|
menuWidth?: string;
|
|
70
|
+
/**
|
|
71
|
+
* How the list is shown. `auto`: in a BottomSheet on a phone (a touch
|
|
72
|
+
* screen narrower than 640px), under the trigger everywhere else.
|
|
73
|
+
* `popover` and `sheet` are always the one or the other. `native`:
|
|
74
|
+
* only the browser's own `<select>`, styled as the field, with the
|
|
75
|
+
* platform's picker. It shows the options' labels and nothing else:
|
|
76
|
+
* no search field, and none of the loading or empty states.
|
|
77
|
+
*/
|
|
78
|
+
presentation?: SelectPresentation;
|
|
26
79
|
noResultsText?: string;
|
|
27
80
|
isLoading?: boolean;
|
|
28
81
|
loadingText?: string;
|
|
@@ -31,32 +84,47 @@
|
|
|
31
84
|
emptyStateActionLabel?: string;
|
|
32
85
|
placeholder?: string;
|
|
33
86
|
label?: string;
|
|
34
|
-
/**
|
|
87
|
+
/** The name the chosen value is submitted under, by the native `<select>`. */
|
|
35
88
|
name?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The form is not sent while nothing is chosen. Checked by the
|
|
91
|
+
* browser on the native `<select>`; once mounted, the browser's own
|
|
92
|
+
* message is shown under the field and focus goes to the trigger.
|
|
93
|
+
*/
|
|
36
94
|
required?: boolean;
|
|
37
95
|
disabled?: boolean;
|
|
38
96
|
size?: "sm" | "md" | "lg";
|
|
39
97
|
variant?: "default" | "success" | "warning" | "error";
|
|
98
|
+
/** Status text under the field. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
|
|
40
99
|
message?: string;
|
|
100
|
+
/** Help text under the field, read out with it. */
|
|
101
|
+
hint?: string;
|
|
102
|
+
/** An error under the field. It marks the field invalid and is announced; it wins over `variant` and `message`. */
|
|
103
|
+
error?: string;
|
|
41
104
|
onchange?: (event: Event) => void;
|
|
42
105
|
onEmptyStateAction?: () => void;
|
|
43
|
-
|
|
106
|
+
/** Ids of other elements that describe the field. The hint and the message are added after them. */
|
|
107
|
+
"aria-describedby"?: string | null;
|
|
108
|
+
};
|
|
44
109
|
|
|
45
110
|
let {
|
|
46
111
|
class: className = "",
|
|
112
|
+
id: idProp,
|
|
47
113
|
value = $bindable(undefined),
|
|
48
114
|
options = [],
|
|
49
115
|
searchable = true,
|
|
50
|
-
|
|
51
|
-
|
|
116
|
+
strings,
|
|
117
|
+
searchPlaceholder,
|
|
118
|
+
maxMenuHeight = "60dvh",
|
|
52
119
|
menuWidth = "100%",
|
|
53
|
-
|
|
120
|
+
presentation = "auto",
|
|
121
|
+
noResultsText,
|
|
54
122
|
isLoading = false,
|
|
55
|
-
loadingText
|
|
56
|
-
emptyStateTitle
|
|
57
|
-
emptyStateDescription
|
|
123
|
+
loadingText,
|
|
124
|
+
emptyStateTitle,
|
|
125
|
+
emptyStateDescription,
|
|
58
126
|
emptyStateActionLabel = "",
|
|
59
|
-
placeholder
|
|
127
|
+
placeholder,
|
|
60
128
|
label = "",
|
|
61
129
|
name = "",
|
|
62
130
|
required = false,
|
|
@@ -64,63 +132,120 @@
|
|
|
64
132
|
size = "md",
|
|
65
133
|
variant = "default",
|
|
66
134
|
message = "",
|
|
135
|
+
hint = "",
|
|
136
|
+
error = "",
|
|
67
137
|
onchange,
|
|
68
138
|
onEmptyStateAction,
|
|
139
|
+
"aria-describedby": describedBy,
|
|
69
140
|
...restProps
|
|
70
141
|
}: Props = $props();
|
|
71
142
|
|
|
143
|
+
/** The defaults, then a provider's words, then `strings`, then the older props where they are set. */
|
|
144
|
+
const provided = zabiStringsFor("select");
|
|
145
|
+
const common = zabiCommonStrings();
|
|
146
|
+
const text = $derived<SelectStrings>({
|
|
147
|
+
...mergeStrings(
|
|
148
|
+
{
|
|
149
|
+
...DEFAULT_SELECT_STRINGS,
|
|
150
|
+
// The sheet's three buttons say what every sheet's do.
|
|
151
|
+
closeLabel: common().close,
|
|
152
|
+
expandLabel: common().expand,
|
|
153
|
+
collapseLabel: common().collapse,
|
|
154
|
+
},
|
|
155
|
+
provided(),
|
|
156
|
+
strings,
|
|
157
|
+
),
|
|
158
|
+
...(placeholder !== undefined && { placeholder }),
|
|
159
|
+
...(searchPlaceholder !== undefined && { searchPlaceholder }),
|
|
160
|
+
...(noResultsText !== undefined && { noResults: noResultsText }),
|
|
161
|
+
...(loadingText !== undefined && { loading: loadingText }),
|
|
162
|
+
...(emptyStateTitle !== undefined && { emptyTitle: emptyStateTitle }),
|
|
163
|
+
...(emptyStateDescription !== undefined && { emptyDescription: emptyStateDescription }),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const isNative = $derived(presentation === "native");
|
|
167
|
+
/**
|
|
168
|
+
* False on the server and until the component has mounted: the native
|
|
169
|
+
* select is the control. True after: the trigger is. The two have the
|
|
170
|
+
* same box, so nothing moves when one replaces the other.
|
|
171
|
+
*/
|
|
172
|
+
let mounted = $state(false);
|
|
173
|
+
/** Whether the native select is what is seen and pressed. */
|
|
174
|
+
const nativeShown = $derived(isNative || !mounted);
|
|
175
|
+
|
|
176
|
+
let nativeElement = $state<HTMLSelectElement | null>(null);
|
|
177
|
+
/** What the browser said was wrong with the hidden native select (`required`), in its own words. */
|
|
178
|
+
let nativeError = $state("");
|
|
179
|
+
|
|
180
|
+
const status = $derived(
|
|
181
|
+
fieldMessageState({ variant, message, hint, error: error || nativeError }),
|
|
182
|
+
);
|
|
183
|
+
|
|
72
184
|
let isOpen = $state(false);
|
|
73
185
|
let selectContainer: HTMLDivElement;
|
|
74
186
|
let searchQuery = $state("");
|
|
75
187
|
|
|
76
|
-
// Same fixed height scale as Button, IconButton and Input (32 / 40 / 48)
|
|
188
|
+
// Same fixed height scale as Button, IconButton and Input (32 / 40 / 48),
|
|
189
|
+
// and the same 16px text below `sm`, so a Select beside an Input matches it.
|
|
77
190
|
const sizeClass = $derived(() => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
191
|
+
// A minimum height, so a label that wraps makes the trigger taller
|
|
192
|
+
// instead of being cut. One line is exactly 32, 40 or 48px: the
|
|
193
|
+
// padding leaves room for the line and the border inside it, and is
|
|
194
|
+
// a step smaller below `sm`, where the text is 16px on a 24px line. `native` is the same box for the native
|
|
195
|
+
// select, which is always one line.
|
|
196
|
+
if (size === "sm") return { box: "min-h-8 px-3 py-1 max-sm:py-0 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-8 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
|
|
197
|
+
if (size === "lg") return { box: "min-h-12 px-4 py-2", text: "text-base", native: "h-12 ps-4 pe-10", chevron: "end-4" };
|
|
198
|
+
return { box: "min-h-10 px-3 py-2 max-sm:py-1 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-10 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
|
|
81
199
|
});
|
|
82
200
|
|
|
83
201
|
const variantClass = $derived(() => {
|
|
84
|
-
return variant === "success"
|
|
202
|
+
return status.variant === "success"
|
|
85
203
|
? "border-success focus-visible:border-success"
|
|
86
|
-
: variant === "warning"
|
|
204
|
+
: status.variant === "warning"
|
|
87
205
|
? "border-warning focus-visible:border-warning"
|
|
88
|
-
: variant === "error"
|
|
206
|
+
: status.variant === "error"
|
|
89
207
|
? "border-error focus-visible:border-error"
|
|
90
|
-
: "border-input-border";
|
|
208
|
+
: "border-input-border enabled:hover:border-input-border-hover";
|
|
91
209
|
});
|
|
92
210
|
|
|
211
|
+
/** What the trigger and the visible native select share: the field's surface, edge and states. */
|
|
212
|
+
const fieldSurface =
|
|
213
|
+
"focus-ring w-full min-w-0 cursor-pointer rounded-control border bg-input text-body transition-colors duration-150 hover:bg-input-hover active:bg-input-active focus-visible:bg-input-focus focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-input-disabled disabled:text-action-disabled-text";
|
|
214
|
+
|
|
93
215
|
const triggerClasses = $derived(() => {
|
|
94
216
|
const sizeStyles = sizeClass();
|
|
95
|
-
const baseClasses =
|
|
96
|
-
"focus-ring flex w-full cursor-pointer items-center justify-between gap-2 rounded-control border bg-input text-body transition-colors duration-150 hover:bg-input-hover active:bg-input-focus focus-visible:bg-input-focus focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-input-disabled disabled:text-action-disabled-text";
|
|
217
|
+
const baseClasses = `${fieldSurface} flex items-center justify-between gap-2`;
|
|
97
218
|
|
|
98
|
-
return cn(
|
|
219
|
+
return cn(
|
|
220
|
+
`${baseClasses} ${sizeStyles.box} ${sizeStyles.text} ${variantClass()}`,
|
|
221
|
+
// Until it has mounted the native select stands in its place.
|
|
222
|
+
!mounted && "hidden",
|
|
223
|
+
);
|
|
99
224
|
});
|
|
100
225
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return "text-warning text-sm mt-1 flex items-center gap-2 w-full";
|
|
226
|
+
const nativeClasses = $derived.by(() => {
|
|
227
|
+
const sizeStyles = sizeClass();
|
|
228
|
+
if (!nativeShown) {
|
|
229
|
+
// Mounted, with a list of our own: still the form control, laid
|
|
230
|
+
// over the trigger so the browser's validation has a place to
|
|
231
|
+
// point at, but not seen, pressed or reached. `invisible`
|
|
232
|
+
// (`visibility: hidden`) is what takes it out of the tab order
|
|
233
|
+
// and the accessibility tree for certain; a form still sends and
|
|
234
|
+
// validates it.
|
|
235
|
+
return "pointer-events-none invisible absolute inset-0 h-full w-full";
|
|
112
236
|
}
|
|
113
|
-
return
|
|
237
|
+
return cn(
|
|
238
|
+
`${fieldSurface} block appearance-none truncate ${sizeStyles.native} ${sizeStyles.text} ${variantClass()}`,
|
|
239
|
+
!disabled && isEmpty() && "text-input-placeholder",
|
|
240
|
+
);
|
|
114
241
|
});
|
|
115
242
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (variant === "warning") return AlertTriangle;
|
|
120
|
-
return null;
|
|
121
|
-
});
|
|
243
|
+
const labelClasses = $derived(
|
|
244
|
+
() => "block text-sm font-medium text-label mb-2",
|
|
245
|
+
);
|
|
122
246
|
|
|
123
|
-
const
|
|
247
|
+
const fallbackId = generateId("select");
|
|
248
|
+
const selectId = $derived(idProp ?? fallbackId);
|
|
124
249
|
|
|
125
250
|
const isEmpty = $derived(() => {
|
|
126
251
|
return value === undefined || value === null || value === "";
|
|
@@ -164,8 +289,14 @@
|
|
|
164
289
|
|
|
165
290
|
function handleOptionClick(optionValue: string | number) {
|
|
166
291
|
if (disabled || isLoading) return;
|
|
167
|
-
value
|
|
292
|
+
if (options.find((option) => option.value === optionValue)?.disabled) return;
|
|
168
293
|
isOpen = false;
|
|
294
|
+
choose(optionValue);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Sets the value and tells the app, as a change of a form control does. */
|
|
298
|
+
function choose(optionValue: string | number | undefined) {
|
|
299
|
+
value = optionValue;
|
|
169
300
|
|
|
170
301
|
if (onchange) {
|
|
171
302
|
const syntheticEvent = new Event("change", { bubbles: true });
|
|
@@ -177,6 +308,124 @@
|
|
|
177
308
|
}
|
|
178
309
|
}
|
|
179
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The native select's value as one of the options' own: a number stays a
|
|
313
|
+
* number. The empty first option is "nothing chosen".
|
|
314
|
+
*/
|
|
315
|
+
function fromNative(raw: string): string | number | undefined {
|
|
316
|
+
if (raw === "") return undefined;
|
|
317
|
+
return options.find((option) => String(option.value) === raw)?.value ?? raw;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The native select was changed: by the visitor, in `native`, or by the browser restoring a form. */
|
|
321
|
+
function handleNativeChange(event: Event) {
|
|
322
|
+
const raw = (event.currentTarget as HTMLSelectElement).value;
|
|
323
|
+
if (raw === (isEmpty() ? "" : String(value))) return;
|
|
324
|
+
choose(fromNative(raw));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The browser found the hidden native select invalid (`required`, and
|
|
329
|
+
* nothing chosen). Its own bubble would point at a control nobody can
|
|
330
|
+
* see, and focus would go into it: the message is shown under the field
|
|
331
|
+
* instead, in the browser's words, and focus goes to the trigger.
|
|
332
|
+
*/
|
|
333
|
+
function handleNativeInvalid(event: Event) {
|
|
334
|
+
if (nativeShown) return;
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
const element = event.currentTarget as HTMLSelectElement;
|
|
337
|
+
nativeError = element.validationMessage;
|
|
338
|
+
// The browser tells every invalid control, and would focus the first.
|
|
339
|
+
// So does this: each Select shows its message, and only the first
|
|
340
|
+
// invalid control of the form takes focus.
|
|
341
|
+
const controls = [...(element.form?.elements ?? [])] as (Element & Partial<HTMLSelectElement>)[];
|
|
342
|
+
const first = controls.find((control) => control.willValidate && control.validity?.valid === false);
|
|
343
|
+
if (!first || first === element) document.getElementById(selectId)?.focus();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
$effect(() => {
|
|
347
|
+
// A choice answers it.
|
|
348
|
+
void value;
|
|
349
|
+
nativeError = "";
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The trigger takes over from the native select. If the keyboard was in
|
|
354
|
+
* the native select, which now leaves the tab order, focus goes to the
|
|
355
|
+
* trigger and does not fall to the page.
|
|
356
|
+
*/
|
|
357
|
+
function handOver() {
|
|
358
|
+
const hadFocus = !isNative && nativeElement !== null && document.activeElement === nativeElement;
|
|
359
|
+
mounted = true;
|
|
360
|
+
if (hadFocus) void tick().then(() => document.getElementById(selectId)?.focus());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The value the Select was rendered with: what a form reset goes back to. */
|
|
364
|
+
const initialValue = untrack(() => value);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The form around the Select was reset. The native select goes back to
|
|
368
|
+
* what its markup says by itself, which the component would not know of:
|
|
369
|
+
* the value is put back to the one it was rendered with, and that is
|
|
370
|
+
* reported once if it is a change. After the browser has done its own
|
|
371
|
+
* resetting, which is after the event.
|
|
372
|
+
*/
|
|
373
|
+
function handleFormReset(event: Event) {
|
|
374
|
+
setTimeout(() => {
|
|
375
|
+
if (event.defaultPrevented) return;
|
|
376
|
+
const initiallyEmpty = initialValue === undefined || initialValue === null || initialValue === "";
|
|
377
|
+
const unchanged = initiallyEmpty ? isEmpty() : isSameValue(value, initialValue);
|
|
378
|
+
if (!unchanged) choose(initiallyEmpty ? undefined : initialValue);
|
|
379
|
+
void tick().then(() => {
|
|
380
|
+
if (nativeElement) nativeElement.value = initiallyEmpty ? "" : String(initialValue);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether the browser's own list of the native select is open, where the browser can say (`:open`). */
|
|
386
|
+
function nativePickerOpen(element: HTMLSelectElement): boolean {
|
|
387
|
+
try {
|
|
388
|
+
return element.matches(":open");
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
onMount(() => {
|
|
395
|
+
// A choice made in the native select before the scripts arrived is
|
|
396
|
+
// the visitor's: it becomes the value, and is reported once. Only a
|
|
397
|
+
// choice: a select nobody touched keeps the value it was given.
|
|
398
|
+
const before = nativeElement ? choiceBeforeMount(nativeElement) : undefined;
|
|
399
|
+
if (before !== undefined && before !== (isEmpty() ? "" : String(value))) {
|
|
400
|
+
choose(fromNative(before));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const element = nativeElement;
|
|
404
|
+
const form = element?.form ?? null;
|
|
405
|
+
form?.addEventListener("reset", handleFormReset);
|
|
406
|
+
const stopListening = () => form?.removeEventListener("reset", handleFormReset);
|
|
407
|
+
|
|
408
|
+
// Someone is choosing in the browser's own list right now: taking the
|
|
409
|
+
// select away would close it under their finger. They keep it until
|
|
410
|
+
// they have chosen or left, and the trigger takes over then.
|
|
411
|
+
if (!isNative && element && document.activeElement === element && nativePickerOpen(element)) {
|
|
412
|
+
const finish = () => {
|
|
413
|
+
element.removeEventListener("change", finish);
|
|
414
|
+
element.removeEventListener("blur", finish);
|
|
415
|
+
handOver();
|
|
416
|
+
};
|
|
417
|
+
element.addEventListener("change", finish);
|
|
418
|
+
element.addEventListener("blur", finish);
|
|
419
|
+
return () => {
|
|
420
|
+
element.removeEventListener("change", finish);
|
|
421
|
+
element.removeEventListener("blur", finish);
|
|
422
|
+
stopListening();
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
handOver();
|
|
426
|
+
return stopListening;
|
|
427
|
+
});
|
|
428
|
+
|
|
180
429
|
function handleTriggerClick(event: MouseEvent) {
|
|
181
430
|
if (disabled || isLoading) return;
|
|
182
431
|
event.stopPropagation();
|
|
@@ -187,6 +436,10 @@
|
|
|
187
436
|
if (!isOpen) return;
|
|
188
437
|
|
|
189
438
|
const target = event.target as HTMLElement;
|
|
439
|
+
if (isInsideToastRegion(target)) return;
|
|
440
|
+
// The list in a sheet is in `<body>`, not in this container: a press
|
|
441
|
+
// in that sheet (an option, the grip, the search field) is not outside.
|
|
442
|
+
if (target.closest('[role="dialog"]')?.querySelector("[data-dropdown-sheet]")) return;
|
|
190
443
|
const clickedSelectContainer = target.closest(".select-container");
|
|
191
444
|
|
|
192
445
|
if (
|
|
@@ -206,47 +459,134 @@
|
|
|
206
459
|
|
|
207
460
|
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
|
208
461
|
|
|
209
|
-
<div bind:this={selectContainer} class={cn("w-full select-container", className)}>
|
|
462
|
+
<div bind:this={selectContainer} class={cn("w-full min-w-0 select-container", className)}>
|
|
210
463
|
{#if label}
|
|
211
464
|
<label for={selectId} class={labelClasses()}>{label}</label>
|
|
212
465
|
{/if}
|
|
213
466
|
|
|
467
|
+
<div class="relative w-full min-w-0">
|
|
468
|
+
<!-- The native select. While it is the control it has the field's id, so
|
|
469
|
+
the label and the messages are its; once the trigger has taken over it is
|
|
470
|
+
hidden from assistive technology and out of the tab order, and only the
|
|
471
|
+
form talks to it. A disabled Select has always sent its value, so the
|
|
472
|
+
hidden one is not disabled; what is not sent is its `required`. -->
|
|
473
|
+
<select
|
|
474
|
+
bind:this={nativeElement}
|
|
475
|
+
id={nativeShown ? selectId : `${selectId}-native`}
|
|
476
|
+
name={name || undefined}
|
|
477
|
+
class={nativeClasses}
|
|
478
|
+
required={required && (nativeShown || !(disabled || isLoading))}
|
|
479
|
+
disabled={nativeShown && (disabled || isLoading)}
|
|
480
|
+
tabindex={nativeShown ? undefined : -1}
|
|
481
|
+
aria-hidden={nativeShown ? undefined : "true"}
|
|
482
|
+
aria-invalid={nativeShown && status.variant === "error" ? "true" : undefined}
|
|
483
|
+
aria-describedby={nativeShown ? fieldDescribedBy(selectId, status, describedBy) : undefined}
|
|
484
|
+
onchange={handleNativeChange}
|
|
485
|
+
oninvalid={handleNativeInvalid}
|
|
486
|
+
aria-label={nativeShown ? restProps["aria-label"] : undefined}
|
|
487
|
+
aria-labelledby={nativeShown ? restProps["aria-labelledby"] : undefined}
|
|
488
|
+
{...{ [NATIVE_SELECT_ATTRIBUTE]: "" }}
|
|
489
|
+
{...isNative ? (restProps as Record<string, unknown>) : {}}
|
|
490
|
+
>
|
|
491
|
+
{#if isEmpty()}
|
|
492
|
+
<option value="" selected>{isLoading ? text.loading : text.placeholder}</option>
|
|
493
|
+
{:else if !selectedOption}
|
|
494
|
+
<!-- The value has no option of its own: its options are still on
|
|
495
|
+
their way, or it is not among them. It is still the value, so the
|
|
496
|
+
select holds it and a form sends it; it is not offered in the list
|
|
497
|
+
where the browser lets an option be hidden. -->
|
|
498
|
+
<option value={String(value)} selected hidden>{isLoading ? text.loading : String(value)}</option>
|
|
499
|
+
{/if}
|
|
500
|
+
{#each options as option (option.value)}
|
|
501
|
+
<option
|
|
502
|
+
value={String(option.value)}
|
|
503
|
+
disabled={option.disabled}
|
|
504
|
+
selected={isSameValue(value, option.value)}
|
|
505
|
+
>
|
|
506
|
+
{option.label}
|
|
507
|
+
</option>
|
|
508
|
+
{/each}
|
|
509
|
+
</select>
|
|
510
|
+
{#if nativeShown}
|
|
511
|
+
<!-- `appearance-none` takes the browser's arrow away with its chrome. -->
|
|
512
|
+
<span
|
|
513
|
+
class={cn(
|
|
514
|
+
"pointer-events-none absolute top-1/2 me-px flex -translate-y-1/2 text-description",
|
|
515
|
+
sizeClass().chevron,
|
|
516
|
+
)}
|
|
517
|
+
aria-hidden="true"
|
|
518
|
+
>
|
|
519
|
+
<ChevronDown size={20} />
|
|
520
|
+
</span>
|
|
521
|
+
{/if}
|
|
522
|
+
|
|
523
|
+
{#if !isNative}
|
|
214
524
|
<Dropdown
|
|
215
525
|
bind:isOpen={isOpen}
|
|
216
526
|
placement="bottom-start"
|
|
217
527
|
selectedValue={value}
|
|
218
528
|
onOptionClick={handleOptionClick}
|
|
219
|
-
ariaLabel="
|
|
529
|
+
ariaLabel={strings?.listLabel || label || (restProps["aria-label"] as string | undefined) || text.listLabel}
|
|
530
|
+
ariaLabelledby={strings?.listLabel || label ? undefined : (restProps["aria-labelledby"] as string | undefined)}
|
|
220
531
|
menuRole="listbox"
|
|
532
|
+
fullWidth
|
|
533
|
+
presentation={presentation === "native" ? "popover" : presentation}
|
|
534
|
+
sheetTitle={label || text.placeholder}
|
|
535
|
+
sheetCloseLabel={text.closeLabel}
|
|
536
|
+
sheetExpandLabel={text.expandLabel}
|
|
537
|
+
sheetCollapseLabel={text.collapseLabel}
|
|
538
|
+
sheetSnap={searchable && options.length > 6 ? "full" : "half"}
|
|
221
539
|
>
|
|
222
540
|
{#snippet trigger(aria)}
|
|
541
|
+
<!-- A select-only combobox, as the ARIA practices describe it:
|
|
542
|
+
a button could not say that it is invalid or required
|
|
543
|
+
(`aria-invalid` and `aria-required` are not a button's), so a
|
|
544
|
+
Select with an error was announced like one without. The keys,
|
|
545
|
+
`aria-haspopup="listbox"`, `aria-expanded` and `aria-controls` are
|
|
546
|
+
as they were. -->
|
|
223
547
|
<button
|
|
224
548
|
type="button"
|
|
225
|
-
id={selectId}
|
|
549
|
+
id={mounted ? selectId : `${selectId}-trigger`}
|
|
226
550
|
class={triggerClasses()}
|
|
227
551
|
{disabled}
|
|
228
552
|
onclick={handleTriggerClick}
|
|
553
|
+
{...restProps}
|
|
229
554
|
{...aria}
|
|
230
|
-
|
|
555
|
+
role="combobox"
|
|
556
|
+
aria-invalid={status.variant === "error" ? "true" : undefined}
|
|
557
|
+
aria-required={required ? "true" : undefined}
|
|
558
|
+
aria-busy={isLoading ? "true" : undefined}
|
|
559
|
+
aria-describedby={fieldDescribedBy(selectId, status, describedBy)}
|
|
231
560
|
>
|
|
561
|
+
<!-- An empty trigger shows its placeholder in the placeholder
|
|
562
|
+
colour, as Input does. A value has no colour class of its own,
|
|
563
|
+
and a disabled trigger none at all: the button's `text-body`
|
|
564
|
+
and its disabled colour are inherited. A colour class here
|
|
565
|
+
beat the button's `disabled:` one, and a disabled Select read
|
|
566
|
+
as enabled. -->
|
|
567
|
+
<!-- The label wraps: cut off, there was nowhere on the closed
|
|
568
|
+
control to read the rest. The chevron stays in the middle of
|
|
569
|
+
the box, however many lines there are. -->
|
|
232
570
|
<span
|
|
233
|
-
class="
|
|
234
|
-
? '
|
|
235
|
-
:
|
|
236
|
-
|
|
237
|
-
|
|
571
|
+
class="min-w-0 flex-1 text-start [overflow-wrap:anywhere] {disabled
|
|
572
|
+
? ''
|
|
573
|
+
: isLoading
|
|
574
|
+
? 'text-description'
|
|
575
|
+
: isEmpty()
|
|
576
|
+
? 'text-input-placeholder'
|
|
577
|
+
: ''}"
|
|
238
578
|
>
|
|
239
579
|
{#if isLoading}
|
|
240
|
-
{
|
|
580
|
+
{text.loading}
|
|
241
581
|
{:else}
|
|
242
582
|
{isEmpty()
|
|
243
|
-
? placeholder
|
|
244
|
-
: selectedOption?.label || placeholder}
|
|
583
|
+
? text.placeholder
|
|
584
|
+
: selectedOption?.label || text.placeholder}
|
|
245
585
|
{/if}
|
|
246
586
|
</span>
|
|
247
587
|
<ChevronDown
|
|
248
588
|
size={20}
|
|
249
|
-
class="text-description transition-transform duration-200 {isOpen
|
|
589
|
+
class="shrink-0 text-description transition-transform duration-200 motion-reduce:transition-none {isOpen
|
|
250
590
|
? 'rotate-180'
|
|
251
591
|
: ''}"
|
|
252
592
|
/>
|
|
@@ -255,30 +595,37 @@
|
|
|
255
595
|
{#snippet header()}
|
|
256
596
|
{#if searchable && !isLoading}
|
|
257
597
|
<!-- Outside role="listbox": a textbox is not a valid listbox child. -->
|
|
258
|
-
|
|
598
|
+
<!-- 0.5rem and the panel's 1px border from the corner, as the
|
|
599
|
+
options below and a Dropdown's items are: an 8px field 9px
|
|
600
|
+
inside the panel's 16px corner is concentric with it. -->
|
|
601
|
+
<div class="px-2 pb-2 pt-1" style:width={menuWidth} data-menu-width>
|
|
259
602
|
<Input
|
|
260
603
|
type="text"
|
|
261
604
|
size="sm"
|
|
262
605
|
class="min-w-0"
|
|
263
|
-
placeholder={searchPlaceholder}
|
|
606
|
+
placeholder={text.searchPlaceholder}
|
|
264
607
|
bind:value={searchQuery}
|
|
265
|
-
aria-label={searchPlaceholder}
|
|
608
|
+
aria-label={text.searchPlaceholder}
|
|
266
609
|
{disabled}
|
|
267
610
|
/>
|
|
268
611
|
</div>
|
|
269
612
|
{/if}
|
|
270
613
|
{/snippet}
|
|
271
614
|
{#snippet children()}
|
|
272
|
-
|
|
615
|
+
<!-- `px-1` twice: the inner one is room for an option's focus
|
|
616
|
+
ring inside the scrolling box, and together with the panel's
|
|
617
|
+
border they put the options 9px from its corner. -->
|
|
618
|
+
<div class="px-1 pb-2 pt-1" style:width={menuWidth} data-menu-width>
|
|
273
619
|
<div
|
|
274
620
|
class="overflow-y-auto px-1"
|
|
621
|
+
data-menu-scroll
|
|
275
622
|
style:max-height={maxMenuHeight}
|
|
276
623
|
>
|
|
277
624
|
{#if isLoading}
|
|
278
625
|
<div class="space-y-2 px-2 py-2" role="status" aria-live="polite">
|
|
279
626
|
<div class="h-8 w-full animate-pulse rounded-control bg-base-200"></div>
|
|
280
627
|
<div class="h-8 w-full animate-pulse rounded-control bg-base-200"></div>
|
|
281
|
-
<p class="text-xs text-description">{
|
|
628
|
+
<p class="text-xs text-description">{text.loading}</p>
|
|
282
629
|
</div>
|
|
283
630
|
{:else if filteredOptions().length > 0}
|
|
284
631
|
{#each filteredOptions() as option (option.value)}
|
|
@@ -286,35 +633,44 @@
|
|
|
286
633
|
"data-value": String(option.value),
|
|
287
634
|
} as Record<string, string>}
|
|
288
635
|
<div class="w-full my-1">
|
|
636
|
+
<!-- `aria-disabled`, not `disabled`: a natively disabled button cannot take focus, so the arrow keys stopped at the option before it. The click is refused in `handleOptionClick`. -->
|
|
289
637
|
<button
|
|
290
638
|
type="button"
|
|
291
639
|
role="option"
|
|
292
|
-
aria-selected={isSameValue(value, option.value)
|
|
293
|
-
|
|
294
|
-
: undefined}
|
|
295
|
-
class="focus-ring flex w-full items-center justify-start rounded-control border-2 px-3 py-2 text-left text-sm font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isSameValue(
|
|
640
|
+
aria-selected={isSameValue(value, option.value)}
|
|
641
|
+
class="focus-ring flex w-full items-center justify-start gap-2 rounded-control border-2 px-3 py-2 pointer-coarse:py-3 text-start text-sm font-medium transition-colors focus:outline-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 {isSameValue(
|
|
296
642
|
value,
|
|
297
643
|
option.value,
|
|
298
644
|
)
|
|
299
|
-
? 'border-
|
|
300
|
-
:
|
|
301
|
-
|
|
645
|
+
? 'border-transparent bg-surface-overlay-hover text-headline active:bg-surface-active'
|
|
646
|
+
: option.disabled
|
|
647
|
+
? 'border-transparent bg-transparent text-body'
|
|
648
|
+
: 'border-transparent bg-transparent text-body hover:bg-surface-overlay-hover active:bg-surface-active'}"
|
|
649
|
+
aria-disabled={option.disabled ? "true" : undefined}
|
|
302
650
|
{...buttonRestProps}
|
|
303
651
|
onclick={() =>
|
|
304
652
|
handleOptionClick(option.value)}
|
|
305
653
|
>
|
|
306
|
-
{option.label}
|
|
654
|
+
<span class="min-w-0 flex-1">{option.label}</span>
|
|
655
|
+
<!-- The chosen option's mark: a check and a
|
|
656
|
+
fill. It used to be an edge in the action
|
|
657
|
+
colour, which is what the focus ring draws:
|
|
658
|
+
the chosen and the focused option could
|
|
659
|
+
not be told apart. -->
|
|
660
|
+
{#if isSameValue(value, option.value)}
|
|
661
|
+
<Check size={16} class="shrink-0" aria-hidden="true" />
|
|
662
|
+
{/if}
|
|
307
663
|
</button>
|
|
308
664
|
</div>
|
|
309
665
|
{/each}
|
|
310
666
|
{:else if hasSearchQuery()}
|
|
311
667
|
<div class="px-3 py-2 text-sm text-description">
|
|
312
|
-
{
|
|
668
|
+
{text.noResults}
|
|
313
669
|
</div>
|
|
314
670
|
{:else}
|
|
315
671
|
<div class="rounded-control border border-border bg-surface-overlay-hover px-3 py-3 text-sm">
|
|
316
|
-
<p class="font-medium text-headline">{
|
|
317
|
-
<p class="mt-1 text-description">{
|
|
672
|
+
<p class="font-medium text-headline">{text.emptyTitle}</p>
|
|
673
|
+
<p class="mt-1 text-description">{text.emptyDescription}</p>
|
|
318
674
|
{#if emptyStateActionLabel && onEmptyStateAction}
|
|
319
675
|
<button
|
|
320
676
|
type="button"
|
|
@@ -330,21 +686,7 @@
|
|
|
330
686
|
</div>
|
|
331
687
|
{/snippet}
|
|
332
688
|
</Dropdown>
|
|
333
|
-
{#if name}
|
|
334
|
-
<input type="hidden" {name} value={isEmpty() ? "" : String(value)} />
|
|
335
|
-
{/if}
|
|
336
|
-
{#if message && variant !== "default"}
|
|
337
|
-
<p
|
|
338
|
-
id={`${selectId}-message`}
|
|
339
|
-
class={messageClasses()}
|
|
340
|
-
role={variant === "error" ? "alert" : "status"}
|
|
341
|
-
aria-live={variant === "error" ? "assertive" : "polite"}
|
|
342
|
-
>
|
|
343
|
-
{#if getIcon()}
|
|
344
|
-
{@const Icon = getIcon()}
|
|
345
|
-
<Icon size={14} class="shrink-0" />
|
|
346
|
-
{/if}
|
|
347
|
-
<span>{message}</span>
|
|
348
|
-
</p>
|
|
349
689
|
{/if}
|
|
690
|
+
</div>
|
|
691
|
+
<FieldMessages fieldId={selectId} {status} {hint} gap="mt-1" messageClass="w-full" />
|
|
350
692
|
</div>
|