@tristan2828/ui-foundation 4.32.0 → 4.34.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 +9 -3
- package/conventions/docs/add-an-entity.md +45 -4
- package/conventions/docs/cell-patterns.md +59 -9
- package/conventions/docs/design-language.md +81 -1
- package/conventions/docs/timed-screens.md +13 -0
- package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
- package/dist/components/app/app-shell-bottom-bar.js +49 -0
- package/dist/components/app/app-shell-nav.d.ts +41 -0
- package/dist/components/app/app-shell-nav.js +40 -8
- package/dist/components/app/app-shell.d.ts +15 -4
- package/dist/components/app/app-shell.js +21 -7
- package/dist/components/app/data-table.d.ts +22 -2
- package/dist/components/app/data-table.js +29 -7
- package/dist/components/app/line-chart.d.ts +26 -0
- package/dist/components/app/line-chart.js +59 -0
- package/dist/components/app/list-editor-rows.d.ts +18 -0
- package/dist/components/app/list-editor-rows.js +23 -0
- package/dist/components/app/list-editor.d.ts +25 -3
- package/dist/components/app/list-editor.js +61 -5
- package/dist/components/app/markdown.d.ts +17 -2
- package/dist/components/app/markdown.js +84 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +22 -10
- package/dist/components/app/prose-look.d.ts +35 -6
- package/dist/components/app/prose-look.js +27 -6
- package/dist/components/app/prose-table-scroll.d.ts +33 -0
- package/dist/components/app/prose-table-scroll.js +87 -0
- package/dist/components/app/rich-text-editor.d.ts +17 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-node-views.d.ts +6 -3
- package/dist/components/app/rich-text-node-views.js +108 -5
- package/dist/components/app/widget-card.d.ts +11 -1
- package/dist/components/app/widget-card.js +19 -3
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +12 -0
- package/dist/hooks/use-muted.d.ts +4 -10
- package/dist/hooks/use-muted.js +4 -67
- package/dist/hooks/use-remembered-flag.d.ts +18 -0
- package/dist/hooks/use-remembered-flag.js +78 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/lib/line-chart-layout.d.ts +23 -0
- package/dist/lib/line-chart-layout.js +44 -0
- package/dist/lib/markdown-top-heading.d.ts +2 -0
- package/dist/lib/markdown-top-heading.js +21 -0
- package/dist/testing/index.d.ts +16 -4
- package/dist/testing/index.js +5 -3
- package/package.json +3 -2
|
@@ -31,10 +31,20 @@ export interface WidgetCardProps {
|
|
|
31
31
|
emptyMessage: string;
|
|
32
32
|
/** How many rows the loading skeleton shows: the number the widget usually holds. */
|
|
33
33
|
skeletonRows?: number;
|
|
34
|
+
/** A button at the header's end that folds the card to its header, and opens it again. */
|
|
35
|
+
collapsible?: boolean;
|
|
36
|
+
/** Whether a collapsible card starts folded, until the person folds or opens it. */
|
|
37
|
+
defaultCollapsed?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Remembers a collapsible card folded or open on this device, under this
|
|
40
|
+
* key (name it for the app and the widget: `"my-app:hub.waiting"`).
|
|
41
|
+
* Left out, it starts at `defaultCollapsed` on every visit.
|
|
42
|
+
*/
|
|
43
|
+
storageKey?: string;
|
|
34
44
|
children?: ReactNode;
|
|
35
45
|
className?: string;
|
|
36
46
|
}
|
|
37
|
-
export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, children, className, }: WidgetCardProps): import("react").JSX.Element;
|
|
47
|
+
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;
|
|
38
48
|
/** The rows of a WidgetCard: a list with a divider between rows. */
|
|
39
49
|
export declare function WidgetList({ children, className }: {
|
|
40
50
|
children: ReactNode;
|
|
@@ -16,6 +16,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
// outline and its landmarks. Height follows content (`self-start` in a
|
|
17
17
|
// grid), so a short widget doesn't stretch to its tallest neighbour.
|
|
18
18
|
//
|
|
19
|
+
// `collapsible` (#233) folds a card to its header, for a hub with more
|
|
20
|
+
// widgets than a phone screen holds: a chevron button at the header's end
|
|
21
|
+
// (named by the title, `aria-expanded`, `aria-controls` the body), the
|
|
22
|
+
// header's link still there while folded. With `storageKey` the choice is
|
|
23
|
+
// remembered on this device (useRememberedFlag: localStorage, in memory
|
|
24
|
+
// where that's blocked). The body is hidden, not unmounted, so whatever
|
|
25
|
+
// the app keeps in it stays put.
|
|
26
|
+
//
|
|
19
27
|
// WidgetList and WidgetListItem are the common body: rows with dividers,
|
|
20
28
|
// each a primary link (or plain text) truncated to one line, an optional
|
|
21
29
|
// muted note ("2 h ago") and an optional trailing element (a badge, a
|
|
@@ -28,18 +36,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
28
36
|
// finger's target.
|
|
29
37
|
import { useId } from 'react';
|
|
30
38
|
import { Link } from 'react-router';
|
|
31
|
-
import { ExternalLinkIcon } from 'lucide-react';
|
|
39
|
+
import { ChevronDownIcon, ExternalLinkIcon } from 'lucide-react';
|
|
32
40
|
import { cn } from 'cn';
|
|
33
41
|
import { ErrorState } from './error-state.js';
|
|
42
|
+
import { Button } from '../ui/button.js';
|
|
34
43
|
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
35
44
|
import { Skeleton } from '../ui/skeleton.js';
|
|
45
|
+
import { useRememberedFlag } from '../../hooks/use-remembered-flag.js';
|
|
36
46
|
const HEADER_LINK_CLASS = 'shrink-0 rounded-sm type-label text-foreground underline underline-offset-4 outline-none ' +
|
|
37
47
|
'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
38
|
-
export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, children, className, }) {
|
|
48
|
+
export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, children, className, }) {
|
|
39
49
|
const headingId = useId();
|
|
50
|
+
const contentId = useId();
|
|
40
51
|
const Heading = `h${headingLevel}`;
|
|
41
52
|
const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
|
|
42
|
-
|
|
53
|
+
const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
|
|
54
|
+
const collapsed = collapsible && folded;
|
|
55
|
+
return (_jsx("section", { "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, { className: "flex items-center justify-between gap-3", 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 || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label })), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
|
|
56
|
+
// The button's own open look (a filled background) would
|
|
57
|
+
// read as pressed: the chevron says it.
|
|
58
|
+
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) })] }) }));
|
|
43
59
|
}
|
|
44
60
|
// One row's height, shared by the skeleton and the list so the card doesn't
|
|
45
61
|
// jump: py-2 around one line of type-body (or a small control). Compact
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
|
2
|
+
declare function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props): import("react").JSX.Element;
|
|
3
|
+
declare function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props): import("react").JSX.Element;
|
|
4
|
+
declare function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props): import("react").JSX.Element;
|
|
5
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
|
3
|
+
function Collapsible({ ...props }) {
|
|
4
|
+
return _jsx(CollapsiblePrimitive.Root, { "data-slot": "collapsible", ...props });
|
|
5
|
+
}
|
|
6
|
+
function CollapsibleTrigger({ ...props }) {
|
|
7
|
+
return (_jsx(CollapsiblePrimitive.Trigger, { "data-slot": "collapsible-trigger", ...props }));
|
|
8
|
+
}
|
|
9
|
+
function CollapsibleContent({ ...props }) {
|
|
10
|
+
return (_jsx(CollapsiblePrimitive.Panel, { "data-slot": "collapsible-content", ...props }));
|
|
11
|
+
}
|
|
12
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
export type MutedStore =
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
subscribe: (key: string, listener: () => void) => () => void;
|
|
6
|
-
/** Another tab changed `key` (a `storage` event): forget the in-memory value and re-read. */
|
|
7
|
-
changedElsewhere: (key: string) => void;
|
|
8
|
-
};
|
|
9
|
-
export declare function createMutedStore(getStorage: () => StorageLike | undefined): MutedStore;
|
|
1
|
+
import { createFlagStore, type FlagStore } from './use-remembered-flag.js';
|
|
2
|
+
export type MutedStore = FlagStore;
|
|
3
|
+
/** The store under useMuted, for its unit tests (tests/use-muted.test.ts). */
|
|
4
|
+
export declare const createMutedStore: typeof createFlagStore;
|
|
10
5
|
/**
|
|
11
6
|
* `[muted, setMuted]`, remembered on this device under `key` (name it for
|
|
12
7
|
* the app and screen: `"my-app:timer:muted"`). Unmuted until set.
|
|
13
8
|
*/
|
|
14
9
|
export declare function useMuted(key: string): readonly [boolean, (muted: boolean) => void];
|
|
15
|
-
export {};
|
package/dist/hooks/use-muted.js
CHANGED
|
@@ -1,73 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const memory = new Map();
|
|
5
|
-
const listeners = new Map();
|
|
6
|
-
const notify = (key) => {
|
|
7
|
-
for (const listener of listeners.get(key) ?? [])
|
|
8
|
-
listener();
|
|
9
|
-
};
|
|
10
|
-
return {
|
|
11
|
-
get(key) {
|
|
12
|
-
const kept = memory.get(key);
|
|
13
|
-
if (kept !== undefined)
|
|
14
|
-
return kept;
|
|
15
|
-
try {
|
|
16
|
-
const stored = getStorage()?.getItem(key);
|
|
17
|
-
if (stored === 'true')
|
|
18
|
-
return true;
|
|
19
|
-
if (stored === 'false')
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
// Storage blocked: unmuted.
|
|
24
|
-
}
|
|
25
|
-
return false;
|
|
26
|
-
},
|
|
27
|
-
set(key, muted) {
|
|
28
|
-
memory.set(key, muted);
|
|
29
|
-
try {
|
|
30
|
-
getStorage()?.setItem(key, String(muted));
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
// Storage blocked or full: remembered in memory for this page only.
|
|
34
|
-
}
|
|
35
|
-
notify(key);
|
|
36
|
-
},
|
|
37
|
-
subscribe(key, listener) {
|
|
38
|
-
let set = listeners.get(key);
|
|
39
|
-
if (!set)
|
|
40
|
-
listeners.set(key, (set = new Set()));
|
|
41
|
-
set.add(listener);
|
|
42
|
-
return () => set.delete(listener);
|
|
43
|
-
},
|
|
44
|
-
changedElsewhere(key) {
|
|
45
|
-
memory.delete(key);
|
|
46
|
-
notify(key);
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
const browserStore = createMutedStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
51
|
-
let listeningForOtherTabs = false;
|
|
52
|
-
function listenForOtherTabs() {
|
|
53
|
-
if (listeningForOtherTabs || typeof window === 'undefined')
|
|
54
|
-
return;
|
|
55
|
-
listeningForOtherTabs = true;
|
|
56
|
-
window.addEventListener('storage', (event) => {
|
|
57
|
-
if (event.key !== null)
|
|
58
|
-
browserStore.changedElsewhere(event.key);
|
|
59
|
-
});
|
|
60
|
-
}
|
|
1
|
+
import { createFlagStore, useRememberedFlag } from './use-remembered-flag.js';
|
|
2
|
+
/** The store under useMuted, for its unit tests (tests/use-muted.test.ts). */
|
|
3
|
+
export const createMutedStore = createFlagStore;
|
|
61
4
|
/**
|
|
62
5
|
* `[muted, setMuted]`, remembered on this device under `key` (name it for
|
|
63
6
|
* the app and screen: `"my-app:timer:muted"`). Unmuted until set.
|
|
64
7
|
*/
|
|
65
8
|
export function useMuted(key) {
|
|
66
|
-
|
|
67
|
-
listenForOtherTabs();
|
|
68
|
-
return browserStore.subscribe(key, listener);
|
|
69
|
-
}, [key]);
|
|
70
|
-
const muted = useSyncExternalStore(subscribe, () => browserStore.get(key), () => false);
|
|
71
|
-
const setMuted = useCallback((next) => browserStore.set(key, next), [key]);
|
|
72
|
-
return [muted, setMuted];
|
|
9
|
+
return useRememberedFlag(key, false);
|
|
73
10
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
|
2
|
+
export type FlagStore = {
|
|
3
|
+
/** What's held for `key`, or `fallback` when nothing is (or storage is blocked). */
|
|
4
|
+
get: (key: string, fallback?: boolean) => boolean;
|
|
5
|
+
set: (key: string, value: boolean) => void;
|
|
6
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
7
|
+
/** Another tab changed `key` (a `storage` event): forget the in-memory value and re-read. */
|
|
8
|
+
changedElsewhere: (key: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function createFlagStore(getStorage: () => StorageLike | undefined): FlagStore;
|
|
11
|
+
/**
|
|
12
|
+
* `[value, setValue]`, remembered on this device under `key` (name it for
|
|
13
|
+
* the app and what it's about: `"my-app:hub.waiting:collapsed"`), and
|
|
14
|
+
* `fallback` until set. Without a key it's plain state, starting at
|
|
15
|
+
* `fallback` and forgotten with the page.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useRememberedFlag(key: string | undefined, fallback?: boolean): readonly [boolean, (value: boolean) => void];
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
export function createFlagStore(getStorage) {
|
|
3
|
+
// Values this page set, for when storage refuses them.
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const listeners = new Map();
|
|
6
|
+
const notify = (key) => {
|
|
7
|
+
for (const listener of listeners.get(key) ?? [])
|
|
8
|
+
listener();
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
get(key, fallback = false) {
|
|
12
|
+
const kept = memory.get(key);
|
|
13
|
+
if (kept !== undefined)
|
|
14
|
+
return kept;
|
|
15
|
+
try {
|
|
16
|
+
const stored = getStorage()?.getItem(key);
|
|
17
|
+
if (stored === 'true')
|
|
18
|
+
return true;
|
|
19
|
+
if (stored === 'false')
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Storage blocked: the fallback.
|
|
24
|
+
}
|
|
25
|
+
return fallback;
|
|
26
|
+
},
|
|
27
|
+
set(key, value) {
|
|
28
|
+
memory.set(key, value);
|
|
29
|
+
try {
|
|
30
|
+
getStorage()?.setItem(key, String(value));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
34
|
+
}
|
|
35
|
+
notify(key);
|
|
36
|
+
},
|
|
37
|
+
subscribe(key, listener) {
|
|
38
|
+
let set = listeners.get(key);
|
|
39
|
+
if (!set)
|
|
40
|
+
listeners.set(key, (set = new Set()));
|
|
41
|
+
set.add(listener);
|
|
42
|
+
return () => set.delete(listener);
|
|
43
|
+
},
|
|
44
|
+
changedElsewhere(key) {
|
|
45
|
+
memory.delete(key);
|
|
46
|
+
notify(key);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const browserStore = createFlagStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
51
|
+
let listeningForOtherTabs = false;
|
|
52
|
+
function listenForOtherTabs() {
|
|
53
|
+
if (listeningForOtherTabs || typeof window === 'undefined')
|
|
54
|
+
return;
|
|
55
|
+
listeningForOtherTabs = true;
|
|
56
|
+
window.addEventListener('storage', (event) => {
|
|
57
|
+
if (event.key !== null)
|
|
58
|
+
browserStore.changedElsewhere(event.key);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `[value, setValue]`, remembered on this device under `key` (name it for
|
|
63
|
+
* the app and what it's about: `"my-app:hub.waiting:collapsed"`), and
|
|
64
|
+
* `fallback` until set. Without a key it's plain state, starting at
|
|
65
|
+
* `fallback` and forgotten with the page.
|
|
66
|
+
*/
|
|
67
|
+
export function useRememberedFlag(key, fallback = false) {
|
|
68
|
+
const [local, setLocal] = useState(fallback);
|
|
69
|
+
const subscribe = useCallback((listener) => {
|
|
70
|
+
if (key === undefined)
|
|
71
|
+
return () => { };
|
|
72
|
+
listenForOtherTabs();
|
|
73
|
+
return browserStore.subscribe(key, listener);
|
|
74
|
+
}, [key]);
|
|
75
|
+
const stored = useSyncExternalStore(subscribe, () => (key === undefined ? fallback : browserStore.get(key, fallback)), () => fallback);
|
|
76
|
+
const set = useCallback((next) => (key === undefined ? setLocal(next) : browserStore.set(key, next)), [key]);
|
|
77
|
+
return [key === undefined ? local : stored, set];
|
|
78
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps, type AppShellVersion, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
1
|
+
export { AppShell, type AppShellProps, type AppShellVersion, type BottomBarItem, type NavEntry, type NavItem, type NavSection, } from './components/app/app-shell.js';
|
|
2
2
|
export type { IconComponent } from './components/app/icon-component.js';
|
|
3
3
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
4
|
-
export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
|
|
4
|
+
export { DataTable, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
5
5
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
6
6
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
7
7
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
@@ -13,6 +13,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
13
13
|
export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
|
|
14
14
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
15
15
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
16
|
+
export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
|
|
16
17
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
17
18
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
18
19
|
export { MultiPick, type MultiPickOption, type MultiPickProps } from './components/app/multi-pick.js';
|
|
@@ -29,6 +30,7 @@ export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCi
|
|
|
29
30
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
30
31
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
31
32
|
export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
|
|
33
|
+
export { LineChart, type LineChartMark, type LineChartPoint, type LineChartProps, } from './components/app/line-chart.js';
|
|
32
34
|
export { StepBar, type StepBarProps } from './components/app/step-bar.js';
|
|
33
35
|
export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
|
|
34
36
|
export { DateField, type DateFieldProps } from './components/app/date-field.js';
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// the gateway seam, MSW support and Playwright suites have their own entry
|
|
4
4
|
// points (`/gateway`, `/mocks`, `/testing`) so each stays behind its boundary.
|
|
5
5
|
// Composites
|
|
6
|
-
export { AppShell } from './components/app/app-shell.js';
|
|
6
|
+
export { AppShell, } from './components/app/app-shell.js';
|
|
7
7
|
export { CountLink } from './components/app/count-link.js';
|
|
8
|
-
export { DataTable } from './components/app/data-table.js';
|
|
8
|
+
export { DataTable, } from './components/app/data-table.js';
|
|
9
9
|
export {} from './components/app/table-groups.js';
|
|
10
10
|
export { byDay } from './lib/group-by-day.js';
|
|
11
11
|
export { EntityForm } from './components/app/entity-form.js';
|
|
@@ -33,6 +33,7 @@ export { StageCircle, } from './components/app/stage-circle.js';
|
|
|
33
33
|
export { Notice } from './components/app/notice.js';
|
|
34
34
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
35
35
|
export { ProgressRing } from './components/app/progress-ring.js';
|
|
36
|
+
export { LineChart, } from './components/app/line-chart.js';
|
|
36
37
|
export { StepBar } from './components/app/step-bar.js';
|
|
37
38
|
export { RatingStar } from './components/app/rating-star.js';
|
|
38
39
|
export { DateField } from './components/app/date-field.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type LineChartPoint = {
|
|
2
|
+
/** A day (`'YYYY-MM-DD'`), a timestamp or any number. Points sit apart by it; if one isn't a time or a number, they're evenly spaced. */
|
|
3
|
+
x: string | number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export type LineChartMark = 'min' | 'max' | 'last';
|
|
7
|
+
export type ChartLayout = {
|
|
8
|
+
/** Each point's place, in the order given. */
|
|
9
|
+
places: {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** The y range drawn, bottom to top: the values and the target, with room around them. */
|
|
14
|
+
domain: {
|
|
15
|
+
min: number;
|
|
16
|
+
max: number;
|
|
17
|
+
};
|
|
18
|
+
/** The target line's height, if there's a target. */
|
|
19
|
+
targetY?: number;
|
|
20
|
+
/** Which point the mark is on, if any. */
|
|
21
|
+
markIndex?: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function chartLayout(points: readonly LineChartPoint[], target?: number, mark?: LineChartMark): ChartLayout;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Where LineChart's points, target and mark go (#238), React-free so the
|
|
2
|
+
// rules are unit tested on their own (tests/line-chart-layout.test.ts).
|
|
3
|
+
// Positions are fractions of the plot, 0…1, x from the left and y from
|
|
4
|
+
// the top, so the chart fills whatever width it's given.
|
|
5
|
+
// A point's position along x: a number as it is, a day or a timestamp as
|
|
6
|
+
// its time.
|
|
7
|
+
function along(x) {
|
|
8
|
+
return typeof x === 'number' ? x : Date.parse(x);
|
|
9
|
+
}
|
|
10
|
+
// Room above and below the values, as a share of their range, so the line
|
|
11
|
+
// never runs along the plot's edge.
|
|
12
|
+
const PAD = 0.1;
|
|
13
|
+
export function chartLayout(points, target, mark) {
|
|
14
|
+
const values = points.map((point) => point.y);
|
|
15
|
+
const all = target === undefined ? values : [...values, target];
|
|
16
|
+
let min = Math.min(...all);
|
|
17
|
+
let max = Math.max(...all);
|
|
18
|
+
// One value (or all the same): a band around it, so the line sits mid-plot.
|
|
19
|
+
const spread = max - min || Math.abs(max) || 1;
|
|
20
|
+
min -= spread * PAD;
|
|
21
|
+
max += spread * PAD;
|
|
22
|
+
const height = (y) => (max - y) / (max - min);
|
|
23
|
+
const times = points.map((point) => along(point.x));
|
|
24
|
+
const timed = times.every(Number.isFinite) && times.length > 1 && times.at(-1) !== times[0];
|
|
25
|
+
const first = timed ? Math.min(...times) : 0;
|
|
26
|
+
const span = timed ? Math.max(...times) - first : points.length - 1;
|
|
27
|
+
const across = (index) => (span === 0 ? 0.5 : ((timed ? times[index] : index) - first) / span);
|
|
28
|
+
let markIndex;
|
|
29
|
+
if (points.length > 0 && mark) {
|
|
30
|
+
if (mark === 'last')
|
|
31
|
+
markIndex = points.length - 1;
|
|
32
|
+
else {
|
|
33
|
+
const pick = mark === 'min' ? Math.min(...values) : Math.max(...values);
|
|
34
|
+
// The latest of equal values: the one that's still true.
|
|
35
|
+
markIndex = values.lastIndexOf(pick);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
places: points.map((point, index) => ({ x: across(index), y: height(point.y) })),
|
|
40
|
+
domain: { min, max },
|
|
41
|
+
targetY: target === undefined ? undefined : height(target),
|
|
42
|
+
markIndex,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The text's own highest heading level (1 for `#`), for `headingFrom="text"`
|
|
2
|
+
// on <Markdown> and the rich-text editor: notes pasted or imported from
|
|
3
|
+
// another tool often start at `##`, and that level is the one that takes
|
|
4
|
+
// the field's `headingLevel`. Parsed as both parse it (remark, GFM), so a
|
|
5
|
+
// `#` in a code block or a setext heading counts as they render it.
|
|
6
|
+
import remarkGfm from 'remark-gfm';
|
|
7
|
+
import remarkParse from 'remark-parse';
|
|
8
|
+
import { unified } from 'unified';
|
|
9
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
10
|
+
/** The lowest `depth` of any heading in the text, nested ones included; 1 when it has none. */
|
|
11
|
+
export function textTopHeading(markdown) {
|
|
12
|
+
let top = 7;
|
|
13
|
+
const visit = (node) => {
|
|
14
|
+
if (node.type === 'heading' && node.depth)
|
|
15
|
+
top = Math.min(top, node.depth);
|
|
16
|
+
for (const child of node.children ?? [])
|
|
17
|
+
visit(child);
|
|
18
|
+
};
|
|
19
|
+
visit(parser.parse(markdown));
|
|
20
|
+
return top === 7 ? 1 : top;
|
|
21
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -43,8 +43,11 @@ export type A11ySuiteOptions = {
|
|
|
43
43
|
* sparse one, so both filled values and "not set" labels are checked.
|
|
44
44
|
*/
|
|
45
45
|
viewRoutes?: readonly string[];
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Screens outside the app shell, e.g. '/login' (and '/register' if the
|
|
48
|
+
* app has it). An app with several a11y spec files lists them in one.
|
|
49
|
+
*/
|
|
50
|
+
loggedOutRoutes?: readonly string[];
|
|
48
51
|
/**
|
|
49
52
|
* The app's nav, the same `NavEntry[]` passed to `AppShell` (`src/nav.ts`).
|
|
50
53
|
* Given, each page in it is a test of its own, so the suite's time per
|
|
@@ -54,6 +57,13 @@ export type A11ySuiteOptions = {
|
|
|
54
57
|
* their number.
|
|
55
58
|
*/
|
|
56
59
|
nav?: readonly NavEntry[];
|
|
60
|
+
/**
|
|
61
|
+
* Whether to check that the rendered sidebar links exactly `nav`'s pages
|
|
62
|
+
* (default true). An app with one a11y spec per module passes each
|
|
63
|
+
* module's own entries as `nav` with `checkSidebar: false`, and keeps
|
|
64
|
+
* the check in one file that passes the whole nav.
|
|
65
|
+
*/
|
|
66
|
+
checkSidebar?: boolean;
|
|
57
67
|
};
|
|
58
68
|
/**
|
|
59
69
|
* Every data view on the page has loaded: at least one shows its data (or,
|
|
@@ -71,12 +81,14 @@ export declare function expectDataViewsLoaded(page: Page, { success }?: {
|
|
|
71
81
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
72
82
|
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
73
83
|
* when `nav` is left out), the given form routes, and the logged-out
|
|
74
|
-
* screens.
|
|
84
|
+
* screens. A modular app can split it into a spec per module: each passes
|
|
85
|
+
* its module's `nav` entries and routes with `checkSidebar: false`, and
|
|
86
|
+
* one file keeps the whole-sidebar check. axe's
|
|
75
87
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
76
88
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
77
89
|
* emulateMedia switches it.
|
|
78
90
|
*/
|
|
79
|
-
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav }: A11ySuiteOptions): void;
|
|
91
|
+
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav, checkSidebar, }: A11ySuiteOptions): void;
|
|
80
92
|
export type MockModeBannerSuiteOptions = {
|
|
81
93
|
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
82
94
|
routes: readonly string[];
|
package/dist/testing/index.js
CHANGED
|
@@ -140,16 +140,18 @@ async function expectNavPageAccessible(page, route, colorScheme) {
|
|
|
140
140
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
141
141
|
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
142
142
|
* when `nav` is left out), the given form routes, and the logged-out
|
|
143
|
-
* screens.
|
|
143
|
+
* screens. A modular app can split it into a spec per module: each passes
|
|
144
|
+
* its module's `nav` entries and routes with `checkSidebar: false`, and
|
|
145
|
+
* one file keeps the whole-sidebar check. axe's
|
|
144
146
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
145
147
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
146
148
|
* emulateMedia switches it.
|
|
147
149
|
*/
|
|
148
|
-
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
|
|
150
|
+
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes = [], nav, checkSidebar = true, }) {
|
|
149
151
|
// Each `to` as given, query string and all, so a page that opens on a
|
|
150
152
|
// saved view is checked on it.
|
|
151
153
|
const listed = nav && navTargets(nav);
|
|
152
|
-
if (listed) {
|
|
154
|
+
if (listed && checkSidebar) {
|
|
153
155
|
test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
|
|
154
156
|
const rendered = await navRoutes(page, isMobile);
|
|
155
157
|
expect(pagePaths(rendered), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual(pagePaths(listed));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.34.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": {
|
|
@@ -65,10 +65,11 @@
|
|
|
65
65
|
"prepack": "npm run build",
|
|
66
66
|
"gen:api": "openapi-typescript ./openapi/foundation.yaml -o ./src/api/schema.ts",
|
|
67
67
|
"lint": "eslint .",
|
|
68
|
+
"docs:index": "node scripts/doc-index.mjs",
|
|
68
69
|
"storybook": "storybook dev -p 6006",
|
|
69
70
|
"build-storybook": "storybook build",
|
|
70
71
|
"preview-storybook": "vite preview --outDir storybook-static --port 6006 --strictPort",
|
|
71
|
-
"verify:fast": "npm run gen:api && git diff --exit-code -- src/api/schema.ts && tsc -b && eslint . --max-warnings 0 && node bin/ui-foundation.mjs check-deps && npm run build && vitest run",
|
|
72
|
+
"verify:fast": "npm run gen:api && git diff --exit-code -- src/api/schema.ts && node scripts/doc-index.mjs --check && tsc -b && eslint . --max-warnings 0 && node bin/ui-foundation.mjs check-deps && npm run build && vitest run",
|
|
72
73
|
"verify": "npm run verify:fast && playwright test -c playwright.storybook.config.ts"
|
|
73
74
|
},
|
|
74
75
|
"peerDependencies": {
|