@eifi1/ui-kit 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -34
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +40 -4
- package/dist/components/amount-input.js +50 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +10 -2
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +51 -2
- package/dist/components/button-group.js +5 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +15 -4
- package/dist/components/calendar-heatmap.d.ts +41 -3
- package/dist/components/calendar-heatmap.js +48 -7
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart.js +14 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +26 -18
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +35 -3
- package/dist/components/choice-card.js +83 -25
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +3 -1
- package/dist/components/combobox-core.js +18 -5
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +51 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +8 -1
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +125 -27
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +40 -20
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +281 -0
- package/dist/components/error-boundary.js +302 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field-sync.js +90 -83
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/field.d.ts +30 -4
- package/dist/components/field.js +40 -17
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +50 -208
- package/dist/components/file-dropzone.d.ts +49 -2
- package/dist/components/floating-panel.d.ts +13 -4
- package/dist/components/floating-panel.js +7 -3
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/full-bleed-dialog.js +4 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/hover-menu.js +64 -56
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +37 -4
- package/dist/components/list.js +35 -14
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +5 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +13 -1
- package/dist/components/mini-calendar.js +46 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +42 -20
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -1
- package/dist/components/nav-pills.js +5 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.d.ts +24 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +25 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +20 -5
- package/dist/components/numpad-sheet.js +3 -2
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-header.d.ts +35 -6
- package/dist/components/page-header.js +7 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +5 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/popover.js +3 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.d.ts +44 -3
- package/dist/components/progress-bar.js +58 -22
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/settings-fields.d.ts +49 -1
- package/dist/components/signed-amount.d.ts +122 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +6 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swipeable-row.js +4 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +39 -5
- package/dist/components/table.js +45 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +238 -0
- package/dist/components/text-link.js +144 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +26 -2
- package/dist/components/text.js +16 -2
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +35 -0
- package/dist/components/toggle-group.js +43 -14
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +53 -3
- package/dist/components/toggle-legend.js +54 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +51 -1
- package/dist/components/tooltip.js +59 -35
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +364 -34
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +4 -6
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +21 -5
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js +86 -75
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +173 -0
- package/dist/feedback/feedback-thread.js +264 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +49 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +15 -4
- package/dist/i18n/defaults.js +31 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +46 -312
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
- package/dist/i18n/locales/de-CH.d.ts +15 -4
- package/dist/i18n/locales/de-informal.d.ts +15 -4
- package/dist/i18n/locales/de.d.ts +15 -4
- package/dist/i18n/locales/de.js +164 -3
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +15 -4
- package/dist/i18n/locales/es.js +161 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +15 -4
- package/dist/i18n/locales/fr.js +161 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +15 -4
- package/dist/i18n/locales/hu.js +161 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +15 -4
- package/dist/i18n/locales/it.js +161 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +15 -4
- package/dist/i18n/locales/zh.js +161 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +39 -19
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
- package/dist/lib/format.d.ts +162 -0
- package/dist/lib/format.js +151 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +283 -0
- package/dist/rhf/fields.js +631 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +49 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf.d.ts +61 -1
- package/dist/rhf.js +2 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.js +20 -14
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +120 -57
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +21 -4
- package/dist/shell/topbar-action-menu.js +18 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +56 -1
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour.js +10 -4
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +48 -1
- package/dist/wizard/use-wizard.js +4 -2
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +24 -1
- package/dist/wizard/wizard-context.js +19 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +30 -5
- package/dist/wizard/wizard-step.js +34 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +6 -6
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/amount-input.tsx +109 -10
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +16 -3
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chart.tsx +19 -8
- package/src/components/chip.tsx +14 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +33 -9
- package/src/components/combobox.tsx +33 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filter-popover.tsx +13 -5
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +230 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +25 -7
- package/src/components/error-boundary.tsx +640 -0
- package/src/components/field-sync.tsx +48 -40
- package/src/components/field.tsx +56 -5
- package/src/components/floating-panel.tsx +20 -7
- package/src/components/form-actions.tsx +191 -0
- package/src/components/full-bleed-dialog.tsx +4 -0
- package/src/components/hover-menu.tsx +26 -17
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +82 -19
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +7 -2
- package/src/components/mini-calendar.tsx +72 -3
- package/src/components/modal.tsx +65 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +27 -7
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +17 -1
- package/src/components/numpad-sheet.tsx +6 -1
- package/src/components/page-header.tsx +42 -5
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +10 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/progress-bar.tsx +129 -19
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/signed-amount.tsx +295 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +11 -3
- package/src/components/swipeable-row.tsx +7 -7
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +356 -0
- package/src/components/text.tsx +46 -2
- package/src/components/toggle-group.tsx +93 -20
- package/src/components/toggle-legend.tsx +108 -2
- package/src/components/tooltip.tsx +79 -8
- package/src/components/ui.tsx +707 -49
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-attachment.tsx +10 -7
- package/src/feedback/feedback-dialog.tsx +49 -7
- package/src/feedback/feedback-inbox.tsx +7 -0
- package/src/feedback/feedback-thread.tsx +510 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +30 -0
- package/src/i18n/kit-labels.tsx +118 -8
- package/src/i18n/locales/de.ts +161 -0
- package/src/i18n/locales/es.ts +158 -0
- package/src/i18n/locales/fr.ts +161 -0
- package/src/i18n/locales/hu.ts +159 -0
- package/src/i18n/locales/it.ts +159 -0
- package/src/i18n/locales/zh.ts +158 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +348 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1135 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/search/command-palette.tsx +29 -19
- package/src/shell/app-shell.tsx +153 -3
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +32 -4
- package/src/shell.ts +2 -0
- package/src/tour/tour.tsx +16 -5
- package/src/wizard/use-wizard.ts +8 -4
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +75 -6
- package/tokens.css +12 -0
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, CSSProperties, 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, replacingClick, routerLinkNavigation } 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,266 @@ 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
|
+
replace?: never;
|
|
218
|
+
reloadDocument?: never;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `<Button href>`: a link that looks like a button — for an action that NAVIGATES.
|
|
223
|
+
*
|
|
224
|
+
* kastlan paints `buttonClasses(…)` onto router Links by hand in six places (the
|
|
225
|
+
* invoice / lease / unit preview dialogs' "Open" at invoice-preview-dialog.tsx:36,
|
|
226
|
+
* platform-companies-page.tsx:111, verify-email-page.tsx:86's "Log in",
|
|
227
|
+
* billing-history-page.tsx:85's PDF download), and has 26 `<Button onClick={() =>
|
|
228
|
+
* navigate(…)}>` that are links in all but markup: no middle-click, no "open in new
|
|
229
|
+
* tab", no URL on hover, and a screen reader announces a button that then changes the
|
|
230
|
+
* page. With `href` it is an `<a>`: the provider's router link
|
|
231
|
+
* ({@link UiKitProvider}'s `linkComponent`), or `renderLink` if given, else a plain one.
|
|
232
|
+
*
|
|
233
|
+
* `type` and `form` are typed out: they are a `<button>`'s, and a link never submits.
|
|
234
|
+
*/
|
|
235
|
+
export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href"> {
|
|
236
|
+
href: string;
|
|
237
|
+
variant?: ButtonVariant;
|
|
238
|
+
size?: ButtonSize;
|
|
239
|
+
stretch?: boolean;
|
|
240
|
+
tone?: ButtonTone;
|
|
241
|
+
/** The in-app router link, over the provider's `linkComponent` (see
|
|
242
|
+
* {@link KitLinkComponent}). Ignored when `external`. */
|
|
243
|
+
renderLink?: KitLinkComponent;
|
|
244
|
+
/** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`, never the
|
|
245
|
+
* router link, and "(opens in a new tab)" read after the label — billing-history's
|
|
246
|
+
* Stripe PDF. A `target="_blank"` of your own is announced the same way. */
|
|
247
|
+
external?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Navigate by replacing the current history entry, so Back does not return here —
|
|
250
|
+
* keksdose F1: the "Continue" out of a one-shot page (an emailed confirmation link, a
|
|
251
|
+
* payment return URL) that must not be landed on again. Handed to the router link as
|
|
252
|
+
* `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click
|
|
253
|
+
* becomes `location.replace(href)`. Ignored when `external`.
|
|
254
|
+
*/
|
|
255
|
+
replace?: boolean;
|
|
256
|
+
/**
|
|
257
|
+
* A plain `<a>` for an in-app `href`, so the browser loads the whole document instead
|
|
258
|
+
* of the router swapping the view — keksdose F1: a route the SPA does not own (a
|
|
259
|
+
* server-rendered export, a logout that must drop every in-memory cache). Wins over
|
|
260
|
+
* `renderLink` and the provider's `linkComponent`.
|
|
261
|
+
*/
|
|
262
|
+
reloadDocument?: boolean;
|
|
263
|
+
/** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
|
|
264
|
+
* `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
|
|
265
|
+
* with the disabled look. The router link is not used: it needs somewhere to go. */
|
|
266
|
+
disabled?: boolean;
|
|
267
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
268
|
+
type?: never;
|
|
269
|
+
form?: never;
|
|
270
|
+
pending?: never;
|
|
271
|
+
pressed?: never;
|
|
272
|
+
disabledReason?: never;
|
|
175
273
|
}
|
|
176
274
|
|
|
177
275
|
function hasContent(node: ReactNode): boolean {
|
|
178
276
|
return node !== undefined && node !== null && node !== false && node !== "";
|
|
179
277
|
}
|
|
180
278
|
|
|
181
|
-
|
|
279
|
+
/** The button look shared by the `<button>` and the `<a>` form. */
|
|
280
|
+
function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
|
|
281
|
+
return cn(
|
|
282
|
+
BUTTON_BASE,
|
|
283
|
+
BUTTON_SIZES[size],
|
|
284
|
+
// In a flex row next to a taller labelled field, `stretch` makes the button
|
|
285
|
+
// fill the field's height so the two line up (self-stretch overrides the row's
|
|
286
|
+
// align-items). No effect outside a flex row / when it's already the tallest.
|
|
287
|
+
stretch && "self-stretch",
|
|
288
|
+
buttonVariantClasses[variant],
|
|
289
|
+
tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
|
|
290
|
+
tone === "danger" && BUTTON_BOXED_DANGER[variant],
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Declared link-first so `<Button href>` picks the link props; the LAST signature is
|
|
295
|
+
// what `ComponentProps<typeof Button>` reads, so wrappers (FileButton) still see the
|
|
296
|
+
// `<button>` props they always did.
|
|
297
|
+
export function Button(props: ButtonLinkProps): ReactElement;
|
|
298
|
+
export function Button(props: ButtonProps): ReactElement;
|
|
299
|
+
export function Button(props: ButtonProps | ButtonLinkProps): ReactElement {
|
|
300
|
+
if (props.href !== undefined) return <ButtonLink {...(props as ButtonLinkProps)} />;
|
|
301
|
+
return <ButtonElement {...(props as ButtonProps)} />;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Calls a `renderLink` / provider link as a function, as ListItem and Chip do — an
|
|
305
|
+
* inline `renderLink` arrow would otherwise be a new component type every render. */
|
|
306
|
+
function RenderedButtonLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
|
|
307
|
+
return render(props);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function ButtonLink({
|
|
311
|
+
href,
|
|
312
|
+
renderLink,
|
|
313
|
+
external,
|
|
314
|
+
replace,
|
|
315
|
+
reloadDocument,
|
|
316
|
+
disabled,
|
|
317
|
+
variant = "primary",
|
|
318
|
+
size = "md",
|
|
319
|
+
stretch,
|
|
320
|
+
tone = "default",
|
|
321
|
+
className,
|
|
322
|
+
children,
|
|
323
|
+
target,
|
|
324
|
+
rel,
|
|
325
|
+
onClick,
|
|
326
|
+
...rest
|
|
327
|
+
}: ButtonLinkProps) {
|
|
328
|
+
const kitLink = useKitLink();
|
|
329
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
330
|
+
// The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
|
|
331
|
+
// in-page `#anchor` is never handed to the provider's router link, even without
|
|
332
|
+
// `external`.
|
|
333
|
+
// `reloadDocument` is the caller saying "not the router" for this one link.
|
|
334
|
+
const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
|
|
335
|
+
const newTab = !disabled && (external || target === "_blank");
|
|
336
|
+
const cls = cn(
|
|
337
|
+
buttonLook(variant, size, stretch, tone),
|
|
338
|
+
// `relative` holds the sr-only note (see sr-only-containment.test).
|
|
339
|
+
newTab && "relative",
|
|
340
|
+
// BUTTON_BASE's `disabled:` classes never match an `<a>`; no pointer events also
|
|
341
|
+
// keeps the hover fill from lighting up on something that refuses the click.
|
|
342
|
+
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
|
343
|
+
className,
|
|
344
|
+
);
|
|
345
|
+
const body = (
|
|
346
|
+
<>
|
|
347
|
+
{children}
|
|
348
|
+
{/* The space OUTSIDE the sr-only span: a name computation trims each child's
|
|
349
|
+
text, so one inside it was lost ("Download(opens…"). In a flex box a bare
|
|
350
|
+
space draws nothing. */}
|
|
351
|
+
{newTab && " "}
|
|
352
|
+
{newTab && <span className="sr-only">({common.opensInNewTab})</span>}
|
|
353
|
+
</>
|
|
354
|
+
);
|
|
355
|
+
if (disabled) {
|
|
356
|
+
return (
|
|
357
|
+
<a {...rest} role="link" aria-disabled="true" className={cls}>
|
|
358
|
+
{body}
|
|
359
|
+
</a>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (external || !Link) {
|
|
363
|
+
return (
|
|
364
|
+
<a
|
|
365
|
+
{...rest}
|
|
366
|
+
href={href}
|
|
367
|
+
target={external ? "_blank" : target}
|
|
368
|
+
rel={external ? (rel ?? "noopener noreferrer") : rel}
|
|
369
|
+
onClick={external ? onClick : replacingClick(onClick, href, replace, target)}
|
|
370
|
+
className={cls}
|
|
371
|
+
>
|
|
372
|
+
{body}
|
|
373
|
+
</a>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return (
|
|
377
|
+
<RenderedButtonLink
|
|
378
|
+
render={Link}
|
|
379
|
+
{...rest}
|
|
380
|
+
{...routerLinkNavigation(replace)}
|
|
381
|
+
href={href}
|
|
382
|
+
target={target}
|
|
383
|
+
rel={rel}
|
|
384
|
+
onClick={onClick}
|
|
385
|
+
className={cls}
|
|
386
|
+
>
|
|
387
|
+
{body}
|
|
388
|
+
</RenderedButtonLink>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ButtonElement({
|
|
182
393
|
variant = "primary",
|
|
183
394
|
size = "md",
|
|
184
395
|
stretch,
|
|
185
396
|
tone = "default",
|
|
186
397
|
pressed,
|
|
187
398
|
disabledReason,
|
|
399
|
+
pending,
|
|
188
400
|
className,
|
|
189
401
|
onClick,
|
|
402
|
+
children,
|
|
403
|
+
href: _href,
|
|
404
|
+
renderLink: _renderLink,
|
|
405
|
+
external: _external,
|
|
406
|
+
replace: _replace,
|
|
407
|
+
reloadDocument: _reloadDocument,
|
|
190
408
|
...rest
|
|
191
409
|
}: ButtonProps) {
|
|
192
410
|
const reasonId = useId();
|
|
193
411
|
const locked = hasContent(disabledReason);
|
|
412
|
+
const inert = locked || Boolean(pending);
|
|
194
413
|
const ownDescribedBy = rest["aria-describedby"];
|
|
195
414
|
const button = (
|
|
196
415
|
<button
|
|
197
416
|
{...rest}
|
|
198
|
-
disabled={
|
|
199
|
-
aria-disabled={
|
|
417
|
+
disabled={inert ? undefined : rest.disabled}
|
|
418
|
+
aria-disabled={inert || rest["aria-disabled"]}
|
|
419
|
+
aria-busy={pending || rest["aria-busy"]}
|
|
200
420
|
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
201
421
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
202
422
|
onClick={(e) => {
|
|
203
423
|
// `preventDefault` as well as not calling through: a locked submit button must
|
|
204
424
|
// not submit its form, and the browser does that after this handler returns.
|
|
205
|
-
if (
|
|
425
|
+
if (inert) {
|
|
206
426
|
e.preventDefault();
|
|
207
427
|
return;
|
|
208
428
|
}
|
|
209
429
|
onClick?.(e);
|
|
210
430
|
}}
|
|
211
431
|
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],
|
|
432
|
+
buttonLook(variant, size, stretch, tone),
|
|
220
433
|
pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
|
|
221
434
|
// The disabled look for the focusable kind of disabled.
|
|
222
435
|
locked && "cursor-not-allowed opacity-50",
|
|
436
|
+
// No dimming while pending: the spinner IS the state, and at half opacity it
|
|
437
|
+
// would be the faintest thing on the button. `relative` anchors it.
|
|
438
|
+
pending && "relative cursor-progress",
|
|
223
439
|
className,
|
|
224
440
|
)}
|
|
225
|
-
|
|
441
|
+
>
|
|
442
|
+
{pending ? (
|
|
443
|
+
<>
|
|
444
|
+
{/* Transparent, not hidden: it keeps the width AND stays the accessible
|
|
445
|
+
name (`visibility: hidden` would drop it from the name). `gap-[inherit]`
|
|
446
|
+
keeps an icon + text spaced as the button spaces them. */}
|
|
447
|
+
<span className="inline-flex items-center justify-center gap-[inherit] opacity-0">{children}</span>
|
|
448
|
+
{/* Decorative: `aria-busy` says it, as on FileButton. */}
|
|
449
|
+
<Spinner
|
|
450
|
+
label={null}
|
|
451
|
+
className="absolute inset-0 m-auto size-4 border-current/30 border-t-current"
|
|
452
|
+
/>
|
|
453
|
+
</>
|
|
454
|
+
) : (
|
|
455
|
+
children
|
|
456
|
+
)}
|
|
457
|
+
</button>
|
|
226
458
|
);
|
|
227
459
|
if (!locked) return button;
|
|
228
460
|
return (
|
|
@@ -254,12 +486,15 @@ export function Button({
|
|
|
254
486
|
const ICON_BUTTON_BASE =
|
|
255
487
|
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
256
488
|
|
|
257
|
-
export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
|
|
489
|
+
export type IconButtonSize = "2xl" | "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
|
|
258
490
|
|
|
259
491
|
// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
|
|
260
492
|
// that touches its edges. The two small steps are lenkbank's list-row (28px) and
|
|
261
493
|
// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
|
|
262
494
|
const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
495
|
+
// 64px with a 28px glyph: a camera's shutter (keksdose invoices/camera-capture.tsx:248,
|
|
496
|
+
// `size-16` by hand) — the one control of a full-screen capture view, thumb-sized.
|
|
497
|
+
"2xl": "size-16 [&_svg]:size-7",
|
|
263
498
|
// 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
|
|
264
499
|
// past the 44px minimum rather than on it.
|
|
265
500
|
xl: "size-12 [&_svg]:size-6",
|
|
@@ -276,6 +511,7 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
276
511
|
// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
|
|
277
512
|
// written as a width plus a minimum height (the square it would otherwise be).
|
|
278
513
|
const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
|
|
514
|
+
"2xl": "w-16 min-h-16 self-stretch [&_svg]:size-7",
|
|
279
515
|
xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
|
|
280
516
|
lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
|
|
281
517
|
md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
|
|
@@ -363,7 +599,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
|
|
|
363
599
|
// and would out-rank a caller's plain `hover:` override), each variant pins its
|
|
364
600
|
// RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
|
|
365
601
|
// and only ever matches a disabled button.
|
|
366
|
-
const ICON_BUTTON_DISABLED_REST: Record<
|
|
602
|
+
const ICON_BUTTON_DISABLED_REST: Record<IconButtonVariant, string> = {
|
|
367
603
|
primary: "disabled:hover:bg-[var(--bg-surface-2)]",
|
|
368
604
|
secondary: "disabled:hover:bg-transparent",
|
|
369
605
|
ghost: "disabled:hover:bg-transparent",
|
|
@@ -371,6 +607,7 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
|
|
|
371
607
|
brand: "disabled:hover:bg-[var(--brand)]",
|
|
372
608
|
link: "disabled:hover:bg-transparent disabled:hover:no-underline",
|
|
373
609
|
overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
|
|
610
|
+
shutter: "disabled:hover:bg-[var(--media-scrim)]",
|
|
374
611
|
};
|
|
375
612
|
|
|
376
613
|
// The quiet looks change the glyph on hover, so they pin their resting glyph the
|
|
@@ -393,15 +630,69 @@ const ICON_BUTTON_PRESSED =
|
|
|
393
630
|
const ICON_BUTTON_OVERLAY =
|
|
394
631
|
"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
632
|
|
|
633
|
+
// `variant="shutter"`: the camera's release — keksdose's camera-capture.tsx:248 draws
|
|
634
|
+
// it by hand as a 64px circle with a thick white ring round a translucent fill. It sits
|
|
635
|
+
// on a live camera picture, which is dark whatever the theme, so it uses the MEDIA
|
|
636
|
+
// tokens (tokens.css), white ink on a dark scrim in both themes — the inverse pair
|
|
637
|
+
// `overlay` uses would give a dark ring on a dark viewfinder in dark mode. The focus ring stands off by 2px, or it would
|
|
638
|
+
// merge into the ring that is part of the look.
|
|
639
|
+
const ICON_BUTTON_SHUTTER =
|
|
640
|
+
"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)]";
|
|
641
|
+
|
|
642
|
+
export type IconButtonVariant = ButtonVariant | "overlay" | "shutter";
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The glyph's size in px, over the one the box size implies — see
|
|
646
|
+
* {@link IconButtonProps.glyphSize}.
|
|
647
|
+
*/
|
|
648
|
+
export type IconButtonGlyphSize = 12 | 14 | 16 | 20 | 24 | 28;
|
|
649
|
+
|
|
650
|
+
const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
|
|
651
|
+
12: "[&_svg]:size-3",
|
|
652
|
+
14: "[&_svg]:size-3.5",
|
|
653
|
+
16: "[&_svg]:size-4",
|
|
654
|
+
20: "[&_svg]:size-5",
|
|
655
|
+
24: "[&_svg]:size-6",
|
|
656
|
+
28: "[&_svg]:size-7",
|
|
657
|
+
};
|
|
658
|
+
|
|
396
659
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
397
|
-
/** Any {@link ButtonVariant}
|
|
398
|
-
* an image (see `ICON_BUTTON_OVERLAY`)
|
|
399
|
-
|
|
400
|
-
|
|
660
|
+
/** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
|
|
661
|
+
* an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
|
|
662
|
+
* disc over the viewfinder (see `ICON_BUTTON_SHUTTER`; pair it with `size="2xl"`). */
|
|
663
|
+
variant?: IconButtonVariant;
|
|
664
|
+
/** Box size: 2xl = 64px with a 28px icon (a camera shutter), xl = 48px with a 24px
|
|
665
|
+
* icon, lg = 44px (a phone's touch target), md = 36px (matches the top bar),
|
|
401
666
|
* sm = 32px — all three with a 20px icon;
|
|
402
667
|
* 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). */
|
|
668
|
+
* icon (an action on a chip or a tab). `glyphSize` overrides the icon. */
|
|
404
669
|
size?: IconButtonSize;
|
|
670
|
+
/**
|
|
671
|
+
* The icon's size in px, when the box's own is not the one wanted. keksdose's sync
|
|
672
|
+
* chip (app/sync-status-indicator.tsx:142) is a 24px round chip round a 20px cloud —
|
|
673
|
+
* `size="2xs" glyphSize={20} shape="round"` — where `2xs` alone draws 14px. A number
|
|
674
|
+
* over a new size step because the box and the glyph are two independent choices; a
|
|
675
|
+
* step per combination (`"sm-lg"`) would have to be named for every one.
|
|
676
|
+
*/
|
|
677
|
+
glyphSize?: IconButtonGlyphSize;
|
|
678
|
+
/**
|
|
679
|
+
* A small mark on the button's corner — the sync chip's 12px check over its cloud
|
|
680
|
+
* ("all good", feedback #67). Rendered OUTSIDE the `<button>`, in a wrapper beside
|
|
681
|
+
* it, so the box's `[&_svg]:size-*` rule that sizes the glyph does not blow the badge
|
|
682
|
+
* up to the glyph's size; a bare `<Check />` comes out 12px, on a
|
|
683
|
+
* `--bg-surface` disc that keeps it legible over the glyph. Decorative
|
|
684
|
+
* (`aria-hidden`): the name (`label` / `aria-label`) must already carry the state.
|
|
685
|
+
* With a badge, `className` still lands on the button; the wrapper is the flex item.
|
|
686
|
+
*/
|
|
687
|
+
badge?: ReactNode;
|
|
688
|
+
/**
|
|
689
|
+
* `keep`: a disabled button keeps its full colour (no 50% drop) and an ordinary
|
|
690
|
+
* cursor. For a STATUS that is also an action — the sync chip shows "in sync" in
|
|
691
|
+
* green and refreshes on click, and is disabled while offline or syncing, when the
|
|
692
|
+
* state it shows matters most; dimmed it read as broken (keksdose wrote
|
|
693
|
+
* `disabled:cursor-default` by hand and left out the dimming). Default `dim`.
|
|
694
|
+
*/
|
|
695
|
+
disabledStyle?: "dim" | "keep";
|
|
405
696
|
/**
|
|
406
697
|
* Fill the height of the flex row it stands in, keeping its width — the delete at
|
|
407
698
|
* the end of keksdose's split line (transaction-fields:162), level with the labelled
|
|
@@ -419,8 +710,12 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
419
710
|
* state re-colours the glyph, its hover fill and its focus ring together. Passing it
|
|
420
711
|
* implies `tone="custom"`. Or leave it out and set the variable yourself from a class
|
|
421
712
|
* (`className="[--icon-button-tone:var(--success)]"`).
|
|
713
|
+
*
|
|
714
|
+
* Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads
|
|
715
|
+
* on the light surface (`#be123c`) is too dark on the dark one, and an inline style
|
|
716
|
+
* cannot say "in dark mode". See {@link IconButtonToneColor}.
|
|
422
717
|
*/
|
|
423
|
-
toneColor?:
|
|
718
|
+
toneColor?: IconButtonToneColor;
|
|
424
719
|
/** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
|
|
425
720
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
426
721
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
@@ -528,9 +823,272 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
528
823
|
* reason nobody can see is not one. Wins over `disabled`, as on Button.
|
|
529
824
|
*/
|
|
530
825
|
disabledReason?: ReactNode;
|
|
826
|
+
/** The `<button>` element — a prop in React 19, as on {@link Button}. */
|
|
827
|
+
ref?: Ref<HTMLButtonElement>;
|
|
828
|
+
/** Only on the link form — see {@link IconButtonLinkProps}. */
|
|
829
|
+
href?: never;
|
|
830
|
+
renderLink?: never;
|
|
831
|
+
external?: never;
|
|
832
|
+
replace?: never;
|
|
833
|
+
reloadDocument?: never;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon
|
|
838
|
+
* actions that navigate (a row's "open", a card's "edit" that goes to the edit page, a
|
|
839
|
+
* top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that
|
|
840
|
+
* changes the page, with no middle click, no "open in new tab" and no URL on hover.
|
|
841
|
+
*
|
|
842
|
+
* The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app
|
|
843
|
+
* `href` goes through `renderLink`, else the provider's `linkComponent`; an external one
|
|
844
|
+
* or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to
|
|
845
|
+
* a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →
|
|
846
|
+
* `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.
|
|
847
|
+
*
|
|
848
|
+
* `disabled` renders an inert `<a>` with no `href` — `role="link"`, `aria-disabled`, out
|
|
849
|
+
* of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and
|
|
850
|
+
* `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link
|
|
851
|
+
* that explains why it cannot be followed is a job for a disabled BUTTON.
|
|
852
|
+
*/
|
|
853
|
+
export interface IconButtonLinkProps
|
|
854
|
+
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href">,
|
|
855
|
+
Pick<
|
|
856
|
+
IconButtonProps,
|
|
857
|
+
| "variant"
|
|
858
|
+
| "size"
|
|
859
|
+
| "glyphSize"
|
|
860
|
+
| "badge"
|
|
861
|
+
| "stretch"
|
|
862
|
+
| "shape"
|
|
863
|
+
| "toneColor"
|
|
864
|
+
| "tone"
|
|
865
|
+
| "quiet"
|
|
866
|
+
| "stopPropagation"
|
|
867
|
+
| "label"
|
|
868
|
+
| "tooltip"
|
|
869
|
+
| "tooltipSide"
|
|
870
|
+
| "tooltipPortal"
|
|
871
|
+
> {
|
|
872
|
+
href: string;
|
|
873
|
+
/** See {@link ButtonLinkProps.renderLink}. */
|
|
874
|
+
renderLink?: KitLinkComponent;
|
|
875
|
+
/** See {@link ButtonLinkProps.external}. */
|
|
876
|
+
external?: boolean;
|
|
877
|
+
/** See {@link ButtonLinkProps.replace}. */
|
|
878
|
+
replace?: boolean;
|
|
879
|
+
/** See {@link ButtonLinkProps.reloadDocument}. */
|
|
880
|
+
reloadDocument?: boolean;
|
|
881
|
+
/** See {@link ButtonLinkProps.disabled}. */
|
|
882
|
+
disabled?: boolean;
|
|
883
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
884
|
+
type?: never;
|
|
885
|
+
form?: never;
|
|
886
|
+
pressed?: never;
|
|
887
|
+
disabledReason?: never;
|
|
888
|
+
disabledStyle?: never;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* `toneColor`: one CSS colour, or one per theme.
|
|
893
|
+
*
|
|
894
|
+
* The pair is two custom properties on the element — `--icon-button-tone-light` and
|
|
895
|
+
* `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the
|
|
896
|
+
* other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour
|
|
897
|
+
* follows a theme switch in CSS alone, with no re-render and no JS reading the theme.
|
|
898
|
+
* Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no
|
|
899
|
+
* pair.
|
|
900
|
+
*/
|
|
901
|
+
export type IconButtonToneColor = string | { light: string; dark: string };
|
|
902
|
+
|
|
903
|
+
const ICON_BUTTON_TONE_PAIR =
|
|
904
|
+
"[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]";
|
|
905
|
+
|
|
906
|
+
/** The inline custom properties `toneColor` sets over the caller's `style`. */
|
|
907
|
+
function toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {
|
|
908
|
+
if (toneColor === undefined) return style;
|
|
909
|
+
const vars: Record<string, string> =
|
|
910
|
+
typeof toneColor === "string"
|
|
911
|
+
? { "--icon-button-tone": toneColor }
|
|
912
|
+
: { "--icon-button-tone-light": toneColor.light, "--icon-button-tone-dark": toneColor.dark };
|
|
913
|
+
return { ...style, ...vars } as CSSProperties;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Everything but the element: the look both forms of IconButton share. */
|
|
917
|
+
function iconButtonLook({
|
|
918
|
+
variant,
|
|
919
|
+
size,
|
|
920
|
+
stretch,
|
|
921
|
+
glyphSize,
|
|
922
|
+
shape,
|
|
923
|
+
tone,
|
|
924
|
+
quiet,
|
|
925
|
+
toneColor,
|
|
926
|
+
}: {
|
|
927
|
+
variant: IconButtonVariant;
|
|
928
|
+
size: IconButtonSize;
|
|
929
|
+
stretch: boolean | undefined;
|
|
930
|
+
glyphSize: IconButtonGlyphSize | undefined;
|
|
931
|
+
shape: "square" | "round";
|
|
932
|
+
tone: IconButtonTone;
|
|
933
|
+
quiet: boolean | undefined;
|
|
934
|
+
toneColor: IconButtonToneColor | undefined;
|
|
935
|
+
}) {
|
|
936
|
+
return cn(
|
|
937
|
+
ICON_BUTTON_BASE,
|
|
938
|
+
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
939
|
+
glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
|
|
940
|
+
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
941
|
+
variant === "overlay"
|
|
942
|
+
? ICON_BUTTON_OVERLAY
|
|
943
|
+
: variant === "shutter"
|
|
944
|
+
? ICON_BUTTON_SHUTTER
|
|
945
|
+
: buttonVariantClasses[variant],
|
|
946
|
+
shape === "round" && "rounded-full",
|
|
947
|
+
ICON_BUTTON_DISABLED_REST[variant],
|
|
948
|
+
iconButtonToneClass(tone, quiet),
|
|
949
|
+
toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** The badge beside the control, not in it — see `badge`. The wrapper is only a
|
|
954
|
+
* positioning box: the control inside it keeps its own focus ring and hit area. */
|
|
955
|
+
function withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {
|
|
956
|
+
if (!hasContent(badge)) return control;
|
|
957
|
+
return (
|
|
958
|
+
<span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
|
|
959
|
+
{control}
|
|
960
|
+
<span
|
|
961
|
+
aria-hidden
|
|
962
|
+
data-slot="icon-button-badge"
|
|
963
|
+
className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
|
|
964
|
+
>
|
|
965
|
+
{badge}
|
|
966
|
+
</span>
|
|
967
|
+
</span>
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Declared link-first, as Button is, so `<IconButton href>` picks the link props and
|
|
972
|
+
// `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.
|
|
973
|
+
export function IconButton(props: IconButtonLinkProps): ReactElement;
|
|
974
|
+
export function IconButton(props: IconButtonProps): ReactElement;
|
|
975
|
+
export function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {
|
|
976
|
+
if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;
|
|
977
|
+
return <IconButtonElement {...(props as IconButtonProps)} />;
|
|
978
|
+
}
|
|
979
|
+
IconButton.displayName = "IconButton";
|
|
980
|
+
|
|
981
|
+
function IconButtonLink({
|
|
982
|
+
href,
|
|
983
|
+
renderLink,
|
|
984
|
+
external,
|
|
985
|
+
replace,
|
|
986
|
+
reloadDocument,
|
|
987
|
+
disabled,
|
|
988
|
+
variant = "ghost",
|
|
989
|
+
size = "md",
|
|
990
|
+
tone: toneProp,
|
|
991
|
+
quiet,
|
|
992
|
+
stopPropagation,
|
|
993
|
+
stretch,
|
|
994
|
+
shape = "square",
|
|
995
|
+
toneColor,
|
|
996
|
+
label,
|
|
997
|
+
tooltip = true,
|
|
998
|
+
tooltipSide,
|
|
999
|
+
tooltipPortal,
|
|
1000
|
+
glyphSize,
|
|
1001
|
+
badge,
|
|
1002
|
+
className,
|
|
1003
|
+
style,
|
|
1004
|
+
target,
|
|
1005
|
+
rel,
|
|
1006
|
+
onClick,
|
|
1007
|
+
onKeyDown,
|
|
1008
|
+
children,
|
|
1009
|
+
ref,
|
|
1010
|
+
...rest
|
|
1011
|
+
}: IconButtonLinkProps) {
|
|
1012
|
+
const kitLink = useKitLink();
|
|
1013
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
1014
|
+
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
1015
|
+
// Button's rule, word for word: `reloadDocument` bypasses the router, an external
|
|
1016
|
+
// href or an in-page `#anchor` never reaches it.
|
|
1017
|
+
const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
|
|
1018
|
+
const newTab = !disabled && (external || target === "_blank");
|
|
1019
|
+
const name = rest["aria-label"] ?? label;
|
|
1020
|
+
// "(opens in a new tab)" joins the NAME here, not the body: an icon link has no text
|
|
1021
|
+
// for an sr-only span to follow, and `aria-label` would override one anyway.
|
|
1022
|
+
const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;
|
|
1023
|
+
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
1024
|
+
if (stopPropagation) e.stopPropagation();
|
|
1025
|
+
onClick?.(e);
|
|
1026
|
+
};
|
|
1027
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {
|
|
1028
|
+
// A link activates on Enter only; that key still bubbles to a clickable row.
|
|
1029
|
+
if (stopPropagation && e.key === "Enter") e.stopPropagation();
|
|
1030
|
+
onKeyDown?.(e);
|
|
1031
|
+
};
|
|
1032
|
+
const cls = cn(
|
|
1033
|
+
iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
|
|
1034
|
+
// BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.
|
|
1035
|
+
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
|
1036
|
+
className,
|
|
1037
|
+
);
|
|
1038
|
+
const look = { style: toneColorStyle(toneColor, style), className: cls };
|
|
1039
|
+
let control: ReactElement;
|
|
1040
|
+
if (disabled) {
|
|
1041
|
+
control = (
|
|
1042
|
+
<a {...rest} {...look} ref={ref} role="link" aria-disabled="true" aria-label={ariaLabel}>
|
|
1043
|
+
{children}
|
|
1044
|
+
</a>
|
|
1045
|
+
);
|
|
1046
|
+
} else if (external || !Link) {
|
|
1047
|
+
control = (
|
|
1048
|
+
<a
|
|
1049
|
+
{...rest}
|
|
1050
|
+
{...look}
|
|
1051
|
+
ref={ref}
|
|
1052
|
+
href={href}
|
|
1053
|
+
aria-label={ariaLabel}
|
|
1054
|
+
target={external ? "_blank" : target}
|
|
1055
|
+
rel={external ? (rel ?? "noopener noreferrer") : rel}
|
|
1056
|
+
onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}
|
|
1057
|
+
onKeyDown={handleKeyDown}
|
|
1058
|
+
>
|
|
1059
|
+
{children}
|
|
1060
|
+
</a>
|
|
1061
|
+
);
|
|
1062
|
+
} else {
|
|
1063
|
+
control = (
|
|
1064
|
+
<RenderedButtonLink
|
|
1065
|
+
render={Link}
|
|
1066
|
+
{...rest}
|
|
1067
|
+
{...look}
|
|
1068
|
+
{...routerLinkNavigation(replace)}
|
|
1069
|
+
ref={ref}
|
|
1070
|
+
href={href}
|
|
1071
|
+
aria-label={ariaLabel}
|
|
1072
|
+
target={target}
|
|
1073
|
+
rel={rel}
|
|
1074
|
+
onClick={handleClick}
|
|
1075
|
+
onKeyDown={handleKeyDown}
|
|
1076
|
+
>
|
|
1077
|
+
{children}
|
|
1078
|
+
</RenderedButtonLink>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
control = withIconButtonBadge(control, badge, stretch);
|
|
1082
|
+
if (!tooltip || label === undefined || label === "") return control;
|
|
1083
|
+
return (
|
|
1084
|
+
// A fragment, as on the button: the bubble says what `aria-label` already does.
|
|
1085
|
+
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
1086
|
+
<>{control}</>
|
|
1087
|
+
</Tooltip>
|
|
1088
|
+
);
|
|
531
1089
|
}
|
|
532
1090
|
|
|
533
|
-
|
|
1091
|
+
function IconButtonElement(
|
|
534
1092
|
{
|
|
535
1093
|
variant = "ghost",
|
|
536
1094
|
size = "md",
|
|
@@ -546,15 +1104,24 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
546
1104
|
tooltipSide,
|
|
547
1105
|
tooltipPortal,
|
|
548
1106
|
disabledReason,
|
|
1107
|
+
glyphSize,
|
|
1108
|
+
badge,
|
|
1109
|
+
disabledStyle = "dim",
|
|
549
1110
|
className,
|
|
550
1111
|
style,
|
|
551
1112
|
onClick,
|
|
552
1113
|
onKeyDown,
|
|
1114
|
+
href: _href,
|
|
1115
|
+
renderLink: _renderLink,
|
|
1116
|
+
external: _external,
|
|
1117
|
+
replace: _replace,
|
|
1118
|
+
reloadDocument: _reloadDocument,
|
|
1119
|
+
ref,
|
|
553
1120
|
...rest
|
|
554
|
-
},
|
|
555
|
-
ref,
|
|
1121
|
+
}: IconButtonProps,
|
|
556
1122
|
) {
|
|
557
1123
|
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
1124
|
+
const keep = disabledStyle === "keep";
|
|
558
1125
|
const reasonId = useId();
|
|
559
1126
|
const locked = hasContent(disabledReason);
|
|
560
1127
|
const ownDescribedBy = rest["aria-describedby"];
|
|
@@ -567,7 +1134,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
567
1134
|
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
568
1135
|
aria-label={rest["aria-label"] ?? label}
|
|
569
1136
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
570
|
-
style={toneColor
|
|
1137
|
+
style={toneColorStyle(toneColor, style)}
|
|
571
1138
|
onClick={(e) => {
|
|
572
1139
|
if (stopPropagation) e.stopPropagation();
|
|
573
1140
|
// `preventDefault` too: a locked submit must not submit its form.
|
|
@@ -584,20 +1151,16 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
584
1151
|
onKeyDown?.(e);
|
|
585
1152
|
}}
|
|
586
1153
|
className={cn(
|
|
587
|
-
|
|
588
|
-
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
589
|
-
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
590
|
-
variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
|
|
591
|
-
shape === "round" && "rounded-full",
|
|
592
|
-
ICON_BUTTON_DISABLED_REST[variant],
|
|
593
|
-
iconButtonToneClass(tone, quiet),
|
|
1154
|
+
iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
|
|
594
1155
|
pressed && ICON_BUTTON_PRESSED,
|
|
595
1156
|
// The disabled look for the focusable kind of disabled, as on Button.
|
|
596
|
-
locked && "cursor-not-allowed opacity-50",
|
|
1157
|
+
locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
|
|
1158
|
+
keep && "disabled:cursor-default disabled:opacity-100",
|
|
597
1159
|
className,
|
|
598
1160
|
)}
|
|
599
1161
|
/>
|
|
600
1162
|
);
|
|
1163
|
+
const control = withIconButtonBadge(button, badge, stretch);
|
|
601
1164
|
if (locked) {
|
|
602
1165
|
return (
|
|
603
1166
|
// Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
|
|
@@ -609,7 +1172,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
609
1172
|
className={stretch ? "self-stretch" : undefined}
|
|
610
1173
|
>
|
|
611
1174
|
<>
|
|
612
|
-
{
|
|
1175
|
+
{control}
|
|
613
1176
|
<span id={reasonId} hidden>
|
|
614
1177
|
{disabledReason}
|
|
615
1178
|
</span>
|
|
@@ -617,16 +1180,15 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
617
1180
|
</Tooltip>
|
|
618
1181
|
);
|
|
619
1182
|
}
|
|
620
|
-
if (!tooltip || label === undefined || label === "") return
|
|
1183
|
+
if (!tooltip || label === undefined || label === "") return control;
|
|
621
1184
|
return (
|
|
622
1185
|
// A fragment, so Tooltip leaves the button's description alone: the bubble says
|
|
623
1186
|
// exactly what `aria-label` already does.
|
|
624
1187
|
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
625
|
-
<>{
|
|
1188
|
+
<>{control}</>
|
|
626
1189
|
</Tooltip>
|
|
627
1190
|
);
|
|
628
|
-
}
|
|
629
|
-
IconButton.displayName = "IconButton";
|
|
1191
|
+
}
|
|
630
1192
|
|
|
631
1193
|
export const FIELD_BASE =
|
|
632
1194
|
"block w-full rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-sm placeholder:text-[var(--text-placeholder)] focus:border-[var(--brand)] focus:ring-[var(--brand)] " +
|
|
@@ -1503,17 +2065,82 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
1503
2065
|
* those copies wanted the same one; a caller's `p-*` still wins. `flush` is ignored:
|
|
1504
2066
|
* an inset panel never runs edge to edge.
|
|
1505
2067
|
*/
|
|
1506
|
-
variant?: "default" | "inset";
|
|
2068
|
+
variant?: "default" | "inset" | "outline";
|
|
2069
|
+
/**
|
|
2070
|
+
* The card's own padding: `none`, `sm` (`p-3`) or `md` (`p-4`).
|
|
2071
|
+
*
|
|
2072
|
+
* `outline` is the bordered box with no shadow — a row or a block INSIDE a section
|
|
2073
|
+
* rather than a card on the page: kastlan's rent breakdown rows
|
|
2074
|
+
* (tenancy/components/breakdown-row.tsx, `rounded-lg border p-4`) and the meters
|
|
2075
|
+
* list's inline add row (meters/components/meter-add-row.tsx). It takes `md` by
|
|
2076
|
+
* default, as every one of those copies does; `sm` is the dense row.
|
|
2077
|
+
*
|
|
2078
|
+
* Left out, the default card keeps no padding (its CardHeader / CardContent own the
|
|
2079
|
+
* rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
|
|
2080
|
+
* in `className` still wins.
|
|
2081
|
+
*/
|
|
2082
|
+
padding?: "none" | "sm" | "md";
|
|
2083
|
+
/**
|
|
2084
|
+
* A card that is a WARNING (or other status) as a whole: the border in the tone's
|
|
2085
|
+
* `-border` colour and the {@link CardTitle} in the tone's text colour. kastlan's
|
|
2086
|
+
* dunning summary (invoice-detail-page.tsx:231-235) writes both by hand —
|
|
2087
|
+
* `border-[var(--warning-border)]` on the Card, `text-[var(--warning)]` on the title
|
|
2088
|
+
* — and the next status card would copy the pair (`outline` takes it the same way).
|
|
2089
|
+
* An `inset` panel has no border, so it takes the tone's quiet `-bg` fill instead.
|
|
2090
|
+
* `data-tone` is set for a caller's own
|
|
2091
|
+
* selectors.
|
|
2092
|
+
*/
|
|
2093
|
+
tone?: CardTone;
|
|
1507
2094
|
}
|
|
1508
2095
|
|
|
1509
|
-
export
|
|
2096
|
+
export type CardTone = "warning" | "danger" | "info" | "success";
|
|
2097
|
+
|
|
2098
|
+
// The title is reached through its `data-slot`, so the tone needs no context and a
|
|
2099
|
+
// caller's `className` on CardTitle still wins (it is on the element itself).
|
|
2100
|
+
const CARD_TONES: Record<CardTone, { border: string; fill: string; title: string }> = {
|
|
2101
|
+
warning: {
|
|
2102
|
+
border: "border-[var(--warning-border)]",
|
|
2103
|
+
fill: "bg-[var(--warning-bg)]",
|
|
2104
|
+
title: "[&_[data-slot=card-title]]:text-[var(--warning)]",
|
|
2105
|
+
},
|
|
2106
|
+
danger: {
|
|
2107
|
+
border: "border-[var(--danger-border)]",
|
|
2108
|
+
fill: "bg-[var(--danger-bg)]",
|
|
2109
|
+
title: "[&_[data-slot=card-title]]:text-[var(--danger)]",
|
|
2110
|
+
},
|
|
2111
|
+
info: {
|
|
2112
|
+
border: "border-[var(--info-border)]",
|
|
2113
|
+
fill: "bg-[var(--info-bg)]",
|
|
2114
|
+
title: "[&_[data-slot=card-title]]:text-[var(--info)]",
|
|
2115
|
+
},
|
|
2116
|
+
success: {
|
|
2117
|
+
border: "border-[var(--success-border)]",
|
|
2118
|
+
fill: "bg-[var(--success-bg)]",
|
|
2119
|
+
title: "[&_[data-slot=card-title]]:text-[var(--success)]",
|
|
2120
|
+
},
|
|
2121
|
+
};
|
|
2122
|
+
|
|
2123
|
+
const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
|
|
2124
|
+
none: "p-0",
|
|
2125
|
+
sm: "p-3",
|
|
2126
|
+
md: "p-4",
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
|
|
2130
|
+
const toned = tone ? CARD_TONES[tone] : undefined;
|
|
2131
|
+
// An outline card is bordered like the default one, so a tone recolours its border.
|
|
2132
|
+
const bordered = variant !== "inset";
|
|
1510
2133
|
return (
|
|
1511
2134
|
<div
|
|
2135
|
+
data-tone={tone}
|
|
1512
2136
|
{...rest}
|
|
1513
2137
|
className={cn(
|
|
1514
2138
|
variant === "inset"
|
|
1515
2139
|
? "rounded-md bg-[var(--bg-surface-2)] p-3"
|
|
1516
|
-
:
|
|
2140
|
+
: variant === "outline"
|
|
2141
|
+
? // No shadow and no `flush`: it sits on a surface that already has both.
|
|
2142
|
+
"rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-4"
|
|
2143
|
+
: cn(
|
|
1517
2144
|
// Surface + border are theme tokens so the palette switcher (feedback
|
|
1518
2145
|
// #307) can re-skin every card; a caller's own bg-*/border-* override
|
|
1519
2146
|
// still wins via tailwind-merge.
|
|
@@ -1522,6 +2149,9 @@ export function Card({ className, children, flush, variant = "default", ...rest
|
|
|
1522
2149
|
? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm"
|
|
1523
2150
|
: "rounded-lg border border-[var(--border)] shadow-sm",
|
|
1524
2151
|
),
|
|
2152
|
+
toned && (bordered ? toned.border : toned.fill),
|
|
2153
|
+
toned?.title,
|
|
2154
|
+
padding && CARD_PADDING[padding],
|
|
1525
2155
|
className,
|
|
1526
2156
|
)}
|
|
1527
2157
|
>
|
|
@@ -1623,6 +2253,19 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1623
2253
|
* one both announce).
|
|
1624
2254
|
*/
|
|
1625
2255
|
label?: string | null;
|
|
2256
|
+
/**
|
|
2257
|
+
* Show the label as TEXT beside the ring, not only to a screen reader. kastlan's
|
|
2258
|
+
* LoadingState (feedback/loading-state.tsx:14) builds exactly this by hand — a
|
|
2259
|
+
* `role="status"` wrapper, the spinner at `label={null}`, a `<p>` of text — so it
|
|
2260
|
+
* has to get the three-part aria arrangement right itself. Here the visible text IS
|
|
2261
|
+
* the live region's content (the label stays the accessible name, said once), and
|
|
2262
|
+
* the ring is decorative. `className` still sizes the ring; the other props go on
|
|
2263
|
+
* the wrapper. Ignored with `label={null}`: there are no words to show.
|
|
2264
|
+
*/
|
|
2265
|
+
showLabel?: boolean;
|
|
2266
|
+
/** Where the shown label sits: `end` (default) — beside the ring, for a row or a
|
|
2267
|
+
* button; `below` — under it, centred, for a panel's loading state. */
|
|
2268
|
+
labelPosition?: "end" | "below";
|
|
1626
2269
|
}
|
|
1627
2270
|
|
|
1628
2271
|
/**
|
|
@@ -1640,7 +2283,7 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
1640
2283
|
* `label={null}`: announcing is right for a spinner standing alone and wrong for one
|
|
1641
2284
|
* beside text, inside a button, or inside the app's own live region.
|
|
1642
2285
|
*/
|
|
1643
|
-
export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
2286
|
+
export function Spinner({ className, label, showLabel, labelPosition = "end", ...rest }: SpinnerProps) {
|
|
1644
2287
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
|
|
1645
2288
|
loading: label ?? undefined,
|
|
1646
2289
|
});
|
|
@@ -1649,6 +2292,21 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1649
2292
|
className,
|
|
1650
2293
|
);
|
|
1651
2294
|
if (label === null) return <span aria-hidden {...rest} className={ring} />;
|
|
2295
|
+
if (showLabel) {
|
|
2296
|
+
return (
|
|
2297
|
+
<span
|
|
2298
|
+
role="status"
|
|
2299
|
+
{...rest}
|
|
2300
|
+
className={cn(
|
|
2301
|
+
"inline-flex items-center text-sm text-[var(--text-secondary)]",
|
|
2302
|
+
labelPosition === "below" ? "flex-col gap-2 text-center" : "gap-2",
|
|
2303
|
+
)}
|
|
2304
|
+
>
|
|
2305
|
+
<span aria-hidden className={ring} />
|
|
2306
|
+
<span>{common.loading}</span>
|
|
2307
|
+
</span>
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
1652
2310
|
return (
|
|
1653
2311
|
<span role="status" {...rest} className={ring}>
|
|
1654
2312
|
{/* The trailing space is a separator for the case the caller forgot `label={null}`
|