@tristan2828/ui-foundation 3.23.0 → 3.24.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.
@@ -241,6 +241,13 @@ is a filled-in example while the Widgets demo is still here).
241
241
  section that holds one long text or one sub-record list is a block
242
242
  (`content`) with no label of its own, since a label would only repeat
243
243
  the heading.
244
+ - **Layout.** With the plan's `Layout: rail`, pass `layout="rail"` and
245
+ give each section its Placement (`placement: 'rail'`; `main` is the
246
+ default and needs nothing). Without a `Layout` line, drop `layout`
247
+ and every `placement` from the copied view (one column). Widget's
248
+ view is `rail`, with Details in the rail. The rail's sections come
249
+ first in the page (header, rail, main) at every width, so the
250
+ view's specs find them first too.
244
251
  - **Every value renders the way its table cell does**: badges, names
245
252
  of linked records, a done-count, from the shared lookups in
246
253
  `<entity>-format.ts`. Never re-derive one for the view.
@@ -431,7 +438,11 @@ is a filled-in example while the Widgets demo is still here).
431
438
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
432
439
  shows as text. Add the view's spec to `playwright.config.ts`'s
433
440
  `mobile-chrome` `testMatch`, with a test that at phone width each
434
- label sits above its value and nothing scrolls sideways.
441
+ label sits above its value and nothing scrolls sideways. With
442
+ `Layout: rail`, that test also checks the rail's sections come before
443
+ the main ones, and another that on a wide screen the main column
444
+ starts beside the rail (copy `wide screens: …` in
445
+ `e2e/widget-view.spec.ts`).
435
446
  States forced via a first-load init script (loading, load-time error)
436
447
  need `page.addInitScript` before navigation; states forced after the
437
448
  page is already up (a mutation's error response) can use a
@@ -123,15 +123,27 @@ except the quick actions, if any.
123
123
  linked from the table: `title`.
124
124
  - Badges beside the title: a few status-like fields, as badges: `status`,
125
125
  `priority`.
126
+ - Layout (optional; delete for one column): `rail` for a record with long
127
+ content (notes written as Markdown, long lists). The page takes the
128
+ screen's whole width, the short sections (a summary, label/value fields,
129
+ a few links) sit in a narrow column on the right that stays in view, and
130
+ the long ones fill the main column beside it. On a phone: one column,
131
+ the rail's sections first. Without this line, every section is in one
132
+ column, as wide as a long read.
133
+
134
+ Layout: rail
135
+
126
136
  - Sections, in order. Each lists its fields in the order they show. A
127
137
  section holding a single long text or a single sub-records list shows
128
- it under the section's heading, with no label of its own.
129
-
130
- | Section | Fields |
131
- |---|---|
132
- | Details | dueDate, project, tags |
133
- | Checklist | checklist |
134
- | Notes | notes |
138
+ it under the section's heading, with no label of its own. With
139
+ `Layout: rail`, Placement says which column each goes in (`rail` or
140
+ `main`); otherwise delete the column.
141
+
142
+ | Section | Fields | Placement |
143
+ |---|---|---|
144
+ | Details | dueDate, project, tags | rail |
145
+ | Checklist | checklist | main |
146
+ | Notes | notes | main |
135
147
 
136
148
  - "Not set" labels: what an empty optional field reads as on the view
137
149
  (`dueDate`: No due date; `checklist`: No items). A field not listed here
@@ -21,7 +21,7 @@ export type EntityViewField = {
21
21
  * only repeat the heading. A block that's empty shows `emptyLabel`, the
22
22
  * same rule as a field.
23
23
  */
24
- export type EntityViewSection = {
24
+ export type EntityViewSection = ({
25
25
  /** The section's heading (an <h2>). Unique on the page. */
26
26
  title: string;
27
27
  fields: EntityViewField[];
@@ -31,7 +31,21 @@ export type EntityViewSection = {
31
31
  emptyLabel?: string;
32
32
  /** The block is editable in place (long text), named by the section's title. */
33
33
  edit?: EditInPlace;
34
+ }) & {
35
+ /**
36
+ * Where the section goes in the `rail` layout: `rail` for the short ones
37
+ * (a summary, label/value fields, a few links), `main` (the default) for
38
+ * long text and long lists. The `column` layout ignores it.
39
+ */
40
+ placement?: EntityViewPlacement;
34
41
  };
42
+ export type EntityViewPlacement = 'main' | 'rail';
43
+ /**
44
+ * `column` (the default): one column, at most max-w-4xl wide. `rail`: the
45
+ * content area's whole width, with the sections placed `rail` in a narrow
46
+ * column on the right (one column on a narrow screen, rail first).
47
+ */
48
+ export type EntityViewLayout = 'column' | 'rail';
35
49
  export type EntityViewProps = {
36
50
  /** The record's name or title, the page's <h1>. */
37
51
  title?: string;
@@ -48,6 +62,10 @@ export type EntityViewProps = {
48
62
  /** The header's actions: an Edit button, a Delete with its confirm dialog. */
49
63
  actions?: ReactNode;
50
64
  sections: EntityViewSection[];
65
+ /** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
66
+ layout?: EntityViewLayout;
67
+ /** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
68
+ railLabel?: string;
51
69
  isLoading: boolean;
52
70
  /** A `notfound` error (including another user's record) shows "Not found" and a way back, never a retry. */
53
71
  error?: AppError | null;
@@ -58,4 +76,4 @@ export type EntityViewProps = {
58
76
  label: string;
59
77
  };
60
78
  };
61
- export declare function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
79
+ export declare function EntityView({ title, titleEdit, badges, actions, sections, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -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
  // Generic read-only view of one record, the read-side partner of
3
3
  // EntityForm: the record's title with a few status badges beside it, an
4
4
  // actions area (Edit, Delete), and sections of label/value rows laid out to
@@ -24,9 +24,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  // time, the leave-page prompt); EditableValue is each value's side, and a
25
25
  // header badge uses it directly. A field not marked renders exactly as
26
26
  // before. Edit, in `actions`, stays for everything else.
27
- import { useId, useState, useSyncExternalStore } from 'react';
27
+ //
28
+ // Two layouts. `column` (the default): every section in one column capped
29
+ // at max-w-4xl. `rail`, for a record with long content: the page takes the
30
+ // content area's whole width, sections placed `rail` go in a narrow column
31
+ // on the right that stays in view while the main column scrolls (an issue
32
+ // tracker's shape), and the rest fill the main column. Below the width two
33
+ // columns need, one column: header, rail, main. The DOM is in that order at
34
+ // every width, so reading and tab order match the screen.
35
+ import { useEffect, useId, useState, useSyncExternalStore } from 'react';
28
36
  import { Link } from 'react-router';
29
37
  import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
38
+ import { cn } from 'cn';
30
39
  import { ErrorState } from './error-state.js';
31
40
  import { Button } from '../ui/button.js';
32
41
  import { Card, CardContent, CardHeader } from '../ui/card.js';
@@ -50,18 +59,79 @@ function orEmptyLabel(value, emptyLabel) {
50
59
  return isEmptyValue(value) ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel ?? DEFAULT_EMPTY_LABEL })) : (value);
51
60
  }
52
61
  // Label beside value from `sm` up; below it (a phone) the label sits
53
- // above its value, so nothing is squeezed into a narrow second column.
54
- function FieldRow({ field }) {
55
- return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
62
+ // above its value, so nothing is squeezed into a narrow second column. In
63
+ // the rail the list's own width decides instead (a container query on the
64
+ // <dl>): label above value in the narrow rail, beside it once the rail's
65
+ // sections stack full width under the header.
66
+ const FIELD_ROW = {
67
+ page: 'sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6',
68
+ rail: '@lg/fields:grid @lg/fields:grid-cols-[minmax(8rem,12rem)_1fr] @lg/fields:gap-6',
69
+ };
70
+ function FieldRow({ field, inRail }) {
71
+ return (_jsxs("div", { className: cn('flex flex-col gap-1 py-3 first:pt-0 last:pb-0', FIELD_ROW[inRail ? 'rail' : 'page']), children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
56
72
  }
57
- function ViewSection({ section }) {
73
+ function ViewSection({ section, inRail = false }) {
58
74
  const headingId = useId();
59
- return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
75
+ return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: cn('flex flex-col divide-y divide-border', inRail && '@container/fields'), children: section.fields.map((field) => (_jsx(FieldRow, { field: field, inRail: inRail }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
76
+ }
77
+ // The rail layout's columns. The view is a container, so the switch to
78
+ // two columns follows the width the page actually has (the sidebar open
79
+ // or not), not the window's: two from 56rem (896px), where the main
80
+ // column still gets ~32rem beside a 22rem rail. Below that, one column in
81
+ // DOM order (header, rail, main); from it, the grid puts the rail on the
82
+ // right of row 1 and main on its left, both starting under the header.
83
+ const RAIL_LAYOUT = {
84
+ page: '@container/view flex min-w-0 flex-col gap-6',
85
+ columns: 'flex flex-col gap-6 @4xl/view:grid @4xl/view:grid-cols-[minmax(0,1fr)_22rem] @4xl/view:items-start',
86
+ rail: '@4xl/view:col-start-2 @4xl/view:row-start-1',
87
+ main: 'flex min-w-0 flex-col gap-6 @4xl/view:col-start-1 @4xl/view:row-start-1',
88
+ // Main alone (no rail sections): the whole row.
89
+ mainAlone: '@4xl/view:col-span-2',
90
+ // Sticky only beside main, and only while the whole rail fits on screen
91
+ // (`data-fits`, measured): a taller one scrolls with the page, so its end
92
+ // is never stuck below the fold.
93
+ sticky: '@4xl/view:data-[fits]:sticky @4xl/view:data-[fits]:top-6',
94
+ };
95
+ function SkeletonCard({ rows, stacked }) {
96
+ return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: rows }, (_, row) => (_jsxs("div", { className: cn('flex flex-col gap-1', !stacked && 'sm:flex-row sm:gap-6'), children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: cn('h-5', stacked ? 'w-full' : 'flex-1') })] }, row))) })] }));
97
+ }
98
+ // The page's shape before the record arrives, in the layout it will have,
99
+ // so nothing jumps when it loads.
100
+ function EntityViewSkeleton({ layout }) {
101
+ const header = (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] })] }));
102
+ if (layout === 'rail') {
103
+ return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [_jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), _jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) })] })] }));
104
+ }
105
+ return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [header, Array.from({ length: 2 }, (_, section) => (_jsx(SkeletonCard, { rows: 4, stacked: false }, section)))] }));
106
+ }
107
+ // The rail's top offset (top-6) plus as much again below it.
108
+ const RAIL_MARGIN_PX = 48;
109
+ // Whether `element` fits in the window with room around it, re-measured
110
+ // whenever it or the window changes size (a field opening in place, a
111
+ // rotated phone).
112
+ function useFitsWindow() {
113
+ const [element, setElement] = useState(null);
114
+ const [fits, setFits] = useState(false);
115
+ useEffect(() => {
116
+ if (!element)
117
+ return;
118
+ const measure = () => setFits(element.offsetHeight + RAIL_MARGIN_PX <= window.innerHeight);
119
+ // A ResizeObserver reports once as soon as it observes: the first measure.
120
+ const observer = new ResizeObserver(measure);
121
+ observer.observe(element);
122
+ window.addEventListener('resize', measure);
123
+ return () => {
124
+ observer.disconnect();
125
+ window.removeEventListener('resize', measure);
126
+ };
127
+ }, [element]);
128
+ return [setElement, fits];
60
129
  }
61
- function EntityViewSkeleton() {
62
- return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
130
+ function Rail({ label, sections }) {
131
+ const [ref, fits] = useFitsWindow();
132
+ 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))) }));
63
133
  }
64
- export function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }) {
134
+ export function EntityView({ title, titleEdit, badges, actions, sections, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
65
135
  // Editing in place: one store per view, and the leave-page prompt only
66
136
  // while a field is open.
67
137
  const [store] = useState(() => new EditInPlaceStore());
@@ -74,6 +144,8 @@ export function EntityView({ title, titleEdit, badges, actions, sections, isLoad
74
144
  if (error)
75
145
  return _jsx(ErrorState, { error: error, onRetry: onRetry });
76
146
  if (isLoading)
77
- return _jsx(EntityViewSkeleton, {});
78
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [openField !== null && _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 })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }) }));
147
+ return _jsx(EntityViewSkeleton, { layout: layout });
148
+ const railSections = sections.filter((section) => section.placement === 'rail');
149
+ const mainSections = sections.filter((section) => section.placement !== 'rail');
150
+ 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: [openField !== null && _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)))] }) }));
79
151
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { AppShell, type AppShellProps, type NavItem } from './components/app/app
2
2
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
3
3
  export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
4
4
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
5
- export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
5
+ export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
6
6
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
7
7
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
8
8
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.23.0",
3
+ "version": "3.24.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": {