@tristan2828/ui-foundation 4.9.1 → 4.10.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.
@@ -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.
@@ -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,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,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,8 @@ 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';
25
27
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
26
28
  export { ThemeProvider } from './components/theme-provider.js';
27
29
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
package/dist/index.js CHANGED
@@ -27,6 +27,8 @@ 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';
30
32
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
31
33
  export { ThemeProvider } from './components/theme-provider.js';
32
34
  // 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.10.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": {