@eifi1/ui-kit 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +89 -19
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +74 -19
- package/dist/components/dropdown.js +72 -22
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +21 -7
- package/dist/components/file-dropzone.js +28 -7
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +21 -2
- package/dist/components/number-input.js +2 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +25 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +5 -3
- package/dist/components/user-avatar.js +4 -2
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +14 -1
- package/dist/theme/palette-presets.js +231 -3
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +66 -13
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useRowSwipe, type SwipeStage } from "../hooks/use-row-swipe";
|
|
5
|
+
import { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One armed swipe action: what it does, and how the reveal panel presents it.
|
|
9
|
+
*
|
|
10
|
+
* The two background classes are the idle and armed states. Before the first
|
|
11
|
+
* threshold is crossed the panel previews the nearest action in its IDLE colour, so
|
|
12
|
+
* the user can see what a little more drag will do; crossing the threshold flips it to
|
|
13
|
+
* `armedClassName`. Passing the same value for both simply removes that feedback.
|
|
14
|
+
*/
|
|
15
|
+
export interface SwipeAction {
|
|
16
|
+
onCommit: () => void;
|
|
17
|
+
label: string;
|
|
18
|
+
/** Rendered inside the panel. Icons stay the consumer's choice — this package
|
|
19
|
+
* ships no opinion about which icon set an app uses. */
|
|
20
|
+
icon?: ReactNode;
|
|
21
|
+
/** Background utility class while the action is previewed but not yet armed. */
|
|
22
|
+
className: string;
|
|
23
|
+
/** Background utility class once the drag has passed this action's threshold. */
|
|
24
|
+
armedClassName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SwipeableRowProps {
|
|
28
|
+
/** Actions committed by dragging RIGHT, nearest threshold first. */
|
|
29
|
+
right?: SwipeAction[];
|
|
30
|
+
/** Actions committed by dragging LEFT, nearest threshold first. */
|
|
31
|
+
left?: SwipeAction[];
|
|
32
|
+
/** Turn the gesture off — an open editor, a pending mutation, a locked row. This
|
|
33
|
+
* also withdraws the keyboard buttons below: a row that must not be acted on must
|
|
34
|
+
* not be actionable by ANY input. */
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
/** Names the revealed action buttons as a set ("Row actions"), for a reader that
|
|
37
|
+
* reaches the group before it reaches any one button. Default:
|
|
38
|
+
* `swipeableRow.actions` from the {@link UiKitProvider}, else English. */
|
|
39
|
+
actionsLabel?: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How much of the row stays on screen at full drag. A row that can be pulled
|
|
45
|
+
* entirely off its own track reads as already deleted, and there is nothing left to
|
|
46
|
+
* drag back; a sliver keeps the gesture reversible and the row identifiable. */
|
|
47
|
+
const PEEK_PX = 40;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A row whose horizontal drag reveals actions underneath it.
|
|
51
|
+
*
|
|
52
|
+
* The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be
|
|
53
|
+
* two half-width blocks side by side, which capped the drag at half the row so the
|
|
54
|
+
* opposite panel could never be exposed — and on a row bound to three actions that
|
|
55
|
+
* left ~55px between stages on a phone, close enough that the wrong one committed.
|
|
56
|
+
* Only the side being dragged toward is painted now, so full width costs nothing:
|
|
57
|
+
* at rest the content covers both anyway, and a drag only ever reveals one side.
|
|
58
|
+
*
|
|
59
|
+
* Thresholds are still not fixed pixel values — they are spread evenly across the
|
|
60
|
+
* available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed
|
|
61
|
+
* 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every
|
|
62
|
+
* action reachable and roughly doubles the room between them.
|
|
63
|
+
*
|
|
64
|
+
* **Whether the action will fire is stated three ways, not one** (feedback #174): the
|
|
65
|
+
* panel goes from dimmed to solid, the icon grows and gains a filled disc, and the
|
|
66
|
+
* label turns bold — plus the existing haptic tick per newly armed stage. A colour
|
|
67
|
+
* shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot
|
|
68
|
+
* distinguish the two tones.
|
|
69
|
+
*
|
|
70
|
+
* Clicks that follow a drag are swallowed in the capture phase: without that, a swipe
|
|
71
|
+
* would also fire whatever click handler the row content carries (expanding it, opening
|
|
72
|
+
* a dialog) on top of the action it just committed.
|
|
73
|
+
*
|
|
74
|
+
* ## The keyboard path is buttons, and deliberately not a typed gesture
|
|
75
|
+
*
|
|
76
|
+
* Every action here used to live exclusively inside a pointer drag — `onCommit` had no
|
|
77
|
+
* other caller — so with a keyboard, with a screen reader, or on any input that cannot
|
|
78
|
+
* express 120px of horizontal travel, the row's actions did not exist at all. That is
|
|
79
|
+
* not a rough edge on a phone control: {@link DataTable} mounts this for every row of
|
|
80
|
+
* its mobile layout, and the actions an app puts here are its destructive ones.
|
|
81
|
+
*
|
|
82
|
+
* The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that
|
|
83
|
+
* "swipes". That invents a private idiom with nothing to discover it by, and it makes
|
|
84
|
+
* the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.
|
|
85
|
+
* So each action is ALSO a real button, `sr-only` until it takes focus and then shown at
|
|
86
|
+
* the row's trailing edge, firing the identical `onCommit`: one definition of what an
|
|
87
|
+
* action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from
|
|
88
|
+
* the eye and not from the accessibility tree, so the row also ANNOUNCES what can be
|
|
89
|
+
* done to it rather than keeping it behind a gesture nobody can hear.
|
|
90
|
+
*/
|
|
91
|
+
export function SwipeableRow({
|
|
92
|
+
right,
|
|
93
|
+
left,
|
|
94
|
+
enabled = true,
|
|
95
|
+
actionsLabel,
|
|
96
|
+
className,
|
|
97
|
+
children,
|
|
98
|
+
}: SwipeableRowProps) {
|
|
99
|
+
const labels = useKitLabels("swipeableRow", DEFAULT_SWIPEABLE_ROW_LABELS, {
|
|
100
|
+
actions: actionsLabel,
|
|
101
|
+
});
|
|
102
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
103
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const el = rowRef.current;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
setRowWidth(el.clientWidth);
|
|
108
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
109
|
+
const ro = new ResizeObserver((entries) => {
|
|
110
|
+
for (const e of entries) setRowWidth(e.contentRect.width);
|
|
111
|
+
});
|
|
112
|
+
ro.observe(el);
|
|
113
|
+
return () => ro.disconnect();
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const rightActions = right ?? [];
|
|
117
|
+
const leftActions = left ?? [];
|
|
118
|
+
const active = enabled && (rightActions.length > 0 || leftActions.length > 0);
|
|
119
|
+
// The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a
|
|
120
|
+
// very narrow row (or one measured before layout) from producing a zero or
|
|
121
|
+
// negative drag, which would arm every stage at once.
|
|
122
|
+
const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;
|
|
123
|
+
const spread = (actions: SwipeAction[]): SwipeStage[] =>
|
|
124
|
+
actions.map((a, i) => ({
|
|
125
|
+
threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),
|
|
126
|
+
onCommit: a.onCommit,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const swipe = useRowSwipe({
|
|
130
|
+
enabled: active,
|
|
131
|
+
rightStages: spread(rightActions),
|
|
132
|
+
leftStages: spread(leftActions),
|
|
133
|
+
maxDrag,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// A haptic tick per newly armed threshold, so each stage is felt and not only seen.
|
|
137
|
+
const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;
|
|
138
|
+
const prevArmed = useRef(-1);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);
|
|
141
|
+
prevArmed.current = armedNow;
|
|
142
|
+
}, [armedNow]);
|
|
143
|
+
|
|
144
|
+
// Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.
|
|
145
|
+
const dx = swipe.dx;
|
|
146
|
+
// Only the side being dragged toward is painted, which is what makes a full-width
|
|
147
|
+
// panel safe.
|
|
148
|
+
//
|
|
149
|
+
// The side has to SURVIVE the release, though, and that is what this ref is for
|
|
150
|
+
// (Keksdose feedback dev#467): *"Swiping a category to the left with no option — if
|
|
151
|
+
// letting it go it gets, for the way back, the background color of the opposite
|
|
152
|
+
// swipe direction where there should be no color change at all."* On release dx
|
|
153
|
+
// snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read
|
|
154
|
+
// "right" for exactly the frames where the left side was still uncovered — and
|
|
155
|
+
// painted the RIGHT action's colour into the gap. With no left action at all that
|
|
156
|
+
// colour was the only thing the gesture ever showed.
|
|
157
|
+
//
|
|
158
|
+
// Written during render rather than in an effect: an effect would repaint a frame
|
|
159
|
+
// later (visibly, at 150ms) and `setState` in an effect body is an eslint error in
|
|
160
|
+
// the consuming app. The value is derived from this render's own dx, so a
|
|
161
|
+
// concurrent re-render recomputes it identically.
|
|
162
|
+
const lastSide = useRef<"left" | "right">("right");
|
|
163
|
+
if (dx !== 0) lastSide.current = dx < 0 ? "left" : "right";
|
|
164
|
+
const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === "left";
|
|
165
|
+
const shown = draggingLeft
|
|
166
|
+
? leftActions[Math.max(0, swipe.armedLeftIndex)]
|
|
167
|
+
: rightActions[Math.max(0, swipe.armedRightIndex)];
|
|
168
|
+
const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div ref={rowRef} className={cn("relative overflow-hidden", className)}>
|
|
172
|
+
{active && shown && (
|
|
173
|
+
<div
|
|
174
|
+
className={cn(
|
|
175
|
+
"pointer-events-none absolute inset-0 flex items-center gap-2 px-4",
|
|
176
|
+
"text-xs font-medium text-white transition-all",
|
|
177
|
+
// Dimmed until the drag has actually passed a threshold: "nothing will
|
|
178
|
+
// happen yet" has to look different from "let go and this fires", and on
|
|
179
|
+
// a moving row a one-step colour change was too quiet to notice.
|
|
180
|
+
armed ? "opacity-100" : "opacity-60",
|
|
181
|
+
armed ? shown.armedClassName : shown.className,
|
|
182
|
+
// The panel is anchored to the edge the row is uncovering, so the label
|
|
183
|
+
// sits where the eye already is rather than across the screen.
|
|
184
|
+
draggingLeft && "flex-row-reverse",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{shown.icon && (
|
|
188
|
+
<span
|
|
189
|
+
className={cn(
|
|
190
|
+
"flex shrink-0 items-center justify-center rounded-full transition-all",
|
|
191
|
+
// Armed, the icon gets a disc of its own and grows — a second,
|
|
192
|
+
// non-colour signal, and the one that stays readable at a glance.
|
|
193
|
+
armed ? "size-8 scale-110 bg-white/25" : "size-7 bg-white/10",
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
196
|
+
{shown.icon}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
<span className={cn("truncate transition-all", armed && "text-sm font-semibold")}>
|
|
200
|
+
{shown.label}
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
<div
|
|
205
|
+
// Capture phase: the row's own click handler lives on a DESCENDANT, so a
|
|
206
|
+
// bubble-phase listener here would run after it — too late to suppress.
|
|
207
|
+
onClickCapture={(e) => {
|
|
208
|
+
if (!swipe.consumeClick()) return;
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
}}
|
|
212
|
+
className={cn(
|
|
213
|
+
"relative bg-[var(--bg-surface)]",
|
|
214
|
+
// A mouse drag is a supported way to swipe (use-row-swipe gates only on
|
|
215
|
+
// the button, not the pointer type), and a horizontal mouse drag over
|
|
216
|
+
// text also SELECTS that text — so committing an action left the row
|
|
217
|
+
// highlighted blue. Suppressed only while dragging, so text in a row at
|
|
218
|
+
// rest stays selectable.
|
|
219
|
+
swipe.dragging ? "select-none" : "transition-transform duration-150",
|
|
220
|
+
)}
|
|
221
|
+
style={{
|
|
222
|
+
transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,
|
|
223
|
+
touchAction: active ? "pan-y" : undefined,
|
|
224
|
+
}}
|
|
225
|
+
{...(active ? swipe.handlers : {})}
|
|
226
|
+
>
|
|
227
|
+
{children}
|
|
228
|
+
</div>
|
|
229
|
+
{active && (
|
|
230
|
+
<div
|
|
231
|
+
role="group"
|
|
232
|
+
aria-label={labels.actions}
|
|
233
|
+
// OUTSIDE the sliding div on purpose: an action that travels with the row it
|
|
234
|
+
// acts on is a moving target, and the drag transform would carry it off the
|
|
235
|
+
// screen mid-gesture.
|
|
236
|
+
//
|
|
237
|
+
// `pointer-events-none` here, restored on a button only once it HAS focus: a
|
|
238
|
+
// sr-only button is clipped to nothing but is still a hit target, and this
|
|
239
|
+
// strip sits at the row's right edge — exactly where a leftward drag begins.
|
|
240
|
+
// A gesture that silently failed to start because it landed on an invisible
|
|
241
|
+
// button is not a bug anyone would find by looking at the screen.
|
|
242
|
+
className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2"
|
|
243
|
+
>
|
|
244
|
+
{/* Both sides, each still nearest-threshold first, so the tab order reads in
|
|
245
|
+
the order the gesture arms them. */}
|
|
246
|
+
{[...rightActions, ...leftActions].map((action, i) => (
|
|
247
|
+
<button
|
|
248
|
+
key={`${action.label}-${i}`}
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={action.onCommit}
|
|
251
|
+
className={cn(
|
|
252
|
+
"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm",
|
|
253
|
+
"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5",
|
|
254
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
255
|
+
)}
|
|
256
|
+
>
|
|
257
|
+
{/* The label is the button's accessible name, so the caller's icon is
|
|
258
|
+
decoration on top of it rather than a second reading of it. */}
|
|
259
|
+
{action.icon && (
|
|
260
|
+
<span aria-hidden="true" className="[&_svg]:size-4">
|
|
261
|
+
{action.icon}
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
{action.label}
|
|
265
|
+
</button>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { forwardRef, useId } from "react";
|
|
2
|
+
import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { hasMessage, mergeDescribedBy } from "./choice-parts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An on/off switch that is still `<input type="checkbox">`, with `role="switch"`.
|
|
8
|
+
*
|
|
9
|
+
* Kastlan's was a Radix `<button>`; this keeps the native element for the reasons
|
|
10
|
+
* `Checkbox` gives (form submission, `required`, reset, `<label for>`), and adds the
|
|
11
|
+
* one thing a checkbox is not: `role="switch"`, which screen readers announce as
|
|
12
|
+
* "on/off" rather than "checked/not checked". Use it for a setting that takes effect
|
|
13
|
+
* the moment it is flipped; a choice that waits for a Save button is a checkbox.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Geometry per size. The thumb sits `2px` in from each end of the track, so its
|
|
17
|
+
* checked offset is `track width − thumb − 2px`. `start-*`, never `left-*` or a
|
|
18
|
+
* `translate-x`: an inline-start offset follows the writing direction, so in a
|
|
19
|
+
* right-to-left form the thumb travels right-to-left with no `rtl:` override to
|
|
20
|
+
* keep in step. */
|
|
21
|
+
const SIZES = {
|
|
22
|
+
// 28×16 track, 12px thumb: the dense setting rows in Kastlan's tables.
|
|
23
|
+
sm: { track: "h-4 w-7", thumb: "size-3 peer-checked:start-[14px]" },
|
|
24
|
+
// 36×20 track, 16px thumb.
|
|
25
|
+
md: { track: "h-5 w-9", thumb: "size-4 peer-checked:start-[18px]" },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type SwitchSize = keyof typeof SIZES;
|
|
29
|
+
|
|
30
|
+
// The track IS the input — `appearance-none` and a pill — so its box is the pointer
|
|
31
|
+
// target and the focus ring lands around the whole control, not the thumb alone.
|
|
32
|
+
//
|
|
33
|
+
// Unchecked it takes `--border-strong`, not `--border`: a hairline-coloured track on
|
|
34
|
+
// a surface-coloured card is a switch you have to look for. Checked it takes the
|
|
35
|
+
// brand fill, which is the only difference in COLOUR; the thumb's position is the
|
|
36
|
+
// difference in SHAPE, so the state never rests on colour alone.
|
|
37
|
+
const TRACK =
|
|
38
|
+
"peer block shrink-0 cursor-pointer appearance-none rounded-full bg-[var(--border-strong)] transition-colors " +
|
|
39
|
+
"checked:bg-[var(--brand)] " +
|
|
40
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] " +
|
|
41
|
+
"disabled:cursor-not-allowed";
|
|
42
|
+
|
|
43
|
+
// The thumb, laid over the input and ignoring the pointer so every click still reaches
|
|
44
|
+
// the input. It swaps to `--brand-contrast` on the brand fill: the surface colour would
|
|
45
|
+
// match the fill in some presets, and the contrast token is the one guaranteed to read
|
|
46
|
+
// against --brand.
|
|
47
|
+
const THUMB =
|
|
48
|
+
"pointer-events-none absolute start-0.5 top-1/2 -translate-y-1/2 rounded-full bg-[var(--bg-surface)] shadow-sm " +
|
|
49
|
+
"transition-[inset-inline-start,background-color] motion-reduce:transition-none " +
|
|
50
|
+
"peer-checked:bg-[var(--brand-contrast)]";
|
|
51
|
+
|
|
52
|
+
export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "role"> {
|
|
53
|
+
/** `"md"` (36×20, default) or `"sm"` (28×16) — Kastlan's `default` and `sm`. */
|
|
54
|
+
size?: SwitchSize;
|
|
55
|
+
/** The words beside the switch, as a `<label for>` — see {@link Checkbox}'s
|
|
56
|
+
* `label` for why it does not wrap the input. Without it, give the switch an
|
|
57
|
+
* `aria-label`. */
|
|
58
|
+
label?: ReactNode;
|
|
59
|
+
/** Secondary text under the label, attached with `aria-describedby` (merged with
|
|
60
|
+
* any the caller passed). */
|
|
61
|
+
description?: ReactNode;
|
|
62
|
+
/** Which end of the row the switch sits at when there is a label. `"end"` (the
|
|
63
|
+
* default) is the settings-list shape Kastlan's profile page uses — the words at
|
|
64
|
+
* the start, the control at the far end; `"start"` puts it before the words, like
|
|
65
|
+
* a checkbox. Logical, so it follows the writing direction. */
|
|
66
|
+
switchPosition?: "start" | "end";
|
|
67
|
+
/** The new state as a boolean — Radix's `onCheckedChange`, so Kastlan's call sites
|
|
68
|
+
* move without unwrapping the event. Fires alongside `onChange`. */
|
|
69
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
70
|
+
/** Classes for the `<input>` (the track). `className` styles the outermost element:
|
|
71
|
+
* the row when there is a label, the switch's own wrapper when there is not. */
|
|
72
|
+
inputClassName?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A native checkbox drawn as a track and thumb, announced as a switch.
|
|
77
|
+
*
|
|
78
|
+
* Every other prop reaches the `<input>`, so it works controlled (`checked` +
|
|
79
|
+
* `onChange`/`onCheckedChange`), uncontrolled (`defaultChecked`) and in a plain
|
|
80
|
+
* `<form>` (`name`, `value`).
|
|
81
|
+
*/
|
|
82
|
+
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
|
|
83
|
+
{
|
|
84
|
+
size = "md",
|
|
85
|
+
label,
|
|
86
|
+
description,
|
|
87
|
+
switchPosition = "end",
|
|
88
|
+
onCheckedChange,
|
|
89
|
+
onChange,
|
|
90
|
+
className,
|
|
91
|
+
inputClassName,
|
|
92
|
+
id,
|
|
93
|
+
disabled,
|
|
94
|
+
...rest
|
|
95
|
+
},
|
|
96
|
+
ref,
|
|
97
|
+
) {
|
|
98
|
+
const generated = useId();
|
|
99
|
+
const inputId = id ?? generated;
|
|
100
|
+
const descriptionId = `${inputId}-description`;
|
|
101
|
+
const showDescription = hasMessage(description);
|
|
102
|
+
const bare = label === undefined && !showDescription;
|
|
103
|
+
const geometry = SIZES[size];
|
|
104
|
+
|
|
105
|
+
const control = (
|
|
106
|
+
<span className={cn("relative inline-flex shrink-0", bare && className)}>
|
|
107
|
+
<input
|
|
108
|
+
ref={ref}
|
|
109
|
+
id={bare ? id : inputId}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
{...rest}
|
|
112
|
+
// After the spread: a caller's props object must not be able to turn this
|
|
113
|
+
// back into a plain checkbox, or into a text field.
|
|
114
|
+
type="checkbox"
|
|
115
|
+
role="switch"
|
|
116
|
+
aria-describedby={mergeDescribedBy(rest["aria-describedby"], showDescription && descriptionId)}
|
|
117
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
118
|
+
onChange?.(e);
|
|
119
|
+
onCheckedChange?.(e.target.checked);
|
|
120
|
+
}}
|
|
121
|
+
className={cn(TRACK, geometry.track, inputClassName)}
|
|
122
|
+
/>
|
|
123
|
+
<span aria-hidden className={cn(THUMB, geometry.thumb)} />
|
|
124
|
+
</span>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (bare) return control;
|
|
128
|
+
|
|
129
|
+
// The switch's cell is one label line tall (text-sm, 20px) so it centres on the
|
|
130
|
+
// FIRST line of a label that wraps, and a description underneath does not drag it
|
|
131
|
+
// down to the middle of the paragraph.
|
|
132
|
+
const cell = <span className="flex h-5 shrink-0 items-center">{control}</span>;
|
|
133
|
+
const words = (
|
|
134
|
+
<span className="min-w-0 flex-1">
|
|
135
|
+
{label !== undefined && (
|
|
136
|
+
<label
|
|
137
|
+
htmlFor={inputId}
|
|
138
|
+
className={cn(
|
|
139
|
+
"block text-sm leading-5 text-[var(--text-primary)]",
|
|
140
|
+
disabled ? "cursor-not-allowed" : "cursor-pointer select-none",
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{label}
|
|
144
|
+
</label>
|
|
145
|
+
)}
|
|
146
|
+
{showDescription && (
|
|
147
|
+
<span id={descriptionId} className="mt-0.5 block text-xs text-[var(--text-muted)]">
|
|
148
|
+
{description}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</span>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className={cn("flex items-start gap-3", disabled && "opacity-60", className)}>
|
|
156
|
+
{switchPosition === "end" ? (
|
|
157
|
+
<>
|
|
158
|
+
{words}
|
|
159
|
+
{cell}
|
|
160
|
+
</>
|
|
161
|
+
) : (
|
|
162
|
+
<>
|
|
163
|
+
{cell}
|
|
164
|
+
{words}
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
Switch.displayName = "Switch";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { ChangeEvent } from "react";
|
|
3
|
+
import { Input, type InputProps } from "./ui";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A time of day, as a `"HH:mm"` string (`"HH:mm:ss"` once `step` asks for seconds).
|
|
8
|
+
*
|
|
9
|
+
* NATIVE `<input type="time">`, deliberately, rather than a pair of custom spinners.
|
|
10
|
+
* What the browser's control gives for free is the part a hand-rolled one gets wrong:
|
|
11
|
+
* 12- versus 24-hour display follows the user's OWN locale settings (not the page's —
|
|
12
|
+
* which is the right authority for "how do I read a clock"), a phone opens its
|
|
13
|
+
* platform wheel or dial, each segment is a spin button a screen reader already knows,
|
|
14
|
+
* and typing "1430" fills both segments. The price is that the display cannot be
|
|
15
|
+
* forced to 24h from here and the look of the segments is the browser's; neither is a
|
|
16
|
+
* reason for three apps to ship an unaudited widget. So this is {@link Input} with
|
|
17
|
+
* `type="time"` and a string contract, and everything else — the floating label, the
|
|
18
|
+
* `invalid`/`error` wiring, the click-anywhere `showPicker()` (feedback #224) — is
|
|
19
|
+
* Input's own, inherited rather than copied.
|
|
20
|
+
*
|
|
21
|
+
* Two things it adds over `<Input type="time">`:
|
|
22
|
+
*
|
|
23
|
+
* - **A normalised value.** A time input's value is any valid time string, so a
|
|
24
|
+
* browser may leave off a zero seconds segment with `step={1}` or report
|
|
25
|
+
* milliseconds, and a stored value may carry seconds the field does not show.
|
|
26
|
+
* `onValueChange` always hands back exactly the shape `step` implies, so the value
|
|
27
|
+
* a form stores compares and sorts as a plain string.
|
|
28
|
+
* - **Out-of-range painted, not just flagged.** A native time input outside
|
|
29
|
+
* `min`/`max` matches `:invalid` and nothing styles that selector (see Input's
|
|
30
|
+
* `invalid`), so an out-of-window time looked fine. Here it wears
|
|
31
|
+
* {@link FIELD_INVALID} and `aria-invalid`. A reversed window (`min="22:00"
|
|
32
|
+
* max="06:00"`, a night shift, quiet hours) is read the way the HTML spec reads it:
|
|
33
|
+
* as wrapping past midnight.
|
|
34
|
+
*/
|
|
35
|
+
export interface TimeInputProps
|
|
36
|
+
extends Omit<InputProps, "type" | "value" | "defaultValue" | "min" | "max" | "step" | "variant"> {
|
|
37
|
+
/** `"HH:mm"` (or `"HH:mm:ss"` with a sub-minute `step`); `""` is "no time". */
|
|
38
|
+
value: string;
|
|
39
|
+
/** The normalised value on every change the browser reports — which for a time
|
|
40
|
+
* input is only once every segment holds a digit, or when the field is cleared
|
|
41
|
+
* (`""`). Plain `onChange` still fires alongside it with the raw event. */
|
|
42
|
+
onValueChange?: (value: string) => void;
|
|
43
|
+
/** Earliest time, `"HH:mm"`. May be later than `max` for a window that wraps
|
|
44
|
+
* midnight. */
|
|
45
|
+
min?: string;
|
|
46
|
+
/** Latest time, `"HH:mm"`. */
|
|
47
|
+
max?: string;
|
|
48
|
+
/** Granularity in SECONDS, as on the native input: 60 (the default) is minutes,
|
|
49
|
+
* 900 is quarter hours, anything not a whole minute (e.g. 1) shows seconds. */
|
|
50
|
+
step?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Seconds shown when `step` is not a whole number of minutes — the browser's own rule
|
|
54
|
+
* for whether the seconds segment appears. */
|
|
55
|
+
function withSeconds(step: number | undefined): boolean {
|
|
56
|
+
return step !== undefined && step % 60 !== 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Any `HH:mm[:ss[.sss]]` the browser reports → exactly the shape `step` implies.
|
|
60
|
+
* Anything else (including `""`) comes back as it went in. */
|
|
61
|
+
export function normalizeTime(raw: string, seconds: boolean): string {
|
|
62
|
+
const m = /^(\d{2}):(\d{2})(?::(\d{2}))?/.exec(raw);
|
|
63
|
+
if (!m) return raw;
|
|
64
|
+
return seconds ? `${m[1]}:${m[2]}:${m[3] ?? "00"}` : `${m[1]}:${m[2]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether `value` lies inside `[min, max]`, wrapping midnight when `min > max`.
|
|
68
|
+
* Zero-padded times compare as strings; both sides are padded to seconds first so
|
|
69
|
+
* "14:30" and "14:30:00" are the same instant. */
|
|
70
|
+
export function isTimeInRange(value: string, min?: string, max?: string): boolean {
|
|
71
|
+
if (value === "") return true;
|
|
72
|
+
const v = normalizeTime(value, true);
|
|
73
|
+
const lo = min ? normalizeTime(min, true) : undefined;
|
|
74
|
+
const hi = max ? normalizeTime(max, true) : undefined;
|
|
75
|
+
if (lo !== undefined && hi !== undefined && lo > hi) return v >= lo || v <= hi;
|
|
76
|
+
return (lo === undefined || v >= lo) && (hi === undefined || v <= hi);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>(function TimeInput(
|
|
80
|
+
{ value, onValueChange, onChange, min, max, step, invalid, inputClassName, ...rest },
|
|
81
|
+
ref,
|
|
82
|
+
) {
|
|
83
|
+
const seconds = withSeconds(step);
|
|
84
|
+
const outOfRange = !isTimeInRange(value, min, max);
|
|
85
|
+
return (
|
|
86
|
+
<Input
|
|
87
|
+
ref={ref}
|
|
88
|
+
{...rest}
|
|
89
|
+
type="time"
|
|
90
|
+
// Normalised on the way IN as well: a stored "14:30:00" handed to a minutes-only
|
|
91
|
+
// field would otherwise make the browser show a seconds segment nobody asked for.
|
|
92
|
+
value={normalizeTime(value, seconds)}
|
|
93
|
+
min={min}
|
|
94
|
+
max={max}
|
|
95
|
+
step={step}
|
|
96
|
+
invalid={invalid || outOfRange}
|
|
97
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
98
|
+
onChange?.(e);
|
|
99
|
+
onValueChange?.(normalizeTime(e.target.value, seconds));
|
|
100
|
+
}}
|
|
101
|
+
// The segments are digits that change under the caret as you spin them;
|
|
102
|
+
// proportional figures make the whole field jitter sideways on every step.
|
|
103
|
+
inputClassName={cn("tabular-nums", inputClassName)}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
TimeInput.displayName = "TimeInput";
|