@tristan2828/ui-foundation 4.44.0 → 4.46.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/add-an-entity.md +19 -10
- package/conventions/docs/cell-patterns.md +111 -32
- package/conventions/docs/design-language.md +82 -21
- package/dist/components/app/data-table.d.ts +61 -4
- package/dist/components/app/data-table.js +217 -17
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +5 -3
- package/dist/components/app/key-stats.d.ts +14 -0
- package/dist/components/app/key-stats.js +6 -0
- package/dist/components/app/table-groups.d.ts +12 -0
- package/dist/components/app/table-groups.js +37 -0
- 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/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +1 -0
- package/dist/hooks/use-table-layout.d.ts +1 -0
- package/dist/hooks/use-table-layout.js +2 -1
- package/dist/hooks/use-table-url-state.d.ts +11 -1
- package/dist/hooks/use-table-url-state.js +26 -2
- package/dist/hooks/use-widget-layout.d.ts +45 -0
- package/dist/hooks/use-widget-layout.js +128 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -13,6 +13,13 @@ export type TableView<F extends string, M extends string> = {
|
|
|
13
13
|
* Applies only while the view is active (see activeViewOf).
|
|
14
14
|
*/
|
|
15
15
|
columns?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The grouping this view turns on, for DataTable's `grouping`: a
|
|
18
|
+
* grouping's id, or '' for None. Pressing the view sets it; a view that
|
|
19
|
+
* leaves it out keeps whatever grouping is on, since a grouping is how
|
|
20
|
+
* the rows are read, not which rows they are.
|
|
21
|
+
*/
|
|
22
|
+
group?: string;
|
|
16
23
|
};
|
|
17
24
|
export declare function serializeSort(next: Sort): string | null;
|
|
18
25
|
export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
|
|
@@ -27,6 +27,7 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
|
|
|
27
27
|
...Object.fromEntries(filterNames.map((name) => [name, view.filters?.[name] ?? ''])),
|
|
28
28
|
...Object.fromEntries(multiFilterNames.map((name) => [name, view.multiFilters?.[name] ?? []])),
|
|
29
29
|
sort: view.sort ? serializeSort(view.sort) : null,
|
|
30
|
+
...(view.group === undefined ? {} : { group: view.group || null }),
|
|
30
31
|
page: null,
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -7,6 +7,7 @@ export type LayoutStore = {
|
|
|
7
7
|
export declare function createLayoutStore(getStorage: () => StorageLike | undefined): LayoutStore;
|
|
8
8
|
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
9
9
|
export declare function resolveLayout(layouts: readonly string[], fromUrl: string | null, remembered: string | null, defaultLayout: string): string;
|
|
10
|
+
export declare const browserStore: LayoutStore;
|
|
10
11
|
export type TableLayoutOptions = {
|
|
11
12
|
/** Every layout id the table has, the table's own `'table'` among them. */
|
|
12
13
|
layouts: readonly string[];
|
|
@@ -43,7 +43,8 @@ export function resolveLayout(layouts, fromUrl, remembered, defaultLayout) {
|
|
|
43
43
|
return remembered;
|
|
44
44
|
return defaultLayout;
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
// This device's remembered picks, shared with useTableUrlState's grouping.
|
|
47
|
+
export const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
47
48
|
/**
|
|
48
49
|
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
49
50
|
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { SortingState } from '../components/app/data-table.js';
|
|
2
2
|
import { type TableView } from './table-url-changes.js';
|
|
3
|
-
export
|
|
3
|
+
export type TableUrlStateOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Where the table's grouping (DataTable's `grouping`) is remembered on
|
|
6
|
+
* this device, one key per table (`"my-app:tasks:group"`), the way
|
|
7
|
+
* useTableLayout remembers a layout. Without one it lives only in the URL.
|
|
8
|
+
*/
|
|
9
|
+
groupStorageKey?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>, { groupStorageKey }?: TableUrlStateOptions): {
|
|
4
12
|
page: number;
|
|
5
13
|
sorting: SortingState;
|
|
6
14
|
filters: Record<F, string>;
|
|
7
15
|
multiFilters: Record<M, string[]>;
|
|
8
16
|
activeView: V | null;
|
|
17
|
+
grouping: string;
|
|
18
|
+
setGrouping: (next: string) => void;
|
|
9
19
|
setPage: (next: number) => void;
|
|
10
20
|
setSorting: (next: SortingState) => void;
|
|
11
21
|
setFilter: (name: F, value: string) => void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
1
2
|
import { useSearchParams } from 'react-router';
|
|
2
3
|
import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
|
|
4
|
+
import { browserStore } from './use-table-layout.js';
|
|
3
5
|
// A table's page, sort and filters, kept in the URL query string
|
|
4
6
|
// (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
|
|
5
7
|
// component state, so they survive going to the edit form and back, a
|
|
@@ -15,9 +17,22 @@ import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from
|
|
|
15
17
|
// counts as active); pass that view's `columns` to DataTable. A view is
|
|
16
18
|
// pressed with applyView().
|
|
17
19
|
//
|
|
20
|
+
// `grouping` is DataTable's: a grouping's id, or '' for None. It's
|
|
21
|
+
// `?group=` in the URL, then the one remembered under `groupStorageKey`,
|
|
22
|
+
// then None. Like the sort it doesn't change which rows are shown, so it
|
|
23
|
+
// keeps the page and doesn't count toward the active view; a view that
|
|
24
|
+
// names a `group` sets it when pressed.
|
|
25
|
+
//
|
|
18
26
|
// Every setter is one update(), and must stay one: see setFilters.
|
|
19
|
-
export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
|
|
27
|
+
export function useTableUrlState(filterNames, multiFilterNames = [], views = {}, { groupStorageKey } = {}) {
|
|
20
28
|
const [params, setParams] = useSearchParams();
|
|
29
|
+
const subscribe = useCallback((listener) => (groupStorageKey === undefined ? () => { } : browserStore.subscribe(groupStorageKey, listener)), [groupStorageKey]);
|
|
30
|
+
const rememberedGrouping = useSyncExternalStore(subscribe, () => (groupStorageKey === undefined ? null : browserStore.get(groupStorageKey)), () => null);
|
|
31
|
+
const grouping = params.get('group') ?? rememberedGrouping ?? '';
|
|
32
|
+
const rememberGrouping = (next) => {
|
|
33
|
+
if (groupStorageKey !== undefined)
|
|
34
|
+
browserStore.set(groupStorageKey, next);
|
|
35
|
+
};
|
|
21
36
|
const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
|
|
22
37
|
const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
|
|
23
38
|
const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
|
|
@@ -33,6 +48,11 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
|
|
|
33
48
|
filters,
|
|
34
49
|
multiFilters,
|
|
35
50
|
activeView,
|
|
51
|
+
grouping,
|
|
52
|
+
setGrouping: (next) => {
|
|
53
|
+
rememberGrouping(next);
|
|
54
|
+
update({ group: next || null });
|
|
55
|
+
},
|
|
36
56
|
setPage: (next) => update({ page: next > 1 ? String(next) : null }),
|
|
37
57
|
setSorting: (next) => update({ sort: serializeSort(next), page: null }),
|
|
38
58
|
setFilter: (name, value) => update({ [name]: value, page: null }),
|
|
@@ -46,6 +66,10 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
|
|
|
46
66
|
setMultiFilter: (name, values) => update({ [name]: values, page: null }),
|
|
47
67
|
// Replaces the whole filter + sort state at once — a saved view. See
|
|
48
68
|
// viewToUrlChanges() for why anything the view doesn't set is cleared.
|
|
49
|
-
applyView: (view) =>
|
|
69
|
+
applyView: (view) => {
|
|
70
|
+
if (view.group !== undefined)
|
|
71
|
+
rememberGrouping(view.group);
|
|
72
|
+
update(viewToUrlChanges(filterNames, multiFilterNames, view));
|
|
73
|
+
},
|
|
50
74
|
};
|
|
51
75
|
}
|
|
@@ -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
|
@@ -6,10 +6,11 @@ export { pickSpinWheelIndex, type SpinWheelItem } from './components/app/spin-wh
|
|
|
6
6
|
export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
|
|
7
7
|
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
8
8
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
9
|
-
export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
9
|
+
export { DataTable, type DataTableCardLayout, type DataTableGrouping, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
10
10
|
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
|
+
export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
|
|
13
14
|
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
14
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';
|
|
15
16
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
@@ -44,7 +45,8 @@ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from '.
|
|
|
44
45
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
45
46
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
46
47
|
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';
|
|
48
|
+
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
49
|
+
export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
|
|
48
50
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
49
51
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
50
52
|
export { RegisterRoute } from './routes/register.js';
|
|
@@ -54,8 +56,9 @@ export type { AuthContextValue, AuthStatus, AuthUser, SignInResult } from './aut
|
|
|
54
56
|
export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
55
57
|
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
56
58
|
export { createQueryClient } from './api/query-client.js';
|
|
57
|
-
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
59
|
+
export { useTableUrlState, type TableUrlStateOptions } from './hooks/use-table-url-state.js';
|
|
58
60
|
export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
|
|
61
|
+
export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, type SavedWidgetLayout, type WidgetLayout, type WidgetLayoutOptions, type WidgetLayoutStore, } from './hooks/use-widget-layout.js';
|
|
59
62
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
60
63
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
61
64
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { DataTable, } from './components/app/data-table.js';
|
|
|
14
14
|
export {} from './components/app/table-groups.js';
|
|
15
15
|
export { byDay } from './lib/group-by-day.js';
|
|
16
16
|
export { EntityForm } from './components/app/entity-form.js';
|
|
17
|
+
export { KeyStats } from './components/app/key-stats.js';
|
|
17
18
|
export { EntityView, } from './components/app/entity-view.js';
|
|
18
19
|
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
19
20
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
@@ -47,7 +48,8 @@ export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
|
|
|
47
48
|
export { formatPlainDate } from './lib/plain-date.js';
|
|
48
49
|
export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
|
|
49
50
|
export { ModuleCard, ModuleStrip, } from './components/app/module-card.js';
|
|
50
|
-
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
51
|
+
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
52
|
+
export { WidgetLayoutSheet } from './components/app/widget-layout-sheet.js';
|
|
51
53
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
52
54
|
// Screens outside the app shell
|
|
53
55
|
export { LoginRoute } from './routes/login.js';
|
|
@@ -59,6 +61,7 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
59
61
|
// Hooks
|
|
60
62
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
61
63
|
export { useTableLayout } from './hooks/use-table-layout.js';
|
|
64
|
+
export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, } from './hooks/use-widget-layout.js';
|
|
62
65
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
63
66
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
64
67
|
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.46.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": {
|