@tristan2828/ui-foundation 4.9.1 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -265,6 +265,18 @@ is a filled-in example while the Widgets demo is still here).
265
265
  view is `rail`, with Details in the rail. The rail's sections come
266
266
  first in the page (header, rail, main) at every width, so the
267
267
  view's specs find them first too.
268
+ - **A notice** (`notice`), only when the plan names a state that
269
+ decides what you do with the record (held, overdue, archived and
270
+ read-only): a `<Notice tone=…>` saying what it means and what to do,
271
+ with at most one action. It goes under the header, across the page.
272
+ Pass `null` when the state doesn't hold; the view announces it
273
+ appearing or clearing. A badge still shows the state itself; the
274
+ notice is for the few states a badge is too quiet for.
275
+ - **A source credit** (`footer`), only when the record's data comes
276
+ from an outside service whose terms ask for one (many free APIs
277
+ do: a film database, a games catalogue): `<SourceCredit sources={[{
278
+ name, href, note }]} />`, with `note` the sentence the terms ask for,
279
+ word for word. Logos and their rules are the app's own.
268
280
  - **Every value renders the way its table cell does**: badges, names
269
281
  of linked records, a done-count, from the shared lookups in
270
282
  `<entity>-format.ts`. Never re-derive one for the view.
@@ -10,11 +10,13 @@ alternatives ([`column-options.md`](column-options.md)), not designed in the abs
10
10
  Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
11
  to its view), 17 (quick action on the view) and 18 (editable value) are
12
12
  the template's own references: Widget's In Stock and Name columns and its
13
- view render them, and their specs pin them. Patterns 19 (stage circle)
14
- and 20 (count linking to the related records), the multi-value variant
15
- of 4, the dependency list in 10 and the pressed icon in 15 were each
16
- built for a real app's screen first; the package's Storybook renders
17
- them (`app/StageCircle`, `app/CountLink`, `patterns/CellPatterns`), and
13
+ view render them, and their specs pin them. Patterns 19 (stage circle),
14
+ 20 (count linking to the related records) and 22 (a highlighted row),
15
+ the multi-value variant of 4, the step bar and star in 9, the dependency
16
+ list in 10, the ring in 13 and the pressed icon in 15 were each built
17
+ for a real app's screen first; the package's Storybook renders
18
+ them (`app/StageCircle`, `app/CountLink`, `patterns/CellPatterns`,
19
+ `patterns/Scales`, `app/DataTable`), and
18
20
  its checks pin their accessibility and contrast in both themes. The
19
21
  other patterns from 11 on are marked **Unproven**: written ahead of a
20
22
  real column, so no app has tested them on real data yet. Prefer a proven
@@ -281,6 +283,38 @@ own state.
281
283
  does not check icon contrast. That's a defensible choice — make it
282
284
  deliberately, in a comment, not by accident.
283
285
 
286
+ ### Two variants the package draws: a step bar and a star
287
+
288
+ Both are in the package's Storybook (`patterns/Scales`), every value on
289
+ the page and on a card, measured by axe and the glyph check in both
290
+ themes. Each is one image to a screen reader, named in words.
291
+
292
+ - **`StepBar`, an ordered enum of a few bands** (best to worst): a short
293
+ bar of steps, the first `value` filled in `foreground`, the rest the
294
+ track (`border`), with the band's word beside it in `muted-foreground`.
295
+ Narrower than five stars, and it reads as a scale, not a rating. Its
296
+ name says both: `label="Quality: High, 4 of 5"` (the default is
297
+ "High, 4 of 5", enough where the column header names the field).
298
+
299
+ ```tsx
300
+ const QUALITY: Record<Quality, { word: string; value: number }> = {
301
+ best: { word: 'Best', value: 5 }, high: { word: 'High', value: 4 }, /* … */
302
+ }
303
+ <StepBar value={QUALITY[q].value} word={QUALITY[q].word} />
304
+ ```
305
+
306
+ - **`RatingStar`, a three-value enum** (yes / partly / no): a full, half
307
+ or empty star with the word beside it. The shape carries the value, so
308
+ it survives greyscale. The half is the left half filled over the whole
309
+ outline, never half a star: alone, half a star reads as broken.
310
+
311
+ ```tsx
312
+ <RatingStar value={OWNED[o].value /* 'full' | 'half' | 'empty' */} word={OWNED[o].word} />
313
+ ```
314
+
315
+ - Both draw in `foreground`. Tone one only as pattern 5 would (a `*-text`
316
+ class in `className`), never by band alone.
317
+
284
318
  ## 10. Summary in the cell, detail in a popover
285
319
 
286
320
  A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
@@ -414,6 +448,24 @@ A percentage, or a count out of a total (`7 of 12 done`).
414
448
  - **Cost:** a bar takes far more width than the number it replaces. On a
415
449
  wide table, the plain number from pattern 11 usually wins.
416
450
 
451
+ ### The compact form: `ProgressRing`
452
+
453
+ Done out of total (a checklist, how far through an ordered list, the
454
+ steps of a process) as a small ring filling clockwise beside the count
455
+ ("4/6"). Proven in an app's table cells and on its view; the package
456
+ ships it, so this needs no `progress` install.
457
+
458
+ ```tsx
459
+ <ProgressRing done={row.original.doneCount} total={row.original.itemCount} />
460
+ ```
461
+
462
+ - The arc is `foreground` while under way and `success-text` once
463
+ complete, on a `border` track; the count is in tabular numbers. Zero
464
+ of zero shows the empty ring and reads "Not started".
465
+ - Its name is "4 of 6 done" unless `label` says better ("4 of 6 chapters
466
+ read"). The count beside it is `aria-hidden`: the name already says it.
467
+ - Prefer it to the bar in a table: it's as narrow as the count.
468
+
417
469
  ## 14. Avatar and name — *Unproven*
418
470
 
419
471
  A person: an assignee or an owner.
@@ -1155,3 +1207,34 @@ const { page, filters, setFilter, setPage } = useTableUrlState(['kind'])
1155
1207
  filter, with a button to take it off. An empty group never renders:
1156
1208
  groups come from the rows.
1157
1209
  - **Not built:** collapsing groups, grouping by more than one key.
1210
+
1211
+ ## 22. A highlighted row, and rows that are done
1212
+
1213
+ One row of a table marked as **the one to look at**: next up, the
1214
+ current one. `DataTable`'s `highlightRow` returns true for it.
1215
+
1216
+ ```tsx
1217
+ <DataTable highlightRow={(book) => book.id === nextUpId} … />
1218
+ ```
1219
+
1220
+ - **A wash of info on every cell**, over the zebra stripe and the pinned
1221
+ columns' opaque backgrounds (`--table-highlight`, pre-mixed onto the
1222
+ page), and a bar in `info-text` at the row's start, so it reads without
1223
+ colour too. Still **say why in the row** (a "Next up" word or badge):
1224
+ the wash says "look here", not what it is.
1225
+ - **Muted text inside it is darkened for you.** `muted-foreground`
1226
+ measures 4.09:1 on the wash, under 4.5:1, and no lighter wash fixes it
1227
+ (4.47:1 even at 4%). So the row redefines `--muted-foreground` as
1228
+ `--table-highlight-muted-foreground`: 6.75:1 in light, the usual grey in
1229
+ dark (6.61:1). Use `text-muted-foreground` as anywhere else.
1230
+ - **At most a row or two.** A highlight on every other row highlights
1231
+ nothing; a state many rows share is a column (pattern 5).
1232
+ - **Rows that are done: colour, not opacity.** Greying finished rows with
1233
+ `opacity-50` (or `text-foreground/60`) drops their text under 4.5:1 the
1234
+ same way. Render their text in `text-muted-foreground`, which is
1235
+ measured, and keep the word that says done.
1236
+ - Storybook's `app/DataTable` Highlighted story has two highlighted rows
1237
+ (one striped), pinned columns and muted text inside them, under axe and
1238
+ the token check in both themes; `e2e/data-table.spec.ts` checks every
1239
+ cell wears the wash.
1240
+
@@ -346,3 +346,33 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
346
346
  - Storybook's `app/WidgetCard` shows three widgets on a page and every
347
347
  one in each state; its checks run axe and the token and glyph checks in
348
348
  both themes and at `large`.
349
+
350
+ ## Notices
351
+
352
+ A `Notice` says, across the page, a state that decides what you do with
353
+ what's on it: held, overdue, archived and read-only. It's louder than a
354
+ badge and stays until the state changes, unlike a toast. `EntityView`'s
355
+ `notice` is its usual place (under the header); any page can render one
356
+ under its own header.
357
+
358
+ ```tsx
359
+ <Notice tone="warning" title="On hold" action={<Button size="sm" variant="outline">Release the hold</Button>}>
360
+ Read “Hyperion” first: this one follows it.
361
+ </Notice>
362
+ ```
363
+
364
+ - **The tone is the state's**, from the same grouping as its badge
365
+ (warning for held, destructive for overdue, info for "up next", success
366
+ rarely: a good state seldom needs saying across the page). `icon`
367
+ swaps the tone's own icon for a better one (a lock for read-only).
368
+ - **A wash, not a fill:** the tone at 10% (20% in dark) with `foreground`
369
+ text and the tone's `*-text` icon. The message reads as body text, so
370
+ nothing inside it is muted. Storybook's `app/Notice` measures every
371
+ tone with axe and the glyph check in both themes.
372
+ - **One or two sentences and at most one action**: what the state means
373
+ and what to do about it. More than that belongs in a section.
374
+ - **Not an alert.** A notice there when the page opens is part of the
375
+ page, so it has no role of its own. `EntityView` keeps a `role="status"`
376
+ region in its place, so one appearing or clearing later is announced.
377
+ Elsewhere, put it in a status region that is rendered before it
378
+ changes.
@@ -90,6 +90,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
90
90
  * still get the column names; sorting moves to the toolbar.
91
91
  */
92
92
  header?: boolean;
93
+ /**
94
+ * The one row to look at (next up, the current one): tinted with info's
95
+ * wash, a bar in `info-text` at its start, and muted text inside it
96
+ * darkened so it still clears 4.5:1. Return true for at most a row or two.
97
+ * Colour isn't the only signal: say why in the row too (a badge, a word).
98
+ */
99
+ highlightRow?: (row: TData) => boolean;
93
100
  };
94
101
  export type DataTableWidth = 'full' | 'content';
95
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, }: DataTableProps<TData>): import("react").JSX.Element;
102
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -75,6 +75,13 @@ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-norma
75
75
  // background so it reads identically on striped and unstriped rows, and it
76
76
  // stays on screen when the table is scrolled right.
77
77
  const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
78
+ // `highlightRow`: the wash on every cell, so it covers the zebra stripe
79
+ // (on the row) and the pinned cells' opaque backgrounds (on the cell, and
80
+ // one class weaker than this), while a pinned cell's hover still wins. A
81
+ // bar on the first cell, as hover's is, so it reads without colour too.
82
+ // Inside, muted text takes the darker grey the wash needs (theme.css).
83
+ const HIGHLIGHTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-(--table-highlight) ' +
84
+ '[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--info-text)]';
78
85
  // `header={false}`: the header row stays in the table, so every cell keeps
79
86
  // its column header for a screen reader, but takes no room: each header
80
87
  // is its name in an sr-only span, in a cell with no height or padding.
@@ -150,7 +157,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
150
157
  }, [containerRef, isSuccessView, isOverflowing]);
151
158
  return { barRef, isOverflowing, scrollWidth };
152
159
  }
153
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, }) {
160
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, }) {
154
161
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
155
162
  // a cell that renders an <EditableValue> edits where it's shown, one at
156
163
  // a time, with the leave-page prompt while one is open, the same rules
@@ -211,7 +218,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
211
218
  const rows = table.getRowModel().rows;
212
219
  const visibleColumnCount = table.getVisibleLeafColumns().length;
213
220
  // One body row, the same in a plain table and a grouped one.
214
- const renderRow = (row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
221
+ const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
215
222
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
216
223
  ? { '--data-table-last-width': `${lastWidth}px` }
217
224
  : undefined, children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
@@ -67,7 +67,19 @@ export type EntityViewProps = {
67
67
  badges?: ReactNode;
68
68
  /** The header's actions: an Edit button, a Delete with its confirm dialog. */
69
69
  actions?: ReactNode;
70
+ /**
71
+ * A state that decides what you do with the record, said before
72
+ * anything else (a `<Notice>`): under the header, across the page at
73
+ * every width. Announced when it appears or changes after the page loads.
74
+ */
75
+ notice?: ReactNode;
70
76
  sections: EntityViewSection[];
77
+ /**
78
+ * Below every section (both columns in the rail layout), in small muted
79
+ * type outside any card: where the record's data comes from
80
+ * (`<SourceCredit>`), when its source's terms ask for a credit.
81
+ */
82
+ footer?: ReactNode;
71
83
  /** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
72
84
  layout?: EntityViewLayout;
73
85
  /** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
@@ -82,4 +94,4 @@ export type EntityViewProps = {
82
94
  label: string;
83
95
  };
84
96
  };
85
- export declare function EntityView({ title, titleEdit, badges, actions, sections, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
97
+ export declare function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -29,6 +29,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
29
29
  // autosave as it's typed (`autosave`), and be the editor itself, with no
30
30
  // click to open (`alwaysEditable`).
31
31
  //
32
+ // Around the sections: `notice` (a <Notice>) under the header, across the
33
+ // page, for a state that decides what you do with the record; `footer`
34
+ // (a <SourceCredit>) after them all, for crediting the data's source.
35
+ //
32
36
  // Two layouts. `column` (the default): every section in one column capped
33
37
  // at max-w-4xl. `rail`, for a record with long content: the page takes the
34
38
  // content area's whole width, sections placed `rail` go in a narrow column
@@ -136,7 +140,7 @@ function Rail({ label, sections }) {
136
140
  const [ref, fits] = useFitsWindow();
137
141
  return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
138
142
  }
139
- export function EntityView({ title, titleEdit, badges, actions, sections, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
143
+ export function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
140
144
  // Editing in place: one store per view, and the leave-page prompt only
141
145
  // while a field is open (or one is always editable).
142
146
  const [store] = useState(() => new EditInPlaceStore());
@@ -152,5 +156,5 @@ export function EntityView({ title, titleEdit, badges, actions, sections, layout
152
156
  return _jsx(EntityViewSkeleton, { layout: layout });
153
157
  const railSections = sections.filter((section) => section.placement === 'rail');
154
158
  const mainSections = sections.filter((section) => section.placement !== 'rail');
155
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title)))] }) }));
159
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
156
160
  }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type LucideIcon } from 'lucide-react';
3
+ export type NoticeTone = 'info' | 'success' | 'warning' | 'destructive';
4
+ export type NoticeProps = {
5
+ /** What kind of state it is (design-language.md "Semantic tones"). Defaults to `info`. */
6
+ tone?: NoticeTone;
7
+ /** A short lead in bold ("On hold"), before the message. */
8
+ title?: ReactNode;
9
+ /** The message: one or two sentences saying what to do about it. */
10
+ children: ReactNode;
11
+ /** One way to act on it: a link or a small button (`<Button size="sm" variant="outline">`). */
12
+ action?: ReactNode;
13
+ /** Another icon than the tone's own, when the state has a better one (a lock for read-only). */
14
+ icon?: LucideIcon;
15
+ className?: string;
16
+ };
17
+ export declare function Notice({ tone, title, children, action, icon, className }: NoticeProps): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CircleAlertIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react';
3
+ import { cn } from 'cn';
4
+ import { Alert, AlertDescription, AlertTitle } from '../ui/alert.js';
5
+ const TONE = {
6
+ info: { surface: 'border-info-text/30 bg-info/10 dark:bg-info/20', icon: 'text-info-text', Icon: InfoIcon },
7
+ success: {
8
+ surface: 'border-success-text/30 bg-success/10 dark:bg-success/20',
9
+ icon: 'text-success-text',
10
+ Icon: CircleCheckIcon,
11
+ },
12
+ warning: {
13
+ surface: 'border-warning-text/30 bg-warning/10 dark:bg-warning/20',
14
+ icon: 'text-warning-text',
15
+ Icon: TriangleAlertIcon,
16
+ },
17
+ destructive: {
18
+ surface: 'border-destructive-text/30 bg-destructive/10 dark:bg-destructive/20',
19
+ icon: 'text-destructive-text',
20
+ Icon: CircleAlertIcon,
21
+ },
22
+ };
23
+ export function Notice({ tone = 'info', title, children, action, icon, className }) {
24
+ const look = TONE[tone];
25
+ const Icon = icon ?? look.Icon;
26
+ return (_jsxs(Alert, { role: undefined, "data-slot": "notice", "data-tone": tone,
27
+ // Flex, not Alert's grid, so the action wraps under the message on a
28
+ // narrow screen instead of squeezing it.
29
+ className: cn('flex flex-wrap items-start gap-x-3 gap-y-2 px-4 py-3 text-foreground', look.surface, className), children: [_jsx("span", { className: cn('mt-0.5 shrink-0', look.icon), children: _jsx(Icon, { "aria-hidden": "true", className: "size-4" }) }), _jsxs("div", { className: "flex min-w-0 flex-1 basis-48 flex-col gap-0.5", children: [title && _jsx(AlertTitle, { className: "font-semibold", children: title }), _jsx(AlertDescription, { className: "text-foreground", children: children })] }), action && _jsx("div", { className: "flex shrink-0 items-center gap-2 pl-7 sm:pl-0", children: action })] }));
30
+ }
@@ -0,0 +1,12 @@
1
+ export type ProgressRingProps = {
2
+ /** How many are done. Clamped to 0…total. */
3
+ done: number;
4
+ /** How many there are. Zero of zero reads as not started. */
5
+ total: number;
6
+ /** What a screen reader hears. Defaults to "4 of 6 done", or "Not started" for zero of zero. */
7
+ label?: string;
8
+ /** Hide the count beside the ring (the ring's name still says it). */
9
+ hideCount?: boolean;
10
+ className?: string;
11
+ };
12
+ export declare function ProgressRing({ done, total, label, hideCount, className }: ProgressRingProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Done out of total (cell pattern 13's compact form): a small ring that
3
+ // fills clockwise from twelve o'clock, with the count beside it ("4/6").
4
+ // Narrow enough for a table column, readable on a view: a checklist, how
5
+ // far through an ordered list someone is, the steps of a process.
6
+ //
7
+ // The arc is the ink (currentColor: `foreground` while under way,
8
+ // `success-text` once complete), so it clears 3:1 on the page in both
9
+ // themes; the track is `border`, decoration only, the same ring empty.
10
+ // The count carries the value exactly, the ring roughly, and the whole is
11
+ // one image to a screen reader, named in words.
12
+ import { cn } from 'cn';
13
+ // 16×16, the stage circle's grid: a thick ring of radius 6 so the arc
14
+ // reads at 16px.
15
+ const R = 6;
16
+ const CIRCUMFERENCE = 2 * Math.PI * R;
17
+ const STROKE = 2.5;
18
+ export function ProgressRing({ done, total, label, hideCount = false, className }) {
19
+ const safeTotal = Math.max(0, total);
20
+ const safeDone = Math.min(Math.max(0, done), safeTotal);
21
+ const complete = safeTotal > 0 && safeDone === safeTotal;
22
+ const fraction = safeTotal === 0 ? 0 : safeDone / safeTotal;
23
+ const name = label ?? (safeTotal === 0 ? 'Not started' : `${safeDone} of ${safeTotal} done`);
24
+ return (_jsxs("span", { role: "img", "aria-label": name, "data-slot": "progress-ring", "data-complete": complete || undefined, className: cn('inline-flex shrink-0 items-center gap-1.5 tabular-nums', complete ? 'text-success-text' : 'text-foreground', className), children: [_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", className: "size-4 shrink-0 -rotate-90", children: [_jsx("circle", { cx: "8", cy: "8", r: R, strokeWidth: STROKE, className: "fill-none stroke-border" }), fraction > 0 && (_jsx("circle", { cx: "8", cy: "8", r: R, strokeWidth: STROKE, strokeDasharray: `${fraction * CIRCUMFERENCE} ${CIRCUMFERENCE}`,
25
+ // A full ring with a round cap would show a seam; butt it then.
26
+ strokeLinecap: complete ? 'butt' : 'round', className: "fill-none stroke-current" }))] }), !hideCount && safeTotal > 0 && (_jsxs("span", { "aria-hidden": "true", className: "type-caption", children: [safeDone, "/", safeTotal] }))] }));
27
+ }
@@ -0,0 +1,10 @@
1
+ export type RatingStarValue = 'full' | 'half' | 'empty';
2
+ export type RatingStarProps = {
3
+ value: RatingStarValue;
4
+ /** The value's word, shown beside the star ("Partly"). */
5
+ word?: string;
6
+ /** What a screen reader hears. Defaults to the word, else "Full star", "Half star" or "Empty star". */
7
+ label?: string;
8
+ className?: string;
9
+ };
10
+ export declare function RatingStar({ value, word, label, className }: RatingStarProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A three-value enum as one star (cell pattern 9's variant): yes, partly,
3
+ // no as a full, half or empty star, with the word beside it. The shape
4
+ // carries the value, so it survives greyscale. A half star alone reads as
5
+ // broken, so it's drawn as the left half filled over the whole outline.
6
+ //
7
+ // The star is the ink (currentColor, `foreground` by default; pass a
8
+ // `*-text` tone in `className` to tone it), clearing 3:1 on the page in
9
+ // both themes. One image to a screen reader, named in words.
10
+ import { useId } from 'react';
11
+ import { cn } from 'cn';
12
+ // Lucide's star (24×24), so it matches the icons around it.
13
+ const STAR = 'M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z';
14
+ const DEFAULT_NAME = {
15
+ full: 'Full star',
16
+ half: 'Half star',
17
+ empty: 'Empty star',
18
+ };
19
+ export function RatingStar({ value, word, label, className }) {
20
+ const clipId = useId();
21
+ return (_jsxs("span", { role: "img", "aria-label": label ?? word ?? DEFAULT_NAME[value], "data-slot": "rating-star", "data-value": value, className: cn('inline-flex shrink-0 items-center gap-1.5 text-foreground', className), children: [_jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", className: "size-4 shrink-0", children: [value === 'half' && (_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("rect", { x: "0", y: "0", width: "12", height: "24" }) }) })), value !== 'empty' && (_jsx("path", { d: STAR, className: "fill-current", clipPath: value === 'half' ? `url(#${clipId})` : undefined })), _jsx("path", { d: STAR, className: "fill-none stroke-current", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] }), word && _jsx("span", { "aria-hidden": "true", children: word })] }));
22
+ }
@@ -0,0 +1,14 @@
1
+ export type Source = {
2
+ /** The service's name, the link's text: "TMDB". */
3
+ name: string;
4
+ /** Its site; the link opens in a new tab. */
5
+ href: string;
6
+ /** A sentence the service's terms ask for, shown after the list as written. */
7
+ note?: string;
8
+ };
9
+ export type SourceCreditProps = {
10
+ sources: Source[];
11
+ /** What comes before the names. Defaults to "Data from". */
12
+ lead?: string;
13
+ };
14
+ export declare function SourceCredit({ sources, lead }: SourceCreditProps): import("react").JSX.Element | null;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Credits the outside services a record's data comes from, as many free
3
+ // APIs require on every screen showing their data ("This product uses the
4
+ // TMDB API but is not endorsed or certified by TMDB"). EntityView's
5
+ // `footer` is its usual home. Text and links only: a service's logo and
6
+ // its usage rules are the app's.
7
+ import { Fragment } from 'react';
8
+ import { ExternalLinkIcon } from 'lucide-react';
9
+ function joined(index, count) {
10
+ if (index === 0)
11
+ return '';
12
+ if (index === count - 1)
13
+ return count === 2 ? ' and ' : ', and ';
14
+ return ', ';
15
+ }
16
+ export function SourceCredit({ sources, lead = 'Data from' }) {
17
+ if (sources.length === 0)
18
+ return null;
19
+ const notes = sources.flatMap((source) => (source.note ? [source.note] : []));
20
+ return (_jsxs("p", { "data-slot": "source-credit", className: "type-caption text-muted-foreground", children: [lead, ' ', sources.map((source, index) => (_jsxs(Fragment, { children: [joined(index, sources.length), _jsxs("a", { href: source.href, target: "_blank", rel: "noreferrer", className: "rounded-sm underline underline-offset-2 outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", children: [source.name, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })] }, source.name))), ".", notes.map((note) => ` ${note}`).join('')] }));
21
+ }
@@ -0,0 +1,16 @@
1
+ export type StepBarProps = {
2
+ /** How many steps are filled, 0…steps. */
3
+ value: number;
4
+ /** How many steps the scale has. Defaults to 5. */
5
+ steps?: number;
6
+ /** The band's word, shown beside the bar ("High"). */
7
+ word?: string;
8
+ /**
9
+ * What a screen reader hears. Defaults to the word and the count
10
+ * ("High, 4 of 5"); pass the field's name in front when the cell
11
+ * doesn't give it ("Quality: High, 4 of 5").
12
+ */
13
+ label?: string;
14
+ className?: string;
15
+ };
16
+ export declare function StepBar({ value, steps, word, label, className }: StepBarProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // An ordered enum of a few bands, best to worst (cell pattern 9's
3
+ // variant): a short bar of steps, the first `value` filled, with the
4
+ // band's word beside it. Narrower than five stars, and reads as a scale,
5
+ // not a rating. "Quality: High" as ■■■■□ High.
6
+ //
7
+ // The filled steps are the ink (currentColor, `foreground`), clearing 3:1
8
+ // on the page in both themes; the empty steps are the track (`border`),
9
+ // deliberately faint and carrying nothing on their own: the count of
10
+ // filled steps and the word carry the value. One image to a screen
11
+ // reader, named with both ("Quality: High, 4 of 5").
12
+ import { cn } from 'cn';
13
+ // Each step 4×8 with a 1.5 gap, in a 16-high box, so the bar sits on the
14
+ // text's line like the other glyphs.
15
+ const STEP_WIDTH = 4;
16
+ const STEP_GAP = 1.5;
17
+ const STEP_HEIGHT = 8;
18
+ export function StepBar({ value, steps = 5, word, label, className }) {
19
+ const filled = Math.min(Math.max(0, Math.round(value)), steps);
20
+ const width = steps * STEP_WIDTH + (steps - 1) * STEP_GAP;
21
+ const name = label ?? `${word ? `${word}, ` : ''}${filled} of ${steps}`;
22
+ return (_jsxs("span", { role: "img", "aria-label": name, "data-slot": "step-bar", "data-value": filled, className: cn('inline-flex shrink-0 items-center gap-1.5 text-foreground', className), children: [_jsx("svg", { viewBox: `0 0 ${width} 16`, "aria-hidden": "true", className: "h-4 shrink-0", style: { width: `${width / 16}rem` }, children: Array.from({ length: steps }, (_, step) => (_jsx("rect", { x: step * (STEP_WIDTH + STEP_GAP), y: (16 - STEP_HEIGHT) / 2, width: STEP_WIDTH, height: STEP_HEIGHT, rx: "1", className: step < filled ? 'fill-current' : 'fill-border' }, step))) }), word && (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: word }))] }));
23
+ }
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare const alertVariants: (props?: ({
4
+ variant?: "default" | "destructive" | null | undefined;
5
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
+ declare function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>): React.JSX.Element;
7
+ declare function AlertTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
8
+ declare function AlertDescription({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
9
+ declare function AlertAction({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
10
+ export { Alert, AlertTitle, AlertDescription, AlertAction };
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cva } from "class-variance-authority";
4
+ import { cn } from "cn";
5
+ const alertVariants = cva("group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", {
6
+ variants: {
7
+ variant: {
8
+ default: "bg-card text-card-foreground",
9
+ // Patched (ui-foundation): upstream's text-destructive is the solid
10
+ // fill's shade, 3.2:1 on the dark card; text is --destructive-text.
11
+ destructive: "bg-card text-destructive-text *:data-[slot=alert-description]:text-destructive-text *:[svg]:text-current",
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ variant: "default",
16
+ },
17
+ });
18
+ function Alert({ className, variant, ...props }) {
19
+ return (_jsx("div", { "data-slot": "alert", role: "alert", className: cn(alertVariants({ variant }), className), ...props }));
20
+ }
21
+ function AlertTitle({ className, ...props }) {
22
+ return (_jsx("div", { "data-slot": "alert-title", className: cn("font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className), ...props }));
23
+ }
24
+ function AlertDescription({ className, ...props }) {
25
+ return (_jsx("div", { "data-slot": "alert-description", className: cn("text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", className), ...props }));
26
+ }
27
+ function AlertAction({ className, ...props }) {
28
+ return (_jsx("div", { "data-slot": "alert-action", className: cn("absolute top-2 right-2", className), ...props }));
29
+ }
30
+ export { Alert, AlertTitle, AlertDescription, AlertAction };
package/dist/index.d.ts CHANGED
@@ -22,6 +22,11 @@ export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
22
22
  export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
23
23
  export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
24
24
  export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
25
+ export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
26
+ export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
27
+ export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
28
+ export { StepBar, type StepBarProps } from './components/app/step-bar.js';
29
+ export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
25
30
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
26
31
  export { ThemeProvider } from './components/theme-provider.js';
27
32
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
package/dist/index.js CHANGED
@@ -27,6 +27,11 @@ export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
27
27
  export { PillFilter } from './components/app/pill-filter.js';
28
28
  export { SecondaryFilters } from './components/app/secondary-filters.js';
29
29
  export { StageCircle, } from './components/app/stage-circle.js';
30
+ export { Notice } from './components/app/notice.js';
31
+ export { SourceCredit } from './components/app/source-credit.js';
32
+ export { ProgressRing } from './components/app/progress-ring.js';
33
+ export { StepBar } from './components/app/step-bar.js';
34
+ export { RatingStar } from './components/app/rating-star.js';
30
35
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
31
36
  export { ThemeProvider } from './components/theme-provider.js';
32
37
  // Screens outside the app shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.9.1",
3
+ "version": "4.11.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": {
package/styles/theme.css CHANGED
@@ -229,6 +229,13 @@
229
229
  matches the row without letting scrolled columns show through. Mixed
230
230
  in sRGB, as the browser composites the translucent stripe. */
231
231
  --table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
232
+ /* A highlighted row (DataTable's highlightRow, #156): info's wash
233
+ pre-mixed onto the page, opaque so pinned cells can wear it. Muted
234
+ text measures 4.09:1 on it (below 4.5 at 12px; no lighter tint gets
235
+ there: 4.47 at 4%), so inside the row muted text takes a darker grey:
236
+ 6.75:1. In dark the usual muted already clears it (6.61:1). */
237
+ --table-highlight: color-mix(in srgb, var(--info) 10%, var(--background));
238
+ --table-highlight-muted-foreground: var(--gray-600);
232
239
  --border: var(--gray-200);
233
240
  --input: var(--gray-200);
234
241
  --ring: var(--gray-400);
@@ -358,6 +365,8 @@
358
365
  --category-11: var(--category-11-400);
359
366
  --category-12: var(--category-12-400);
360
367
  --table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
368
+ --table-highlight: color-mix(in srgb, var(--info) 20%, var(--background));
369
+ --table-highlight-muted-foreground: var(--gray-400); /* the usual muted: not var(--muted-foreground), which the row redefines from this */
361
370
  --border: var(--white-a10);
362
371
  --input: var(--white-a15);
363
372
  --ring: var(--gray-500);