@eifi1/ui-kit 0.8.1 → 0.10.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 +25 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- 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 +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/chip.d.ts +58 -10
- package/dist/components/chip.js +91 -7
- package/dist/components/chip.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- 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.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +89 -3
- package/dist/components/date-picker.js +163 -68
- 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 +68 -4
- package/dist/components/disclosure.js +33 -15
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -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 +29 -2
- package/dist/components/floating-panel.js +16 -2
- 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 +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- 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/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 +16 -2
- package/dist/components/progress-bar.js +4 -2
- 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 +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +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 +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +139 -16
- package/dist/components/ui.js +136 -26
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +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-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- 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 +31 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +31 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +31 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +31 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +31 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +31 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/search/command-palette.d.ts +67 -2
- package/dist/search/command-palette.js +147 -33
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +166 -0
- package/dist/search/global-search.js +293 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +3 -2
- 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-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +25 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +164 -13
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +377 -118
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +116 -28
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +321 -41
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +30 -0
- package/src/i18n/locales/es.ts +30 -0
- package/src/i18n/locales/fr.ts +30 -0
- package/src/i18n/locales/hu.ts +30 -0
- package/src/i18n/locales/it.ts +30 -0
- package/src/i18n/locales/zh.ts +30 -0
- package/src/index.ts +30 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +241 -31
- package/src/search/global-search.tsx +525 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +13 -2
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-summary.tsx +14 -10
- package/tokens.css +97 -0
package/src/components/ui.tsx
CHANGED
|
@@ -57,6 +57,40 @@ const buttonVariantClasses: Record<ButtonVariant, string> = {
|
|
|
57
57
|
"rounded-sm p-0 bg-transparent text-[var(--brand)] underline-offset-4 hover:underline focus:ring-[var(--brand)]",
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The text colour of a `link` or `ghost` button, over the variant's own.
|
|
62
|
+
*
|
|
63
|
+
* `muted` is the QUIET link keksdose hand-rolls in six places (tours-page:321's "Mark
|
|
64
|
+
* undone", transaction-fields:176/215's "Add line" / "Fill total",
|
|
65
|
+
* invoice-lines-table:376, invoice-review:464): secondary text that darkens to the
|
|
66
|
+
* body colour under the pointer, for an action that must be findable but not compete
|
|
67
|
+
* with the brand-coloured one beside it. `danger` is the same quiet look turning
|
|
68
|
+
* `--danger` on hover — transaction-editor:401's "Remove split", which is a text
|
|
69
|
+
* action that destroys something.
|
|
70
|
+
*
|
|
71
|
+
* A `tone` rather than a `link-muted` variant, because it is the axis IconButton
|
|
72
|
+
* already has (`tone="muted"`, `tone="danger"`, same quiet-until-hover meaning), and
|
|
73
|
+
* because the same two looks are wanted on `ghost`. The filled and bordered variants
|
|
74
|
+
* carry their meaning in the box, not the text, so a tone on them does nothing.
|
|
75
|
+
*/
|
|
76
|
+
export type ButtonTone = "default" | "muted" | "danger";
|
|
77
|
+
|
|
78
|
+
const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
|
|
79
|
+
muted:
|
|
80
|
+
"text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:hover:text-[var(--text-secondary)]",
|
|
81
|
+
danger:
|
|
82
|
+
"text-[var(--text-secondary)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)] disabled:hover:text-[var(--text-secondary)]",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Only the two transparent variants take a tone; see {@link ButtonTone}. */
|
|
86
|
+
const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
|
|
87
|
+
|
|
88
|
+
// `pressed` on a `link`: the brand colour and a heavier weight, which is exactly what
|
|
89
|
+
// lenkbank's "All speeds" toggle paints by hand (gear/hysteresis-charts.tsx:482). After
|
|
90
|
+
// the tone, so a pressed muted link is brand, and an unpressed one is quiet grey.
|
|
91
|
+
const BUTTON_LINK_PRESSED =
|
|
92
|
+
"font-medium text-[var(--brand)] hover:text-[var(--brand)] disabled:hover:text-[var(--brand)]";
|
|
93
|
+
|
|
60
94
|
/** The second argument of {@link buttonClasses} in its options form. */
|
|
61
95
|
export interface ButtonClassesOptions {
|
|
62
96
|
/** See {@link ButtonProps.size}. */
|
|
@@ -105,12 +139,75 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
105
139
|
* ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —
|
|
106
140
|
* declared here only because `ButtonHTMLAttributes` does not carry it. */
|
|
107
141
|
ref?: Ref<HTMLButtonElement>;
|
|
142
|
+
/** Text colour for `link` and `ghost`: `muted` (quiet, body colour on hover) or
|
|
143
|
+
* `danger` (quiet, `--danger` on hover). Ignored by the other variants. See
|
|
144
|
+
* {@link ButtonTone}. */
|
|
145
|
+
tone?: ButtonTone;
|
|
146
|
+
/**
|
|
147
|
+
* Make it a toggle button, as {@link IconButton}'s `pressed` does. `true` sets
|
|
148
|
+
* `aria-pressed="true"` and draws the "on" look — on a `link`, the brand colour and a
|
|
149
|
+
* medium weight; on the boxed variants, the quiet brand fill IconButton uses — and
|
|
150
|
+
* `false` sets `aria-pressed="false"` with the ordinary look. Left out, no
|
|
151
|
+
* `aria-pressed` (or the caller's own). For lenkbank's "All speeds" text toggle
|
|
152
|
+
* (gear/hysteresis-charts.tsx:482), which is a hand-rolled `<button aria-pressed>`
|
|
153
|
+
* swapping two class strings; with `variant="link" tone="muted"` it is this.
|
|
154
|
+
*/
|
|
155
|
+
pressed?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Why the action is not available — the button's half of DangerConfirm's
|
|
158
|
+
* `lockedReason` (kastlan handover-detail-page.tsx:197 locks a signed handover).
|
|
159
|
+
*
|
|
160
|
+
* A disabled button that cannot say why is a dead end: the native `disabled` takes it
|
|
161
|
+
* out of the tab order, so a keyboard user never lands on it, and a pointer gets a
|
|
162
|
+
* `not-allowed` cursor and nothing else. With a reason the button is `aria-disabled`
|
|
163
|
+
* instead — still focusable, still hoverable — clicks (and the Enter/Space and form
|
|
164
|
+
* submission they stand for) do nothing, and the reason is shown in the kit
|
|
165
|
+
* {@link Tooltip} and attached with `aria-describedby`, so a screen reader hears it
|
|
166
|
+
* on focus. It wins over `disabled`: passing both keeps the button reachable, which is
|
|
167
|
+
* the point of giving a reason.
|
|
168
|
+
*
|
|
169
|
+
* The description is a `hidden` copy of the reason rather than the bubble itself: the
|
|
170
|
+
* bubble exists only while hovered or focused once it is portalled, and a description
|
|
171
|
+
* that comes and goes is read inconsistently. The bubble is kept visual, the way
|
|
172
|
+
* DangerConfirm keeps its own.
|
|
173
|
+
*/
|
|
174
|
+
disabledReason?: ReactNode;
|
|
108
175
|
}
|
|
109
176
|
|
|
110
|
-
|
|
111
|
-
return
|
|
177
|
+
function hasContent(node: ReactNode): boolean {
|
|
178
|
+
return node !== undefined && node !== null && node !== false && node !== "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Button({
|
|
182
|
+
variant = "primary",
|
|
183
|
+
size = "md",
|
|
184
|
+
stretch,
|
|
185
|
+
tone = "default",
|
|
186
|
+
pressed,
|
|
187
|
+
disabledReason,
|
|
188
|
+
className,
|
|
189
|
+
onClick,
|
|
190
|
+
...rest
|
|
191
|
+
}: ButtonProps) {
|
|
192
|
+
const reasonId = useId();
|
|
193
|
+
const locked = hasContent(disabledReason);
|
|
194
|
+
const ownDescribedBy = rest["aria-describedby"];
|
|
195
|
+
const button = (
|
|
112
196
|
<button
|
|
113
197
|
{...rest}
|
|
198
|
+
disabled={locked ? undefined : rest.disabled}
|
|
199
|
+
aria-disabled={locked || rest["aria-disabled"]}
|
|
200
|
+
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
201
|
+
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
202
|
+
onClick={(e) => {
|
|
203
|
+
// `preventDefault` as well as not calling through: a locked submit button must
|
|
204
|
+
// not submit its form, and the browser does that after this handler returns.
|
|
205
|
+
if (locked) {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
onClick?.(e);
|
|
210
|
+
}}
|
|
114
211
|
className={cn(
|
|
115
212
|
BUTTON_BASE,
|
|
116
213
|
BUTTON_SIZES[size],
|
|
@@ -119,10 +216,29 @@ export function Button({ variant = "primary", size = "md", stretch, className, .
|
|
|
119
216
|
// align-items). No effect outside a flex row / when it's already the tallest.
|
|
120
217
|
stretch && "self-stretch",
|
|
121
218
|
buttonVariantClasses[variant],
|
|
219
|
+
tone !== "default" && TONED_VARIANTS.has(variant) && BUTTON_TONES[tone],
|
|
220
|
+
pressed && (variant === "link" ? BUTTON_LINK_PRESSED : ICON_BUTTON_PRESSED),
|
|
221
|
+
// The disabled look for the focusable kind of disabled.
|
|
222
|
+
locked && "cursor-not-allowed opacity-50",
|
|
122
223
|
className,
|
|
123
224
|
)}
|
|
124
225
|
/>
|
|
125
226
|
);
|
|
227
|
+
if (!locked) return button;
|
|
228
|
+
return (
|
|
229
|
+
// The button in a FRAGMENT so Tooltip does not clone its bubble into the
|
|
230
|
+
// description as well — the hidden copy below already describes it, and the same
|
|
231
|
+
// sentence twice would be read on every focus. `stretch` moves to the wrapper,
|
|
232
|
+
// which is now the flex item.
|
|
233
|
+
<Tooltip label={disabledReason} className={stretch ? "self-stretch" : undefined}>
|
|
234
|
+
<>
|
|
235
|
+
{button}
|
|
236
|
+
<span id={reasonId} hidden>
|
|
237
|
+
{disabledReason}
|
|
238
|
+
</span>
|
|
239
|
+
</>
|
|
240
|
+
</Tooltip>
|
|
241
|
+
);
|
|
126
242
|
}
|
|
127
243
|
|
|
128
244
|
// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
|
|
@@ -154,27 +270,50 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
154
270
|
};
|
|
155
271
|
|
|
156
272
|
// A tone re-colours the glyph without changing what the variant draws around it.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
273
|
+
// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
|
|
274
|
+
// QUIET is placeholder grey until the pointer or focus arrives, then the tone's
|
|
275
|
+
// family; TONED wears the tone's colour at rest. The defaults are the looks each tone
|
|
276
|
+
// had before `quiet` existed: `danger` is quiet — an action in every row of a list
|
|
277
|
+
// must not shout from every row, so the red arrives only on the one row you are about
|
|
278
|
+
// to act on — while `warning` and `info` are toned, because they mark the ONE thing
|
|
279
|
+
// on the row to notice (keksdose's "needs review" flag, its reconcile action at
|
|
280
|
+
// accounts-page:867), and quiet grey would hide the very thing they point out.
|
|
281
|
+
// `muted` is quiet by definition and has no toned look; `default` has no tone.
|
|
282
|
+
export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info";
|
|
283
|
+
|
|
284
|
+
type ColouredTone = "danger" | "warning" | "info";
|
|
285
|
+
|
|
286
|
+
const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
|
|
287
|
+
danger: {
|
|
288
|
+
quiet:
|
|
289
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
|
|
290
|
+
toned: "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus:ring-[var(--danger-border)]",
|
|
291
|
+
},
|
|
292
|
+
warning: {
|
|
293
|
+
quiet:
|
|
294
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--warning-bg)] hover:text-[var(--warning)] focus:ring-[var(--warning-border)]",
|
|
295
|
+
toned: "text-[var(--warning)] hover:bg-[var(--warning-bg)] focus:ring-[var(--warning-border)]",
|
|
296
|
+
},
|
|
297
|
+
info: {
|
|
298
|
+
quiet:
|
|
299
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
|
|
300
|
+
toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const ICON_BUTTON_MUTED =
|
|
305
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
|
|
306
|
+
|
|
307
|
+
/** Which tones sit quiet at rest when `quiet` is left out. */
|
|
308
|
+
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = { danger: true, warning: false, info: false };
|
|
309
|
+
|
|
310
|
+
function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
|
|
311
|
+
if (tone === "default") return "";
|
|
312
|
+
if (tone === "muted") return cn(ICON_BUTTON_MUTED, ICON_BUTTON_QUIET_DISABLED_REST);
|
|
313
|
+
const isQuiet = quiet ?? QUIET_BY_DEFAULT[tone];
|
|
314
|
+
const look = ICON_BUTTON_TONES[tone];
|
|
315
|
+
return isQuiet ? cn(look.quiet, ICON_BUTTON_QUIET_DISABLED_REST) : look.toned;
|
|
316
|
+
}
|
|
178
317
|
|
|
179
318
|
// A disabled button must not answer the pointer. The hover classes above are plain
|
|
180
319
|
// `hover:` (so a caller's `className="hover:…"` still replaces them through
|
|
@@ -194,11 +333,9 @@ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay", string> = {
|
|
|
194
333
|
overlay: "disabled:hover:bg-[color-mix(in_srgb,var(--bg-inverse)_60%,transparent)]",
|
|
195
334
|
};
|
|
196
335
|
|
|
197
|
-
// The
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
danger: "disabled:hover:text-[var(--text-placeholder)]",
|
|
201
|
-
};
|
|
336
|
+
// The quiet looks change the glyph on hover, so they pin their resting glyph the
|
|
337
|
+
// same way. A toned look keeps its glyph on hover and needs no pin.
|
|
338
|
+
const ICON_BUTTON_QUIET_DISABLED_REST = "disabled:hover:text-[var(--text-placeholder)]";
|
|
202
339
|
|
|
203
340
|
// `pressed`: a toggle that is on. The brand glyph on the quiet brand fill — the
|
|
204
341
|
// "selected" look of a Chip or a SegmentedControl option, so an on toggle reads as on
|
|
@@ -229,8 +366,26 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
229
366
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
230
367
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
231
368
|
* wants attention. `info`: sky at rest — a notice-worthy but harmless action
|
|
232
|
-
* (keksdose's reconcile). Default: the variant's own colours.
|
|
233
|
-
tone
|
|
369
|
+
* (keksdose's reconcile). Default: the variant's own colours. See `quiet` for
|
|
370
|
+
* turning a coloured tone's resting look the other way. */
|
|
371
|
+
tone?: IconButtonTone;
|
|
372
|
+
/**
|
|
373
|
+
* Whether a coloured tone (`danger`, `warning`, `info`) waits for the pointer:
|
|
374
|
+
* `true` is placeholder grey at rest and the tone's colour on hover and focus;
|
|
375
|
+
* `false` wears the tone's colour at rest. Left out, each tone keeps its own
|
|
376
|
+
* default — `danger` quiet, `warning` and `info` not. Ignored for `muted` (quiet by
|
|
377
|
+
* definition) and `default` (no tone).
|
|
378
|
+
*
|
|
379
|
+
* `quiet={false}` on `danger` is for a destructive action that stands ALONE, where
|
|
380
|
+
* hover-to-reveal hides it: keksdose's phone bulk bar (mobile-bulk-bar.tsx) has one
|
|
381
|
+
* "delete all" and a touch screen that never hovers, so it painted rose by hand.
|
|
382
|
+
* `quiet` on `warning`/`info` is the same switch the other way, for a flag repeated
|
|
383
|
+
* down a list. A boolean over the tone rather than a new tone (`danger-solid`) or an
|
|
384
|
+
* `emphasis` scale: there are exactly two resting looks, every coloured tone has
|
|
385
|
+
* both, and which one fits is a question about the SITE (alone or repeated, touch or
|
|
386
|
+
* pointer), not about the tone — so it is one switch the family shares.
|
|
387
|
+
*/
|
|
388
|
+
quiet?: boolean;
|
|
234
389
|
/**
|
|
235
390
|
* Make it a toggle button. `true` sets `aria-pressed="true"` and draws the "on" look
|
|
236
391
|
* (brand glyph on the quiet brand fill); `false` sets `aria-pressed="false"` with the
|
|
@@ -266,16 +421,59 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
266
421
|
* The pair look exactly like the nested version, click exactly like it, and
|
|
267
422
|
* are two tab stops a screen reader can tell apart. */
|
|
268
423
|
stopPropagation?: boolean;
|
|
424
|
+
/**
|
|
425
|
+
* The button's name, said once: it becomes the `aria-label` AND the text of a kit
|
|
426
|
+
* {@link Tooltip} round the button.
|
|
427
|
+
*
|
|
428
|
+
* Every app writes the pair by hand. lenkbank's nine icon actions all carry
|
|
429
|
+
* `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,
|
|
430
|
+
* projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,
|
|
431
|
+
* shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,
|
|
432
|
+
* which shows late, never on focus and never on touch, and looks like no other
|
|
433
|
+
* label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)
|
|
434
|
+
* and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.
|
|
435
|
+
*
|
|
436
|
+
* A caller's own `aria-label` still wins, for the rare name that should be longer
|
|
437
|
+
* than the bubble. The bubble is visual only: it would otherwise describe the button
|
|
438
|
+
* with its own name, and a screen reader would read the same word twice. Tooltip's
|
|
439
|
+
* default placement applies — in place, or portalled inside a scroll container —
|
|
440
|
+
* unless `tooltipPortal` says otherwise.
|
|
441
|
+
*/
|
|
442
|
+
label?: string;
|
|
443
|
+
/** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the
|
|
444
|
+
* accessible name only — for a button whose glyph is universally read (a close ✕ in
|
|
445
|
+
* a dialog header) or that already sits under a tooltip of its own. */
|
|
446
|
+
tooltip?: boolean;
|
|
447
|
+
/** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */
|
|
448
|
+
tooltipSide?: TooltipSide;
|
|
449
|
+
/** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
|
|
450
|
+
tooltipPortal?: boolean;
|
|
269
451
|
}
|
|
270
452
|
|
|
271
453
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
272
|
-
{
|
|
454
|
+
{
|
|
455
|
+
variant = "ghost",
|
|
456
|
+
size = "md",
|
|
457
|
+
tone = "default",
|
|
458
|
+
quiet,
|
|
459
|
+
pressed,
|
|
460
|
+
stopPropagation,
|
|
461
|
+
label,
|
|
462
|
+
tooltip = true,
|
|
463
|
+
tooltipSide,
|
|
464
|
+
tooltipPortal,
|
|
465
|
+
className,
|
|
466
|
+
onClick,
|
|
467
|
+
onKeyDown,
|
|
468
|
+
...rest
|
|
469
|
+
},
|
|
273
470
|
ref,
|
|
274
471
|
) {
|
|
275
|
-
|
|
472
|
+
const button = (
|
|
276
473
|
<button
|
|
277
474
|
ref={ref}
|
|
278
475
|
{...rest}
|
|
476
|
+
aria-label={rest["aria-label"] ?? label}
|
|
279
477
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
280
478
|
onClick={(e) => {
|
|
281
479
|
if (stopPropagation) e.stopPropagation();
|
|
@@ -293,13 +491,20 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
293
491
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
294
492
|
variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
|
|
295
493
|
ICON_BUTTON_DISABLED_REST[variant],
|
|
296
|
-
|
|
297
|
-
ICON_BUTTON_TONES_DISABLED_REST[tone],
|
|
494
|
+
iconButtonToneClass(tone, quiet),
|
|
298
495
|
pressed && ICON_BUTTON_PRESSED,
|
|
299
496
|
className,
|
|
300
497
|
)}
|
|
301
498
|
/>
|
|
302
499
|
);
|
|
500
|
+
if (!tooltip || label === undefined || label === "") return button;
|
|
501
|
+
return (
|
|
502
|
+
// A fragment, so Tooltip leaves the button's description alone: the bubble says
|
|
503
|
+
// exactly what `aria-label` already does.
|
|
504
|
+
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal}>
|
|
505
|
+
<>{button}</>
|
|
506
|
+
</Tooltip>
|
|
507
|
+
);
|
|
303
508
|
});
|
|
304
509
|
IconButton.displayName = "IconButton";
|
|
305
510
|
|
|
@@ -1334,13 +1539,24 @@ export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
|
1334
1539
|
);
|
|
1335
1540
|
}
|
|
1336
1541
|
|
|
1337
|
-
export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
1542
|
+
export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
|
|
1338
1543
|
/** The box renders `title` and `hint` in its own two-line rhythm, which is what makes
|
|
1339
1544
|
* every empty state in three apps look like the same thing — so there is no
|
|
1340
1545
|
* `children` slot to put arbitrary content in. The two slots below are the only
|
|
1341
1546
|
* other things an empty state has turned out to need, and each has a fixed place. */
|
|
1342
|
-
title
|
|
1343
|
-
|
|
1547
|
+
/** Nodes rather than strings, so a title can carry a code, a link or an emphasised
|
|
1548
|
+
* word — keksdose's error boundary shows the error's name in `<code>` — while the
|
|
1549
|
+
* box still sets the type. */
|
|
1550
|
+
title: ReactNode;
|
|
1551
|
+
hint?: ReactNode;
|
|
1552
|
+
/**
|
|
1553
|
+
* Render the title as a heading of this level. Left out it is a `<div>`, as before:
|
|
1554
|
+
* most empty states are a message inside a section that already has its heading.
|
|
1555
|
+
* keksdose's error boundary IS the page when it trips, and its `<h2>` went missing
|
|
1556
|
+
* when it moved onto EmptyState — so the page's heading outline lost the one line
|
|
1557
|
+
* that says what happened. Only the element changes; the look is the box's.
|
|
1558
|
+
*/
|
|
1559
|
+
headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
|
|
1344
1560
|
/** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
|
|
1345
1561
|
* offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
|
|
1346
1562
|
* muted, so five call sites cannot pick five sizes; hidden from assistive tech,
|
|
@@ -1351,9 +1567,72 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
1351
1567
|
* wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
|
|
1352
1568
|
* caller renders; the box only places them. */
|
|
1353
1569
|
action?: ReactNode;
|
|
1570
|
+
/**
|
|
1571
|
+
* `box` (default): the dashed, padded card that stands in for a whole section.
|
|
1572
|
+
* `inline`: one quiet line with no box — for the empty state INSIDE something that
|
|
1573
|
+
* already has its own frame, where a dashed card would be a box in a box. keksdose
|
|
1574
|
+
* writes that line by hand five times as a muted `<p className="py-6 text-center">`:
|
|
1575
|
+
* the notification inbox (notification-inbox:140), a support thread
|
|
1576
|
+
* (support-thread:144), the assistant (assistant-page:321), the admin support panel
|
|
1577
|
+
* (support-panel:265) and the funding dialog (funding-dialog:89). The icon shrinks
|
|
1578
|
+
* to the text's size and sits before the title, the hint follows on the same line,
|
|
1579
|
+
* and the row wraps only when it must. Centred like the box; `className="justify-start"`
|
|
1580
|
+
* for a list that reads from the start edge (funding-dialog).
|
|
1581
|
+
*/
|
|
1582
|
+
variant?: "box" | "inline";
|
|
1583
|
+
/**
|
|
1584
|
+
* Colour the icon and the title: `danger` for an error standing where the content
|
|
1585
|
+
* should be — kastlan's ErrorState (shared/components/feedback/error-state.tsx) and
|
|
1586
|
+
* its error boundary's fallback (error-boundary.tsx:16), both a red triangle over a
|
|
1587
|
+
* message — and `success` for an empty state that is GOOD news, kastlan's "No defects
|
|
1588
|
+
* recorded for this room" (handover defects-step.tsx:142, a green check). Left out,
|
|
1589
|
+
* both stay muted. The hint stays muted either way: it is the explanation, not the
|
|
1590
|
+
* verdict.
|
|
1591
|
+
*/
|
|
1592
|
+
tone?: "danger" | "success";
|
|
1354
1593
|
}
|
|
1355
1594
|
|
|
1356
|
-
|
|
1595
|
+
const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
|
|
1596
|
+
danger: "text-[var(--danger)]",
|
|
1597
|
+
success: "text-[var(--success)]",
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
export function EmptyState({
|
|
1601
|
+
title,
|
|
1602
|
+
hint,
|
|
1603
|
+
icon,
|
|
1604
|
+
action,
|
|
1605
|
+
headingAs,
|
|
1606
|
+
variant = "box",
|
|
1607
|
+
tone,
|
|
1608
|
+
className,
|
|
1609
|
+
...rest
|
|
1610
|
+
}: EmptyStateProps) {
|
|
1611
|
+
const Title = headingAs ?? "div";
|
|
1612
|
+
const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
|
|
1613
|
+
const hasHint = hint != null && hint !== false && hint !== "";
|
|
1614
|
+
if (variant === "inline") {
|
|
1615
|
+
return (
|
|
1616
|
+
<div
|
|
1617
|
+
{...rest}
|
|
1618
|
+
className={cn(
|
|
1619
|
+
"flex flex-wrap items-center justify-center gap-x-2 gap-y-1 py-4 text-center text-sm text-[var(--text-muted)]",
|
|
1620
|
+
className,
|
|
1621
|
+
)}
|
|
1622
|
+
>
|
|
1623
|
+
{icon != null && (
|
|
1624
|
+
<span aria-hidden className={cn("flex text-[var(--text-placeholder)] [&_svg]:size-4", toneClass)}>
|
|
1625
|
+
{icon}
|
|
1626
|
+
</span>
|
|
1627
|
+
)}
|
|
1628
|
+
{/* No weight of its own: one muted line is what these sites were, and a
|
|
1629
|
+
bold title would turn a quiet "nothing here" into a heading. */}
|
|
1630
|
+
<Title className={cn("text-sm", toneClass)}>{title}</Title>
|
|
1631
|
+
{hasHint && <span className="text-xs">{hint}</span>}
|
|
1632
|
+
{action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
|
|
1633
|
+
</div>
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1357
1636
|
return (
|
|
1358
1637
|
<div
|
|
1359
1638
|
{...rest}
|
|
@@ -1363,12 +1642,13 @@ export function EmptyState({ title, hint, icon, action, className, ...rest }: Em
|
|
|
1363
1642
|
)}
|
|
1364
1643
|
>
|
|
1365
1644
|
{icon != null && (
|
|
1366
|
-
<div aria-hidden className="mb-3 text-[var(--text-placeholder)] [&_svg]:size-8">
|
|
1645
|
+
<div aria-hidden className={cn("mb-3 text-[var(--text-placeholder)] [&_svg]:size-8", toneClass)}>
|
|
1367
1646
|
{icon}
|
|
1368
1647
|
</div>
|
|
1369
1648
|
)}
|
|
1370
|
-
|
|
1371
|
-
|
|
1649
|
+
{/* `text-sm`: a heading keeps the box's size, not whatever a stylesheet gives h2. */}
|
|
1650
|
+
<Title className={cn("text-sm font-medium text-[var(--text-secondary)]", toneClass)}>{title}</Title>
|
|
1651
|
+
{hasHint && <div className="mt-1 text-xs">{hint}</div>}
|
|
1372
1652
|
{action != null && (
|
|
1373
1653
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
|
|
1374
1654
|
)}
|
|
@@ -6,6 +6,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
6
6
|
* by a copy of it. */
|
|
7
7
|
export const OVERLAY_EXIT_MS = 220;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Whether the user has asked for no motion, read NOW. `true` where `matchMedia` is
|
|
11
|
+
* absent (jsdom, SSR): nothing is painting there, so "no animation" is the only
|
|
12
|
+
* correct answer. Shared by every exit timer in the package, so they agree.
|
|
13
|
+
*/
|
|
14
|
+
export 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
|
+
|
|
9
22
|
/**
|
|
10
23
|
* The state machine an exit animation needs, written once for every overlay in this
|
|
11
24
|
* package (Keksdose live #320 rework: *"Recognized the opening transition. Choose
|
|
@@ -66,11 +79,7 @@ export function useCloseTransition(
|
|
|
66
79
|
// Read at close time, not at mount: the setting can change under a long-lived
|
|
67
80
|
// page, and `matchMedia` is absent in jsdom and in SSR — where "no animation" is
|
|
68
81
|
// also the only correct answer, since nothing is painting.
|
|
69
|
-
|
|
70
|
-
typeof window === "undefined" ||
|
|
71
|
-
typeof window.matchMedia !== "function" ||
|
|
72
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
73
|
-
if (reduced || ms <= 0) {
|
|
82
|
+
if (prefersReducedMotion() || ms <= 0) {
|
|
74
83
|
latest.current();
|
|
75
84
|
return;
|
|
76
85
|
}
|