@tristan2828/ui-foundation 4.46.0 → 4.47.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 +16 -11
- package/conventions/docs/design-language.md +22 -11
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +13 -8
- package/dist/components/app/module-card.d.ts +11 -2
- package/dist/components/app/module-card.js +45 -5
- package/dist/index.d.ts +2 -2
- package/package.json +1 -1
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
6
|
- [The reference files](#the-reference-files) · lines 37–59
|
|
7
|
-
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–
|
|
7
|
+
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–720
|
|
8
8
|
- Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
|
|
9
9
|
- Step 2: `npm run gen:api` · lines 107–108
|
|
10
10
|
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
|
|
11
11
|
- Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 121–130
|
|
12
12
|
- Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
|
|
13
|
-
- Step 6: Copy the reference files, one for one, not just the two screens · lines 136–
|
|
14
|
-
- Step 7: Register routes · lines
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
19
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
20
|
-
- [Deleting](#deleting) · lines
|
|
21
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
13
|
+
- Step 6: Copy the reference files, one for one, not just the two screens · lines 136–612
|
|
14
|
+
- Step 7: Register routes · lines 613–623
|
|
15
|
+
- Step 8: Add Playwright specs for all three screens · lines 624–702
|
|
16
|
+
- Step 9: Register the nav entry and route names · lines 703–717
|
|
17
|
+
- Step 10: `npm run verify`. · lines 718–720
|
|
18
|
+
- [What not to copy](#what-not-to-copy) · lines 722–748
|
|
19
|
+
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 750–767
|
|
20
|
+
- [Deleting](#deleting) · lines 769–798
|
|
21
|
+
- [Fields only a script writes](#fields-only-a-script-writes) · lines 800–821
|
|
22
22
|
|
|
23
23
|
<!-- /index -->
|
|
24
24
|
|
|
@@ -409,7 +409,12 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
409
409
|
and every `placement` from the copied view (one column). Widget's
|
|
410
410
|
view is `rail`, with Details in the rail. The rail's sections come
|
|
411
411
|
first in the page (header, rail, main) at every width, so the
|
|
412
|
-
view's specs find them first too.
|
|
412
|
+
view's specs find them first too. When the plan says the main
|
|
413
|
+
column comes first on a phone (the record is opened for its notes
|
|
414
|
+
or its checklist), pass `mobileOrder="main-first"`: header, main,
|
|
415
|
+
rail where the columns stack, and in the DOM at every width, with
|
|
416
|
+
the rail still on the right two columns wide. Never re-order
|
|
417
|
+
`sections` by the window's width yourself.
|
|
413
418
|
- **A subtitle** (`subtitle`), only when the plan names a line that
|
|
414
419
|
says what the record is for (a goal, a one-line summary): under the
|
|
415
420
|
title in muted body type. When the plan marks it editable, wrap it
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
- [Written text (notes)](#written-text-notes) · lines 277–328
|
|
14
14
|
- [Density](#density) · lines 330–387
|
|
15
15
|
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 389–503
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [A list as a board](#a-list-as-a-board) · lines
|
|
20
|
-
- [A list as cards](#a-list-as-cards) · lines
|
|
21
|
-
- [Key numbers on a record](#key-numbers-on-a-record) · lines
|
|
22
|
-
- [Picking at random](#picking-at-random) · lines
|
|
23
|
-
- [Filling in what's missing](#filling-in-whats-missing) · lines
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–574
|
|
17
|
+
- [Notices](#notices) · lines 576–604
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 606–639
|
|
19
|
+
- [A list as a board](#a-list-as-a-board) · lines 641–692
|
|
20
|
+
- [A list as cards](#a-list-as-cards) · lines 694–740
|
|
21
|
+
- [Key numbers on a record](#key-numbers-on-a-record) · lines 742–764
|
|
22
|
+
- [Picking at random](#picking-at-random) · lines 766–797
|
|
23
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 799–849
|
|
24
24
|
|
|
25
25
|
<!-- /index -->
|
|
26
26
|
|
|
@@ -557,10 +557,21 @@ const { data, isLoading, error, refetch } = useProjectsSummaryQuery()
|
|
|
557
557
|
each tile one link to its place (at least 44px tall); Retry stays its
|
|
558
558
|
own button above it. Six places make the phone's 3 × 2 grid; past
|
|
559
559
|
that, put the rest behind one tile ("More") rather than a third row.
|
|
560
|
+
- **A row of chips on a phone** (`<ModuleStrip mobile="row">`), when the
|
|
561
|
+
hub's widgets should start on the first screen, or there are more
|
|
562
|
+
places than six tiles: below the sidebar's breakpoint the strip is one
|
|
563
|
+
row that scrolls sideways, a chip per place with its icon in its slot's
|
|
564
|
+
colour, its name and its number (no note, no action). While the number
|
|
565
|
+
loads, a small skeleton; on an error, a muted dash ("Couldn't load" to
|
|
566
|
+
a screen reader), with Retry left to the desktop card and the place's
|
|
567
|
+
own page. The row scrolls inside itself and the page never does. A
|
|
568
|
+
desktop shows the cards either way, so don't render your own row
|
|
569
|
+
beside a strip hidden on a phone.
|
|
560
570
|
- Storybook's `app/ModuleCard` shows a hub with a strip and a widget,
|
|
561
|
-
every state,
|
|
562
|
-
|
|
563
|
-
desktop and a phone
|
|
571
|
+
every state, both at `compact`, and both again as a row of chips (Row,
|
|
572
|
+
Row states); its checks run axe and the token and glyph checks in both
|
|
573
|
+
themes and at `large`, measure the strip at a desktop and a phone
|
|
574
|
+
width, and run axe on the chips at a phone's.
|
|
564
575
|
|
|
565
576
|
## Notices
|
|
566
577
|
|
|
@@ -52,6 +52,11 @@ export type EntityViewPlacement = 'main' | 'rail';
|
|
|
52
52
|
* column on the right (one column on a narrow screen, rail first).
|
|
53
53
|
*/
|
|
54
54
|
export type EntityViewLayout = 'column' | 'rail';
|
|
55
|
+
/**
|
|
56
|
+
* Which column comes first where the rail layout is one column (a phone,
|
|
57
|
+
* or a narrow content area): `rail-first` (the default) or `main-first`.
|
|
58
|
+
*/
|
|
59
|
+
export type EntityViewMobileOrder = 'rail-first' | 'main-first';
|
|
55
60
|
export type EntityViewProps = {
|
|
56
61
|
/** The record's name or title, the page's <h1>, and the browser tab's once it loads (`documentTitle`). */
|
|
57
62
|
title?: string;
|
|
@@ -102,6 +107,14 @@ export type EntityViewProps = {
|
|
|
102
107
|
footer?: ReactNode;
|
|
103
108
|
/** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
|
|
104
109
|
layout?: EntityViewLayout;
|
|
110
|
+
/**
|
|
111
|
+
* In the `rail` layout, where its columns stack (a phone): the rail
|
|
112
|
+
* before the main column (`rail-first`, the default), or the main column
|
|
113
|
+
* straight after the header and its `strip` (`main-first`). Two columns
|
|
114
|
+
* wide, the rail stays on the right either way. The `column` layout
|
|
115
|
+
* ignores it.
|
|
116
|
+
*/
|
|
117
|
+
mobileOrder?: EntityViewMobileOrder;
|
|
105
118
|
/** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
|
|
106
119
|
railLabel?: string;
|
|
107
120
|
isLoading: boolean;
|
|
@@ -114,4 +127,4 @@ export type EntityViewProps = {
|
|
|
114
127
|
label: string;
|
|
115
128
|
};
|
|
116
129
|
};
|
|
117
|
-
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
130
|
+
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -40,8 +40,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
40
40
|
// content area's whole width, sections placed `rail` go in a narrow column
|
|
41
41
|
// on the right that stays in view while the main column scrolls (an issue
|
|
42
42
|
// tracker's shape), and the rest fill the main column. Below the width two
|
|
43
|
-
// columns need, one column: header, rail, main
|
|
44
|
-
//
|
|
43
|
+
// columns need, one column: header, rail, main, or header, main, rail with
|
|
44
|
+
// `mobileOrder="main-first"` (a record whose long content is what you open
|
|
45
|
+
// it for: notes, a checklist). The DOM is in that order at every width, so
|
|
46
|
+
// reading and tab order match the screen: beside each other, the columns'
|
|
47
|
+
// places come from the grid, not the DOM.
|
|
45
48
|
import { useEffect, useId, useState, useSyncExternalStore } from 'react';
|
|
46
49
|
import { Link } from 'react-router';
|
|
47
50
|
import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
|
|
@@ -90,7 +93,7 @@ function ViewSection({ section, inRail = false }) {
|
|
|
90
93
|
// two columns follows the width the page actually has (the sidebar open
|
|
91
94
|
// or not), not the window's: two from 56rem (896px), where the main
|
|
92
95
|
// column still gets ~32rem beside a 22rem rail. Below that, one column in
|
|
93
|
-
// DOM order (header, rail, main); from it, the grid puts the rail on the
|
|
96
|
+
// DOM order (header, rail, main, or main first); from it, the grid puts the rail on the
|
|
94
97
|
// right of row 1 and main on its left, both starting under the header.
|
|
95
98
|
const RAIL_LAYOUT = {
|
|
96
99
|
page: '@container/view flex min-w-0 flex-col gap-6',
|
|
@@ -109,10 +112,11 @@ function SkeletonCard({ rows, stacked }) {
|
|
|
109
112
|
}
|
|
110
113
|
// The page's shape before the record arrives, in the layout it will have,
|
|
111
114
|
// so nothing jumps when it loads.
|
|
112
|
-
function EntityViewSkeleton({ layout }) {
|
|
115
|
+
function EntityViewSkeleton({ layout, mobileOrder }) {
|
|
113
116
|
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" })] })] }));
|
|
114
117
|
if (layout === 'rail') {
|
|
115
|
-
|
|
118
|
+
const main = (_jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) }));
|
|
119
|
+
return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, _jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), mobileOrder === 'rail-first' && main] })] }));
|
|
116
120
|
}
|
|
117
121
|
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)))] }));
|
|
118
122
|
}
|
|
@@ -143,7 +147,7 @@ function Rail({ label, sections }) {
|
|
|
143
147
|
const [ref, fits] = useFitsWindow();
|
|
144
148
|
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))) }));
|
|
145
149
|
}
|
|
146
|
-
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
|
|
150
|
+
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, isLoading, error, onRetry, back, }) {
|
|
147
151
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
148
152
|
// while a field is open (or one is always editable).
|
|
149
153
|
const [store] = useState(() => new EditInPlaceStore());
|
|
@@ -157,8 +161,9 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
|
|
|
157
161
|
if (error)
|
|
158
162
|
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
159
163
|
if (isLoading)
|
|
160
|
-
return _jsx(EntityViewSkeleton, { layout: layout });
|
|
164
|
+
return _jsx(EntityViewSkeleton, { layout: layout, mobileOrder: mobileOrder });
|
|
161
165
|
const railSections = sections.filter((section) => section.placement === 'rail');
|
|
162
166
|
const mainSections = sections.filter((section) => section.placement !== 'rail');
|
|
163
|
-
|
|
167
|
+
const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
|
|
168
|
+
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) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), 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 }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (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 }))] }) }));
|
|
164
169
|
}
|
|
@@ -38,16 +38,25 @@ export type ModuleCardProps = ModuleCardPlace & {
|
|
|
38
38
|
onRetry?: () => void;
|
|
39
39
|
className?: string;
|
|
40
40
|
};
|
|
41
|
+
/** How a ModuleStrip shows its places on a phone: `tiles` (the default), or one sideways `row` of chips. */
|
|
42
|
+
export type ModuleStripMobile = 'tiles' | 'row';
|
|
41
43
|
export declare function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading, error, onRetry, className, }: ModuleCardProps): import("react").JSX.Element;
|
|
42
44
|
export interface ModuleStripProps {
|
|
43
45
|
/** Names the strip's navigation landmark: "Places". */
|
|
44
46
|
'aria-label': string;
|
|
45
47
|
/** ModuleCards, one per place. */
|
|
46
48
|
children: ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* On a phone: three columns of `tiles` (the default), or one `row` of
|
|
51
|
+
* chips (icon, name, number) that scrolls sideways, below the sidebar's
|
|
52
|
+
* breakpoint. A desktop shows the cards either way.
|
|
53
|
+
*/
|
|
54
|
+
mobile?: ModuleStripMobile;
|
|
47
55
|
className?: string;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
58
|
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
51
|
-
* wrap, and three columns of tiles
|
|
59
|
+
* wrap, and on a phone three columns of tiles, or one sideways row of
|
|
60
|
+
* chips (`mobile="row"`).
|
|
52
61
|
*/
|
|
53
|
-
export declare function ModuleStrip({ 'aria-label': label, children, className }: ModuleStripProps): import("react").JSX.Element;
|
|
62
|
+
export declare function ModuleStrip({ 'aria-label': label, children, mobile, className }: ModuleStripProps): import("react").JSX.Element;
|
|
@@ -21,7 +21,16 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
21
21
|
// smaller tiles with no action, each tile a whole link (the band link's
|
|
22
22
|
// target stretched over the card) at least 44px tall. Retry sits above
|
|
23
23
|
// that target. data-density="compact" tightens the band and the body.
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// `mobile="row"` makes the strip one sideways-scrolling row of chips below
|
|
26
|
+
// the sidebar's breakpoint (md) instead, so a hub's widgets start on the
|
|
27
|
+
// first screen: each place's icon in its slot's colour, its name and its
|
|
28
|
+
// number, a skeleton while that loads and a dash when it fails (Retry is
|
|
29
|
+
// the card's; the place's own page says what went wrong). Each ModuleCard
|
|
30
|
+
// renders its chip beside its card, one shown at each width. The row
|
|
31
|
+
// scrolls inside itself and is `relative`, so a chip's sr-only text
|
|
32
|
+
// (absolutely placed) scrolls with it instead of widening the page.
|
|
33
|
+
import { Children, createContext, useContext, useId } from 'react';
|
|
25
34
|
import { Link } from 'react-router';
|
|
26
35
|
import { AlertCircleIcon, ExternalLinkIcon } from 'lucide-react';
|
|
27
36
|
import { cn } from 'cn';
|
|
@@ -67,29 +76,60 @@ const BAND_LINK_CLASS = 'group/band flex min-h-9 items-center gap-2 px-3 py-1.5
|
|
|
67
76
|
'max-sm:gap-1.5 max-sm:px-2 max-sm:after:absolute max-sm:after:inset-0 max-sm:after:rounded-xl ' +
|
|
68
77
|
'max-sm:focus-visible:ring-0 max-sm:focus-visible:after:ring-3 max-sm:focus-visible:after:ring-inset ' +
|
|
69
78
|
'max-sm:focus-visible:after:ring-ring/50';
|
|
79
|
+
// The chip on a phone in a `mobile="row"` strip. Its focus ring is inset:
|
|
80
|
+
// the row clips anything drawn outside it.
|
|
81
|
+
const CHIP_CLASS = 'flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 type-label ' +
|
|
82
|
+
'whitespace-nowrap text-foreground outline-none hover:bg-accent focus-visible:ring-3 ' +
|
|
83
|
+
'focus-visible:ring-inset focus-visible:ring-ring/50';
|
|
70
84
|
const ACTION_CLASS = 'flex items-center gap-1 border-t border-border px-3 py-2 type-label text-foreground underline ' +
|
|
71
85
|
'underline-offset-4 outline-none hover:decoration-2 focus-visible:ring-3 focus-visible:ring-inset ' +
|
|
72
86
|
'focus-visible:ring-ring/50 density-compact:py-1.5 max-sm:hidden';
|
|
73
87
|
function NewTabMark() {
|
|
74
88
|
return (_jsxs(_Fragment, { children: [_jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
|
|
75
89
|
}
|
|
90
|
+
const StripMobileContext = createContext('tiles');
|
|
91
|
+
function ChipValue({ state, value }) {
|
|
92
|
+
if (state === 'loading')
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Loading" })] }));
|
|
94
|
+
if (state === 'error')
|
|
95
|
+
return (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "sr-only", children: "Couldn't load" })] }));
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
return (_jsx("span", { "data-slot": "module-card-chip-value", className: "font-semibold tabular-nums", children: value }));
|
|
99
|
+
}
|
|
100
|
+
function ModuleChip({ title, icon: Icon, category, to, href, value, state, }) {
|
|
101
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), title, _jsx(ChipValue, { state: state, value: value })] }));
|
|
102
|
+
const chipProps = { 'data-slot': 'module-card-chip', 'data-state': state, className: cn(CHIP_CLASS, 'md:hidden') };
|
|
103
|
+
return href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...chipProps, children: [content, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...chipProps, children: content }));
|
|
104
|
+
}
|
|
76
105
|
export function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading = false, error, onRetry, className, }) {
|
|
77
106
|
const titleId = useId();
|
|
78
107
|
const bodyId = useId();
|
|
79
108
|
const state = error ? 'error' : isLoading ? 'loading' : 'success';
|
|
109
|
+
const mobile = useContext(StripMobileContext);
|
|
80
110
|
const band = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), _jsx("span", { id: titleId, className: "min-w-0 flex-1 truncate underline-offset-4 group-hover/band:underline", children: title })] }));
|
|
81
111
|
const bandProps = {
|
|
82
112
|
'aria-describedby': bodyId,
|
|
83
113
|
'data-slot': 'module-card-band',
|
|
84
114
|
className: cn(BAND_LINK_CLASS, category && BAND_CLASS[category]),
|
|
85
115
|
};
|
|
86
|
-
|
|
116
|
+
const card = (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('relative h-full min-w-0 gap-0 py-0', mobile === 'row' && 'max-md:hidden', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
|
|
87
117
|
(action.href !== undefined ? (_jsxs("a", { href: action.href, target: "_blank", rel: "noreferrer", "aria-describedby": titleId, className: ACTION_CLASS, children: [_jsx("span", { className: "truncate", children: action.label }), _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: action.to, "aria-describedby": titleId, className: ACTION_CLASS, children: _jsx("span", { className: "truncate", children: action.label }) })))] }));
|
|
118
|
+
if (mobile === 'tiles')
|
|
119
|
+
return card;
|
|
120
|
+
return (_jsxs(_Fragment, { children: [card, _jsx(ModuleChip, { title: title, icon: Icon, category: category, to: to, href: href, value: value, state: state })] }));
|
|
88
121
|
}
|
|
122
|
+
// The list's columns: whole class names, so Tailwind generates them.
|
|
123
|
+
const STRIP_LIST = {
|
|
124
|
+
tiles: 'grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
|
|
125
|
+
row: 'flex gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
|
|
126
|
+
};
|
|
89
127
|
/**
|
|
90
128
|
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
91
|
-
* wrap, and three columns of tiles
|
|
129
|
+
* wrap, and on a phone three columns of tiles, or one sideways row of
|
|
130
|
+
* chips (`mobile="row"`).
|
|
92
131
|
*/
|
|
93
|
-
export function ModuleStrip({ 'aria-label': label, children, className }) {
|
|
94
|
-
|
|
132
|
+
export function ModuleStrip({ 'aria-label': label, children, mobile = 'tiles', className }) {
|
|
133
|
+
const row = mobile === 'row';
|
|
134
|
+
return (_jsx(StripMobileContext.Provider, { value: mobile, children: _jsx("nav", { "aria-label": label, "data-slot": "module-strip", "data-mobile": mobile, className: cn(row && 'relative min-w-0 max-md:overflow-x-auto max-md:[scrollbar-width:none]', className), children: _jsx("ul", { className: STRIP_LIST[mobile], children: Children.map(children, (child) => child && _jsx("li", { className: cn('min-w-0', row && 'max-md:shrink-0'), children: child })) }) }) }));
|
|
95
135
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { type RowGroup } from './components/app/table-groups.js';
|
|
|
11
11
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
12
12
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
13
13
|
export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
|
|
14
|
-
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
14
|
+
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewMobileOrder, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
15
15
|
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
16
16
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
17
17
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
@@ -44,7 +44,7 @@ export { ImageField, type ImageFieldProps } from './components/app/image-field.j
|
|
|
44
44
|
export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
|
|
45
45
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
46
46
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
47
|
-
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
|
|
47
|
+
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripMobile, type ModuleStripProps, } from './components/app/module-card.js';
|
|
48
48
|
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
49
49
|
export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
|
|
50
50
|
export { ThemeProvider } from './components/theme-provider.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.47.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": {
|