@eifi1/ui-kit 0.10.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 +66 -37
- 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/alert-banner.d.ts +26 -1
- package/dist/components/alert-banner.js +10 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +26 -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/bulk-action-bar.d.ts +51 -5
- package/dist/components/bulk-action-bar.js +88 -41
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +107 -3
- package/dist/components/button-group.js +60 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +17 -4
- package/dist/components/calendar-heatmap.d.ts +162 -0
- package/dist/components/calendar-heatmap.js +336 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- 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 +66 -2
- package/dist/components/choice-card.js +108 -1
- 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 +52 -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 +104 -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 +69 -0
- package/dist/components/field.js +75 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +51 -208
- package/dist/components/file-dropzone.d.ts +50 -2
- package/dist/components/floating-panel.d.ts +156 -4
- package/dist/components/floating-panel.js +193 -29
- 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 +37 -3
- package/dist/components/list.js +39 -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 +13 -2
- package/dist/components/menu-item.js +11 -2
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +82 -5
- package/dist/components/mini-calendar.js +212 -64
- 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 +67 -0
- package/dist/components/nav-pills.js +69 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +26 -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 +27 -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 +17 -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 +73 -3
- package/dist/components/progress-bar.js +110 -8
- 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/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +50 -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/status-dot.d.ts +6 -2
- package/dist/components/status-dot.js +6 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/table.d.ts +77 -11
- package/dist/components/table.js +85 -17
- 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/text.d.ts +8 -1
- package/dist/components/text.js +1 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +50 -1
- package/dist/components/toggle-group.d.ts +28 -0
- package/dist/components/toggle-group.js +58 -9
- 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/tooltip.d.ts +11 -3
- package/dist/components/tooltip.js +2 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +52 -729
- package/dist/components/ui.js +296 -27
- 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/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.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 +50 -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 +17 -4
- package/dist/i18n/defaults.js +33 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +47 -310
- 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 +17 -4
- package/dist/i18n/locales/de-CH.d.ts +17 -4
- package/dist/i18n/locales/de-informal.d.ts +17 -4
- package/dist/i18n/locales/de.d.ts +17 -4
- package/dist/i18n/locales/de.js +165 -4
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +17 -4
- package/dist/i18n/locales/es.js +162 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +17 -4
- package/dist/i18n/locales/fr.js +162 -4
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +17 -4
- package/dist/i18n/locales/hu.js +162 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +17 -4
- package/dist/i18n/locales/it.js +162 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +17 -4
- package/dist/i18n/locales/zh.js +162 -4
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +45 -21
- package/dist/index.js +43 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
- package/dist/lib/clipping.d.ts +21 -5
- package/dist/lib/clipping.js +3 -0
- package/dist/lib/clipping.js.map +1 -1
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- 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 +50 -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/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +63 -1
- package/dist/rhf.js +3 -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 +84 -3
- package/dist/shell/topbar-action-menu.js +115 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +58 -2
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -1
- package/dist/wizard/wizard-context.d.ts +30 -1
- package/dist/wizard/wizard-context.js +23 -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 +51 -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/alert-banner.tsx +36 -7
- 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/bulk-action-bar.tsx +163 -57
- package/src/components/button-group.tsx +129 -4
- package/src/components/calendar-heatmap.tsx +599 -0
- package/src/components/chip.tsx +8 -4
- package/src/components/choice-card.tsx +208 -1
- 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 +231 -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 +188 -0
- package/src/components/floating-panel.tsx +380 -16
- 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 +93 -18
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +29 -3
- package/src/components/mini-calendar.tsx +317 -57
- 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 +152 -0
- 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 +241 -13
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/series-chart.tsx +21 -0
- 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/status-dot.tsx +10 -2
- package/src/components/table.tsx +186 -23
- package/src/components/text-link.tsx +248 -0
- package/src/components/text.tsx +9 -1
- package/src/components/toggle-group.tsx +106 -13
- package/src/components/toggle-legend.tsx +87 -2
- package/src/components/tooltip.tsx +17 -3
- package/src/components/ui.tsx +572 -44
- package/src/components/use-table-state.ts +265 -33
- package/src/components/user-avatar.tsx +57 -3
- 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 +32 -0
- package/src/i18n/kit-labels.tsx +104 -8
- package/src/i18n/locales/de.ts +162 -0
- package/src/i18n/locales/es.ts +159 -0
- package/src/i18n/locales/fr.ts +162 -0
- package/src/i18n/locales/hu.ts +160 -0
- package/src/i18n/locales/it.ts +160 -0
- package/src/i18n/locales/zh.ts +159 -0
- package/src/index.ts +64 -0
- package/src/lib/clipping.ts +22 -4
- package/src/lib/dates.ts +37 -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/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +8 -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 +260 -49
- package/src/shell.ts +2 -0
- package/src/wizard/wizard-context.tsx +50 -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,18 @@ 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 = "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",
|
|
475
|
+
// 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
|
|
476
|
+
// past the 44px minimum rather than on it.
|
|
477
|
+
xl: "size-12 [&_svg]:size-6",
|
|
263
478
|
// The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
|
|
264
479
|
// bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
|
|
265
480
|
lg: "size-11 [&_svg]:size-5",
|
|
@@ -269,6 +484,19 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
269
484
|
"2xs": "size-6 rounded [&_svg]:size-3.5",
|
|
270
485
|
};
|
|
271
486
|
|
|
487
|
+
// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of
|
|
488
|
+
// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
|
|
489
|
+
// written as a width plus a minimum height (the square it would otherwise be).
|
|
490
|
+
const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
|
|
491
|
+
"2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
|
|
492
|
+
xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
|
|
493
|
+
lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
|
|
494
|
+
md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
|
|
495
|
+
sm: "w-8 min-h-8 self-stretch [&_svg]:size-5",
|
|
496
|
+
xs: "w-7 min-h-7 self-stretch rounded [&_svg]:size-4",
|
|
497
|
+
"2xs": "w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5",
|
|
498
|
+
};
|
|
499
|
+
|
|
272
500
|
// A tone re-colours the glyph without changing what the variant draws around it.
|
|
273
501
|
// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
|
|
274
502
|
// QUIET is placeholder grey until the pointer or focus arrives, then the tone's
|
|
@@ -279,9 +507,14 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
279
507
|
// on the row to notice (keksdose's "needs review" flag, its reconcile action at
|
|
280
508
|
// accounts-page:867), and quiet grey would hide the very thing they point out.
|
|
281
509
|
// `muted` is quiet by definition and has no toned look; `default` has no tone.
|
|
282
|
-
|
|
510
|
+
//
|
|
511
|
+
// `success` is toned at rest too: the green is the news (keksdose's "in sync" chip).
|
|
512
|
+
// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose
|
|
513
|
+
// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as
|
|
514
|
+
// the sync moves), where swapping `tone` would do but the colour is one variable.
|
|
515
|
+
export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info" | "success" | "custom";
|
|
283
516
|
|
|
284
|
-
type ColouredTone = "danger" | "warning" | "info";
|
|
517
|
+
type ColouredTone = "danger" | "warning" | "info" | "success" | "custom";
|
|
285
518
|
|
|
286
519
|
const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
|
|
287
520
|
danger: {
|
|
@@ -299,13 +532,33 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
|
|
|
299
532
|
"text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
|
|
300
533
|
toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
|
|
301
534
|
},
|
|
535
|
+
success: {
|
|
536
|
+
quiet:
|
|
537
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--success-bg)] hover:text-[var(--success)] focus:ring-[var(--success-border)]",
|
|
538
|
+
toned: "text-[var(--success)] hover:bg-[var(--success-bg)] focus:ring-[var(--success-border)]",
|
|
539
|
+
},
|
|
540
|
+
// The hover fill and the ring are MIXED from the one colour, since a custom tone
|
|
541
|
+
// brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a
|
|
542
|
+
// `custom` tone with no colour set is the default look rather than invisible.
|
|
543
|
+
custom: {
|
|
544
|
+
quiet:
|
|
545
|
+
"text-[var(--text-placeholder)] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
|
|
546
|
+
toned:
|
|
547
|
+
"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
|
|
548
|
+
},
|
|
302
549
|
};
|
|
303
550
|
|
|
304
551
|
const ICON_BUTTON_MUTED =
|
|
305
552
|
"text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
|
|
306
553
|
|
|
307
554
|
/** Which tones sit quiet at rest when `quiet` is left out. */
|
|
308
|
-
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
|
|
555
|
+
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
|
|
556
|
+
danger: true,
|
|
557
|
+
warning: false,
|
|
558
|
+
info: false,
|
|
559
|
+
success: false,
|
|
560
|
+
custom: false,
|
|
561
|
+
};
|
|
309
562
|
|
|
310
563
|
function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
|
|
311
564
|
if (tone === "default") return "";
|
|
@@ -323,7 +576,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
|
|
|
323
576
|
// and would out-rank a caller's plain `hover:` override), each variant pins its
|
|
324
577
|
// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
|
|
325
578
|
// and only ever matches a disabled button.
|
|
326
|
-
const ICON_BUTTON_DISABLED_REST: Record<
|
|
579
|
+
const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
|
|
327
580
|
primary: "disabled:hover:bg-[var(--bg-surface-2)]",
|
|
328
581
|
secondary: "disabled:hover:bg-transparent",
|
|
329
582
|
ghost: "disabled:hover:bg-transparent",
|
|
@@ -331,6 +584,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
|
|
|
331
584
|
brand: "disabled:hover:bg-[var(--brand)]",
|
|
332
585
|
link: "disabled:hover:bg-transparent disabled:hover:no-underline",
|
|
333
586
|
overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
|
|
587
|
+
shutter: "disabled:hover:bg-[var(--media-scrim)]",
|
|
334
588
|
};
|
|
335
589
|
|
|
336
590
|
// The quiet looks change the glyph on hover, so they pin their resting glyph the
|
|
@@ -353,20 +607,94 @@ const ICON_BUTTON_PRESSED =
|
|
|
353
607
|
const ICON_BUTTON_OVERLAY =
|
|
354
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)]";
|
|
355
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
|
+
|
|
356
636
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
357
|
-
/** Any {@link ButtonVariant}
|
|
358
|
-
* an image (see `ICON_BUTTON_OVERLAY`)
|
|
359
|
-
|
|
360
|
-
|
|
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),
|
|
361
643
|
* sm = 32px — all three with a 20px icon;
|
|
362
644
|
* xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
|
|
363
|
-
* icon (an action on a chip or a tab). */
|
|
645
|
+
* icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
|
|
364
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";
|
|
673
|
+
/**
|
|
674
|
+
* Fill the height of the flex row it stands in, keeping its width — the delete at
|
|
675
|
+
* the end of keksdose's split line (transaction-fields:162), level with the labelled
|
|
676
|
+
* fields beside it rather than floating at half their height. {@link Button}'s
|
|
677
|
+
* `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is
|
|
678
|
+
* what stretches.
|
|
679
|
+
*/
|
|
680
|
+
stretch?: boolean;
|
|
681
|
+
/** `round`: a circle instead of the rounded square — a compact status chip in a top
|
|
682
|
+
* bar (keksdose's sync indicator). `overlay` is always round. */
|
|
683
|
+
shape?: "square" | "round";
|
|
684
|
+
/**
|
|
685
|
+
* The colour of `tone="custom"`, as any CSS colour — a token (`var(--warning)`),
|
|
686
|
+
* ideally. Sets `--icon-button-tone` on the button, so passing a different one per
|
|
687
|
+
* state re-colours the glyph, its hover fill and its focus ring together. Passing it
|
|
688
|
+
* implies `tone="custom"`. Or leave it out and set the variable yourself from a class
|
|
689
|
+
* (`className="[--icon-button-tone:var(--success)]"`).
|
|
690
|
+
*/
|
|
691
|
+
toneColor?: string;
|
|
365
692
|
/** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
|
|
366
693
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
367
694
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
368
695
|
* wants attention. `info`: sky at rest — a notice-worthy but harmless action
|
|
369
|
-
* (keksdose's reconcile).
|
|
696
|
+
* (keksdose's reconcile). `success`: green at rest — a good state worth seeing.
|
|
697
|
+
* `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for
|
|
370
698
|
* turning a coloured tone's resting look the other way. */
|
|
371
699
|
tone?: IconButtonTone;
|
|
372
700
|
/**
|
|
@@ -448,35 +776,77 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
448
776
|
tooltipSide?: TooltipSide;
|
|
449
777
|
/** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
|
|
450
778
|
tooltipPortal?: boolean;
|
|
779
|
+
/**
|
|
780
|
+
* Why the action is not available — {@link Button}'s `disabledReason`, on the icon
|
|
781
|
+
* button: keksdose's accounts page cannot hide an account with a balance or delete
|
|
782
|
+
* one with bookings (accounts-page:879/913 — "hide requires zero", "delete blocked"),
|
|
783
|
+
* nor its budgets page delete the only budget (budgets-page:330), and each wrapped a
|
|
784
|
+
* natively disabled IconButton in a Tooltip that swaps its label for the reason — a
|
|
785
|
+
* bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason
|
|
786
|
+
* a screen reader never hears.
|
|
787
|
+
*
|
|
788
|
+
* The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays
|
|
789
|
+
* focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed
|
|
790
|
+
* — `stopPropagation` still applies, so a locked action in a clickable row does not
|
|
791
|
+
* open the row either; the reason shows in the kit {@link Tooltip} and is attached
|
|
792
|
+
* through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /
|
|
793
|
+
* `label` — "Delete", described by "The active budget cannot be deleted" — and the
|
|
794
|
+
* bubble shows the reason in place of the label, since the glyph already says what
|
|
795
|
+
* the button is and the reason is the news. Shown even with `tooltip={false}`: a
|
|
796
|
+
* reason nobody can see is not one. Wins over `disabled`, as on Button.
|
|
797
|
+
*/
|
|
798
|
+
disabledReason?: ReactNode;
|
|
451
799
|
}
|
|
452
800
|
|
|
453
801
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
454
802
|
{
|
|
455
803
|
variant = "ghost",
|
|
456
804
|
size = "md",
|
|
457
|
-
tone
|
|
805
|
+
tone: toneProp,
|
|
458
806
|
quiet,
|
|
459
807
|
pressed,
|
|
460
808
|
stopPropagation,
|
|
809
|
+
stretch,
|
|
810
|
+
shape = "square",
|
|
811
|
+
toneColor,
|
|
461
812
|
label,
|
|
462
813
|
tooltip = true,
|
|
463
814
|
tooltipSide,
|
|
464
815
|
tooltipPortal,
|
|
816
|
+
disabledReason,
|
|
817
|
+
glyphSize,
|
|
818
|
+
badge,
|
|
819
|
+
disabledStyle = "dim",
|
|
465
820
|
className,
|
|
821
|
+
style,
|
|
466
822
|
onClick,
|
|
467
823
|
onKeyDown,
|
|
468
824
|
...rest
|
|
469
825
|
},
|
|
470
826
|
ref,
|
|
471
827
|
) {
|
|
828
|
+
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
829
|
+
const keep = disabledStyle === "keep";
|
|
830
|
+
const reasonId = useId();
|
|
831
|
+
const locked = hasContent(disabledReason);
|
|
832
|
+
const ownDescribedBy = rest["aria-describedby"];
|
|
472
833
|
const button = (
|
|
473
834
|
<button
|
|
474
835
|
ref={ref}
|
|
475
836
|
{...rest}
|
|
837
|
+
disabled={locked ? undefined : rest.disabled}
|
|
838
|
+
aria-disabled={locked || rest["aria-disabled"]}
|
|
839
|
+
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
476
840
|
aria-label={rest["aria-label"] ?? label}
|
|
477
841
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
842
|
+
style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
|
|
478
843
|
onClick={(e) => {
|
|
479
844
|
if (stopPropagation) e.stopPropagation();
|
|
845
|
+
// `preventDefault` too: a locked submit must not submit its form.
|
|
846
|
+
if (locked) {
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
480
850
|
onClick?.(e);
|
|
481
851
|
}}
|
|
482
852
|
onKeyDown={(e) => {
|
|
@@ -487,22 +857,66 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
487
857
|
}}
|
|
488
858
|
className={cn(
|
|
489
859
|
ICON_BUTTON_BASE,
|
|
490
|
-
ICON_BUTTON_SIZES[size],
|
|
860
|
+
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
861
|
+
glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
491
862
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
492
|
-
variant === "overlay"
|
|
863
|
+
variant === "overlay"
|
|
864
|
+
? ICON_BUTTON_OVERLAY
|
|
865
|
+
: variant === "shutter"
|
|
866
|
+
? ICON_BUTTON_SHUTTER
|
|
867
|
+
: buttonVariantClasses[variant],
|
|
868
|
+
shape === "round" && "rounded-full",
|
|
493
869
|
ICON_BUTTON_DISABLED_REST[variant],
|
|
494
870
|
iconButtonToneClass(tone, quiet),
|
|
495
871
|
pressed && ICON_BUTTON_PRESSED,
|
|
872
|
+
// The disabled look for the focusable kind of disabled, as on Button.
|
|
873
|
+
locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
|
|
874
|
+
keep && "disabled:cursor-default disabled:opacity-100",
|
|
496
875
|
className,
|
|
497
876
|
)}
|
|
498
877
|
/>
|
|
499
878
|
);
|
|
500
|
-
|
|
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
|
+
);
|
|
895
|
+
if (locked) {
|
|
896
|
+
return (
|
|
897
|
+
// Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
|
|
898
|
+
// not add its bubble to the description as well.
|
|
899
|
+
<Tooltip
|
|
900
|
+
label={disabledReason}
|
|
901
|
+
side={tooltipSide}
|
|
902
|
+
portal={tooltipPortal}
|
|
903
|
+
className={stretch ? "self-stretch" : undefined}
|
|
904
|
+
>
|
|
905
|
+
<>
|
|
906
|
+
{control}
|
|
907
|
+
<span id={reasonId} hidden>
|
|
908
|
+
{disabledReason}
|
|
909
|
+
</span>
|
|
910
|
+
</>
|
|
911
|
+
</Tooltip>
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (!tooltip || label === undefined || label === "") return control;
|
|
501
915
|
return (
|
|
502
916
|
// A fragment, so Tooltip leaves the button's description alone: the bubble says
|
|
503
917
|
// exactly what `aria-label` already does.
|
|
504
|
-
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal}>
|
|
505
|
-
<>{
|
|
918
|
+
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
919
|
+
<>{control}</>
|
|
506
920
|
</Tooltip>
|
|
507
921
|
);
|
|
508
922
|
});
|
|
@@ -1383,17 +1797,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
1383
1797
|
* those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
|
|
1384
1798
|
* an inset panel never runs edge to edge.
|
|
1385
1799
|
*/
|
|
1386
|
-
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;
|
|
1387
1826
|
}
|
|
1388
1827
|
|
|
1389
|
-
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";
|
|
1390
1865
|
return (
|
|
1391
1866
|
<div
|
|
1867
|
+
data-tone={tone}
|
|
1392
1868
|
{...rest}
|
|
1393
1869
|
className={cn(
|
|
1394
1870
|
variant === "inset"
|
|
1395
1871
|
? "rounded-md bg-[var(--bg-surface-2)] p-3"
|
|
1396
|
-
:
|
|
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(
|
|
1397
1876
|
// Surface + border are theme tokens so the palette switcher (feedback
|
|
1398
1877
|
// #307) can re-skin every card; a caller's own bg-*/border-* override
|
|
1399
1878
|
// still wins via tailwind-merge.
|
|
@@ -1402,6 +1881,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
|
|
|
1402
1881
|
? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
|
|
1403
1882
|
: "rounded-lg border border-[var(--border)] shadow-sm",
|
|
1404
1883
|
),
|
|
1884
|
+
toned && (bordered ? toned.border : toned.fill),
|
|
1885
|
+
toned?.title,
|
|
1886
|
+
padding && CARD_PADDING[padding],
|
|
1405
1887
|
className,
|
|
1406
1888
|
)}
|
|
1407
1889
|
>
|
|
@@ -1503,6 +1985,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1503
1985
|
* one both announce).
|
|
1504
1986
|
*/
|
|
1505
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";
|
|
1506
2001
|
}
|
|
1507
2002
|
|
|
1508
2003
|
/**
|
|
@@ -1520,7 +2015,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1520
2015
|
* `label={null}`: announcing is right for a spinner standing alone and wrong for one
|
|
1521
2016
|
* beside text, inside a button, or inside the app's own live region.
|
|
1522
2017
|
*/
|
|
1523
|
-
export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
2018
|
+
export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
|
|
1524
2019
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
|
|
1525
2020
|
loading: label ?? undefined,
|
|
1526
2021
|
});
|
|
@@ -1529,6 +2024,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1529
2024
|
className,
|
|
1530
2025
|
);
|
|
1531
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
|
+
}
|
|
1532
2042
|
return (
|
|
1533
2043
|
<span role="status" {...rest} className={ring}>
|
|
1534
2044
|
{/* The trailing space is a separator for the case the caller forgot `label={null}`
|
|
@@ -1590,6 +2100,16 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
1590
2100
|
* verdict.
|
|
1591
2101
|
*/
|
|
1592
2102
|
tone?: "danger" | "success";
|
|
2103
|
+
/**
|
|
2104
|
+
* `inline` only (0.11.0). `sm`: 12px, start-aligned and tight (`py-1`) — the empty
|
|
2105
|
+
* line INSIDE a detail panel, under a panel heading and among `text-xs` rows, where
|
|
2106
|
+
* the default's 14px centred line with `py-4` read as a section of its own.
|
|
2107
|
+
* keksdose's holdings panel (holdings-panel:563), asset-loan panel
|
|
2108
|
+
* (asset-loan-panel:108) and loan-payment panel (loan-payment-panel:191) each wrote
|
|
2109
|
+
* `className="justify-start py-1"` over it and still got body-size text. Default `md`,
|
|
2110
|
+
* the look it had. Ignored by the box.
|
|
2111
|
+
*/
|
|
2112
|
+
size?: "sm" | "md";
|
|
1593
2113
|
}
|
|
1594
2114
|
|
|
1595
2115
|
const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
|
|
@@ -1605,6 +2125,7 @@ export function EmptyState({
|
|
|
1605
2125
|
headingAs,
|
|
1606
2126
|
variant = "box",
|
|
1607
2127
|
tone,
|
|
2128
|
+
size = "md",
|
|
1608
2129
|
className,
|
|
1609
2130
|
...rest
|
|
1610
2131
|
}: EmptyStateProps) {
|
|
@@ -1612,22 +2133,29 @@ export function EmptyState({
|
|
|
1612
2133
|
const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
|
|
1613
2134
|
const hasHint = hint != null && hint !== false && hint !== "";
|
|
1614
2135
|
if (variant === "inline") {
|
|
2136
|
+
const sm = size === "sm";
|
|
1615
2137
|
return (
|
|
1616
2138
|
<div
|
|
1617
2139
|
{...rest}
|
|
1618
2140
|
className={cn(
|
|
1619
|
-
"flex flex-wrap items-center
|
|
2141
|
+
"flex flex-wrap items-center text-[var(--text-muted)]",
|
|
2142
|
+
sm
|
|
2143
|
+
? "justify-start gap-x-1.5 gap-y-0.5 py-1 text-start text-xs"
|
|
2144
|
+
: "justify-center gap-x-2 gap-y-1 py-4 text-center text-sm",
|
|
1620
2145
|
className,
|
|
1621
2146
|
)}
|
|
1622
2147
|
>
|
|
1623
2148
|
{icon != null && (
|
|
1624
|
-
<span
|
|
2149
|
+
<span
|
|
2150
|
+
aria-hidden
|
|
2151
|
+
className={cn("flex text-[var(--text-placeholder)]", sm ? "[&_svg]:size-3.5" : "[&_svg]:size-4", toneClass)}
|
|
2152
|
+
>
|
|
1625
2153
|
{icon}
|
|
1626
2154
|
</span>
|
|
1627
2155
|
)}
|
|
1628
2156
|
{/* No weight of its own: one muted line is what these sites were, and a
|
|
1629
2157
|
bold title would turn a quiet "nothing here" into a heading. */}
|
|
1630
|
-
<Title className={cn("text-sm", toneClass)}>{title}</Title>
|
|
2158
|
+
<Title className={cn(sm ? "text-xs" : "text-sm", toneClass)}>{title}</Title>
|
|
1631
2159
|
{hasHint && <span className="text-xs">{hint}</span>}
|
|
1632
2160
|
{action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
|
|
1633
2161
|
</div>
|