@tristan2828/ui-foundation 3.28.1 → 3.30.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.
@@ -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
 
@@ -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`.
@@ -0,0 +1,34 @@
1
+ import type { ComponentType } from 'react';
2
+ export type NavItem = {
3
+ /** The route this entry links to. */
4
+ to: string;
5
+ label: string;
6
+ icon: ComponentType;
7
+ /** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
8
+ end?: boolean;
9
+ };
10
+ /** A labelled group of sidebar entries: one module's pages, say. */
11
+ export type NavSection = {
12
+ label: string;
13
+ items: readonly NavItem[];
14
+ };
15
+ /** One entry of `AppShell`'s `nav`: a page, or a labelled section of pages. */
16
+ export type NavEntry = NavItem | NavSection;
17
+ export declare function isNavSection(entry: NavEntry): entry is NavSection;
18
+ /** A sidebar group as rendered: an optional label over its items. */
19
+ export type NavGroup = {
20
+ label?: string;
21
+ items: readonly NavItem[];
22
+ };
23
+ /**
24
+ * The sidebar groups for a nav, in order. A flat nav (no sections) is the
25
+ * one "Navigation" group it always was. Otherwise each section is its own
26
+ * labelled group, and each run of plain items between them an unlabelled one.
27
+ */
28
+ export declare function navGroups(nav: readonly NavEntry[]): NavGroup[];
29
+ /**
30
+ * The `to` of the one entry to mark active: the most specific that matches.
31
+ * With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
32
+ * marks Categories only, and '/tasks/3' marks Tasks.
33
+ */
34
+ export declare function activeNavTo(nav: readonly NavEntry[], pathname: string): string | undefined;
@@ -0,0 +1,52 @@
1
+ export function isNavSection(entry) {
2
+ return 'items' in entry;
3
+ }
4
+ /**
5
+ * The sidebar groups for a nav, in order. A flat nav (no sections) is the
6
+ * one "Navigation" group it always was. Otherwise each section is its own
7
+ * labelled group, and each run of plain items between them an unlabelled one.
8
+ */
9
+ export function navGroups(nav) {
10
+ if (!nav.some(isNavSection))
11
+ return [{ label: 'Navigation', items: nav }];
12
+ const groups = [];
13
+ let run = [];
14
+ for (const entry of nav) {
15
+ if (isNavSection(entry)) {
16
+ if (run.length)
17
+ groups.push({ items: run });
18
+ run = [];
19
+ groups.push({ label: entry.label, items: entry.items });
20
+ }
21
+ else {
22
+ run.push(entry);
23
+ }
24
+ }
25
+ if (run.length)
26
+ groups.push({ items: run });
27
+ return groups;
28
+ }
29
+ function matches(item, pathname) {
30
+ if (item.end)
31
+ return pathname === item.to;
32
+ if (pathname === item.to)
33
+ return true;
34
+ // On a path-segment boundary: '/tasks' is active on '/tasks/3', not on
35
+ // '/tasks-archive'.
36
+ return pathname.startsWith(item.to.endsWith('/') ? item.to : `${item.to}/`);
37
+ }
38
+ /**
39
+ * The `to` of the one entry to mark active: the most specific that matches.
40
+ * With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
41
+ * marks Categories only, and '/tasks/3' marks Tasks.
42
+ */
43
+ export function activeNavTo(nav, pathname) {
44
+ let best;
45
+ for (const entry of nav) {
46
+ for (const item of isNavSection(entry) ? entry.items : [entry]) {
47
+ if (matches(item, pathname) && (best === undefined || item.to.length > best.length))
48
+ best = item.to;
49
+ }
50
+ }
51
+ return best;
52
+ }
@@ -1,17 +1,17 @@
1
- import { type ComponentType, type ReactNode } from 'react';
2
- export type NavItem = {
3
- /** The route this entry links to. */
4
- to: string;
5
- label: string;
6
- icon: ComponentType;
7
- /** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
8
- end?: boolean;
9
- };
1
+ import { type ReactNode } from 'react';
2
+ import { type NavEntry } from './app-shell-nav.js';
3
+ export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
10
4
  export type AppShellProps = {
11
5
  /** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
12
6
  title: string;
13
- /** The app's primary navigation, in order. */
14
- nav: readonly NavItem[];
7
+ /**
8
+ * The app's primary navigation, in order: pages, and labelled sections of
9
+ * pages (`{ label, items }`, one per module, say). A nav with no sections
10
+ * is the single "Navigation" group. One entry is marked active: the most
11
+ * specific whose route matches, so '/tasks' and '/tasks/categories' can
12
+ * both be listed.
13
+ */
14
+ nav: readonly NavEntry[];
15
15
  /**
16
16
  * More sidebar groups under the primary nav — reference links, say. Built
17
17
  * from the sidebar primitives (`@tristan2828/ui-foundation/ui/sidebar`).
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
3
3
  import { useTheme } from 'next-themes';
4
4
  import { useEffect, useState } from 'react';
5
- import { NavLink, Navigate, Outlet, useLocation } from 'react-router';
5
+ import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
+ import { activeNavTo, navGroups } from './app-shell-nav.js';
7
8
  import { ErrorState } from './error-state.js';
8
9
  import { Button } from '../ui/button.js';
9
10
  import { Skeleton } from '../ui/skeleton.js';
@@ -64,12 +65,14 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
64
65
  const from = `${location.pathname}${location.search}${location.hash}`;
65
66
  return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
66
67
  }
67
- return (_jsxs(TooltipProvider, { children: [_jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: "Navigation" }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: nav.map(({ to, label, icon: Icon, end }) => {
68
- const isActive = end
69
- ? location.pathname === to
70
- : location.pathname.startsWith(to);
71
- return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(NavLink, { to: to, end: end }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
72
- }) }) })] }), sidebarExtra] }), _jsx(SidebarFooter, { children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
68
+ const activeTo = activeNavTo(nav, location.pathname);
69
+ return (_jsxs(TooltipProvider, { children: [_jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
70
+ // A plain Link, not NavLink: NavLink marks every
71
+ // matching entry current, and only the most specific
72
+ // one should be (activeNavTo).
73
+ const isActive = to === activeTo;
74
+ return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
75
+ }) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsx(SidebarFooter, { children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
73
76
  setIsLoggingOut(true);
74
77
  // A failed request still ends the session on screen
75
78
  // (AuthProvider), so this stays a deliberate logout: a
@@ -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
- return (_jsxs(Empty, { className: className, "data-state": "error", children: [_jsxs(EmptyHeader, { 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" }) }))] }));
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
  }
@@ -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-label": count > 0 ? `${label}, ${count} active` : label, children: [_jsx(SlidersHorizontalIcon, { "aria-hidden": "true" }), label, count > 0 && (_jsx("span", { "aria-hidden": "true", className: "rounded-full bg-primary px-1.5 type-caption text-primary-foreground tabular-nums", children: count }))] }));
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
@@ -1,4 +1,4 @@
1
- export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
1
+ export { AppShell, type AppShellProps, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
2
2
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
3
3
  export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
4
4
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
@@ -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.28.1",
3
+ "version": "3.30.0",
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": {