@eifi1/ui-kit 0.4.0 → 0.5.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 +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useId, useMemo, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
|
|
5
|
+
import { ComboboxPanel, useComboboxCore, type ComboOption } from "./combobox-core";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_COMBOBOX_LABELS,
|
|
8
|
+
DEFAULT_COMMON_LABELS,
|
|
9
|
+
useKitLabels,
|
|
10
|
+
useKitLocale,
|
|
11
|
+
} from "../i18n/kit-labels";
|
|
12
|
+
|
|
13
|
+
/** `onChange` is the kit's — "a selection was made", carrying values — rather
|
|
14
|
+
* than the div's form event, so the DOM's spelling of it is omitted. */
|
|
15
|
+
export interface MultiEntityComboboxProps<V extends string | number>
|
|
16
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
17
|
+
/** Selected ids. */
|
|
18
|
+
value: V[];
|
|
19
|
+
onChange: (value: V[]) => void;
|
|
20
|
+
/** Already-loaded options (client-side filtered); also resolves selected labels. */
|
|
21
|
+
options?: ComboOption<V>[];
|
|
22
|
+
/** Async option source, debounced + race-safe. When set, `options` resolves
|
|
23
|
+
* labels only. */
|
|
24
|
+
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
label?: ReactNode;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
searchPlaceholder?: string;
|
|
29
|
+
emptyLabel?: string;
|
|
30
|
+
/** Trigger summary when 1+ selected (default: the labels as a locale-formatted
|
|
31
|
+
* list, or `combobox.selectedCount` when a label can't be resolved). */
|
|
32
|
+
itemLabel?: (count: number) => string;
|
|
33
|
+
clearable?: boolean;
|
|
34
|
+
clearLabel?: string;
|
|
35
|
+
/** The phone sheet's close button. Its own prop rather than a reuse of
|
|
36
|
+
* `clearLabel`: "clear the selection" and "close this screen" are different
|
|
37
|
+
* actions, and on a full-screen sheet the close button is the only way out —
|
|
38
|
+
* so it is the one control here that MUST be in the reader's language. */
|
|
39
|
+
closeLabel?: string;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
/** When set, a "create" row appears for a non-empty query with no exact match.
|
|
42
|
+
* The panel stays open (adding the new entity to `value` is the caller's job). */
|
|
43
|
+
onCreate?: (query: string) => void;
|
|
44
|
+
createLabel?: (query: string) => string;
|
|
45
|
+
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
46
|
+
invalid?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Multi-value sibling of {@link EntityCombobox}: pick several entities by search,
|
|
51
|
+
* with the same async `loadOptions`/`onCreate` support. Rows show a checkbox and
|
|
52
|
+
* toggle without closing the panel; the trigger summarises the selection.
|
|
53
|
+
*/
|
|
54
|
+
export function MultiEntityCombobox<V extends string | number>({
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
options,
|
|
58
|
+
loadOptions,
|
|
59
|
+
loading,
|
|
60
|
+
label,
|
|
61
|
+
placeholder,
|
|
62
|
+
searchPlaceholder,
|
|
63
|
+
emptyLabel,
|
|
64
|
+
itemLabel,
|
|
65
|
+
clearable,
|
|
66
|
+
clearLabel,
|
|
67
|
+
closeLabel,
|
|
68
|
+
disabled,
|
|
69
|
+
onCreate,
|
|
70
|
+
createLabel,
|
|
71
|
+
className,
|
|
72
|
+
invalid,
|
|
73
|
+
"aria-label": ariaLabel,
|
|
74
|
+
...rest
|
|
75
|
+
}: MultiEntityComboboxProps<V>) {
|
|
76
|
+
const core = useComboboxCore<V>({ options, loadOptions, loading });
|
|
77
|
+
// The props are the per-instance overrides, the provider the app-wide ones; a
|
|
78
|
+
// prop left `undefined` falls through to the provider rather than masking it.
|
|
79
|
+
const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
|
|
80
|
+
search: searchPlaceholder,
|
|
81
|
+
noResults: emptyLabel,
|
|
82
|
+
clear: clearLabel,
|
|
83
|
+
create: createLabel,
|
|
84
|
+
});
|
|
85
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
86
|
+
const { open, results, resolve, setOpen } = core;
|
|
87
|
+
// One id per instance, generated here rather than in the core: `aria-controls` on
|
|
88
|
+
// the trigger has to name the list while the list is still closed, so the id
|
|
89
|
+
// belongs to whoever renders both ends of it.
|
|
90
|
+
const listboxId = useId();
|
|
91
|
+
|
|
92
|
+
const valueSet = useMemo(() => new Set(value), [value]);
|
|
93
|
+
// A list of NAMES, not a sentence, so the narrow conjunction: English gets
|
|
94
|
+
// "Food, Rent, Travel" (no "and" squeezed into a truncating trigger), German and
|
|
95
|
+
// French still get their "und"/"et" where the language insists on it, and Japanese
|
|
96
|
+
// and Chinese get the ideographic comma "、". The `unit` type looked like the
|
|
97
|
+
// natural fit for a summary, but in ja/zh it concatenates the names with nothing
|
|
98
|
+
// between them at all.
|
|
99
|
+
const locale = useKitLocale();
|
|
100
|
+
const listFormat = useMemo(
|
|
101
|
+
() => new Intl.ListFormat(locale, { type: "conjunction", style: "narrow" }),
|
|
102
|
+
[locale],
|
|
103
|
+
);
|
|
104
|
+
const summary = (() => {
|
|
105
|
+
if (value.length === 0) return placeholder ?? "";
|
|
106
|
+
if (itemLabel) return itemLabel(value.length);
|
|
107
|
+
const chosen = value.map(resolve);
|
|
108
|
+
return chosen.every(Boolean)
|
|
109
|
+
? listFormat.format(chosen.map((o) => o!.label))
|
|
110
|
+
: labels.selectedCount(value.length);
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
const q = core.query.trim();
|
|
114
|
+
const showCreate =
|
|
115
|
+
Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
116
|
+
const showClear = Boolean(clearable && value.length > 0 && !disabled);
|
|
117
|
+
|
|
118
|
+
const toggle = (o: ComboOption<V>) => {
|
|
119
|
+
core.cacheRef.current.set(o.value, o);
|
|
120
|
+
if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));
|
|
121
|
+
else onChange([...value, o.value]);
|
|
122
|
+
// Stay open — multi-select keeps adding.
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
// `rest` dresses the wrapper, which has no role; the accessible NAME goes on
|
|
127
|
+
// the trigger, which has one. Spread FIRST so the trigger's ARIA and the
|
|
128
|
+
// handlers that open the panel cannot be clobbered from outside.
|
|
129
|
+
<div {...rest} className={cn("relative", className)}>
|
|
130
|
+
{label !== undefined && <FieldLabel>{label}</FieldLabel>}
|
|
131
|
+
<button
|
|
132
|
+
ref={core.triggerRef}
|
|
133
|
+
type="button"
|
|
134
|
+
// A combobox, not a button. The distinction is not pedantry: this control
|
|
135
|
+
// carried `aria-invalid`, which `button` does not support, so a required
|
|
136
|
+
// field left empty painted a rose border and told a reader nothing at all —
|
|
137
|
+
// and ESLint flagged it as exactly that (`role-supports-aria-props`). The
|
|
138
|
+
// fix the audit asked for is the role that describes what this IS: a closed
|
|
139
|
+
// choice that expands into the list named below. `combobox` supports
|
|
140
|
+
// `aria-invalid`, so the border and the announcement finally agree.
|
|
141
|
+
role="combobox"
|
|
142
|
+
aria-expanded={open}
|
|
143
|
+
aria-controls={listboxId}
|
|
144
|
+
aria-haspopup="listbox"
|
|
145
|
+
// The label is a floating <span>, not a <label for>, so without this the
|
|
146
|
+
// trigger's accessible name is whatever value happens to be selected —
|
|
147
|
+
// "Checking" with nothing saying it is the account. The label AND the
|
|
148
|
+
// value, because `aria-label` replaces the content rather than adding to
|
|
149
|
+
// it, and a control that announces only its name has lost the answer.
|
|
150
|
+
// A caller's own name wins over the composition: two fields labelled
|
|
151
|
+
// "Account" on a transfer form are the from and the to, and only the
|
|
152
|
+
// caller knows which is which.
|
|
153
|
+
aria-label={
|
|
154
|
+
ariaLabel ??
|
|
155
|
+
(typeof label === "string" ? common.fieldValue(label, summary) : undefined)
|
|
156
|
+
}
|
|
157
|
+
disabled={disabled}
|
|
158
|
+
aria-invalid={invalid || undefined}
|
|
159
|
+
onClick={() => !disabled && setOpen((o) => !o)}
|
|
160
|
+
// Down/Up opens the list from the closed trigger, per the APG. Enter and
|
|
161
|
+
// Space already do it through the button's own click.
|
|
162
|
+
onKeyDown={(e) => {
|
|
163
|
+
if (disabled) return;
|
|
164
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
setOpen(true);
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
className={cn(
|
|
170
|
+
FIELD_TRIGGER,
|
|
171
|
+
"pr-9",
|
|
172
|
+
label !== undefined && FIELD_FLOATING_PAD,
|
|
173
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
174
|
+
invalid && FIELD_INVALID,
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
<span
|
|
178
|
+
className={cn(
|
|
179
|
+
"min-w-0 truncate",
|
|
180
|
+
// Inherit FIELD_BASE's ink for a value, keep the placeholder tone
|
|
181
|
+
// (`--text-placeholder`) — the same rule every field on a form row
|
|
182
|
+
// follows (Keksdose dev#477).
|
|
183
|
+
!value.length && "text-[var(--text-placeholder)]",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{summary}
|
|
187
|
+
</span>
|
|
188
|
+
{showClear ? (
|
|
189
|
+
<span
|
|
190
|
+
role="button"
|
|
191
|
+
tabIndex={-1}
|
|
192
|
+
aria-label={labels.clear}
|
|
193
|
+
onClick={(e) => {
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
onChange([]);
|
|
196
|
+
}}
|
|
197
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
|
|
198
|
+
>
|
|
199
|
+
<X className="size-4" />
|
|
200
|
+
</span>
|
|
201
|
+
) : (
|
|
202
|
+
<FieldChevron />
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
<ComboboxPanel
|
|
206
|
+
core={core}
|
|
207
|
+
listboxId={listboxId}
|
|
208
|
+
multi
|
|
209
|
+
// On a phone the panel becomes a full-screen sheet, which needs the field's
|
|
210
|
+
// own label to say what it is asking for (live #200).
|
|
211
|
+
sheetTitle={label ?? placeholder}
|
|
212
|
+
searchPlaceholder={labels.search}
|
|
213
|
+
emptyLabel={labels.noResults}
|
|
214
|
+
closeLabel={closeLabel}
|
|
215
|
+
isSelected={(v) => valueSet.has(v)}
|
|
216
|
+
onChoose={toggle}
|
|
217
|
+
showCreate={showCreate}
|
|
218
|
+
onCreate={() => {
|
|
219
|
+
onCreate?.(q);
|
|
220
|
+
core.setQuery("");
|
|
221
|
+
}}
|
|
222
|
+
createContent={labels.create(q)}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { useId, useMemo, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { Check } from "lucide-react";
|
|
8
|
+
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui";
|
|
9
|
+
import { cn } from "../lib/cn";
|
|
10
|
+
import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown";
|
|
11
|
+
import { useActiveOptionScroll } from "./combobox-core";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_COMMON_LABELS,
|
|
14
|
+
DEFAULT_MULTI_SELECT_LABELS,
|
|
15
|
+
useKitLabels,
|
|
16
|
+
} from "../i18n/kit-labels";
|
|
17
|
+
|
|
18
|
+
export interface MultiSelectOption {
|
|
19
|
+
value: string | number;
|
|
20
|
+
label: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `onChange` is the kit's — "the selection changed", carrying the new values —
|
|
25
|
+
* rather than the div's form event. */
|
|
26
|
+
export interface MultiSelectProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
27
|
+
options: MultiSelectOption[];
|
|
28
|
+
values: (string | number)[];
|
|
29
|
+
onChange: (next: (string | number)[]) => void;
|
|
30
|
+
/** Displayed when no value is selected (i.e. "all"). Defaults to `placeholder`,
|
|
31
|
+
* then to `multiSelect.all` from the {@link UiKitProvider}, then "All". */
|
|
32
|
+
allLabel?: string;
|
|
33
|
+
/** Singular/plural countable label, used when 1+ items are selected. Default:
|
|
34
|
+
* `multiSelect.selectedCount` from the provider — the bare count in English. */
|
|
35
|
+
itemLabel?: (count: number) => string;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Embedded top-boundary label, matching the native Input/Select fields. */
|
|
38
|
+
label?: ReactNode;
|
|
39
|
+
/** Search-box placeholder. Default: `multiSelect.search` from the provider, else "Search". */
|
|
40
|
+
searchLabel?: string;
|
|
41
|
+
/** "Select all" action label. Default: `multiSelect.selectAll`, else "Select all". */
|
|
42
|
+
selectAllLabel?: string;
|
|
43
|
+
/** "Clear" action label. Default: `multiSelect.clear`, else "Clear". */
|
|
44
|
+
clearLabel?: string;
|
|
45
|
+
/** Extra classes for the open dropdown PANEL — the way to widen it past its
|
|
46
|
+
* 16rem default when the rows carry more than a label (Keksdose feedback #147). */
|
|
47
|
+
panelClassName?: string;
|
|
48
|
+
/** Required and unanswered — {@link FIELD_INVALID}, the same rose border the
|
|
49
|
+
* native `Select` has worn since feedback #235. Every field-styled control in
|
|
50
|
+
* this package carries it now, so a form can mark any of its fields rather than
|
|
51
|
+
* only the one that happened to have it first. */
|
|
52
|
+
invalid?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function MultiSelect({
|
|
56
|
+
options,
|
|
57
|
+
values,
|
|
58
|
+
onChange,
|
|
59
|
+
allLabel,
|
|
60
|
+
itemLabel,
|
|
61
|
+
placeholder,
|
|
62
|
+
label,
|
|
63
|
+
searchLabel,
|
|
64
|
+
selectAllLabel,
|
|
65
|
+
clearLabel,
|
|
66
|
+
className,
|
|
67
|
+
panelClassName,
|
|
68
|
+
invalid,
|
|
69
|
+
"aria-label": ariaLabel,
|
|
70
|
+
...rest
|
|
71
|
+
}: MultiSelectProps) {
|
|
72
|
+
const { open, setOpen, wrapperRef, triggerRef, closeToTrigger, query, setQuery, inputRef } =
|
|
73
|
+
useDropdownSearch();
|
|
74
|
+
// `allLabel` is deliberately NOT folded in here: between it and the provider's
|
|
75
|
+
// `all` sits `placeholder`, which has always doubled as the "nothing picked" text,
|
|
76
|
+
// and a caller who set it must keep seeing it.
|
|
77
|
+
const labels = useKitLabels("multiSelect", DEFAULT_MULTI_SELECT_LABELS, {
|
|
78
|
+
search: searchLabel,
|
|
79
|
+
selectAll: selectAllLabel,
|
|
80
|
+
clear: clearLabel,
|
|
81
|
+
selectedCount: itemLabel,
|
|
82
|
+
});
|
|
83
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
84
|
+
// One id per instance: `aria-controls` on the trigger names the list while the
|
|
85
|
+
// list is still closed.
|
|
86
|
+
const uid = useId();
|
|
87
|
+
const listboxId = `${uid}-listbox`;
|
|
88
|
+
const optionId = (index: number) => `${uid}-option-${index}`;
|
|
89
|
+
// Which row the arrow keys are on. Reset from the handlers that change what the
|
|
90
|
+
// list contains rather than from an effect — a render pass is not where this
|
|
91
|
+
// decision belongs, and the two callers that move the list are right here.
|
|
92
|
+
const [active, setActive] = useState(0);
|
|
93
|
+
|
|
94
|
+
const filtered = useMemo(() => {
|
|
95
|
+
const q = query.trim().toLowerCase();
|
|
96
|
+
if (!q) return options;
|
|
97
|
+
return options.filter(
|
|
98
|
+
(o) =>
|
|
99
|
+
o.label.toLowerCase().includes(q) ||
|
|
100
|
+
(o.hint ?? "").toLowerCase().includes(q),
|
|
101
|
+
);
|
|
102
|
+
}, [options, query]);
|
|
103
|
+
|
|
104
|
+
const valueSet = useMemo(() => new Set(values), [values]);
|
|
105
|
+
const allChecked = options.length > 0 && options.every((o) => valueSet.has(o.value));
|
|
106
|
+
const anyChecked = values.length > 0;
|
|
107
|
+
const selectedSummary = (() => {
|
|
108
|
+
if (!anyChecked || allChecked) return allLabel ?? placeholder ?? labels.all;
|
|
109
|
+
return labels.selectedCount(values.length);
|
|
110
|
+
})();
|
|
111
|
+
|
|
112
|
+
const toggle = (val: string | number) => {
|
|
113
|
+
if (valueSet.has(val)) onChange(values.filter((v) => v !== val));
|
|
114
|
+
else onChange([...values, val]);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const selectAll = () => onChange(options.map((o) => o.value));
|
|
118
|
+
const clearAll = () => onChange([]);
|
|
119
|
+
|
|
120
|
+
const activeId = active >= 0 && active < filtered.length ? optionId(active) : undefined;
|
|
121
|
+
useActiveOptionScroll(activeId);
|
|
122
|
+
|
|
123
|
+
// The WAI-ARIA APG's listbox keyboard, handled on the search box because that is
|
|
124
|
+
// where focus is while the panel is up. Escape is `useDropdown`'s, on the document.
|
|
125
|
+
const onListKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
126
|
+
if (e.key === "ArrowDown") {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
setActive((i) => Math.min(i + 1, filtered.length - 1));
|
|
129
|
+
} else if (e.key === "ArrowUp") {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
setActive((i) => Math.max(i - 1, 0));
|
|
132
|
+
} else if (e.key === "Home") {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
setActive(0);
|
|
135
|
+
} else if (e.key === "End") {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
setActive(filtered.length - 1);
|
|
138
|
+
} else if (e.key === "Enter") {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
// Toggling keeps the panel open, exactly as clicking a row does: this control
|
|
141
|
+
// exists to answer "which of these", which is rarely one of them.
|
|
142
|
+
if (filtered[active]) toggle(filtered[active].value);
|
|
143
|
+
} else if (e.key === "Tab") {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
closeToTrigger();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
// `rest` dresses the wrapper; the name belongs to the trigger below it, which is
|
|
151
|
+
// the thing with a role. Spread FIRST so the ARIA the trigger composes, and the
|
|
152
|
+
// handlers that open the list, cannot be replaced by accident from outside.
|
|
153
|
+
<div {...rest} ref={wrapperRef} className={cn("relative", className)}>
|
|
154
|
+
{label !== undefined && <FieldLabel>{label}</FieldLabel>}
|
|
155
|
+
<button
|
|
156
|
+
ref={triggerRef}
|
|
157
|
+
type="button"
|
|
158
|
+
// A combobox, not a bare button: this trigger carried `aria-invalid`, which
|
|
159
|
+
// `button` does not support, so a required-and-empty filter painted its rose
|
|
160
|
+
// border and announced nothing (ESLint's `role-supports-aria-props`, the
|
|
161
|
+
// audit's §a11y). The role that describes it is the one that also supports
|
|
162
|
+
// the attribute.
|
|
163
|
+
role="combobox"
|
|
164
|
+
aria-expanded={open}
|
|
165
|
+
aria-controls={listboxId}
|
|
166
|
+
aria-haspopup="listbox"
|
|
167
|
+
// The label is a floating <span>, not a <label for>, so without this the
|
|
168
|
+
// trigger announces only its summary — "3 selected", with nothing saying
|
|
169
|
+
// three of WHAT. The label AND the summary, because `aria-label` replaces
|
|
170
|
+
// the content rather than adding to it; the same composition its two
|
|
171
|
+
// siblings use, so a form row of pickers reads the same way throughout.
|
|
172
|
+
// A caller's own name wins: two filters labelled "Accounts" on one screen have
|
|
173
|
+
// to be told apart, and only the caller knows by what.
|
|
174
|
+
aria-label={
|
|
175
|
+
ariaLabel ?? (typeof label === "string" ? common.fieldValue(label, selectedSummary) : undefined)
|
|
176
|
+
}
|
|
177
|
+
onClick={() => {
|
|
178
|
+
setOpen((v) => !v);
|
|
179
|
+
setActive(0);
|
|
180
|
+
}}
|
|
181
|
+
// Down/Up opens the list from the closed trigger, per the APG; Enter and
|
|
182
|
+
// Space already do through the button's own click.
|
|
183
|
+
onKeyDown={(e) => {
|
|
184
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
setOpen(true);
|
|
187
|
+
setActive(0);
|
|
188
|
+
}
|
|
189
|
+
}}
|
|
190
|
+
aria-invalid={invalid || undefined}
|
|
191
|
+
className={cn(FIELD_TRIGGER, "pr-9", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}
|
|
192
|
+
>
|
|
193
|
+
{/* The summary IS the field's value, so it inherits FIELD_BASE's ink rather
|
|
194
|
+
than restating a lighter one (Keksdose dev#477). */}
|
|
195
|
+
<span className="truncate">{selectedSummary}</span>
|
|
196
|
+
<FieldChevron />
|
|
197
|
+
</button>
|
|
198
|
+
{open && (
|
|
199
|
+
<DropdownPanel
|
|
200
|
+
// 16rem by default; `panelClassName` is how a caller widens it (Keksdose
|
|
201
|
+
// feedback #147: the market picker's rows carry a postcode, a town, a
|
|
202
|
+
// distance and a receipt count, all of which were being truncated). The
|
|
203
|
+
// override used to be applied from the OUTSIDE with a
|
|
204
|
+
// `[&>div]:w-full` descendant selector, which worked and was a hack —
|
|
205
|
+
// "change the package" was the right call.
|
|
206
|
+
className={cn("w-64", panelClassName)}
|
|
207
|
+
empty={filtered.length === 0}
|
|
208
|
+
// The rows below are options, so the list around them has to be a listbox
|
|
209
|
+
// — and one that says several answers are allowed, which is the only thing
|
|
210
|
+
// that made the trigger's "3 selected" make sense to a reader.
|
|
211
|
+
listProps={{ id: listboxId, role: "listbox", "aria-multiselectable": true }}
|
|
212
|
+
header={
|
|
213
|
+
<>
|
|
214
|
+
<DropdownSearchHeader
|
|
215
|
+
query={query}
|
|
216
|
+
onQueryChange={(v) => {
|
|
217
|
+
setQuery(v);
|
|
218
|
+
// The list under the highlight just changed; keeping the old index
|
|
219
|
+
// would point `aria-activedescendant` at a different option than
|
|
220
|
+
// the one the user was on.
|
|
221
|
+
setActive(0);
|
|
222
|
+
}}
|
|
223
|
+
inputRef={inputRef}
|
|
224
|
+
placeholder={labels.search}
|
|
225
|
+
listboxId={listboxId}
|
|
226
|
+
activeId={activeId}
|
|
227
|
+
onKeyDown={onListKeyDown}
|
|
228
|
+
/>
|
|
229
|
+
<div className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs">
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={selectAll}
|
|
233
|
+
disabled={allChecked}
|
|
234
|
+
className="rounded px-2 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] disabled:opacity-40"
|
|
235
|
+
>
|
|
236
|
+
{labels.selectAll}
|
|
237
|
+
</button>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={clearAll}
|
|
241
|
+
disabled={!anyChecked}
|
|
242
|
+
className="rounded px-2 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] disabled:opacity-40"
|
|
243
|
+
>
|
|
244
|
+
{labels.clear}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
}
|
|
249
|
+
>
|
|
250
|
+
{filtered.map((o, i) => {
|
|
251
|
+
const checked = valueSet.has(o.value);
|
|
252
|
+
return (
|
|
253
|
+
<li key={o.value} role="presentation">
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
id={optionId(i)}
|
|
257
|
+
// The checked state used to exist ONLY as the decorative span
|
|
258
|
+
// below (the audit's §a11y): the row said its label and nothing
|
|
259
|
+
// else, so "selected" was imperceptible without sight. This is
|
|
260
|
+
// the same state, in the one place a reader looks for it.
|
|
261
|
+
role="option"
|
|
262
|
+
aria-selected={checked}
|
|
263
|
+
// Focus stays in the search box — that is what
|
|
264
|
+
// `aria-activedescendant` is for — so the rows are not tab stops.
|
|
265
|
+
tabIndex={-1}
|
|
266
|
+
onClick={() => toggle(o.value)}
|
|
267
|
+
onMouseEnter={() => setActive(i)}
|
|
268
|
+
className={cn(
|
|
269
|
+
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-sm text-left hover:bg-[var(--bg-hover)]",
|
|
270
|
+
checked && "bg-[var(--bg-active)]",
|
|
271
|
+
i === active && "bg-[var(--bg-hover)]",
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
275
|
+
<span
|
|
276
|
+
// A picture of `aria-selected` above; a reader that met both
|
|
277
|
+
// would be told twice.
|
|
278
|
+
aria-hidden
|
|
279
|
+
className={cn(
|
|
280
|
+
"flex size-4 shrink-0 items-center justify-center rounded border",
|
|
281
|
+
checked
|
|
282
|
+
? "border-[var(--bg-inverse)] bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
|
|
283
|
+
: "border-[var(--border-strong)] bg-[var(--bg-surface)]",
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{checked && <Check className="size-3" />}
|
|
287
|
+
</span>
|
|
288
|
+
<span className="truncate text-[var(--text-primary)]">{o.label}</span>
|
|
289
|
+
{o.hint && (
|
|
290
|
+
<span className="truncate text-xs text-[var(--text-placeholder)]">
|
|
291
|
+
{o.hint}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
</span>
|
|
295
|
+
</button>
|
|
296
|
+
</li>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</DropdownPanel>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|