@eifi1/ui-kit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createPortal } from "react-dom";
|
|
2
|
+
import { Delete } from "lucide-react";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { useEscapeKey } from "../hooks/use-dismiss";
|
|
7
|
+
import { evaluateExpression, formatResult, sanitizeLive } from "../lib/calc";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_CALCULATOR_LABELS,
|
|
10
|
+
useKitLabels,
|
|
11
|
+
type CalculatorLabels,
|
|
12
|
+
} from "../i18n/kit-labels";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
|
|
16
|
+
* the desktop {@link CalculatorButton} popover, and the replacement for the old
|
|
17
|
+
* under-the-field operator bar (`MathKeys`, feedback #334).
|
|
18
|
+
*
|
|
19
|
+
* A native app (YNAB) can swap the system keyboard for its own keypad; a PWA
|
|
20
|
+
* cannot — but it CAN suppress the OS keyboard with `inputMode="none"` on the
|
|
21
|
+
* host field and paint its own keypad in the freed space. So the host input keeps
|
|
22
|
+
* real focus/caret/selection + the existing value/commit contract, while this
|
|
23
|
+
* sheet drives it: digits + operators + a live `= …` preview, evaluated through
|
|
24
|
+
* the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the
|
|
25
|
+
* native keyboard and the calculator popover); the host gates this to mobile.
|
|
26
|
+
*
|
|
27
|
+
* Buttons commit through the host's `onChange`/blur, so the sheet is stateless —
|
|
28
|
+
* the field text is the single source of truth. `onPointerDown` preventDefault on
|
|
29
|
+
* the root keeps the host input focused (caret stays, keyboard stays hidden) as
|
|
30
|
+
* the user taps keys; the buttons' `onClick` still fires.
|
|
31
|
+
*/
|
|
32
|
+
/** Names for the pad itself and its four non-digit keys. The first four are
|
|
33
|
+
* screen-reader names; `done` is the one string here that is READ OFF THE SCREEN —
|
|
34
|
+
* the brand-filled primary button — and it was a hardcoded English literal while
|
|
35
|
+
* its three neighbours each took an override, so a German phone showed "Done"
|
|
36
|
+
* among German labels with no prop able to change it. */
|
|
37
|
+
export interface NumberPadSheetLabels extends Partial<CalculatorLabels> {
|
|
38
|
+
/** The pad's own name — the `calculator.panel` key under its older name here. */
|
|
39
|
+
pad?: string;
|
|
40
|
+
backspace?: string;
|
|
41
|
+
clear?: string;
|
|
42
|
+
equals?: string;
|
|
43
|
+
/** Visible text on the primary key, not an `aria-label`. */
|
|
44
|
+
done?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type PadKey =
|
|
48
|
+
| { kind: "ins"; label: string; ins: string; accent?: boolean; name?: PadKeyName }
|
|
49
|
+
| { kind: "back" };
|
|
50
|
+
|
|
51
|
+
/** The {@link CalculatorLabels} key naming a non-digit key; digits are their glyph. */
|
|
52
|
+
type PadKeyName = "plus" | "minus" | "times" | "divide" | "decimal";
|
|
53
|
+
|
|
54
|
+
// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`
|
|
55
|
+
// but strips the unicode minus `−`, so minus inserts an ASCII "-" while showing
|
|
56
|
+
// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.
|
|
57
|
+
const PAD_KEYS: PadKey[] = [
|
|
58
|
+
{ kind: "ins", label: "7", ins: "7" },
|
|
59
|
+
{ kind: "ins", label: "8", ins: "8" },
|
|
60
|
+
{ kind: "ins", label: "9", ins: "9" },
|
|
61
|
+
{ kind: "ins", label: "÷", ins: "÷", accent: true, name: "divide" },
|
|
62
|
+
{ kind: "ins", label: "4", ins: "4" },
|
|
63
|
+
{ kind: "ins", label: "5", ins: "5" },
|
|
64
|
+
{ kind: "ins", label: "6", ins: "6" },
|
|
65
|
+
{ kind: "ins", label: "×", ins: "×", accent: true, name: "times" },
|
|
66
|
+
{ kind: "ins", label: "1", ins: "1" },
|
|
67
|
+
{ kind: "ins", label: "2", ins: "2" },
|
|
68
|
+
{ kind: "ins", label: "3", ins: "3" },
|
|
69
|
+
{ kind: "ins", label: "−", ins: "-", accent: true, name: "minus" },
|
|
70
|
+
{ kind: "ins", label: ".", ins: ".", name: "decimal" },
|
|
71
|
+
{ kind: "ins", label: "0", ins: "0" },
|
|
72
|
+
{ kind: "back" },
|
|
73
|
+
{ kind: "ins", label: "+", ins: "+", accent: true, name: "plus" },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Every key's shape — including the thing this pad used to delete without replacing.
|
|
77
|
+
*
|
|
78
|
+
* `focus:outline-none` alone is not a style choice, it is the removal of the only
|
|
79
|
+
* signal a keyboard user has; on a 4×4 grid of identical tiles it leaves no way at all
|
|
80
|
+
* to tell which key Enter is about to press (the audit's §a11y, and the same defect as
|
|
81
|
+
* the dropdown search box).
|
|
82
|
+
*
|
|
83
|
+
* `focus-visible` and `ring-inset`, for the two reasons {@link ToggleGroup} already
|
|
84
|
+
* writes down. This is a touch control first, and a plain `focus:` ring paints itself
|
|
85
|
+
* on every TAP, because a tap focuses the button. And the keys sit 6px apart, which is
|
|
86
|
+
* thinner than an outward ring plus the neighbour it would spill onto — inset keeps the
|
|
87
|
+
* ring inside the key it describes.
|
|
88
|
+
*
|
|
89
|
+
* --brand rather than --border-strong: the operator keys are filled with --border, and
|
|
90
|
+
* a ring in the colour of the thing it surrounds is not an indicator. */
|
|
91
|
+
const PAD_BTN =
|
|
92
|
+
"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]";
|
|
93
|
+
const PAD_DIGIT = "bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]";
|
|
94
|
+
const PAD_ACCENT = "bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]";
|
|
95
|
+
|
|
96
|
+
export function NumberPadSheet({
|
|
97
|
+
value,
|
|
98
|
+
onChange,
|
|
99
|
+
onDone,
|
|
100
|
+
label,
|
|
101
|
+
labels,
|
|
102
|
+
}: {
|
|
103
|
+
value: string;
|
|
104
|
+
/** Fired with the raw (sanitised) field text on every key — same contract as
|
|
105
|
+
* the host field's own `onChange`. */
|
|
106
|
+
onChange: (value: string) => void;
|
|
107
|
+
/** Fired on "Done": the host blurs the input, which commits (evaluates) and
|
|
108
|
+
* unmounts the sheet via its existing blur handler. */
|
|
109
|
+
onDone: () => void;
|
|
110
|
+
/** Optional field label, echoed in the sheet header so the user still knows
|
|
111
|
+
* which field they're editing when the sheet covers it. */
|
|
112
|
+
label?: ReactNode;
|
|
113
|
+
/** Names for the pad itself and its non-digit keys — screen-reader names, except
|
|
114
|
+
* `done`, which is the visible text on the primary key. Merged over `calculator`
|
|
115
|
+
* from `<UiKitProvider labels>`: this is the same keypad as `CalculatorButton`'s,
|
|
116
|
+
* on a phone, and one translation covers both. Digits need no name — their glyph
|
|
117
|
+
* IS the name; the operators do (see {@link CalculatorLabels.plus}). */
|
|
118
|
+
labels?: NumberPadSheetLabels;
|
|
119
|
+
}) {
|
|
120
|
+
// `pad` is this sheet's older spelling of `panel`, so it is folded in as that.
|
|
121
|
+
const fromProps = useMemo(() => {
|
|
122
|
+
if (!labels) return undefined;
|
|
123
|
+
const { pad, ...rest } = labels;
|
|
124
|
+
return pad === undefined ? rest : { ...rest, panel: pad };
|
|
125
|
+
}, [labels]);
|
|
126
|
+
const text = useKitLabels("calculator", DEFAULT_CALCULATOR_LABELS, fromProps);
|
|
127
|
+
|
|
128
|
+
// NO `useBodyScrollLock`, and that is the point of the control (Keksdose live
|
|
129
|
+
// #317: *"Background not scrollable when the amount input calculator field is
|
|
130
|
+
// open"*).
|
|
131
|
+
//
|
|
132
|
+
// This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and
|
|
133
|
+
// exists only because a PWA cannot swap the system keyboard for its own — so it
|
|
134
|
+
// suppresses the OS keyboard with `inputMode="none"` and paints itself in the
|
|
135
|
+
// freed space. An OS keyboard does not freeze the page behind it; it takes the
|
|
136
|
+
// bottom of the screen and leaves you free to scroll what is left, which is how
|
|
137
|
+
// you reach the field you are typing into when the keys cover it.
|
|
138
|
+
//
|
|
139
|
+
// Locking here made the page unreachable at exactly the moment it matters most:
|
|
140
|
+
// correcting a receipt total against the running line sum, where the figure you
|
|
141
|
+
// are comparing against sits below the keypad. The other four holders of that
|
|
142
|
+
// hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable
|
|
143
|
+
// row dialog — and they stay locked, because for them the page behind is not
|
|
144
|
+
// part of the task.
|
|
145
|
+
//
|
|
146
|
+
// The pad still does not scroll ITSELF away under a stray drag: the root's
|
|
147
|
+
// `onPointerDown` preventDefault (below) keeps the host input focused and eats
|
|
148
|
+
// the gesture on the sheet, so a touch that starts on a key is not a page scroll.
|
|
149
|
+
|
|
150
|
+
// ── NO focus trap either, and for a harder reason than the scroll lock ────────
|
|
151
|
+
//
|
|
152
|
+
// Wave 3 put `useFocusTrap` on every overlay in this package. This one is the
|
|
153
|
+
// exception, and it is not an oversight to be tidied up later: a trap here would
|
|
154
|
+
// DELETE THE COMPONENT IT WAS PROTECTING. Both hosts render the pad with
|
|
155
|
+
// `showNumpad = isMobile && !disabled && focused`, where `focused` is the host
|
|
156
|
+
// input's own focus state. Moving focus into the pad blurs that input, the host
|
|
157
|
+
// sets `focused` false, and the pad unmounts — on mount, before a single key can be
|
|
158
|
+
// pressed. The blur also COMMITS, so the trap would evaluate a half-typed
|
|
159
|
+
// expression on the way out.
|
|
160
|
+
//
|
|
161
|
+
// That is the contract, not an accident of it. This sheet exists because a PWA
|
|
162
|
+
// cannot swap the system keyboard for its own: the host keeps real focus, caret and
|
|
163
|
+
// selection while `inputMode="none"` suppresses the OS keys, and the root's
|
|
164
|
+
// `onPointerDown` preventDefault below is there for the same reason. The pad is
|
|
165
|
+
// `role="group"`, not a dialog. A keyboard does not trap focus; it is what you type
|
|
166
|
+
// WITH.
|
|
167
|
+
//
|
|
168
|
+
// What was genuinely missing is a way OUT that is not a tap. `useEscapeKey` and not
|
|
169
|
+
// a handler on the sheet, because the sheet never has the focus a keydown would
|
|
170
|
+
// bubble from — the host does, and the host is not this component. Escape does what
|
|
171
|
+
// the Done key does rather than cancelling, because committing on blur is the
|
|
172
|
+
// host's contract and a cancel would have to be the host's to offer.
|
|
173
|
+
//
|
|
174
|
+
// The limit, since it is better written down than discovered: inside a `Modal` the
|
|
175
|
+
// dialog's own Escape handler sits on the focus path and stops the event before a
|
|
176
|
+
// document listener sees it, so there the press closes the dialog — which it did
|
|
177
|
+
// before this line existed too, and which takes the pad with it either way.
|
|
178
|
+
useEscapeKey(onDone);
|
|
179
|
+
|
|
180
|
+
const result = evaluateExpression(value);
|
|
181
|
+
const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
|
|
182
|
+
|
|
183
|
+
const insert = (ch: string) => onChange(sanitizeLive(value + ch));
|
|
184
|
+
const backspace = () => onChange(value.slice(0, -1));
|
|
185
|
+
const clearAll = () => onChange("");
|
|
186
|
+
const equals = () => {
|
|
187
|
+
if (result !== null) onChange(formatResult(result));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const sheet = (
|
|
191
|
+
<div
|
|
192
|
+
role="group"
|
|
193
|
+
aria-label={text.panel}
|
|
194
|
+
// Keep the host input focused when tapping the pad: preventDefault on
|
|
195
|
+
// pointerdown blocks the focus/blur, while the buttons' click still fires.
|
|
196
|
+
onPointerDown={(e) => e.preventDefault()}
|
|
197
|
+
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)]"
|
|
198
|
+
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
|
199
|
+
>
|
|
200
|
+
{/* Header display: which field + the running expression and live result, so
|
|
201
|
+
the value stays visible even when the sheet covers the field. */}
|
|
202
|
+
<div className="flex items-end justify-between gap-3 px-2 pb-2">
|
|
203
|
+
{label != null && (
|
|
204
|
+
<span className="truncate pb-1 text-xs font-medium text-[var(--money-neutral)]">{label}</span>
|
|
205
|
+
)}
|
|
206
|
+
<div className="ml-auto min-w-0 text-right">
|
|
207
|
+
<div className="truncate font-mono text-lg leading-tight text-[var(--text-primary)]">{value || "0"}</div>
|
|
208
|
+
<div className="h-4 font-mono text-xs text-[var(--money-neutral)]">{preview}</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="grid grid-cols-4 gap-1.5">
|
|
213
|
+
{PAD_KEYS.map((key, i) =>
|
|
214
|
+
key.kind === "back" ? (
|
|
215
|
+
<button
|
|
216
|
+
key="back"
|
|
217
|
+
type="button"
|
|
218
|
+
aria-label={text.backspace}
|
|
219
|
+
onClick={backspace}
|
|
220
|
+
className={cn(PAD_BTN, PAD_ACCENT)}
|
|
221
|
+
>
|
|
222
|
+
<Delete className="size-5" />
|
|
223
|
+
</button>
|
|
224
|
+
) : (
|
|
225
|
+
<button
|
|
226
|
+
key={`${key.ins}-${i}`}
|
|
227
|
+
type="button"
|
|
228
|
+
aria-label={key.name ? text[key.name] : key.label}
|
|
229
|
+
onClick={() => insert(key.ins)}
|
|
230
|
+
className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}
|
|
231
|
+
>
|
|
232
|
+
{key.label}
|
|
233
|
+
</button>
|
|
234
|
+
),
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div className="mt-1.5 grid grid-cols-4 gap-1.5">
|
|
239
|
+
<button type="button" aria-label={text.clear} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>
|
|
240
|
+
C
|
|
241
|
+
</button>
|
|
242
|
+
<button type="button" aria-label={text.equals} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>
|
|
243
|
+
=
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={onDone}
|
|
248
|
+
// The one key whose own fill is --brand, so it rings in the colour that fill
|
|
249
|
+
// was chosen to be legible against instead (tailwind-merge keeps the later
|
|
250
|
+
// ring colour). A --brand ring here would be present in the DOM and invisible
|
|
251
|
+
// on screen, which is the defect this change exists to fix.
|
|
252
|
+
className={cn(
|
|
253
|
+
PAD_BTN,
|
|
254
|
+
"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] focus-visible:ring-[var(--brand-contrast)] active:bg-[var(--brand-hover)]",
|
|
255
|
+
)}
|
|
256
|
+
>
|
|
257
|
+
{text.done}
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return createPortal(sheet, document.body);
|
|
264
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* "On this page": a page's own table of contents, with the section being read marked.
|
|
9
|
+
*
|
|
10
|
+
* Three parts, because an app needs them separately:
|
|
11
|
+
*
|
|
12
|
+
* - {@link useScrollSpy} — which section is current, from the headings' positions in
|
|
13
|
+
* a scroll container. No markup; usable for anything that follows the reader.
|
|
14
|
+
* - {@link PageContents} — the list. `variant="rail"` for beside the page,
|
|
15
|
+
* `variant="disclosure"` for a collapsed block under the title on narrow screens.
|
|
16
|
+
* - {@link PageContentsLayout} — the page with the rail sticky on one side of it.
|
|
17
|
+
*
|
|
18
|
+
* Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it
|
|
19
|
+
* works with any router, including a hash router where `#id` alone would be read as a
|
|
20
|
+
* route.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface PageContentsItem {
|
|
24
|
+
/** The id of the heading the entry jumps to. */
|
|
25
|
+
id: string;
|
|
26
|
+
label: ReactNode;
|
|
27
|
+
/** 2 indents the entry under the one before it. */
|
|
28
|
+
level?: 1 | 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PageContentsLabels {
|
|
32
|
+
/** The heading above the list, and the landmark's accessible name. */
|
|
33
|
+
title: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels = {
|
|
37
|
+
title: "On this page",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface UseScrollSpyOptions {
|
|
41
|
+
/** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its
|
|
42
|
+
* `<main>` — it is the page's scroll container from `md` up. */
|
|
43
|
+
root?: Element | null;
|
|
44
|
+
/**
|
|
45
|
+
* How far down the scroller the "reading line" sits, as a fraction of its height.
|
|
46
|
+
* A section becomes current once its heading passes above it, and stays current
|
|
47
|
+
* until the next heading does — so a long section keeps its entry marked while it
|
|
48
|
+
* is being read, not only while its heading is on screen. Default 0.15: close to
|
|
49
|
+
* the top, so a short section followed by another does not hand its mark to the
|
|
50
|
+
* next one the moment it is scrolled to.
|
|
51
|
+
*/
|
|
52
|
+
line?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The id of the section being read: the last of `ids` whose element has scrolled
|
|
57
|
+
* above the reading line — or the first, before any has.
|
|
58
|
+
*/
|
|
59
|
+
export function useScrollSpy(ids: readonly string[], options: UseScrollSpyOptions = {}): string | null {
|
|
60
|
+
const { root = null, line = 0.15 } = options;
|
|
61
|
+
const [current, setCurrent] = useState<string | null>(null);
|
|
62
|
+
const key = ids.join("\u0000");
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (ids.length === 0) return;
|
|
66
|
+
const scroller: Element | Window = root ?? window;
|
|
67
|
+
const measure = () => {
|
|
68
|
+
const top = root ? root.getBoundingClientRect().top : 0;
|
|
69
|
+
const height = root ? root.clientHeight : window.innerHeight;
|
|
70
|
+
const reading = top + height * line;
|
|
71
|
+
let next = ids[0];
|
|
72
|
+
for (const id of ids) {
|
|
73
|
+
const el = document.getElementById(id);
|
|
74
|
+
if (el && el.getBoundingClientRect().top <= reading) next = id;
|
|
75
|
+
}
|
|
76
|
+
// At the very bottom the last sections may never reach the line; the reader
|
|
77
|
+
// who scrolled to the end is reading the end.
|
|
78
|
+
const atEnd = root
|
|
79
|
+
? root.scrollTop + root.clientHeight >= root.scrollHeight - 2
|
|
80
|
+
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
81
|
+
const scrolled = root ? root.scrollTop : window.scrollY;
|
|
82
|
+
if (atEnd && scrolled > 0) next = ids[ids.length - 1];
|
|
83
|
+
setCurrent(next);
|
|
84
|
+
};
|
|
85
|
+
let frame = 0;
|
|
86
|
+
const onScroll = () => {
|
|
87
|
+
cancelAnimationFrame(frame);
|
|
88
|
+
frame = requestAnimationFrame(measure);
|
|
89
|
+
};
|
|
90
|
+
measure();
|
|
91
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
92
|
+
window.addEventListener("resize", onScroll);
|
|
93
|
+
return () => {
|
|
94
|
+
cancelAnimationFrame(frame);
|
|
95
|
+
scroller.removeEventListener("scroll", onScroll);
|
|
96
|
+
window.removeEventListener("resize", onScroll);
|
|
97
|
+
};
|
|
98
|
+
// `key` stands for `ids`: a new array with the same entries is the same list.
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, [key, root, line]);
|
|
101
|
+
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PageContentsProps extends Omit<ComponentPropsWithoutRef<"nav">, "children"> {
|
|
106
|
+
items: PageContentsItem[];
|
|
107
|
+
/** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */
|
|
108
|
+
activeId?: string | null;
|
|
109
|
+
/** The link for an entry. Default `#id`; under a hash router build it with the
|
|
110
|
+
* router (react-router's `useHref({ hash: id })` gives `#/page#id`). */
|
|
111
|
+
hrefFor?: (id: string) => string;
|
|
112
|
+
/** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,
|
|
113
|
+
* for narrow screens where a rail has no room. */
|
|
114
|
+
variant?: "rail" | "disclosure";
|
|
115
|
+
labels?: Partial<PageContentsLabels>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The list itself. The current entry gets a brand bar on the list's rule, the brand
|
|
120
|
+
* colour, and `aria-current="location"` — the marker documentation sites use, and the
|
|
121
|
+
* one a screen reader announces.
|
|
122
|
+
*/
|
|
123
|
+
export function PageContents({
|
|
124
|
+
items,
|
|
125
|
+
activeId,
|
|
126
|
+
hrefFor = (id) => `#${id}`,
|
|
127
|
+
variant = "rail",
|
|
128
|
+
labels,
|
|
129
|
+
className,
|
|
130
|
+
...rest
|
|
131
|
+
}: PageContentsProps) {
|
|
132
|
+
const l = useKitLabels("pageContents", DEFAULT_PAGE_CONTENTS_LABELS, labels);
|
|
133
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
134
|
+
// The entry just clicked is the current one until the reader scrolls on their own.
|
|
135
|
+
// Without this, jumping to a short section near the end of a page marks whichever
|
|
136
|
+
// later section the jump also carried past the reading line — you click one entry
|
|
137
|
+
// and another lights up.
|
|
138
|
+
const [clicked, setClicked] = useState<string | null>(null);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!clicked) return;
|
|
141
|
+
const release = () => setClicked(null);
|
|
142
|
+
// A user scroll — wheel, touch, keys — releases it; the programmatic scroll the
|
|
143
|
+
// jump itself causes does not fire these.
|
|
144
|
+
window.addEventListener("wheel", release, { passive: true, once: true });
|
|
145
|
+
window.addEventListener("touchmove", release, { passive: true, once: true });
|
|
146
|
+
window.addEventListener("keydown", release, { once: true });
|
|
147
|
+
return () => {
|
|
148
|
+
window.removeEventListener("wheel", release);
|
|
149
|
+
window.removeEventListener("touchmove", release);
|
|
150
|
+
window.removeEventListener("keydown", release);
|
|
151
|
+
};
|
|
152
|
+
}, [clicked]);
|
|
153
|
+
const shown = clicked ?? activeId;
|
|
154
|
+
|
|
155
|
+
// Keep the current entry in view when the list is taller than the window.
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!shown || variant !== "rail") return;
|
|
158
|
+
listRef.current
|
|
159
|
+
?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(shown)}"]`)
|
|
160
|
+
?.scrollIntoView?.({ block: "nearest" });
|
|
161
|
+
}, [shown, variant]);
|
|
162
|
+
|
|
163
|
+
if (items.length === 0) return null;
|
|
164
|
+
|
|
165
|
+
const list = (
|
|
166
|
+
<ul
|
|
167
|
+
ref={listRef}
|
|
168
|
+
className={cn(
|
|
169
|
+
"space-y-0.5 border-s border-[var(--border)]",
|
|
170
|
+
variant === "rail" && "max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]",
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
{items.map((item) => {
|
|
174
|
+
const current = item.id === shown;
|
|
175
|
+
return (
|
|
176
|
+
<li key={item.id} data-entry={item.id}>
|
|
177
|
+
<a
|
|
178
|
+
href={hrefFor(item.id)}
|
|
179
|
+
// One line per entry in the rail; past 22rem the ellipsis takes over and
|
|
180
|
+
// the full text is on hover. The disclosure has the page's width and wraps.
|
|
181
|
+
title={variant === "rail" && typeof item.label === "string" ? item.label : undefined}
|
|
182
|
+
aria-current={current ? "location" : undefined}
|
|
183
|
+
onClick={() => setClicked(item.id)}
|
|
184
|
+
className={cn(
|
|
185
|
+
"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
|
|
186
|
+
variant === "rail" && "truncate",
|
|
187
|
+
item.level === 2 ? "ps-6" : "ps-3",
|
|
188
|
+
current
|
|
189
|
+
? "border-[var(--brand)] font-medium text-[var(--brand)]"
|
|
190
|
+
: "border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]",
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{item.label}
|
|
194
|
+
</a>
|
|
195
|
+
</li>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</ul>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (variant === "disclosure") {
|
|
202
|
+
return (
|
|
203
|
+
<details
|
|
204
|
+
className={cn(
|
|
205
|
+
"group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
|
|
206
|
+
className,
|
|
207
|
+
)}
|
|
208
|
+
>
|
|
209
|
+
<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">
|
|
210
|
+
{l.title}
|
|
211
|
+
<ChevronDown
|
|
212
|
+
aria-hidden
|
|
213
|
+
className="size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none"
|
|
214
|
+
/>
|
|
215
|
+
</summary>
|
|
216
|
+
<nav {...rest} aria-label={l.title} className="border-t border-[var(--border)] px-2 py-2">
|
|
217
|
+
{list}
|
|
218
|
+
</nav>
|
|
219
|
+
</details>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<nav {...rest} aria-label={l.title} className={className}>
|
|
225
|
+
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
|
226
|
+
{l.title}
|
|
227
|
+
</p>
|
|
228
|
+
{list}
|
|
229
|
+
</nav>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface PageContentsLayoutProps extends ComponentPropsWithoutRef<"div"> {
|
|
234
|
+
/** What goes in the rail — typically a `<PageContents variant="rail">`. */
|
|
235
|
+
contents: ReactNode;
|
|
236
|
+
/**
|
|
237
|
+
* Which side of the page the rail sits on: `start` is the left in a left-to-right
|
|
238
|
+
* language (next to the app's own sidebar, so both navigations are in one place),
|
|
239
|
+
* `end` the right (the documentation-site convention, clear of the sidebar). Logical,
|
|
240
|
+
* so both flip in a right-to-left UI.
|
|
241
|
+
*/
|
|
242
|
+
position?: "start" | "end";
|
|
243
|
+
children: ReactNode;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that
|
|
248
|
+
* the rail would squeeze the content, so it is not rendered there — pair it with a
|
|
249
|
+
* `<PageContents variant="disclosure" className="xl:hidden">` in the page.
|
|
250
|
+
*
|
|
251
|
+
* The rail is `sticky top-6`, which sticks inside the nearest scroll container — the
|
|
252
|
+
* viewport, or `AppShell`'s `<main>`.
|
|
253
|
+
*/
|
|
254
|
+
export function PageContentsLayout({
|
|
255
|
+
contents,
|
|
256
|
+
position = "start",
|
|
257
|
+
children,
|
|
258
|
+
className,
|
|
259
|
+
...rest
|
|
260
|
+
}: PageContentsLayoutProps) {
|
|
261
|
+
// In DOM order on its side, not moved there with `order`: Tab and a screen reader
|
|
262
|
+
// follow the source, and a rail painted on the left but read after the page would
|
|
263
|
+
// put the page's index at its end.
|
|
264
|
+
const rail = (
|
|
265
|
+
<aside className="hidden xl:block">
|
|
266
|
+
<div className="sticky top-6">{contents}</div>
|
|
267
|
+
</aside>
|
|
268
|
+
);
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
{...rest}
|
|
272
|
+
className={cn(
|
|
273
|
+
"xl:grid xl:gap-10",
|
|
274
|
+
// `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.
|
|
275
|
+
// A fixed 13rem broke "Select — unlabelled, labelled, invalid, disabled" over
|
|
276
|
+
// three lines beside a page with room to spare; an index you scan should read
|
|
277
|
+
// one entry per line.
|
|
278
|
+
position === "start"
|
|
279
|
+
? "xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]"
|
|
280
|
+
: "xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]",
|
|
281
|
+
className,
|
|
282
|
+
)}
|
|
283
|
+
>
|
|
284
|
+
{position === "start" && rail}
|
|
285
|
+
<div className="min-w-0">{children}</div>
|
|
286
|
+
{position === "end" && rail}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|