@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
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
|
|
@@ -138,12 +254,15 @@ export function Button({ variant = "primary", size = "md", stretch, className, .
|
|
|
138
254
|
const ICON_BUTTON_BASE =
|
|
139
255
|
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
140
256
|
|
|
141
|
-
export type IconButtonSize = "lg" | "md" | "sm" | "xs" | "2xs";
|
|
257
|
+
export type IconButtonSize = "xl" | "lg" | "md" | "sm" | "xs" | "2xs";
|
|
142
258
|
|
|
143
259
|
// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
|
|
144
260
|
// that touches its edges. The two small steps are lenkbank's list-row (28px) and
|
|
145
261
|
// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
|
|
146
262
|
const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
263
|
+
// 48px with a 24px glyph: the one primary action of a phone screen or sheet, a step
|
|
264
|
+
// past the 44px minimum rather than on it.
|
|
265
|
+
xl: "size-12 [&_svg]:size-6",
|
|
147
266
|
// The 44px touch target (WCAG 2.5.5's size) with the same 20px glyph — keksdose's
|
|
148
267
|
// bulk-action bars write `size-11` by hand over an `md` button to get it on phones.
|
|
149
268
|
lg: "size-11 [&_svg]:size-5",
|
|
@@ -153,6 +272,18 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
153
272
|
"2xs": "size-6 rounded [&_svg]:size-3.5",
|
|
154
273
|
};
|
|
155
274
|
|
|
275
|
+
// `stretch`: the same WIDTH and glyph, and a height taken from the row instead of
|
|
276
|
+
// stated — `size-*` sets a height that `self-stretch` cannot override, so the box is
|
|
277
|
+
// written as a width plus a minimum height (the square it would otherwise be).
|
|
278
|
+
const ICON_BUTTON_STRETCH_SIZES: Record<IconButtonSize, string> = {
|
|
279
|
+
xl: "w-12 min-h-12 self-stretch [&_svg]:size-6",
|
|
280
|
+
lg: "w-11 min-h-11 self-stretch [&_svg]:size-5",
|
|
281
|
+
md: "w-9 min-h-9 self-stretch [&_svg]:size-5",
|
|
282
|
+
sm: "w-8 min-h-8 self-stretch [&_svg]:size-5",
|
|
283
|
+
xs: "w-7 min-h-7 self-stretch rounded [&_svg]:size-4",
|
|
284
|
+
"2xs": "w-6 min-h-6 self-stretch rounded [&_svg]:size-3.5",
|
|
285
|
+
};
|
|
286
|
+
|
|
156
287
|
// A tone re-colours the glyph without changing what the variant draws around it.
|
|
157
288
|
// Each coloured tone comes in two resting looks, picked by `quiet` (see the prop):
|
|
158
289
|
// QUIET is placeholder grey until the pointer or focus arrives, then the tone's
|
|
@@ -163,9 +294,14 @@ const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
|
163
294
|
// on the row to notice (keksdose's "needs review" flag, its reconcile action at
|
|
164
295
|
// accounts-page:867), and quiet grey would hide the very thing they point out.
|
|
165
296
|
// `muted` is quiet by definition and has no toned look; `default` has no tone.
|
|
166
|
-
|
|
297
|
+
//
|
|
298
|
+
// `success` is toned at rest too: the green is the news (keksdose's "in sync" chip).
|
|
299
|
+
// `custom` paints from `--icon-button-tone`, which `toneColor` sets — for a glyph whose
|
|
300
|
+
// colour FOLLOWS STATE across tones (keksdose's sync chip: rose, amber, sky, green as
|
|
301
|
+
// the sync moves), where swapping `tone` would do but the colour is one variable.
|
|
302
|
+
export type IconButtonTone = "default" | "muted" | "danger" | "warning" | "info" | "success" | "custom";
|
|
167
303
|
|
|
168
|
-
type ColouredTone = "danger" | "warning" | "info";
|
|
304
|
+
type ColouredTone = "danger" | "warning" | "info" | "success" | "custom";
|
|
169
305
|
|
|
170
306
|
const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }> = {
|
|
171
307
|
danger: {
|
|
@@ -183,13 +319,33 @@ const ICON_BUTTON_TONES: Record<ColouredTone, { quiet: string; toned: string }>
|
|
|
183
319
|
"text-[var(--text-placeholder)] hover:bg-[var(--info-bg)] hover:text-[var(--info)] focus:ring-[var(--info-border)]",
|
|
184
320
|
toned: "text-[var(--info)] hover:bg-[var(--info-bg)] focus:ring-[var(--info-border)]",
|
|
185
321
|
},
|
|
322
|
+
success: {
|
|
323
|
+
quiet:
|
|
324
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--success-bg)] hover:text-[var(--success)] focus:ring-[var(--success-border)]",
|
|
325
|
+
toned: "text-[var(--success)] hover:bg-[var(--success-bg)] focus:ring-[var(--success-border)]",
|
|
326
|
+
},
|
|
327
|
+
// The hover fill and the ring are MIXED from the one colour, since a custom tone
|
|
328
|
+
// brings no `-bg` / `-border` pair of its own. The fallback is the body colour, so a
|
|
329
|
+
// `custom` tone with no colour set is the default look rather than invisible.
|
|
330
|
+
custom: {
|
|
331
|
+
quiet:
|
|
332
|
+
"text-[var(--text-placeholder)] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] hover:text-[var(--icon-button-tone,var(--text-primary))] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
|
|
333
|
+
toned:
|
|
334
|
+
"text-[var(--icon-button-tone,var(--text-primary))] hover:bg-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_12%,transparent)] focus:ring-[color-mix(in_srgb,var(--icon-button-tone,var(--text-primary))_40%,transparent)]",
|
|
335
|
+
},
|
|
186
336
|
};
|
|
187
337
|
|
|
188
338
|
const ICON_BUTTON_MUTED =
|
|
189
339
|
"text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]";
|
|
190
340
|
|
|
191
341
|
/** Which tones sit quiet at rest when `quiet` is left out. */
|
|
192
|
-
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
|
|
342
|
+
const QUIET_BY_DEFAULT: Record<ColouredTone, boolean> = {
|
|
343
|
+
danger: true,
|
|
344
|
+
warning: false,
|
|
345
|
+
info: false,
|
|
346
|
+
success: false,
|
|
347
|
+
custom: false,
|
|
348
|
+
};
|
|
193
349
|
|
|
194
350
|
function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined): string {
|
|
195
351
|
if (tone === "default") return "";
|
|
@@ -246,11 +402,31 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
246
402
|
* xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
|
|
247
403
|
* icon (an action on a chip or a tab). */
|
|
248
404
|
size?: IconButtonSize;
|
|
405
|
+
/**
|
|
406
|
+
* Fill the height of the flex row it stands in, keeping its width — the delete at
|
|
407
|
+
* the end of keksdose's split line (transaction-fields:162), level with the labelled
|
|
408
|
+
* fields beside it rather than floating at half their height. {@link Button}'s
|
|
409
|
+
* `stretch`; no effect outside a flex row. With a `label`, the tooltip's wrapper is
|
|
410
|
+
* what stretches.
|
|
411
|
+
*/
|
|
412
|
+
stretch?: boolean;
|
|
413
|
+
/** `round`: a circle instead of the rounded square — a compact status chip in a top
|
|
414
|
+
* bar (keksdose's sync indicator). `overlay` is always round. */
|
|
415
|
+
shape?: "square" | "round";
|
|
416
|
+
/**
|
|
417
|
+
* The colour of `tone="custom"`, as any CSS colour — a token (`var(--warning)`),
|
|
418
|
+
* ideally. Sets `--icon-button-tone` on the button, so passing a different one per
|
|
419
|
+
* state re-colours the glyph, its hover fill and its focus ring together. Passing it
|
|
420
|
+
* implies `tone="custom"`. Or leave it out and set the variable yourself from a class
|
|
421
|
+
* (`className="[--icon-button-tone:var(--success)]"`).
|
|
422
|
+
*/
|
|
423
|
+
toneColor?: string;
|
|
249
424
|
/** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
|
|
250
425
|
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
251
426
|
* remove/delete that repeats down a list. `warning`: amber at rest — a flag that
|
|
252
427
|
* wants attention. `info`: sky at rest — a notice-worthy but harmless action
|
|
253
|
-
* (keksdose's reconcile).
|
|
428
|
+
* (keksdose's reconcile). `success`: green at rest — a good state worth seeing.
|
|
429
|
+
* `custom`: the colour of `toneColor` (see there). Default: the variant's own colours. See `quiet` for
|
|
254
430
|
* turning a coloured tone's resting look the other way. */
|
|
255
431
|
tone?: IconButtonTone;
|
|
256
432
|
/**
|
|
@@ -305,19 +481,100 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|
|
305
481
|
* The pair look exactly like the nested version, click exactly like it, and
|
|
306
482
|
* are two tab stops a screen reader can tell apart. */
|
|
307
483
|
stopPropagation?: boolean;
|
|
484
|
+
/**
|
|
485
|
+
* The button's name, said once: it becomes the `aria-label` AND the text of a kit
|
|
486
|
+
* {@link Tooltip} round the button.
|
|
487
|
+
*
|
|
488
|
+
* Every app writes the pair by hand. lenkbank's nine icon actions all carry
|
|
489
|
+
* `aria-label={t(x)} title={t(x)}` (shared/lib/table-columns.tsx:103,
|
|
490
|
+
* projects-page.tsx:83, setpoint/segment-list.tsx:198/207, profile-bar.tsx:128/142,
|
|
491
|
+
* shortcut-dialog.tsx:815/857/1007) — and `title` is the browser's own tooltip,
|
|
492
|
+
* which shows late, never on focus and never on touch, and looks like no other
|
|
493
|
+
* label in the kit. kastlan's RowAction (shared/components/data-table/row-action.tsx)
|
|
494
|
+
* and keksdose's icon wrappers each exist to put a Tooltip round an IconButton.
|
|
495
|
+
*
|
|
496
|
+
* A caller's own `aria-label` still wins, for the rare name that should be longer
|
|
497
|
+
* than the bubble. The bubble is visual only: it would otherwise describe the button
|
|
498
|
+
* with its own name, and a screen reader would read the same word twice. Tooltip's
|
|
499
|
+
* default placement applies — in place, or portalled inside a scroll container —
|
|
500
|
+
* unless `tooltipPortal` says otherwise.
|
|
501
|
+
*/
|
|
502
|
+
label?: string;
|
|
503
|
+
/** Show `label` as a tooltip. Default `true`; `false` keeps `label` as the
|
|
504
|
+
* accessible name only — for a button whose glyph is universally read (a close ✕ in
|
|
505
|
+
* a dialog header) or that already sits under a tooltip of its own. */
|
|
506
|
+
tooltip?: boolean;
|
|
507
|
+
/** Where the `label` tooltip opens. See {@link Tooltip}'s `side`. */
|
|
508
|
+
tooltipSide?: TooltipSide;
|
|
509
|
+
/** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
|
|
510
|
+
tooltipPortal?: boolean;
|
|
511
|
+
/**
|
|
512
|
+
* Why the action is not available — {@link Button}'s `disabledReason`, on the icon
|
|
513
|
+
* button: keksdose's accounts page cannot hide an account with a balance or delete
|
|
514
|
+
* one with bookings (accounts-page:879/913 — "hide requires zero", "delete blocked"),
|
|
515
|
+
* nor its budgets page delete the only budget (budgets-page:330), and each wrapped a
|
|
516
|
+
* natively disabled IconButton in a Tooltip that swaps its label for the reason — a
|
|
517
|
+
* bubble a keyboard never opens, since `disabled` leaves the tab order, and a reason
|
|
518
|
+
* a screen reader never hears.
|
|
519
|
+
*
|
|
520
|
+
* The same contract as Button's: `aria-disabled` instead of `disabled`, so it stays
|
|
521
|
+
* focusable and hoverable; clicks (and the Enter/Space they stand for) are swallowed
|
|
522
|
+
* — `stopPropagation` still applies, so a locked action in a clickable row does not
|
|
523
|
+
* open the row either; the reason shows in the kit {@link Tooltip} and is attached
|
|
524
|
+
* through `aria-describedby` as a `hidden` copy. The NAME stays `aria-label` /
|
|
525
|
+
* `label` — "Delete", described by "The active budget cannot be deleted" — and the
|
|
526
|
+
* bubble shows the reason in place of the label, since the glyph already says what
|
|
527
|
+
* the button is and the reason is the news. Shown even with `tooltip={false}`: a
|
|
528
|
+
* reason nobody can see is not one. Wins over `disabled`, as on Button.
|
|
529
|
+
*/
|
|
530
|
+
disabledReason?: ReactNode;
|
|
308
531
|
}
|
|
309
532
|
|
|
310
533
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
311
|
-
{
|
|
534
|
+
{
|
|
535
|
+
variant = "ghost",
|
|
536
|
+
size = "md",
|
|
537
|
+
tone: toneProp,
|
|
538
|
+
quiet,
|
|
539
|
+
pressed,
|
|
540
|
+
stopPropagation,
|
|
541
|
+
stretch,
|
|
542
|
+
shape = "square",
|
|
543
|
+
toneColor,
|
|
544
|
+
label,
|
|
545
|
+
tooltip = true,
|
|
546
|
+
tooltipSide,
|
|
547
|
+
tooltipPortal,
|
|
548
|
+
disabledReason,
|
|
549
|
+
className,
|
|
550
|
+
style,
|
|
551
|
+
onClick,
|
|
552
|
+
onKeyDown,
|
|
553
|
+
...rest
|
|
554
|
+
},
|
|
312
555
|
ref,
|
|
313
556
|
) {
|
|
314
|
-
|
|
557
|
+
const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
|
|
558
|
+
const reasonId = useId();
|
|
559
|
+
const locked = hasContent(disabledReason);
|
|
560
|
+
const ownDescribedBy = rest["aria-describedby"];
|
|
561
|
+
const button = (
|
|
315
562
|
<button
|
|
316
563
|
ref={ref}
|
|
317
564
|
{...rest}
|
|
565
|
+
disabled={locked ? undefined : rest.disabled}
|
|
566
|
+
aria-disabled={locked || rest["aria-disabled"]}
|
|
567
|
+
aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
|
|
568
|
+
aria-label={rest["aria-label"] ?? label}
|
|
318
569
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
570
|
+
style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
|
|
319
571
|
onClick={(e) => {
|
|
320
572
|
if (stopPropagation) e.stopPropagation();
|
|
573
|
+
// `preventDefault` too: a locked submit must not submit its form.
|
|
574
|
+
if (locked) {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
321
578
|
onClick?.(e);
|
|
322
579
|
}}
|
|
323
580
|
onKeyDown={(e) => {
|
|
@@ -328,16 +585,46 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
328
585
|
}}
|
|
329
586
|
className={cn(
|
|
330
587
|
ICON_BUTTON_BASE,
|
|
331
|
-
ICON_BUTTON_SIZES[size],
|
|
588
|
+
stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
|
|
332
589
|
// After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
|
|
333
590
|
variant === "overlay" ? ICON_BUTTON_OVERLAY : buttonVariantClasses[variant],
|
|
591
|
+
shape === "round" && "rounded-full",
|
|
334
592
|
ICON_BUTTON_DISABLED_REST[variant],
|
|
335
593
|
iconButtonToneClass(tone, quiet),
|
|
336
594
|
pressed && ICON_BUTTON_PRESSED,
|
|
595
|
+
// The disabled look for the focusable kind of disabled, as on Button.
|
|
596
|
+
locked && "cursor-not-allowed opacity-50",
|
|
337
597
|
className,
|
|
338
598
|
)}
|
|
339
599
|
/>
|
|
340
600
|
);
|
|
601
|
+
if (locked) {
|
|
602
|
+
return (
|
|
603
|
+
// Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
|
|
604
|
+
// not add its bubble to the description as well.
|
|
605
|
+
<Tooltip
|
|
606
|
+
label={disabledReason}
|
|
607
|
+
side={tooltipSide}
|
|
608
|
+
portal={tooltipPortal}
|
|
609
|
+
className={stretch ? "self-stretch" : undefined}
|
|
610
|
+
>
|
|
611
|
+
<>
|
|
612
|
+
{button}
|
|
613
|
+
<span id={reasonId} hidden>
|
|
614
|
+
{disabledReason}
|
|
615
|
+
</span>
|
|
616
|
+
</>
|
|
617
|
+
</Tooltip>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (!tooltip || label === undefined || label === "") return button;
|
|
621
|
+
return (
|
|
622
|
+
// A fragment, so Tooltip leaves the button's description alone: the bubble says
|
|
623
|
+
// exactly what `aria-label` already does.
|
|
624
|
+
<Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
|
|
625
|
+
<>{button}</>
|
|
626
|
+
</Tooltip>
|
|
627
|
+
);
|
|
341
628
|
});
|
|
342
629
|
IconButton.displayName = "IconButton";
|
|
343
630
|
|
|
@@ -1400,10 +1687,90 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<"div">, "
|
|
|
1400
1687
|
* wrap and centre as one row), kastlan's "Create a rule". Buttons or links the
|
|
1401
1688
|
* caller renders; the box only places them. */
|
|
1402
1689
|
action?: ReactNode;
|
|
1690
|
+
/**
|
|
1691
|
+
* `box` (default): the dashed, padded card that stands in for a whole section.
|
|
1692
|
+
* `inline`: one quiet line with no box — for the empty state INSIDE something that
|
|
1693
|
+
* already has its own frame, where a dashed card would be a box in a box. keksdose
|
|
1694
|
+
* writes that line by hand five times as a muted `<p className="py-6 text-center">`:
|
|
1695
|
+
* the notification inbox (notification-inbox:140), a support thread
|
|
1696
|
+
* (support-thread:144), the assistant (assistant-page:321), the admin support panel
|
|
1697
|
+
* (support-panel:265) and the funding dialog (funding-dialog:89). The icon shrinks
|
|
1698
|
+
* to the text's size and sits before the title, the hint follows on the same line,
|
|
1699
|
+
* and the row wraps only when it must. Centred like the box; `className="justify-start"`
|
|
1700
|
+
* for a list that reads from the start edge (funding-dialog).
|
|
1701
|
+
*/
|
|
1702
|
+
variant?: "box" | "inline";
|
|
1703
|
+
/**
|
|
1704
|
+
* Colour the icon and the title: `danger` for an error standing where the content
|
|
1705
|
+
* should be — kastlan's ErrorState (shared/components/feedback/error-state.tsx) and
|
|
1706
|
+
* its error boundary's fallback (error-boundary.tsx:16), both a red triangle over a
|
|
1707
|
+
* message — and `success` for an empty state that is GOOD news, kastlan's "No defects
|
|
1708
|
+
* recorded for this room" (handover defects-step.tsx:142, a green check). Left out,
|
|
1709
|
+
* both stay muted. The hint stays muted either way: it is the explanation, not the
|
|
1710
|
+
* verdict.
|
|
1711
|
+
*/
|
|
1712
|
+
tone?: "danger" | "success";
|
|
1713
|
+
/**
|
|
1714
|
+
* `inline` only (0.11.0). `sm`: 12px, start-aligned and tight (`py-1`) — the empty
|
|
1715
|
+
* line INSIDE a detail panel, under a panel heading and among `text-xs` rows, where
|
|
1716
|
+
* the default's 14px centred line with `py-4` read as a section of its own.
|
|
1717
|
+
* keksdose's holdings panel (holdings-panel:563), asset-loan panel
|
|
1718
|
+
* (asset-loan-panel:108) and loan-payment panel (loan-payment-panel:191) each wrote
|
|
1719
|
+
* `className="justify-start py-1"` over it and still got body-size text. Default `md`,
|
|
1720
|
+
* the look it had. Ignored by the box.
|
|
1721
|
+
*/
|
|
1722
|
+
size?: "sm" | "md";
|
|
1403
1723
|
}
|
|
1404
1724
|
|
|
1405
|
-
|
|
1725
|
+
const EMPTY_STATE_TONE: Record<"danger" | "success", string> = {
|
|
1726
|
+
danger: "text-[var(--danger)]",
|
|
1727
|
+
success: "text-[var(--success)]",
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
export function EmptyState({
|
|
1731
|
+
title,
|
|
1732
|
+
hint,
|
|
1733
|
+
icon,
|
|
1734
|
+
action,
|
|
1735
|
+
headingAs,
|
|
1736
|
+
variant = "box",
|
|
1737
|
+
tone,
|
|
1738
|
+
size = "md",
|
|
1739
|
+
className,
|
|
1740
|
+
...rest
|
|
1741
|
+
}: EmptyStateProps) {
|
|
1406
1742
|
const Title = headingAs ?? "div";
|
|
1743
|
+
const toneClass = tone ? EMPTY_STATE_TONE[tone] : undefined;
|
|
1744
|
+
const hasHint = hint != null && hint !== false && hint !== "";
|
|
1745
|
+
if (variant === "inline") {
|
|
1746
|
+
const sm = size === "sm";
|
|
1747
|
+
return (
|
|
1748
|
+
<div
|
|
1749
|
+
{...rest}
|
|
1750
|
+
className={cn(
|
|
1751
|
+
"flex flex-wrap items-center text-[var(--text-muted)]",
|
|
1752
|
+
sm
|
|
1753
|
+
? "justify-start gap-x-1.5 gap-y-0.5 py-1 text-start text-xs"
|
|
1754
|
+
: "justify-center gap-x-2 gap-y-1 py-4 text-center text-sm",
|
|
1755
|
+
className,
|
|
1756
|
+
)}
|
|
1757
|
+
>
|
|
1758
|
+
{icon != null && (
|
|
1759
|
+
<span
|
|
1760
|
+
aria-hidden
|
|
1761
|
+
className={cn("flex text-[var(--text-placeholder)]", sm ? "[&_svg]:size-3.5" : "[&_svg]:size-4", toneClass)}
|
|
1762
|
+
>
|
|
1763
|
+
{icon}
|
|
1764
|
+
</span>
|
|
1765
|
+
)}
|
|
1766
|
+
{/* No weight of its own: one muted line is what these sites were, and a
|
|
1767
|
+
bold title would turn a quiet "nothing here" into a heading. */}
|
|
1768
|
+
<Title className={cn(sm ? "text-xs" : "text-sm", toneClass)}>{title}</Title>
|
|
1769
|
+
{hasHint && <span className="text-xs">{hint}</span>}
|
|
1770
|
+
{action != null && <span className="flex flex-wrap items-center gap-2">{action}</span>}
|
|
1771
|
+
</div>
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1407
1774
|
return (
|
|
1408
1775
|
<div
|
|
1409
1776
|
{...rest}
|
|
@@ -1413,13 +1780,13 @@ export function EmptyState({ title, hint, icon, action, headingAs, className, ..
|
|
|
1413
1780
|
)}
|
|
1414
1781
|
>
|
|
1415
1782
|
{icon != null && (
|
|
1416
|
-
<div aria-hidden className="mb-3 text-[var(--text-placeholder)] [&_svg]:size-8">
|
|
1783
|
+
<div aria-hidden className={cn("mb-3 text-[var(--text-placeholder)] [&_svg]:size-8", toneClass)}>
|
|
1417
1784
|
{icon}
|
|
1418
1785
|
</div>
|
|
1419
1786
|
)}
|
|
1420
1787
|
{/* `text-sm`: a heading keeps the box's size, not whatever a stylesheet gives h2. */}
|
|
1421
|
-
<Title className="text-sm font-medium text-[var(--text-secondary)]">{title}</Title>
|
|
1422
|
-
{
|
|
1788
|
+
<Title className={cn("text-sm font-medium text-[var(--text-secondary)]", toneClass)}>{title}</Title>
|
|
1789
|
+
{hasHint && <div className="mt-1 text-xs">{hint}</div>}
|
|
1423
1790
|
{action != null && (
|
|
1424
1791
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">{action}</div>
|
|
1425
1792
|
)}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { ComponentPropsWithoutRef } from "react";
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
+
import { StatusDot } from "./status-dot";
|
|
4
|
+
import type { StatusDotSize, StatusDotTone } from "./status-dot";
|
|
3
5
|
|
|
4
6
|
/** Initials from a display name (first + last) or, failing that, an email —
|
|
5
7
|
* e.g. "Marcel Eifert" → "ME", "marcel@x.com" → "MA". App-agnostic.
|
|
@@ -25,19 +27,71 @@ const AVATAR_SIZES = {
|
|
|
25
27
|
lg: "size-12 text-base",
|
|
26
28
|
} as const;
|
|
27
29
|
|
|
30
|
+
const CHIP =
|
|
31
|
+
"inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--bg-inverse)] font-semibold text-[var(--text-inverse)]";
|
|
32
|
+
|
|
33
|
+
/** The dot grows with the avatar: 8px on `sm`, 10px on `md` (keksdose's), 12px on `lg`. */
|
|
34
|
+
const BADGE_SIZES: Record<keyof typeof AVATAR_SIZES, StatusDotSize> = { sm: "sm", md: "md", lg: "lg" };
|
|
35
|
+
|
|
36
|
+
/** A status dot in the avatar's top corner — an unread count, presence. */
|
|
37
|
+
export interface UserAvatarBadge {
|
|
38
|
+
/** What the dot MEANS, for a screen reader ("3 unread"). Required: a coloured circle
|
|
39
|
+
* says nothing to someone who cannot see it. */
|
|
40
|
+
label: ReactNode;
|
|
41
|
+
/** Default `danger`, the unread dot both apps draw by hand. */
|
|
42
|
+
tone?: StatusDotTone;
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/** A `<span>`'s props, minus `children`: the content is the initials this computes
|
|
29
46
|
* from `name`/`email`, so there is nothing for a caller to put inside. */
|
|
30
47
|
export interface UserAvatarProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
|
|
31
48
|
name?: string | null;
|
|
32
49
|
email?: string | null;
|
|
33
50
|
size?: keyof typeof AVATAR_SIZES;
|
|
51
|
+
/**
|
|
52
|
+
* A status dot in the top-end corner (the top-right in LTR, top-left in RTL), ringed
|
|
53
|
+
* in the surface colour so it reads as its own mark (keksdose account-menu ~385,
|
|
54
|
+
* top-bar :152). Its `label` is read as text where the dot sits — inside a button
|
|
55
|
+
* named by its content it joins that name. In `TopBarActionMenu`'s `trigger` the
|
|
56
|
+
* button is named by the menu's `ariaLabel` followed by this label: "Account menu
|
|
57
|
+
* 3 unread". The initials stay hidden either way, so the person's name is NOT part
|
|
58
|
+
* of it — put it in `ariaLabel` ("Marcel Eifert, account menu") if it should be.
|
|
59
|
+
*/
|
|
60
|
+
badge?: UserAvatarBadge | null;
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
/**
|
|
37
64
|
* A round initials avatar — the common user chip shared across apps (feedback
|
|
38
65
|
* #333). Purely presentational; wrap it in a button for the account menu trigger.
|
|
39
66
|
*/
|
|
40
|
-
export function UserAvatar({ name, email, size = "md", className, ...rest }: UserAvatarProps) {
|
|
67
|
+
export function UserAvatar({ name, email, size = "md", badge, className, ...rest }: UserAvatarProps) {
|
|
68
|
+
if (badge) {
|
|
69
|
+
// A wrapper, because the chip itself is `aria-hidden` and the badge label must not
|
|
70
|
+
// be. `className` stays on the chip (a size override still sizes the circle); the
|
|
71
|
+
// other attributes go on the wrapper, which is what a caller addresses. `relative`
|
|
72
|
+
// also contains the sr-only text.
|
|
73
|
+
return (
|
|
74
|
+
<span {...rest} className="relative inline-flex shrink-0">
|
|
75
|
+
<span
|
|
76
|
+
aria-hidden
|
|
77
|
+
className={cn(
|
|
78
|
+
CHIP,
|
|
79
|
+
AVATAR_SIZES[size],
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{avatarInitials(name, email)}
|
|
84
|
+
</span>
|
|
85
|
+
<StatusDot
|
|
86
|
+
ring
|
|
87
|
+
tone={badge.tone ?? "danger"}
|
|
88
|
+
size={BADGE_SIZES[size]}
|
|
89
|
+
className="absolute -end-0.5 -top-0.5"
|
|
90
|
+
/>
|
|
91
|
+
<span className="sr-only">{badge.label}</span>
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
41
95
|
return (
|
|
42
96
|
<span
|
|
43
97
|
// `aria-hidden` sits BEFORE the spread, unlike the structural attributes on the
|
|
@@ -50,7 +104,7 @@ export function UserAvatar({ name, email, size = "md", className, ...rest }: Use
|
|
|
50
104
|
aria-hidden
|
|
51
105
|
{...rest}
|
|
52
106
|
className={cn(
|
|
53
|
-
|
|
107
|
+
CHIP,
|
|
54
108
|
AVATAR_SIZES[size],
|
|
55
109
|
className,
|
|
56
110
|
)}
|
|
@@ -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
|
}
|