@eifi1/ui-kit 0.4.1 → 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 +89 -19
- 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 +74 -19
- package/dist/components/dropdown.js +72 -22
- 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 +21 -7
- package/dist/components/file-dropzone.js +28 -7
- 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 +21 -2
- package/dist/components/number-input.js +2 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +25 -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 +5 -3
- package/dist/components/user-avatar.js +4 -2
- 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 +14 -1
- package/dist/theme/palette-presets.js +231 -3
- 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 +66 -13
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/numpad-sheet.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport { Delete } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { evaluateExpression, formatResult, sanitizeLive } from \"../lib/calc\";\n\n/**\n * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to\n * the desktop {@link CalculatorButton} popover, and the replacement for the old\n * under-the-field operator bar (`MathKeys`, feedback #334).\n *\n * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA\n * cannot — but it CAN suppress the OS keyboard with `inputMode=\"none\"` on the\n * host field and paint its own keypad in the freed space. So the host input keeps\n * real focus/caret/selection + the existing value/commit contract, while this\n * sheet drives it: digits + operators + a live `= …` preview, evaluated through\n * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the\n * native keyboard and the calculator popover); the host gates this to mobile.\n *\n * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —\n * the field text is the single source of truth. `onPointerDown` preventDefault on\n * the root keeps the host input focused (caret stays, keyboard stays hidden) as\n * the user taps keys; the buttons' `onClick` still fires.\n */\n/** Names for the pad itself and its four non-digit keys. The first four are\n * screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —\n * the brand-filled primary button — and it was a hardcoded English literal while\n * its three neighbours each took an override, so a German phone showed \"Done\"\n * among German labels with no prop able to change it. */\nexport interface NumberPadSheetLabels {\n pad?: string;\n backspace?: string;\n clear?: string;\n equals?: string;\n /** Visible text on the primary key, not an `aria-label`. */\n done?: string;\n}\n\ntype PadKey =\n | { kind: \"ins\"; label: string; ins: string; accent?: boolean }\n | { kind: \"back\" };\n\n// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`\n// but strips the unicode minus `−`, so minus inserts an ASCII \"-\" while showing\n// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.\nconst PAD_KEYS: PadKey[] = [\n { kind: \"ins\", label: \"7\", ins: \"7\" },\n { kind: \"ins\", label: \"8\", ins: \"8\" },\n { kind: \"ins\", label: \"9\", ins: \"9\" },\n { kind: \"ins\", label: \"÷\", ins: \"÷\", accent: true },\n { kind: \"ins\", label: \"4\", ins: \"4\" },\n { kind: \"ins\", label: \"5\", ins: \"5\" },\n { kind: \"ins\", label: \"6\", ins: \"6\" },\n { kind: \"ins\", label: \"×\", ins: \"×\", accent: true },\n { kind: \"ins\", label: \"1\", ins: \"1\" },\n { kind: \"ins\", label: \"2\", ins: \"2\" },\n { kind: \"ins\", label: \"3\", ins: \"3\" },\n { kind: \"ins\", label: \"−\", ins: \"-\", accent: true },\n { kind: \"ins\", label: \".\", ins: \".\" },\n { kind: \"ins\", label: \"0\", ins: \"0\" },\n { kind: \"back\" },\n { kind: \"ins\", label: \"+\", ins: \"+\", accent: true },\n];\n\nconst PAD_BTN =\n \"flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none active:scale-[0.97]\";\nconst PAD_DIGIT = \"bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]\";\nconst PAD_ACCENT = \"bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]\";\n\nexport function NumberPadSheet({\n value,\n onChange,\n onDone,\n label,\n labels,\n}: {\n value: string;\n /** Fired with the raw (sanitised) field text on every key — same contract as\n * the host field's own `onChange`. */\n onChange: (value: string) => void;\n /** Fired on \"Done\": the host blurs the input, which commits (evaluates) and\n * unmounts the sheet via its existing blur handler. */\n onDone: () => void;\n /** Optional field label, echoed in the sheet header so the user still knows\n * which field they're editing when the sheet covers it. */\n label?: ReactNode;\n /** Names for the pad itself and its four non-digit keys — screen-reader names,\n * except `done`, which is the visible text on the primary key. The digits and\n * operators need none: their visible glyph IS the name. */\n labels?: NumberPadSheetLabels;\n}) {\n // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live\n // #317: *\"Background not scrollable when the amount input calculator field is\n // open\"*).\n //\n // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and\n // exists only because a PWA cannot swap the system keyboard for its own — so it\n // suppresses the OS keyboard with `inputMode=\"none\"` and paints itself in the\n // freed space. An OS keyboard does not freeze the page behind it; it takes the\n // bottom of the screen and leaves you free to scroll what is left, which is how\n // you reach the field you are typing into when the keys cover it.\n //\n // Locking here made the page unreachable at exactly the moment it matters most:\n // correcting a receipt total against the running line sum, where the figure you\n // are comparing against sits below the keypad. The other four holders of that\n // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable\n // row dialog — and they stay locked, because for them the page behind is not\n // part of the task.\n //\n // The pad still does not scroll ITSELF away under a stray drag: the root's\n // `onPointerDown` preventDefault (below) keeps the host input focused and eats\n // the gesture on the sheet, so a touch that starts on a key is not a page scroll.\n\n const result = evaluateExpression(value);\n const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : \"\";\n\n const insert = (ch: string) => onChange(sanitizeLive(value + ch));\n const backspace = () => onChange(value.slice(0, -1));\n const clearAll = () => onChange(\"\");\n const equals = () => {\n if (result !== null) onChange(formatResult(result));\n };\n\n const sheet = (\n <div\n role=\"group\"\n aria-label={labels?.pad ?? \"Number pad\"}\n // Keep the host input focused when tapping the pad: preventDefault on\n // pointerdown blocks the focus/blur, while the buttons' click still fires.\n onPointerDown={(e) => e.preventDefault()}\n className=\"fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]\"\n style={{ paddingBottom: \"max(0.5rem, env(safe-area-inset-bottom))\" }}\n >\n {/* Header display: which field + the running expression and live result, so\n the value stays visible even when the sheet covers the field. */}\n <div className=\"flex items-end justify-between gap-3 px-2 pb-2\">\n {label != null && (\n <span className=\"truncate pb-1 text-xs font-medium text-[var(--money-neutral)]\">{label}</span>\n )}\n <div className=\"ml-auto min-w-0 text-right\">\n <div className=\"truncate font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div className=\"h-4 font-mono text-xs text-[var(--money-neutral)]\">{preview}</div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-4 gap-1.5\">\n {PAD_KEYS.map((key, i) =>\n key.kind === \"back\" ? (\n <button\n key=\"back\"\n type=\"button\"\n aria-label={labels?.backspace ?? \"Backspace\"}\n onClick={backspace}\n className={cn(PAD_BTN, PAD_ACCENT)}\n >\n <Delete className=\"size-5\" />\n </button>\n ) : (\n <button\n key={`${key.ins}-${i}`}\n type=\"button\"\n aria-label={key.label}\n onClick={() => insert(key.ins)}\n className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}\n >\n {key.label}\n </button>\n ),\n )}\n </div>\n\n <div className=\"mt-1.5 grid grid-cols-4 gap-1.5\">\n <button type=\"button\" aria-label={labels?.clear ?? \"Clear\"} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>\n C\n </button>\n <button type=\"button\" aria-label={labels?.equals ?? \"Equals\"} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>\n =\n </button>\n <button\n type=\"button\"\n onClick={onDone}\n className={cn(PAD_BTN, \"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] active:bg-[var(--brand-hover)]\")}\n >\n {labels?.done ?? \"Done\"}\n </button>\n </div>\n </div>\n );\n\n return createPortal(sheet, document.body);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/numpad-sheet.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport { Delete } from \"lucide-react\";\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { evaluateExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport {\n DEFAULT_CALCULATOR_LABELS,\n useKitLabels,\n type CalculatorLabels,\n} from \"../i18n/kit-labels\";\n\n/**\n * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to\n * the desktop {@link CalculatorButton} popover, and the replacement for the old\n * under-the-field operator bar (`MathKeys`, feedback #334).\n *\n * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA\n * cannot — but it CAN suppress the OS keyboard with `inputMode=\"none\"` on the\n * host field and paint its own keypad in the freed space. So the host input keeps\n * real focus/caret/selection + the existing value/commit contract, while this\n * sheet drives it: digits + operators + a live `= …` preview, evaluated through\n * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the\n * native keyboard and the calculator popover); the host gates this to mobile.\n *\n * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —\n * the field text is the single source of truth. `onPointerDown` preventDefault on\n * the root keeps the host input focused (caret stays, keyboard stays hidden) as\n * the user taps keys; the buttons' `onClick` still fires.\n */\n/** Names for the pad itself and its four non-digit keys. The first four are\n * screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —\n * the brand-filled primary button — and it was a hardcoded English literal while\n * its three neighbours each took an override, so a German phone showed \"Done\"\n * among German labels with no prop able to change it. */\nexport interface NumberPadSheetLabels extends Partial<CalculatorLabels> {\n /** The pad's own name — the `calculator.panel` key under its older name here. */\n pad?: string;\n backspace?: string;\n clear?: string;\n equals?: string;\n /** Visible text on the primary key, not an `aria-label`. */\n done?: string;\n}\n\ntype PadKey =\n | { kind: \"ins\"; label: string; ins: string; accent?: boolean; name?: PadKeyName }\n | { kind: \"back\" };\n\n/** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */\ntype PadKeyName = \"plus\" | \"minus\" | \"times\" | \"divide\" | \"decimal\";\n\n// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`\n// but strips the unicode minus `−`, so minus inserts an ASCII \"-\" while showing\n// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.\nconst PAD_KEYS: PadKey[] = [\n { kind: \"ins\", label: \"7\", ins: \"7\" },\n { kind: \"ins\", label: \"8\", ins: \"8\" },\n { kind: \"ins\", label: \"9\", ins: \"9\" },\n { kind: \"ins\", label: \"÷\", ins: \"÷\", accent: true, name: \"divide\" },\n { kind: \"ins\", label: \"4\", ins: \"4\" },\n { kind: \"ins\", label: \"5\", ins: \"5\" },\n { kind: \"ins\", label: \"6\", ins: \"6\" },\n { kind: \"ins\", label: \"×\", ins: \"×\", accent: true, name: \"times\" },\n { kind: \"ins\", label: \"1\", ins: \"1\" },\n { kind: \"ins\", label: \"2\", ins: \"2\" },\n { kind: \"ins\", label: \"3\", ins: \"3\" },\n { kind: \"ins\", label: \"−\", ins: \"-\", accent: true, name: \"minus\" },\n { kind: \"ins\", label: \".\", ins: \".\", name: \"decimal\" },\n { kind: \"ins\", label: \"0\", ins: \"0\" },\n { kind: \"back\" },\n { kind: \"ins\", label: \"+\", ins: \"+\", accent: true, name: \"plus\" },\n];\n\n/** Every key's shape — including the thing this pad used to delete without replacing.\n *\n * `focus:outline-none` alone is not a style choice, it is the removal of the only\n * signal a keyboard user has; on a 4×4 grid of identical tiles it leaves no way at all\n * to tell which key Enter is about to press (the audit's §a11y, and the same defect as\n * the dropdown search box).\n *\n * `focus-visible` and `ring-inset`, for the two reasons {@link ToggleGroup} already\n * writes down. This is a touch control first, and a plain `focus:` ring paints itself\n * on every TAP, because a tap focuses the button. And the keys sit 6px apart, which is\n * thinner than an outward ring plus the neighbour it would spill onto — inset keeps the\n * ring inside the key it describes.\n *\n * --brand rather than --border-strong: the operator keys are filled with --border, and\n * a ring in the colour of the thing it surrounds is not an indicator. */\nconst PAD_BTN =\n \"flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)] active:scale-[0.97]\";\nconst PAD_DIGIT = \"bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]\";\nconst PAD_ACCENT = \"bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]\";\n\nexport function NumberPadSheet({\n value,\n onChange,\n onDone,\n label,\n labels,\n}: {\n value: string;\n /** Fired with the raw (sanitised) field text on every key — same contract as\n * the host field's own `onChange`. */\n onChange: (value: string) => void;\n /** Fired on \"Done\": the host blurs the input, which commits (evaluates) and\n * unmounts the sheet via its existing blur handler. */\n onDone: () => void;\n /** Optional field label, echoed in the sheet header so the user still knows\n * which field they're editing when the sheet covers it. */\n label?: ReactNode;\n /** Names for the pad itself and its non-digit keys — screen-reader names, except\n * `done`, which is the visible text on the primary key. Merged over `calculator`\n * from `<UiKitProvider labels>`: this is the same keypad as `CalculatorButton`'s,\n * on a phone, and one translation covers both. Digits need no name — their glyph\n * IS the name; the operators do (see {@link CalculatorLabels.plus}). */\n labels?: NumberPadSheetLabels;\n}) {\n // `pad` is this sheet's older spelling of `panel`, so it is folded in as that.\n const fromProps = useMemo(() => {\n if (!labels) return undefined;\n const { pad, ...rest } = labels;\n return pad === undefined ? rest : { ...rest, panel: pad };\n }, [labels]);\n const text = useKitLabels(\"calculator\", DEFAULT_CALCULATOR_LABELS, fromProps);\n\n // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live\n // #317: *\"Background not scrollable when the amount input calculator field is\n // open\"*).\n //\n // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and\n // exists only because a PWA cannot swap the system keyboard for its own — so it\n // suppresses the OS keyboard with `inputMode=\"none\"` and paints itself in the\n // freed space. An OS keyboard does not freeze the page behind it; it takes the\n // bottom of the screen and leaves you free to scroll what is left, which is how\n // you reach the field you are typing into when the keys cover it.\n //\n // Locking here made the page unreachable at exactly the moment it matters most:\n // correcting a receipt total against the running line sum, where the figure you\n // are comparing against sits below the keypad. The other four holders of that\n // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable\n // row dialog — and they stay locked, because for them the page behind is not\n // part of the task.\n //\n // The pad still does not scroll ITSELF away under a stray drag: the root's\n // `onPointerDown` preventDefault (below) keeps the host input focused and eats\n // the gesture on the sheet, so a touch that starts on a key is not a page scroll.\n\n // ── NO focus trap either, and for a harder reason than the scroll lock ────────\n //\n // Wave 3 put `useFocusTrap` on every overlay in this package. This one is the\n // exception, and it is not an oversight to be tidied up later: a trap here would\n // DELETE THE COMPONENT IT WAS PROTECTING. Both hosts render the pad with\n // `showNumpad = isMobile && !disabled && focused`, where `focused` is the host\n // input's own focus state. Moving focus into the pad blurs that input, the host\n // sets `focused` false, and the pad unmounts — on mount, before a single key can be\n // pressed. The blur also COMMITS, so the trap would evaluate a half-typed\n // expression on the way out.\n //\n // That is the contract, not an accident of it. This sheet exists because a PWA\n // cannot swap the system keyboard for its own: the host keeps real focus, caret and\n // selection while `inputMode=\"none\"` suppresses the OS keys, and the root's\n // `onPointerDown` preventDefault below is there for the same reason. The pad is\n // `role=\"group\"`, not a dialog. A keyboard does not trap focus; it is what you type\n // WITH.\n //\n // What was genuinely missing is a way OUT that is not a tap. `useEscapeKey` and not\n // a handler on the sheet, because the sheet never has the focus a keydown would\n // bubble from — the host does, and the host is not this component. Escape does what\n // the Done key does rather than cancelling, because committing on blur is the\n // host's contract and a cancel would have to be the host's to offer.\n //\n // The limit, since it is better written down than discovered: inside a `Modal` the\n // dialog's own Escape handler sits on the focus path and stops the event before a\n // document listener sees it, so there the press closes the dialog — which it did\n // before this line existed too, and which takes the pad with it either way.\n useEscapeKey(onDone);\n\n const result = evaluateExpression(value);\n const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : \"\";\n\n const insert = (ch: string) => onChange(sanitizeLive(value + ch));\n const backspace = () => onChange(value.slice(0, -1));\n const clearAll = () => onChange(\"\");\n const equals = () => {\n if (result !== null) onChange(formatResult(result));\n };\n\n const sheet = (\n <div\n role=\"group\"\n aria-label={text.panel}\n // Keep the host input focused when tapping the pad: preventDefault on\n // pointerdown blocks the focus/blur, while the buttons' click still fires.\n onPointerDown={(e) => e.preventDefault()}\n className=\"fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]\"\n style={{ paddingBottom: \"max(0.5rem, env(safe-area-inset-bottom))\" }}\n >\n {/* Header display: which field + the running expression and live result, so\n the value stays visible even when the sheet covers the field. */}\n <div className=\"flex items-end justify-between gap-3 px-2 pb-2\">\n {label != null && (\n <span className=\"truncate pb-1 text-xs font-medium text-[var(--money-neutral)]\">{label}</span>\n )}\n <div className=\"ml-auto min-w-0 text-right\">\n <div className=\"truncate font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div className=\"h-4 font-mono text-xs text-[var(--money-neutral)]\">{preview}</div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-4 gap-1.5\">\n {PAD_KEYS.map((key, i) =>\n key.kind === \"back\" ? (\n <button\n key=\"back\"\n type=\"button\"\n aria-label={text.backspace}\n onClick={backspace}\n className={cn(PAD_BTN, PAD_ACCENT)}\n >\n <Delete className=\"size-5\" />\n </button>\n ) : (\n <button\n key={`${key.ins}-${i}`}\n type=\"button\"\n aria-label={key.name ? text[key.name] : key.label}\n onClick={() => insert(key.ins)}\n className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}\n >\n {key.label}\n </button>\n ),\n )}\n </div>\n\n <div className=\"mt-1.5 grid grid-cols-4 gap-1.5\">\n <button type=\"button\" aria-label={text.clear} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>\n C\n </button>\n <button type=\"button\" aria-label={text.equals} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>\n =\n </button>\n <button\n type=\"button\"\n onClick={onDone}\n // The one key whose own fill is --brand, so it rings in the colour that fill\n // was chosen to be legible against instead (tailwind-merge keeps the later\n // ring colour). A --brand ring here would be present in the DOM and invisible\n // on screen, which is the defect this change exists to fix.\n className={cn(\n PAD_BTN,\n \"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] focus-visible:ring-[var(--brand-contrast)] active:bg-[var(--brand-hover)]\",\n )}\n >\n {text.done}\n </button>\n </div>\n </div>\n );\n\n return createPortal(sheet, document.body);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "On this page": a page's own table of contents, with the section being read marked.
|
|
6
|
+
*
|
|
7
|
+
* Three parts, because an app needs them separately:
|
|
8
|
+
*
|
|
9
|
+
* - {@link useScrollSpy} — which section is current, from the headings' positions in
|
|
10
|
+
* a scroll container. No markup; usable for anything that follows the reader.
|
|
11
|
+
* - {@link PageContents} — the list. `variant="rail"` for beside the page,
|
|
12
|
+
* `variant="disclosure"` for a collapsed block under the title on narrow screens.
|
|
13
|
+
* - {@link PageContentsLayout} — the page with the rail sticky on one side of it.
|
|
14
|
+
*
|
|
15
|
+
* Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it
|
|
16
|
+
* works with any router, including a hash router where `#id` alone would be read as a
|
|
17
|
+
* route.
|
|
18
|
+
*/
|
|
19
|
+
interface PageContentsItem {
|
|
20
|
+
/** The id of the heading the entry jumps to. */
|
|
21
|
+
id: string;
|
|
22
|
+
label: ReactNode;
|
|
23
|
+
/** 2 indents the entry under the one before it. */
|
|
24
|
+
level?: 1 | 2;
|
|
25
|
+
}
|
|
26
|
+
interface PageContentsLabels {
|
|
27
|
+
/** The heading above the list, and the landmark's accessible name. */
|
|
28
|
+
title: string;
|
|
29
|
+
}
|
|
30
|
+
declare const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels;
|
|
31
|
+
interface UseScrollSpyOptions {
|
|
32
|
+
/** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its
|
|
33
|
+
* `<main>` — it is the page's scroll container from `md` up. */
|
|
34
|
+
root?: Element | null;
|
|
35
|
+
/**
|
|
36
|
+
* How far down the scroller the "reading line" sits, as a fraction of its height.
|
|
37
|
+
* A section becomes current once its heading passes above it, and stays current
|
|
38
|
+
* until the next heading does — so a long section keeps its entry marked while it
|
|
39
|
+
* is being read, not only while its heading is on screen. Default 0.15: close to
|
|
40
|
+
* the top, so a short section followed by another does not hand its mark to the
|
|
41
|
+
* next one the moment it is scrolled to.
|
|
42
|
+
*/
|
|
43
|
+
line?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The id of the section being read: the last of `ids` whose element has scrolled
|
|
47
|
+
* above the reading line — or the first, before any has.
|
|
48
|
+
*/
|
|
49
|
+
declare function useScrollSpy(ids: readonly string[], options?: UseScrollSpyOptions): string | null;
|
|
50
|
+
interface PageContentsProps extends Omit<ComponentPropsWithoutRef<"nav">, "children"> {
|
|
51
|
+
items: PageContentsItem[];
|
|
52
|
+
/** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */
|
|
53
|
+
activeId?: string | null;
|
|
54
|
+
/** The link for an entry. Default `#id`; under a hash router build it with the
|
|
55
|
+
* router (react-router's `useHref({ hash: id })` gives `#/page#id`). */
|
|
56
|
+
hrefFor?: (id: string) => string;
|
|
57
|
+
/** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,
|
|
58
|
+
* for narrow screens where a rail has no room. */
|
|
59
|
+
variant?: "rail" | "disclosure";
|
|
60
|
+
labels?: Partial<PageContentsLabels>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The list itself. The current entry gets a brand bar on the list's rule, the brand
|
|
64
|
+
* colour, and `aria-current="location"` — the marker documentation sites use, and the
|
|
65
|
+
* one a screen reader announces.
|
|
66
|
+
*/
|
|
67
|
+
declare function PageContents({ items, activeId, hrefFor, variant, labels, className, ...rest }: PageContentsProps): react.JSX.Element | null;
|
|
68
|
+
interface PageContentsLayoutProps extends ComponentPropsWithoutRef<"div"> {
|
|
69
|
+
/** What goes in the rail — typically a `<PageContents variant="rail">`. */
|
|
70
|
+
contents: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* Which side of the page the rail sits on: `start` is the left in a left-to-right
|
|
73
|
+
* language (next to the app's own sidebar, so both navigations are in one place),
|
|
74
|
+
* `end` the right (the documentation-site convention, clear of the sidebar). Logical,
|
|
75
|
+
* so both flip in a right-to-left UI.
|
|
76
|
+
*/
|
|
77
|
+
position?: "start" | "end";
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that
|
|
82
|
+
* the rail would squeeze the content, so it is not rendered there — pair it with a
|
|
83
|
+
* `<PageContents variant="disclosure" className="xl:hidden">` in the page.
|
|
84
|
+
*
|
|
85
|
+
* The rail is `sticky top-6`, which sticks inside the nearest scroll container — the
|
|
86
|
+
* viewport, or `AppShell`'s `<main>`.
|
|
87
|
+
*/
|
|
88
|
+
declare function PageContentsLayout({ contents, position, children, className, ...rest }: PageContentsLayoutProps): react.JSX.Element;
|
|
89
|
+
|
|
90
|
+
export { DEFAULT_PAGE_CONTENTS_LABELS, PageContents, type PageContentsItem, type PageContentsLabels, PageContentsLayout, type PageContentsLayoutProps, type PageContentsProps, type UseScrollSpyOptions, useScrollSpy };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
7
|
+
const DEFAULT_PAGE_CONTENTS_LABELS = {
|
|
8
|
+
title: "On this page"
|
|
9
|
+
};
|
|
10
|
+
function useScrollSpy(ids, options = {}) {
|
|
11
|
+
const { root = null, line = 0.15 } = options;
|
|
12
|
+
const [current, setCurrent] = useState(null);
|
|
13
|
+
const key = ids.join("\0");
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (ids.length === 0) return;
|
|
16
|
+
const scroller = root ?? window;
|
|
17
|
+
const measure = () => {
|
|
18
|
+
const top = root ? root.getBoundingClientRect().top : 0;
|
|
19
|
+
const height = root ? root.clientHeight : window.innerHeight;
|
|
20
|
+
const reading = top + height * line;
|
|
21
|
+
let next = ids[0];
|
|
22
|
+
for (const id of ids) {
|
|
23
|
+
const el = document.getElementById(id);
|
|
24
|
+
if (el && el.getBoundingClientRect().top <= reading) next = id;
|
|
25
|
+
}
|
|
26
|
+
const atEnd = root ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
27
|
+
const scrolled = root ? root.scrollTop : window.scrollY;
|
|
28
|
+
if (atEnd && scrolled > 0) next = ids[ids.length - 1];
|
|
29
|
+
setCurrent(next);
|
|
30
|
+
};
|
|
31
|
+
let frame = 0;
|
|
32
|
+
const onScroll = () => {
|
|
33
|
+
cancelAnimationFrame(frame);
|
|
34
|
+
frame = requestAnimationFrame(measure);
|
|
35
|
+
};
|
|
36
|
+
measure();
|
|
37
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
38
|
+
window.addEventListener("resize", onScroll);
|
|
39
|
+
return () => {
|
|
40
|
+
cancelAnimationFrame(frame);
|
|
41
|
+
scroller.removeEventListener("scroll", onScroll);
|
|
42
|
+
window.removeEventListener("resize", onScroll);
|
|
43
|
+
};
|
|
44
|
+
}, [key, root, line]);
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
function PageContents({
|
|
48
|
+
items,
|
|
49
|
+
activeId,
|
|
50
|
+
hrefFor = (id) => `#${id}`,
|
|
51
|
+
variant = "rail",
|
|
52
|
+
labels,
|
|
53
|
+
className,
|
|
54
|
+
...rest
|
|
55
|
+
}) {
|
|
56
|
+
const l = useKitLabels("pageContents", DEFAULT_PAGE_CONTENTS_LABELS, labels);
|
|
57
|
+
const listRef = useRef(null);
|
|
58
|
+
const [clicked, setClicked] = useState(null);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!clicked) return;
|
|
61
|
+
const release = () => setClicked(null);
|
|
62
|
+
window.addEventListener("wheel", release, { passive: true, once: true });
|
|
63
|
+
window.addEventListener("touchmove", release, { passive: true, once: true });
|
|
64
|
+
window.addEventListener("keydown", release, { once: true });
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener("wheel", release);
|
|
67
|
+
window.removeEventListener("touchmove", release);
|
|
68
|
+
window.removeEventListener("keydown", release);
|
|
69
|
+
};
|
|
70
|
+
}, [clicked]);
|
|
71
|
+
const shown = clicked ?? activeId;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!shown || variant !== "rail") return;
|
|
74
|
+
listRef.current?.querySelector(`[data-entry="${CSS.escape(shown)}"]`)?.scrollIntoView?.({ block: "nearest" });
|
|
75
|
+
}, [shown, variant]);
|
|
76
|
+
if (items.length === 0) return null;
|
|
77
|
+
const list = /* @__PURE__ */ jsx(
|
|
78
|
+
"ul",
|
|
79
|
+
{
|
|
80
|
+
ref: listRef,
|
|
81
|
+
className: cn(
|
|
82
|
+
"space-y-0.5 border-s border-[var(--border)]",
|
|
83
|
+
variant === "rail" && "max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]"
|
|
84
|
+
),
|
|
85
|
+
children: items.map((item) => {
|
|
86
|
+
const current = item.id === shown;
|
|
87
|
+
return /* @__PURE__ */ jsx("li", { "data-entry": item.id, children: /* @__PURE__ */ jsx(
|
|
88
|
+
"a",
|
|
89
|
+
{
|
|
90
|
+
href: hrefFor(item.id),
|
|
91
|
+
title: variant === "rail" && typeof item.label === "string" ? item.label : void 0,
|
|
92
|
+
"aria-current": current ? "location" : void 0,
|
|
93
|
+
onClick: () => setClicked(item.id),
|
|
94
|
+
className: cn(
|
|
95
|
+
"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
|
|
96
|
+
variant === "rail" && "truncate",
|
|
97
|
+
item.level === 2 ? "ps-6" : "ps-3",
|
|
98
|
+
current ? "border-[var(--brand)] font-medium text-[var(--brand)]" : "border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
|
|
99
|
+
),
|
|
100
|
+
children: item.label
|
|
101
|
+
}
|
|
102
|
+
) }, item.id);
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
if (variant === "disclosure") {
|
|
107
|
+
return /* @__PURE__ */ jsxs(
|
|
108
|
+
"details",
|
|
109
|
+
{
|
|
110
|
+
className: cn(
|
|
111
|
+
"group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
|
|
112
|
+
className
|
|
113
|
+
),
|
|
114
|
+
children: [
|
|
115
|
+
/* @__PURE__ */ jsxs("summary", { className: "flex cursor-pointer list-none items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-secondary)] [&::-webkit-details-marker]:hidden", children: [
|
|
116
|
+
l.title,
|
|
117
|
+
/* @__PURE__ */ jsx(
|
|
118
|
+
ChevronDown,
|
|
119
|
+
{
|
|
120
|
+
"aria-hidden": true,
|
|
121
|
+
className: "size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none"
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
] }),
|
|
125
|
+
/* @__PURE__ */ jsx("nav", { ...rest, "aria-label": l.title, className: "border-t border-[var(--border)] px-2 py-2", children: list })
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return /* @__PURE__ */ jsxs("nav", { ...rest, "aria-label": l.title, className, children: [
|
|
131
|
+
/* @__PURE__ */ jsx("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]", children: l.title }),
|
|
132
|
+
list
|
|
133
|
+
] });
|
|
134
|
+
}
|
|
135
|
+
function PageContentsLayout({
|
|
136
|
+
contents,
|
|
137
|
+
position = "start",
|
|
138
|
+
children,
|
|
139
|
+
className,
|
|
140
|
+
...rest
|
|
141
|
+
}) {
|
|
142
|
+
const rail = /* @__PURE__ */ jsx("aside", { className: "hidden xl:block", children: /* @__PURE__ */ jsx("div", { className: "sticky top-6", children: contents }) });
|
|
143
|
+
return /* @__PURE__ */ jsxs(
|
|
144
|
+
"div",
|
|
145
|
+
{
|
|
146
|
+
...rest,
|
|
147
|
+
className: cn(
|
|
148
|
+
"xl:grid xl:gap-10",
|
|
149
|
+
// `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.
|
|
150
|
+
// A fixed 13rem broke "Select — unlabelled, labelled, invalid, disabled" over
|
|
151
|
+
// three lines beside a page with room to spare; an index you scan should read
|
|
152
|
+
// one entry per line.
|
|
153
|
+
position === "start" ? "xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]" : "xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]",
|
|
154
|
+
className
|
|
155
|
+
),
|
|
156
|
+
children: [
|
|
157
|
+
position === "start" && rail,
|
|
158
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0", children }),
|
|
159
|
+
position === "end" && rail
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
export {
|
|
165
|
+
DEFAULT_PAGE_CONTENTS_LABELS,
|
|
166
|
+
PageContents,
|
|
167
|
+
PageContentsLayout,
|
|
168
|
+
useScrollSpy
|
|
169
|
+
};
|
|
170
|
+
//# sourceMappingURL=page-contents.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-contents.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * \"On this page\": a page's own table of contents, with the section being read marked.\n *\n * Three parts, because an app needs them separately:\n *\n * - {@link useScrollSpy} — which section is current, from the headings' positions in\n * a scroll container. No markup; usable for anything that follows the reader.\n * - {@link PageContents} — the list. `variant=\"rail\"` for beside the page,\n * `variant=\"disclosure\"` for a collapsed block under the title on narrow screens.\n * - {@link PageContentsLayout} — the page with the rail sticky on one side of it.\n *\n * Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it\n * works with any router, including a hash router where `#id` alone would be read as a\n * route.\n */\n\nexport interface PageContentsItem {\n /** The id of the heading the entry jumps to. */\n id: string;\n label: ReactNode;\n /** 2 indents the entry under the one before it. */\n level?: 1 | 2;\n}\n\nexport interface PageContentsLabels {\n /** The heading above the list, and the landmark's accessible name. */\n title: string;\n}\n\nexport const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels = {\n title: \"On this page\",\n};\n\nexport interface UseScrollSpyOptions {\n /** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its\n * `<main>` — it is the page's scroll container from `md` up. */\n root?: Element | null;\n /**\n * How far down the scroller the \"reading line\" sits, as a fraction of its height.\n * A section becomes current once its heading passes above it, and stays current\n * until the next heading does — so a long section keeps its entry marked while it\n * is being read, not only while its heading is on screen. Default 0.15: close to\n * the top, so a short section followed by another does not hand its mark to the\n * next one the moment it is scrolled to.\n */\n line?: number;\n}\n\n/**\n * The id of the section being read: the last of `ids` whose element has scrolled\n * above the reading line — or the first, before any has.\n */\nexport function useScrollSpy(ids: readonly string[], options: UseScrollSpyOptions = {}): string | null {\n const { root = null, line = 0.15 } = options;\n const [current, setCurrent] = useState<string | null>(null);\n const key = ids.join(\"\\u0000\");\n\n useEffect(() => {\n if (ids.length === 0) return;\n const scroller: Element | Window = root ?? window;\n const measure = () => {\n const top = root ? root.getBoundingClientRect().top : 0;\n const height = root ? root.clientHeight : window.innerHeight;\n const reading = top + height * line;\n let next = ids[0];\n for (const id of ids) {\n const el = document.getElementById(id);\n if (el && el.getBoundingClientRect().top <= reading) next = id;\n }\n // At the very bottom the last sections may never reach the line; the reader\n // who scrolled to the end is reading the end.\n const atEnd = root\n ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;\n const scrolled = root ? root.scrollTop : window.scrollY;\n if (atEnd && scrolled > 0) next = ids[ids.length - 1];\n setCurrent(next);\n };\n let frame = 0;\n const onScroll = () => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(measure);\n };\n measure();\n scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n return () => {\n cancelAnimationFrame(frame);\n scroller.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n };\n // `key` stands for `ids`: a new array with the same entries is the same list.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [key, root, line]);\n\n return current;\n}\n\nexport interface PageContentsProps extends Omit<ComponentPropsWithoutRef<\"nav\">, \"children\"> {\n items: PageContentsItem[];\n /** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */\n activeId?: string | null;\n /** The link for an entry. Default `#id`; under a hash router build it with the\n * router (react-router's `useHref({ hash: id })` gives `#/page#id`). */\n hrefFor?: (id: string) => string;\n /** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,\n * for narrow screens where a rail has no room. */\n variant?: \"rail\" | \"disclosure\";\n labels?: Partial<PageContentsLabels>;\n}\n\n/**\n * The list itself. The current entry gets a brand bar on the list's rule, the brand\n * colour, and `aria-current=\"location\"` — the marker documentation sites use, and the\n * one a screen reader announces.\n */\nexport function PageContents({\n items,\n activeId,\n hrefFor = (id) => `#${id}`,\n variant = \"rail\",\n labels,\n className,\n ...rest\n}: PageContentsProps) {\n const l = useKitLabels(\"pageContents\", DEFAULT_PAGE_CONTENTS_LABELS, labels);\n const listRef = useRef<HTMLUListElement>(null);\n // The entry just clicked is the current one until the reader scrolls on their own.\n // Without this, jumping to a short section near the end of a page marks whichever\n // later section the jump also carried past the reading line — you click one entry\n // and another lights up.\n const [clicked, setClicked] = useState<string | null>(null);\n useEffect(() => {\n if (!clicked) return;\n const release = () => setClicked(null);\n // A user scroll — wheel, touch, keys — releases it; the programmatic scroll the\n // jump itself causes does not fire these.\n window.addEventListener(\"wheel\", release, { passive: true, once: true });\n window.addEventListener(\"touchmove\", release, { passive: true, once: true });\n window.addEventListener(\"keydown\", release, { once: true });\n return () => {\n window.removeEventListener(\"wheel\", release);\n window.removeEventListener(\"touchmove\", release);\n window.removeEventListener(\"keydown\", release);\n };\n }, [clicked]);\n const shown = clicked ?? activeId;\n\n // Keep the current entry in view when the list is taller than the window.\n useEffect(() => {\n if (!shown || variant !== \"rail\") return;\n listRef.current\n ?.querySelector<HTMLElement>(`[data-entry=\"${CSS.escape(shown)}\"]`)\n ?.scrollIntoView?.({ block: \"nearest\" });\n }, [shown, variant]);\n\n if (items.length === 0) return null;\n\n const list = (\n <ul\n ref={listRef}\n className={cn(\n \"space-y-0.5 border-s border-[var(--border)]\",\n variant === \"rail\" && \"max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]\",\n )}\n >\n {items.map((item) => {\n const current = item.id === shown;\n return (\n <li key={item.id} data-entry={item.id}>\n <a\n href={hrefFor(item.id)}\n // One line per entry in the rail; past 22rem the ellipsis takes over and\n // the full text is on hover. The disclosure has the page's width and wraps.\n title={variant === \"rail\" && typeof item.label === \"string\" ? item.label : undefined}\n aria-current={current ? \"location\" : undefined}\n onClick={() => setClicked(item.id)}\n className={cn(\n \"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150\",\n variant === \"rail\" && \"truncate\",\n item.level === 2 ? \"ps-6\" : \"ps-3\",\n current\n ? \"border-[var(--brand)] font-medium text-[var(--brand)]\"\n : \"border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]\",\n )}\n >\n {item.label}\n </a>\n </li>\n );\n })}\n </ul>\n );\n\n if (variant === \"disclosure\") {\n return (\n <details\n className={cn(\n \"group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]\",\n className,\n )}\n >\n <summary className=\"flex cursor-pointer list-none items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-secondary)] [&::-webkit-details-marker]:hidden\">\n {l.title}\n <ChevronDown\n aria-hidden\n className=\"size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none\"\n />\n </summary>\n <nav {...rest} aria-label={l.title} className=\"border-t border-[var(--border)] px-2 py-2\">\n {list}\n </nav>\n </details>\n );\n }\n\n return (\n <nav {...rest} aria-label={l.title} className={className}>\n <p className=\"mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {l.title}\n </p>\n {list}\n </nav>\n );\n}\n\nexport interface PageContentsLayoutProps extends ComponentPropsWithoutRef<\"div\"> {\n /** What goes in the rail — typically a `<PageContents variant=\"rail\">`. */\n contents: ReactNode;\n /**\n * Which side of the page the rail sits on: `start` is the left in a left-to-right\n * language (next to the app's own sidebar, so both navigations are in one place),\n * `end` the right (the documentation-site convention, clear of the sidebar). Logical,\n * so both flip in a right-to-left UI.\n */\n position?: \"start\" | \"end\";\n children: ReactNode;\n}\n\n/**\n * The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that\n * the rail would squeeze the content, so it is not rendered there — pair it with a\n * `<PageContents variant=\"disclosure\" className=\"xl:hidden\">` in the page.\n *\n * The rail is `sticky top-6`, which sticks inside the nearest scroll container — the\n * viewport, or `AppShell`'s `<main>`.\n */\nexport function PageContentsLayout({\n contents,\n position = \"start\",\n children,\n className,\n ...rest\n}: PageContentsLayoutProps) {\n // In DOM order on its side, not moved there with `order`: Tab and a screen reader\n // follow the source, and a rail painted on the left but read after the page would\n // put the page's index at its end.\n const rail = (\n <aside className=\"hidden xl:block\">\n <div className=\"sticky top-6\">{contents}</div>\n </aside>\n );\n return (\n <div\n {...rest}\n className={cn(\n \"xl:grid xl:gap-10\",\n // `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.\n // A fixed 13rem broke \"Select — unlabelled, labelled, invalid, disabled\" over\n // three lines beside a page with room to spare; an index you scan should read\n // one entry per line.\n position === \"start\"\n ? \"xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]\"\n : \"xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]\",\n className,\n )}\n >\n {position === \"start\" && rail}\n <div className=\"min-w-0\">{children}</div>\n {position === \"end\" && rail}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bar, the word and the advisory checklist under a field where someone is
|
|
6
|
+
* CHOOSING a secret.
|
|
7
|
+
*
|
|
8
|
+
* Ported from Keksdose (`features/auth/password-strength-meter.tsx`), where one meter
|
|
9
|
+
* sits under the sign-in password and the encryption passphrase alike — a copy per
|
|
10
|
+
* form is what had let the passphrase dialogs ship with no meter at all. Three things
|
|
11
|
+
* changed on the way in:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Tokens, not palette.** The four fills were red/amber/lime/emerald utilities;
|
|
14
|
+
* they are `--danger` → `--warning` → `--success` now, so a consumer re-pointing
|
|
15
|
+
* those families re-skins the meter with them.
|
|
16
|
+
* 2. **Not colour alone.** The level is always written out beside the bar, and each
|
|
17
|
+
* checklist item says "met" / "not met" to a screen reader — the ✓/○ glyphs are
|
|
18
|
+
* decoration and hidden.
|
|
19
|
+
* 3. **Announced.** A level change is spoken through a polite live region
|
|
20
|
+
* ({@link useAnnounce}); the bar itself is `aria-hidden`, because four coloured
|
|
21
|
+
* strips read out as four empty groups.
|
|
22
|
+
*
|
|
23
|
+
* Advisory only, as in Keksdose: nothing here blocks a submit. The caller decides what
|
|
24
|
+
* is required — {@link passwordRules} marks `length` as the one rule that usually is,
|
|
25
|
+
* so a form can gate on it with the same function the checklist draws from.
|
|
26
|
+
*
|
|
27
|
+
* Dependency-free on purpose. zxcvbn is ~400 kB of dictionaries, and neither consumer
|
|
28
|
+
* ships it; a host that does can pass its scorer as `score` and keep the rest.
|
|
29
|
+
*/
|
|
30
|
+
/** 0 = too short (or empty), 4 = strong. */
|
|
31
|
+
type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4;
|
|
32
|
+
type PasswordRuleId = "length" | "case" | "digit" | "symbol";
|
|
33
|
+
interface PasswordRule {
|
|
34
|
+
id: PasswordRuleId;
|
|
35
|
+
met: boolean;
|
|
36
|
+
/** Only `length` is required; the rest are suggestions. */
|
|
37
|
+
required: boolean;
|
|
38
|
+
}
|
|
39
|
+
interface PasswordScoreOptions {
|
|
40
|
+
/** The length below which a secret is "too short", whatever else it has. Keep it
|
|
41
|
+
* in step with the server's own minimum. Default 8. */
|
|
42
|
+
minLength?: number;
|
|
43
|
+
}
|
|
44
|
+
/** The checklist's rules, evaluated. Pure; the order is the order they are shown in. */
|
|
45
|
+
declare function passwordRules(value: string, { minLength }?: PasswordScoreOptions): PasswordRule[];
|
|
46
|
+
/**
|
|
47
|
+
* A 0–4 strength bucket. Length dominates, because it genuinely dominates real-world
|
|
48
|
+
* guessing cost: one step each at `minLength`, `minLength + 4` and `minLength + 8`,
|
|
49
|
+
* and one more for at least two of the three variety rules.
|
|
50
|
+
*
|
|
51
|
+
* One addition over Keksdose's curve: a secret with fewer than four DISTINCT
|
|
52
|
+
* characters caps at 1. Sixteen `a`s are long, and nothing about them is strong.
|
|
53
|
+
*/
|
|
54
|
+
declare function scorePassword(value: string, options?: PasswordScoreOptions): PasswordStrengthScore;
|
|
55
|
+
/** UTF-8 byte length — what a bcrypt-style ceiling counts. "ü" is two, an emoji four. */
|
|
56
|
+
declare function passwordByteLength(value: string): number;
|
|
57
|
+
/** Every string the meter renders or speaks. */
|
|
58
|
+
interface PasswordStrengthLabels {
|
|
59
|
+
/** The five level names, shown beside the bar. Score 0 … 4. */
|
|
60
|
+
tooShort: string;
|
|
61
|
+
weak: string;
|
|
62
|
+
fair: string;
|
|
63
|
+
good: string;
|
|
64
|
+
strong: string;
|
|
65
|
+
/** What the live region says when the level changes, given the level's name. */
|
|
66
|
+
announcement: (level: string) => string;
|
|
67
|
+
/** The length rule, given the minimum. */
|
|
68
|
+
ruleLength: (minLength: number) => string;
|
|
69
|
+
ruleCase: string;
|
|
70
|
+
ruleDigit: string;
|
|
71
|
+
ruleSymbol: string;
|
|
72
|
+
/** A suggestion rather than a requirement, given the rule's text. */
|
|
73
|
+
optional: (rule: string) => string;
|
|
74
|
+
/** Screen-reader-only state before each checklist item — the ✓/○ are hidden. */
|
|
75
|
+
met: string;
|
|
76
|
+
notMet: string;
|
|
77
|
+
/** Shown when the value is over `maxBytes`, given that ceiling. */
|
|
78
|
+
tooLong: (maxBytes: number) => string;
|
|
79
|
+
}
|
|
80
|
+
declare const DEFAULT_PASSWORD_STRENGTH_LABELS: PasswordStrengthLabels;
|
|
81
|
+
interface PasswordStrengthMeterProps extends ComponentPropsWithoutRef<"div"> {
|
|
82
|
+
/** The secret being chosen. Nothing visible renders while it is empty. */
|
|
83
|
+
value: string;
|
|
84
|
+
/** Replace the built-in scorer (e.g. with zxcvbn's `score`). Must be pure. */
|
|
85
|
+
score?: (value: string) => PasswordStrengthScore;
|
|
86
|
+
/** See {@link PasswordScoreOptions.minLength}. Drives the built-in scorer and the
|
|
87
|
+
* length rule's text. */
|
|
88
|
+
minLength?: number;
|
|
89
|
+
/** Show the checklist under the bar. Default `true`. */
|
|
90
|
+
showRequirements?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* A byte ceiling to warn about, if the secret has one — bcrypt reads at most 72
|
|
93
|
+
* BYTES. Leave it unset for a secret that has none (a passphrase stretched in the
|
|
94
|
+
* browser): inventing one talks people out of the long passphrase they chose.
|
|
95
|
+
*/
|
|
96
|
+
maxBytes?: number;
|
|
97
|
+
/** User-facing strings; see {@link PasswordStrengthLabels}. */
|
|
98
|
+
labels?: Partial<PasswordStrengthLabels>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Render directly under the password field. To have the field itself describe its
|
|
102
|
+
* strength, give this an `id` and point the input's `aria-describedby` at it.
|
|
103
|
+
*/
|
|
104
|
+
declare function PasswordStrengthMeter({ value, score: scoreProp, minLength, showRequirements, maxBytes, labels: labelsProp, className, ...rest }: PasswordStrengthMeterProps): react.JSX.Element;
|
|
105
|
+
|
|
106
|
+
export { DEFAULT_PASSWORD_STRENGTH_LABELS, type PasswordRule, type PasswordRuleId, type PasswordScoreOptions, type PasswordStrengthLabels, PasswordStrengthMeter, type PasswordStrengthMeterProps, type PasswordStrengthScore, passwordByteLength, passwordRules, scorePassword };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useAnnounce } from "../hooks/use-announce.js";
|
|
6
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
7
|
+
const DEFAULT_MIN_LENGTH = 8;
|
|
8
|
+
function passwordRules(value, { minLength = DEFAULT_MIN_LENGTH } = {}) {
|
|
9
|
+
return [
|
|
10
|
+
// Code points, not UTF-16 units: an emoji is one character to the person typing it.
|
|
11
|
+
{ id: "length", met: [...value].length >= minLength, required: true },
|
|
12
|
+
{ id: "case", met: /\p{Ll}/u.test(value) && /\p{Lu}/u.test(value), required: false },
|
|
13
|
+
{ id: "digit", met: /\p{Nd}/u.test(value), required: false },
|
|
14
|
+
{ id: "symbol", met: /[^\p{L}\p{N}\s]/u.test(value), required: false }
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
function scorePassword(value, options = {}) {
|
|
18
|
+
const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
|
|
19
|
+
const chars = [...value];
|
|
20
|
+
if (chars.length === 0 || chars.length < minLength) return 0;
|
|
21
|
+
let score = 1;
|
|
22
|
+
if (chars.length >= minLength + 4) score += 1;
|
|
23
|
+
if (chars.length >= minLength + 8) score += 1;
|
|
24
|
+
const variety = passwordRules(value, { minLength }).filter((r) => !r.required && r.met).length;
|
|
25
|
+
if (variety >= 2) score += 1;
|
|
26
|
+
if (new Set(chars).size < 4) score = 1;
|
|
27
|
+
return Math.min(score, 4);
|
|
28
|
+
}
|
|
29
|
+
function passwordByteLength(value) {
|
|
30
|
+
return new TextEncoder().encode(value).length;
|
|
31
|
+
}
|
|
32
|
+
const DEFAULT_PASSWORD_STRENGTH_LABELS = {
|
|
33
|
+
tooShort: "Too short",
|
|
34
|
+
weak: "Weak",
|
|
35
|
+
fair: "Fair",
|
|
36
|
+
good: "Good",
|
|
37
|
+
strong: "Strong",
|
|
38
|
+
announcement: (level) => `Password strength: ${level}`,
|
|
39
|
+
ruleLength: (minLength) => `At least ${minLength} characters`,
|
|
40
|
+
ruleCase: "Upper and lower case",
|
|
41
|
+
ruleDigit: "A number",
|
|
42
|
+
ruleSymbol: "A symbol",
|
|
43
|
+
optional: (rule) => `${rule} (optional)`,
|
|
44
|
+
met: "Met:",
|
|
45
|
+
notMet: "Not met:",
|
|
46
|
+
tooLong: (maxBytes) => `At most ${maxBytes} characters (accents and emoji count for more than one).`
|
|
47
|
+
};
|
|
48
|
+
const LEVEL_KEYS = ["tooShort", "weak", "fair", "good", "strong"];
|
|
49
|
+
const FILL = {
|
|
50
|
+
0: "",
|
|
51
|
+
1: "bg-[var(--danger)]",
|
|
52
|
+
2: "bg-[var(--warning)]",
|
|
53
|
+
3: "bg-[color-mix(in_oklab,var(--success)_60%,var(--warning))]",
|
|
54
|
+
4: "bg-[var(--success)]"
|
|
55
|
+
};
|
|
56
|
+
function PasswordStrengthMeter({
|
|
57
|
+
value,
|
|
58
|
+
score: scoreProp,
|
|
59
|
+
minLength = DEFAULT_MIN_LENGTH,
|
|
60
|
+
showRequirements = true,
|
|
61
|
+
maxBytes,
|
|
62
|
+
labels: labelsProp,
|
|
63
|
+
className,
|
|
64
|
+
...rest
|
|
65
|
+
}) {
|
|
66
|
+
const labels = useKitLabels("passwordStrength", DEFAULT_PASSWORD_STRENGTH_LABELS, labelsProp);
|
|
67
|
+
const { announce, regionProps } = useAnnounce();
|
|
68
|
+
const empty = value.length === 0;
|
|
69
|
+
const score = empty ? 0 : scoreProp ? scoreProp(value) : scorePassword(value, { minLength });
|
|
70
|
+
const level = labels[LEVEL_KEYS[score]];
|
|
71
|
+
const previous = useRef(score);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (previous.current === score) return;
|
|
74
|
+
previous.current = score;
|
|
75
|
+
if (!empty) announce(labels.announcement(level));
|
|
76
|
+
}, [score, empty, level, labels, announce]);
|
|
77
|
+
const rules = passwordRules(value, { minLength });
|
|
78
|
+
const ruleText = {
|
|
79
|
+
length: labels.ruleLength(minLength),
|
|
80
|
+
case: labels.ruleCase,
|
|
81
|
+
digit: labels.ruleDigit,
|
|
82
|
+
symbol: labels.ruleSymbol
|
|
83
|
+
};
|
|
84
|
+
const tooLong = maxBytes !== void 0 && passwordByteLength(value) > maxBytes;
|
|
85
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("mt-1.5 space-y-1", className), children: [
|
|
86
|
+
/* @__PURE__ */ jsx("div", { ...regionProps }),
|
|
87
|
+
!empty && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
88
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
89
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-1 gap-1", "aria-hidden": "true", "data-score": score, children: [1, 2, 3, 4].map((step) => /* @__PURE__ */ jsx(
|
|
90
|
+
"div",
|
|
91
|
+
{
|
|
92
|
+
className: cn(
|
|
93
|
+
"h-1 flex-1 rounded-full",
|
|
94
|
+
step <= score ? FILL[score] : "bg-[var(--border)]"
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
step
|
|
98
|
+
)) }),
|
|
99
|
+
/* @__PURE__ */ jsx("p", { className: "min-w-[4.5rem] text-end text-xs text-[var(--text-secondary)]", children: level })
|
|
100
|
+
] }),
|
|
101
|
+
showRequirements && /* @__PURE__ */ jsx("ul", { className: "flex flex-wrap gap-x-3 gap-y-0.5 text-xs", children: rules.map((r) => /* @__PURE__ */ jsxs(
|
|
102
|
+
"li",
|
|
103
|
+
{
|
|
104
|
+
className: cn(
|
|
105
|
+
"relative",
|
|
106
|
+
r.met ? "text-[var(--success)]" : "text-[var(--text-muted)]"
|
|
107
|
+
),
|
|
108
|
+
"data-met": r.met,
|
|
109
|
+
children: [
|
|
110
|
+
/* @__PURE__ */ jsxs("span", { className: "sr-only", children: [
|
|
111
|
+
r.met ? labels.met : labels.notMet,
|
|
112
|
+
" "
|
|
113
|
+
] }),
|
|
114
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: r.met ? "\u2713" : "\u25CB" }),
|
|
115
|
+
" ",
|
|
116
|
+
r.required ? ruleText[r.id] : labels.optional(ruleText[r.id])
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
r.id
|
|
120
|
+
)) }),
|
|
121
|
+
tooLong && /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--danger)]", children: labels.tooLong(maxBytes) })
|
|
122
|
+
] })
|
|
123
|
+
] });
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
DEFAULT_PASSWORD_STRENGTH_LABELS,
|
|
127
|
+
PasswordStrengthMeter,
|
|
128
|
+
passwordByteLength,
|
|
129
|
+
passwordRules,
|
|
130
|
+
scorePassword
|
|
131
|
+
};
|
|
132
|
+
//# sourceMappingURL=password-strength.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/password-strength.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * The bar, the word and the advisory checklist under a field where someone is\n * CHOOSING a secret.\n *\n * Ported from Keksdose (`features/auth/password-strength-meter.tsx`), where one meter\n * sits under the sign-in password and the encryption passphrase alike — a copy per\n * form is what had let the passphrase dialogs ship with no meter at all. Three things\n * changed on the way in:\n *\n * 1. **Tokens, not palette.** The four fills were red/amber/lime/emerald utilities;\n * they are `--danger` → `--warning` → `--success` now, so a consumer re-pointing\n * those families re-skins the meter with them.\n * 2. **Not colour alone.** The level is always written out beside the bar, and each\n * checklist item says \"met\" / \"not met\" to a screen reader — the ✓/○ glyphs are\n * decoration and hidden.\n * 3. **Announced.** A level change is spoken through a polite live region\n * ({@link useAnnounce}); the bar itself is `aria-hidden`, because four coloured\n * strips read out as four empty groups.\n *\n * Advisory only, as in Keksdose: nothing here blocks a submit. The caller decides what\n * is required — {@link passwordRules} marks `length` as the one rule that usually is,\n * so a form can gate on it with the same function the checklist draws from.\n *\n * Dependency-free on purpose. zxcvbn is ~400 kB of dictionaries, and neither consumer\n * ships it; a host that does can pass its scorer as `score` and keep the rest.\n */\n\n/* ── The scorer ──────────────────────────────────────────────────────────── */\n\n/** 0 = too short (or empty), 4 = strong. */\nexport type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4;\n\nexport type PasswordRuleId = \"length\" | \"case\" | \"digit\" | \"symbol\";\n\nexport interface PasswordRule {\n id: PasswordRuleId;\n met: boolean;\n /** Only `length` is required; the rest are suggestions. */\n required: boolean;\n}\n\nexport interface PasswordScoreOptions {\n /** The length below which a secret is \"too short\", whatever else it has. Keep it\n * in step with the server's own minimum. Default 8. */\n minLength?: number;\n}\n\nconst DEFAULT_MIN_LENGTH = 8;\n\n/** The checklist's rules, evaluated. Pure; the order is the order they are shown in. */\nexport function passwordRules(value: string, { minLength = DEFAULT_MIN_LENGTH }: PasswordScoreOptions = {}): PasswordRule[] {\n return [\n // Code points, not UTF-16 units: an emoji is one character to the person typing it.\n { id: \"length\", met: [...value].length >= minLength, required: true },\n { id: \"case\", met: /\\p{Ll}/u.test(value) && /\\p{Lu}/u.test(value), required: false },\n { id: \"digit\", met: /\\p{Nd}/u.test(value), required: false },\n { id: \"symbol\", met: /[^\\p{L}\\p{N}\\s]/u.test(value), required: false },\n ];\n}\n\n/**\n * A 0–4 strength bucket. Length dominates, because it genuinely dominates real-world\n * guessing cost: one step each at `minLength`, `minLength + 4` and `minLength + 8`,\n * and one more for at least two of the three variety rules.\n *\n * One addition over Keksdose's curve: a secret with fewer than four DISTINCT\n * characters caps at 1. Sixteen `a`s are long, and nothing about them is strong.\n */\nexport function scorePassword(value: string, options: PasswordScoreOptions = {}): PasswordStrengthScore {\n const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;\n const chars = [...value];\n // Under the minimum is never anything but \"too short\", however varied.\n if (chars.length === 0 || chars.length < minLength) return 0;\n let score = 1;\n if (chars.length >= minLength + 4) score += 1;\n if (chars.length >= minLength + 8) score += 1;\n const variety = passwordRules(value, { minLength }).filter((r) => !r.required && r.met).length;\n if (variety >= 2) score += 1;\n if (new Set(chars).size < 4) score = 1;\n return Math.min(score, 4) as PasswordStrengthScore;\n}\n\n/** UTF-8 byte length — what a bcrypt-style ceiling counts. \"ü\" is two, an emoji four. */\nexport function passwordByteLength(value: string): number {\n return new TextEncoder().encode(value).length;\n}\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the meter renders or speaks. */\nexport interface PasswordStrengthLabels {\n /** The five level names, shown beside the bar. Score 0 … 4. */\n tooShort: string;\n weak: string;\n fair: string;\n good: string;\n strong: string;\n /** What the live region says when the level changes, given the level's name. */\n announcement: (level: string) => string;\n /** The length rule, given the minimum. */\n ruleLength: (minLength: number) => string;\n ruleCase: string;\n ruleDigit: string;\n ruleSymbol: string;\n /** A suggestion rather than a requirement, given the rule's text. */\n optional: (rule: string) => string;\n /** Screen-reader-only state before each checklist item — the ✓/○ are hidden. */\n met: string;\n notMet: string;\n /** Shown when the value is over `maxBytes`, given that ceiling. */\n tooLong: (maxBytes: number) => string;\n}\n\nexport const DEFAULT_PASSWORD_STRENGTH_LABELS: PasswordStrengthLabels = {\n tooShort: \"Too short\",\n weak: \"Weak\",\n fair: \"Fair\",\n good: \"Good\",\n strong: \"Strong\",\n announcement: (level) => `Password strength: ${level}`,\n ruleLength: (minLength) => `At least ${minLength} characters`,\n ruleCase: \"Upper and lower case\",\n ruleDigit: \"A number\",\n ruleSymbol: \"A symbol\",\n optional: (rule) => `${rule} (optional)`,\n met: \"Met:\",\n notMet: \"Not met:\",\n tooLong: (maxBytes) =>\n `At most ${maxBytes} characters (accents and emoji count for more than one).`,\n};\n\nconst LEVEL_KEYS = [\"tooShort\", \"weak\", \"fair\", \"good\", \"strong\"] as const;\n\n/* ── The meter ───────────────────────────────────────────────────────────── */\n\n// The filled segments' colour per score. \"Good\" sits between the warning and the\n// success hue rather than borrowing either, so 3 and 4 differ in colour as well as in\n// segment count. Index 0 is never drawn — a too-short secret fills no segment.\nconst FILL: Record<PasswordStrengthScore, string> = {\n 0: \"\",\n 1: \"bg-[var(--danger)]\",\n 2: \"bg-[var(--warning)]\",\n 3: \"bg-[color-mix(in_oklab,var(--success)_60%,var(--warning))]\",\n 4: \"bg-[var(--success)]\",\n};\n\nexport interface PasswordStrengthMeterProps extends ComponentPropsWithoutRef<\"div\"> {\n /** The secret being chosen. Nothing visible renders while it is empty. */\n value: string;\n /** Replace the built-in scorer (e.g. with zxcvbn's `score`). Must be pure. */\n score?: (value: string) => PasswordStrengthScore;\n /** See {@link PasswordScoreOptions.minLength}. Drives the built-in scorer and the\n * length rule's text. */\n minLength?: number;\n /** Show the checklist under the bar. Default `true`. */\n showRequirements?: boolean;\n /**\n * A byte ceiling to warn about, if the secret has one — bcrypt reads at most 72\n * BYTES. Leave it unset for a secret that has none (a passphrase stretched in the\n * browser): inventing one talks people out of the long passphrase they chose.\n */\n maxBytes?: number;\n /** User-facing strings; see {@link PasswordStrengthLabels}. */\n labels?: Partial<PasswordStrengthLabels>;\n}\n\n/**\n * Render directly under the password field. To have the field itself describe its\n * strength, give this an `id` and point the input's `aria-describedby` at it.\n */\nexport function PasswordStrengthMeter({\n value,\n score: scoreProp,\n minLength = DEFAULT_MIN_LENGTH,\n showRequirements = true,\n maxBytes,\n labels: labelsProp,\n className,\n ...rest\n}: PasswordStrengthMeterProps) {\n // Prop > <UiKitProvider> > English, like every labelled component in the kit.\n const labels = useKitLabels(\"passwordStrength\", DEFAULT_PASSWORD_STRENGTH_LABELS, labelsProp);\n const { announce, regionProps } = useAnnounce();\n\n const empty = value.length === 0;\n const score = empty ? 0 : scoreProp ? scoreProp(value) : scorePassword(value, { minLength });\n const level = labels[LEVEL_KEYS[score]];\n\n // Speak on a CHANGE of level only — not on every keystroke, and not on mount (a\n // prefilled field has not changed anything). Clearing the field says nothing: the\n // user did that on purpose and the meter disappearing is the whole news.\n const previous = useRef(score);\n useEffect(() => {\n if (previous.current === score) return;\n previous.current = score;\n if (!empty) announce(labels.announcement(level));\n }, [score, empty, level, labels, announce]);\n\n const rules = passwordRules(value, { minLength });\n const ruleText: Record<PasswordRuleId, string> = {\n length: labels.ruleLength(minLength),\n case: labels.ruleCase,\n digit: labels.ruleDigit,\n symbol: labels.ruleSymbol,\n };\n const tooLong = maxBytes !== undefined && passwordByteLength(value) > maxBytes;\n\n return (\n <div {...rest} className={cn(\"mt-1.5 space-y-1\", className)}>\n {/* Rendered from the start and never unmounted — see useAnnounce. */}\n <div {...regionProps} />\n {!empty && (\n <>\n <div className=\"flex items-center gap-2\">\n <div className=\"flex flex-1 gap-1\" aria-hidden=\"true\" data-score={score}>\n {[1, 2, 3, 4].map((step) => (\n <div\n key={step}\n className={cn(\n \"h-1 flex-1 rounded-full\",\n step <= score ? FILL[score] : \"bg-[var(--border)]\",\n )}\n />\n ))}\n </div>\n <p className=\"min-w-[4.5rem] text-end text-xs text-[var(--text-secondary)]\">{level}</p>\n </div>\n {showRequirements && (\n <ul className=\"flex flex-wrap gap-x-3 gap-y-0.5 text-xs\">\n {rules.map((r) => (\n <li\n key={r.id}\n // `relative`: the sr-only state needs a local containing block.\n className={cn(\n \"relative\",\n r.met ? \"text-[var(--success)]\" : \"text-[var(--text-muted)]\",\n )}\n data-met={r.met}\n >\n <span className=\"sr-only\">{r.met ? labels.met : labels.notMet} </span>\n <span aria-hidden=\"true\">{r.met ? \"✓\" : \"○\"}</span>{\" \"}\n {r.required ? ruleText[r.id] : labels.optional(ruleText[r.id])}\n </li>\n ))}\n </ul>\n )}\n {tooLong && <p className=\"text-xs text-[var(--danger)]\">{labels.tooLong(maxBytes)}</p>}\n </>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|