@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.
Files changed (48) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +45 -4
  3. package/conventions/docs/cell-patterns.md +59 -9
  4. package/conventions/docs/design-language.md +81 -1
  5. package/conventions/docs/timed-screens.md +13 -0
  6. package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
  7. package/dist/components/app/app-shell-bottom-bar.js +49 -0
  8. package/dist/components/app/app-shell-nav.d.ts +41 -0
  9. package/dist/components/app/app-shell-nav.js +40 -8
  10. package/dist/components/app/app-shell.d.ts +15 -4
  11. package/dist/components/app/app-shell.js +21 -7
  12. package/dist/components/app/data-table.d.ts +22 -2
  13. package/dist/components/app/data-table.js +29 -7
  14. package/dist/components/app/line-chart.d.ts +26 -0
  15. package/dist/components/app/line-chart.js +59 -0
  16. package/dist/components/app/list-editor-rows.d.ts +18 -0
  17. package/dist/components/app/list-editor-rows.js +23 -0
  18. package/dist/components/app/list-editor.d.ts +25 -3
  19. package/dist/components/app/list-editor.js +61 -5
  20. package/dist/components/app/markdown.d.ts +17 -2
  21. package/dist/components/app/markdown.js +84 -11
  22. package/dist/components/app/milkdown-editor.d.ts +1 -1
  23. package/dist/components/app/milkdown-editor.js +22 -10
  24. package/dist/components/app/prose-look.d.ts +35 -6
  25. package/dist/components/app/prose-look.js +27 -6
  26. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  27. package/dist/components/app/prose-table-scroll.js +87 -0
  28. package/dist/components/app/rich-text-editor.d.ts +17 -0
  29. package/dist/components/app/rich-text-editor.js +5 -0
  30. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  31. package/dist/components/app/rich-text-node-views.js +108 -5
  32. package/dist/components/app/widget-card.d.ts +11 -1
  33. package/dist/components/app/widget-card.js +19 -3
  34. package/dist/components/ui/collapsible.d.ts +5 -0
  35. package/dist/components/ui/collapsible.js +12 -0
  36. package/dist/hooks/use-muted.d.ts +4 -10
  37. package/dist/hooks/use-muted.js +4 -67
  38. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  39. package/dist/hooks/use-remembered-flag.js +78 -0
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.js +3 -2
  42. package/dist/lib/line-chart-layout.d.ts +23 -0
  43. package/dist/lib/line-chart-layout.js +44 -0
  44. package/dist/lib/markdown-top-heading.d.ts +2 -0
  45. package/dist/lib/markdown-top-heading.js +21 -0
  46. package/dist/testing/index.d.ts +16 -4
  47. package/dist/testing/index.js +5 -3
  48. 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
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, 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 && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "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) })] }) }));
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
- type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
2
- export type MutedStore = {
3
- get: (key: string) => boolean;
4
- set: (key: string, muted: boolean) => void;
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 {};
@@ -1,73 +1,10 @@
1
- import { useCallback, useSyncExternalStore } from 'react';
2
- export function createMutedStore(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) {
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
- const subscribe = useCallback((listener) => {
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,2 @@
1
+ /** The lowest `depth` of any heading in the text, nested ones included; 1 when it has none. */
2
+ export declare function textTopHeading(markdown: string): number;
@@ -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
+ }
@@ -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
- /** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
47
- loggedOutRoutes: readonly string[];
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. axe's
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[];
@@ -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. axe's
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.32.0",
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": {