@tristan2828/ui-foundation 3.22.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.
- package/conventions/docs/add-an-entity.md +12 -1
- package/conventions/docs/cell-patterns.md +74 -8
- package/conventions/docs/design-language.md +4 -0
- package/conventions/docs/entity-plan-template.md +21 -8
- package/dist/components/app/count-link.d.ts +14 -0
- package/dist/components/app/count-link.js +20 -0
- package/dist/components/app/entity-view.d.ts +20 -2
- package/dist/components/app/entity-view.js +84 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -10,14 +10,14 @@ 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.
|
|
14
|
-
and
|
|
15
|
-
|
|
16
|
-
package's Storybook renders
|
|
17
|
-
`patterns/CellPatterns`), and
|
|
18
|
-
contrast in both themes. The
|
|
19
|
-
marked **Unproven**: written ahead of a
|
|
20
|
-
column, so no app has tested them on real data yet. Prefer a proven
|
|
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
|
|
18
|
+
its checks pin their accessibility and contrast in both themes. The
|
|
19
|
+
other patterns from 11 on are marked **Unproven**: written ahead of a
|
|
20
|
+
real column, so no app has tested them on real data yet. Prefer a proven
|
|
21
21
|
pattern when one fits. When you use an unproven one, say so in the
|
|
22
22
|
entity's plan, and raise what you learned for the foundation, so the
|
|
23
23
|
entry can lose its label. A pattern that proves generic is worth raising
|
|
@@ -836,3 +836,69 @@ cell: ({ getValue }) => {
|
|
|
836
836
|
`<EditableValue>` like a badge.
|
|
837
837
|
- **Cost:** a reader learns the shapes once. Until then the word does the
|
|
838
838
|
work, which is why it stays.
|
|
839
|
+
|
|
840
|
+
## 20. Count linking to the related records
|
|
841
|
+
|
|
842
|
+
A count of the records in another table that point at this row (open
|
|
843
|
+
Tasks per Project, items per Tag), which opens that table already filtered
|
|
844
|
+
to them. The count is a `computed` integer (the entity plan's type).
|
|
845
|
+
The package ships it: `CountLink`, rendered in its Storybook
|
|
846
|
+
(`app/CountLink`).
|
|
847
|
+
|
|
848
|
+
```tsx
|
|
849
|
+
import { CountLink } from '@tristan2828/ui-foundation'
|
|
850
|
+
|
|
851
|
+
cell: ({ row }) => {
|
|
852
|
+
const { id, name, openTaskCount: count } = row.original
|
|
853
|
+
return (
|
|
854
|
+
<CountLink
|
|
855
|
+
count={count}
|
|
856
|
+
to={`/tasks?project=${id}`}
|
|
857
|
+
label={`open ${count === 1 ? 'task' : 'tasks'} in ${name}`}
|
|
858
|
+
/>
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
What the component decides, so a cell doesn't have to:
|
|
864
|
+
|
|
865
|
+
- **Underlined at rest**, not only on hover as in pattern 16. A title
|
|
866
|
+
reads as a name to open; a bare number doesn't look like it goes
|
|
867
|
+
anywhere until it's underlined. Hover thickens the line. Foreground,
|
|
868
|
+
not `text-link`: `--link` is for links in written text, and a column of
|
|
869
|
+
blue numbers pulls the eye from every other column.
|
|
870
|
+
- **Zero is a plain `0`**, not a link (a link to an empty list is a dead
|
|
871
|
+
end) and not an em dash: zero is a value (pattern 11). Same
|
|
872
|
+
`tabular-nums`, so it lines up with the links.
|
|
873
|
+
- **The accessible name is the count, then `label`**: `3 open tasks in
|
|
874
|
+
Kitchen remodel`. Every row's link text is just a number, so `3` alone
|
|
875
|
+
isn't distinct, and starting with it keeps the link findable by what
|
|
876
|
+
it shows (speech input, `getByRole('link', { name: /^3 / })`).
|
|
877
|
+
|
|
878
|
+
What the cell decides:
|
|
879
|
+
|
|
880
|
+
- **`label` says what's counted and which row**, singular for one
|
|
881
|
+
(`open task in Garden`).
|
|
882
|
+
- **The target is the other entity's list, filtered by the URL**:
|
|
883
|
+
`/<other>?<filterName>=<id>`, the same query string `useTableUrlState`
|
|
884
|
+
reads (a multi-value filter takes the id the same way). Back, forward,
|
|
885
|
+
a refresh and saved views keep working, and the other list needs a
|
|
886
|
+
filter by this reference (`Filter: yes` in its plan).
|
|
887
|
+
- **The count and the list it opens must agree.** If the target list
|
|
888
|
+
narrows by default (hides finished records unless asked), the count
|
|
889
|
+
applies the same condition, and the word in the name says so (`open`
|
|
890
|
+
tasks). Where the list has a filter for that condition, prefer putting
|
|
891
|
+
it in the link too (`?project=7&status=open`), so the list shows what
|
|
892
|
+
was counted even if its default changes. Say the rule in the field's
|
|
893
|
+
`description` in `openapi.yaml` (`Tasks in this project that aren't
|
|
894
|
+
done; what /tasks?project=<id> lists`), so the backend, the mocks and
|
|
895
|
+
the next agent count the same thing.
|
|
896
|
+
- **Sorted on the server, as a number**, like any computed field: the
|
|
897
|
+
scalar subquery is the sort expression, with a stable tiebreak (the
|
|
898
|
+
name, then the id) so rows with the same count don't change places
|
|
899
|
+
between pages.
|
|
900
|
+
- **Cost:** one correlated subquery per row read. Fine at an app's scale;
|
|
901
|
+
give the other table's foreign key column an index, since every row's
|
|
902
|
+
count reads it.
|
|
903
|
+
- **On the record's view**, the same `CountLink` beside its label (`Open
|
|
904
|
+
tasks: 3`), with the record's name in `label` like the table's.
|
|
@@ -194,6 +194,10 @@ shade: note `info`, tip `success`, important category slot 2, warning
|
|
|
194
194
|
`warning`, caution `destructive`. The package draws all of it; an app
|
|
195
195
|
only renders `<Markdown>` or the editor.
|
|
196
196
|
|
|
197
|
+
`--link` is for written text only. A link in a table cell is
|
|
198
|
+
`text-foreground` and underlined, never blue: a column of coloured text
|
|
199
|
+
pulls the eye from every other column (cell patterns 16 and 20).
|
|
200
|
+
|
|
197
201
|
## Density
|
|
198
202
|
|
|
199
203
|
Tables have three densities, set with `data-density` on **any ancestor**
|
|
@@ -43,7 +43,8 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
43
43
|
every read and never stores it — give its rule, and if it's a choice,
|
|
44
44
|
its values in sort order with a label for each; a read-only column, a filter and a sort the
|
|
45
45
|
server evaluates, never on the form). A count of related records is a
|
|
46
|
-
`computed` integer
|
|
46
|
+
`computed` integer; say whether its column links to those records
|
|
47
|
+
(cell pattern 20) and which of them it counts (`open tasks`).
|
|
47
48
|
- *Close variants* (built by a small, stated change to the nearest
|
|
48
49
|
pattern): `integer` (from decimal), `url` (from email — a format
|
|
49
50
|
check), `date` (from date-time — no time part).
|
|
@@ -122,15 +123,27 @@ except the quick actions, if any.
|
|
|
122
123
|
linked from the table: `title`.
|
|
123
124
|
- Badges beside the title: a few status-like fields, as badges: `status`,
|
|
124
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
|
+
|
|
125
136
|
- Sections, in order. Each lists its fields in the order they show. A
|
|
126
137
|
section holding a single long text or a single sub-records list shows
|
|
127
|
-
it under the section's heading, with no label of its own.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
|
132
|
-
|
|
133
|
-
|
|
|
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 |
|
|
134
147
|
|
|
135
148
|
- "Not set" labels: what an empty optional field reads as on the view
|
|
136
149
|
(`dueDate`: No due date; `checklist`: No items). A field not listed here
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type To } from 'react-router';
|
|
2
|
+
export interface CountLinkProps {
|
|
3
|
+
/** How many related records there are. Count what `to` lists. */
|
|
4
|
+
count: number;
|
|
5
|
+
/** The other entity's list, filtered to this row: `/tasks?project=7`. */
|
|
6
|
+
to: To;
|
|
7
|
+
/**
|
|
8
|
+
* What's counted and for which row, read after the count: `open tasks in
|
|
9
|
+
* Kitchen remodel` (`open task` for one).
|
|
10
|
+
*/
|
|
11
|
+
label: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function CountLink({ count, to, label, className }: CountLinkProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// A count linking to the related records (cell pattern 20): how many
|
|
3
|
+
// records of another entity point at this row, as a link to that entity's
|
|
4
|
+
// list filtered to them (`/tasks?project=7`, the query string
|
|
5
|
+
// useTableUrlState reads).
|
|
6
|
+
//
|
|
7
|
+
// - Underlined at rest: a bare number doesn't look like it goes anywhere.
|
|
8
|
+
// Foreground, not text-link: --link is for written text.
|
|
9
|
+
// - Zero is a plain 0, not a link (a list of nothing is a dead end) and
|
|
10
|
+
// not an em dash (zero is a value).
|
|
11
|
+
// - The accessible name is the count, then `label`, so every row's link is
|
|
12
|
+
// distinct ("3 open tasks in Kitchen remodel") and still starts with
|
|
13
|
+
// what it shows.
|
|
14
|
+
import { Link } from 'react-router';
|
|
15
|
+
import { cn } from 'cn';
|
|
16
|
+
export function CountLink({ count, to, label, className }) {
|
|
17
|
+
if (count === 0)
|
|
18
|
+
return _jsx("span", { className: cn('tabular-nums', className), children: "0" });
|
|
19
|
+
return (_jsx(Link, { to: to, "aria-label": `${count} ${label}`, className: cn('rounded-sm tabular-nums text-foreground underline underline-offset-4 outline-none', 'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50', className), children: count }));
|
|
20
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
|
|
2
|
+
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
2
3
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
4
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
|
-
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';
|
|
5
6
|
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
|
|
6
7
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
7
8
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// points (`/gateway`, `/mocks`, `/testing`) so each stays behind its boundary.
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell } from './components/app/app-shell.js';
|
|
7
|
+
export { CountLink } from './components/app/count-link.js';
|
|
7
8
|
export { DataTable } from './components/app/data-table.js';
|
|
8
9
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
10
|
export { EntityView, } from './components/app/entity-view.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
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": {
|