@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,258 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The bar, the word and the advisory checklist under a field where someone is
|
|
9
|
+
* CHOOSING a secret.
|
|
10
|
+
*
|
|
11
|
+
* Ported from Keksdose (`features/auth/password-strength-meter.tsx`), where one meter
|
|
12
|
+
* sits under the sign-in password and the encryption passphrase alike — a copy per
|
|
13
|
+
* form is what had let the passphrase dialogs ship with no meter at all. Three things
|
|
14
|
+
* changed on the way in:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Tokens, not palette.** The four fills were red/amber/lime/emerald utilities;
|
|
17
|
+
* they are `--danger` → `--warning` → `--success` now, so a consumer re-pointing
|
|
18
|
+
* those families re-skins the meter with them.
|
|
19
|
+
* 2. **Not colour alone.** The level is always written out beside the bar, and each
|
|
20
|
+
* checklist item says "met" / "not met" to a screen reader — the ✓/○ glyphs are
|
|
21
|
+
* decoration and hidden.
|
|
22
|
+
* 3. **Announced.** A level change is spoken through a polite live region
|
|
23
|
+
* ({@link useAnnounce}); the bar itself is `aria-hidden`, because four coloured
|
|
24
|
+
* strips read out as four empty groups.
|
|
25
|
+
*
|
|
26
|
+
* Advisory only, as in Keksdose: nothing here blocks a submit. The caller decides what
|
|
27
|
+
* is required — {@link passwordRules} marks `length` as the one rule that usually is,
|
|
28
|
+
* so a form can gate on it with the same function the checklist draws from.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free on purpose. zxcvbn is ~400 kB of dictionaries, and neither consumer
|
|
31
|
+
* ships it; a host that does can pass its scorer as `score` and keep the rest.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/* ── The scorer ──────────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
/** 0 = too short (or empty), 4 = strong. */
|
|
37
|
+
export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4;
|
|
38
|
+
|
|
39
|
+
export type PasswordRuleId = "length" | "case" | "digit" | "symbol";
|
|
40
|
+
|
|
41
|
+
export interface PasswordRule {
|
|
42
|
+
id: PasswordRuleId;
|
|
43
|
+
met: boolean;
|
|
44
|
+
/** Only `length` is required; the rest are suggestions. */
|
|
45
|
+
required: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PasswordScoreOptions {
|
|
49
|
+
/** The length below which a secret is "too short", whatever else it has. Keep it
|
|
50
|
+
* in step with the server's own minimum. Default 8. */
|
|
51
|
+
minLength?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_MIN_LENGTH = 8;
|
|
55
|
+
|
|
56
|
+
/** The checklist's rules, evaluated. Pure; the order is the order they are shown in. */
|
|
57
|
+
export function passwordRules(value: string, { minLength = DEFAULT_MIN_LENGTH }: PasswordScoreOptions = {}): PasswordRule[] {
|
|
58
|
+
return [
|
|
59
|
+
// Code points, not UTF-16 units: an emoji is one character to the person typing it.
|
|
60
|
+
{ id: "length", met: [...value].length >= minLength, required: true },
|
|
61
|
+
{ id: "case", met: /\p{Ll}/u.test(value) && /\p{Lu}/u.test(value), required: false },
|
|
62
|
+
{ id: "digit", met: /\p{Nd}/u.test(value), required: false },
|
|
63
|
+
{ id: "symbol", met: /[^\p{L}\p{N}\s]/u.test(value), required: false },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A 0–4 strength bucket. Length dominates, because it genuinely dominates real-world
|
|
69
|
+
* guessing cost: one step each at `minLength`, `minLength + 4` and `minLength + 8`,
|
|
70
|
+
* and one more for at least two of the three variety rules.
|
|
71
|
+
*
|
|
72
|
+
* One addition over Keksdose's curve: a secret with fewer than four DISTINCT
|
|
73
|
+
* characters caps at 1. Sixteen `a`s are long, and nothing about them is strong.
|
|
74
|
+
*/
|
|
75
|
+
export function scorePassword(value: string, options: PasswordScoreOptions = {}): PasswordStrengthScore {
|
|
76
|
+
const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
|
|
77
|
+
const chars = [...value];
|
|
78
|
+
// Under the minimum is never anything but "too short", however varied.
|
|
79
|
+
if (chars.length === 0 || chars.length < minLength) return 0;
|
|
80
|
+
let score = 1;
|
|
81
|
+
if (chars.length >= minLength + 4) score += 1;
|
|
82
|
+
if (chars.length >= minLength + 8) score += 1;
|
|
83
|
+
const variety = passwordRules(value, { minLength }).filter((r) => !r.required && r.met).length;
|
|
84
|
+
if (variety >= 2) score += 1;
|
|
85
|
+
if (new Set(chars).size < 4) score = 1;
|
|
86
|
+
return Math.min(score, 4) as PasswordStrengthScore;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** UTF-8 byte length — what a bcrypt-style ceiling counts. "ü" is two, an emoji four. */
|
|
90
|
+
export function passwordByteLength(value: string): number {
|
|
91
|
+
return new TextEncoder().encode(value).length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
|
95
|
+
|
|
96
|
+
/** Every string the meter renders or speaks. */
|
|
97
|
+
export interface PasswordStrengthLabels {
|
|
98
|
+
/** The five level names, shown beside the bar. Score 0 … 4. */
|
|
99
|
+
tooShort: string;
|
|
100
|
+
weak: string;
|
|
101
|
+
fair: string;
|
|
102
|
+
good: string;
|
|
103
|
+
strong: string;
|
|
104
|
+
/** What the live region says when the level changes, given the level's name. */
|
|
105
|
+
announcement: (level: string) => string;
|
|
106
|
+
/** The length rule, given the minimum. */
|
|
107
|
+
ruleLength: (minLength: number) => string;
|
|
108
|
+
ruleCase: string;
|
|
109
|
+
ruleDigit: string;
|
|
110
|
+
ruleSymbol: string;
|
|
111
|
+
/** A suggestion rather than a requirement, given the rule's text. */
|
|
112
|
+
optional: (rule: string) => string;
|
|
113
|
+
/** Screen-reader-only state before each checklist item — the ✓/○ are hidden. */
|
|
114
|
+
met: string;
|
|
115
|
+
notMet: string;
|
|
116
|
+
/** Shown when the value is over `maxBytes`, given that ceiling. */
|
|
117
|
+
tooLong: (maxBytes: number) => string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const DEFAULT_PASSWORD_STRENGTH_LABELS: PasswordStrengthLabels = {
|
|
121
|
+
tooShort: "Too short",
|
|
122
|
+
weak: "Weak",
|
|
123
|
+
fair: "Fair",
|
|
124
|
+
good: "Good",
|
|
125
|
+
strong: "Strong",
|
|
126
|
+
announcement: (level) => `Password strength: ${level}`,
|
|
127
|
+
ruleLength: (minLength) => `At least ${minLength} characters`,
|
|
128
|
+
ruleCase: "Upper and lower case",
|
|
129
|
+
ruleDigit: "A number",
|
|
130
|
+
ruleSymbol: "A symbol",
|
|
131
|
+
optional: (rule) => `${rule} (optional)`,
|
|
132
|
+
met: "Met:",
|
|
133
|
+
notMet: "Not met:",
|
|
134
|
+
tooLong: (maxBytes) =>
|
|
135
|
+
`At most ${maxBytes} characters (accents and emoji count for more than one).`,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const LEVEL_KEYS = ["tooShort", "weak", "fair", "good", "strong"] as const;
|
|
139
|
+
|
|
140
|
+
/* ── The meter ───────────────────────────────────────────────────────────── */
|
|
141
|
+
|
|
142
|
+
// The filled segments' colour per score. "Good" sits between the warning and the
|
|
143
|
+
// success hue rather than borrowing either, so 3 and 4 differ in colour as well as in
|
|
144
|
+
// segment count. Index 0 is never drawn — a too-short secret fills no segment.
|
|
145
|
+
const FILL: Record<PasswordStrengthScore, string> = {
|
|
146
|
+
0: "",
|
|
147
|
+
1: "bg-[var(--danger)]",
|
|
148
|
+
2: "bg-[var(--warning)]",
|
|
149
|
+
3: "bg-[color-mix(in_oklab,var(--success)_60%,var(--warning))]",
|
|
150
|
+
4: "bg-[var(--success)]",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export interface PasswordStrengthMeterProps extends ComponentPropsWithoutRef<"div"> {
|
|
154
|
+
/** The secret being chosen. Nothing visible renders while it is empty. */
|
|
155
|
+
value: string;
|
|
156
|
+
/** Replace the built-in scorer (e.g. with zxcvbn's `score`). Must be pure. */
|
|
157
|
+
score?: (value: string) => PasswordStrengthScore;
|
|
158
|
+
/** See {@link PasswordScoreOptions.minLength}. Drives the built-in scorer and the
|
|
159
|
+
* length rule's text. */
|
|
160
|
+
minLength?: number;
|
|
161
|
+
/** Show the checklist under the bar. Default `true`. */
|
|
162
|
+
showRequirements?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* A byte ceiling to warn about, if the secret has one — bcrypt reads at most 72
|
|
165
|
+
* BYTES. Leave it unset for a secret that has none (a passphrase stretched in the
|
|
166
|
+
* browser): inventing one talks people out of the long passphrase they chose.
|
|
167
|
+
*/
|
|
168
|
+
maxBytes?: number;
|
|
169
|
+
/** User-facing strings; see {@link PasswordStrengthLabels}. */
|
|
170
|
+
labels?: Partial<PasswordStrengthLabels>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Render directly under the password field. To have the field itself describe its
|
|
175
|
+
* strength, give this an `id` and point the input's `aria-describedby` at it.
|
|
176
|
+
*/
|
|
177
|
+
export function PasswordStrengthMeter({
|
|
178
|
+
value,
|
|
179
|
+
score: scoreProp,
|
|
180
|
+
minLength = DEFAULT_MIN_LENGTH,
|
|
181
|
+
showRequirements = true,
|
|
182
|
+
maxBytes,
|
|
183
|
+
labels: labelsProp,
|
|
184
|
+
className,
|
|
185
|
+
...rest
|
|
186
|
+
}: PasswordStrengthMeterProps) {
|
|
187
|
+
// Prop > <UiKitProvider> > English, like every labelled component in the kit.
|
|
188
|
+
const labels = useKitLabels("passwordStrength", DEFAULT_PASSWORD_STRENGTH_LABELS, labelsProp);
|
|
189
|
+
const { announce, regionProps } = useAnnounce();
|
|
190
|
+
|
|
191
|
+
const empty = value.length === 0;
|
|
192
|
+
const score = empty ? 0 : scoreProp ? scoreProp(value) : scorePassword(value, { minLength });
|
|
193
|
+
const level = labels[LEVEL_KEYS[score]];
|
|
194
|
+
|
|
195
|
+
// Speak on a CHANGE of level only — not on every keystroke, and not on mount (a
|
|
196
|
+
// prefilled field has not changed anything). Clearing the field says nothing: the
|
|
197
|
+
// user did that on purpose and the meter disappearing is the whole news.
|
|
198
|
+
const previous = useRef(score);
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (previous.current === score) return;
|
|
201
|
+
previous.current = score;
|
|
202
|
+
if (!empty) announce(labels.announcement(level));
|
|
203
|
+
}, [score, empty, level, labels, announce]);
|
|
204
|
+
|
|
205
|
+
const rules = passwordRules(value, { minLength });
|
|
206
|
+
const ruleText: Record<PasswordRuleId, string> = {
|
|
207
|
+
length: labels.ruleLength(minLength),
|
|
208
|
+
case: labels.ruleCase,
|
|
209
|
+
digit: labels.ruleDigit,
|
|
210
|
+
symbol: labels.ruleSymbol,
|
|
211
|
+
};
|
|
212
|
+
const tooLong = maxBytes !== undefined && passwordByteLength(value) > maxBytes;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div {...rest} className={cn("mt-1.5 space-y-1", className)}>
|
|
216
|
+
{/* Rendered from the start and never unmounted — see useAnnounce. */}
|
|
217
|
+
<div {...regionProps} />
|
|
218
|
+
{!empty && (
|
|
219
|
+
<>
|
|
220
|
+
<div className="flex items-center gap-2">
|
|
221
|
+
<div className="flex flex-1 gap-1" aria-hidden="true" data-score={score}>
|
|
222
|
+
{[1, 2, 3, 4].map((step) => (
|
|
223
|
+
<div
|
|
224
|
+
key={step}
|
|
225
|
+
className={cn(
|
|
226
|
+
"h-1 flex-1 rounded-full",
|
|
227
|
+
step <= score ? FILL[score] : "bg-[var(--border)]",
|
|
228
|
+
)}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
<p className="min-w-[4.5rem] text-end text-xs text-[var(--text-secondary)]">{level}</p>
|
|
233
|
+
</div>
|
|
234
|
+
{showRequirements && (
|
|
235
|
+
<ul className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
|
236
|
+
{rules.map((r) => (
|
|
237
|
+
<li
|
|
238
|
+
key={r.id}
|
|
239
|
+
// `relative`: the sr-only state needs a local containing block.
|
|
240
|
+
className={cn(
|
|
241
|
+
"relative",
|
|
242
|
+
r.met ? "text-[var(--success)]" : "text-[var(--text-muted)]",
|
|
243
|
+
)}
|
|
244
|
+
data-met={r.met}
|
|
245
|
+
>
|
|
246
|
+
<span className="sr-only">{r.met ? labels.met : labels.notMet} </span>
|
|
247
|
+
<span aria-hidden="true">{r.met ? "✓" : "○"}</span>{" "}
|
|
248
|
+
{r.required ? ruleText[r.id] : labels.optional(ruleText[r.id])}
|
|
249
|
+
</li>
|
|
250
|
+
))}
|
|
251
|
+
</ul>
|
|
252
|
+
)}
|
|
253
|
+
{tooLong && <p className="text-xs text-[var(--danger)]">{labels.tooLong(maxBytes)}</p>}
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from "react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { DropdownSearchHeader } from "./dropdown";
|
|
7
|
+
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
|
|
8
|
+
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
9
|
+
import { useFocusTrap } from "../hooks/use-focus-trap";
|
|
10
|
+
import { useVisualViewport } from "../hooks/use-anchored-panel";
|
|
11
|
+
import { DEFAULT_PICKER_SHEET_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Two of the div's own attributes are omitted because this component already owns
|
|
15
|
+
* the name: `title` here is the sheet's HEADING (and a ReactNode), not the browser's
|
|
16
|
+
* tooltip string, and `onClose` is "the sheet was dismissed", not the DOM's
|
|
17
|
+
* `<dialog>` close event. Everything else a caller can pass reaches the sheet.
|
|
18
|
+
*/
|
|
19
|
+
export interface PickerSheetProps
|
|
20
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "title" | "onClose"> {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
/** The field's own label — a sheet that fills the screen has to say what it is
|
|
24
|
+
* asking for, which the anchored panel got for free by sitting under it. */
|
|
25
|
+
title?: ReactNode;
|
|
26
|
+
query: string;
|
|
27
|
+
onQueryChange: (value: string) => void;
|
|
28
|
+
searchPlaceholder?: string;
|
|
29
|
+
inputRef?: RefObject<HTMLInputElement | null>;
|
|
30
|
+
/** Default: `pickerSheet.close` from the {@link UiKitProvider}, else "Close". */
|
|
31
|
+
closeLabel?: string;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The PHONE presentation of a picker: a full-screen dialog with a search box at
|
|
37
|
+
* the top and the list filling everything below it (Keksdose live #200).
|
|
38
|
+
*
|
|
39
|
+
* *"Paid as full screen dialog with input. Similar to the account select that
|
|
40
|
+
* already appears as full screen."* The account field is a native `<select>`, and
|
|
41
|
+
* a phone browser renders that as a full-screen list — so the app's own pickers,
|
|
42
|
+
* which are anchored dropdown panels, were the odd ones out: a 320px-tall panel
|
|
43
|
+
* squeezed between the field and the keyboard, showing three or four rows of a
|
|
44
|
+
* list that might have two hundred entries.
|
|
45
|
+
*
|
|
46
|
+
* What this fixes beyond size: the panel had to be *placed* (above/below, tracking
|
|
47
|
+
* the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every
|
|
48
|
+
* one of those decisions is a chance to be wrong on a screen this small. A sheet
|
|
49
|
+
* has no placement. It also gives the search input somewhere unambiguous to live,
|
|
50
|
+
* which is the other half of the request: *"add filler possibility to the account
|
|
51
|
+
* select or even better right to the hoc the account select is derived from"* —
|
|
52
|
+
* so this lives in the shared picker, not in one form's copy of it.
|
|
53
|
+
*
|
|
54
|
+
* Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list
|
|
55
|
+
* scrolls, the header does not, and the body behind it is scroll-locked so a drag
|
|
56
|
+
* that overshoots the list does not move the page underneath.
|
|
57
|
+
*/
|
|
58
|
+
export function PickerSheet({
|
|
59
|
+
open,
|
|
60
|
+
onClose,
|
|
61
|
+
title,
|
|
62
|
+
query,
|
|
63
|
+
onQueryChange,
|
|
64
|
+
searchPlaceholder,
|
|
65
|
+
inputRef,
|
|
66
|
+
closeLabel,
|
|
67
|
+
children,
|
|
68
|
+
"aria-label": ariaLabel,
|
|
69
|
+
className,
|
|
70
|
+
style,
|
|
71
|
+
...rest
|
|
72
|
+
}: PickerSheetProps) {
|
|
73
|
+
// Every picker in the kit opens one of these on a phone, and most of them pass
|
|
74
|
+
// their own `closeLabel` through as `undefined` — so this lookup is what makes the
|
|
75
|
+
// one way out of a full-screen sheet speak the app's language without each of
|
|
76
|
+
// them having to (see the prop's note in `EntityCombobox`).
|
|
77
|
+
const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });
|
|
78
|
+
useBodyScrollLock(open);
|
|
79
|
+
/**
|
|
80
|
+
* Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:
|
|
81
|
+
* *"Mouse Back does not only close the select but also the whole edit or create
|
|
82
|
+
* dialog which is cumbersome."*).
|
|
83
|
+
*
|
|
84
|
+
* The sheet is a full-screen overlay over whatever opened it, and on a phone that
|
|
85
|
+
* is almost always the row editor — which DOES push a history entry
|
|
86
|
+
* (`data-table.tsx`). So the sheet was invisible to Back: one press popped the
|
|
87
|
+
* editor's entry and took the whole dialog with it, discarding an edit in progress
|
|
88
|
+
* in order to dismiss a list.
|
|
89
|
+
*
|
|
90
|
+
* `useOverlayHistory` was built for exactly this nesting — its stack unwinds
|
|
91
|
+
* last-in-first-out, and the U-12 regression test in its own suite is written
|
|
92
|
+
* around "a Modal and a PickerSheet inside it". The sheet simply never called it.
|
|
93
|
+
*
|
|
94
|
+
* Nothing else has to change: the hook is a no-op while `open` is false, and it
|
|
95
|
+
* pops its own entry when the sheet closes some other way (the X, a row, the
|
|
96
|
+
* backdrop) so history does not accumulate husks.
|
|
97
|
+
*/
|
|
98
|
+
useOverlayHistory(open, onClose);
|
|
99
|
+
// The visible region, while the sheet is up — see the `style` below for why a
|
|
100
|
+
// full-screen sheet cannot simply be `inset-0` on a phone (live #328).
|
|
101
|
+
const vv = useVisualViewport(open);
|
|
102
|
+
// A fallback ref so a caller that does not need the handle still gets the opening
|
|
103
|
+
// focus, rather than the behaviour depending on whether a prop was passed. What that
|
|
104
|
+
// focus is FOR is on the trap below.
|
|
105
|
+
const ownRef = useRef<HTMLInputElement | null>(null);
|
|
106
|
+
const searchRef = inputRef ?? ownRef;
|
|
107
|
+
|
|
108
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
/**
|
|
110
|
+
* The sheet said `aria-modal="true"` and then left focus on the field behind it, so
|
|
111
|
+
* a screen reader was told to hide the page the user's cursor was still standing in
|
|
112
|
+
* (the audit's *"Only Modal manages focus"*). Through the shared hook rather than
|
|
113
|
+
* the bare `.focus()` that used to live here: the hook re-reads the tabbable list on
|
|
114
|
+
* every Tab, which this panel needs more than most — its list is filtered as you
|
|
115
|
+
* type, so the rows a trap captured at open time are gone by the second keystroke.
|
|
116
|
+
*
|
|
117
|
+
* **`initialFocus` is the search box**, not the container, and this is the one place
|
|
118
|
+
* in the package that overrides `Modal`'s rule. The sheet exists in order to be typed
|
|
119
|
+
* into (live #212: *"make the text input filter active on select click"*), and the
|
|
120
|
+
* callers cannot do it themselves — `Combobox`'s field runs
|
|
121
|
+
* `setOpen(true); sheetInputRef.current?.focus()` in a single `onFocus`, so the focus
|
|
122
|
+
* call happens while this sheet is still unmounted and the ref is null. Only the
|
|
123
|
+
* sheet knows when its own input exists. Popping the keyboard is the POINT here,
|
|
124
|
+
* where in a form dialog it is the cost.
|
|
125
|
+
*
|
|
126
|
+
* **`restoreFocus` is off**, which is the uncomfortable half. Every caller in this
|
|
127
|
+
* kit opens the sheet from the field's own `onFocus` handler, so handing focus back
|
|
128
|
+
* to that field on close re-opens the sheet the user has just dismissed — a row tap
|
|
129
|
+
* would select a value and immediately put the list back over it. The field that
|
|
130
|
+
* opened it is the only thing that knows whether re-focusing it means "open"; a sheet
|
|
131
|
+
* that cannot tell must not guess. The cost is that focus lands on `<body>` after a
|
|
132
|
+
* dismissal, which on the phone this shape is for costs nothing, and on a pointer
|
|
133
|
+
* device is what happened before this change too.
|
|
134
|
+
*/
|
|
135
|
+
useFocusTrap(sheetRef, {
|
|
136
|
+
active: open,
|
|
137
|
+
restoreFocus: false,
|
|
138
|
+
initialFocus: () => searchRef.current,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
142
|
+
if (e.key !== "Escape") return;
|
|
143
|
+
// The sheet is almost always the INNERMOST overlay — it opens from a field inside a
|
|
144
|
+
// `Modal` or a `FullBleedDialog`, both of which take Escape on their own panel. A
|
|
145
|
+
// React portal's events still travel up the React tree, so without stopping it here
|
|
146
|
+
// one press would dismiss this list and the dialog underneath with it: Keksdose
|
|
147
|
+
// live #309 (*"Mouse Back does not only close the select but also the whole edit or
|
|
148
|
+
// create dialog which is cumbersome"*), which the Back gesture already learned, on
|
|
149
|
+
// the key a desktop user reaches for instead.
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
onClose();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (!open || typeof document === "undefined") return null;
|
|
155
|
+
return createPortal(
|
|
156
|
+
<div
|
|
157
|
+
// Spread FIRST, and every attribute below it deliberately wins: `style`,
|
|
158
|
+
// `onMouseDown` and `onKeyDown` here are not styling choices, they are live
|
|
159
|
+
// #328, dev#477 and live #309 respectively, and a caller who passed one by
|
|
160
|
+
// accident would re-open a bug three apps have already paid for. `style` is
|
|
161
|
+
// MERGED rather than won outright (see below), which is the one case where a
|
|
162
|
+
// caller has something to add.
|
|
163
|
+
{...rest}
|
|
164
|
+
ref={sheetRef}
|
|
165
|
+
role="dialog"
|
|
166
|
+
aria-modal="true"
|
|
167
|
+
// Without it the container cannot take focus, and `useFocusTrap` has nothing to
|
|
168
|
+
// fall back to when the list is empty and the search box is the only thing in
|
|
169
|
+
// here — see the hook's "nothing tabbable" branch.
|
|
170
|
+
tabIndex={-1}
|
|
171
|
+
onKeyDown={handleKeyDown}
|
|
172
|
+
// The caller's own name wins over the heading: a sheet titled "Account" in a
|
|
173
|
+
// form with two of them needs to say WHICH, and `title` is what the user reads
|
|
174
|
+
// rather than what a reader announces.
|
|
175
|
+
aria-label={ariaLabel ?? (typeof title === "string" ? title : undefined)}
|
|
176
|
+
// The sheet is PORTALLED to <body>, so it is not inside the field's own
|
|
177
|
+
// wrapper — and `useDropdown` closes on any document mousedown landing
|
|
178
|
+
// outside that wrapper. Tapping a row therefore unmounted the row (open →
|
|
179
|
+
// false) before its CLICK could fire, so the tap selected nothing at all and
|
|
180
|
+
// the sheet just vanished (Keksdose dev#477).
|
|
181
|
+
//
|
|
182
|
+
// It bit whichever sheet commits on `onClick` — the payee field's, since live
|
|
183
|
+
// #200, where it hid behind the free-text value the search box was already
|
|
184
|
+
// setting; and the account field's the moment dev#477 moved it onto the inline
|
|
185
|
+
// picker. `ComboboxPanel`'s rows happen to commit on `onMouseDown`, so they
|
|
186
|
+
// beat the document listener and were never affected — which is luck, not a
|
|
187
|
+
// design, and exactly why the fix belongs to the sheet.
|
|
188
|
+
//
|
|
189
|
+
// Stopping the mousedown here rather than teaching every caller about the
|
|
190
|
+
// portal: the sheet is modal and full-screen, so "a click in here is not an
|
|
191
|
+
// outside click" is a property of the sheet, not of whoever opened it.
|
|
192
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
193
|
+
// ⚠️ `inset-0` was the whole of Keksdose live #328: *"When the keyboard overlays
|
|
194
|
+
// the entries I cannot scroll past them … to see also the last entries. Happens
|
|
195
|
+
// when browsing the account select."*
|
|
196
|
+
//
|
|
197
|
+
// This sheet FOCUSES ITS OWN SEARCH BOX on open (see above), so the keyboard is
|
|
198
|
+
// up every single time it is used on a phone. On Android that shrinks the visual
|
|
199
|
+
// viewport and leaves the LAYOUT viewport alone — `inset-0` is the layout
|
|
200
|
+
// viewport — so the sheet kept its full height with its bottom third behind the
|
|
201
|
+
// keyboard. The list below is `flex-1 overflow-y-auto`, so it sized itself to
|
|
202
|
+
// that hidden height too: it scrolled to ITS end while the last rows were still
|
|
203
|
+
// under the keys, and no gesture could bring them out. Nothing was clipped and
|
|
204
|
+
// nothing looked broken, which is why it reads as "I cannot scroll past them".
|
|
205
|
+
//
|
|
206
|
+
// `visualViewport` is the API that answers rather than hints (the live #292
|
|
207
|
+
// lesson): it reports the region actually on screen, keyboard and pinch-pan
|
|
208
|
+
// included, and it fires on the keyboard opening and closing. Pinned to that box,
|
|
209
|
+
// the sheet ends where the keys begin and the list's own scroll covers the rest.
|
|
210
|
+
// `useVisualViewport` returns null when there is nothing to correct, and then
|
|
211
|
+
// this is character-for-character the sheet that was here before.
|
|
212
|
+
// A caller's `style` is kept and the correction laid OVER it: the three values
|
|
213
|
+
// below are the whole of live #328, so they are not negotiable, but a z-index
|
|
214
|
+
// or a transition from outside has nothing to do with them. Without a visual
|
|
215
|
+
// viewport to correct for this is character-for-character what the caller passed.
|
|
216
|
+
style={vv ? { ...style, top: vv.top, height: vv.height, bottom: "auto" } : style}
|
|
217
|
+
className={cn("fixed inset-0 z-50 flex flex-col bg-[var(--bg-surface)] outline-none", className)}
|
|
218
|
+
>
|
|
219
|
+
<div className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2">
|
|
220
|
+
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--text-primary)]">
|
|
221
|
+
{title}
|
|
222
|
+
</span>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
aria-label={labels.close}
|
|
226
|
+
onClick={onClose}
|
|
227
|
+
className="rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
|
|
228
|
+
>
|
|
229
|
+
<X className="size-5" />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
<DropdownSearchHeader
|
|
233
|
+
query={query}
|
|
234
|
+
onQueryChange={onQueryChange}
|
|
235
|
+
inputRef={searchRef}
|
|
236
|
+
placeholder={searchPlaceholder}
|
|
237
|
+
/>
|
|
238
|
+
{/* min-h-0 so the LIST scrolls rather than the dialog growing past the
|
|
239
|
+
viewport — a flex child defaults to min-height:auto. */}
|
|
240
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
|
|
241
|
+
</div>,
|
|
242
|
+
document.body,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,
|
|
247
|
+
* so a list is comfortable to hit with a thumb rather than merely legible. */
|
|
248
|
+
export const SHEET_ROW_CLASS = cn(
|
|
249
|
+
"flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base",
|
|
250
|
+
"text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
|
|
251
|
+
);
|