@tristan2828/ui-foundation 4.44.0 → 4.45.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/AGENTS.md +2 -1
- package/conventions/docs/design-language.md +46 -10
- package/dist/components/app/widget-card.d.ts +29 -1
- package/dist/components/app/widget-card.js +61 -5
- package/dist/components/app/widget-layout-sheet.d.ts +25 -0
- package/dist/components/app/widget-layout-sheet.js +55 -0
- package/dist/hooks/use-widget-layout.d.ts +45 -0
- package/dist/hooks/use-widget-layout.js +128 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/package.json +1 -1
package/conventions/AGENTS.md
CHANGED
|
@@ -87,7 +87,8 @@ Every data view handles: loading, empty, error, and success. Use
|
|
|
87
87
|
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
|
|
88
88
|
and `EntityView` (one record) adds not found: a plain 404, never a retry.
|
|
89
89
|
A small widget on a hub page is a `WidgetCard`, which holds all four for
|
|
90
|
-
its own data, its error inside the card
|
|
90
|
+
its own data, its error inside the card (folded, its `summary` follows
|
|
91
|
+
the same states); a hub's place (a module, with
|
|
91
92
|
one live number) is a `ModuleCard` in a `ModuleStrip`, the same way.
|
|
92
93
|
Every screen has one Playwright test per state, forced via MSW overrides
|
|
93
94
|
(`@tristan2828/ui-foundation/testing`).
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
- [Typography](#typography) · lines 245–327
|
|
13
13
|
- [Written text (notes)](#written-text-notes) · lines 276–327
|
|
14
14
|
- [Density](#density) · lines 329–386
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
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
|
-
- [Picking at random](#picking-at-random) · lines
|
|
22
|
-
- [Filling in what's missing](#filling-in-whats-missing) · lines
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–502
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 504–562
|
|
17
|
+
- [Notices](#notices) · lines 564–592
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 594–627
|
|
19
|
+
- [A list as a board](#a-list-as-a-board) · lines 629–680
|
|
20
|
+
- [A list as cards](#a-list-as-cards) · lines 682–728
|
|
21
|
+
- [Picking at random](#picking-at-random) · lines 730–761
|
|
22
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 763–813
|
|
23
23
|
|
|
24
24
|
<!-- /index -->
|
|
25
25
|
|
|
@@ -446,7 +446,42 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
|
|
|
446
446
|
(`"<app>:hub.<widget>"`) so a card folded stays folded on that device,
|
|
447
447
|
and `defaultCollapsed` for one that should start folded. The chevron
|
|
448
448
|
at the header's end is named by the title; the header's link stays
|
|
449
|
-
while folded.
|
|
449
|
+
while folded. Give a card that folds a `summary`: one short value its
|
|
450
|
+
header shows while folded (`summary={data?.total}`, `"Due today"`),
|
|
451
|
+
so a hub of folded cards still reads at a glance. It follows the
|
|
452
|
+
card's state: a small skeleton while loading, a muted dash on an error
|
|
453
|
+
(the error itself is in the body, opened), and `emptySummary`
|
|
454
|
+
(`"None"`) or nothing when empty.
|
|
455
|
+
- **One open at a time on a phone:** put the cards in a
|
|
456
|
+
`WidgetCardGroup accordion="mobile"` (its `className` is the grid),
|
|
457
|
+
give each an `id`, and name the one to start open in `defaultOpen`.
|
|
458
|
+
Below the sidebar's breakpoint every card folds, opening one folds the
|
|
459
|
+
others, and a tap anywhere on a header (not its link) toggles it; on a
|
|
460
|
+
desktop the cards keep their own `collapsible`. `accordion` alone does
|
|
461
|
+
it at every width.
|
|
462
|
+
- **Letting someone order the cards:** keep the order in
|
|
463
|
+
`useWidgetLayout('<app>:hub', ids)` (every card's id, in the default
|
|
464
|
+
order), render `layout.visible`, and put a `WidgetLayoutSheet` (its
|
|
465
|
+
Customize button) beside the page's title, with each card's title by id.
|
|
466
|
+
Its rows have Up, Down and a Show switch, with Reset: the same on a
|
|
467
|
+
phone, a desktop and a keyboard (the focus stays on the button
|
|
468
|
+
pressed). The order is kept on the device; a removed id drops out and
|
|
469
|
+
a new one falls in at its default place. To follow the person between
|
|
470
|
+
devices, pass `{ store }`, a `WidgetLayoutStore` over the app's
|
|
471
|
+
backend.
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
const HUB_CARDS = { ready: 'Ready to start', waiting: 'Waiting on', links: 'Saved links' }
|
|
475
|
+
const layout = useWidgetLayout('my-app:hub', Object.keys(HUB_CARDS))
|
|
476
|
+
|
|
477
|
+
<div className="flex items-center justify-between gap-3">
|
|
478
|
+
<h1 className="type-page-title text-foreground">Home</h1>
|
|
479
|
+
<WidgetLayoutSheet layout={layout} titles={HUB_CARDS} />
|
|
480
|
+
</div>
|
|
481
|
+
<WidgetCardGroup accordion="mobile" defaultOpen="ready" className="grid … gap-4">
|
|
482
|
+
{layout.visible.map((id) => <HubCard key={id} id={id} />)}
|
|
483
|
+
</WidgetCardGroup>
|
|
484
|
+
```
|
|
450
485
|
- **A row is one line:** its text is a link (`to` for a route in the
|
|
451
486
|
app, `href` for an external address, which opens in a new tab with the
|
|
452
487
|
external-link mark) or plain text, cut with an ellipsis, the whole of
|
|
@@ -462,7 +497,8 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
|
|
|
462
497
|
many 288px-or-wider columns as fit). Each card is as tall as its rows;
|
|
463
498
|
it doesn't stretch to its tallest neighbour.
|
|
464
499
|
- Storybook's `app/WidgetCard` shows three widgets on a page and every
|
|
465
|
-
one in each state
|
|
500
|
+
one in each state, a folded summary in each state and an accordion,
|
|
501
|
+
and `app/WidgetLayoutSheet` a hub with its Customize sheet; its checks run axe and the token and glyph checks in
|
|
466
502
|
both themes and at `large`.
|
|
467
503
|
|
|
468
504
|
## Module cards (a hub's places)
|
|
@@ -4,6 +4,11 @@ import type { AppError } from '../../api/contracts.js';
|
|
|
4
4
|
import type { IconComponent } from './icon-component.js';
|
|
5
5
|
export interface WidgetCardProps {
|
|
6
6
|
title: string;
|
|
7
|
+
/**
|
|
8
|
+
* The card's id, on its region: the one WidgetCardGroup's `defaultOpen`
|
|
9
|
+
* names and useWidgetLayout orders (`"ready"`). Unique on the page.
|
|
10
|
+
*/
|
|
11
|
+
id?: string;
|
|
7
12
|
/**
|
|
8
13
|
* An icon shown before the title: a Lucide icon, or an app's own mark (an
|
|
9
14
|
* outside service's logo on a card about that service). Decorative: the
|
|
@@ -39,6 +44,14 @@ export interface WidgetCardProps {
|
|
|
39
44
|
* Left out, it starts at `defaultCollapsed` on every visit.
|
|
40
45
|
*/
|
|
41
46
|
storageKey?: string;
|
|
47
|
+
/**
|
|
48
|
+
* One short value shown in the header while the card is folded: a count
|
|
49
|
+
* (`3`), or a word or two (`"Due today"`). A skeleton while loading, a
|
|
50
|
+
* muted dash on an error. Ignored while open.
|
|
51
|
+
*/
|
|
52
|
+
summary?: ReactNode;
|
|
53
|
+
/** What a folded card's summary says when it's empty (`"None"`); nothing when left out. */
|
|
54
|
+
emptySummary?: ReactNode;
|
|
42
55
|
children?: ReactNode;
|
|
43
56
|
className?: string;
|
|
44
57
|
}
|
|
@@ -51,7 +64,22 @@ export type WidgetCardLink = {
|
|
|
51
64
|
label: string;
|
|
52
65
|
to?: never;
|
|
53
66
|
};
|
|
54
|
-
export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
|
|
67
|
+
export declare function WidgetCard({ id, title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, summary, emptySummary, children, className, }: WidgetCardProps): import("react").JSX.Element;
|
|
68
|
+
export interface WidgetCardGroupProps {
|
|
69
|
+
/**
|
|
70
|
+
* One card open at a time: `true` always, `"mobile"` only below the
|
|
71
|
+
* sidebar's breakpoint (a desktop hub keeps each card's own
|
|
72
|
+
* `collapsible`), `false` never (the group is a plain wrapper).
|
|
73
|
+
*/
|
|
74
|
+
accordion?: boolean | 'mobile';
|
|
75
|
+
/** The `id` of the card open at first; all folded (showing their summaries) when left out. */
|
|
76
|
+
defaultOpen?: string | null;
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
/** The wrapper's classes: the hub's grid of cards, usually. */
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
/** Wraps a hub's WidgetCards so that opening one folds the others (on a phone only, with `accordion="mobile"`). */
|
|
82
|
+
export declare function WidgetCardGroup({ accordion, defaultOpen, children, className }: WidgetCardGroupProps): import("react").JSX.Element;
|
|
55
83
|
/** The rows of a WidgetCard: a list with a divider between rows. */
|
|
56
84
|
export declare function WidgetList({ children, className }: {
|
|
57
85
|
children: ReactNode;
|
|
@@ -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
|
// A small titled card on a hub page (issue #121): a few rows from one part
|
|
3
3
|
// of the app, loaded on their own. It owns the four required states the
|
|
4
4
|
// way DataTable and EntityView do, so every widget on a page skeletons,
|
|
@@ -24,6 +24,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
24
24
|
// where that's blocked). The body is hidden, not unmounted, so whatever
|
|
25
25
|
// the app keeps in it stays put.
|
|
26
26
|
//
|
|
27
|
+
// `summary` (#263) is one short value a folded card shows in its header
|
|
28
|
+
// ("3", "Due today"), so a hub of folded cards still reads at a glance.
|
|
29
|
+
// It follows the card's state: a small skeleton while loading, a muted
|
|
30
|
+
// dash ("Couldn't load" to a screen reader) on an error, `emptySummary`
|
|
31
|
+
// (or nothing) when empty. Opened, the summary goes and the body shows,
|
|
32
|
+
// the error in it as before. The chevron is described by it.
|
|
33
|
+
//
|
|
34
|
+
// WidgetCardGroup (#263) makes its cards an accordion: every card in it
|
|
35
|
+
// folds, opening one folds the others, and a click anywhere on a header
|
|
36
|
+
// (not on its link or another control) toggles it; the chevron stays the
|
|
37
|
+
// keyboard's way in. `accordion="mobile"` does that only below the
|
|
38
|
+
// sidebar's breakpoint (useIsMobile), so a desktop hub keeps each card's
|
|
39
|
+
// own `collapsible`. While the accordion is on it owns the folding and a
|
|
40
|
+
// card's `storageKey` is left alone.
|
|
41
|
+
//
|
|
27
42
|
// The header's link is an app route (`to`) or, for a widget about a
|
|
28
43
|
// service outside the app, its address (`href`, #248): that one opens in a
|
|
29
44
|
// new tab, with the outside-link mark and a screen reader's "(opens in a
|
|
@@ -39,7 +54,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
39
54
|
// a row's to 4px, for a dense hub; text stays at type-body. On a phone a
|
|
40
55
|
// compact row is 44px tall and its link fills that height, so it stays a
|
|
41
56
|
// finger's target.
|
|
42
|
-
import { useId } from 'react';
|
|
57
|
+
import { createContext, useContext, useId, useState } from 'react';
|
|
43
58
|
import { Link } from 'react-router';
|
|
44
59
|
import { ChevronDownIcon, ExternalLinkIcon } from 'lucide-react';
|
|
45
60
|
import { cn } from 'cn';
|
|
@@ -47,21 +62,62 @@ import { ErrorState } from './error-state.js';
|
|
|
47
62
|
import { Button } from '../ui/button.js';
|
|
48
63
|
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
49
64
|
import { Skeleton } from '../ui/skeleton.js';
|
|
65
|
+
import { useIsMobile } from '../../hooks/use-mobile.js';
|
|
50
66
|
import { useRememberedFlag } from '../../hooks/use-remembered-flag.js';
|
|
51
67
|
const HEADER_LINK_CLASS = 'shrink-0 rounded-sm type-label text-foreground underline underline-offset-4 outline-none ' +
|
|
52
68
|
'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
53
|
-
export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, children, className, }) {
|
|
69
|
+
export function WidgetCard({ id, title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, summary, emptySummary, children, className, }) {
|
|
54
70
|
const headingId = useId();
|
|
55
71
|
const contentId = useId();
|
|
72
|
+
const summaryId = useId();
|
|
73
|
+
const ownId = useId();
|
|
74
|
+
const cardId = id ?? ownId;
|
|
75
|
+
const group = useContext(GroupContext);
|
|
76
|
+
const inAccordion = group !== null && group.active;
|
|
56
77
|
const Heading = `h${headingLevel}`;
|
|
57
78
|
const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
|
|
58
79
|
const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
|
|
59
|
-
const collapsed = collapsible && folded;
|
|
60
|
-
|
|
80
|
+
const collapsed = inAccordion ? group.openId !== cardId : collapsible && folded;
|
|
81
|
+
const foldable = inAccordion || collapsible;
|
|
82
|
+
const toggle = () => (inAccordion ? group.setOpenId(collapsed ? cardId : null) : setFolded(!collapsed));
|
|
83
|
+
const summaryShown = collapsed && (summary !== undefined || emptySummary !== undefined);
|
|
84
|
+
// In an accordion the whole header toggles, except where the click lands
|
|
85
|
+
// on its link or another control (the chevron toggles on its own).
|
|
86
|
+
const onHeaderClick = (event) => {
|
|
87
|
+
if (!inAccordion)
|
|
88
|
+
return;
|
|
89
|
+
if (event.target.closest('a, button, input, select, textarea, [role="switch"]'))
|
|
90
|
+
return;
|
|
91
|
+
toggle();
|
|
92
|
+
};
|
|
93
|
+
return (_jsx("section", { id: id, "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { onClick: onHeaderClick, className: cn('flex items-center justify-between gap-3', inAccordion && 'cursor-pointer select-none'), children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || foldable || summaryShown) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [summaryShown && (_jsx("span", { id: summaryId, "data-slot": "widget-card-summary", className: "mr-2 flex items-center", children: _jsx(WidgetCardSummary, { state: state, summary: summary, emptySummary: emptySummary }) })), link?.href !== undefined ? (_jsxs("a", { href: link.href, target: "_blank", rel: "noreferrer", "aria-describedby": headingId, className: cn(HEADER_LINK_CLASS, 'inline-flex items-center gap-1'), children: [link.label, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))), foldable && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-describedby": summaryShown ? summaryId : undefined, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: toggle,
|
|
61
94
|
// The button's own open look (a filled background) would
|
|
62
95
|
// read as pressed: the chevron says it.
|
|
63
96
|
className: "-my-1 -mr-1.5 aria-expanded:not-hover:bg-transparent", children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('transition-transform', collapsed && '-rotate-90') }) }))] }))] }), _jsx(CardContent, { id: contentId, hidden: collapsed, "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
|
|
64
97
|
}
|
|
98
|
+
// A folded card's one value, by state. Loading's skeleton is about one
|
|
99
|
+
// number wide; an error's dash says "Couldn't load" to a screen reader,
|
|
100
|
+
// the error itself waiting in the body.
|
|
101
|
+
function WidgetCardSummary({ state, summary, emptySummary, }) {
|
|
102
|
+
if (state === 'loading') {
|
|
103
|
+
return (_jsxs(_Fragment, { children: [_jsx(Skeleton, { "data-state": "loading", className: "h-4 w-8" }), _jsx("span", { className: "sr-only", children: "Loading" })] }));
|
|
104
|
+
}
|
|
105
|
+
if (state === 'error') {
|
|
106
|
+
return (_jsxs("span", { "data-state": "error", className: "type-label text-muted-foreground", children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "sr-only", children: "Couldn't load" })] }));
|
|
107
|
+
}
|
|
108
|
+
if (state === 'empty') {
|
|
109
|
+
return emptySummary === undefined ? null : (_jsx("span", { "data-state": "empty", className: "type-label text-muted-foreground", children: emptySummary }));
|
|
110
|
+
}
|
|
111
|
+
return _jsx("span", { className: "type-label tabular-nums text-foreground", children: summary });
|
|
112
|
+
}
|
|
113
|
+
const GroupContext = createContext(null);
|
|
114
|
+
/** Wraps a hub's WidgetCards so that opening one folds the others (on a phone only, with `accordion="mobile"`). */
|
|
115
|
+
export function WidgetCardGroup({ accordion = true, defaultOpen = null, children, className }) {
|
|
116
|
+
const isMobile = useIsMobile();
|
|
117
|
+
const [openId, setOpenId] = useState(defaultOpen);
|
|
118
|
+
const active = accordion === 'mobile' ? isMobile : accordion;
|
|
119
|
+
return (_jsx(GroupContext.Provider, { value: { active, openId, setOpenId }, children: _jsx("div", { "data-slot": "widget-card-group", "data-accordion": active || undefined, className: className, children: children }) }));
|
|
120
|
+
}
|
|
65
121
|
// One row's height, shared by the skeleton and the list so the card doesn't
|
|
66
122
|
// jump: py-2 around one line of type-body (or a small control). Compact
|
|
67
123
|
// is 32px (6px around the line); on a phone it's 44px, the link's own
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { WidgetLayout } from '../../hooks/use-widget-layout.js';
|
|
3
|
+
export interface WidgetLayoutSheetProps {
|
|
4
|
+
/** The page's layout, from useWidgetLayout. */
|
|
5
|
+
layout: WidgetLayout;
|
|
6
|
+
/** Each card's title by its id: the row's name. An id without one shows as the id. */
|
|
7
|
+
titles: Readonly<Record<string, string>>;
|
|
8
|
+
/** The sheet's heading. */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** A line under the heading. */
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
/** The trigger button's label. */
|
|
13
|
+
triggerLabel?: string;
|
|
14
|
+
/** Opens and closes the sheet from outside (then the trigger can be left out with `trigger={false}`). */
|
|
15
|
+
open?: boolean;
|
|
16
|
+
/** Whether the sheet starts open, when `open` isn't given. */
|
|
17
|
+
defaultOpen?: boolean;
|
|
18
|
+
onOpenChange?: (open: boolean) => void;
|
|
19
|
+
/** Show the Customize button; `false` when the app opens the sheet itself. */
|
|
20
|
+
trigger?: boolean;
|
|
21
|
+
/** Which side the sheet comes from: the right on a desktop, `bottom` suits a phone. */
|
|
22
|
+
side?: 'right' | 'bottom';
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function WidgetLayoutSheet({ layout, titles, title, description, triggerLabel, open, defaultOpen, onOpenChange, trigger, side, className, }: WidgetLayoutSheetProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A hub page's Customize sheet (#263): every card the page has, in the
|
|
3
|
+
// person's order, each row with Up and Down buttons and a Show switch,
|
|
4
|
+
// and Reset at the foot. It edits a useWidgetLayout, which keeps the
|
|
5
|
+
// order (on this device by default) and which the page renders its cards
|
|
6
|
+
// by (`layout.visible`).
|
|
7
|
+
//
|
|
8
|
+
// It's the same on a phone, a desktop and a keyboard: no dragging. Up and
|
|
9
|
+
// Down keep the focus on the button pressed, so pressing it again moves
|
|
10
|
+
// the card on. At either end the button is `aria-disabled`, not
|
|
11
|
+
// `disabled`, so the focus stays on it there too, and a polite live region
|
|
12
|
+
// says where the card went ("Saved links, 1 of 3").
|
|
13
|
+
//
|
|
14
|
+
// The trigger is a Customize button for the page's header; pass `open`
|
|
15
|
+
// and `onOpenChange` to open it from elsewhere instead.
|
|
16
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
17
|
+
import { ArrowDownIcon, ArrowUpIcon, SlidersHorizontalIcon } from 'lucide-react';
|
|
18
|
+
import { cn } from 'cn';
|
|
19
|
+
import { Button } from '../ui/button.js';
|
|
20
|
+
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from '../ui/sheet.js';
|
|
21
|
+
import { Switch } from '../ui/switch.js';
|
|
22
|
+
export function WidgetLayoutSheet({ layout, titles, title = 'Customize this page', description = 'Order the cards, and hide the ones you don’t need.', triggerLabel = 'Customize', open, defaultOpen, onOpenChange, trigger = true, side = 'right', className, }) {
|
|
23
|
+
const listRef = useRef(null);
|
|
24
|
+
const [moved, setMoved] = useState(null);
|
|
25
|
+
const [announcement, setAnnouncement] = useState('');
|
|
26
|
+
const { order, hidden } = layout;
|
|
27
|
+
const nameOf = (id) => titles[id] ?? id;
|
|
28
|
+
// Moving a row re-orders the list, which can take the focus off the
|
|
29
|
+
// button that moved it: put it back.
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
if (moved === null)
|
|
32
|
+
return;
|
|
33
|
+
const button = listRef.current?.querySelector(`[data-widget-id="${CSS.escape(moved.id)}"] [data-move="${moved.by}"]`);
|
|
34
|
+
if (button && document.activeElement !== button)
|
|
35
|
+
button.focus();
|
|
36
|
+
}, [moved, order]);
|
|
37
|
+
const move = (id, by) => {
|
|
38
|
+
const from = order.indexOf(id);
|
|
39
|
+
const to = from + by;
|
|
40
|
+
if (from === -1 || to < 0 || to >= order.length)
|
|
41
|
+
return;
|
|
42
|
+
layout.move(id, by);
|
|
43
|
+
setMoved({ id, by });
|
|
44
|
+
setAnnouncement(`${nameOf(id)}, ${to + 1} of ${order.length}`);
|
|
45
|
+
};
|
|
46
|
+
return (_jsxs(Sheet, { open: open, defaultOpen: defaultOpen, onOpenChange: onOpenChange ? (next) => onOpenChange(next) : undefined, children: [trigger && (_jsxs(SheetTrigger, { render: _jsx(Button, { variant: "outline", size: "sm" }), children: [_jsx(SlidersHorizontalIcon, { "aria-hidden": "true" }), triggerLabel] })), _jsxs(SheetContent, { side: side, className: cn(side === 'bottom' && 'max-h-[85dvh]', className), children: [_jsxs(SheetHeader, { children: [_jsx(SheetTitle, { children: title }), description && _jsx(SheetDescription, { children: description })] }), _jsx("ol", { ref: listRef, "data-slot": "widget-layout-list", className: "flex flex-col divide-y divide-border overflow-y-auto px-4", children: order.map((id, index) => (_jsx(WidgetLayoutRow, { id: id, name: nameOf(id), shown: !hidden.includes(id), first: index === 0, last: index === order.length - 1, onMove: (by) => move(id, by), onToggle: () => layout.toggle(id) }, id))) }), _jsx("div", { "aria-live": "polite", className: "sr-only", children: announcement }), _jsx(SheetFooter, { children: _jsx(Button, { variant: "outline", "aria-disabled": layout.isDefault || undefined, onClick: () => {
|
|
47
|
+
if (layout.isDefault)
|
|
48
|
+
return;
|
|
49
|
+
layout.reset();
|
|
50
|
+
setAnnouncement('Back to the default order');
|
|
51
|
+
}, className: "aria-disabled:opacity-50", children: "Reset" }) })] })] }));
|
|
52
|
+
}
|
|
53
|
+
function WidgetLayoutRow({ id, name, shown, first, last, onMove, onToggle, }) {
|
|
54
|
+
return (_jsxs("li", { "data-widget-id": id, "data-hidden": !shown || undefined, className: "flex min-h-12 items-center gap-2 py-2", children: [_jsx("span", { className: cn('min-w-0 flex-1 truncate type-body', shown ? 'text-foreground' : 'text-muted-foreground'), children: name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "data-move": "-1", "aria-label": `Move ${name} up`, "aria-disabled": first || undefined, onClick: () => onMove(-1), className: "aria-disabled:opacity-50", children: _jsx(ArrowUpIcon, { "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "data-move": "1", "aria-label": `Move ${name} down`, "aria-disabled": last || undefined, onClick: () => onMove(1), className: "aria-disabled:opacity-50", children: _jsx(ArrowDownIcon, { "aria-hidden": "true" }) }), _jsx(Switch, { "aria-label": `Show ${name}`, checked: shown, onCheckedChange: onToggle, className: "ml-2" })] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** What's kept for a page: the ids in the person's order, and the hidden ones. */
|
|
2
|
+
export type SavedWidgetLayout = {
|
|
3
|
+
order: readonly string[];
|
|
4
|
+
hidden: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
export type WidgetLayoutStore = {
|
|
7
|
+
/** What's saved under `key`, or null. The same object until it changes. */
|
|
8
|
+
get: (key: string) => SavedWidgetLayout | null;
|
|
9
|
+
/** Saves `layout` under `key`; null forgets it (a reset). */
|
|
10
|
+
set: (key: string, layout: SavedWidgetLayout | null) => void;
|
|
11
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
12
|
+
};
|
|
13
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
14
|
+
export declare function createWidgetLayoutStore(getStorage: () => StorageLike | undefined): WidgetLayoutStore;
|
|
15
|
+
/** The order and hidden ids to show: `saved` merged with the app's `defaultIds`. */
|
|
16
|
+
export declare function mergeWidgetLayout(defaultIds: readonly string[], saved: SavedWidgetLayout | null): SavedWidgetLayout;
|
|
17
|
+
/** `order` with `id` one place earlier (`-1`) or later (`1`); unchanged at either end. */
|
|
18
|
+
export declare function moveWidget(order: readonly string[], id: string, by: -1 | 1): string[];
|
|
19
|
+
export type WidgetLayoutOptions = {
|
|
20
|
+
/** Where the layout is kept; this device's localStorage when left out. */
|
|
21
|
+
store?: WidgetLayoutStore;
|
|
22
|
+
};
|
|
23
|
+
export type WidgetLayout = {
|
|
24
|
+
/** Every card's id, in the person's order. */
|
|
25
|
+
order: readonly string[];
|
|
26
|
+
/** The ids the person hid. */
|
|
27
|
+
hidden: readonly string[];
|
|
28
|
+
/** `order` without the hidden ones: the cards to render. */
|
|
29
|
+
visible: readonly string[];
|
|
30
|
+
/** Moves a card one place up (`-1`) or down (`1`). */
|
|
31
|
+
move: (id: string, by: -1 | 1) => void;
|
|
32
|
+
/** Hides a shown card, or shows a hidden one. */
|
|
33
|
+
toggle: (id: string) => void;
|
|
34
|
+
/** Back to the default order, everything shown. */
|
|
35
|
+
reset: () => void;
|
|
36
|
+
/** True while the layout is the default one. */
|
|
37
|
+
isDefault: boolean;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The order of a page's cards and which are hidden, remembered under
|
|
41
|
+
* `pageKey` (name it for the app and the page: `"my-app:hub"`).
|
|
42
|
+
* `defaultIds` is every card the page has, in its default order.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useWidgetLayout(pageKey: string, defaultIds: readonly string[], { store }?: WidgetLayoutOptions): WidgetLayout;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
function parse(raw) {
|
|
3
|
+
if (raw === null)
|
|
4
|
+
return null;
|
|
5
|
+
try {
|
|
6
|
+
const value = JSON.parse(raw);
|
|
7
|
+
if (typeof value !== 'object' || value === null)
|
|
8
|
+
return null;
|
|
9
|
+
const { order, hidden } = value;
|
|
10
|
+
const strings = (list) => (Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []);
|
|
11
|
+
return { order: strings(order), hidden: strings(hidden) };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function createWidgetLayoutStore(getStorage) {
|
|
18
|
+
// Layouts this page set, for when storage refuses them (null: a reset).
|
|
19
|
+
const memory = new Map();
|
|
20
|
+
// The last string read per key and what it parsed to, so `get` hands
|
|
21
|
+
// back the same object until the stored string changes.
|
|
22
|
+
const parsed = new Map();
|
|
23
|
+
const listeners = new Map();
|
|
24
|
+
return {
|
|
25
|
+
get(key) {
|
|
26
|
+
if (memory.has(key))
|
|
27
|
+
return memory.get(key) ?? null;
|
|
28
|
+
let raw = null;
|
|
29
|
+
try {
|
|
30
|
+
raw = getStorage()?.getItem(key) ?? null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage blocked: nothing saved.
|
|
34
|
+
}
|
|
35
|
+
const cached = parsed.get(key);
|
|
36
|
+
if (cached && cached.raw === raw)
|
|
37
|
+
return cached.layout;
|
|
38
|
+
const layout = parse(raw);
|
|
39
|
+
parsed.set(key, { raw, layout });
|
|
40
|
+
return layout;
|
|
41
|
+
},
|
|
42
|
+
set(key, layout) {
|
|
43
|
+
memory.set(key, layout);
|
|
44
|
+
try {
|
|
45
|
+
const storage = getStorage();
|
|
46
|
+
if (layout === null)
|
|
47
|
+
storage?.removeItem(key);
|
|
48
|
+
else
|
|
49
|
+
storage?.setItem(key, JSON.stringify(layout));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Storage blocked or full: kept in memory for this page only.
|
|
53
|
+
}
|
|
54
|
+
for (const listener of listeners.get(key) ?? [])
|
|
55
|
+
listener();
|
|
56
|
+
},
|
|
57
|
+
subscribe(key, listener) {
|
|
58
|
+
let set = listeners.get(key);
|
|
59
|
+
if (!set)
|
|
60
|
+
listeners.set(key, (set = new Set()));
|
|
61
|
+
set.add(listener);
|
|
62
|
+
return () => set.delete(listener);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The order and hidden ids to show: `saved` merged with the app's `defaultIds`. */
|
|
67
|
+
export function mergeWidgetLayout(defaultIds, saved) {
|
|
68
|
+
if (saved === null)
|
|
69
|
+
return { order: [...defaultIds], hidden: [] };
|
|
70
|
+
const known = new Set(defaultIds);
|
|
71
|
+
const order = [];
|
|
72
|
+
for (const id of saved.order)
|
|
73
|
+
if (known.has(id) && !order.includes(id))
|
|
74
|
+
order.push(id);
|
|
75
|
+
defaultIds.forEach((id, index) => {
|
|
76
|
+
if (order.includes(id))
|
|
77
|
+
return;
|
|
78
|
+
// After the nearest card before it in the default order.
|
|
79
|
+
let at = 0;
|
|
80
|
+
for (let before = index - 1; before >= 0; before--) {
|
|
81
|
+
const placed = order.indexOf(defaultIds[before]);
|
|
82
|
+
if (placed !== -1) {
|
|
83
|
+
at = placed + 1;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
order.splice(at, 0, id);
|
|
88
|
+
});
|
|
89
|
+
const hidden = order.filter((id) => saved.hidden.includes(id));
|
|
90
|
+
return { order, hidden };
|
|
91
|
+
}
|
|
92
|
+
/** `order` with `id` one place earlier (`-1`) or later (`1`); unchanged at either end. */
|
|
93
|
+
export function moveWidget(order, id, by) {
|
|
94
|
+
const next = [...order];
|
|
95
|
+
const from = next.indexOf(id);
|
|
96
|
+
const to = from + by;
|
|
97
|
+
if (from === -1 || to < 0 || to >= next.length)
|
|
98
|
+
return next;
|
|
99
|
+
[next[from], next[to]] = [next[to], next[from]];
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
const browserStore = createWidgetLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
103
|
+
/**
|
|
104
|
+
* The order of a page's cards and which are hidden, remembered under
|
|
105
|
+
* `pageKey` (name it for the app and the page: `"my-app:hub"`).
|
|
106
|
+
* `defaultIds` is every card the page has, in its default order.
|
|
107
|
+
*/
|
|
108
|
+
export function useWidgetLayout(pageKey, defaultIds, { store = browserStore } = {}) {
|
|
109
|
+
const key = `${pageKey}:widgets`;
|
|
110
|
+
const subscribe = useCallback((listener) => store.subscribe(key, listener), [store, key]);
|
|
111
|
+
const saved = useSyncExternalStore(subscribe, () => store.get(key), () => null);
|
|
112
|
+
// The ids as a string, so a new array with the same ids each render doesn't re-merge.
|
|
113
|
+
const idsKey = defaultIds.join('\u0000');
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands for defaultIds
|
|
115
|
+
const { order, hidden } = useMemo(() => mergeWidgetLayout(defaultIds, saved), [idsKey, saved]);
|
|
116
|
+
const move = useCallback((id, by) => store.set(key, { order: moveWidget(order, id, by), hidden }), [store, key, order, hidden]);
|
|
117
|
+
const toggle = useCallback((id) => store.set(key, { order, hidden: hidden.includes(id) ? hidden.filter((other) => other !== id) : [...hidden, id] }), [store, key, order, hidden]);
|
|
118
|
+
const reset = useCallback(() => store.set(key, null), [store, key]);
|
|
119
|
+
return {
|
|
120
|
+
order,
|
|
121
|
+
hidden,
|
|
122
|
+
visible: order.filter((id) => !hidden.includes(id)),
|
|
123
|
+
move,
|
|
124
|
+
toggle,
|
|
125
|
+
reset,
|
|
126
|
+
isDefault: hidden.length === 0 && order.every((id, index) => id === defaultIds[index]),
|
|
127
|
+
};
|
|
128
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -44,7 +44,8 @@ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from '.
|
|
|
44
44
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
45
45
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
46
46
|
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
|
|
47
|
-
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
47
|
+
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
48
|
+
export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
|
|
48
49
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
49
50
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
50
51
|
export { RegisterRoute } from './routes/register.js';
|
|
@@ -56,6 +57,7 @@ export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
|
56
57
|
export { createQueryClient } from './api/query-client.js';
|
|
57
58
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
58
59
|
export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
|
|
60
|
+
export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, type SavedWidgetLayout, type WidgetLayout, type WidgetLayoutOptions, type WidgetLayoutStore, } from './hooks/use-widget-layout.js';
|
|
59
61
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
60
62
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
61
63
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,8 @@ export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
|
|
|
47
47
|
export { formatPlainDate } from './lib/plain-date.js';
|
|
48
48
|
export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
|
|
49
49
|
export { ModuleCard, ModuleStrip, } from './components/app/module-card.js';
|
|
50
|
-
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
50
|
+
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
51
|
+
export { WidgetLayoutSheet } from './components/app/widget-layout-sheet.js';
|
|
51
52
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
52
53
|
// Screens outside the app shell
|
|
53
54
|
export { LoginRoute } from './routes/login.js';
|
|
@@ -59,6 +60,7 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
59
60
|
// Hooks
|
|
60
61
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
61
62
|
export { useTableLayout } from './hooks/use-table-layout.js';
|
|
63
|
+
export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, } from './hooks/use-widget-layout.js';
|
|
62
64
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
63
65
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
64
66
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.45.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": {
|