@eifi1/ui-kit 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -34
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +40 -4
- package/dist/components/amount-input.js +50 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +51 -2
- package/dist/components/button-group.js +5 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +15 -4
- package/dist/components/calendar-heatmap.d.ts +41 -3
- package/dist/components/calendar-heatmap.js +48 -7
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +26 -18
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +35 -3
- package/dist/components/choice-card.js +83 -25
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +18 -5
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +51 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +8 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +125 -27
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +40 -20
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +281 -0
- package/dist/components/error-boundary.js +302 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +30 -4
- package/dist/components/field.js +40 -17
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +50 -208
- package/dist/components/file-dropzone.d.ts +49 -2
- package/dist/components/floating-panel.d.ts +13 -4
- package/dist/components/floating-panel.js +7 -3
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +37 -4
- package/dist/components/list.js +35 -14
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +5 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +13 -1
- package/dist/components/mini-calendar.js +46 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +42 -20
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -1
- package/dist/components/nav-pills.js +5 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.d.ts +24 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +25 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +20 -5
- package/dist/components/numpad-sheet.js +3 -2
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +5 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.d.ts +44 -3
- package/dist/components/progress-bar.js +58 -22
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/settings-fields.d.ts +49 -1
- package/dist/components/signed-amount.d.ts +122 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +6 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +39 -5
- package/dist/components/table.js +45 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +238 -0
- package/dist/components/text-link.js +144 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +35 -0
- package/dist/components/toggle-group.js +43 -14
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +53 -3
- package/dist/components/toggle-legend.js +54 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +364 -34
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +21 -5
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +173 -0
- package/dist/feedback/feedback-thread.js +264 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +49 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +15 -4
- package/dist/i18n/defaults.js +31 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +46 -312
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
- package/dist/i18n/locales/de-CH.d.ts +15 -4
- package/dist/i18n/locales/de-informal.d.ts +15 -4
- package/dist/i18n/locales/de.d.ts +15 -4
- package/dist/i18n/locales/de.js +164 -3
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +15 -4
- package/dist/i18n/locales/es.js +161 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +15 -4
- package/dist/i18n/locales/fr.js +161 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +15 -4
- package/dist/i18n/locales/hu.js +161 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +15 -4
- package/dist/i18n/locales/it.js +161 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +15 -4
- package/dist/i18n/locales/zh.js +161 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +39 -19
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
- package/dist/lib/format.d.ts +162 -0
- package/dist/lib/format.js +151 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +283 -0
- package/dist/rhf/fields.js +631 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +49 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf.d.ts +61 -1
- package/dist/rhf.js +2 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +120 -57
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +21 -4
- package/dist/shell/topbar-action-menu.js +18 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +56 -1
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +48 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +24 -1
- package/dist/wizard/wizard-context.js +19 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +30 -5
- package/dist/wizard/wizard-step.js +34 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +6 -6
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/amount-input.tsx +109 -10
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +14 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +33 -9
- package/src/components/combobox.tsx +33 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +230 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +25 -7
- package/src/components/error-boundary.tsx +640 -0
- package/src/components/field-sync.tsx +48 -40
- package/src/components/field.tsx +56 -5
- package/src/components/floating-panel.tsx +20 -7
- package/src/components/form-actions.tsx +191 -0
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +82 -19
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +7 -2
- package/src/components/mini-calendar.tsx +72 -3
- package/src/components/modal.tsx +65 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +27 -7
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +17 -1
- package/src/components/numpad-sheet.tsx +6 -1
- package/src/components/page-header.tsx +42 -5
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +10 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/progress-bar.tsx +129 -19
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/signed-amount.tsx +295 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +11 -3
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +356 -0
- package/src/components/text.tsx +46 -2
- package/src/components/toggle-group.tsx +93 -20
- package/src/components/toggle-legend.tsx +108 -2
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +707 -49
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +49 -7
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +510 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +30 -0
- package/src/i18n/kit-labels.tsx +118 -8
- package/src/i18n/locales/de.ts +161 -0
- package/src/i18n/locales/es.ts +158 -0
- package/src/i18n/locales/fr.ts +161 -0
- package/src/i18n/locales/hu.ts +159 -0
- package/src/i18n/locales/it.ts +159 -0
- package/src/i18n/locales/zh.ts +158 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +348 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1135 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +153 -3
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +32 -4
- package/src/shell.ts +2 -0
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +75 -6
- package/tokens.css +12 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
2
|
+
import { ExternalLink } from "lucide-react";
|
|
3
|
+
import type { LucideIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
6
|
+
import type { KitLinkComponent } from "../i18n/kit-labels";
|
|
7
|
+
|
|
8
|
+
/* ── Choosing what draws a link ──────────────────────────────────────────── */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a
|
|
12
|
+
* protocol-relative `//host`. A router's link is for paths inside the app, so the
|
|
13
|
+
* provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.
|
|
14
|
+
*
|
|
15
|
+
* NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the
|
|
16
|
+
* hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to
|
|
17
|
+
* `to="/x"`. A plain in-page anchor (`#section`) is not external either, but it is not
|
|
18
|
+
* a route: see {@link isInPageAnchor}.
|
|
19
|
+
*/
|
|
20
|
+
export function isExternalHref(href: string): boolean {
|
|
21
|
+
return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An `href` that only scrolls the current page: `#section` (or a bare `#`). The
|
|
26
|
+
* hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router
|
|
27
|
+
* link must not get an anchor — under a `HashRouter` `#section` would navigate to the
|
|
28
|
+
* path `/section` — so every kit link draws it as a plain `<a>`.
|
|
29
|
+
*/
|
|
30
|
+
export function isInPageAnchor(href: string): boolean {
|
|
31
|
+
return href.startsWith("#") && !href.startsWith("#/");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the provider's `linkComponent` (or a kit component's router fallback) draws
|
|
36
|
+
* `href`: an in-app path, `#/x` included — not an external `href`
|
|
37
|
+
* ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).
|
|
38
|
+
*/
|
|
39
|
+
export function isRoutableHref(href: string): boolean {
|
|
40
|
+
return !isExternalHref(href) && !isInPageAnchor(href);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What draws a kit link to `href`: the component's own `renderLink` when it was given
|
|
45
|
+
* one (it always wins — the caller asked for it by name), else the
|
|
46
|
+
* `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else
|
|
47
|
+
* `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /
|
|
48
|
+
* `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router
|
|
49
|
+
* `#/x` go to the router link. Every kit link props type is a subset of the anchor
|
|
50
|
+
* attributes `KitLinkProps` describes, which is what makes the cast sound.
|
|
51
|
+
* @internal Shared by the kit's link-bearing components; not part of the barrel.
|
|
52
|
+
*/
|
|
53
|
+
export function pickLinkRenderer<P>(
|
|
54
|
+
own: ((props: P) => ReactElement) | undefined,
|
|
55
|
+
kitLink: KitLinkComponent | undefined,
|
|
56
|
+
href: string | undefined,
|
|
57
|
+
): ((props: P) => ReactElement) | undefined {
|
|
58
|
+
if (own) return own;
|
|
59
|
+
if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;
|
|
60
|
+
return kitLink as unknown as (props: P) => ReactElement;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /
|
|
64
|
+
* Shift / Alt (new tab, new window, download), and not one a handler already took. */
|
|
65
|
+
function isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
|
66
|
+
return (
|
|
67
|
+
!event.defaultPrevented &&
|
|
68
|
+
event.button === 0 &&
|
|
69
|
+
!event.metaKey &&
|
|
70
|
+
!event.ctrlKey &&
|
|
71
|
+
!event.shiftKey &&
|
|
72
|
+
!event.altKey
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).
|
|
78
|
+
*
|
|
79
|
+
* A router link replaces the history entry itself (it is handed `replace`, see
|
|
80
|
+
* {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the
|
|
81
|
+
* browser's own navigation always pushes. So a plain click is taken over — the caller's
|
|
82
|
+
* `onClick` runs first and may still `preventDefault` — and becomes
|
|
83
|
+
* `location.replace(href)`: the same document load, with Back skipping the one-shot URL.
|
|
84
|
+
* That is the case of `replace` together with `reloadDocument`, or an app with no
|
|
85
|
+
* `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left
|
|
86
|
+
* to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes
|
|
87
|
+
* back untouched.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
export function replacingClick(
|
|
91
|
+
onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,
|
|
92
|
+
href: string,
|
|
93
|
+
replace: boolean | undefined,
|
|
94
|
+
target?: string,
|
|
95
|
+
): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {
|
|
96
|
+
if (!replace || (target !== undefined && target !== "" && target !== "_self")) return onClick;
|
|
97
|
+
return (event) => {
|
|
98
|
+
onClick?.(event);
|
|
99
|
+
if (!isPlainNavigationClick(event)) return;
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
window.location.replace(href);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else
|
|
107
|
+
* nothing at all — so a provider link that spreads its props onto a DOM `<a>` never
|
|
108
|
+
* receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {
|
|
112
|
+
return replace ? { replace: true } : {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Draws `props` through `render` as a component of its own, so a router link's hooks
|
|
117
|
+
* belong to it and not to the kit component calling it — for the kit components that
|
|
118
|
+
* call their own `renderLink` inline and reach the provider's link through here.
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
export function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {
|
|
122
|
+
return render(props);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── TextLink ─────────────────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `brand` (default) for a link in running text, `muted` for footer and meta links
|
|
129
|
+
* that should not compete with the content, `danger` for a destructive one ("Leave
|
|
130
|
+
* group"), `inherit` for a link that takes its surroundings' colour.
|
|
131
|
+
*
|
|
132
|
+
* `primary` and `secondary` are the body and the secondary text colour, UNDERLINED
|
|
133
|
+
* (keksdose F3): a link that must read as part of the text around it — a name in a
|
|
134
|
+
* list row, a reference in a card's meta line — where `brand` repaints it in the accent
|
|
135
|
+
* and `muted` is a step too light to read as content. `secondary` darkens to the body
|
|
136
|
+
* colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its
|
|
137
|
+
* underline thickens instead.
|
|
138
|
+
*
|
|
139
|
+
* `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside
|
|
140
|
+
* a `<Tone tone="warning">` sentence or a warning banner, which `brand` would paint in a
|
|
141
|
+
* second hue and `inherit` would leave indistinguishable from the sentence. Like
|
|
142
|
+
* `primary`, it keeps its colour on hover and thickens the underline.
|
|
143
|
+
*
|
|
144
|
+
* The three colours that sit IN the text default to `underline="always"`: with the
|
|
145
|
+
* colour of their surroundings, the underline is the only thing that says "link"
|
|
146
|
+
* (WCAG 1.4.1). An explicit `underline` still wins.
|
|
147
|
+
*/
|
|
148
|
+
export type TextLinkTone = "brand" | "muted" | "danger" | "inherit" | "primary" | "secondary" | "warning";
|
|
149
|
+
|
|
150
|
+
/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under
|
|
151
|
+
* the pointer — the idiom all three apps draw. `always` (the default for `primary`,
|
|
152
|
+
* `secondary`, `warning`) for a link inside prose, where colour alone must not be the
|
|
153
|
+
* only mark (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
|
|
154
|
+
export type TextLinkUnderline = "hover" | "always" | "none";
|
|
155
|
+
|
|
156
|
+
/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
|
|
157
|
+
export type TextLinkCurrent = "page" | "step" | "location" | "date" | "time" | "true";
|
|
158
|
+
|
|
159
|
+
/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is
|
|
160
|
+
* handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */
|
|
161
|
+
export interface TextLinkRenderProps {
|
|
162
|
+
href: string;
|
|
163
|
+
/** The link's look — keep it, or the tone, underline and focus ring go. */
|
|
164
|
+
className: string;
|
|
165
|
+
children: ReactNode;
|
|
166
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
167
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
168
|
+
onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
|
|
169
|
+
"aria-current"?: TextLinkCurrent;
|
|
170
|
+
id?: string;
|
|
171
|
+
title?: string;
|
|
172
|
+
/** Present, as `true`, only when the link was given `replace` — see
|
|
173
|
+
* `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */
|
|
174
|
+
replace?: boolean;
|
|
175
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
176
|
+
[key: `data-${string}`]: unknown;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface TextLinkProps
|
|
180
|
+
extends Omit<ComponentPropsWithoutRef<"a">, "href" | "children" | "aria-current" | "onClick" | "onKeyDown"> {
|
|
181
|
+
href: string;
|
|
182
|
+
children: ReactNode;
|
|
183
|
+
/** Default `brand`. */
|
|
184
|
+
tone?: TextLinkTone;
|
|
185
|
+
/** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */
|
|
186
|
+
underline?: TextLinkUnderline;
|
|
187
|
+
/**
|
|
188
|
+
* Navigate by replacing the current history entry, so Back does not return to this
|
|
189
|
+
* URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed
|
|
190
|
+
* to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a
|
|
191
|
+
* plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes
|
|
192
|
+
* `location.replace(href)`. Ignored on an `external` link, which opens a new tab.
|
|
193
|
+
*/
|
|
194
|
+
replace?: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* A plain `<a>` for an in-app `href`: the browser loads the whole document instead of
|
|
197
|
+
* the router swapping the view — keksdose F1, a route served outside the SPA (a file
|
|
198
|
+
* download, a server-rendered page, a logout that must drop every in-memory cache).
|
|
199
|
+
* Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying
|
|
200
|
+
* "not the router" for this one link.
|
|
201
|
+
*/
|
|
202
|
+
reloadDocument?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
|
|
205
|
+
* (never the router's), an external-link mark after the text (mirrored in RTL), and
|
|
206
|
+
* "opens in a new tab" (`common.opensInNewTab`) read after it — the mark says it
|
|
207
|
+
* only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).
|
|
208
|
+
*/
|
|
209
|
+
external?: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Keep the click (and the Enter that activates the link) from reaching an ancestor —
|
|
212
|
+
* a link in a DataTable row with `onRowClick` goes to its own target instead of
|
|
213
|
+
* running the row's action. kastlan's `CellLink` did this by hand.
|
|
214
|
+
*/
|
|
215
|
+
stopPropagation?: boolean;
|
|
216
|
+
/** Mark the link as the current one of a set — `true` is `aria-current="page"`, the
|
|
217
|
+
* legal footer's link to the page you are on. Drawn in semibold. */
|
|
218
|
+
current?: boolean | TextLinkCurrent;
|
|
219
|
+
/** A Lucide icon before the text, at the text's size — kastlan's "← Back" link. */
|
|
220
|
+
icon?: LucideIcon;
|
|
221
|
+
/** Your router's link for an in-app `href`. Default: the `<UiKitProvider
|
|
222
|
+
* linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */
|
|
223
|
+
renderLink?: (props: TextLinkRenderProps) => ReactElement;
|
|
224
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
225
|
+
onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
|
|
226
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const TONE: Record<TextLinkTone, string> = {
|
|
230
|
+
brand: "text-[var(--brand)] hover:text-[var(--brand-hover)]",
|
|
231
|
+
muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
|
|
232
|
+
danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
|
|
233
|
+
inherit: "",
|
|
234
|
+
primary: "text-[var(--text-primary)] hover:decoration-2",
|
|
235
|
+
secondary: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
236
|
+
warning: "text-[var(--warning)] hover:decoration-2",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** The tones whose colour is their surroundings' — underlined by default; see
|
|
240
|
+
* {@link TextLinkTone}. */
|
|
241
|
+
const UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>(["primary", "secondary", "warning"]);
|
|
242
|
+
|
|
243
|
+
const UNDERLINE: Record<TextLinkUnderline, string> = {
|
|
244
|
+
hover: "no-underline hover:underline",
|
|
245
|
+
always: "underline",
|
|
246
|
+
none: "no-underline",
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The one inline link: a real `<a href>` (middle click, "open in new tab", link
|
|
251
|
+
* semantics) through the app's router for an in-app path, in one of four tones.
|
|
252
|
+
*
|
|
253
|
+
* kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,
|
|
254
|
+
* hover underline, one stopping the row click), the legal pages' back link and footer,
|
|
255
|
+
* the feedback page's URL links, the calculator's external sources with a hand-placed
|
|
256
|
+
* ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)
|
|
257
|
+
* used a raw `<a>` to an in-app route, which reloads the whole app. Here the router
|
|
258
|
+
* link comes from `<UiKitProvider linkComponent>`, set once.
|
|
259
|
+
*
|
|
260
|
+
* Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit
|
|
261
|
+
* on the text's baseline at the text's size, so they scale with whatever type the link
|
|
262
|
+
* is set in. The focus ring is an outline, which follows every line of a wrapped link.
|
|
263
|
+
*/
|
|
264
|
+
export function TextLink({
|
|
265
|
+
href,
|
|
266
|
+
children,
|
|
267
|
+
tone = "brand",
|
|
268
|
+
underline: underlineProp,
|
|
269
|
+
external = false,
|
|
270
|
+
replace,
|
|
271
|
+
reloadDocument = false,
|
|
272
|
+
stopPropagation = false,
|
|
273
|
+
current,
|
|
274
|
+
icon: Icon,
|
|
275
|
+
renderLink,
|
|
276
|
+
className,
|
|
277
|
+
onClick,
|
|
278
|
+
onKeyDown,
|
|
279
|
+
ref,
|
|
280
|
+
...rest
|
|
281
|
+
}: TextLinkProps) {
|
|
282
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
283
|
+
const kitLink = useKitLink();
|
|
284
|
+
const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? "always" : "hover");
|
|
285
|
+
const ariaCurrent: TextLinkCurrent | undefined =
|
|
286
|
+
current === true ? "page" : current === false ? undefined : current;
|
|
287
|
+
|
|
288
|
+
const look = cn(
|
|
289
|
+
"rounded-sm underline-offset-2 transition-colors",
|
|
290
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
|
|
291
|
+
TONE[tone],
|
|
292
|
+
UNDERLINE[underline],
|
|
293
|
+
ariaCurrent && "font-semibold",
|
|
294
|
+
className,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const body = (
|
|
298
|
+
<>
|
|
299
|
+
{Icon && <Icon aria-hidden className="me-1 inline size-[1em] shrink-0 align-[-0.125em]" />}
|
|
300
|
+
{children}
|
|
301
|
+
{external && (
|
|
302
|
+
<>
|
|
303
|
+
<ExternalLink aria-hidden className="ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100" />
|
|
304
|
+
{" "}
|
|
305
|
+
<span className="sr-only">({common.opensInNewTab})</span>
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const handleClick: TextLinkRenderProps["onClick"] = stopPropagation
|
|
312
|
+
? (event) => {
|
|
313
|
+
event.stopPropagation();
|
|
314
|
+
onClick?.(event);
|
|
315
|
+
}
|
|
316
|
+
: onClick;
|
|
317
|
+
const props: TextLinkRenderProps = {
|
|
318
|
+
...(rest as Partial<TextLinkRenderProps>),
|
|
319
|
+
ref,
|
|
320
|
+
href,
|
|
321
|
+
className: look,
|
|
322
|
+
"aria-current": ariaCurrent,
|
|
323
|
+
onClick: handleClick,
|
|
324
|
+
onKeyDown: stopPropagation
|
|
325
|
+
? (event) => {
|
|
326
|
+
if (event.key === "Enter") event.stopPropagation();
|
|
327
|
+
onKeyDown?.(event);
|
|
328
|
+
}
|
|
329
|
+
: onKeyDown,
|
|
330
|
+
children: body,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);
|
|
334
|
+
if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;
|
|
335
|
+
// A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.
|
|
336
|
+
const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);
|
|
337
|
+
const { children: content, ...anchor } = props;
|
|
338
|
+
return (
|
|
339
|
+
<a
|
|
340
|
+
{...anchor}
|
|
341
|
+
href={href}
|
|
342
|
+
onClick={plainClick}
|
|
343
|
+
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
|
|
344
|
+
>
|
|
345
|
+
{content}
|
|
346
|
+
</a>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Calls the link renderer as a component, so a router link's hooks are its own. */
|
|
351
|
+
function RenderedTextLink({
|
|
352
|
+
render,
|
|
353
|
+
...props
|
|
354
|
+
}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {
|
|
355
|
+
return render(props);
|
|
356
|
+
}
|
package/src/components/text.tsx
CHANGED
|
@@ -16,6 +16,14 @@ import { cn } from "../lib/cn";
|
|
|
16
16
|
* (support-thread:153/223) at `text-[11px]` by hand — 10px read as a footnote under a
|
|
17
17
|
* figure that size, 12px competed with it. Named `md` rather than slotted in order
|
|
18
18
|
* because `sm` was already the default and renaming it would move every caller.
|
|
19
|
+
*
|
|
20
|
+
* `md` is also the heading over a CHART COLUMN (lenkbank P9): the small label centred
|
|
21
|
+
* over each of a pair of facing charts, or over each column of a small-multiples grid.
|
|
22
|
+
* There the label names a plot, not a section of the page, so `sm` (12px, the page's
|
|
23
|
+
* section heading) out-ranks the card title above it, and `xs` (10px, a legend's
|
|
24
|
+
* title) sinks below the axis ticks it sits beside. Rule of thumb: `xs` inside a
|
|
25
|
+
* control (legend, menu), `md` over a figure or a plot, `sm` over a block of page
|
|
26
|
+
* content.
|
|
19
27
|
*/
|
|
20
28
|
export type SectionLabelSize = "xs" | "md" | "sm";
|
|
21
29
|
|
|
@@ -34,6 +42,31 @@ export const SECTION_LABEL_CLASS: Record<SectionLabelSize, string> = {
|
|
|
34
42
|
sm: "text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]",
|
|
35
43
|
};
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `plain` (default): the label alone, as above.
|
|
47
|
+
*
|
|
48
|
+
* `band` (0.13, keksdose F5): the label on a full-width `surface-2` bar with a bottom
|
|
49
|
+
* border — the day-group header of keksdose's mobile transaction list
|
|
50
|
+
* (mobile-transaction-list.tsx:171), which separates one day's rows from the next.
|
|
51
|
+
* Its type is kept as keksdose drew it — `font-medium` in the SECONDARY text colour,
|
|
52
|
+
* not the plain label's semibold muted — so adopting the kit does not restyle a live
|
|
53
|
+
* list: the bar already sets the label apart, and needs no extra weight. The padding (`px-4 py-2`) lines the text up with a
|
|
54
|
+
* `ListItem density="comfortable"` row below it; override it with `className` for a
|
|
55
|
+
* list at another density. A sticky header is the caller's: add `sticky top-0`.
|
|
56
|
+
*/
|
|
57
|
+
export type SectionLabelVariant = "plain" | "band";
|
|
58
|
+
|
|
59
|
+
/** The band's own type and box, per size — spelled out rather than merged over
|
|
60
|
+
* {@link SECTION_LABEL_CLASS}, so the result does not hang on tailwind-merge telling
|
|
61
|
+
* a font-size `text-[…]` from a colour `text-[var(…)]`. */
|
|
62
|
+
const SECTION_LABEL_BAND_CLASS: Record<SectionLabelSize, string> = {
|
|
63
|
+
xs: "text-[10px]",
|
|
64
|
+
md: "text-[11px]",
|
|
65
|
+
sm: "text-xs",
|
|
66
|
+
};
|
|
67
|
+
const BAND =
|
|
68
|
+
"block border-b border-[var(--border)] bg-[var(--bg-surface-2)] px-4 py-2 font-medium uppercase tracking-wide text-[var(--text-secondary)]";
|
|
69
|
+
|
|
37
70
|
type SectionLabelElement = "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div" | "legend";
|
|
38
71
|
|
|
39
72
|
export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
@@ -45,6 +78,9 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
|
45
78
|
*/
|
|
46
79
|
as?: SectionLabelElement;
|
|
47
80
|
size?: SectionLabelSize;
|
|
81
|
+
/** `band` draws the label on a `surface-2` bar with a bottom border — a list's group
|
|
82
|
+
* header. See {@link SectionLabelVariant}. */
|
|
83
|
+
variant?: SectionLabelVariant;
|
|
48
84
|
children: ReactNode;
|
|
49
85
|
}
|
|
50
86
|
|
|
@@ -55,10 +91,18 @@ export interface SectionLabelProps extends ComponentPropsWithoutRef<"h3"> {
|
|
|
55
91
|
* 10px version on a menu row. One component, the token colour, and the level chosen
|
|
56
92
|
* where the outline is known.
|
|
57
93
|
*/
|
|
58
|
-
export function SectionLabel({
|
|
94
|
+
export function SectionLabel({
|
|
95
|
+
as = "h3",
|
|
96
|
+
size = "sm",
|
|
97
|
+
variant = "plain",
|
|
98
|
+
className,
|
|
99
|
+
children,
|
|
100
|
+
...rest
|
|
101
|
+
}: SectionLabelProps) {
|
|
59
102
|
const Tag = as as ElementType;
|
|
103
|
+
const type = variant === "band" ? cn(SECTION_LABEL_BAND_CLASS[size], BAND) : SECTION_LABEL_CLASS[size];
|
|
60
104
|
return (
|
|
61
|
-
<Tag {...rest} className={cn(
|
|
105
|
+
<Tag {...rest} className={cn(type, className)}>
|
|
62
106
|
{children}
|
|
63
107
|
</Tag>
|
|
64
108
|
);
|
|
@@ -2,7 +2,7 @@ import { useId } from "react";
|
|
|
2
2
|
import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { horizontalStep } from "../lib/direction";
|
|
5
|
-
import { FIELD_INVALID, FloatingField } from "./ui";
|
|
5
|
+
import { FIELD_INVALID, FloatingField, Label } from "./ui";
|
|
6
6
|
|
|
7
7
|
export interface ToggleOption<T extends string> {
|
|
8
8
|
value: T;
|
|
@@ -67,6 +67,19 @@ export interface ToggleGroupBaseProps<T extends string>
|
|
|
67
67
|
* group's own box is dropped: two nested borders read as a control in a control.
|
|
68
68
|
*/
|
|
69
69
|
label?: ReactNode;
|
|
70
|
+
/**
|
|
71
|
+
* Where `label` goes. `"field"` (default): the field chrome described under `label`.
|
|
72
|
+
* `"above"`: the kit's {@link Label} over the bare group — the shape of a {@link Field}
|
|
73
|
+
* — with `hint` beside the label and `error` under the group, for a form that sets
|
|
74
|
+
* its labels above its fields (kastlan's international-rent-calculator.tsx, whose DE
|
|
75
|
+
* cap pair sits in a `Field` column between two labelled-above inputs, where the
|
|
76
|
+
* chrome's inner label would be the only one of its kind).
|
|
77
|
+
*
|
|
78
|
+
* Inside a `Field`, pass no `label` at all and spread the render-prop instead —
|
|
79
|
+
* `{(ids, { labelId }) => <ToggleGroup {...ids} aria-labelledby={labelId} … />}` —
|
|
80
|
+
* so the Field's label names the group and its hint and error describe it.
|
|
81
|
+
*/
|
|
82
|
+
labelPlacement?: "field" | "above";
|
|
70
83
|
/** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with
|
|
71
84
|
* `label`. */
|
|
72
85
|
hint?: ReactNode;
|
|
@@ -91,6 +104,25 @@ type ToggleGroupCaption<V> = ReactNode | ((value: V) => ReactNode);
|
|
|
91
104
|
/** The group as it has always been: one option is always the answer. */
|
|
92
105
|
export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
93
106
|
allowEmpty?: false;
|
|
107
|
+
/**
|
|
108
|
+
* What the options ARE to a screen reader, separately from whether the group can be
|
|
109
|
+
* emptied (kastlan feedback #47).
|
|
110
|
+
*
|
|
111
|
+
* - `"radio"` (default) — a `radiogroup` of radios: one Tab stop, arrow keys MOVE the
|
|
112
|
+
* choice. Right for a choice between answers ("Monthly / Yearly").
|
|
113
|
+
* - `"pressed"` — a `group` of toggle buttons (`aria-pressed`), each its own Tab stop,
|
|
114
|
+
* Space/Enter to press; still one option always pressed. Right for a row that reads
|
|
115
|
+
* as a set of switches — a view mode, a toolbar-like filter — where arrows moving the
|
|
116
|
+
* selection on focus would be a surprise.
|
|
117
|
+
*
|
|
118
|
+
* Before this the only way to `aria-pressed` was `allowEmpty`, which also let a second
|
|
119
|
+
* press clear the choice — so kastlan took the clearable shape and threw the `null`
|
|
120
|
+
* away in `onChange` (`(v) => v && setMode(v)`), a group announcing that a press would
|
|
121
|
+
* unpress what it would not. Here a press on the pressed option re-sends its value, as
|
|
122
|
+
* the radio shape does, and nothing is ever unpressed. A clearable group
|
|
123
|
+
* (`allowEmpty`) is always `"pressed"`: a radio cannot be unchecked by activating it.
|
|
124
|
+
*/
|
|
125
|
+
semantics?: "radio" | "pressed";
|
|
94
126
|
value: T;
|
|
95
127
|
onChange: (value: T) => void;
|
|
96
128
|
/** See {@link ToggleGroupCaption}. */
|
|
@@ -109,6 +141,9 @@ export interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupB
|
|
|
109
141
|
*/
|
|
110
142
|
export interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {
|
|
111
143
|
allowEmpty: true;
|
|
144
|
+
/** Always toggle buttons — see {@link ToggleGroupRequiredProps.semantics}. Accepted so
|
|
145
|
+
* a wrapper can forward one `semantics` to either shape. */
|
|
146
|
+
semantics?: "pressed";
|
|
112
147
|
value: T | null;
|
|
113
148
|
onChange: (value: T | null) => void;
|
|
114
149
|
/** See {@link ToggleGroupCaption}. `null` while nothing is chosen. */
|
|
@@ -143,6 +178,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
143
178
|
disabled = false,
|
|
144
179
|
size = "md",
|
|
145
180
|
label,
|
|
181
|
+
labelPlacement = "field",
|
|
146
182
|
hint,
|
|
147
183
|
error,
|
|
148
184
|
"aria-label": ariaLabelAttr,
|
|
@@ -150,11 +186,17 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
150
186
|
} = props;
|
|
151
187
|
const labelId = useId();
|
|
152
188
|
const errorId = useId();
|
|
153
|
-
const
|
|
154
|
-
|
|
189
|
+
const labelled = label !== undefined && label !== null && label !== false && label !== "";
|
|
190
|
+
// `field` is the chrome; a label placed above keeps the bare group's own box.
|
|
191
|
+
const field = labelled && labelPlacement === "field";
|
|
192
|
+
const above = labelled && labelPlacement === "above";
|
|
193
|
+
const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
|
|
155
194
|
// Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
|
|
156
195
|
// what lets the `onChange` below be called with `null` only in the mode that allows it.
|
|
157
|
-
const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
|
|
196
|
+
const { allowEmpty: _allowEmpty, onChange: _onChange, caption, semantics: _semantics, ...rest } = restWithMode;
|
|
197
|
+
// Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
|
|
198
|
+
// border has to say what the attribute says.
|
|
199
|
+
const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
|
|
158
200
|
const captionId = useId();
|
|
159
201
|
const captionIsLive = typeof caption === "function";
|
|
160
202
|
const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
|
|
@@ -163,11 +205,14 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
163
205
|
if (props.allowEmpty) props.onChange(next === value ? null : next);
|
|
164
206
|
else props.onChange(next);
|
|
165
207
|
};
|
|
166
|
-
|
|
208
|
+
// `pressed`: toggle buttons. Every clearable group is; a required one is when it asks
|
|
209
|
+
// (kastlan #47) — the role, and not whether a second press clears, is what this flag
|
|
210
|
+
// decides from here on, so `choose` above still keys off `allowEmpty` alone.
|
|
211
|
+
const pressed = props.allowEmpty === true || props.semantics === "pressed";
|
|
167
212
|
// A radio group is ONE tab stop (the checked radio, else the first) and arrows move
|
|
168
213
|
// the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
|
|
169
|
-
// 0.7.0 each segment was its own tab stop with no arrow keys. The
|
|
170
|
-
// row of
|
|
214
|
+
// 0.7.0 each segment was its own tab stop with no arrow keys. The toggle-button shape
|
|
215
|
+
// is a row of buttons, where separate tab stops are the pattern.
|
|
171
216
|
const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
|
|
172
217
|
const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
173
218
|
const last = options.length - 1;
|
|
@@ -198,11 +243,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
198
243
|
// radiogroup role or the disabled state by accident. `className` is destructured
|
|
199
244
|
// out entirely and merged through `cn`, so it is never in here.
|
|
200
245
|
{...rest}
|
|
201
|
-
role={
|
|
246
|
+
role={pressed ? "group" : "radiogroup"}
|
|
202
247
|
// The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
|
|
203
248
|
// have not moved yet.
|
|
204
249
|
aria-label={ariaLabelAttr ?? ariaLabel}
|
|
205
|
-
aria-labelledby={
|
|
250
|
+
aria-labelledby={labelled && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
|
|
206
251
|
aria-invalid={hasError || rest["aria-invalid"] || undefined}
|
|
207
252
|
aria-describedby={
|
|
208
253
|
[rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
|
|
@@ -229,10 +274,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
229
274
|
// package does; `cursor-not-allowed` is on the buttons, which is what a
|
|
230
275
|
// pointer is actually over.
|
|
231
276
|
disabled && "opacity-60",
|
|
277
|
+
// The bare group (no chrome to paint) wears the invalid border itself.
|
|
278
|
+
!field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,
|
|
232
279
|
// Inside the field's chrome the group is only a row of segments: no border, no
|
|
233
280
|
// surface, no padding of its own, and the field (not the group) is what dims.
|
|
234
281
|
field && "border-0 bg-transparent p-0 shadow-none opacity-100",
|
|
235
|
-
!
|
|
282
|
+
!labelled && className,
|
|
236
283
|
)}
|
|
237
284
|
>
|
|
238
285
|
{options.map((opt, index) => {
|
|
@@ -241,12 +288,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
241
288
|
<button
|
|
242
289
|
key={opt.value}
|
|
243
290
|
type="button"
|
|
244
|
-
role={
|
|
245
|
-
aria-checked={
|
|
246
|
-
aria-pressed={
|
|
291
|
+
role={pressed ? undefined : "radio"}
|
|
292
|
+
aria-checked={pressed ? undefined : active}
|
|
293
|
+
aria-pressed={pressed ? active : undefined}
|
|
247
294
|
disabled={disabled}
|
|
248
|
-
tabIndex={
|
|
249
|
-
onKeyDown={
|
|
295
|
+
tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
|
|
296
|
+
onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
|
|
250
297
|
onClick={() => choose(opt.value)}
|
|
251
298
|
className={cn(
|
|
252
299
|
// `truncate` (which carries whitespace-nowrap) rather than letting a
|
|
@@ -308,6 +355,36 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
308
355
|
{hasCaption ? captionNode : null}
|
|
309
356
|
</p>
|
|
310
357
|
) : null;
|
|
358
|
+
const errorEl = hasError ? (
|
|
359
|
+
<p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
|
|
360
|
+
{error}
|
|
361
|
+
</p>
|
|
362
|
+
) : null;
|
|
363
|
+
if (above) {
|
|
364
|
+
// `relative` so a caller's `sr-only` label cannot escape (sr-only-containment).
|
|
365
|
+
return (
|
|
366
|
+
<div className={cn("relative grid min-w-0 gap-1.5", className)}>
|
|
367
|
+
<div className="flex items-center gap-1">
|
|
368
|
+
{/* A `<label>` with no `htmlFor`: a group is not labelable, so it is named
|
|
369
|
+
by `aria-labelledby` on the group; the element keeps the Field look. */}
|
|
370
|
+
<Label
|
|
371
|
+
id={labelId}
|
|
372
|
+
disabled={disabled}
|
|
373
|
+
data-error={hasError || undefined}
|
|
374
|
+
className="data-[error=true]:text-[var(--danger)]"
|
|
375
|
+
>
|
|
376
|
+
{label}
|
|
377
|
+
</Label>
|
|
378
|
+
{hint}
|
|
379
|
+
</div>
|
|
380
|
+
<div className="min-w-0">
|
|
381
|
+
{group}
|
|
382
|
+
{captionEl}
|
|
383
|
+
{errorEl}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
311
388
|
if (!field) {
|
|
312
389
|
if (!captionEl) return group;
|
|
313
390
|
// The group keeps its `className`, as without a caption; the wrapper only stacks.
|
|
@@ -339,11 +416,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
339
416
|
</div>
|
|
340
417
|
</FloatingField>
|
|
341
418
|
{captionEl}
|
|
342
|
-
{
|
|
343
|
-
<p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
|
|
344
|
-
{error}
|
|
345
|
-
</p>
|
|
346
|
-
)}
|
|
419
|
+
{errorEl}
|
|
347
420
|
</div>
|
|
348
421
|
);
|
|
349
422
|
}
|