@eifi1/ui-kit 0.15.4 → 0.16.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 +20 -15
- package/dist/chart.d.ts +3 -4
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -4
- package/dist/components/amount-input.js +166 -157
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +14 -3
- package/dist/components/authed-image.js +32 -23
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/button-group.d.ts +3 -4
- package/dist/components/calculator.d.ts +3 -4
- package/dist/components/checkbox.js +1 -1
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +3 -4
- package/dist/components/error-boundary.js +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/facing-pair.d.ts +3 -4
- package/dist/components/file-button.d.ts +3 -4
- package/dist/components/file-button.js +64 -31
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +4 -5
- package/dist/components/form-actions.js +18 -6
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +53 -2
- package/dist/components/line-items.js +73 -7
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +99 -5
- package/dist/components/list.js +103 -38
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +19 -3
- package/dist/components/loading-state.js +14 -3
- package/dist/components/loading-state.js.map +1 -1
- package/dist/components/money-field.d.ts +3 -4
- package/dist/components/number-field.d.ts +3 -4
- package/dist/components/number-input.d.ts +3 -4
- package/dist/components/number-input.js +4 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -4
- package/dist/components/passkeys-setting.d.ts +30 -10
- package/dist/components/passkeys-setting.js +19 -11
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +12 -3
- package/dist/components/progress-bar.js +21 -11
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +3 -4
- package/dist/components/settings-fields.d.ts +3 -4
- package/dist/components/signed-amount.d.ts +28 -4
- package/dist/components/signed-amount.js +17 -8
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.d.ts +31 -2
- package/dist/components/stat-tile.js +37 -6
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +4 -1
- package/dist/components/status-dot.js +20 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -1
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/table.d.ts +18 -13
- package/dist/components/table.js +20 -5
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +3 -4
- package/dist/components/time-input.d.ts +3 -4
- package/dist/components/toggle-legend.d.ts +12 -1
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +3 -4
- package/dist/components/ui.js +156 -52
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -82
- package/dist/feedback/feedback-attachment.js +285 -55
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +2 -105
- package/dist/feedback/feedback-dialog.js +57 -25
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
- package/dist/feedback.d.ts +1 -2
- package/dist/hooks/use-file-drop.d.ts +3 -4
- package/dist/i18n/defaults.d.ts +3 -4
- package/dist/i18n/kit-labels.d.ts +2 -3
- package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
- package/dist/i18n/locales/de-CH.d.ts +3 -4
- package/dist/i18n/locales/de-informal.d.ts +3 -4
- package/dist/i18n/locales/de.d.ts +3 -4
- package/dist/i18n/locales/de.js +5 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -4
- package/dist/i18n/locales/es.js +5 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -4
- package/dist/i18n/locales/fr.js +5 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -4
- package/dist/i18n/locales/hu.js +5 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -4
- package/dist/i18n/locales/it.js +5 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -4
- package/dist/i18n/locales/zh.js +5 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +10 -11
- package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
- package/dist/lib/format.d.ts +18 -3
- package/dist/lib/format.js +2 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -4
- package/dist/rhf/form.d.ts +3 -4
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +3 -4
- package/dist/shell/app-shell.d.ts +3 -4
- package/dist/shell/top-bar-brand.d.ts +3 -4
- package/dist/shell.d.ts +3 -4
- package/dist/theme/palette-derive.d.ts +3 -0
- package/dist/theme/palette-derive.js +7 -0
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +3 -4
- package/dist/wizard.d.ts +3 -4
- package/package.json +4 -2
- package/src/components/alert-banner.tsx +1 -1
- package/src/components/amount-input.tsx +47 -16
- package/src/components/authed-image.tsx +20 -2
- package/src/components/checkbox.tsx +1 -1
- package/src/components/choice-card.tsx +1 -1
- package/src/components/error-boundary.tsx +1 -1
- package/src/components/file-button.tsx +77 -27
- package/src/components/file-dropzone.tsx +1 -1
- package/src/components/form-actions.tsx +86 -8
- package/src/components/line-items.tsx +131 -14
- package/src/components/list.tsx +240 -50
- package/src/components/loading-state.tsx +35 -4
- package/src/components/number-input.tsx +18 -2
- package/src/components/passkeys-setting.tsx +45 -18
- package/src/components/progress-bar.tsx +46 -21
- package/src/components/signed-amount.tsx +47 -8
- package/src/components/stat-tile.tsx +76 -6
- package/src/components/status-dot.tsx +31 -0
- package/src/components/swatch-picker.tsx +3 -1
- package/src/components/table.tsx +42 -15
- package/src/components/toggle-legend.tsx +21 -4
- package/src/components/ui.tsx +280 -70
- package/src/feedback/feedback-attachment.tsx +472 -96
- package/src/feedback/feedback-dialog.tsx +162 -50
- package/src/i18n/locales/de.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/lib/format.ts +24 -3
- package/src/rhf/line-items.tsx +6 -0
- package/src/theme/palette-derive.ts +10 -0
- package/tokens.css +9 -1
package/src/components/list.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { createContext, forwardRef, useContext, useId } from "react";
|
|
1
|
+
import { createContext, forwardRef, useContext, useId, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
|
-
import { ExternalLink } from "lucide-react";
|
|
3
|
+
import { ChevronDown, ExternalLink } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { Spinner } from "./ui";
|
|
7
7
|
import { StatusDot } from "./status-dot";
|
|
8
|
+
import { Collapse } from "./disclosure";
|
|
9
|
+
import { toneTextClass } from "./signed-amount";
|
|
10
|
+
import type { TextTone } from "./signed-amount";
|
|
8
11
|
import { useKitLabels, useKitLink } from "../i18n/kit-labels";
|
|
9
12
|
import { pickLinkRenderer } from "./text-link";
|
|
10
13
|
|
|
@@ -96,12 +99,19 @@ export interface ListItemLinkProps {
|
|
|
96
99
|
[key: `data-${string}`]: unknown;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
const PAD: Record<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
const PAD: Record<
|
|
103
|
+
ListDensity,
|
|
104
|
+
{ target: string; actions: string; leadingActions: string; gap: string; body: string }
|
|
105
|
+
> = {
|
|
106
|
+
compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", leadingActions: "ps-1.5", gap: "gap-0.5", body: "px-2 pb-1.5" },
|
|
107
|
+
default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", leadingActions: "ps-2", gap: "gap-1", body: "px-3 pb-2.5" },
|
|
108
|
+
comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", leadingActions: "ps-3", gap: "gap-1", body: "px-4 pb-3" },
|
|
103
109
|
};
|
|
104
110
|
|
|
111
|
+
/** {@link ListItemBaseProps.trailingTone}: a {@link TextTone}, or `inherit` — no
|
|
112
|
+
* colour of the slot's own, so an element's colour (or the row's) stands. */
|
|
113
|
+
export type ListItemTrailingTone = TextTone | "inherit";
|
|
114
|
+
|
|
105
115
|
const TARGET_RING =
|
|
106
116
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
107
117
|
|
|
@@ -124,8 +134,37 @@ interface ListItemBaseProps {
|
|
|
124
134
|
subtitle?: ReactNode;
|
|
125
135
|
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
126
136
|
subtitleLines?: 1 | 2;
|
|
137
|
+
/**
|
|
138
|
+
* A small line ABOVE the title, in the caption type: the group a row belongs to, a
|
|
139
|
+
* date heading, a category ("Groceries · weekly"). Part of the target and of its
|
|
140
|
+
* name, read before the title. One line, truncated.
|
|
141
|
+
*/
|
|
142
|
+
overline?: ReactNode;
|
|
127
143
|
/** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
|
|
128
144
|
meta?: ReactNode;
|
|
145
|
+
/**
|
|
146
|
+
* Let {@link meta} wrap: it becomes a wrapping row (`flex-wrap`, a small gap)
|
|
147
|
+
* instead of one truncated line — for a run of chips (keksdose's payee-backfill
|
|
148
|
+
* tags), which otherwise had nowhere to go but the body, outside the target. Still
|
|
149
|
+
* inside the target, so still no controls: chips that only show, not ones that
|
|
150
|
+
* remove themselves.
|
|
151
|
+
*/
|
|
152
|
+
metaWrap?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Content INSIDE the main target, under the title, subtitle and meta: a progress or
|
|
155
|
+
* budget bar, a sparkline, a strip of figures (keksdose's food-group bars). Unlike
|
|
156
|
+
* {@link children}, which sits outside the target, a click on it is a click on the
|
|
157
|
+
* row, and its hover fill covers it.
|
|
158
|
+
*
|
|
159
|
+
* Because it is inside the button or link it obeys the {@link trailing} rule: shown,
|
|
160
|
+
* never operated — no buttons, links or inputs (invalid HTML in a button, and folded
|
|
161
|
+
* into its name). Its text joins the row's accessible name, so give a bar that only
|
|
162
|
+
* repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,
|
|
163
|
+
* full width of the text column. Strictly a button holds phrasing content only; the
|
|
164
|
+
* kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
|
|
165
|
+
* there but an HTML validator flags.
|
|
166
|
+
*/
|
|
167
|
+
content?: ReactNode;
|
|
129
168
|
/** A Lucide icon before the text, muted, at 16px. */
|
|
130
169
|
icon?: LucideIcon;
|
|
131
170
|
/** Anything else before the text — an avatar, a file-type badge, a drag handle. */
|
|
@@ -136,6 +175,13 @@ interface ListItemBaseProps {
|
|
|
136
175
|
* Controls go in {@link actions}.
|
|
137
176
|
*/
|
|
138
177
|
trailing?: ReactNode;
|
|
178
|
+
/**
|
|
179
|
+
* The colour of the {@link trailing} slot. Default `muted` — today's look, right for
|
|
180
|
+
* a size or a count in plain text. `inherit` sets none, so an element's own colour
|
|
181
|
+
* (an amount coloured by its sign) stands without a second override; any
|
|
182
|
+
* {@link TextTone} colours a plain-text figure (`"danger"` for an overdue count).
|
|
183
|
+
*/
|
|
184
|
+
trailingTone?: ListItemTrailingTone;
|
|
139
185
|
/**
|
|
140
186
|
* Controls BESIDE the main target, never inside it: copy, delete, "Open".
|
|
141
187
|
*
|
|
@@ -147,6 +193,15 @@ interface ListItemBaseProps {
|
|
|
147
193
|
* on "Copy" from selecting the row.
|
|
148
194
|
*/
|
|
149
195
|
actions?: ReactNode;
|
|
196
|
+
/**
|
|
197
|
+
* Controls BEFORE the main target, never inside it: a selection checkbox, a drag
|
|
198
|
+
* handle that is a button. The mirror of {@link actions} at the start edge — a
|
|
199
|
+
* sibling of the target, so checking the box does not activate (or expand) the row
|
|
200
|
+
* and the checkbox keeps its own name. Give the control an `aria-label` naming the
|
|
201
|
+
* row ("Select Groceries"), since it is not inside the row's name. Anything that only
|
|
202
|
+
* shows goes in {@link leading}, inside the target.
|
|
203
|
+
*/
|
|
204
|
+
leadingActions?: ReactNode;
|
|
150
205
|
/**
|
|
151
206
|
* Rich content UNDER the title row and outside its target: photos, per-row buttons,
|
|
152
207
|
* form fields — kastlan's handover room inspector (room-inspector.tsx), the handover
|
|
@@ -213,6 +268,20 @@ interface ListItemBaseProps {
|
|
|
213
268
|
bordered?: boolean;
|
|
214
269
|
/** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
|
|
215
270
|
as?: "li" | "div";
|
|
271
|
+
/**
|
|
272
|
+
* Wraps the visible row (the bordered box: target, actions and body) in an element
|
|
273
|
+
* of your own, INSIDE the `<li>` — a `<Tooltip>` explaining why the row is disabled,
|
|
274
|
+
* an app's save guard: `renderRow={(row) => <Tooltip label="Locked">{row}</Tooltip>}`.
|
|
275
|
+
*
|
|
276
|
+
* The wrapper otherwise had to go round the whole `ListItem`, which put a `<span>`
|
|
277
|
+
* between the `<ul>` and its `<li>` (invalid; the list stops counting items), so
|
|
278
|
+
* callers wrote `<li><Wrapper><ListItem as="div" /></Wrapper></li>` — and lost the
|
|
279
|
+
* divider and its spacing, which sit on the row's own `<li>`. Here the `<li>` stays
|
|
280
|
+
* the list's child and keeps both. The `<li>` becomes a column and the row takes the
|
|
281
|
+
* full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
|
|
282
|
+
* the row across the list.
|
|
283
|
+
*/
|
|
284
|
+
renderRow?: (row: ReactElement) => ReactNode;
|
|
216
285
|
/** Reaches the main target — the element with the row's name and action. */
|
|
217
286
|
id?: string;
|
|
218
287
|
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
@@ -225,15 +294,15 @@ interface ListItemBaseProps {
|
|
|
225
294
|
*/
|
|
226
295
|
export type ListItemProps = ListItemBaseProps &
|
|
227
296
|
(
|
|
228
|
-
| {
|
|
297
|
+
| ({
|
|
229
298
|
/** Makes the row a button — select, open, mark read. */
|
|
230
299
|
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
231
300
|
href?: never;
|
|
232
301
|
renderLink?: never;
|
|
233
302
|
external?: never;
|
|
234
303
|
onAuxClick?: never;
|
|
235
|
-
}
|
|
236
|
-
| {
|
|
304
|
+
} & NotExpandable)
|
|
305
|
+
| ({
|
|
237
306
|
/**
|
|
238
307
|
* Makes the row a link. A real `<a href>`, so a middle click opens it in a
|
|
239
308
|
* background tab (keksdose feedback #451) — which a button calling `navigate`
|
|
@@ -256,8 +325,33 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
256
325
|
* notification read here too, so a row opened into a background tab does not
|
|
257
326
|
* stay unread. */
|
|
258
327
|
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
259
|
-
}
|
|
328
|
+
} & NotExpandable)
|
|
329
|
+
| ({
|
|
330
|
+
onClick?: never;
|
|
331
|
+
href?: never;
|
|
332
|
+
renderLink?: never;
|
|
333
|
+
external?: never;
|
|
334
|
+
onAuxClick?: never;
|
|
335
|
+
} & NotExpandable)
|
|
260
336
|
| {
|
|
337
|
+
/**
|
|
338
|
+
* Makes the row a disclosure: the target is a button with `aria-expanded` and a
|
|
339
|
+
* turning chevron at its end, and this opens under the row, outside the target
|
|
340
|
+
* (so it may hold controls), animated by {@link Collapse}. keksdose's collapsible
|
|
341
|
+
* groups — a group row with a select-all checkbox before it
|
|
342
|
+
* ({@link ListItemBaseProps.leadingActions}) and its members inside.
|
|
343
|
+
*
|
|
344
|
+
* The row's ONE action is the toggle, so it takes no `onClick` or `href`. The
|
|
345
|
+
* content is a `role="group"` named by the title, unmounted while shut (as a
|
|
346
|
+
* `Disclosure`'s body is).
|
|
347
|
+
*/
|
|
348
|
+
expandedContent: ReactNode;
|
|
349
|
+
/** Controlled open state. Leave it out for the row to keep its own. */
|
|
350
|
+
expanded?: boolean;
|
|
351
|
+
/** The uncontrolled row's first state. Default `false`. */
|
|
352
|
+
defaultExpanded?: boolean;
|
|
353
|
+
/** Runs on every toggle with the new state. */
|
|
354
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
261
355
|
onClick?: never;
|
|
262
356
|
href?: never;
|
|
263
357
|
renderLink?: never;
|
|
@@ -266,6 +360,13 @@ export type ListItemProps = ListItemBaseProps &
|
|
|
266
360
|
}
|
|
267
361
|
);
|
|
268
362
|
|
|
363
|
+
interface NotExpandable {
|
|
364
|
+
expandedContent?: never;
|
|
365
|
+
expanded?: never;
|
|
366
|
+
defaultExpanded?: never;
|
|
367
|
+
onExpandedChange?: never;
|
|
368
|
+
}
|
|
369
|
+
|
|
269
370
|
/**
|
|
270
371
|
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
271
372
|
* truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
|
|
@@ -282,13 +383,18 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
282
383
|
{
|
|
283
384
|
title,
|
|
284
385
|
titleLines = 1,
|
|
386
|
+
overline,
|
|
285
387
|
subtitle,
|
|
286
388
|
subtitleLines = 1,
|
|
287
389
|
meta,
|
|
390
|
+
metaWrap = false,
|
|
391
|
+
content,
|
|
288
392
|
icon: Icon,
|
|
289
393
|
leading,
|
|
290
394
|
trailing,
|
|
395
|
+
trailingTone = "muted",
|
|
291
396
|
actions,
|
|
397
|
+
leadingActions,
|
|
292
398
|
unread = false,
|
|
293
399
|
status,
|
|
294
400
|
selected = false,
|
|
@@ -301,6 +407,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
301
407
|
targetProps,
|
|
302
408
|
bordered = false,
|
|
303
409
|
as = "li",
|
|
410
|
+
renderRow,
|
|
304
411
|
children,
|
|
305
412
|
bodyClassName,
|
|
306
413
|
href,
|
|
@@ -308,6 +415,10 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
308
415
|
external = false,
|
|
309
416
|
onClick,
|
|
310
417
|
onAuxClick,
|
|
418
|
+
expandedContent,
|
|
419
|
+
expanded: expandedProp,
|
|
420
|
+
defaultExpanded = false,
|
|
421
|
+
onExpandedChange,
|
|
311
422
|
...rest
|
|
312
423
|
},
|
|
313
424
|
ref,
|
|
@@ -315,14 +426,27 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
315
426
|
const list = useContext(ListContext);
|
|
316
427
|
const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
317
428
|
const kitLink = useKitLink();
|
|
429
|
+
const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);
|
|
318
430
|
const density = densityProp ?? list.density;
|
|
319
431
|
const pad = PAD[density];
|
|
432
|
+
const expandable = expandedContent !== undefined;
|
|
433
|
+
const expanded = expandable && (expandedProp ?? ownExpanded);
|
|
320
434
|
const isLink = href !== undefined && !disabled;
|
|
321
|
-
const isButton = !isLink && onClick !== undefined && href === undefined;
|
|
435
|
+
const isButton = !isLink && (onClick !== undefined || expandable) && href === undefined;
|
|
322
436
|
const interactive = isLink || isButton;
|
|
323
437
|
const inert = disabled || loading;
|
|
324
438
|
const hasBody = children !== undefined && children !== null && children !== false;
|
|
439
|
+
// Anything under the row's own line: the box turns into a column, and the target
|
|
440
|
+
// takes its own corners and hover fill (see `children`).
|
|
441
|
+
const stacked = hasBody || expandable;
|
|
325
442
|
const titleId = useId();
|
|
443
|
+
const expandedId = useId();
|
|
444
|
+
|
|
445
|
+
const toggle = () => {
|
|
446
|
+
const next = !expanded;
|
|
447
|
+
if (expandedProp === undefined) setOwnExpanded(next);
|
|
448
|
+
onExpandedChange?.(next);
|
|
449
|
+
};
|
|
326
450
|
|
|
327
451
|
const mark =
|
|
328
452
|
status ??
|
|
@@ -338,9 +462,14 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
338
462
|
)}
|
|
339
463
|
{leading}
|
|
340
464
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
465
|
+
{overline != null && (
|
|
466
|
+
<span className="block truncate text-[11px] font-medium leading-snug text-[var(--text-muted)]">
|
|
467
|
+
{overline}
|
|
468
|
+
</span>
|
|
469
|
+
)}
|
|
341
470
|
<span
|
|
342
|
-
// Names the body's group; harmless without one.
|
|
343
|
-
id={
|
|
471
|
+
// Names the body's and the expanded content's group; harmless without one.
|
|
472
|
+
id={stacked ? titleId : undefined}
|
|
344
473
|
className={cn(
|
|
345
474
|
"block text-sm text-[var(--text-primary)]",
|
|
346
475
|
titleLines === 1 ? "truncate" : titleLines === 2 ? "line-clamp-2 break-words" : "break-words",
|
|
@@ -361,9 +490,28 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
361
490
|
{subtitle}
|
|
362
491
|
</span>
|
|
363
492
|
)}
|
|
364
|
-
{meta != null &&
|
|
493
|
+
{meta != null && (
|
|
494
|
+
<span
|
|
495
|
+
className={cn(
|
|
496
|
+
"mt-0.5 text-[11px] leading-snug text-[var(--text-muted)]",
|
|
497
|
+
metaWrap ? "flex flex-wrap items-center gap-1" : "block truncate",
|
|
498
|
+
)}
|
|
499
|
+
>
|
|
500
|
+
{meta}
|
|
501
|
+
</span>
|
|
502
|
+
)}
|
|
503
|
+
{content != null && <span className="mt-1.5 block min-w-0">{content}</span>}
|
|
365
504
|
</span>
|
|
366
|
-
{trailing != null &&
|
|
505
|
+
{trailing != null && (
|
|
506
|
+
<span
|
|
507
|
+
className={cn(
|
|
508
|
+
"flex shrink-0 items-center gap-2 text-xs",
|
|
509
|
+
trailingTone !== "inherit" && toneTextClass(trailingTone),
|
|
510
|
+
)}
|
|
511
|
+
>
|
|
512
|
+
{trailing}
|
|
513
|
+
</span>
|
|
514
|
+
)}
|
|
367
515
|
{loading ? (
|
|
368
516
|
<Spinner label={null} className="size-3.5 shrink-0" />
|
|
369
517
|
) : (
|
|
@@ -372,6 +520,18 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
372
520
|
)
|
|
373
521
|
)}
|
|
374
522
|
{mark}
|
|
523
|
+
{expandable && (
|
|
524
|
+
// Turned, not swapped — the Disclosure card's convention: down while shut, up
|
|
525
|
+
// while open. A rotation is its own mirror, so RTL needs nothing.
|
|
526
|
+
<ChevronDown
|
|
527
|
+
aria-hidden
|
|
528
|
+
className={cn(
|
|
529
|
+
"size-4 shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none",
|
|
530
|
+
align === "start" && "mt-0.5",
|
|
531
|
+
expanded && "rotate-180",
|
|
532
|
+
)}
|
|
533
|
+
/>
|
|
534
|
+
)}
|
|
375
535
|
</>
|
|
376
536
|
);
|
|
377
537
|
|
|
@@ -379,10 +539,10 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
379
539
|
"flex min-w-0 flex-1 text-start",
|
|
380
540
|
align === "start" ? "items-start" : "items-center",
|
|
381
541
|
pad.target,
|
|
382
|
-
//
|
|
542
|
+
// When stacked the target is the top of the box, not all of it, so it takes its
|
|
383
543
|
// own corners and its own hover fill (see `children`).
|
|
384
|
-
|
|
385
|
-
|
|
544
|
+
stacked ? "rounded-md" : "rounded-[inherit]",
|
|
545
|
+
stacked && interactive && !inert && !selected && "transition-colors hover:bg-[var(--bg-hover)]",
|
|
386
546
|
interactive && TARGET_RING,
|
|
387
547
|
interactive && !inert && "cursor-pointer",
|
|
388
548
|
loading && "cursor-progress",
|
|
@@ -431,7 +591,15 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
431
591
|
aria-disabled={loading || undefined}
|
|
432
592
|
aria-busy={loading || undefined}
|
|
433
593
|
aria-current={selected ? "true" : undefined}
|
|
434
|
-
|
|
594
|
+
aria-expanded={expandable ? expanded : undefined}
|
|
595
|
+
aria-controls={expandable ? expandedId : undefined}
|
|
596
|
+
onClick={
|
|
597
|
+
loading
|
|
598
|
+
? undefined
|
|
599
|
+
: expandable
|
|
600
|
+
? toggle
|
|
601
|
+
: (onClick as (event: MouseEvent<HTMLButtonElement>) => void)
|
|
602
|
+
}
|
|
435
603
|
className={target}
|
|
436
604
|
>
|
|
437
605
|
{body}
|
|
@@ -452,48 +620,70 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
452
620
|
);
|
|
453
621
|
}
|
|
454
622
|
|
|
623
|
+
const leadingActionsEl =
|
|
624
|
+
leadingActions != null ? (
|
|
625
|
+
<div className={cn("flex shrink-0 items-center", pad.gap, pad.leadingActions)}>{leadingActions}</div>
|
|
626
|
+
) : null;
|
|
455
627
|
const actionsEl =
|
|
456
628
|
actions != null ? <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div> : null;
|
|
457
629
|
|
|
630
|
+
const row = (
|
|
631
|
+
<div
|
|
632
|
+
className={cn(
|
|
633
|
+
"flex min-w-0 rounded-md border transition-colors",
|
|
634
|
+
stacked ? "flex-col" : "items-center",
|
|
635
|
+
// See `renderRow`: the wrapper may be inline, the row is not.
|
|
636
|
+
renderRow && "w-full",
|
|
637
|
+
selected
|
|
638
|
+
? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
|
|
639
|
+
: bordered
|
|
640
|
+
? "border-[var(--border)]"
|
|
641
|
+
: "border-transparent",
|
|
642
|
+
!stacked && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
643
|
+
disabled && "opacity-50",
|
|
644
|
+
className,
|
|
645
|
+
)}
|
|
646
|
+
>
|
|
647
|
+
{stacked ? (
|
|
648
|
+
<>
|
|
649
|
+
<div className="flex min-w-0 items-center">
|
|
650
|
+
{leadingActionsEl}
|
|
651
|
+
{main}
|
|
652
|
+
{actionsEl}
|
|
653
|
+
</div>
|
|
654
|
+
{expandable && (
|
|
655
|
+
<Collapse open={expanded}>
|
|
656
|
+
<div id={expandedId} role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body)}>
|
|
657
|
+
{expandedContent}
|
|
658
|
+
</div>
|
|
659
|
+
</Collapse>
|
|
660
|
+
)}
|
|
661
|
+
{hasBody && (
|
|
662
|
+
<div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
|
|
663
|
+
{children}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
</>
|
|
667
|
+
) : (
|
|
668
|
+
<>
|
|
669
|
+
{leadingActionsEl}
|
|
670
|
+
{main}
|
|
671
|
+
{actionsEl}
|
|
672
|
+
</>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
|
|
458
677
|
const Tag = as;
|
|
459
678
|
return (
|
|
460
679
|
<Tag
|
|
461
680
|
className={cn(
|
|
462
681
|
list.separator === "divider" && "border-b border-[var(--border)] last:border-b-0",
|
|
463
682
|
list.separator === "divider" && as === "li" && "py-0.5",
|
|
683
|
+
renderRow && "flex flex-col",
|
|
464
684
|
)}
|
|
465
685
|
>
|
|
466
|
-
|
|
467
|
-
className={cn(
|
|
468
|
-
"flex min-w-0 rounded-md border transition-colors",
|
|
469
|
-
hasBody ? "flex-col" : "items-center",
|
|
470
|
-
selected
|
|
471
|
-
? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
|
|
472
|
-
: bordered
|
|
473
|
-
? "border-[var(--border)]"
|
|
474
|
-
: "border-transparent",
|
|
475
|
-
!hasBody && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
476
|
-
disabled && "opacity-50",
|
|
477
|
-
className,
|
|
478
|
-
)}
|
|
479
|
-
>
|
|
480
|
-
{hasBody ? (
|
|
481
|
-
<>
|
|
482
|
-
<div className="flex min-w-0 items-center">
|
|
483
|
-
{main}
|
|
484
|
-
{actionsEl}
|
|
485
|
-
</div>
|
|
486
|
-
<div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
|
|
487
|
-
{children}
|
|
488
|
-
</div>
|
|
489
|
-
</>
|
|
490
|
-
) : (
|
|
491
|
-
<>
|
|
492
|
-
{main}
|
|
493
|
-
{actionsEl}
|
|
494
|
-
</>
|
|
495
|
-
)}
|
|
496
|
-
</div>
|
|
686
|
+
{renderRow ? renderRow(row) : row}
|
|
497
687
|
</Tag>
|
|
498
688
|
);
|
|
499
689
|
});
|
|
@@ -5,12 +5,29 @@ import { Spinner } from "./ui";
|
|
|
5
5
|
|
|
6
6
|
export type LoadingStateSize = "sm" | "md" | "lg";
|
|
7
7
|
|
|
8
|
+
/** Whether {@link LoadingStateProps.label} shows, or is only read out. */
|
|
9
|
+
export type LoadingStateLabelVisibility = "visible" | "sr-only";
|
|
10
|
+
|
|
8
11
|
export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
9
12
|
/** The visible words under the spinner, and what the status region announces.
|
|
10
|
-
* Default: `common.loading` ("Loading…").
|
|
13
|
+
* Default: `common.loading` ("Loading…"). `null` (or `""`) hides the words — the
|
|
14
|
+
* same as `labelVisibility="sr-only"` with the default text, so the region still
|
|
15
|
+
* announces "Loading…" rather than nothing. */
|
|
11
16
|
label?: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* `sr-only`: the spinner alone shows, the label is still the status region's words
|
|
19
|
+
* for a screen reader — a busy row or a bubble where "Loading…" in print is noise.
|
|
20
|
+
* Default `visible`.
|
|
21
|
+
*/
|
|
22
|
+
labelVisibility?: LoadingStateLabelVisibility;
|
|
12
23
|
/** `sm` for a panel or a table body, `md` (default) for a section, `lg` for a page. */
|
|
13
24
|
size?: LoadingStateSize;
|
|
25
|
+
/**
|
|
26
|
+
* Less room above and below, the size's spinner and text unchanged — `md` at `py-8`
|
|
27
|
+
* instead of `py-12` (`sm` `py-2`, `lg` `py-12`): the step between a panel and a
|
|
28
|
+
* section, for a card body that `md` makes look empty rather than loading.
|
|
29
|
+
*/
|
|
30
|
+
compact?: boolean;
|
|
14
31
|
/** Spinner beside the text on one line, rather than above it. */
|
|
15
32
|
inline?: boolean;
|
|
16
33
|
}
|
|
@@ -18,6 +35,7 @@ export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
18
35
|
const SPINNER: Record<LoadingStateSize, string> = { sm: "h-4 w-4", md: "h-8 w-8", lg: "h-10 w-10" };
|
|
19
36
|
const TEXT: Record<LoadingStateSize, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
20
37
|
const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py-20" };
|
|
38
|
+
const PAD_COMPACT: Record<LoadingStateSize, string> = { sm: "py-2", md: "py-8", lg: "py-12" };
|
|
21
39
|
|
|
22
40
|
/**
|
|
23
41
|
* A spinner and the words "Loading…", centred where the content will be.
|
|
@@ -27,8 +45,21 @@ const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py
|
|
|
27
45
|
* visible text inside it, so the spinner is decorative (`label={null}`) — otherwise
|
|
28
46
|
* the reader hears "Loading… Loading…", one from each.
|
|
29
47
|
*/
|
|
30
|
-
export function LoadingState({
|
|
48
|
+
export function LoadingState({
|
|
49
|
+
label,
|
|
50
|
+
labelVisibility = "visible",
|
|
51
|
+
size = "md",
|
|
52
|
+
compact = false,
|
|
53
|
+
inline = false,
|
|
54
|
+
className,
|
|
55
|
+
...rest
|
|
56
|
+
}: LoadingStateProps) {
|
|
31
57
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
58
|
+
// `null` / `""` used to fall through `??` to a printed "Loading…" (null) or leave a
|
|
59
|
+
// silent region (""): both now mean "no words on screen", and the region keeps the
|
|
60
|
+
// default words to announce — a status region with nothing in it says nothing.
|
|
61
|
+
const hidden = label === null || label === "" || labelVisibility === "sr-only";
|
|
62
|
+
const words = label === null || label === undefined || label === "" ? common.loading : label;
|
|
32
63
|
return (
|
|
33
64
|
<div
|
|
34
65
|
{...rest}
|
|
@@ -36,12 +67,12 @@ export function LoadingState({ label, size = "md", inline = false, className, ..
|
|
|
36
67
|
className={cn(
|
|
37
68
|
"flex items-center justify-center text-center text-[var(--text-muted)]",
|
|
38
69
|
inline ? "gap-2" : "flex-col gap-2",
|
|
39
|
-
PAD[size],
|
|
70
|
+
(compact ? PAD_COMPACT : PAD)[size],
|
|
40
71
|
className,
|
|
41
72
|
)}
|
|
42
73
|
>
|
|
43
74
|
<Spinner label={null} className={SPINNER[size]} />
|
|
44
|
-
<span className={TEXT[size]}>{
|
|
75
|
+
<span className={hidden ? "sr-only" : TEXT[size]}>{words}</span>
|
|
45
76
|
</div>
|
|
46
77
|
);
|
|
47
78
|
}
|
|
@@ -195,6 +195,14 @@ export function NumberInput({
|
|
|
195
195
|
const endRef = useRef<HTMLDivElement>(null);
|
|
196
196
|
const [endWidth, setEndWidth] = useState<number | null>(null);
|
|
197
197
|
const hasEnd = showCalc || suffix !== undefined;
|
|
198
|
+
// Before the first measurement (and wherever there is no layout), a TEXT unit
|
|
199
|
+
// reserves an estimate from its length — one `ch` a character, in the input's own
|
|
200
|
+
// (larger) font, so it errs wide — plus the span's `pe-2` and the 4px gap, and the
|
|
201
|
+
// calculator's 36px. The class fallback's flat pe-8 gave a "%" 32px for a frame and
|
|
202
|
+
// "km/h" too little.
|
|
203
|
+
const suffixChars = typeof suffix === "string" || typeof suffix === "number" ? String(suffix).length : null;
|
|
204
|
+
const estimatedEnd =
|
|
205
|
+
suffixChars !== null ? `calc(${suffixChars}ch + 0.75rem${showCalc ? " + 2.25rem" : ""})` : undefined;
|
|
198
206
|
useLayoutEffect(() => {
|
|
199
207
|
const el = endRef.current;
|
|
200
208
|
if (!hasEnd || !el || typeof ResizeObserver === "undefined") return;
|
|
@@ -291,7 +299,13 @@ export function NumberInput({
|
|
|
291
299
|
// field reserves exactly their width (+4px). A fixed pe-8 for any unit left a
|
|
292
300
|
// "%" in a ~78px table cell 32px of padding and the digits ~32px of room
|
|
293
301
|
// (keksdose live #367); a long unit ("km/h") got too little.
|
|
294
|
-
style={
|
|
302
|
+
style={
|
|
303
|
+
hasEnd && endWidth != null
|
|
304
|
+
? { paddingInlineEnd: endWidth + 4 }
|
|
305
|
+
: estimatedEnd !== undefined
|
|
306
|
+
? { paddingInlineEnd: estimatedEnd }
|
|
307
|
+
: undefined
|
|
308
|
+
}
|
|
295
309
|
/>
|
|
296
310
|
{(showCalc || suffix !== undefined) && (
|
|
297
311
|
// One flex track for both, so the unit and the calculator sit side by side
|
|
@@ -310,7 +324,9 @@ export function NumberInput({
|
|
|
310
324
|
{suffix !== undefined && (
|
|
311
325
|
<span
|
|
312
326
|
aria-hidden
|
|
313
|
-
|
|
327
|
+
// pe-2, not the input's px-3: the unit is chrome at the box's edge, and
|
|
328
|
+
// with pe-3 plus the 4px gap it took ~26px of a narrow table cell.
|
|
329
|
+
className="pointer-events-none pe-2 text-xs font-medium text-[var(--text-muted)]"
|
|
314
330
|
>
|
|
315
331
|
{suffix}
|
|
316
332
|
</span>
|