@tristan2828/ui-foundation 3.29.0 → 3.30.1
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/conventions/AGENTS.md +2 -0
- package/conventions/docs/cell-patterns.md +6 -0
- package/conventions/docs/design-language.md +71 -0
- package/dist/components/app/error-state.d.ts +3 -1
- package/dist/components/app/error-state.js +9 -3
- package/dist/components/app/multi-choice.js +13 -1
- package/dist/components/app/multi-reference.js +13 -1
- package/dist/components/app/secondary-filters.js +2 -2
- package/dist/components/app/widget-card.d.ts +56 -0
- package/dist/components/app/widget-card.js +57 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/conventions/AGENTS.md
CHANGED
|
@@ -77,6 +77,8 @@ is right. Do not disable, skip, or work around it.
|
|
|
77
77
|
Every data view handles: loading, empty, error, and success. Use
|
|
78
78
|
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
|
|
79
79
|
and `EntityView` (one record) adds not found: a plain 404, never a retry.
|
|
80
|
+
A small widget on a hub page is a `WidgetCard`, which holds all four for
|
|
81
|
+
its own data, its error inside the card.
|
|
80
82
|
Every screen has one Playwright test per state, forced via MSW overrides
|
|
81
83
|
(`@tristan2828/ui-foundation/testing`).
|
|
82
84
|
|
|
@@ -688,6 +688,12 @@ Save button, no Undo:
|
|
|
688
688
|
| `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
|
|
689
689
|
| `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
|
|
690
690
|
|
|
691
|
+
In either multi picker (`MultiChoice`, `MultiReference`), on a form, in
|
|
692
|
+
a toolbar filter or in place, Escape never removes a pick: it closes the
|
|
693
|
+
list when it's open and leaves the value alone when it's closed (Base UI
|
|
694
|
+
would empty it). A pick comes off only by its chip's remove button,
|
|
695
|
+
Backspace in the empty input, or unpicking it in the list.
|
|
696
|
+
|
|
691
697
|
Leaving means clicking or tabbing anywhere else. Unchanged closes with no
|
|
692
698
|
request; a value the form's schema refuses never leaves the browser and
|
|
693
699
|
shows the schema's message. Opening another value saves the open one
|
|
@@ -271,3 +271,74 @@ set, not a `DataTable` prop:
|
|
|
271
271
|
shows the primitives, `DataTable` and the sidebar at each. Its checks
|
|
272
272
|
measure that every step changes what it should, and that the default
|
|
273
273
|
keeps today's sizes.
|
|
274
|
+
|
|
275
|
+
## Widget cards (hub pages)
|
|
276
|
+
|
|
277
|
+
A hub page (an app's home screen) shows a few small widgets from
|
|
278
|
+
different parts of the app, usually above a grid of navigation tiles.
|
|
279
|
+
Each widget is a `WidgetCard`: a titled card holding a short list,
|
|
280
|
+
loading its own data. Use one when a screen samples several lists at
|
|
281
|
+
once and each links on to the full list. A list someone works through
|
|
282
|
+
belongs on its own screen, in a `DataTable`.
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of 5
|
|
286
|
+
|
|
287
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,--spacing(72)),1fr))] gap-4">
|
|
288
|
+
<WidgetCard
|
|
289
|
+
title="Ready to start"
|
|
290
|
+
icon={ListChecksIcon}
|
|
291
|
+
link={{ to: '/tasks?status=ready', label: 'All' }}
|
|
292
|
+
isLoading={isLoading}
|
|
293
|
+
error={error}
|
|
294
|
+
onRetry={() => refetch()}
|
|
295
|
+
isEmpty={data?.items.length === 0}
|
|
296
|
+
emptyMessage="Nothing waiting"
|
|
297
|
+
skeletonRows={3}
|
|
298
|
+
>
|
|
299
|
+
<WidgetList>
|
|
300
|
+
{data?.items.map((task) => (
|
|
301
|
+
<WidgetListItem
|
|
302
|
+
key={task.id}
|
|
303
|
+
to={`/tasks/${task.id}`}
|
|
304
|
+
trailing={<Badge variant={READINESS_VARIANT[task.readiness]}>{READINESS_LABEL[task.readiness]}</Badge>}
|
|
305
|
+
>
|
|
306
|
+
{task.title}
|
|
307
|
+
</WidgetListItem>
|
|
308
|
+
))}
|
|
309
|
+
</WidgetList>
|
|
310
|
+
</WidgetCard>
|
|
311
|
+
</div>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
- **Three to six rows.** A widget is a sample, not the list: ask the API
|
|
315
|
+
for that many (the query's page size) and put the rest behind the
|
|
316
|
+
header `link` ("All", "All doing"), which goes to the full list
|
|
317
|
+
filtered the same way. Set `skeletonRows` to the number it usually holds, so the card
|
|
318
|
+
keeps its height when the data arrives.
|
|
319
|
+
- **Each widget owns its four states.** Loading is a skeleton of rows,
|
|
320
|
+
empty is one quiet line the app writes ("Nothing waiting", not "No
|
|
321
|
+
data"), and **an error stays inside the card**: the compact
|
|
322
|
+
`ErrorState` with a retry, so one failing widget never takes the page
|
|
323
|
+
down. Never let a widget's error reach the route's error boundary.
|
|
324
|
+
- **The title is a heading** (`headingLevel`, h2 under the page's h1 by
|
|
325
|
+
default) and names the card's region, so the page's outline and its
|
|
326
|
+
landmarks list every widget. Give each widget on a page a different
|
|
327
|
+
title.
|
|
328
|
+
- **A row is one line:** its text is a link (`to` for a route in the
|
|
329
|
+
app, `href` for an external address, which opens in a new tab with the
|
|
330
|
+
external-link mark) or plain text, cut with an ellipsis, the whole of
|
|
331
|
+
it in the link's name and its tooltip. After it, an optional `note`
|
|
332
|
+
(muted, small: "2 h ago", "holds 2") and an optional `trailing`
|
|
333
|
+
element: a tone badge (pattern 5's mapping) or a control.
|
|
334
|
+
- **A control in a row is named by the row's text.** Pass `trailing` as
|
|
335
|
+
a function and put the id it gets in the control's `aria-labelledby`:
|
|
336
|
+
`trailing={(id) => <Switch aria-labelledby={id} … />}`. A switch saves
|
|
337
|
+
the way any row's switch does (`useRecordUpdate`, pattern 15).
|
|
338
|
+
- **Equal widths, height from content.** Put the cards in a grid of
|
|
339
|
+
equal columns that wraps (the class above: one column on a phone, as
|
|
340
|
+
many 288px-or-wider columns as fit). Each card is as tall as its rows;
|
|
341
|
+
it doesn't stretch to its tallest neighbour.
|
|
342
|
+
- Storybook's `app/WidgetCard` shows three widgets on a page and every
|
|
343
|
+
one in each state; its checks run axe and the token and glyph checks in
|
|
344
|
+
both themes and at `large`.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AppError } from '../../api/contracts.js';
|
|
2
|
-
export declare function ErrorState({ error, onRetry, className, }: {
|
|
2
|
+
export declare function ErrorState({ error, onRetry, size, className, }: {
|
|
3
3
|
error: AppError;
|
|
4
4
|
onRetry?: () => void;
|
|
5
|
+
/** `compact` inside a small container such as a WidgetCard. */
|
|
6
|
+
size?: 'default' | 'compact';
|
|
5
7
|
className?: string;
|
|
6
8
|
}): import("react").JSX.Element;
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// The composite AGENTS.md's "Correct Patterns" section names directly:
|
|
3
3
|
// `if (error) return <ErrorState error={error} />`. Renders an AppError —
|
|
4
4
|
// never a raw response (the gateway translates every failure) —
|
|
5
5
|
// using the same <Empty> primitive the "no rows" state uses, so a screen's
|
|
6
6
|
// error and empty states read as one family instead of two unrelated
|
|
7
7
|
// widgets.
|
|
8
|
+
//
|
|
9
|
+
// `compact` is the same state for a small container (a WidgetCard): less
|
|
10
|
+
// padding and the icon beside the title instead of in a box above it, so
|
|
11
|
+
// one failing widget reads as that widget's problem, not the page's.
|
|
8
12
|
import { AlertCircleIcon } from 'lucide-react';
|
|
13
|
+
import { cn } from 'cn';
|
|
9
14
|
import { Button } from '../ui/button.js';
|
|
10
15
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
11
16
|
const TITLE_BY_KIND = {
|
|
@@ -15,6 +20,7 @@ const TITLE_BY_KIND = {
|
|
|
15
20
|
auth: 'Not authorized',
|
|
16
21
|
validation: 'Check the form',
|
|
17
22
|
};
|
|
18
|
-
export function ErrorState({ error, onRetry, className, }) {
|
|
19
|
-
|
|
23
|
+
export function ErrorState({ error, onRetry, size = 'default', className, }) {
|
|
24
|
+
const compact = size === 'compact';
|
|
25
|
+
return (_jsxs(Empty, { className: cn(compact && 'gap-3 p-4', className), "data-state": "error", "data-size": size, children: [_jsxs(EmptyHeader, { className: cn(compact && 'gap-1'), children: [compact ? (_jsxs(EmptyTitle, { className: "flex items-center gap-1.5", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), TITLE_BY_KIND[error.kind]] })) : (_jsxs(_Fragment, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(AlertCircleIcon, { className: "text-destructive" }) }), _jsx(EmptyTitle, { children: TITLE_BY_KIND[error.kind] })] })), _jsx(EmptyDescription, { children: error.message })] }), onRetry && (_jsx(EmptyContent, { children: _jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, children: "Try again" }) }))] }));
|
|
20
26
|
}
|
|
@@ -13,5 +13,17 @@ import * as React from 'react';
|
|
|
13
13
|
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
|
|
14
14
|
export function MultiChoice({ options, value, onValueChange, getLabel = String, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
|
|
15
15
|
const anchor = useComboboxAnchor();
|
|
16
|
-
return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next
|
|
16
|
+
return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next, details) => {
|
|
17
|
+
// Base UI empties the value on Escape while the list is closed, so
|
|
18
|
+
// a second Escape after a pick wiped every chip (issue #123). Escape
|
|
19
|
+
// never removes picks here: a chip's remove button, Backspace or
|
|
20
|
+
// unpicking in the list does. Let the key go on, so a dialog or an
|
|
21
|
+
// edit in place gets its own Escape.
|
|
22
|
+
if (details.reason === 'escape-key') {
|
|
23
|
+
details.cancel();
|
|
24
|
+
details.allowPropagation();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
onValueChange(next);
|
|
28
|
+
}, itemToStringLabel: getLabel, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: getLabel(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: getLabel(option) }, option)) })] })] }));
|
|
17
29
|
}
|
|
@@ -27,7 +27,19 @@ export function MultiReference({ options, value, onValueChange, getLabel, onSear
|
|
|
27
27
|
return (_jsxs(Combobox, { multiple: true, autoHighlight: true,
|
|
28
28
|
// Ids, not {id,label} objects: Base UI compares items to the value
|
|
29
29
|
// with Object.is, so the items must have the value's own shape.
|
|
30
|
-
items: options.map((option) => option.id), value: value, onValueChange: (next
|
|
30
|
+
items: options.map((option) => option.id), value: value, onValueChange: (next, details) => {
|
|
31
|
+
// Base UI empties the value on Escape while the list is closed, so
|
|
32
|
+
// a second Escape after a pick wiped every chip (issue #123). Escape
|
|
33
|
+
// never removes picks here: a chip's remove button, Backspace or
|
|
34
|
+
// unpicking in the list does. Let the key go on, so a dialog or an
|
|
35
|
+
// edit in place gets its own Escape.
|
|
36
|
+
if (details.reason === 'escape-key') {
|
|
37
|
+
details.cancel();
|
|
38
|
+
details.allowPropagation();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
onValueChange(next);
|
|
42
|
+
}, onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, readOnly: readOnly, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, onKeyDown: (event) => {
|
|
31
43
|
if (searching && event.key === 'Enter') {
|
|
32
44
|
event.preventDefault();
|
|
33
45
|
event.preventBaseUIHandler();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// A table toolbar's less-used filters behind one "Filters" button, so a
|
|
3
3
|
// table with many filters still starts near the top of the page (issue
|
|
4
4
|
// #93). The primary filters (search, status, whatever most visits use)
|
|
@@ -26,7 +26,7 @@ export function SecondaryFilters({ children, active, onClear, label = 'Filters'
|
|
|
26
26
|
const isMobile = useIsMobile();
|
|
27
27
|
const [open, setOpen] = useState(false);
|
|
28
28
|
const count = active.length;
|
|
29
|
-
const trigger = (_jsxs(Button, { variant: "outline", "aria-
|
|
29
|
+
const trigger = (_jsxs(Button, { variant: "outline", children: [_jsx(SlidersHorizontalIcon, { "aria-hidden": "true" }), _jsxs("span", { children: [label, count > 0 && _jsx("span", { className: "text-[0px]", children: "," })] }), count > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { className: "rounded-full bg-primary px-1.5 type-caption text-primary-foreground tabular-nums", children: count }), _jsx("span", { className: "sr-only", children: " active" })] }))] }));
|
|
30
30
|
const footer = (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", disabled: count === 0, onClick: onClear, children: "Clear filters" }), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" })] }));
|
|
31
31
|
return (_jsxs("div", { "data-slot": "secondary-filters", className: "flex flex-wrap items-center gap-2", children: [isMobile ? (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { render: trigger }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] overflow-y-auto", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: label }) }), _jsx("div", { className: "flex flex-col gap-3 px-4 [&>*]:w-full", children: children }), _jsx(SheetFooter, { children: footer })] })] })) : (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { render: trigger }), _jsxs(PopoverContent, { align: "start", className: "w-auto min-w-64 gap-3 p-3", children: [_jsx(PopoverTitle, { children: label }), _jsx("div", { className: "flex flex-col gap-3", children: children }), footer] })] })), active.map((filter) => (_jsxs("span", { "data-slot": "secondary-filter-chip", className: "inline-flex h-7 items-center gap-0.5 rounded-md bg-muted pr-0.5 pl-2 type-caption text-foreground", children: [filter.label, _jsx(Button, { variant: "ghost", size: "icon-xs", "aria-label": `Remove filter: ${filter.label}`, onClick: filter.onRemove, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }, filter.id))), count > 1 && (_jsx(Button, { variant: "ghost", size: "sm", onClick: onClear, children: "Clear filters" }))] }));
|
|
32
32
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type To } from 'react-router';
|
|
3
|
+
import { type LucideIcon } from 'lucide-react';
|
|
4
|
+
import type { AppError } from '../../api/contracts.js';
|
|
5
|
+
export interface WidgetCardProps {
|
|
6
|
+
title: string;
|
|
7
|
+
/** A Lucide icon shown before the title. Decorative: the title names the card. */
|
|
8
|
+
icon?: LucideIcon;
|
|
9
|
+
/** The title's heading level: 2 under the page's h1 (the default), deeper under a section. */
|
|
10
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
11
|
+
/**
|
|
12
|
+
* A link on the right of the header to everything the widget samples:
|
|
13
|
+
* `{ to: '/tasks?status=doing', label: 'All doing' }`. Its accessible
|
|
14
|
+
* name is `label`, described by the title, so several "All" links on one
|
|
15
|
+
* page stay distinct.
|
|
16
|
+
*/
|
|
17
|
+
link?: {
|
|
18
|
+
to: To;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
isLoading: boolean;
|
|
22
|
+
error?: AppError | null;
|
|
23
|
+
onRetry?: () => void;
|
|
24
|
+
/** True once loaded with nothing to show. Ignored while loading or failed. */
|
|
25
|
+
isEmpty: boolean;
|
|
26
|
+
/** The empty state's one line: "Nothing waiting". */
|
|
27
|
+
emptyMessage: string;
|
|
28
|
+
/** How many rows the loading skeleton shows: the number the widget usually holds. */
|
|
29
|
+
skeletonRows?: number;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, children, className, }: WidgetCardProps): import("react").JSX.Element;
|
|
34
|
+
/** The rows of a WidgetCard: a list with a divider between rows. */
|
|
35
|
+
export declare function WidgetList({ children, className }: {
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
export interface WidgetListItemProps {
|
|
40
|
+
/** The row's text: one line, cut with an ellipsis, the whole of it in the link's name and its tooltip. */
|
|
41
|
+
children: string;
|
|
42
|
+
/** An app route: the row is a link to it. */
|
|
43
|
+
to?: To;
|
|
44
|
+
/** An external address: the row is a link that opens in a new tab. Without `to` or `href` the row is plain text. */
|
|
45
|
+
href?: string;
|
|
46
|
+
/** A small muted note after the text: "2 h ago", "holds 2". */
|
|
47
|
+
note?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* What ends the row: a badge, or a control. A control needs a name: pass
|
|
50
|
+
* a function, and put the id it gets in the control's `aria-labelledby`,
|
|
51
|
+
* so the row's text names it.
|
|
52
|
+
*/
|
|
53
|
+
trailing?: ReactNode | ((rowTextId: string) => ReactNode);
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function WidgetListItem({ children, to, href, note, trailing, className }: WidgetListItemProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A small titled card on a hub page (issue #121): a few rows from one part
|
|
3
|
+
// of the app, loaded on their own. It owns the four required states the
|
|
4
|
+
// way DataTable and EntityView do, so every widget on a page skeletons,
|
|
5
|
+
// empties and fails alike:
|
|
6
|
+
//
|
|
7
|
+
// - loading: `skeletonRows` rows shaped like the list's, so the card
|
|
8
|
+
// keeps its height when the data arrives;
|
|
9
|
+
// - empty: one quiet line the app supplies ("Nothing waiting");
|
|
10
|
+
// - error: ErrorState's compact form with a retry, inside the card, so a
|
|
11
|
+
// failing widget never takes the page down;
|
|
12
|
+
// - success: the app's own children, usually a WidgetList.
|
|
13
|
+
//
|
|
14
|
+
// The title is a real heading (`headingLevel`, h2 by default, under the
|
|
15
|
+
// page's h1) and names the card's region, so each widget is in the page's
|
|
16
|
+
// outline and its landmarks. Height follows content (`self-start` in a
|
|
17
|
+
// grid), so a short widget doesn't stretch to its tallest neighbour.
|
|
18
|
+
//
|
|
19
|
+
// WidgetList and WidgetListItem are the common body: rows with dividers,
|
|
20
|
+
// each a primary link (or plain text) truncated to one line, an optional
|
|
21
|
+
// muted note ("2 h ago") and an optional trailing element (a badge, a
|
|
22
|
+
// switch). A trailing control is labelled by the row's text: pass a
|
|
23
|
+
// function and it gets the id to put in `aria-labelledby`.
|
|
24
|
+
import { useId } from 'react';
|
|
25
|
+
import { Link } from 'react-router';
|
|
26
|
+
import { ExternalLinkIcon } from 'lucide-react';
|
|
27
|
+
import { cn } from 'cn';
|
|
28
|
+
import { ErrorState } from './error-state.js';
|
|
29
|
+
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
30
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
31
|
+
const HEADER_LINK_CLASS = 'shrink-0 rounded-sm type-label text-foreground underline underline-offset-4 outline-none ' +
|
|
32
|
+
'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
33
|
+
export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, children, className, }) {
|
|
34
|
+
const headingId = useId();
|
|
35
|
+
const Heading = `h${headingLevel}`;
|
|
36
|
+
const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
|
|
37
|
+
return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
|
|
38
|
+
}
|
|
39
|
+
// One row's height, shared by the skeleton and the list so the card doesn't
|
|
40
|
+
// jump: py-2 around one line of type-body (or a small control).
|
|
41
|
+
const ROW_CLASS = 'flex min-h-9 items-center gap-3 py-2';
|
|
42
|
+
// Varied widths, so the skeleton reads as a list of titles, not a grid.
|
|
43
|
+
const SKELETON_WIDTHS = ['w-3/5', 'w-2/5', 'w-1/2', 'w-2/3', 'w-1/3', 'w-3/5'];
|
|
44
|
+
function WidgetListSkeleton({ rows }) {
|
|
45
|
+
return (_jsxs("div", { "data-state": "loading", className: "flex flex-col divide-y divide-border", children: [_jsx("span", { className: "sr-only", children: "Loading" }), Array.from({ length: rows }, (_, row) => (_jsxs("div", { className: ROW_CLASS, children: [_jsx(Skeleton, { className: cn('h-4', SKELETON_WIDTHS[row % SKELETON_WIDTHS.length]) }), _jsx(Skeleton, { className: "ml-auto h-4 w-12" })] }, row)))] }));
|
|
46
|
+
}
|
|
47
|
+
/** The rows of a WidgetCard: a list with a divider between rows. */
|
|
48
|
+
export function WidgetList({ children, className }) {
|
|
49
|
+
return (_jsx("ul", { "data-slot": "widget-list", className: cn('flex flex-col divide-y divide-border', className), children: children }));
|
|
50
|
+
}
|
|
51
|
+
const ROW_LINK_CLASS = 'min-w-0 truncate rounded-sm font-medium text-foreground underline-offset-4 outline-none ' +
|
|
52
|
+
'hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
53
|
+
export function WidgetListItem({ children, to, href, note, trailing, className }) {
|
|
54
|
+
const textId = useId();
|
|
55
|
+
const text = to !== undefined ? (_jsx(Link, { id: textId, to: to, title: children, className: ROW_LINK_CLASS, children: children })) : href !== undefined ? (_jsxs("a", { id: textId, href: href, target: "_blank", rel: "noreferrer", title: children, className: cn(ROW_LINK_CLASS, 'flex items-center gap-1'), children: [_jsx("span", { className: "truncate", children: children }), _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (_jsx("span", { id: textId, title: children, className: "min-w-0 truncate text-foreground", children: children }));
|
|
56
|
+
return (_jsxs("li", { "data-slot": "widget-list-item", className: cn(ROW_CLASS, 'type-body', className), children: [text, (note !== undefined || trailing !== undefined) && (_jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-3", children: [note !== undefined && _jsx("span", { className: "type-caption tabular-nums text-muted-foreground", children: note }), typeof trailing === 'function' ? trailing(textId) : trailing] }))] }));
|
|
57
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-
|
|
|
19
19
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
20
20
|
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
21
21
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
22
|
+
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
22
23
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
23
24
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
24
25
|
export { RegisterRoute } from './routes/register.js';
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
|
24
24
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
25
25
|
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
26
26
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
27
|
+
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
27
28
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
28
29
|
// Screens outside the app shell
|
|
29
30
|
export { LoginRoute } from './routes/login.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.1",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|