@eifi1/ui-kit 0.11.0 → 0.13.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/README.md +59 -34
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +40 -4
- package/dist/components/amount-input.js +50 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +51 -2
- package/dist/components/button-group.js +5 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +15 -4
- package/dist/components/calendar-heatmap.d.ts +41 -3
- package/dist/components/calendar-heatmap.js +48 -7
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +26 -18
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +35 -3
- package/dist/components/choice-card.js +83 -25
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +18 -5
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +51 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +8 -1
- 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 +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +125 -27
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +40 -20
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +281 -0
- package/dist/components/error-boundary.js +302 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +30 -4
- package/dist/components/field.js +40 -17
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +50 -208
- package/dist/components/file-dropzone.d.ts +49 -2
- package/dist/components/floating-panel.d.ts +13 -4
- package/dist/components/floating-panel.js +7 -3
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +37 -4
- package/dist/components/list.js +35 -14
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +5 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +13 -1
- package/dist/components/mini-calendar.js +46 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +42 -20
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -1
- package/dist/components/nav-pills.js +5 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.d.ts +24 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +25 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +20 -5
- package/dist/components/numpad-sheet.js +3 -2
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +5 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.d.ts +44 -3
- package/dist/components/progress-bar.js +58 -22
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/settings-fields.d.ts +49 -1
- package/dist/components/signed-amount.d.ts +122 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +6 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +39 -5
- package/dist/components/table.js +45 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +238 -0
- package/dist/components/text-link.js +144 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +35 -0
- package/dist/components/toggle-group.js +43 -14
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +53 -3
- package/dist/components/toggle-legend.js +54 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +364 -34
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +21 -5
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +173 -0
- package/dist/feedback/feedback-thread.js +264 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +49 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +15 -4
- package/dist/i18n/defaults.js +31 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +46 -312
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
- package/dist/i18n/locales/de-CH.d.ts +15 -4
- package/dist/i18n/locales/de-informal.d.ts +15 -4
- package/dist/i18n/locales/de.d.ts +15 -4
- package/dist/i18n/locales/de.js +164 -3
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +15 -4
- package/dist/i18n/locales/es.js +161 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +15 -4
- package/dist/i18n/locales/fr.js +161 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +15 -4
- package/dist/i18n/locales/hu.js +161 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +15 -4
- package/dist/i18n/locales/it.js +161 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +15 -4
- package/dist/i18n/locales/zh.js +161 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +39 -19
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
- package/dist/lib/format.d.ts +162 -0
- package/dist/lib/format.js +151 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +283 -0
- package/dist/rhf/fields.js +631 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +49 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf.d.ts +61 -1
- package/dist/rhf.js +2 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +120 -57
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +21 -4
- package/dist/shell/topbar-action-menu.js +18 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +56 -1
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +48 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +24 -1
- package/dist/wizard/wizard-context.js +19 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +30 -5
- package/dist/wizard/wizard-step.js +34 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +6 -6
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/amount-input.tsx +109 -10
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +14 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +33 -9
- package/src/components/combobox.tsx +33 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +230 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +25 -7
- package/src/components/error-boundary.tsx +640 -0
- package/src/components/field-sync.tsx +48 -40
- package/src/components/field.tsx +56 -5
- package/src/components/floating-panel.tsx +20 -7
- package/src/components/form-actions.tsx +191 -0
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +82 -19
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +7 -2
- package/src/components/mini-calendar.tsx +72 -3
- package/src/components/modal.tsx +65 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +27 -7
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +17 -1
- package/src/components/numpad-sheet.tsx +6 -1
- package/src/components/page-header.tsx +42 -5
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +10 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/progress-bar.tsx +129 -19
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/signed-amount.tsx +295 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +11 -3
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +356 -0
- package/src/components/text.tsx +46 -2
- package/src/components/toggle-group.tsx +93 -20
- package/src/components/toggle-legend.tsx +108 -2
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +707 -49
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +49 -7
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +510 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +30 -0
- package/src/i18n/kit-labels.tsx +118 -8
- package/src/i18n/locales/de.ts +161 -0
- package/src/i18n/locales/es.ts +158 -0
- package/src/i18n/locales/fr.ts +161 -0
- package/src/i18n/locales/hu.ts +159 -0
- package/src/i18n/locales/it.ts +159 -0
- package/src/i18n/locales/zh.ts +158 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +348 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1135 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +153 -3
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +32 -4
- package/src/shell.ts +2 -0
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +75 -6
- package/tokens.css +12 -0
|
@@ -1 +1 @@
|
|
|
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 {/* At the logical end, opposite the label; the figures themselves stay\n left-to-right and right-aligned, like the desktop calculator's. */}\n <div className=\"ms-auto min-w-0\">\n <div dir=\"ltr\" className=\"truncate text-right font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div dir=\"ltr\" className=\"h-4 text-right 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":[]}
|
|
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 decimalMark = \".\",\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 /** The glyph on the decimal key — \",\" where the host shows a comma (AmountInput\n * in fr-CH, kastlan 40). The key still inserts a dot; the host's text is dot-form\n * underneath and `sanitizeLive` reads either. */\n decimalMark?: \".\" | \",\";\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 {/* At the logical end, opposite the label; the figures themselves stay\n left-to-right and right-aligned, like the desktop calculator's. */}\n <div className=\"ms-auto min-w-0\">\n <div dir=\"ltr\" className=\"truncate text-right font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div dir=\"ltr\" className=\"h-4 text-right 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.kind === \"ins\" && key.name === \"decimal\" ? decimalMark : 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":[]}
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
|
|
6
|
+
* phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).
|
|
7
|
+
*
|
|
8
|
+
* `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at
|
|
9
|
+
* EVERY width — budgets, rules, accounts, transactions and budget pages all write
|
|
10
|
+
* exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to
|
|
11
|
+
* `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's
|
|
12
|
+
* titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is
|
|
13
|
+
* one: the letters are taken in order, and there is none between `sm` and `md` — an
|
|
14
|
+
* `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is
|
|
15
|
+
* not.
|
|
16
|
+
*/
|
|
17
|
+
type PageHeaderSize = "sm" | "compact" | "md";
|
|
18
|
+
/**
|
|
19
|
+
* How the title block and the actions sit below the `sm` breakpoint. From `sm` up both
|
|
20
|
+
* layouts are one row: title block at the start, actions at the end.
|
|
21
|
+
*/
|
|
22
|
+
type PageHeaderMobileLayout = "stacked" | "inline";
|
|
7
23
|
interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
8
24
|
title: ReactNode;
|
|
9
25
|
/** A sentence under the title. */
|
|
10
26
|
description?: ReactNode;
|
|
11
27
|
/** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
|
|
12
|
-
* wrapping, on a phone
|
|
28
|
+
* wrapping, on a phone — unless {@link mobileLayout} is `inline`. */
|
|
13
29
|
actions?: ReactNode;
|
|
14
30
|
/** A `Breadcrumbs` trail, drawn above everything else. */
|
|
15
31
|
breadcrumbs?: ReactNode;
|
|
@@ -20,6 +36,19 @@ interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title">
|
|
|
20
36
|
* header inside a page that already has its `h1` (a tab's own header). */
|
|
21
37
|
as?: "h1" | "h2" | "h3";
|
|
22
38
|
size?: PageHeaderSize;
|
|
39
|
+
/**
|
|
40
|
+
* `stacked` (default): on a phone the actions drop under the title and wrap, so a
|
|
41
|
+
* row of three buttons never pushes the title off the screen.
|
|
42
|
+
*
|
|
43
|
+
* `inline`: the actions stay beside the title on a phone too, vertically centred on
|
|
44
|
+
* it — keksdose's headers (transactions-page.tsx:515, `flex items-center
|
|
45
|
+
* justify-between gap-2`), which carry one or two icon-sized controls and were tuned
|
|
46
|
+
* for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the
|
|
47
|
+
* row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is
|
|
48
|
+
* for a header whose actions are known to fit beside a phone-width title (an icon
|
|
49
|
+
* button, a month picker); a long title wraps to make room for them instead.
|
|
50
|
+
*/
|
|
51
|
+
mobileLayout?: PageHeaderMobileLayout;
|
|
23
52
|
}
|
|
24
53
|
/**
|
|
25
54
|
* A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
|
|
@@ -32,6 +61,6 @@ interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title">
|
|
|
32
61
|
* than fit a phone's width, instead of running off it. No outer margin: the page's
|
|
33
62
|
* own rhythm (`space-y-*`, a grid gap) places it.
|
|
34
63
|
*/
|
|
35
|
-
declare function PageHeader({ title, description, actions, breadcrumbs, eyebrow, as, size, className, ...rest }: PageHeaderProps): react.JSX.Element;
|
|
64
|
+
declare function PageHeader({ title, description, actions, breadcrumbs, eyebrow, as, size, mobileLayout, className, ...rest }: PageHeaderProps): react.JSX.Element;
|
|
36
65
|
|
|
37
|
-
export { PageHeader, type PageHeaderProps, type PageHeaderSize };
|
|
66
|
+
export { PageHeader, type PageHeaderMobileLayout, type PageHeaderProps, type PageHeaderSize };
|
|
@@ -4,8 +4,13 @@ import { cn } from "../lib/cn.js";
|
|
|
4
4
|
import { SECTION_LABEL_CLASS } from "./text.js";
|
|
5
5
|
const TITLE = {
|
|
6
6
|
sm: "text-lg font-semibold",
|
|
7
|
+
compact: "text-xl font-semibold",
|
|
7
8
|
md: "text-xl font-semibold tracking-tight sm:text-2xl"
|
|
8
9
|
};
|
|
10
|
+
const ROW = {
|
|
11
|
+
stacked: "flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4",
|
|
12
|
+
inline: "flex-row items-center justify-between gap-2 sm:gap-4"
|
|
13
|
+
};
|
|
9
14
|
function PageHeader({
|
|
10
15
|
title,
|
|
11
16
|
description,
|
|
@@ -14,13 +19,14 @@ function PageHeader({
|
|
|
14
19
|
eyebrow,
|
|
15
20
|
as = "h1",
|
|
16
21
|
size = "md",
|
|
22
|
+
mobileLayout = "stacked",
|
|
17
23
|
className,
|
|
18
24
|
...rest
|
|
19
25
|
}) {
|
|
20
26
|
const Heading = as;
|
|
21
27
|
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("flex min-w-0 flex-col gap-2", className), children: [
|
|
22
28
|
breadcrumbs,
|
|
23
|
-
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0
|
|
29
|
+
/* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0", ROW[mobileLayout]), children: [
|
|
24
30
|
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
25
31
|
eyebrow != null && /* @__PURE__ */ jsx("p", { className: cn(SECTION_LABEL_CLASS.xs, "mb-1"), children: eyebrow }),
|
|
26
32
|
/* @__PURE__ */ jsx(Heading, { className: cn("break-words text-[var(--text-primary)]", TITLE[size]), children: title }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/**\n * `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx).\n *\n * `compact` (0.13) is keksdose's page title (keksdose F4): `text-xl font-semibold` at\n * EVERY width — budgets, rules, accounts, transactions and budget pages all write\n * exactly that `<h1>` by hand. It is `md`'s phone size without `md`'s step up to\n * `text-2xl` on a wider screen, and without its `tracking-tight`, which keksdose's\n * titles never had. A word and not a letter for the reason `ProgressBar`'s `slim` is\n * one: the letters are taken in order, and there is none between `sm` and `md` — an\n * `lg` or `xl` at `text-xl` would read as LARGER than `md`, which from `sm` up it is\n * not.\n */\nexport type PageHeaderSize = \"sm\" | \"compact\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n compact: \"text-xl font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\n/**\n * How the title block and the actions sit below the `sm` breakpoint. From `sm` up both\n * layouts are one row: title block at the start, actions at the end.\n */\nexport type PageHeaderMobileLayout = \"stacked\" | \"inline\";\n\nconst ROW: Record<PageHeaderMobileLayout, string> = {\n stacked: \"flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\",\n inline: \"flex-row items-center justify-between gap-2 sm:gap-4\",\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone — unless {@link mobileLayout} is `inline`. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n /**\n * `stacked` (default): on a phone the actions drop under the title and wrap, so a\n * row of three buttons never pushes the title off the screen.\n *\n * `inline`: the actions stay beside the title on a phone too, vertically centred on\n * it — keksdose's headers (transactions-page.tsx:515, `flex items-center\n * justify-between gap-2`), which carry one or two icon-sized controls and were tuned\n * for the phone in live #263. Adopting `PageHeader` must not undo that tuning, so the\n * row stays a row (keksdose F4). The actions do NOT wrap or shrink here: `inline` is\n * for a header whose actions are known to fit beside a phone-width title (an icon\n * button, a month picker); a long title wraps to make room for them instead.\n */\n mobileLayout?: PageHeaderMobileLayout;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n mobileLayout = \"stacked\",\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n <div className={cn(\"flex min-w-0\", ROW[mobileLayout])}>\n <div className=\"min-w-0 flex-1\">\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading className={cn(\"break-words text-[var(--text-primary)]\", TITLE[size])}>{title}</Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{actions}</div>}\n </div>\n </div>\n );\n}\n"],"mappings":";AA+FQ,SACsB,KADtB;AA9FR,SAAS,UAAU;AACnB,SAAS,2BAA2B;AAiBpC,MAAM,QAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,IAAI;AACN;AAQA,MAAM,MAA8C;AAAA,EAClD,SAAS;AAAA,EACT,QAAQ;AACV;AA4CO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL,OAAO;AAAA,EACP,eAAe;AAAA,EACf;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,UAAU;AAChB,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IACD,qBAAC,SAAI,WAAW,GAAG,gBAAgB,IAAI,YAAY,CAAC,GAClD;AAAA,2BAAC,SAAI,WAAU,kBACZ;AAAA,mBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,QAC/E,oBAAC,WAAQ,WAAW,GAAG,0CAA0C,MAAM,IAAI,CAAC,GAAI,iBAAM;AAAA,QACrF,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,SAC5F;AAAA,MACC,WAAW,QAAQ,oBAAC,SAAI,WAAU,8CAA8C,mBAAQ;AAAA,OAC3F;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { PasskeysSettingLabels } from './account-settings-labels.js';
|
|
4
|
+
|
|
5
|
+
/** One registered passkey, as the app's API lists it. */
|
|
6
|
+
interface PasskeyItem {
|
|
7
|
+
id: string | number;
|
|
8
|
+
name: string;
|
|
9
|
+
/** A `Date`, an ISO string or epoch ms. */
|
|
10
|
+
createdAt?: Date | string | number | null;
|
|
11
|
+
/** Null (or left out) reads as "Never used". */
|
|
12
|
+
lastUsedAt?: Date | string | number | null;
|
|
13
|
+
}
|
|
14
|
+
interface PasskeysSettingProps {
|
|
15
|
+
/** `undefined` while the first load is in flight (or pass `loading`). */
|
|
16
|
+
passkeys: readonly PasskeyItem[] | undefined;
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Run the WebAuthn ceremony and register the passkey — app-side, since the options
|
|
20
|
+
* and the attestation are the app's server's business. Called with the trimmed name
|
|
21
|
+
* field, which may be EMPTY: pick your own default ("Passkey", the device name).
|
|
22
|
+
* Return the promise and the field clears when it resolves; a rejection (including
|
|
23
|
+
* the user cancelling the OS prompt) keeps what was typed.
|
|
24
|
+
*/
|
|
25
|
+
onAdd: (name: string) => void | Promise<unknown>;
|
|
26
|
+
/** The ceremony is running. The add button says so and does not start a second one. */
|
|
27
|
+
adding?: boolean;
|
|
28
|
+
/** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
|
|
29
|
+
onRename?: (id: PasskeyItem["id"], name: string) => void | Promise<unknown>;
|
|
30
|
+
/** Offer "Delete" on each row — always behind an inline confirmation. */
|
|
31
|
+
onDelete?: (id: PasskeyItem["id"]) => void | Promise<unknown>;
|
|
32
|
+
/** A row whose rename or delete is in flight; its buttons are disabled. */
|
|
33
|
+
busyId?: PasskeyItem["id"] | null;
|
|
34
|
+
/** Show the name field above the add button. Default true. */
|
|
35
|
+
nameField?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Shown INSTEAD of the list and the add button — passkeys are not supported by this
|
|
38
|
+
* browser, or the page is not a secure context. Its wording is the app's: only the
|
|
39
|
+
* app knows which of those it detected.
|
|
40
|
+
*/
|
|
41
|
+
unavailable?: ReactNode;
|
|
42
|
+
/** A date for the "Added …" / "Last used …" lines. Default: the locale's medium date. */
|
|
43
|
+
formatDate?: (date: Date) => string;
|
|
44
|
+
/** Overrides the provider's locale for the default date format. */
|
|
45
|
+
locale?: string;
|
|
46
|
+
/** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */
|
|
47
|
+
labels?: Partial<PasskeysSettingLabels>;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The passkeys section of an account page: what is registered, when it was last used,
|
|
52
|
+
* rename and delete, and "Add passkey" — the fourth card beside `ProfileSetting`,
|
|
53
|
+
* `PasswordSetting` and `TwoFactorSetting`, and built the same way (a self-contained
|
|
54
|
+
* `<Card>` owning its transient form state; the app owns the data and the API).
|
|
55
|
+
*
|
|
56
|
+
* Lifted from kastlan's passkeys-card. **The WebAuthn ceremony stays app-side**:
|
|
57
|
+
* `navigator.credentials.create` needs options minted by the app's server and hands
|
|
58
|
+
* back an attestation only that server can verify, so the kit calls `onAdd` and waits.
|
|
59
|
+
*
|
|
60
|
+
* **Delete confirms inline**, in the row, rather than through `useConfirm`: this card
|
|
61
|
+
* must work in an app without a `ConfirmProvider`, and the question belongs next to
|
|
62
|
+
* the passkey it names. Focus moves to Cancel, as in every destructive confirm in the
|
|
63
|
+
* kit, so the Enter that pressed Delete cannot also answer it.
|
|
64
|
+
*/
|
|
65
|
+
declare function PasskeysSetting({ passkeys, loading, onAdd, adding, onRename, onDelete, busyId, nameField, unavailable, formatDate, locale: localeProp, labels: labelsProp, className, }: PasskeysSettingProps): react.JSX.Element;
|
|
66
|
+
|
|
67
|
+
export { type PasskeyItem, PasskeysSetting, type PasskeysSettingProps };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { KeyRound } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useKitLocale } from "../i18n/kit-labels.js";
|
|
7
|
+
import { Button, Card, EmptyState, Input } from "./ui.js";
|
|
8
|
+
import { Skeleton } from "./skeleton.js";
|
|
9
|
+
import { useAccountSettingsLabels } from "./account-settings-labels.js";
|
|
10
|
+
function PasskeysSetting({
|
|
11
|
+
passkeys,
|
|
12
|
+
loading,
|
|
13
|
+
onAdd,
|
|
14
|
+
adding = false,
|
|
15
|
+
onRename,
|
|
16
|
+
onDelete,
|
|
17
|
+
busyId,
|
|
18
|
+
nameField = true,
|
|
19
|
+
unavailable,
|
|
20
|
+
formatDate,
|
|
21
|
+
locale: localeProp,
|
|
22
|
+
labels: labelsProp,
|
|
23
|
+
className
|
|
24
|
+
}) {
|
|
25
|
+
const labels = useAccountSettingsLabels("passkeys", labelsProp);
|
|
26
|
+
const locale = useKitLocale(localeProp);
|
|
27
|
+
const [name, setName] = useState("");
|
|
28
|
+
const date = (value) => {
|
|
29
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
30
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
32
|
+
return formatDate ? formatDate(d) : new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d);
|
|
33
|
+
};
|
|
34
|
+
const add = () => {
|
|
35
|
+
if (adding) return;
|
|
36
|
+
const typed = name;
|
|
37
|
+
Promise.resolve(onAdd(typed.trim())).then(() => setName((current) => current === typed ? "" : current)).catch(() => {
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const isLoading = loading || passkeys === void 0;
|
|
41
|
+
const items = passkeys ?? [];
|
|
42
|
+
const body = (() => {
|
|
43
|
+
if (unavailable != null && unavailable !== false) {
|
|
44
|
+
return /* @__PURE__ */ jsx(EmptyState, { variant: "inline", size: "sm", icon: /* @__PURE__ */ jsx(KeyRound, {}), title: unavailable });
|
|
45
|
+
}
|
|
46
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
47
|
+
isLoading ? /* @__PURE__ */ jsx(Skeleton, { lines: 2, label: labels.loading }) : items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "inline", size: "sm", icon: /* @__PURE__ */ jsx(KeyRound, {}), title: labels.empty }) : /* @__PURE__ */ jsx("ul", { "aria-label": labels.list, className: "divide-y divide-[var(--border)]", children: items.map((item) => /* @__PURE__ */ jsx(
|
|
48
|
+
PasskeyRow,
|
|
49
|
+
{
|
|
50
|
+
item,
|
|
51
|
+
created: date(item.createdAt),
|
|
52
|
+
lastUsed: date(item.lastUsedAt),
|
|
53
|
+
busy: busyId !== void 0 && busyId !== null && busyId === item.id,
|
|
54
|
+
onRename,
|
|
55
|
+
onDelete,
|
|
56
|
+
labels
|
|
57
|
+
},
|
|
58
|
+
item.id
|
|
59
|
+
)) }),
|
|
60
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-2 pt-1", children: [
|
|
61
|
+
nameField && /* @__PURE__ */ jsx(
|
|
62
|
+
Input,
|
|
63
|
+
{
|
|
64
|
+
label: labels.nameLabel,
|
|
65
|
+
placeholder: labels.namePlaceholder,
|
|
66
|
+
value: name,
|
|
67
|
+
maxLength: 120,
|
|
68
|
+
onChange: (e) => setName(e.target.value),
|
|
69
|
+
onKeyDown: (e) => {
|
|
70
|
+
if (e.key === "Enter") {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
add();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
),
|
|
77
|
+
/* @__PURE__ */ jsxs(Button, { onClick: add, disabled: adding, "aria-busy": adding || void 0, children: [
|
|
78
|
+
/* @__PURE__ */ jsx(KeyRound, { className: "size-4", "aria-hidden": true }),
|
|
79
|
+
adding ? labels.adding : labels.add
|
|
80
|
+
] })
|
|
81
|
+
] })
|
|
82
|
+
] });
|
|
83
|
+
})();
|
|
84
|
+
return /* @__PURE__ */ jsxs(Card, { className: cn("p-4 space-y-3", className), children: [
|
|
85
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
86
|
+
/* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: labels.title }),
|
|
87
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: labels.description })
|
|
88
|
+
] }),
|
|
89
|
+
body
|
|
90
|
+
] });
|
|
91
|
+
}
|
|
92
|
+
function PasskeyRow({
|
|
93
|
+
item,
|
|
94
|
+
created,
|
|
95
|
+
lastUsed,
|
|
96
|
+
busy,
|
|
97
|
+
onRename,
|
|
98
|
+
onDelete,
|
|
99
|
+
labels
|
|
100
|
+
}) {
|
|
101
|
+
const [mode, setMode] = useState("view");
|
|
102
|
+
const [draft, setDraft] = useState(item.name);
|
|
103
|
+
const [returnTo, setReturnTo] = useState(null);
|
|
104
|
+
const cancelDeleteRef = useRef(null);
|
|
105
|
+
const renameRef = useRef(null);
|
|
106
|
+
const renameButtonRef = useRef(null);
|
|
107
|
+
const deleteButtonRef = useRef(null);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (mode === "delete") cancelDeleteRef.current?.focus();
|
|
110
|
+
else if (mode === "rename") renameRef.current?.select();
|
|
111
|
+
else if (returnTo) (returnTo === "rename" ? renameButtonRef : deleteButtonRef).current?.focus();
|
|
112
|
+
}, [mode, returnTo]);
|
|
113
|
+
const open = (next) => {
|
|
114
|
+
setReturnTo(next);
|
|
115
|
+
setDraft(item.name);
|
|
116
|
+
setMode(next);
|
|
117
|
+
};
|
|
118
|
+
const leave = () => setMode("view");
|
|
119
|
+
const escapeLeaves = (e) => {
|
|
120
|
+
if (e.key === "Escape") {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
leave();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const saveRename = () => {
|
|
127
|
+
const next = draft.trim();
|
|
128
|
+
if (!onRename || !next || busy) return;
|
|
129
|
+
if (next === item.name) {
|
|
130
|
+
leave();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
Promise.resolve(onRename(item.id, next)).then(leave).catch(() => {
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const confirmDelete = () => {
|
|
137
|
+
if (!onDelete || busy) return;
|
|
138
|
+
Promise.resolve(onDelete(item.id)).then(() => setMode("view")).catch(() => {
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed].filter(Boolean).join(" \xB7 ");
|
|
142
|
+
if (mode === "rename") {
|
|
143
|
+
return /* @__PURE__ */ jsxs("li", { className: "flex flex-wrap items-center gap-2 py-2", children: [
|
|
144
|
+
/* @__PURE__ */ jsx(
|
|
145
|
+
Input,
|
|
146
|
+
{
|
|
147
|
+
ref: renameRef,
|
|
148
|
+
"aria-label": labels.renameField(item.name),
|
|
149
|
+
className: "min-w-0 flex-1",
|
|
150
|
+
value: draft,
|
|
151
|
+
maxLength: 120,
|
|
152
|
+
onChange: (e) => setDraft(e.target.value),
|
|
153
|
+
onKeyDown: (e) => {
|
|
154
|
+
if (e.key === "Enter") {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
saveRename();
|
|
157
|
+
} else if (e.key === "Escape") {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
leave();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
),
|
|
165
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", onClick: saveRename, disabled: busy || !draft.trim(), children: labels.save }),
|
|
166
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "ghost", onClick: leave, children: labels.cancel })
|
|
167
|
+
] });
|
|
168
|
+
}
|
|
169
|
+
if (mode === "delete") {
|
|
170
|
+
return /* @__PURE__ */ jsxs("li", { className: "space-y-2 py-2", children: [
|
|
171
|
+
/* @__PURE__ */ jsx("p", { role: "alert", className: "text-sm", children: labels.deleteConfirm(item.name) }),
|
|
172
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
173
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "danger", onClick: confirmDelete, onKeyDown: escapeLeaves, disabled: busy, children: labels.confirmDelete }),
|
|
174
|
+
/* @__PURE__ */ jsx(Button, { ref: cancelDeleteRef, size: "sm", variant: "ghost", onClick: leave, onKeyDown: escapeLeaves, children: labels.cancel })
|
|
175
|
+
] })
|
|
176
|
+
] });
|
|
177
|
+
}
|
|
178
|
+
return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 py-2", children: [
|
|
179
|
+
/* @__PURE__ */ jsx(KeyRound, { className: "size-4 shrink-0 text-[var(--text-muted)]", "aria-hidden": true }),
|
|
180
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
181
|
+
/* @__PURE__ */ jsx("div", { className: "truncate text-sm font-medium", children: item.name }),
|
|
182
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: meta })
|
|
183
|
+
] }),
|
|
184
|
+
onRename && /* @__PURE__ */ jsx(
|
|
185
|
+
Button,
|
|
186
|
+
{
|
|
187
|
+
ref: renameButtonRef,
|
|
188
|
+
size: "sm",
|
|
189
|
+
variant: "ghost",
|
|
190
|
+
disabled: busy,
|
|
191
|
+
onClick: () => open("rename"),
|
|
192
|
+
"aria-label": labels.renameItem(item.name),
|
|
193
|
+
children: labels.rename
|
|
194
|
+
}
|
|
195
|
+
),
|
|
196
|
+
onDelete && /* @__PURE__ */ jsx(
|
|
197
|
+
Button,
|
|
198
|
+
{
|
|
199
|
+
ref: deleteButtonRef,
|
|
200
|
+
size: "sm",
|
|
201
|
+
variant: "ghost",
|
|
202
|
+
tone: "danger",
|
|
203
|
+
disabled: busy,
|
|
204
|
+
onClick: () => open("delete"),
|
|
205
|
+
"aria-label": labels.deleteItem(item.name),
|
|
206
|
+
children: labels.delete
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
] });
|
|
210
|
+
}
|
|
211
|
+
export {
|
|
212
|
+
PasskeysSetting
|
|
213
|
+
};
|
|
214
|
+
//# sourceMappingURL=passkeys-setting.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/passkeys-setting.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { KeyRound } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { Button, Card, EmptyState, Input } from \"./ui\";\nimport { Skeleton } from \"./skeleton\";\nimport { useAccountSettingsLabels } from \"./account-settings-labels\";\nimport type { PasskeysSettingLabels } from \"./account-settings-labels\";\n\n/** One registered passkey, as the app's API lists it. */\nexport interface PasskeyItem {\n id: string | number;\n name: string;\n /** A `Date`, an ISO string or epoch ms. */\n createdAt?: Date | string | number | null;\n /** Null (or left out) reads as \"Never used\". */\n lastUsedAt?: Date | string | number | null;\n}\n\nexport interface PasskeysSettingProps {\n /** `undefined` while the first load is in flight (or pass `loading`). */\n passkeys: readonly PasskeyItem[] | undefined;\n loading?: boolean;\n /**\n * Run the WebAuthn ceremony and register the passkey — app-side, since the options\n * and the attestation are the app's server's business. Called with the trimmed name\n * field, which may be EMPTY: pick your own default (\"Passkey\", the device name).\n * Return the promise and the field clears when it resolves; a rejection (including\n * the user cancelling the OS prompt) keeps what was typed.\n */\n onAdd: (name: string) => void | Promise<unknown>;\n /** The ceremony is running. The add button says so and does not start a second one. */\n adding?: boolean;\n /** Offer \"Rename\" on each row. Return a promise to leave edit mode only on success. */\n onRename?: (id: PasskeyItem[\"id\"], name: string) => void | Promise<unknown>;\n /** Offer \"Delete\" on each row — always behind an inline confirmation. */\n onDelete?: (id: PasskeyItem[\"id\"]) => void | Promise<unknown>;\n /** A row whose rename or delete is in flight; its buttons are disabled. */\n busyId?: PasskeyItem[\"id\"] | null;\n /** Show the name field above the add button. Default true. */\n nameField?: boolean;\n /**\n * Shown INSTEAD of the list and the add button — passkeys are not supported by this\n * browser, or the page is not a secure context. Its wording is the app's: only the\n * app knows which of those it detected.\n */\n unavailable?: ReactNode;\n /** A date for the \"Added …\" / \"Last used …\" lines. Default: the locale's medium date. */\n formatDate?: (date: Date) => string;\n /** Overrides the provider's locale for the default date format. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */\n labels?: Partial<PasskeysSettingLabels>;\n className?: string;\n}\n\n/**\n * The passkeys section of an account page: what is registered, when it was last used,\n * rename and delete, and \"Add passkey\" — the fourth card beside `ProfileSetting`,\n * `PasswordSetting` and `TwoFactorSetting`, and built the same way (a self-contained\n * `<Card>` owning its transient form state; the app owns the data and the API).\n *\n * Lifted from kastlan's passkeys-card. **The WebAuthn ceremony stays app-side**:\n * `navigator.credentials.create` needs options minted by the app's server and hands\n * back an attestation only that server can verify, so the kit calls `onAdd` and waits.\n *\n * **Delete confirms inline**, in the row, rather than through `useConfirm`: this card\n * must work in an app without a `ConfirmProvider`, and the question belongs next to\n * the passkey it names. Focus moves to Cancel, as in every destructive confirm in the\n * kit, so the Enter that pressed Delete cannot also answer it.\n */\nexport function PasskeysSetting({\n passkeys,\n loading,\n onAdd,\n adding = false,\n onRename,\n onDelete,\n busyId,\n nameField = true,\n unavailable,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: PasskeysSettingProps) {\n const labels = useAccountSettingsLabels(\"passkeys\", labelsProp);\n const locale = useKitLocale(localeProp);\n const [name, setName] = useState(\"\");\n\n const date = (value: PasskeyItem[\"createdAt\"]): string | null => {\n if (value === null || value === undefined || value === \"\") return null;\n const d = value instanceof Date ? value : new Date(value);\n if (Number.isNaN(d.getTime())) return null;\n return formatDate ? formatDate(d) : new Intl.DateTimeFormat(locale, { dateStyle: \"medium\" }).format(d);\n };\n\n const add = () => {\n if (adding) return;\n const typed = name;\n Promise.resolve(onAdd(typed.trim()))\n // Clear only what was sent: the field stays editable while the OS prompt is up.\n .then(() => setName((current) => (current === typed ? \"\" : current)))\n .catch(() => {});\n };\n\n const isLoading = loading || passkeys === undefined;\n const items = passkeys ?? [];\n\n const body = (() => {\n if (unavailable != null && unavailable !== false) {\n return <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={unavailable} />;\n }\n return (\n <>\n {isLoading ? (\n <Skeleton lines={2} label={labels.loading} />\n ) : items.length === 0 ? (\n <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={labels.empty} />\n ) : (\n <ul aria-label={labels.list} className=\"divide-y divide-[var(--border)]\">\n {items.map((item) => (\n <PasskeyRow\n key={item.id}\n item={item}\n created={date(item.createdAt)}\n lastUsed={date(item.lastUsedAt)}\n busy={busyId !== undefined && busyId !== null && busyId === item.id}\n onRename={onRename}\n onDelete={onDelete}\n labels={labels}\n />\n ))}\n </ul>\n )}\n <div className=\"space-y-2 pt-1\">\n {nameField && (\n <Input\n label={labels.nameLabel}\n placeholder={labels.namePlaceholder}\n value={name}\n maxLength={120}\n onChange={(e) => setName(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n add();\n }\n }}\n />\n )}\n <Button onClick={add} disabled={adding} aria-busy={adding || undefined}>\n <KeyRound className=\"size-4\" aria-hidden />\n {adding ? labels.adding : labels.add}\n </Button>\n </div>\n </>\n );\n })();\n\n return (\n <Card className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <div className=\"text-sm font-medium\">{labels.title}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {labels.description}\n </div>\n </div>\n {body}\n </Card>\n );\n}\n\ntype RowMode = \"view\" | \"rename\" | \"delete\";\n\nfunction PasskeyRow({\n item,\n created,\n lastUsed,\n busy,\n onRename,\n onDelete,\n labels,\n}: {\n item: PasskeyItem;\n created: string | null;\n lastUsed: string | null;\n busy: boolean;\n onRename?: PasskeysSettingProps[\"onRename\"];\n onDelete?: PasskeysSettingProps[\"onDelete\"];\n labels: PasskeysSettingLabels;\n}) {\n const [mode, setMode] = useState<RowMode>(\"view\");\n const [draft, setDraft] = useState(item.name);\n // Which button opened the sub-mode, so leaving it puts focus back there — the\n // confirm or the field unmounts under the focus, which otherwise falls to <body>.\n const [returnTo, setReturnTo] = useState<\"rename\" | \"delete\" | null>(null);\n const cancelDeleteRef = useRef<HTMLButtonElement>(null);\n const renameRef = useRef<HTMLInputElement>(null);\n const renameButtonRef = useRef<HTMLButtonElement>(null);\n const deleteButtonRef = useRef<HTMLButtonElement>(null);\n\n useEffect(() => {\n if (mode === \"delete\") cancelDeleteRef.current?.focus();\n else if (mode === \"rename\") renameRef.current?.select();\n else if (returnTo) (returnTo === \"rename\" ? renameButtonRef : deleteButtonRef).current?.focus();\n }, [mode, returnTo]);\n\n const open = (next: \"rename\" | \"delete\") => {\n setReturnTo(next);\n setDraft(item.name);\n setMode(next);\n };\n const leave = () => setMode(\"view\");\n const escapeLeaves = (e: KeyboardEvent<HTMLButtonElement>) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n };\n\n const saveRename = () => {\n const next = draft.trim();\n if (!onRename || !next || busy) return;\n if (next === item.name) {\n leave();\n return;\n }\n Promise.resolve(onRename(item.id, next))\n .then(leave)\n .catch(() => {});\n };\n\n const confirmDelete = () => {\n if (!onDelete || busy) return;\n // The row unmounts once the list refreshes; on a failure it stays, confirm open.\n Promise.resolve(onDelete(item.id))\n .then(() => setMode(\"view\"))\n .catch(() => {});\n };\n\n const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed]\n .filter(Boolean)\n .join(\" · \");\n\n if (mode === \"rename\") {\n return (\n <li className=\"flex flex-wrap items-center gap-2 py-2\">\n <Input\n ref={renameRef}\n aria-label={labels.renameField(item.name)}\n className=\"min-w-0 flex-1\"\n value={draft}\n maxLength={120}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n saveRename();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n }}\n />\n <Button size=\"sm\" onClick={saveRename} disabled={busy || !draft.trim()}>\n {labels.save}\n </Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={leave}>\n {labels.cancel}\n </Button>\n </li>\n );\n }\n\n if (mode === \"delete\") {\n return (\n <li className=\"space-y-2 py-2\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.deleteConfirm(item.name)}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button size=\"sm\" variant=\"danger\" onClick={confirmDelete} onKeyDown={escapeLeaves} disabled={busy}>\n {labels.confirmDelete}\n </Button>\n <Button ref={cancelDeleteRef} size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {labels.cancel}\n </Button>\n </div>\n </li>\n );\n }\n\n return (\n <li className=\"flex items-center gap-3 py-2\">\n <KeyRound className=\"size-4 shrink-0 text-[var(--text-muted)]\" aria-hidden />\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm font-medium\">{item.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">{meta}</div>\n </div>\n {onRename && (\n <Button\n ref={renameButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n disabled={busy}\n onClick={() => open(\"rename\")}\n aria-label={labels.renameItem(item.name)}\n >\n {labels.rename}\n </Button>\n )}\n {onDelete && (\n <Button\n ref={deleteButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n tone=\"danger\"\n disabled={busy}\n onClick={() => open(\"delete\")}\n aria-label={labels.deleteItem(item.name)}\n >\n {labels.delete}\n </Button>\n )}\n </li>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -4,6 +4,7 @@ import { useRef } from "react";
|
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { CLIPS_ATTRIBUTE } from "../lib/clipping.js";
|
|
7
8
|
import { DropdownSearchHeader } from "./dropdown.js";
|
|
8
9
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
9
10
|
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
@@ -44,6 +45,9 @@ function PickerSheet({
|
|
|
44
45
|
};
|
|
45
46
|
if (!open || typeof document === "undefined") return null;
|
|
46
47
|
return createPortal(
|
|
48
|
+
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
|
|
49
|
+
container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
|
|
50
|
+
inside it holds focus, and `onMouseDown` only stops propagation (live #328). */
|
|
47
51
|
/* @__PURE__ */ jsxs(
|
|
48
52
|
"div",
|
|
49
53
|
{
|
|
@@ -80,7 +84,7 @@ function PickerSheet({
|
|
|
80
84
|
placeholder: searchPlaceholder
|
|
81
85
|
}
|
|
82
86
|
),
|
|
83
|
-
/* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children })
|
|
87
|
+
/* @__PURE__ */ jsx("div", { ...{ [CLIPS_ATTRIBUTE]: "" }, className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children })
|
|
84
88
|
]
|
|
85
89
|
}
|
|
86
90
|
),
|