@eifi1/ui-kit 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -32
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +24 -4
- package/dist/components/amount-input.js +5 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/button-group.d.ts +51 -2
- package/dist/components/button-group.js +5 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +15 -4
- package/dist/components/calendar-heatmap.d.ts +41 -3
- package/dist/components/calendar-heatmap.js +48 -7
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +6 -3
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +35 -3
- package/dist/components/choice-card.js +83 -25
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.js +2 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +51 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +102 -12
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +18 -3
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +92 -0
- package/dist/components/error-boundary.js +111 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field.d.ts +30 -4
- package/dist/components/field.js +40 -17
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +50 -208
- package/dist/components/file-dropzone.d.ts +49 -2
- package/dist/components/floating-panel.d.ts +13 -4
- package/dist/components/floating-panel.js +7 -3
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +23 -2
- package/dist/components/list.js +32 -13
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +5 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +13 -1
- package/dist/components/mini-calendar.js +46 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +19 -3
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -1
- package/dist/components/nav-pills.js +5 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.d.ts +24 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +25 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +15 -4
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +2 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/progress-bar.d.ts +44 -3
- package/dist/components/progress-bar.js +58 -22
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/settings-fields.d.ts +49 -1
- package/dist/components/signed-amount.d.ts +121 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +5 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +39 -5
- package/dist/components/table.js +45 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +176 -0
- package/dist/components/text-link.js +109 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/time-input.d.ts +49 -1
- package/dist/components/toggle-group.d.ts +13 -0
- package/dist/components/toggle-group.js +35 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +36 -3
- package/dist/components/toggle-legend.js +49 -0
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +51 -779
- package/dist/components/ui.js +215 -23
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +16 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +133 -0
- package/dist/feedback/feedback-thread.js +237 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +49 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +15 -4
- package/dist/i18n/defaults.js +31 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +46 -312
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
- package/dist/i18n/locales/de-CH.d.ts +15 -4
- package/dist/i18n/locales/de-informal.d.ts +15 -4
- package/dist/i18n/locales/de.d.ts +15 -4
- package/dist/i18n/locales/de.js +156 -3
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +15 -4
- package/dist/i18n/locales/es.js +153 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +15 -4
- package/dist/i18n/locales/fr.js +153 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +15 -4
- package/dist/i18n/locales/hu.js +153 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +15 -4
- package/dist/i18n/locales/it.js +153 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +15 -4
- package/dist/i18n/locales/zh.js +153 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +37 -17
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
- package/dist/lib/format.d.ts +144 -0
- package/dist/lib/format.js +149 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +241 -0
- package/dist/rhf/fields.js +601 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +49 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf.d.ts +61 -1
- package/dist/rhf.js +2 -0
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +58 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +12 -1
- package/dist/shell/topbar-action-menu.js +16 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +56 -1
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +48 -1
- package/dist/wizard/wizard-context.d.ts +24 -1
- package/dist/wizard/wizard-context.js +19 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +20 -5
- package/dist/wizard/wizard-step.js +16 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +50 -3
- package/package.json +1 -1
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/amount-input.tsx +13 -1
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/button-group.tsx +7 -2
- package/src/components/calendar-heatmap.tsx +105 -10
- package/src/components/chip.tsx +8 -4
- package/src/components/choice-card.tsx +136 -25
- package/src/components/combobox-core.tsx +5 -0
- package/src/components/combobox.tsx +31 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +224 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +18 -3
- package/src/components/error-boundary.tsx +216 -0
- package/src/components/field.tsx +56 -5
- package/src/components/floating-panel.tsx +20 -7
- package/src/components/form-actions.tsx +191 -0
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +65 -16
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +7 -2
- package/src/components/mini-calendar.tsx +72 -3
- package/src/components/modal.tsx +61 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +19 -3
- package/src/components/nav-pills.tsx +8 -1
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +16 -1
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +7 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/progress-bar.tsx +129 -19
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/signed-amount.tsx +286 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +8 -2
- package/src/components/table.tsx +102 -13
- package/src/components/text-link.tsx +248 -0
- package/src/components/toggle-group.tsx +58 -10
- package/src/components/toggle-legend.tsx +87 -2
- package/src/components/ui.tsx +426 -36
- package/src/components/use-table-state.ts +265 -33
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-dialog.tsx +42 -3
- package/src/feedback/feedback-thread.tsx +434 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +30 -0
- package/src/i18n/kit-labels.tsx +102 -8
- package/src/i18n/locales/de.ts +153 -0
- package/src/i18n/locales/es.ts +150 -0
- package/src/i18n/locales/fr.ts +153 -0
- package/src/i18n/locales/hu.ts +151 -0
- package/src/i18n/locales/it.ts +151 -0
- package/src/i18n/locales/zh.ts +150 -0
- package/src/index.ts +59 -0
- package/src/lib/format.ts +325 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1054 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf.ts +6 -0
- package/src/shell/app-shell.tsx +147 -2
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +21 -0
- package/src/shell.ts +2 -0
- package/src/wizard/wizard-context.tsx +41 -1
- package/src/wizard/wizard-step.tsx +43 -6
- package/tokens.css +12 -0
|
@@ -21,6 +21,13 @@ export interface SkeletonProps extends Omit<ComponentPropsWithoutRef<"div">, "ch
|
|
|
21
21
|
shape?: SkeletonShape;
|
|
22
22
|
/** For `line`: this many lines, the last one shorter — a paragraph's outline. */
|
|
23
23
|
lines?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Say what is loading, once: wraps the placeholder in a `role="status"` region with
|
|
26
|
+
* this as its (visually hidden) text — kastlan's activity feed (activity-feed:52)
|
|
27
|
+
* built exactly that by hand around `<Skeleton lines={5} />`. The placeholder itself
|
|
28
|
+
* stays hidden. Left out, nothing is announced, as before.
|
|
29
|
+
*/
|
|
30
|
+
label?: string;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
const SHAPE: Record<SkeletonShape, string> = {
|
|
@@ -37,10 +44,21 @@ const SHAPE: Record<SkeletonShape, string> = {
|
|
|
37
44
|
*
|
|
38
45
|
* ALWAYS `aria-hidden`. A placeholder is not content, and a reader that met twelve
|
|
39
46
|
* unnamed grey boxes would have learned nothing. Say "loading" once, where it
|
|
40
|
-
* belongs: `aria-busy` on the region being filled,
|
|
41
|
-
* beside the skeletons
|
|
47
|
+
* belongs: `aria-busy` on the region being filled, a `Spinner` / sr-only line
|
|
48
|
+
* beside the skeletons, or this component's own `label`.
|
|
42
49
|
*/
|
|
43
|
-
export function Skeleton({
|
|
50
|
+
export function Skeleton({ label, ...props }: SkeletonProps) {
|
|
51
|
+
if (!label) return <SkeletonFill {...props} />;
|
|
52
|
+
return (
|
|
53
|
+
// `relative`: the containing block for the sr-only text (sr-only-containment.test).
|
|
54
|
+
<div role="status" className="relative" data-skeleton-status="">
|
|
55
|
+
<span className="sr-only">{label}</span>
|
|
56
|
+
<SkeletonFill {...props} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function SkeletonFill({ shape = "line", lines, className, ...rest }: Omit<SkeletonProps, "label">) {
|
|
44
62
|
if (shape === "line" && lines !== undefined && lines > 1) {
|
|
45
63
|
return (
|
|
46
64
|
<div {...rest} aria-hidden className={cn("flex flex-col gap-2", className)} data-skeleton="lines">
|
|
@@ -2,7 +2,8 @@ import { isValidElement, useId, useLayoutEffect, useState } from "react";
|
|
|
2
2
|
import type { ComponentPropsWithoutRef, CSSProperties, ReactElement, ReactNode } from "react";
|
|
3
3
|
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
-
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
5
|
+
import { useKitLabels, useKitLink, useKitLocale } from "../i18n/kit-labels";
|
|
6
|
+
import { pickLinkRenderer, RenderedKitLink } from "./text-link";
|
|
6
7
|
import { Card, FieldHint } from "./ui";
|
|
7
8
|
import { Sparkline } from "./sparkline";
|
|
8
9
|
import { Tooltip } from "./tooltip";
|
|
@@ -219,7 +220,8 @@ export interface StatTileProps
|
|
|
219
220
|
/** Makes the tile a link. See {@link renderLink} for a router. */
|
|
220
221
|
href?: string;
|
|
221
222
|
/** Renders the link for `href` — pass your router's `<Link>` here, since a plain
|
|
222
|
-
* `<a>` reloads a single-page app. Default: `<
|
|
223
|
+
* `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`
|
|
224
|
+
* (for an in-app `href`), then `<a>`. */
|
|
223
225
|
renderLink?: (props: StatTileLinkProps) => ReactElement;
|
|
224
226
|
/** Makes the tile a button (ignored when `href` is given). */
|
|
225
227
|
onClick?: () => void;
|
|
@@ -297,6 +299,7 @@ export function StatTile({
|
|
|
297
299
|
...rest
|
|
298
300
|
}: StatTileProps) {
|
|
299
301
|
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
302
|
+
const kitLink = useKitLink();
|
|
300
303
|
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);
|
|
301
304
|
const labelCut = useIsTruncated(truncateLabel ? labelEl : null);
|
|
302
305
|
const kitLocale = useKitLocale(locale);
|
|
@@ -334,8 +337,11 @@ export function StatTile({
|
|
|
334
337
|
"aria-describedby": describedBy,
|
|
335
338
|
children: label,
|
|
336
339
|
};
|
|
340
|
+
const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);
|
|
337
341
|
labelNode = renderLink ? (
|
|
338
342
|
renderLink(linkProps)
|
|
343
|
+
) : kit ? (
|
|
344
|
+
<RenderedKitLink render={kit} props={linkProps} />
|
|
339
345
|
) : (
|
|
340
346
|
<a href={href} className={stretched} aria-describedby={describedBy}>
|
|
341
347
|
{label}
|
package/src/components/table.tsx
CHANGED
|
@@ -29,10 +29,21 @@ export type TableHeaderCellSize = "xs" | "sm";
|
|
|
29
29
|
/** The header cell's weight. Default `medium`. */
|
|
30
30
|
export type TableHeaderCellWeight = "normal" | "medium" | "semibold";
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What a {@link TableRow} is in a statement-style table (0.12.0): an ordinary `row`, a
|
|
34
|
+
* `group` heading ("Current assets", a tinted full-width label row), a `subtotal` under
|
|
35
|
+
* a group, or the `total` of the whole table. kastlan's balance sheet, income statement
|
|
36
|
+
* and journal entry each spelled these as `className="font-medium"`, `"font-bold
|
|
37
|
+
* border-t-2"` and `bg-muted/50` on the cell — three pages, three sets of classes for
|
|
38
|
+
* the same four kinds of line.
|
|
39
|
+
*/
|
|
40
|
+
export type TableRowVariant = "row" | "group" | "subtotal" | "total";
|
|
41
|
+
|
|
32
42
|
interface TableContextValue {
|
|
33
43
|
density: TableDensity;
|
|
34
44
|
zebra: boolean;
|
|
35
45
|
hover: boolean;
|
|
46
|
+
rowDividers: boolean;
|
|
36
47
|
captionId: string;
|
|
37
48
|
registerCaption: () => () => void;
|
|
38
49
|
}
|
|
@@ -41,12 +52,44 @@ const TableContext = createContext<TableContextValue>({
|
|
|
41
52
|
density: "comfortable",
|
|
42
53
|
zebra: false,
|
|
43
54
|
hover: false,
|
|
55
|
+
rowDividers: true,
|
|
44
56
|
captionId: "",
|
|
45
57
|
registerCaption: () => () => {},
|
|
46
58
|
});
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
type TableSection = "head" | "body" | "foot";
|
|
61
|
+
|
|
62
|
+
/** Which section a row sits in — zebra and hover apply to body rows only. `undefined`
|
|
63
|
+
* outside the kit's section parts: a raw `<thead>` / `<tbody>`, which
|
|
64
|
+
* {@link useSection} then reads off the DOM. */
|
|
65
|
+
const SectionContext = createContext<TableSection | undefined>(undefined);
|
|
66
|
+
|
|
67
|
+
const SECTION_OF_TAG: Record<string, TableSection> = { THEAD: "head", TBODY: "body", TFOOT: "foot" };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The section a row or cell sits in: the kit part's context where there is one, else the
|
|
71
|
+
* nearest `<thead>` / `<tbody>` / `<tfoot>` in the DOM.
|
|
72
|
+
*
|
|
73
|
+
* keksdose's VAT summary writes a raw `<thead>` round kit cells in places, and a
|
|
74
|
+
* `TableHeaderCell` there took itself for a BODY cell — `scope="row"`, body type — with
|
|
75
|
+
* nothing on the page to say why. React cannot see an ancestor's tag, so the fallback
|
|
76
|
+
* reads it after mount; a layout effect, so the corrected cell is what gets painted.
|
|
77
|
+
*/
|
|
78
|
+
function useSection<E extends HTMLElement>(): [TableSection, ((el: E | null) => void) | undefined] {
|
|
79
|
+
const fromContext = useContext(SectionContext);
|
|
80
|
+
const [node, setNode] = useState<E | null>(null);
|
|
81
|
+
const [detected, setDetected] = useState<TableSection | undefined>(undefined);
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (fromContext !== undefined || !node) return;
|
|
84
|
+
const section = node.closest("thead, tbody, tfoot");
|
|
85
|
+
const found = section ? SECTION_OF_TAG[section.tagName] : undefined;
|
|
86
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- read off the DOM, which only exists after mount
|
|
87
|
+
if (found !== detected) setDetected(found);
|
|
88
|
+
}, [fromContext, node, detected]);
|
|
89
|
+
// No ref at all under a kit section: holding the node would cost every row and cell
|
|
90
|
+
// a second render on mount for an answer the context already gave.
|
|
91
|
+
return [fromContext ?? detected ?? "body", fromContext === undefined ? setNode : undefined];
|
|
92
|
+
}
|
|
50
93
|
|
|
51
94
|
/** A {@link TableRow}'s `valign`, which its cells take unless they set their own. It
|
|
52
95
|
* cannot be left to CSS inheritance: every cell states its own default alignment,
|
|
@@ -102,6 +145,22 @@ export interface TableProps extends ComponentPropsWithoutRef<"table"> {
|
|
|
102
145
|
wrapperClassName?: string;
|
|
103
146
|
/** See {@link TableLayout}. Unset leaves the browser's `auto` and adds no class. */
|
|
104
147
|
layout?: TableLayout;
|
|
148
|
+
/**
|
|
149
|
+
* A rounded border round the whole table (0.12.0) — for a table standing on the page
|
|
150
|
+
* or in a dialog rather than inside a card. kastlan wraps six of them in
|
|
151
|
+
* `<div className="rounded-md border">` by hand (payment allocation, the new-budget
|
|
152
|
+
* lines, the meter readings, the deposit transactions, the unit values editor, the
|
|
153
|
+
* maintenance detail), and a hand-made frame outside the scroll wrapper does not clip
|
|
154
|
+
* the rows' tint to its corners.
|
|
155
|
+
*/
|
|
156
|
+
framed?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* The rule between body rows. Default true. `false` for a small table whose rows are
|
|
159
|
+
* spaced by their own content — keksdose's VAT summary, a block of three quiet
|
|
160
|
+
* columns inside a card — which otherwise had to drop to raw `<tr>`s to lose it. One
|
|
161
|
+
* row can still say otherwise with {@link TableRowProps.bordered}.
|
|
162
|
+
*/
|
|
163
|
+
rowDividers?: boolean;
|
|
105
164
|
}
|
|
106
165
|
|
|
107
166
|
/**
|
|
@@ -127,6 +186,8 @@ export function Table({
|
|
|
127
186
|
hover = false,
|
|
128
187
|
wrapperClassName,
|
|
129
188
|
layout,
|
|
189
|
+
framed = false,
|
|
190
|
+
rowDividers = true,
|
|
130
191
|
className,
|
|
131
192
|
"aria-label": ariaLabel,
|
|
132
193
|
...rest
|
|
@@ -141,13 +202,14 @@ export function Table({
|
|
|
141
202
|
density,
|
|
142
203
|
zebra,
|
|
143
204
|
hover,
|
|
205
|
+
rowDividers,
|
|
144
206
|
captionId,
|
|
145
207
|
registerCaption: () => {
|
|
146
208
|
setCaptions((n) => n + 1);
|
|
147
209
|
return () => setCaptions((n) => n - 1);
|
|
148
210
|
},
|
|
149
211
|
}),
|
|
150
|
-
[density, zebra, hover, captionId],
|
|
212
|
+
[density, zebra, hover, rowDividers, captionId],
|
|
151
213
|
);
|
|
152
214
|
|
|
153
215
|
const regionName = captions > 0 ? { "aria-labelledby": captionId } : ariaLabel ? { "aria-label": ariaLabel } : null;
|
|
@@ -163,9 +225,13 @@ export function Table({
|
|
|
163
225
|
tabIndex={overflowing ? 0 : undefined}
|
|
164
226
|
data-overflowing={overflowing || undefined}
|
|
165
227
|
data-clips=""
|
|
228
|
+
data-framed={framed || undefined}
|
|
166
229
|
className={cn(
|
|
167
230
|
// `relative`: the containing block for an `sr-only` caption.
|
|
168
231
|
"relative w-full overflow-x-auto",
|
|
232
|
+
// On the scroll wrapper itself, so its clipping keeps the rows inside the
|
|
233
|
+
// rounded corners.
|
|
234
|
+
framed && "rounded-md border border-[var(--border)]",
|
|
169
235
|
THIN_SCROLLBAR_CLASS,
|
|
170
236
|
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
|
|
171
237
|
wrapperClassName,
|
|
@@ -187,12 +253,16 @@ export function Table({
|
|
|
187
253
|
);
|
|
188
254
|
}
|
|
189
255
|
|
|
190
|
-
export
|
|
256
|
+
export interface TableHeadProps extends ComponentPropsWithoutRef<"thead"> {
|
|
257
|
+
/** The rule under the head. Default true; `false` for a quiet header over a small
|
|
258
|
+
* table (keksdose's VAT summary wrote `className="border-b-0"` to lose it). */
|
|
259
|
+
bordered?: boolean;
|
|
260
|
+
}
|
|
191
261
|
|
|
192
|
-
export function TableHead({ className, ...rest }: TableHeadProps) {
|
|
262
|
+
export function TableHead({ bordered = true, className, ...rest }: TableHeadProps) {
|
|
193
263
|
return (
|
|
194
264
|
<SectionContext.Provider value="head">
|
|
195
|
-
<thead {...rest} className={cn("border-b border-[var(--border)]", className)} />
|
|
265
|
+
<thead {...rest} className={cn(bordered && "border-b border-[var(--border)]", className)} />
|
|
196
266
|
</SectionContext.Provider>
|
|
197
267
|
);
|
|
198
268
|
}
|
|
@@ -290,24 +360,42 @@ export function TableFoot({ className, ...rest }: TableFootProps) {
|
|
|
290
360
|
);
|
|
291
361
|
}
|
|
292
362
|
|
|
363
|
+
const ROW_VARIANT: Record<TableRowVariant, string | false> = {
|
|
364
|
+
row: false,
|
|
365
|
+
group: "bg-[var(--bg-surface-2)] font-semibold",
|
|
366
|
+
subtotal: "font-medium",
|
|
367
|
+
// A 2px rule over the 1px divider the row above draws on its bottom: collapsed
|
|
368
|
+
// borders meet on one line, the wider wins, and the heavier rule is what says "sum".
|
|
369
|
+
total: "border-t-2 border-t-[var(--border-strong)] font-semibold",
|
|
370
|
+
};
|
|
371
|
+
|
|
293
372
|
/** The deprecated HTML `valign` attribute is replaced by a class-backed one. */
|
|
294
373
|
export interface TableRowProps extends ComponentPropsWithoutRef<"tr"> {
|
|
295
374
|
/** Vertical alignment for every cell of the row that does not set its own. See
|
|
296
375
|
* {@link TableVAlign}. Left out, each cell keeps its default. */
|
|
297
376
|
valign?: TableVAlign;
|
|
377
|
+
/** See {@link TableRowVariant}. Default `row`. Zebra and hover tint ordinary rows
|
|
378
|
+
* only: a group heading or a total is not one more record. */
|
|
379
|
+
variant?: TableRowVariant;
|
|
380
|
+
/** The rule under this body row. Default: the table's `rowDividers`. */
|
|
381
|
+
bordered?: boolean;
|
|
298
382
|
}
|
|
299
383
|
|
|
300
|
-
export function TableRow({ valign, className, ...rest }: TableRowProps) {
|
|
301
|
-
const { zebra, hover } = useContext(TableContext);
|
|
302
|
-
const section =
|
|
384
|
+
export function TableRow({ valign, variant = "row", bordered, className, ...rest }: TableRowProps) {
|
|
385
|
+
const { zebra, hover, rowDividers } = useContext(TableContext);
|
|
386
|
+
const [section, ref] = useSection<HTMLTableRowElement>();
|
|
303
387
|
const body = section === "body";
|
|
388
|
+
const record = body && variant === "row";
|
|
304
389
|
const row = (
|
|
305
390
|
<tr
|
|
391
|
+
ref={ref}
|
|
306
392
|
{...rest}
|
|
393
|
+
data-variant={variant === "row" ? undefined : variant}
|
|
307
394
|
className={cn(
|
|
308
|
-
body && "border-b border-[var(--border)]",
|
|
309
|
-
|
|
310
|
-
|
|
395
|
+
body && (bordered ?? rowDividers) && "border-b border-[var(--border)]",
|
|
396
|
+
record && zebra && "even:bg-[var(--bg-surface-2)]",
|
|
397
|
+
record && hover && "transition-colors hover:bg-[var(--bg-hover)]",
|
|
398
|
+
ROW_VARIANT[variant],
|
|
311
399
|
// On the row as well, for a raw `<td>` of the caller's, which inherits it.
|
|
312
400
|
valign && VALIGN[valign],
|
|
313
401
|
className,
|
|
@@ -356,12 +444,13 @@ export function TableHeaderCell({
|
|
|
356
444
|
...rest
|
|
357
445
|
}: TableHeaderCellProps) {
|
|
358
446
|
const { density } = useContext(TableContext);
|
|
359
|
-
const section =
|
|
447
|
+
const [section, ref] = useSection<HTMLTableCellElement>();
|
|
360
448
|
const rowVAlign = useContext(RowVAlignContext);
|
|
361
449
|
const head = section === "head";
|
|
362
450
|
const textSize = size ?? (head ? "xs" : undefined);
|
|
363
451
|
return (
|
|
364
452
|
<th
|
|
453
|
+
ref={ref}
|
|
365
454
|
{...rest}
|
|
366
455
|
scope={scope ?? (section === "head" ? "col" : "row")}
|
|
367
456
|
className={cn(
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
2
|
+
import { ExternalLink } from "lucide-react";
|
|
3
|
+
import type { LucideIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
6
|
+
import type { KitLinkComponent } from "../i18n/kit-labels";
|
|
7
|
+
|
|
8
|
+
/* ── Choosing what draws a link ──────────────────────────────────────────── */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a
|
|
12
|
+
* protocol-relative `//host`. A router's link is for paths inside the app, so the
|
|
13
|
+
* provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.
|
|
14
|
+
*
|
|
15
|
+
* NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the
|
|
16
|
+
* hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to
|
|
17
|
+
* `to="/x"`. A plain in-page anchor (`#section`) is not external either, but it is not
|
|
18
|
+
* a route: see {@link isInPageAnchor}.
|
|
19
|
+
*/
|
|
20
|
+
export function isExternalHref(href: string): boolean {
|
|
21
|
+
return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An `href` that only scrolls the current page: `#section` (or a bare `#`). The
|
|
26
|
+
* hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router
|
|
27
|
+
* link must not get an anchor — under a `HashRouter` `#section` would navigate to the
|
|
28
|
+
* path `/section` — so every kit link draws it as a plain `<a>`.
|
|
29
|
+
*/
|
|
30
|
+
export function isInPageAnchor(href: string): boolean {
|
|
31
|
+
return href.startsWith("#") && !href.startsWith("#/");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the provider's `linkComponent` (or a kit component's router fallback) draws
|
|
36
|
+
* `href`: an in-app path, `#/x` included — not an external `href`
|
|
37
|
+
* ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).
|
|
38
|
+
*/
|
|
39
|
+
export function isRoutableHref(href: string): boolean {
|
|
40
|
+
return !isExternalHref(href) && !isInPageAnchor(href);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What draws a kit link to `href`: the component's own `renderLink` when it was given
|
|
45
|
+
* one (it always wins — the caller asked for it by name), else the
|
|
46
|
+
* `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else
|
|
47
|
+
* `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /
|
|
48
|
+
* `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router
|
|
49
|
+
* `#/x` go to the router link. Every kit link props type is a subset of the anchor
|
|
50
|
+
* attributes `KitLinkProps` describes, which is what makes the cast sound.
|
|
51
|
+
* @internal Shared by the kit's link-bearing components; not part of the barrel.
|
|
52
|
+
*/
|
|
53
|
+
export function pickLinkRenderer<P>(
|
|
54
|
+
own: ((props: P) => ReactElement) | undefined,
|
|
55
|
+
kitLink: KitLinkComponent | undefined,
|
|
56
|
+
href: string | undefined,
|
|
57
|
+
): ((props: P) => ReactElement) | undefined {
|
|
58
|
+
if (own) return own;
|
|
59
|
+
if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;
|
|
60
|
+
return kitLink as unknown as (props: P) => ReactElement;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Draws `props` through `render` as a component of its own, so a router link's hooks
|
|
65
|
+
* belong to it and not to the kit component calling it — for the kit components that
|
|
66
|
+
* call their own `renderLink` inline and reach the provider's link through here.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {
|
|
70
|
+
return render(props);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── TextLink ─────────────────────────────────────────────────────────────── */
|
|
74
|
+
|
|
75
|
+
/** `brand` (default) for a link in running text, `muted` for footer and meta links
|
|
76
|
+
* that should not compete with the content, `danger` for a destructive one ("Leave
|
|
77
|
+
* group"), `inherit` for a link that takes its surroundings' colour. */
|
|
78
|
+
export type TextLinkTone = "brand" | "muted" | "danger" | "inherit";
|
|
79
|
+
|
|
80
|
+
/** `hover` (default): underlined under the pointer — the idiom all three apps draw.
|
|
81
|
+
* `always` for a link inside prose, where colour alone must not be the only mark
|
|
82
|
+
* (WCAG 1.4.1). `none` where the context already says "link" (a footer nav). */
|
|
83
|
+
export type TextLinkUnderline = "hover" | "always" | "none";
|
|
84
|
+
|
|
85
|
+
/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */
|
|
86
|
+
export type TextLinkCurrent = "page" | "step" | "location" | "date" | "time" | "true";
|
|
87
|
+
|
|
88
|
+
/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is
|
|
89
|
+
* handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */
|
|
90
|
+
export interface TextLinkRenderProps {
|
|
91
|
+
href: string;
|
|
92
|
+
/** The link's look — keep it, or the tone, underline and focus ring go. */
|
|
93
|
+
className: string;
|
|
94
|
+
children: ReactNode;
|
|
95
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
96
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
97
|
+
onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
|
|
98
|
+
"aria-current"?: TextLinkCurrent;
|
|
99
|
+
id?: string;
|
|
100
|
+
title?: string;
|
|
101
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
102
|
+
[key: `data-${string}`]: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TextLinkProps
|
|
106
|
+
extends Omit<ComponentPropsWithoutRef<"a">, "href" | "children" | "aria-current" | "onClick" | "onKeyDown"> {
|
|
107
|
+
href: string;
|
|
108
|
+
children: ReactNode;
|
|
109
|
+
/** Default `brand`. */
|
|
110
|
+
tone?: TextLinkTone;
|
|
111
|
+
/** Default `hover`. */
|
|
112
|
+
underline?: TextLinkUnderline;
|
|
113
|
+
/**
|
|
114
|
+
* The link leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`
|
|
115
|
+
* (never the router's), an external-link mark after the text (mirrored in RTL), and
|
|
116
|
+
* "opens in a new tab" (`common.opensInNewTab`) read after it — the mark says it
|
|
117
|
+
* only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).
|
|
118
|
+
*/
|
|
119
|
+
external?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Keep the click (and the Enter that activates the link) from reaching an ancestor —
|
|
122
|
+
* a link in a DataTable row with `onRowClick` goes to its own target instead of
|
|
123
|
+
* running the row's action. kastlan's `CellLink` did this by hand.
|
|
124
|
+
*/
|
|
125
|
+
stopPropagation?: boolean;
|
|
126
|
+
/** Mark the link as the current one of a set — `true` is `aria-current="page"`, the
|
|
127
|
+
* legal footer's link to the page you are on. Drawn in semibold. */
|
|
128
|
+
current?: boolean | TextLinkCurrent;
|
|
129
|
+
/** A Lucide icon before the text, at the text's size — kastlan's "← Back" link. */
|
|
130
|
+
icon?: LucideIcon;
|
|
131
|
+
/** Your router's link for an in-app `href`. Default: the `<UiKitProvider
|
|
132
|
+
* linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */
|
|
133
|
+
renderLink?: (props: TextLinkRenderProps) => ReactElement;
|
|
134
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
135
|
+
onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;
|
|
136
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TONE: Record<TextLinkTone, string> = {
|
|
140
|
+
brand: "text-[var(--brand)] hover:text-[var(--brand-hover)]",
|
|
141
|
+
muted: "text-[var(--text-muted)] hover:text-[var(--text-primary)]",
|
|
142
|
+
danger: "text-[var(--danger)] hover:text-[var(--danger-hover)]",
|
|
143
|
+
inherit: "",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const UNDERLINE: Record<TextLinkUnderline, string> = {
|
|
147
|
+
hover: "no-underline hover:underline",
|
|
148
|
+
always: "underline",
|
|
149
|
+
none: "no-underline",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one inline link: a real `<a href>` (middle click, "open in new tab", link
|
|
154
|
+
* semantics) through the app's router for an in-app path, in one of four tones.
|
|
155
|
+
*
|
|
156
|
+
* kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,
|
|
157
|
+
* hover underline, one stopping the row click), the legal pages' back link and footer,
|
|
158
|
+
* the feedback page's URL links, the calculator's external sources with a hand-placed
|
|
159
|
+
* ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)
|
|
160
|
+
* used a raw `<a>` to an in-app route, which reloads the whole app. Here the router
|
|
161
|
+
* link comes from `<UiKitProvider linkComponent>`, set once.
|
|
162
|
+
*
|
|
163
|
+
* Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit
|
|
164
|
+
* on the text's baseline at the text's size, so they scale with whatever type the link
|
|
165
|
+
* is set in. The focus ring is an outline, which follows every line of a wrapped link.
|
|
166
|
+
*/
|
|
167
|
+
export function TextLink({
|
|
168
|
+
href,
|
|
169
|
+
children,
|
|
170
|
+
tone = "brand",
|
|
171
|
+
underline = "hover",
|
|
172
|
+
external = false,
|
|
173
|
+
stopPropagation = false,
|
|
174
|
+
current,
|
|
175
|
+
icon: Icon,
|
|
176
|
+
renderLink,
|
|
177
|
+
className,
|
|
178
|
+
onClick,
|
|
179
|
+
onKeyDown,
|
|
180
|
+
ref,
|
|
181
|
+
...rest
|
|
182
|
+
}: TextLinkProps) {
|
|
183
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
184
|
+
const kitLink = useKitLink();
|
|
185
|
+
const ariaCurrent: TextLinkCurrent | undefined =
|
|
186
|
+
current === true ? "page" : current === false ? undefined : current;
|
|
187
|
+
|
|
188
|
+
const look = cn(
|
|
189
|
+
"rounded-sm underline-offset-2 transition-colors",
|
|
190
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
|
|
191
|
+
TONE[tone],
|
|
192
|
+
UNDERLINE[underline],
|
|
193
|
+
ariaCurrent && "font-semibold",
|
|
194
|
+
className,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const body = (
|
|
198
|
+
<>
|
|
199
|
+
{Icon && <Icon aria-hidden className="me-1 inline size-[1em] shrink-0 align-[-0.125em]" />}
|
|
200
|
+
{children}
|
|
201
|
+
{external && (
|
|
202
|
+
<>
|
|
203
|
+
<ExternalLink aria-hidden className="ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100" />
|
|
204
|
+
{" "}
|
|
205
|
+
<span className="sr-only">({common.opensInNewTab})</span>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const props: TextLinkRenderProps = {
|
|
212
|
+
...(rest as Partial<TextLinkRenderProps>),
|
|
213
|
+
ref,
|
|
214
|
+
href,
|
|
215
|
+
className: look,
|
|
216
|
+
"aria-current": ariaCurrent,
|
|
217
|
+
onClick: stopPropagation
|
|
218
|
+
? (event) => {
|
|
219
|
+
event.stopPropagation();
|
|
220
|
+
onClick?.(event);
|
|
221
|
+
}
|
|
222
|
+
: onClick,
|
|
223
|
+
onKeyDown: stopPropagation
|
|
224
|
+
? (event) => {
|
|
225
|
+
if (event.key === "Enter") event.stopPropagation();
|
|
226
|
+
onKeyDown?.(event);
|
|
227
|
+
}
|
|
228
|
+
: onKeyDown,
|
|
229
|
+
children: body,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const render = external ? undefined : pickLinkRenderer(renderLink, kitLink, href);
|
|
233
|
+
if (render) return <RenderedTextLink render={render} {...props} />;
|
|
234
|
+
const { children: content, ...anchor } = props;
|
|
235
|
+
return (
|
|
236
|
+
<a {...anchor} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
|
|
237
|
+
{content}
|
|
238
|
+
</a>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Calls the link renderer as a component, so a router link's hooks are its own. */
|
|
243
|
+
function RenderedTextLink({
|
|
244
|
+
render,
|
|
245
|
+
...props
|
|
246
|
+
}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {
|
|
247
|
+
return render(props);
|
|
248
|
+
}
|
|
@@ -2,7 +2,7 @@ import { useId } from "react";
|
|
|
2
2
|
import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { horizontalStep } from "../lib/direction";
|
|
5
|
-
import { FIELD_INVALID, FloatingField } from "./ui";
|
|
5
|
+
import { FIELD_INVALID, FloatingField, Label } from "./ui";
|
|
6
6
|
|
|
7
7
|
export interface ToggleOption<T extends string> {
|
|
8
8
|
value: T;
|
|
@@ -67,6 +67,19 @@ export interface ToggleGroupBaseProps<T extends string>
|
|
|
67
67
|
* group's own box is dropped: two nested borders read as a control in a control.
|
|
68
68
|
*/
|
|
69
69
|
label?: ReactNode;
|
|
70
|
+
/**
|
|
71
|
+
* Where `label` goes. `"field"` (default): the field chrome described under `label`.
|
|
72
|
+
* `"above"`: the kit's {@link Label} over the bare group — the shape of a {@link Field}
|
|
73
|
+
* — with `hint` beside the label and `error` under the group, for a form that sets
|
|
74
|
+
* its labels above its fields (kastlan's international-rent-calculator.tsx, whose DE
|
|
75
|
+
* cap pair sits in a `Field` column between two labelled-above inputs, where the
|
|
76
|
+
* chrome's inner label would be the only one of its kind).
|
|
77
|
+
*
|
|
78
|
+
* Inside a `Field`, pass no `label` at all and spread the render-prop instead —
|
|
79
|
+
* `{(ids, { labelId }) => <ToggleGroup {...ids} aria-labelledby={labelId} … />}` —
|
|
80
|
+
* so the Field's label names the group and its hint and error describe it.
|
|
81
|
+
*/
|
|
82
|
+
labelPlacement?: "field" | "above";
|
|
70
83
|
/** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with
|
|
71
84
|
* `label`. */
|
|
72
85
|
hint?: ReactNode;
|
|
@@ -143,6 +156,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
143
156
|
disabled = false,
|
|
144
157
|
size = "md",
|
|
145
158
|
label,
|
|
159
|
+
labelPlacement = "field",
|
|
146
160
|
hint,
|
|
147
161
|
error,
|
|
148
162
|
"aria-label": ariaLabelAttr,
|
|
@@ -150,11 +164,17 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
150
164
|
} = props;
|
|
151
165
|
const labelId = useId();
|
|
152
166
|
const errorId = useId();
|
|
153
|
-
const
|
|
154
|
-
|
|
167
|
+
const labelled = label !== undefined && label !== null && label !== false && label !== "";
|
|
168
|
+
// `field` is the chrome; a label placed above keeps the bare group's own box.
|
|
169
|
+
const field = labelled && labelPlacement === "field";
|
|
170
|
+
const above = labelled && labelPlacement === "above";
|
|
171
|
+
const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
|
|
155
172
|
// Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
|
|
156
173
|
// what lets the `onChange` below be called with `null` only in the mode that allows it.
|
|
157
174
|
const { allowEmpty: _allowEmpty, onChange: _onChange, caption, ...rest } = restWithMode;
|
|
175
|
+
// Invalid from outside too: a `Field` hands the bare group `aria-invalid`, and the
|
|
176
|
+
// border has to say what the attribute says.
|
|
177
|
+
const outsideInvalid = rest["aria-invalid"] === true || rest["aria-invalid"] === "true";
|
|
158
178
|
const captionId = useId();
|
|
159
179
|
const captionIsLive = typeof caption === "function";
|
|
160
180
|
const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
|
|
@@ -202,7 +222,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
202
222
|
// The DOM spelling wins; `ariaLabel` is the fallback for the call sites that
|
|
203
223
|
// have not moved yet.
|
|
204
224
|
aria-label={ariaLabelAttr ?? ariaLabel}
|
|
205
|
-
aria-labelledby={
|
|
225
|
+
aria-labelledby={labelled && ariaLabelAttr === undefined && ariaLabel === undefined ? labelId : rest["aria-labelledby"]}
|
|
206
226
|
aria-invalid={hasError || rest["aria-invalid"] || undefined}
|
|
207
227
|
aria-describedby={
|
|
208
228
|
[rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
|
|
@@ -229,10 +249,12 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
229
249
|
// package does; `cursor-not-allowed` is on the buttons, which is what a
|
|
230
250
|
// pointer is actually over.
|
|
231
251
|
disabled && "opacity-60",
|
|
252
|
+
// The bare group (no chrome to paint) wears the invalid border itself.
|
|
253
|
+
!field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,
|
|
232
254
|
// Inside the field's chrome the group is only a row of segments: no border, no
|
|
233
255
|
// surface, no padding of its own, and the field (not the group) is what dims.
|
|
234
256
|
field && "border-0 bg-transparent p-0 shadow-none opacity-100",
|
|
235
|
-
!
|
|
257
|
+
!labelled && className,
|
|
236
258
|
)}
|
|
237
259
|
>
|
|
238
260
|
{options.map((opt, index) => {
|
|
@@ -308,6 +330,36 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
308
330
|
{hasCaption ? captionNode : null}
|
|
309
331
|
</p>
|
|
310
332
|
) : null;
|
|
333
|
+
const errorEl = hasError ? (
|
|
334
|
+
<p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
|
|
335
|
+
{error}
|
|
336
|
+
</p>
|
|
337
|
+
) : null;
|
|
338
|
+
if (above) {
|
|
339
|
+
// `relative` so a caller's `sr-only` label cannot escape (sr-only-containment).
|
|
340
|
+
return (
|
|
341
|
+
<div className={cn("relative grid min-w-0 gap-1.5", className)}>
|
|
342
|
+
<div className="flex items-center gap-1">
|
|
343
|
+
{/* A `<label>` with no `htmlFor`: a group is not labelable, so it is named
|
|
344
|
+
by `aria-labelledby` on the group; the element keeps the Field look. */}
|
|
345
|
+
<Label
|
|
346
|
+
id={labelId}
|
|
347
|
+
disabled={disabled}
|
|
348
|
+
data-error={hasError || undefined}
|
|
349
|
+
className="data-[error=true]:text-[var(--danger)]"
|
|
350
|
+
>
|
|
351
|
+
{label}
|
|
352
|
+
</Label>
|
|
353
|
+
{hint}
|
|
354
|
+
</div>
|
|
355
|
+
<div className="min-w-0">
|
|
356
|
+
{group}
|
|
357
|
+
{captionEl}
|
|
358
|
+
{errorEl}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
311
363
|
if (!field) {
|
|
312
364
|
if (!captionEl) return group;
|
|
313
365
|
// The group keeps its `className`, as without a caption; the wrapper only stacks.
|
|
@@ -339,11 +391,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
|
|
|
339
391
|
</div>
|
|
340
392
|
</FloatingField>
|
|
341
393
|
{captionEl}
|
|
342
|
-
{
|
|
343
|
-
<p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
|
|
344
|
-
{error}
|
|
345
|
-
</p>
|
|
346
|
-
)}
|
|
394
|
+
{errorEl}
|
|
347
395
|
</div>
|
|
348
396
|
);
|
|
349
397
|
}
|