@eifi1/ui-kit 0.9.0 → 0.11.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 +37 -23
- package/dist/components/alert-banner.d.ts +57 -5
- package/dist/components/alert-banner.js +63 -17
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +112 -0
- package/dist/components/bulk-action-bar.js +196 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/button-group.d.ts +58 -3
- package/dist/components/button-group.js +56 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/calendar-heatmap.d.ts +124 -0
- package/dist/components/calendar-heatmap.js +295 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/chip.d.ts +31 -5
- package/dist/components/chip.js +85 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +34 -2
- package/dist/components/choice-card.js +49 -0
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +14 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +22 -2
- package/dist/components/date-picker.js +11 -3
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +57 -2
- package/dist/components/disclosure.js +24 -12
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/field.d.ts +43 -0
- package/dist/components/field.js +52 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +1 -0
- package/dist/components/file-dropzone.d.ts +4 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +147 -4
- package/dist/components/floating-panel.js +188 -28
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +187 -0
- package/dist/components/list.js +221 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +109 -0
- package/dist/components/menu-item.js +88 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +70 -5
- package/dist/components/mini-calendar.js +166 -62
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/nav-pills.d.ts +66 -0
- package/dist/components/nav-pills.js +65 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +31 -2
- package/dist/components/progress-bar.js +73 -7
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +32 -0
- package/dist/components/series-chart.js +141 -4
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -0
- package/dist/components/status-dot.d.ts +53 -0
- package/dist/components/status-dot.js +57 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +78 -13
- package/dist/components/table.js +84 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +70 -0
- package/dist/components/text.js +24 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/time-input.d.ts +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +44 -1
- package/dist/components/toggle-group.js +73 -4
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +46 -15
- package/dist/components/tooltip.js +87 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +158 -7
- package/dist/components/ui.js +191 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -0
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +30 -2
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +30 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +30 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +30 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +30 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +30 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +27 -15
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +25 -0
- package/dist/lib/clipping.js +17 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/rhf.js +1 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.d.ts +19 -2
- package/dist/search/command-palette.js +16 -5
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +26 -1
- package/dist/search/global-search.js +29 -13
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/topbar-action-menu.d.ts +73 -3
- package/dist/shell/topbar-action-menu.js +100 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +1 -0
- package/dist/wizard/stepper-nav.js +1 -1
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +7 -1
- package/dist/wizard/wizard-context.js +4 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +16 -12
- package/src/components/alert-banner.tsx +149 -21
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +362 -0
- package/src/components/button-group.tsx +124 -4
- package/src/components/calendar-heatmap.tsx +504 -0
- package/src/components/chip.tsx +128 -7
- package/src/components/choice-card.tsx +97 -1
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +7 -0
- package/src/components/date-picker.tsx +49 -1
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +93 -21
- package/src/components/field.tsx +137 -0
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +366 -15
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +443 -0
- package/src/components/menu-item.tsx +235 -0
- package/src/components/mini-calendar.tsx +245 -54
- package/src/components/modal.tsx +80 -17
- package/src/components/nav-pills.tsx +145 -0
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +129 -11
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +274 -3
- package/src/components/status-dot.tsx +107 -0
- package/src/components/table.tsx +176 -17
- package/src/components/text.tsx +97 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +128 -4
- package/src/components/tooltip.tsx +200 -100
- package/src/components/ui.tsx +381 -14
- package/src/components/user-avatar.tsx +57 -3
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +29 -0
- package/src/i18n/locales/es.ts +29 -0
- package/src/i18n/locales/fr.ts +29 -0
- package/src/i18n/locales/hu.ts +29 -0
- package/src/i18n/locales/it.ts +29 -0
- package/src/i18n/locales/zh.ts +29 -0
- package/src/index.ts +31 -0
- package/src/lib/clipping.ts +34 -0
- package/src/lib/dates.ts +37 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +2 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- package/src/shell/topbar-action-menu.tsx +239 -49
- package/src/wizard/stepper-nav.tsx +2 -1
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-context.tsx +9 -0
- package/src/wizard/wizard-summary.tsx +14 -10
- package/tokens.css +97 -0
|
@@ -3,16 +3,62 @@ import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
|
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { FieldHint } from "./ui";
|
|
5
5
|
|
|
6
|
-
export type DescriptionListLayout = "rows" | "cards";
|
|
7
|
-
export type DescriptionListDensity = "comfortable" | "compact";
|
|
6
|
+
export type DescriptionListLayout = "rows" | "cards" | "stacked";
|
|
7
|
+
export type DescriptionListDensity = "comfortable" | "compact" | "tight";
|
|
8
|
+
/** How many columns a `stacked` or `cards` list may use. */
|
|
9
|
+
export type DescriptionListColumns = 1 | 2 | 3 | 4 | 5 | 6;
|
|
8
10
|
|
|
9
11
|
interface ListContextValue {
|
|
10
12
|
layout: DescriptionListLayout;
|
|
11
13
|
numeric: boolean;
|
|
14
|
+
prose: boolean;
|
|
12
15
|
density: DescriptionListDensity;
|
|
16
|
+
columns: DescriptionListColumns;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
const ListContext = createContext<ListContextValue>({
|
|
19
|
+
const ListContext = createContext<ListContextValue>({
|
|
20
|
+
layout: "rows",
|
|
21
|
+
numeric: false,
|
|
22
|
+
prose: false,
|
|
23
|
+
density: "comfortable",
|
|
24
|
+
columns: 2,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `stacked` columns, by the width of the LIST (a container query on a wrapper — the
|
|
29
|
+
* `<dl>` is the grid and cannot query itself). One column on a phone-narrow pane, two
|
|
30
|
+
* from 20rem, then one more column each time every column would still get ~12rem.
|
|
31
|
+
* The same shape as kastlan's DetailGrid (1 → 2 at `sm` → n at `md`), measured on the
|
|
32
|
+
* pane rather than the viewport, so the four preview dialogs and a full-width detail
|
|
33
|
+
* page can share one list. Literal strings, because Tailwind only emits what it reads.
|
|
34
|
+
*/
|
|
35
|
+
const STACKED_COLS: Record<DescriptionListColumns, string> = {
|
|
36
|
+
1: "grid-cols-1",
|
|
37
|
+
2: "grid-cols-1 @xs:grid-cols-2",
|
|
38
|
+
3: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3",
|
|
39
|
+
4: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4",
|
|
40
|
+
5: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4 @5xl:grid-cols-5",
|
|
41
|
+
6: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4 @5xl:grid-cols-5 @6xl:grid-cols-6",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** An item's span at each of those thresholds, never more columns than exist there —
|
|
45
|
+
* so a span can never open an implicit column and push the grid past its pane. */
|
|
46
|
+
const STACKED_SPAN: Record<DescriptionListColumns, string> = {
|
|
47
|
+
1: "",
|
|
48
|
+
2: "@xs:col-span-2",
|
|
49
|
+
3: "@xs:col-span-2 @xl:col-span-3",
|
|
50
|
+
4: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4",
|
|
51
|
+
5: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4 @5xl:col-span-5",
|
|
52
|
+
6: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4 @5xl:col-span-5 @6xl:col-span-6",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The cards grid's gap per density, as a class and as the length the column formula
|
|
56
|
+
* subtracts — one table so the two cannot disagree. */
|
|
57
|
+
const CARD_GAP: Record<DescriptionListDensity, { cls: string; len: string }> = {
|
|
58
|
+
comfortable: { cls: "gap-3", len: "0.75rem" },
|
|
59
|
+
compact: { cls: "gap-2", len: "0.5rem" },
|
|
60
|
+
tight: { cls: "gap-1", len: "0.25rem" },
|
|
61
|
+
};
|
|
16
62
|
|
|
17
63
|
export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
|
|
18
64
|
/**
|
|
@@ -21,6 +67,10 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
|
|
|
21
67
|
* beside a sidebar stacks where it is actually cramped, not where the viewport is.
|
|
22
68
|
* `cards`: a grid of small bordered cards, term above body — keksdose's overview
|
|
23
69
|
* figures, lenkbank's account facts.
|
|
70
|
+
* `stacked` (0.10.0): a borderless grid, term ABOVE detail, {@link columns} wide —
|
|
71
|
+
* kastlan's DetailField/DetailGrid and the label-over-value grids of its lease,
|
|
72
|
+
* invoice, contact and unit preview dialogs. Items can {@link DescriptionItemProps.span}
|
|
73
|
+
* several columns (a notes field across the whole row).
|
|
24
74
|
*/
|
|
25
75
|
layout?: DescriptionListLayout;
|
|
26
76
|
/** Every detail is a figure: tabular digits, end-aligned. A `numeric` list of
|
|
@@ -28,10 +78,31 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
|
|
|
28
78
|
* term would double the list's height to gain no width. Per item: see
|
|
29
79
|
* {@link DescriptionItemProps.numeric}. */
|
|
30
80
|
numeric?: boolean;
|
|
31
|
-
/** `compact` tightens the rhythm for a dense side panel.
|
|
81
|
+
/** `compact` tightens the rhythm for a dense side panel. `tight` (0.10.0) is 11px
|
|
82
|
+
* type on a 2px rhythm with no rules between rows — keksdose's admin metrics
|
|
83
|
+
* breakdowns (metrics-panel's label-left, count-right lists), which are read as a
|
|
84
|
+
* block of small print, not scanned row by row. */
|
|
32
85
|
density?: DescriptionListDensity;
|
|
33
86
|
/** `cards` only: the narrowest a card may get before the grid drops a column. */
|
|
34
87
|
minCardWidth?: string;
|
|
88
|
+
/**
|
|
89
|
+
* `stacked`: how many columns the grid grows to on a wide pane (default 2), see
|
|
90
|
+
* {@link STACKED_COLS}. `cards` (0.10.0): the MOST cards per row; the grid still
|
|
91
|
+
* drops columns below {@link minCardWidth}. keksdose's category editor faked "two
|
|
92
|
+
* side by side" with `minCardWidth="calc(50% - 0.5rem)"`, which is `columns={2}
|
|
93
|
+
* minCardWidth="0"` now, without knowing the gap. Ignored by `rows`.
|
|
94
|
+
*/
|
|
95
|
+
columns?: DescriptionListColumns;
|
|
96
|
+
/**
|
|
97
|
+
* The details are sentences, not values: regular weight in the secondary ink.
|
|
98
|
+
* lenkbank's control explainer cards each carry a paragraph, and at the card's
|
|
99
|
+
* `font-medium` primary ink a grid of paragraphs reads as a wall of headings — it
|
|
100
|
+
* spelled `font-normal text-slate-700` into every item. A flag rather than a new
|
|
101
|
+
* default because every other card list in the apps holds values (dates, figures,
|
|
102
|
+
* counts) that are not flagged `numeric` and ARE meant to stand out; lightening them
|
|
103
|
+
* all would not be an improvement. Per item: {@link DescriptionItemProps.prose}.
|
|
104
|
+
*/
|
|
105
|
+
prose?: boolean;
|
|
35
106
|
}
|
|
36
107
|
|
|
37
108
|
/**
|
|
@@ -54,27 +125,66 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
|
|
|
54
125
|
export function DescriptionList({
|
|
55
126
|
layout = "rows",
|
|
56
127
|
numeric = false,
|
|
128
|
+
prose = false,
|
|
57
129
|
density = "comfortable",
|
|
58
130
|
minCardWidth = "10rem",
|
|
131
|
+
columns,
|
|
59
132
|
className,
|
|
60
133
|
style,
|
|
61
134
|
...rest
|
|
62
135
|
}: DescriptionListProps) {
|
|
63
136
|
const cards = layout === "cards";
|
|
137
|
+
const stacked = layout === "stacked";
|
|
138
|
+
const gap = CARD_GAP[density];
|
|
139
|
+
// Cards: auto-fill at `minCardWidth`, and with `columns` a floor on the track of one
|
|
140
|
+
// n-th of the row (less the gaps) — so auto-fill can never fit more than n. The
|
|
141
|
+
// outer `min(100%, …)` keeps a single card from overflowing a pane narrower than
|
|
142
|
+
// its minimum.
|
|
143
|
+
const track =
|
|
144
|
+
columns !== undefined
|
|
145
|
+
? `max(${minCardWidth}, (100% - ${columns - 1} * ${gap.len}) / ${columns})`
|
|
146
|
+
: minCardWidth;
|
|
64
147
|
const grid: CSSProperties | undefined = cards
|
|
65
|
-
? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${
|
|
148
|
+
? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${track}), 1fr))` }
|
|
66
149
|
: undefined;
|
|
150
|
+
const ctx: ListContextValue = { layout, numeric, prose, density, columns: columns ?? 2 };
|
|
151
|
+
const text = density === "tight" ? "text-[11px] leading-4" : density === "compact" ? "text-xs" : "text-sm";
|
|
152
|
+
|
|
153
|
+
if (stacked) {
|
|
154
|
+
return (
|
|
155
|
+
<ListContext.Provider value={ctx}>
|
|
156
|
+
{/* The container the columns are measured on — see STACKED_COLS. */}
|
|
157
|
+
<div className="@container">
|
|
158
|
+
<dl
|
|
159
|
+
{...rest}
|
|
160
|
+
data-layout={layout}
|
|
161
|
+
style={style}
|
|
162
|
+
className={cn(
|
|
163
|
+
"grid",
|
|
164
|
+
STACKED_COLS[ctx.columns],
|
|
165
|
+
density === "tight" ? "gap-x-3 gap-y-1" : density === "compact" ? "gap-x-4 gap-y-2" : "gap-x-6 gap-y-4",
|
|
166
|
+
text,
|
|
167
|
+
className,
|
|
168
|
+
)}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</ListContext.Provider>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
67
175
|
return (
|
|
68
|
-
<ListContext.Provider value={
|
|
176
|
+
<ListContext.Provider value={ctx}>
|
|
69
177
|
<dl
|
|
70
178
|
{...rest}
|
|
71
179
|
data-layout={layout}
|
|
72
180
|
style={grid ? { ...grid, ...style } : style}
|
|
73
181
|
className={cn(
|
|
74
182
|
cards
|
|
75
|
-
? cn("grid",
|
|
76
|
-
:
|
|
77
|
-
|
|
183
|
+
? cn("grid", gap.cls)
|
|
184
|
+
: // Tight rows carry no rules: at a 2px rhythm a line under every row
|
|
185
|
+
// turns a block of small print into a ruled table.
|
|
186
|
+
cn("@container", density !== "tight" && "divide-y divide-[var(--border)]"),
|
|
187
|
+
text,
|
|
78
188
|
className,
|
|
79
189
|
)}
|
|
80
190
|
/>
|
|
@@ -95,10 +205,28 @@ export interface DescriptionItemProps extends Omit<ComponentPropsWithoutRef<"div
|
|
|
95
205
|
numeric?: boolean;
|
|
96
206
|
/** Classes for the `<dd>`. */
|
|
97
207
|
detailClassName?: string;
|
|
208
|
+
/** This detail is prose (see {@link DescriptionListProps.prose}); overrides the
|
|
209
|
+
* list's setting either way. */
|
|
210
|
+
prose?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* `stacked`: how many columns this item spans, clamped to the list's `columns` and
|
|
213
|
+
* to however many the pane has room for; `"full"` is the whole row. kastlan's
|
|
214
|
+
* DetailField `span={2}` for notes and descriptions. `cards`: only `"full"` applies,
|
|
215
|
+
* because a card grid's column count is not known to CSS ahead of layout.
|
|
216
|
+
*/
|
|
217
|
+
span?: number | "full";
|
|
98
218
|
}
|
|
99
219
|
|
|
100
|
-
const ROW_PAD: Record<DescriptionListDensity, string> = {
|
|
101
|
-
|
|
220
|
+
const ROW_PAD: Record<DescriptionListDensity, string> = {
|
|
221
|
+
comfortable: "py-2.5",
|
|
222
|
+
compact: "py-1.5",
|
|
223
|
+
tight: "py-px",
|
|
224
|
+
};
|
|
225
|
+
const CARD_PAD: Record<DescriptionListDensity, string> = {
|
|
226
|
+
comfortable: "px-3 py-2.5",
|
|
227
|
+
compact: "px-2.5 py-1.5",
|
|
228
|
+
tight: "px-2 py-1",
|
|
229
|
+
};
|
|
102
230
|
|
|
103
231
|
/** One term and its detail inside a {@link DescriptionList}. */
|
|
104
232
|
export function DescriptionItem({
|
|
@@ -106,19 +234,32 @@ export function DescriptionItem({
|
|
|
106
234
|
children,
|
|
107
235
|
hint,
|
|
108
236
|
numeric: numericProp,
|
|
237
|
+
prose: proseProp,
|
|
238
|
+
span,
|
|
109
239
|
detailClassName,
|
|
110
240
|
className,
|
|
111
241
|
...rest
|
|
112
242
|
}: DescriptionItemProps) {
|
|
113
|
-
const { layout, numeric: listNumeric, density } = useContext(ListContext);
|
|
243
|
+
const { layout, numeric: listNumeric, prose: listProse, density, columns } = useContext(ListContext);
|
|
114
244
|
const numeric = numericProp ?? listNumeric;
|
|
245
|
+
const prose = proseProp ?? listProse;
|
|
115
246
|
const cards = layout === "cards";
|
|
247
|
+
const stacked = layout === "stacked";
|
|
248
|
+
const spanClass =
|
|
249
|
+
span === "full"
|
|
250
|
+
? "col-span-full"
|
|
251
|
+
: stacked && span !== undefined && span > 1
|
|
252
|
+
? STACKED_SPAN[Math.min(Math.floor(span), columns) as DescriptionListColumns]
|
|
253
|
+
: undefined;
|
|
116
254
|
|
|
117
255
|
const termNode = (
|
|
118
256
|
<dt
|
|
119
257
|
className={cn(
|
|
120
258
|
"flex min-w-0 items-center gap-1 text-[var(--text-muted)]",
|
|
121
|
-
cards && "text-xs uppercase tracking-wide",
|
|
259
|
+
cards && (density === "tight" ? "text-[10px] uppercase tracking-wide" : "text-xs uppercase tracking-wide"),
|
|
260
|
+
// Stacked terms are a step smaller than the detail, as kastlan's dialogs set
|
|
261
|
+
// them; in a list that is already small they stay the list's own size.
|
|
262
|
+
stacked && density === "comfortable" && "text-xs",
|
|
122
263
|
)}
|
|
123
264
|
>
|
|
124
265
|
<span className="min-w-0 break-words">{term}</span>
|
|
@@ -129,8 +270,10 @@ export function DescriptionItem({
|
|
|
129
270
|
<dd
|
|
130
271
|
className={cn(
|
|
131
272
|
"min-w-0 break-words text-[var(--text-primary)]",
|
|
132
|
-
|
|
133
|
-
|
|
273
|
+
// A stacked figure sits under its term, so it keeps the term's start edge.
|
|
274
|
+
numeric && (stacked ? "tabular-nums" : "tabular-nums text-end"),
|
|
275
|
+
(cards || stacked) && "mt-0.5 font-medium",
|
|
276
|
+
prose && "font-normal text-[var(--text-secondary)]",
|
|
134
277
|
detailClassName,
|
|
135
278
|
)}
|
|
136
279
|
>
|
|
@@ -145,6 +288,7 @@ export function DescriptionItem({
|
|
|
145
288
|
className={cn(
|
|
146
289
|
"min-w-0 rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
|
|
147
290
|
CARD_PAD[density],
|
|
291
|
+
spanClass,
|
|
148
292
|
className,
|
|
149
293
|
)}
|
|
150
294
|
>
|
|
@@ -154,6 +298,15 @@ export function DescriptionItem({
|
|
|
154
298
|
);
|
|
155
299
|
}
|
|
156
300
|
|
|
301
|
+
if (stacked) {
|
|
302
|
+
return (
|
|
303
|
+
<div {...rest} className={cn("min-w-0", spanClass, className)}>
|
|
304
|
+
{termNode}
|
|
305
|
+
{detail}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
157
310
|
return (
|
|
158
311
|
<div
|
|
159
312
|
{...rest}
|
|
@@ -103,6 +103,13 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
|
|
|
103
103
|
* tailwind-merged last), which is how a full-screen phone sheet is spelt:
|
|
104
104
|
* `fullBleed className="h-[100dvh] max-w-full rounded-none md:h-auto md:rounded-lg"`.
|
|
105
105
|
*
|
|
106
|
+
* ## Kept mounted
|
|
107
|
+
*
|
|
108
|
+
* `open` reaches the `Modal` like every other prop, so `<DialogFrame open={open} …>`
|
|
109
|
+
* replaces the `{open && <DialogFrame …/>}` gate (kastlan's `FormModal` was only
|
|
110
|
+
* that gate) and gains the exit a caller-driven close otherwise skips. See
|
|
111
|
+
* {@link ModalProps.open}.
|
|
112
|
+
*
|
|
106
113
|
* ## What it is not
|
|
107
114
|
*
|
|
108
115
|
* Not `FullBleedDialog`: that is the phone's full-screen editor with its own `open`,
|
|
@@ -3,22 +3,14 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
|
3
3
|
import { ChevronDown } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
// Read at the moment of closing, as `useCloseTransition` does: the setting can change
|
|
7
|
+
// under a long-lived page.
|
|
8
|
+
import { prefersReducedMotion } from "../hooks/use-close-transition";
|
|
6
9
|
|
|
7
10
|
/** How long the fold takes, in ms. The same number as the `duration-200` below and as
|
|
8
11
|
* the unmount timer, because they are the same movement. */
|
|
9
12
|
const COLLAPSE_MS = 200;
|
|
10
13
|
|
|
11
|
-
/** Read at the moment of closing, like `useCloseTransition` does: the setting can
|
|
12
|
-
* change under a long-lived page, and jsdom/SSR have no `matchMedia` — where "no
|
|
13
|
-
* animation" is also the only correct answer, since nothing is painting. */
|
|
14
|
-
function prefersReducedMotion(): boolean {
|
|
15
|
-
return (
|
|
16
|
-
typeof window === "undefined" ||
|
|
17
|
-
typeof window.matchMedia !== "function" ||
|
|
18
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
14
|
/**
|
|
23
15
|
* `extends` the div's props so an `id` (what a trigger's `aria-controls` points at), a
|
|
24
16
|
* `data-tour` anchor or a test id reaches the element that folds.
|
|
@@ -163,8 +155,14 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
163
155
|
* after the flag, like every other menu row with a sub-list. With `trailing`, the
|
|
164
156
|
* chevron follows it, as on a card. A `card` always has it at the end, so this is
|
|
165
157
|
* ignored there. A `menu` defaults to `end`, like the menu rows around it.
|
|
158
|
+
*
|
|
159
|
+
* `after-title` sits it right after the title's text, pointing down and turning up —
|
|
160
|
+
* "Net profit ⌄ ······ 1,234.50", Lenkbank's result rows, where the far end of the
|
|
161
|
+
* row belongs to a figure (see {@link trailingInTrigger}) and a leading chevron
|
|
162
|
+
* would push every label off the column the non-expandable rows' labels start on.
|
|
163
|
+
* Not a card's: its chevron keeps the end.
|
|
166
164
|
*/
|
|
167
|
-
chevronPosition?: "start" | "end";
|
|
165
|
+
chevronPosition?: "start" | "end" | "after-title";
|
|
168
166
|
/**
|
|
169
167
|
* Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
|
|
170
168
|
* puts the button INSIDE the heading when the disclosure titles a section, so the
|
|
@@ -203,8 +201,26 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
203
201
|
* the row under it (a stretched hit area), so clicking the empty space or the
|
|
204
202
|
* card's chevron still toggles, while whatever sits in `trailing` gets its own
|
|
205
203
|
* clicks. With `trailing`, the card's chevron moves after it, to the row's end.
|
|
204
|
+
*
|
|
205
|
+
* Unless {@link trailingInTrigger} is set, which moves it INTO the button.
|
|
206
206
|
*/
|
|
207
207
|
trailing?: ReactNode;
|
|
208
|
+
/**
|
|
209
|
+
* Render {@link trailing} inside the header button instead of beside it, so the
|
|
210
|
+
* whole row — figure included — is one click target and the figure is part of the
|
|
211
|
+
* button's accessible name: "Net profit 1,234.50, collapsed" rather than a button
|
|
212
|
+
* called "Net profit" next to an unexplained number.
|
|
213
|
+
*
|
|
214
|
+
* Lenkbank's result rows (features/steering/results-panel.tsx) are the case: label,
|
|
215
|
+
* chevron, and the value at the far end, all one `<button>`, because a result row
|
|
216
|
+
* has nothing else it could mean.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ Only for NON-interactive content — a count, a figure, a status chip. A button
|
|
219
|
+
* may not contain another interactive element; an action belongs in the default,
|
|
220
|
+
* sibling `trailing`. The content is rendered in a `<span>`, so keep it phrasing
|
|
221
|
+
* content too (no `div`s).
|
|
222
|
+
*/
|
|
223
|
+
trailingInTrigger?: boolean;
|
|
208
224
|
/**
|
|
209
225
|
* Trigger-only mode: the id of an element the CALLER renders elsewhere — the hidden
|
|
210
226
|
* rows of a table, a panel in another column — which this header shows and hides.
|
|
@@ -212,6 +228,37 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
212
228
|
* its own (`children`, `bodyClassName` and `keepMounted` are ignored). Pair it with
|
|
213
229
|
* `open` / `onOpenChange`: the caller owns the state, since the caller renders what
|
|
214
230
|
* it governs.
|
|
231
|
+
*
|
|
232
|
+
* It is an IDREF LIST, as `aria-controls` is: several space-separated ids when what
|
|
233
|
+
* folds is several elements — a budget group's category rows, each its own `<li>`,
|
|
234
|
+
* siblings of this header (keksdose's budget-mobile-list joins their ids with
|
|
235
|
+
* `" "`). It may be the EMPTY string: trigger-only mode with nothing to point at yet
|
|
236
|
+
* (a group whose rows are all filtered away, rows that are not rendered while shut).
|
|
237
|
+
* The header then carries no `aria-controls` at all rather than an empty one, and
|
|
238
|
+
* still toggles.
|
|
239
|
+
*
|
|
240
|
+
* A table's leading row — keksdose's "Upcoming" toggle over the scheduled rows of
|
|
241
|
+
* its transactions table — is this mode too: the rows it folds are the TABLE's, so
|
|
242
|
+
* the disclosure is only the header, and the table renders or drops them from the
|
|
243
|
+
* same state.
|
|
244
|
+
*
|
|
245
|
+
* ```tsx
|
|
246
|
+
* <DataTable
|
|
247
|
+
* rows={showFuture ? [...scheduled, ...posted] : posted}
|
|
248
|
+
* leadingRow={
|
|
249
|
+
* <Disclosure
|
|
250
|
+
* variant="bare"
|
|
251
|
+
* title={upcomingLabel}
|
|
252
|
+
* open={showFuture}
|
|
253
|
+
* onOpenChange={setShowFuture}
|
|
254
|
+
* // The scheduled rows' ids while they are rendered; "" while they are not.
|
|
255
|
+
* controls={showFuture ? scheduled.map((tx) => `row-s${tx.id}`).join(" ") : ""}
|
|
256
|
+
* headerClassName="px-3 py-2"
|
|
257
|
+
* />
|
|
258
|
+
* }
|
|
259
|
+
* …
|
|
260
|
+
* />
|
|
261
|
+
* ```
|
|
215
262
|
*/
|
|
216
263
|
controls?: string;
|
|
217
264
|
/** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
|
|
@@ -247,6 +294,7 @@ export function Disclosure({
|
|
|
247
294
|
triggerProps,
|
|
248
295
|
bodyClassName,
|
|
249
296
|
trailing,
|
|
297
|
+
trailingInTrigger = false,
|
|
250
298
|
controls,
|
|
251
299
|
className,
|
|
252
300
|
children,
|
|
@@ -259,11 +307,17 @@ export function Disclosure({
|
|
|
259
307
|
const menu = variant === "menu";
|
|
260
308
|
// The card's chevron trails always; a menu row's unless asked otherwise; a bare
|
|
261
309
|
// one only when asked to.
|
|
262
|
-
const
|
|
310
|
+
const chevronAt = card ? "end" : (chevronPosition ?? (menu ? "end" : "start"));
|
|
311
|
+
const chevronAtEnd = chevronAt === "end";
|
|
312
|
+
const chevronAfterTitle = chevronAt === "after-title";
|
|
263
313
|
const triggerOnly = controls !== undefined;
|
|
264
314
|
// The card's header squares its lower corners only when a body opens under it.
|
|
265
315
|
const joined = open && !triggerOnly;
|
|
266
316
|
const hasTrailing = trailing !== undefined && trailing !== null && trailing !== false;
|
|
317
|
+
// `trailing` beside the button, which then stretches under it; inside the button,
|
|
318
|
+
// the row is simply the button.
|
|
319
|
+
const trailingInside = hasTrailing && trailingInTrigger;
|
|
320
|
+
const trailingBeside = hasTrailing && !trailingInTrigger;
|
|
267
321
|
|
|
268
322
|
const toggle = () => {
|
|
269
323
|
const next = !open;
|
|
@@ -278,13 +332,14 @@ export function Disclosure({
|
|
|
278
332
|
{...extraTriggerProps}
|
|
279
333
|
type="button"
|
|
280
334
|
aria-expanded={open}
|
|
281
|
-
|
|
335
|
+
// `||`: an empty `controls` is trigger-only with nothing to name, not `aria-controls=""`.
|
|
336
|
+
aria-controls={triggerOnly ? controls || undefined : bodyId}
|
|
282
337
|
disabled={disabled}
|
|
283
338
|
onClick={toggle}
|
|
284
339
|
data-disclosure-trigger=""
|
|
285
340
|
className={cn(
|
|
286
341
|
"flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
287
|
-
|
|
342
|
+
trailingBeside
|
|
288
343
|
? // Stretched over the whole header row (the row is `relative`), so the space
|
|
289
344
|
// round `trailing` and the card's chevron still toggle; the ring and the
|
|
290
345
|
// card's hover paint on the stretched area / the row, not the text box.
|
|
@@ -297,7 +352,7 @@ export function Disclosure({
|
|
|
297
352
|
card
|
|
298
353
|
? cn(
|
|
299
354
|
"items-center justify-between rounded-lg p-4",
|
|
300
|
-
|
|
355
|
+
trailingBeside ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
|
|
301
356
|
joined && "rounded-b-none",
|
|
302
357
|
)
|
|
303
358
|
: menu
|
|
@@ -309,29 +364,46 @@ export function Disclosure({
|
|
|
309
364
|
headerClassName,
|
|
310
365
|
)}
|
|
311
366
|
>
|
|
312
|
-
{
|
|
367
|
+
{chevronAt === "start" && <Chevron open={open} leading />}
|
|
313
368
|
{/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
|
|
314
369
|
at the header's far edge. */}
|
|
315
370
|
<span className="min-w-0 flex-1">
|
|
316
|
-
|
|
371
|
+
{chevronAfterTitle ? (
|
|
372
|
+
// The chevron hugs the text, however long the translation: the text wraps
|
|
373
|
+
// before the chevron is pushed away from it.
|
|
374
|
+
<span className="flex items-center gap-1">
|
|
375
|
+
<span className="min-w-0">{title}</span>
|
|
376
|
+
<Chevron open={open} small />
|
|
377
|
+
</span>
|
|
378
|
+
) : (
|
|
379
|
+
<span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
|
|
380
|
+
)}
|
|
317
381
|
{hint !== undefined && (
|
|
318
382
|
<span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
|
|
319
383
|
)}
|
|
320
384
|
</span>
|
|
321
|
-
{
|
|
385
|
+
{/* The space is for the accessible name — "Net profit 1,234.50", not
|
|
386
|
+
"Net profit1,234.50"; between flex items it renders as nothing. */}
|
|
387
|
+
{trailingInside && " "}
|
|
388
|
+
{trailingInside && (
|
|
389
|
+
<span data-disclosure-trailing="" className="flex shrink-0 items-center gap-2">
|
|
390
|
+
{trailing}
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
{chevronAtEnd && !trailingBeside && <Chevron open={open} small={!card} />}
|
|
322
394
|
</button>
|
|
323
395
|
);
|
|
324
396
|
|
|
325
397
|
// Preflight already makes h1–h6 inherit size and weight, so the heading adds
|
|
326
398
|
// structure and nothing visible.
|
|
327
|
-
const header = Heading ? <Heading className={
|
|
399
|
+
const header = Heading ? <Heading className={trailingBeside ? "min-w-0 flex-1" : undefined}>{button}</Heading> : button;
|
|
328
400
|
|
|
329
401
|
return (
|
|
330
402
|
<div
|
|
331
403
|
{...rest}
|
|
332
404
|
className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
|
|
333
405
|
>
|
|
334
|
-
{
|
|
406
|
+
{trailingBeside ? (
|
|
335
407
|
<div
|
|
336
408
|
className={cn(
|
|
337
409
|
"relative flex items-center gap-2",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field group with its label ABOVE the control: label, control, hint, error.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <Field label="IBAN" required hint="22 characters" error={errors.iban}>
|
|
6
|
+
* {(ids) => <Input {...ids} value={iban} onChange={…} />}
|
|
7
|
+
* </Field>
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The kit's `Input` and `Select` float their label inside the field, and the pickers
|
|
11
|
+
* draw theirs the same way. That is the wrong shape for a control the kit did not
|
|
12
|
+
* render, a radio group, a range slider, or a form that sets its labels above the
|
|
13
|
+
* fields throughout — which is what kastlan's wizards are, and why it kept its own
|
|
14
|
+
* `WizardField` after the kit dropped the one it had never rendered. This is that
|
|
15
|
+
* group, with the wiring the old one left to the caller: the label names the control
|
|
16
|
+
* through `htmlFor`, and the control is described by the hint and the error that are
|
|
17
|
+
* actually on screen.
|
|
18
|
+
*
|
|
19
|
+
* ## Wiring
|
|
20
|
+
*
|
|
21
|
+
* Pass a function as `children` and it receives {@link FieldControlProps} — `id`,
|
|
22
|
+
* `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.
|
|
23
|
+
* Plain children render as they are, for a control that wires itself (a checkbox that
|
|
24
|
+
* carries its own label, a group with `aria-labelledby`); `htmlFor` then points the
|
|
25
|
+
* label at the control's own id, and the hint and error are on screen but describe
|
|
26
|
+
* nothing — the same as the `WizardField` this replaces.
|
|
27
|
+
*
|
|
28
|
+
* ## Decoupled
|
|
29
|
+
*
|
|
30
|
+
* Deliberately knows neither the wizard nor react-hook-form: `error` is a plain
|
|
31
|
+
* value. Pass `wizard.fieldErrors.iban`, a step-local error, or
|
|
32
|
+
* `fieldState.error?.message` from a `FormField` render — the markup is the same
|
|
33
|
+
* whichever of them gates the step, so a step can move between them without its
|
|
34
|
+
* fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from
|
|
35
|
+
* `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)
|
|
36
|
+
*
|
|
37
|
+
* The error is NOT `role="alert"`, for the reason the kit's `error` prop gives (see
|
|
38
|
+
* `useFieldError` in components/ui.tsx): it is read when focus reaches the control.
|
|
39
|
+
*/
|
|
40
|
+
import { useId, type ReactNode } from "react";
|
|
41
|
+
import { cn } from "../lib/cn";
|
|
42
|
+
import { Label } from "./ui";
|
|
43
|
+
|
|
44
|
+
/** What a `Field` hands its render-prop children, to spread on the control. */
|
|
45
|
+
export interface FieldControlProps {
|
|
46
|
+
/** The id the label's `htmlFor` points at. */
|
|
47
|
+
id: string;
|
|
48
|
+
/** The ids of the hint and the error that are rendered, or `undefined` when
|
|
49
|
+
* neither is. */
|
|
50
|
+
"aria-describedby": string | undefined;
|
|
51
|
+
/** `true` while the field has an error, `undefined` otherwise. */
|
|
52
|
+
"aria-invalid": true | undefined;
|
|
53
|
+
/** `true` when the field is `required`. The label's star is `aria-hidden`; this
|
|
54
|
+
* is what a screen reader hears instead. */
|
|
55
|
+
"aria-required": true | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FieldProps {
|
|
59
|
+
/** The label above the control. Omit it for a group whose control is labelled
|
|
60
|
+
* some other way; the hint and error still render. */
|
|
61
|
+
label?: ReactNode;
|
|
62
|
+
/** Draws the kit's required mark after the label, and sets `aria-required` on the
|
|
63
|
+
* render-prop control. */
|
|
64
|
+
required?: boolean;
|
|
65
|
+
/** Muted helper text below the control. */
|
|
66
|
+
hint?: ReactNode;
|
|
67
|
+
/** The error below the control. `null`, `false` and `""` are no error — what a
|
|
68
|
+
* `touched && errors.x` evaluates to on the happy path. */
|
|
69
|
+
error?: ReactNode;
|
|
70
|
+
/** The control's id. Defaults to a generated one; set it when the control carries
|
|
71
|
+
* its own id (plain children). */
|
|
72
|
+
htmlFor?: string;
|
|
73
|
+
/** Dims the label with its control. */
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
/** The label's size: `sm` for a dense row, `md` otherwise. */
|
|
76
|
+
labelSize?: "sm" | "md";
|
|
77
|
+
/** Wrapper className. */
|
|
78
|
+
className?: string;
|
|
79
|
+
/** The control. A function receives {@link FieldControlProps} to spread on it. */
|
|
80
|
+
children: ReactNode | ((control: FieldControlProps) => ReactNode);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isShown = (node: ReactNode) =>
|
|
84
|
+
node !== undefined && node !== null && node !== false && node !== "";
|
|
85
|
+
|
|
86
|
+
export function Field({
|
|
87
|
+
label,
|
|
88
|
+
required,
|
|
89
|
+
hint,
|
|
90
|
+
error,
|
|
91
|
+
htmlFor,
|
|
92
|
+
disabled,
|
|
93
|
+
labelSize,
|
|
94
|
+
className,
|
|
95
|
+
children,
|
|
96
|
+
}: FieldProps) {
|
|
97
|
+
const generated = useId();
|
|
98
|
+
const id = htmlFor ?? `${generated}-control`;
|
|
99
|
+
const hintId = `${generated}-hint`;
|
|
100
|
+
const errorId = `${generated}-error`;
|
|
101
|
+
const hasHint = isShown(hint);
|
|
102
|
+
const hasError = isShown(error);
|
|
103
|
+
const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(" ");
|
|
104
|
+
const control: FieldControlProps = {
|
|
105
|
+
id,
|
|
106
|
+
"aria-describedby": describedBy || undefined,
|
|
107
|
+
"aria-invalid": hasError || undefined,
|
|
108
|
+
"aria-required": required || undefined,
|
|
109
|
+
};
|
|
110
|
+
return (
|
|
111
|
+
<div data-slot="field" className={cn("grid gap-1.5", className)}>
|
|
112
|
+
{label !== undefined && (
|
|
113
|
+
<Label
|
|
114
|
+
htmlFor={id}
|
|
115
|
+
required={required}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
size={labelSize}
|
|
118
|
+
data-error={hasError || undefined}
|
|
119
|
+
className="data-[error=true]:text-[var(--danger)]"
|
|
120
|
+
>
|
|
121
|
+
{label}
|
|
122
|
+
</Label>
|
|
123
|
+
)}
|
|
124
|
+
{typeof children === "function" ? children(control) : children}
|
|
125
|
+
{hasHint && (
|
|
126
|
+
<p id={hintId} className="text-[11px] leading-tight text-[var(--text-muted)]">
|
|
127
|
+
{hint}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
{hasError && (
|
|
131
|
+
<p id={errorId} className="text-[11px] leading-tight text-[var(--danger)]">
|
|
132
|
+
{error}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|