@eifi1/ui-kit 0.11.0 → 0.12.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 +57 -32
- 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 +24 -4
- package/dist/components/amount-input.js +5 -2
- 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/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/chip.d.ts +2 -1
- package/dist/components/chip.js +6 -3
- 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.js +2 -0
- 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-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 +102 -12
- 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 +18 -3
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +92 -0
- package/dist/components/error-boundary.js +111 -0
- package/dist/components/error-boundary.js.map +1 -0
- 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/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 +23 -2
- package/dist/components/list.js +32 -13
- 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 +19 -3
- 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 +15 -4
- 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 +2 -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/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 +121 -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 +5 -2
- package/dist/components/stat-tile.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 +176 -0
- package/dist/components/text-link.js +109 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +13 -0
- package/dist/components/toggle-group.js +35 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +36 -3
- package/dist/components/toggle-legend.js +49 -0
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +215 -23
- 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-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +16 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +133 -0
- package/dist/feedback/feedback-thread.js +237 -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 +156 -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 +153 -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 +153 -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 +153 -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 +153 -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 +153 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +37 -17
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
- package/dist/lib/format.d.ts +144 -0
- package/dist/lib/format.js +149 -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 +241 -0
- package/dist/rhf/fields.js +601 -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/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +58 -3
- 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 +12 -1
- package/dist/shell/topbar-action-menu.js +16 -1
- 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/wizard/stepper-nav.d.ts +48 -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 +20 -5
- package/dist/wizard/wizard-step.js +16 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +1 -1
- 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 +13 -1
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chip.tsx +8 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +5 -0
- package/src/components/combobox.tsx +31 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +224 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +18 -3
- package/src/components/error-boundary.tsx +216 -0
- 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/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 +65 -16
- 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 +61 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +19 -3
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +16 -1
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +7 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -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 +286 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +8 -2
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +248 -0
- package/src/components/toggle-group.tsx +58 -10
- package/src/components/toggle-legend.tsx +87 -2
- package/src/components/ui.tsx +426 -36
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-dialog.tsx +42 -3
- package/src/feedback/feedback-thread.tsx +434 -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 +102 -8
- package/src/i18n/locales/de.ts +153 -0
- package/src/i18n/locales/es.ts +150 -0
- package/src/i18n/locales/fr.ts +153 -0
- package/src/i18n/locales/hu.ts +151 -0
- package/src/i18n/locales/it.ts +151 -0
- package/src/i18n/locales/zh.ts +150 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +325 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1054 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/shell/app-shell.tsx +147 -2
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +21 -0
- package/src/shell.ts +2 -0
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +43 -6
- package/tokens.css +12 -0
package/src/components/ui.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
|
|
3
|
-
import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
3
|
+
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { horizontalStep } from "../lib/direction";
|
|
6
6
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
7
7
|
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
8
|
-
import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
9
|
+
import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
|
|
10
|
+
import { pickLinkRenderer } from "./text-link";
|
|
9
11
|
|
|
10
12
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
|
|
11
13
|
|
|
@@ -70,8 +72,18 @@ const buttonVariantClasses: Record<ButtonVariant, string> = {
|
|
|
70
72
|
*
|
|
71
73
|
* A `tone` rather than a `link-muted` variant, because it is the axis IconButton
|
|
72
74
|
* already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
|
|
73
|
-
* because the same two looks are wanted on `ghost`.
|
|
74
|
-
*
|
|
75
|
+
* because the same two looks are wanted on `ghost`.
|
|
76
|
+
*
|
|
77
|
+
* `danger` also reaches the two NEUTRAL boxed variants, where the box carries it:
|
|
78
|
+
* `secondary` becomes an outlined destructive button (`--danger` text and a
|
|
79
|
+
* `--danger-border` outline, the quiet danger fill on hover) and `primary` a soft one
|
|
80
|
+
* (the quiet fill at rest, solid `--danger` on hover). kastlan's meeting-invitations-
|
|
81
|
+
* tab.tsx "Remove all" is `variant="secondary" className="text-destructive"` — a
|
|
82
|
+
* destructive action that must sit in a row of secondary buttons without shouting
|
|
83
|
+
* like the solid `variant="danger"` would, and whose red text alone left the border
|
|
84
|
+
* and hover saying "neutral". `muted` stays a text look and does nothing on a box, and
|
|
85
|
+
* `danger`, `brand` and `link`'s own colours are left alone: a tone that recoloured a
|
|
86
|
+
* solid danger or brand button would be a second variant under another name.
|
|
75
87
|
*/
|
|
76
88
|
export type ButtonTone = "default" | "muted" | "danger";
|
|
77
89
|
|
|
@@ -82,9 +94,19 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
|
|
|
82
94
|
"text-[var(--text-secondary)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)] disabled:hover:text-[var(--text-secondary)]",
|
|
83
95
|
};
|
|
84
96
|
|
|
85
|
-
/** Only the two transparent variants take
|
|
97
|
+
/** Only the two transparent variants take every tone; see {@link ButtonTone}. */
|
|
86
98
|
const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
|
|
87
99
|
|
|
100
|
+
// `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
|
|
101
|
+
// restates every colour the variant sets (border, fill, text, hover, ring) so none of
|
|
102
|
+
// the neutral ones survives the merge.
|
|
103
|
+
const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
|
|
104
|
+
secondary:
|
|
105
|
+
"border-[var(--danger-border)] bg-transparent text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
|
|
106
|
+
primary:
|
|
107
|
+
"border-[var(--danger-border)] bg-[var(--danger-bg)] text-[var(--danger)] hover:border-[var(--danger)] hover:bg-[var(--danger)] hover:text-[var(--danger-contrast)] focus:ring-[var(--danger-border)] disabled:hover:border-[var(--danger-border)] disabled:hover:bg-[var(--danger-bg)] disabled:hover:text-[var(--danger)]",
|
|
108
|
+
};
|
|
109
|
+
|
|
88
110
|
// `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what
|
|
89
111
|
// lenkbank's "All speeds" toggle paints by hand (gear/hysteresis-charts.tsx:482). After
|
|
90
112
|
// the tone, so a pressed muted link is brand, and an unpressed one is quiet grey.
|
|
@@ -140,8 +162,9 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
140
162
|
* declared here only because `ButtonHTMLAttributes` does not carry it. */
|
|
141
163
|
ref?: Ref<HTMLButtonElement>;
|
|
142
164
|
/** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
|
|
143
|
-
* `danger` (quiet, `--danger` on hover).
|
|
144
|
-
*
|
|
165
|
+
* `danger` (quiet, `--danger` on hover). `danger` also makes `secondary` an
|
|
166
|
+
* outlined destructive button and `primary` a soft one; ignored by the other
|
|
167
|
+
* variants. See {@link ButtonTone}. */
|
|
145
168
|
tone?: ButtonTone;
|
|
146
169
|
/**
|
|
147
170
|
* Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
|
|
@@ -172,57 +195,243 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
172
195
|
* DangerConfirm keeps its own.
|
|
173
196
|
*/
|
|
174
197
|
disabledReason?: ReactNode;
|
|
198
|
+
/**
|
|
199
|
+
* Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
|
|
200
|
+
*
|
|
201
|
+
* kastlan's FormActions (form-actions.tsx:50) disables its submit and swaps in a
|
|
202
|
+
* `submittingLabel`, so the button jumps width mid-click and a screen reader hears
|
|
203
|
+
* the name change instead of "busy". Here the label STAYS — it is still the
|
|
204
|
+
* accessible name and still holds the button's width — and the spinner is drawn over
|
|
205
|
+
* it in the text colour, so nothing beside the button moves.
|
|
206
|
+
*
|
|
207
|
+
* The same prop {@link FileButton} has (`pending`), with one difference: this one is
|
|
208
|
+
* `aria-disabled`, not `disabled`. The button being pressed is the one that has focus,
|
|
209
|
+
* and a native `disabled` drops that focus to `<body>` the moment the save starts;
|
|
210
|
+
* clicks, and the Enter that submits a form through it, are swallowed instead.
|
|
211
|
+
*/
|
|
212
|
+
pending?: boolean;
|
|
213
|
+
/** Only on the link form — see {@link ButtonLinkProps}. */
|
|
214
|
+
href?: never;
|
|
215
|
+
renderLink?: never;
|
|
216
|
+
external?: never;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
|
|
221
|
+
*
|
|
222
|
+
* kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
|
|
223
|
+
* invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
|
|
224
|
+
* platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
|
|
225
|
+
* billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
|
|
226
|
+
* navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
|
|
227
|
+
* tab", no URL on hover, and a screen reader announces a button that then changes the
|
|
228
|
+
* page. With `href` it is an `<a>`: the provider's router link
|
|
229
|
+
* ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
|
|
230
|
+
*
|
|
231
|
+
* `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
|
|
232
|
+
*/
|
|
233
|
+
export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
|
|
234
|
+
href: string;
|
|
235
|
+
variant?: ButtonVariant;
|
|
236
|
+
size?: ButtonSize;
|
|
237
|
+
stretch?: boolean;
|
|
238
|
+
tone?: ButtonTone;
|
|
239
|
+
/** The in-app router link, over the provider's `linkComponent` (see
|
|
240
|
+
* {@link KitLinkComponent}). Ignored when `external`. */
|
|
241
|
+
renderLink?: KitLinkComponent;
|
|
242
|
+
/** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
|
|
243
|
+
* router link, and "(opens in a new tab)" read after the label — billing-history's
|
|
244
|
+
* Stripe PDF. A `target="_blank"` of your own is announced the same way. */
|
|
245
|
+
external?: boolean;
|
|
246
|
+
/** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
|
|
247
|
+
* `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
|
|
248
|
+
* with the disabled look. The router link is not used: it needs somewhere to go. */
|
|
249
|
+
disabled?: boolean;
|
|
250
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
251
|
+
type?: never;
|
|
252
|
+
form?: never;
|
|
253
|
+
pending?: never;
|
|
254
|
+
pressed?: never;
|
|
255
|
+
disabledReason?: never;
|
|
175
256
|
}
|
|
176
257
|
|
|
177
258
|
function hasContent(node: ReactNode): boolean {
|
|
178
259
|
return node !== undefined && node !== null && node !== false && node !== "";
|
|
179
260
|
}
|
|
180
261
|
|
|
181
|
-
|
|
262
|
+
/** The button look shared by the `<button>` and the `<a>` form. */
|
|
263
|
+
function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
|
|
264
|
+
return cn(
|
|
265
|
+
BUTTON_BASE,
|
|
266
|
+
BUTTON_SIZES[size],
|
|
267
|
+
// In a flex row next to a taller labelled field, `stretch` makes the button
|
|
268
|
+
// fill the field's height so the two line up (self-stretch overrides the row's
|
|
269
|
+
// align-items). No effect outside a flex row / when it's already the tallest.
|
|
270
|
+
stretch && "self-stretch",
|
|
271
|
+
buttonVariantClasses[variant],
|
|
272
|
+
tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
|
|
273
|
+
tone === "danger" && BUTTON_BOXED_DANGER[variant],
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Declared link-first so `<Button href>` picks the link props; the LAST signature is
|
|
278
|
+
// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
|
|
279
|
+
// `<button>` props they always did.
|
|
280
|
+
export function Button(props: ButtonLinkProps): ReactElement;
|
|
281
|
+
export function Button(props: ButtonProps): ReactElement;
|
|
282
|
+
export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
|
|
283
|
+
if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
|
|
284
|
+
return <ButtonElement {...(props as ButtonProps)} />;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
|
|
288
|
+
* inline `renderLink` arrow would otherwise be a new component type every render. */
|
|
289
|
+
function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
|
|
290
|
+
return render(props);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function ButtonLink({
|
|
294
|
+
href,
|
|
295
|
+
renderLink,
|
|
296
|
+
external,
|
|
297
|
+
disabled,
|
|
298
|
+
variant = "primary",
|
|
299
|
+
size = "md",
|
|
300
|
+
stretch,
|
|
301
|
+
tone = "default",
|
|
302
|
+
className,
|
|
303
|
+
children,
|
|
304
|
+
target,
|
|
305
|
+
rel,
|
|
306
|
+
onClick,
|
|
307
|
+
...rest
|
|
308
|
+
}: ButtonLinkProps) {
|
|
309
|
+
const kitLink = useKitLink();
|
|
310
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
311
|
+
// The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
|
|
312
|
+
// in-page `#anchor` is never handed to the provider's router link, even without
|
|
313
|
+
// `external`.
|
|
314
|
+
const Link = pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
|
|
315
|
+
const newTab = !disabled && (external || target === "_blank");
|
|
316
|
+
const cls = cn(
|
|
317
|
+
buttonLook(variant, size, stretch, tone),
|
|
318
|
+
// `relative` holds the sr-only note (see sr-only-containment.test).
|
|
319
|
+
newTab && "relative",
|
|
320
|
+
// BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
|
|
321
|
+
// keeps the hover fill from lighting up on something that refuses the click.
|
|
322
|
+
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
|
323
|
+
className,
|
|
324
|
+
);
|
|
325
|
+
const body = (
|
|
326
|
+
<>
|
|
327
|
+
{children}
|
|
328
|
+
{/* The space OUTSIDE the sr-only span: a name computation trims each child's
|
|
329
|
+
text, so one inside it was lost ("Download(opens…"). In a flex box a bare
|
|
330
|
+
space draws nothing. */}
|
|
331
|
+
{newTab && " "}
|
|
332
|
+
{newTab && <span className="sr-only">({common.opensInNewTab})</span>}
|
|
333
|
+
</>
|
|
334
|
+
);
|
|
335
|
+
if (disabled) {
|
|
336
|
+
return (
|
|
337
|
+
<a {...rest} role="link" aria-disabled="true" className={cls}>
|
|
338
|
+
{body}
|
|
339
|
+
</a>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (external || !Link) {
|
|
343
|
+
return (
|
|
344
|
+
<a
|
|
345
|
+
{...rest}
|
|
346
|
+
href={href}
|
|
347
|
+
target={external ? "_blank" : target}
|
|
348
|
+
rel={external ? (rel ?? "noopener noreferrer") : rel}
|
|
349
|
+
onClick={onClick}
|
|
350
|
+
className={cls}
|
|
351
|
+
>
|
|
352
|
+
{body}
|
|
353
|
+
</a>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return (
|
|
357
|
+
<RenderedButtonLink
|
|
358
|
+
render={Link}
|
|
359
|
+
{...rest}
|
|
360
|
+
href={href}
|
|
361
|
+
target={target}
|
|
362
|
+
rel={rel}
|
|
363
|
+
onClick={onClick}
|
|
364
|
+
className={cls}
|
|
365
|
+
>
|
|
366
|
+
{body}
|
|
367
|
+
</RenderedButtonLink>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function ButtonElement({
|
|
182
372
|
variant = "primary",
|
|
183
373
|
size = "md",
|
|
184
374
|
stretch,
|
|
185
375
|
tone = "default",
|
|
186
376
|
pressed,
|
|
187
377
|
disabledReason,
|
|
378
|
+
pending,
|
|
188
379
|
className,
|
|
189
380
|
onClick,
|
|
381
|
+
children,
|
|
382
|
+
href: _href,
|
|
383
|
+
renderLink: _renderLink,
|
|
384
|
+
external: _external,
|
|
190
385
|
...rest
|
|
191
386
|
}: ButtonProps) {
|
|
192
387
|
const reasonId = useId();
|
|
193
388
|
const locked = hasContent(disabledReason);
|
|
389
|
+
const inert = locked || Boolean(pending);
|
|
194
390
|
const ownDescribedBy = rest["aria-describedby"];
|
|
195
391
|
const button = (
|
|
196
392
|
<button
|
|
197
393
|
{...rest}
|
|
198
|
-
disabled={
|
|
199
|
-
aria-disabled={
|
|
394
|
+
disabled={inert ? undefined : rest.disabled}
|
|
395
|
+
aria-disabled={inert || rest["aria-disabled"]}
|
|
396
|
+
aria-busy={pending || rest["aria-busy"]}
|
|
200
397
|
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
201
398
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
202
399
|
onClick={(e) => {
|
|
203
400
|
// `preventDefault` as well as not calling through: a locked submit button must
|
|
204
401
|
// not submit its form, and the browser does that after this handler returns.
|
|
205
|
-
if (
|
|
402
|
+
if (inert) {
|
|
206
403
|
e.preventDefault();
|
|
207
404
|
return;
|
|
208
405
|
}
|
|
209
406
|
onClick?.(e);
|
|
210
407
|
}}
|
|
211
408
|
className={cn(
|
|
212
|
-
|
|
213
|
-
BUTTON_SIZES[size],
|
|
214
|
-
// In a flex row next to a taller labelled field, `stretch` makes the button
|
|
215
|
-
// fill the field's height so the two line up (self-stretch overrides the row's
|
|
216
|
-
// align-items). No effect outside a flex row / when it's already the tallest.
|
|
217
|
-
stretch && "self-stretch",
|
|
218
|
-
buttonVariantClasses[variant],
|
|
219
|
-
tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
|
|
409
|
+
buttonLook(variant, size, stretch, tone),
|
|
220
410
|
pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
|
|
221
411
|
// The disabled look for the focusable kind of disabled.
|
|
222
412
|
locked && "cursor-not-allowed opacity-50",
|
|
413
|
+
// No dimming while pending: the spinner IS the state, and at half opacity it
|
|
414
|
+
// would be the faintest thing on the button. `relative` anchors it.
|
|
415
|
+
pending && "relative cursor-progress",
|
|
223
416
|
className,
|
|
224
417
|
)}
|
|
225
|
-
|
|
418
|
+
>
|
|
419
|
+
{pending ? (
|
|
420
|
+
<>
|
|
421
|
+
{/* Transparent, not hidden: it keeps the width AND stays the accessible
|
|
422
|
+
name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
|
|
423
|
+
keeps an icon + text spaced as the button spaces them. */}
|
|
424
|
+
<span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
|
|
425
|
+
{/* Decorative: `aria-busy` says it, as on FileButton. */}
|
|
426
|
+
<Spinner
|
|
427
|
+
label={null}
|
|
428
|
+
className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
|
|
429
|
+
/>
|
|
430
|
+
</>
|
|
431
|
+
) : (
|
|
432
|
+
children
|
|
433
|
+
)}
|
|
434
|
+
</button>
|
|
226
435
|
);
|
|
227
436
|
if (!locked) return button;
|
|
228
437
|
return (
|
|
@@ -254,12 +463,15 @@ export function Button({
|
|
|
254
463
|
const ICON_BUTTON_BASE =
|
|
255
464
|
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
256
465
|
|
|
257
|
-
export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
|
|
466
|
+
export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
|
|
258
467
|
|
|
259
468
|
// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
|
|
260
469
|
// that touches its edges. The two small steps are lenkbank's list-row (28px) and
|
|
261
470
|
// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
|
|
262
471
|
const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
472
|
+
// 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
|
|
473
|
+
// `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
|
|
474
|
+
"2xl": "size-16 [&_svg]:size-7",
|
|
263
475
|
// 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
|
|
264
476
|
// past the 44px minimum rather than on it.
|
|
265
477
|
xl: "size-12 [&_svg]:size-6",
|
|
@@ -276,6 +488,7 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
276
488
|
// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
|
|
277
489
|
// written as a width plus a minimum height (the square it would otherwise be).
|
|
278
490
|
const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
|
|
491
|
+
"2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
|
|
279
492
|
xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
|
|
280
493
|
lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
|
|
281
494
|
md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
|
|
@@ -363,7 +576,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
|
|
|
363
576
|
// and would out-rank a caller's plain `hover:` override), each variant pins its
|
|
364
577
|
// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
|
|
365
578
|
// and only ever matches a disabled button.
|
|
366
|
-
const ICON_BUTTON_DISABLED_REST: Record<
|
|
579
|
+
const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
|
|
367
580
|
primary: "disabled:hover:bg-[var(--bg-surface-2)]",
|
|
368
581
|
secondary: "disabled:hover:bg-transparent",
|
|
369
582
|
ghost: "disabled:hover:bg-transparent",
|
|
@@ -371,6 +584,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
|
|
|
371
584
|
brand: "disabled:hover:bg-[var(--brand)]",
|
|
372
585
|
link: "disabled:hover:bg-transparent disabled:hover:no-underline",
|
|
373
586
|
overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
|
|
587
|
+
shutter: "disabled:hover:bg-[var(--media-scrim)]",
|
|
374
588
|
};
|
|
375
589
|
|
|
376
590
|
// The quiet looks change the glyph on hover, so they pin their resting glyph the
|
|
@@ -393,15 +607,69 @@ const ICON_BUTTON_PRESSED =
|
|
|
393
607
|
const ICON_BUTTON_OVERLAY =
|
|
394
608
|
"rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)] text-[var(--text-inverse)] backdrop-blur-sm hover:bg-[color-mix(in_srgb,var(--bg-inverse)_75%,transparent)] focus:ring-[var(--text-inverse)]";
|
|
395
609
|
|
|
610
|
+
// `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
|
|
611
|
+
// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
|
|
612
|
+
// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
|
|
613
|
+
// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
|
|
614
|
+
// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
|
|
615
|
+
// merge into the ring that is part of the look.
|
|
616
|
+
const ICON_BUTTON_SHUTTER =
|
|
617
|
+
"rounded-full border-4 border-[var(--media-ink)] bg-[var(--media-scrim)] text-[var(--media-ink)] backdrop-blur-sm hover:bg-[var(--media-scrim-hover)] focus:ring-[var(--media-ink)] focus:ring-offset-2 focus:ring-offset-[var(--media-scrim-hover)]";
|
|
618
|
+
|
|
619
|
+
export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The glyph's size in px, over the one the box size implies — see
|
|
623
|
+
* {@link IconButtonProps.glyphSize}.
|
|
624
|
+
*/
|
|
625
|
+
export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
|
|
626
|
+
|
|
627
|
+
const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
|
|
628
|
+
12: "[&_svg]:size-3",
|
|
629
|
+
14: "[&_svg]:size-3.5",
|
|
630
|
+
16: "[&_svg]:size-4",
|
|
631
|
+
20: "[&_svg]:size-5",
|
|
632
|
+
24: "[&_svg]:size-6",
|
|
633
|
+
28: "[&_svg]:size-7",
|
|
634
|
+
};
|
|
635
|
+
|
|
396
636
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
397
|
-
/** Any {@link ButtonVariant}
|
|
398
|
-
* an image (see `ICON_BUTTON_OVERLAY`)
|
|
399
|
-
|
|
400
|
-
|
|
637
|
+
/** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
|
|
638
|
+
* an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
|
|
639
|
+
* disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
|
|
640
|
+
variant?: IconButtonVariant;
|
|
641
|
+
/** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
|
|
642
|
+
* icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
|
|
401
643
|
* sm = 32px — all three with a 20px icon;
|
|
402
644
|
* xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
|
|
403
|
-
* icon (an action on a chip or a tab). */
|
|
645
|
+
* icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
|
|
404
646
|
size?: IconButtonSize;
|
|
647
|
+
/**
|
|
648
|
+
* The icon's size in px, when the box's own is not the one wanted. keksdose's sync
|
|
649
|
+
* chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
|
|
650
|
+
* `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
|
|
651
|
+
* over a new size step because the box and the glyph are two independent choices; a
|
|
652
|
+
* step per combination (`"sm-lg"`) would have to be named for every one.
|
|
653
|
+
*/
|
|
654
|
+
glyphSize?: IconButtonGlyphSize;
|
|
655
|
+
/**
|
|
656
|
+
* A small mark on the button's corner — the sync chip's 12px check over its cloud
|
|
657
|
+
* ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
|
|
658
|
+
* it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
|
|
659
|
+
* up to the glyph's size; a bare `<Check />` comes out 12px, on a
|
|
660
|
+
* `--bg-surface` disc that keeps it legible over the glyph. Decorative
|
|
661
|
+
* (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
|
|
662
|
+
* With a badge, `className` still lands on the button; the wrapper is the flex item.
|
|
663
|
+
*/
|
|
664
|
+
badge?: ReactNode;
|
|
665
|
+
/**
|
|
666
|
+
* `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
|
|
667
|
+
* cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
|
|
668
|
+
* green and refreshes on click, and is disabled while offline or syncing, when the
|
|
669
|
+
* state it shows matters most; dimmed it read as broken (keksdose wrote
|
|
670
|
+
* `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
|
|
671
|
+
*/
|
|
672
|
+
disabledStyle?: "dim" | "keep";
|
|
405
673
|
/**
|
|
406
674
|
* Fill the height of the flex row it stands in, keeping its width — the delete at
|
|
407
675
|
* the end of keksdose's split line (transaction-fields:162), level with the labelled
|
|
@@ -546,6 +814,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
546
814
|
tooltipSide,
|
|
547
815
|
tooltipPortal,
|
|
548
816
|
disabledReason,
|
|
817
|
+
glyphSize,
|
|
818
|
+
badge,
|
|
819
|
+
disabledStyle = "dim",
|
|
549
820
|
className,
|
|
550
821
|
style,
|
|
551
822
|
onClick,
|
|
@@ -555,6 +826,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
555
826
|
ref,
|
|
556
827
|
) {
|
|
557
828
|
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
829
|
+
const keep = disabledStyle === "keep";
|
|
558
830
|
const reasonId = useId();
|
|
559
831
|
const locked = hasContent(disabledReason);
|
|
560
832
|
const ownDescribedBy = rest["aria-describedby"];
|
|
@@ -586,18 +858,40 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
586
858
|
className={cn(
|
|
587
859
|
ICON_BUTTON_BASE,
|
|
588
860
|
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
861
|
+
glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
589
862
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
590
|
-
variant === "overlay"
|
|
863
|
+
variant === "overlay"
|
|
864
|
+
? ICON_BUTTON_OVERLAY
|
|
865
|
+
: variant === "shutter"
|
|
866
|
+
? ICON_BUTTON_SHUTTER
|
|
867
|
+
: buttonVariantClasses[variant],
|
|
591
868
|
shape === "round" && "rounded-full",
|
|
592
869
|
ICON_BUTTON_DISABLED_REST[variant],
|
|
593
870
|
iconButtonToneClass(tone, quiet),
|
|
594
871
|
pressed && ICON_BUTTON_PRESSED,
|
|
595
872
|
// The disabled look for the focusable kind of disabled, as on Button.
|
|
596
|
-
locked && "cursor-not-allowed opacity-50",
|
|
873
|
+
locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
|
|
874
|
+
keep && "disabled:cursor-default disabled:opacity-100",
|
|
597
875
|
className,
|
|
598
876
|
)}
|
|
599
877
|
/>
|
|
600
878
|
);
|
|
879
|
+
// The badge beside the button, not in it — see `badge`. The wrapper is only a
|
|
880
|
+
// positioning box: the button inside it keeps its own focus ring and hit area.
|
|
881
|
+
const control = hasContent(badge) ? (
|
|
882
|
+
<span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
|
|
883
|
+
{button}
|
|
884
|
+
<span
|
|
885
|
+
aria-hidden
|
|
886
|
+
data-slot="icon-button-badge"
|
|
887
|
+
className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
|
|
888
|
+
>
|
|
889
|
+
{badge}
|
|
890
|
+
</span>
|
|
891
|
+
</span>
|
|
892
|
+
) : (
|
|
893
|
+
button
|
|
894
|
+
);
|
|
601
895
|
if (locked) {
|
|
602
896
|
return (
|
|
603
897
|
// Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
|
|
@@ -609,7 +903,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
609
903
|
className={stretch ? "self-stretch" : undefined}
|
|
610
904
|
>
|
|
611
905
|
<>
|
|
612
|
-
{
|
|
906
|
+
{control}
|
|
613
907
|
<span id={reasonId} hidden>
|
|
614
908
|
{disabledReason}
|
|
615
909
|
</span>
|
|
@@ -617,12 +911,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
617
911
|
</Tooltip>
|
|
618
912
|
);
|
|
619
913
|
}
|
|
620
|
-
if (!tooltip || label === undefined || label === "") return
|
|
914
|
+
if (!tooltip || label === undefined || label === "") return control;
|
|
621
915
|
return (
|
|
622
916
|
// A fragment, so Tooltip leaves the button's description alone: the bubble says
|
|
623
917
|
// exactly what `aria-label` already does.
|
|
624
918
|
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
625
|
-
<>{
|
|
919
|
+
<>{control}</>
|
|
626
920
|
</Tooltip>
|
|
627
921
|
);
|
|
628
922
|
});
|
|
@@ -1503,17 +1797,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
1503
1797
|
* those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
|
|
1504
1798
|
* an inset panel never runs edge to edge.
|
|
1505
1799
|
*/
|
|
1506
|
-
variant?: "default" | "inset";
|
|
1800
|
+
variant?: "default" | "inset" | "outline";
|
|
1801
|
+
/**
|
|
1802
|
+
* The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
|
|
1803
|
+
*
|
|
1804
|
+
* `outline` is the bordered box with no shadow — a row or a block INSIDE a section
|
|
1805
|
+
* rather than a card on the page: kastlan's rent breakdown rows
|
|
1806
|
+
* (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
|
|
1807
|
+
* list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
|
|
1808
|
+
* default, as every one of those copies does; `sm` is the dense row.
|
|
1809
|
+
*
|
|
1810
|
+
* Left out, the default card keeps no padding (its CardHeader / CardContent own the
|
|
1811
|
+
* rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
|
|
1812
|
+
* in `className` still wins.
|
|
1813
|
+
*/
|
|
1814
|
+
padding?: "none" | "sm" | "md";
|
|
1815
|
+
/**
|
|
1816
|
+
* A card that is a WARNING (or other status) as a whole: the border in the tone's
|
|
1817
|
+
* `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
|
|
1818
|
+
* dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
|
|
1819
|
+
* `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
|
|
1820
|
+
* — and the next status card would copy the pair (`outline` takes it the same way).
|
|
1821
|
+
* An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
|
|
1822
|
+
* `data-tone` is set for a caller's own
|
|
1823
|
+
* selectors.
|
|
1824
|
+
*/
|
|
1825
|
+
tone?: CardTone;
|
|
1507
1826
|
}
|
|
1508
1827
|
|
|
1509
|
-
export
|
|
1828
|
+
export type CardTone = "warning" | "danger" | "info" | "success";
|
|
1829
|
+
|
|
1830
|
+
// The title is reached through its `data-slot`, so the tone needs no context and a
|
|
1831
|
+
// caller's `className` on CardTitle still wins (it is on the element itself).
|
|
1832
|
+
const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
|
|
1833
|
+
warning: {
|
|
1834
|
+
border: "border-[var(--warning-border)]",
|
|
1835
|
+
fill: "bg-[var(--warning-bg)]",
|
|
1836
|
+
title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
|
|
1837
|
+
},
|
|
1838
|
+
danger: {
|
|
1839
|
+
border: "border-[var(--danger-border)]",
|
|
1840
|
+
fill: "bg-[var(--danger-bg)]",
|
|
1841
|
+
title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
|
|
1842
|
+
},
|
|
1843
|
+
info: {
|
|
1844
|
+
border: "border-[var(--info-border)]",
|
|
1845
|
+
fill: "bg-[var(--info-bg)]",
|
|
1846
|
+
title: "[&_[data-slot=card-title]]:text-[var(--info)]",
|
|
1847
|
+
},
|
|
1848
|
+
success: {
|
|
1849
|
+
border: "border-[var(--success-border)]",
|
|
1850
|
+
fill: "bg-[var(--success-bg)]",
|
|
1851
|
+
title: "[&_[data-slot=card-title]]:text-[var(--success)]",
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
|
|
1856
|
+
none: "p-0",
|
|
1857
|
+
sm: "p-3",
|
|
1858
|
+
md: "p-4",
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
|
|
1862
|
+
const toned = tone ? CARD_TONES[tone] : undefined;
|
|
1863
|
+
// An outline card is bordered like the default one, so a tone recolours its border.
|
|
1864
|
+
const bordered = variant !== "inset";
|
|
1510
1865
|
return (
|
|
1511
1866
|
<div
|
|
1867
|
+
data-tone={tone}
|
|
1512
1868
|
{...rest}
|
|
1513
1869
|
className={cn(
|
|
1514
1870
|
variant === "inset"
|
|
1515
1871
|
? "rounded-md bg-[var(--bg-surface-2)] p-3"
|
|
1516
|
-
:
|
|
1872
|
+
: variant === "outline"
|
|
1873
|
+
? // No shadow and no `flush`: it sits on a surface that already has both.
|
|
1874
|
+
"rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
|
|
1875
|
+
: cn(
|
|
1517
1876
|
// Surface + border are theme tokens so the palette switcher (feedback
|
|
1518
1877
|
// #307) can re-skin every card; a caller's own bg-*/border-* override
|
|
1519
1878
|
// still wins via tailwind-merge.
|
|
@@ -1522,6 +1881,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
|
|
|
1522
1881
|
? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
|
|
1523
1882
|
: "rounded-lg border border-[var(--border)] shadow-sm",
|
|
1524
1883
|
),
|
|
1884
|
+
toned && (bordered ? toned.border : toned.fill),
|
|
1885
|
+
toned?.title,
|
|
1886
|
+
padding && CARD_PADDING[padding],
|
|
1525
1887
|
className,
|
|
1526
1888
|
)}
|
|
1527
1889
|
>
|
|
@@ -1623,6 +1985,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1623
1985
|
* one both announce).
|
|
1624
1986
|
*/
|
|
1625
1987
|
label?: string | null;
|
|
1988
|
+
/**
|
|
1989
|
+
* Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
|
|
1990
|
+
* LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
|
|
1991
|
+
* `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
|
|
1992
|
+
* has to get the three-part aria arrangement right itself. Here the visible text IS
|
|
1993
|
+
* the live region's content (the label stays the accessible name, said once), and
|
|
1994
|
+
* the ring is decorative. `className` still sizes the ring; the other props go on
|
|
1995
|
+
* the wrapper. Ignored with `label={null}`: there are no words to show.
|
|
1996
|
+
*/
|
|
1997
|
+
showLabel?: boolean;
|
|
1998
|
+
/** Where the shown label sits: `end` (default) — beside the ring, for a row or a
|
|
1999
|
+
* button; `below` — under it, centred, for a panel's loading state. */
|
|
2000
|
+
labelPosition?: "end" | "below";
|
|
1626
2001
|
}
|
|
1627
2002
|
|
|
1628
2003
|
/**
|
|
@@ -1640,7 +2015,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1640
2015
|
* `label={null}`: announcing is right for a spinner standing alone and wrong for one
|
|
1641
2016
|
* beside text, inside a button, or inside the app's own live region.
|
|
1642
2017
|
*/
|
|
1643
|
-
export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
2018
|
+
export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
|
|
1644
2019
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
|
|
1645
2020
|
loading: label ?? undefined,
|
|
1646
2021
|
});
|
|
@@ -1649,6 +2024,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1649
2024
|
className,
|
|
1650
2025
|
);
|
|
1651
2026
|
if (label === null) return <span aria-hidden {...rest} className={ring} />;
|
|
2027
|
+
if (showLabel) {
|
|
2028
|
+
return (
|
|
2029
|
+
<span
|
|
2030
|
+
role="status"
|
|
2031
|
+
{...rest}
|
|
2032
|
+
className={cn(
|
|
2033
|
+
"inline-flex items-center text-sm text-[var(--text-secondary)]",
|
|
2034
|
+
labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
|
|
2035
|
+
)}
|
|
2036
|
+
>
|
|
2037
|
+
<span aria-hidden className={ring} />
|
|
2038
|
+
<span>{common.loading}</span>
|
|
2039
|
+
</span>
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
1652
2042
|
return (
|
|
1653
2043
|
<span role="status" {...rest} className={ring}>
|
|
1654
2044
|
{/* The trailing space is a separator for the case the caller forgot `label={null}`
|