@tristan2828/ui-foundation 4.19.0 → 4.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/docs/add-an-entity.md +15 -1
- package/conventions/docs/cell-patterns.md +29 -1
- package/dist/components/app/app-shell-nav.d.ts +25 -4
- package/dist/components/app/app-shell-nav.js +42 -11
- package/dist/components/app/app-shell.d.ts +9 -2
- package/dist/components/app/app-shell.js +3 -1
- package/dist/components/app/edit-in-place.d.ts +15 -0
- package/dist/components/app/editable-value.js +8 -5
- package/dist/components/app/entity-view.d.ts +9 -2
- package/dist/components/app/entity-view.js +3 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +19 -9
- package/dist/components/app/rich-text-editor.d.ts +12 -3
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/hooks/use-document-title.d.ts +27 -0
- package/dist/hooks/use-document-title.js +72 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/testing/index.js +12 -8
- package/package.json +1 -1
|
@@ -269,6 +269,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
269
269
|
Its badge fields sit beside it, each the same `Badge` and variant map
|
|
270
270
|
as its table cell. Edit (a `Button` rendering a `Link` to
|
|
271
271
|
`/<entity>/:id/edit`) and the delete action go in `actions`.
|
|
272
|
+
- **The browser tab** reads the record's title once it loads
|
|
273
|
+
("Wireless Mouse · <app>"): `EntityView` sets it from `title`, and
|
|
274
|
+
puts the nav's ("Widgets · <app>") back while loading, on an error
|
|
275
|
+
and when you leave. Nothing to add. `documentTitle` names the tab
|
|
276
|
+
otherwise, when the title alone wouldn't say what the tab is (a
|
|
277
|
+
record named by a date, say: `documentTitle={`Entry ${title}`}`);
|
|
278
|
+
`false` leaves the nav's. Any other screen names its tab with
|
|
279
|
+
`useDocumentTitle('New Widget')`, which wins over the nav's while the
|
|
280
|
+
screen is mounted.
|
|
272
281
|
- **Sections**, one per row of the plan's section table, in order.
|
|
273
282
|
Fields are label/value rows (`fields`), labelled as on the form. A
|
|
274
283
|
section that holds one long text or one sub-record list is a block
|
|
@@ -461,7 +470,12 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
461
470
|
values should stay grey, and colour is never the only signal.
|
|
462
471
|
7. **Register routes** for `/<entity>`, `/<entity>/new`, `/<entity>/:id`
|
|
463
472
|
(the view) and `/<entity>/:id/edit` in `src/App.tsx`, and add a nav
|
|
464
|
-
entry to `src/nav.ts`.
|
|
473
|
+
entry to `src/nav.ts`. Its label names the browser tab on the list and
|
|
474
|
+
every page under it ("Widgets · <app>"; the home entry, '/', gives the
|
|
475
|
+
app's name alone). For a list that should open on one of its saved
|
|
476
|
+
views, `to` carries that view's query string
|
|
477
|
+
(`/widgets?status=active`): the entry is still matched on its path, so
|
|
478
|
+
it stays current on the list and the records under it.
|
|
465
479
|
8. **Add Playwright specs for all three screens**, one test per state
|
|
466
480
|
(`loading`, `empty`, `error`, `validation`, `success`), forced through
|
|
467
481
|
MSW overrides — `forceMswOverride`, `waitForMswReady` and the
|
|
@@ -856,6 +856,32 @@ view, so a link in the notes is edited there, not followed, and the
|
|
|
856
856
|
editor loads on every visit. Use it where the notes are what the page is
|
|
857
857
|
for; elsewhere `autosave` alone keeps the read view at rest.
|
|
858
858
|
|
|
859
|
+
**Edited where it's read (`inline: true`, implies `alwaysEditable`)**,
|
|
860
|
+
Notion-style, for rich text: the editor from the start, drawn as the read
|
|
861
|
+
view rather than as a field (no border, no box, the text where the
|
|
862
|
+
section's text would be), so there's no mode change at all: click into
|
|
863
|
+
the words and type. Its control gets `inline: true` among its props;
|
|
864
|
+
pass it to `RichTextEditor`'s `inline`, with `toolbar="floating"`, which
|
|
865
|
+
keeps the toolbar over a selection (Alt+F10 reaches it) and the "/" menu
|
|
866
|
+
but drops the bar above the text. An empty one shows the editor's
|
|
867
|
+
`placeholder` (say "or type / for blocks" in it), not the view's "Not
|
|
868
|
+
set". It saves like any always-editable value (a pause after typing,
|
|
869
|
+
leaving the page saves first) and is otherwise the same: on the form the
|
|
870
|
+
control is the boxed editor with its fixed toolbar.
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
notes: editInPlace({
|
|
874
|
+
kind: 'long-text',
|
|
875
|
+
inline: true,
|
|
876
|
+
value: widget.notes,
|
|
877
|
+
schema: widgetFormSchema.shape.notes,
|
|
878
|
+
save: (notes) => editField.mutateAsync({ notes }),
|
|
879
|
+
// The form's own control, spreading what it gets onto RichTextEditor:
|
|
880
|
+
// inline={props.inline} toolbar={props.inline ? 'floating' : true}
|
|
881
|
+
control: (props) => <WidgetNotesEditor {...} />,
|
|
882
|
+
}),
|
|
883
|
+
```
|
|
884
|
+
|
|
859
885
|
```tsx
|
|
860
886
|
description: editInPlace({
|
|
861
887
|
kind: 'long-text',
|
|
@@ -919,7 +945,9 @@ const price = editInPlace({
|
|
|
919
945
|
beside it add a row or a column, and the toolbar's Table menu inserts
|
|
920
946
|
rows and columns, aligns a column and deletes.
|
|
921
947
|
`toolbar={false}` leaves out the toolbars and the slash menu where a
|
|
922
|
-
value is short and the shortcuts are enough
|
|
948
|
+
value is short and the shortcuts are enough; `toolbar="floating"` keeps
|
|
949
|
+
the floating one and the slash menu without the bar above (an `inline`
|
|
950
|
+
field's look).
|
|
923
951
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
924
952
|
`optimistic: false, toastOnError: false`, the entity's
|
|
925
953
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
export type NavItem = {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* The route this entry links to. It may carry a query string, to open a
|
|
5
|
+
* list on a saved view ('/widgets?status=active'): the entry is matched
|
|
6
|
+
* on its path alone, so it's still current anywhere under /widgets.
|
|
7
|
+
*/
|
|
4
8
|
to: string;
|
|
5
9
|
label: string;
|
|
6
10
|
icon: ComponentType;
|
|
@@ -39,8 +43,25 @@ export type NavGroup = {
|
|
|
39
43
|
*/
|
|
40
44
|
export declare function navGroups(nav: readonly NavEntry[]): NavGroup[];
|
|
41
45
|
/**
|
|
42
|
-
* The `to`
|
|
43
|
-
*
|
|
44
|
-
|
|
46
|
+
* The path a `to` names, without its query string or hash: what an entry is
|
|
47
|
+
* matched on, and what the a11y suite compares with the rendered sidebar.
|
|
48
|
+
*/
|
|
49
|
+
export declare function navPathname(to: string): string;
|
|
50
|
+
/** Every `to` in a nav, each once, in order: the pages its sidebar links. */
|
|
51
|
+
export declare function navTargets(nav: readonly NavEntry[]): string[];
|
|
52
|
+
/**
|
|
53
|
+
* The one entry to mark active: the most specific that matches, by the path
|
|
54
|
+
* of its `to` (its query string ignored). With '/tasks' and
|
|
55
|
+
* '/tasks/categories' both in the nav, '/tasks/categories/2' marks
|
|
56
|
+
* Categories only, and '/tasks/3' marks Tasks. Of two entries with the same
|
|
57
|
+
* path, the first.
|
|
45
58
|
*/
|
|
59
|
+
export declare function activeNavItem(nav: readonly NavEntry[], pathname: string): NavItem | undefined;
|
|
60
|
+
/** The `to` of the one entry to mark active (`activeNavItem`). */
|
|
46
61
|
export declare function activeNavTo(nav: readonly NavEntry[], pathname: string): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The page the nav names for `pathname`, for the browser tab: the active
|
|
64
|
+
* entry's label. None on the home entry ('/'), whose page is the app
|
|
65
|
+
* itself, or on a page no entry covers.
|
|
66
|
+
*/
|
|
67
|
+
export declare function navPageLabel(nav: readonly NavEntry[], pathname: string): string | undefined;
|
|
@@ -30,27 +30,58 @@ export function navGroups(nav) {
|
|
|
30
30
|
groups.push({ items: run });
|
|
31
31
|
return groups;
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The path a `to` names, without its query string or hash: what an entry is
|
|
35
|
+
* matched on, and what the a11y suite compares with the rendered sidebar.
|
|
36
|
+
*/
|
|
37
|
+
export function navPathname(to) {
|
|
38
|
+
const end = to.search(/[?#]/);
|
|
39
|
+
return end === -1 ? to : to.slice(0, end);
|
|
40
|
+
}
|
|
41
|
+
/** Every `to` in a nav, each once, in order: the pages its sidebar links. */
|
|
42
|
+
export function navTargets(nav) {
|
|
43
|
+
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
44
|
+
}
|
|
45
|
+
function matches(path, end, pathname) {
|
|
46
|
+
if (end)
|
|
47
|
+
return pathname === path;
|
|
48
|
+
if (pathname === path)
|
|
37
49
|
return true;
|
|
38
50
|
// On a path-segment boundary: '/tasks' is active on '/tasks/3', not on
|
|
39
51
|
// '/tasks-archive'.
|
|
40
|
-
return pathname.startsWith(
|
|
52
|
+
return pathname.startsWith(path.endsWith('/') ? path : `${path}/`);
|
|
41
53
|
}
|
|
42
54
|
/**
|
|
43
|
-
* The
|
|
44
|
-
*
|
|
45
|
-
*
|
|
55
|
+
* The one entry to mark active: the most specific that matches, by the path
|
|
56
|
+
* of its `to` (its query string ignored). With '/tasks' and
|
|
57
|
+
* '/tasks/categories' both in the nav, '/tasks/categories/2' marks
|
|
58
|
+
* Categories only, and '/tasks/3' marks Tasks. Of two entries with the same
|
|
59
|
+
* path, the first.
|
|
46
60
|
*/
|
|
47
|
-
export function
|
|
61
|
+
export function activeNavItem(nav, pathname) {
|
|
48
62
|
let best;
|
|
63
|
+
let bestLength = -1;
|
|
49
64
|
for (const entry of nav) {
|
|
50
65
|
for (const item of isNavSection(entry) ? entry.items : [entry]) {
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
const path = navPathname(item.to);
|
|
67
|
+
if (matches(path, item.end, pathname) && path.length > bestLength) {
|
|
68
|
+
best = item;
|
|
69
|
+
bestLength = path.length;
|
|
70
|
+
}
|
|
53
71
|
}
|
|
54
72
|
}
|
|
55
73
|
return best;
|
|
56
74
|
}
|
|
75
|
+
/** The `to` of the one entry to mark active (`activeNavItem`). */
|
|
76
|
+
export function activeNavTo(nav, pathname) {
|
|
77
|
+
return activeNavItem(nav, pathname)?.to;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The page the nav names for `pathname`, for the browser tab: the active
|
|
81
|
+
* entry's label. None on the home entry ('/'), whose page is the app
|
|
82
|
+
* itself, or on a page no entry covers.
|
|
83
|
+
*/
|
|
84
|
+
export function navPageLabel(nav, pathname) {
|
|
85
|
+
const item = activeNavItem(nav, pathname);
|
|
86
|
+
return item && navPathname(item.to) !== '/' ? item.label : undefined;
|
|
87
|
+
}
|
|
@@ -4,14 +4,21 @@ import { type AppShellVersion } from './app-shell-version.js';
|
|
|
4
4
|
export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
5
5
|
export type { AppShellVersion } from './app-shell-version.js';
|
|
6
6
|
export type AppShellProps = {
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Shown at the top of the sidebar (hidden on the collapsed icon rail), and
|
|
9
|
+
* the app's part of the browser tab's title: "<page> · <title>", the page
|
|
10
|
+
* named by the current nav entry, or the title alone on the home entry
|
|
11
|
+
* ('/'). A screen names its own page with `useDocumentTitle` (EntityView
|
|
12
|
+
* does, with its record's title).
|
|
13
|
+
*/
|
|
8
14
|
title: string;
|
|
9
15
|
/**
|
|
10
16
|
* The app's primary navigation, in order: pages, and labelled sections of
|
|
11
17
|
* pages (`{ label, items }`, one per module, say). A nav with no sections
|
|
12
18
|
* is the single "Navigation" group. One entry is marked active: the most
|
|
13
19
|
* specific whose route matches, so '/tasks' and '/tasks/categories' can
|
|
14
|
-
* both be listed.
|
|
20
|
+
* both be listed. An entry is matched on its `to`'s path, so `to` can
|
|
21
|
+
* open a list on a saved view ('/tasks?status=open'). A section marked `collapsible` folds under its label,
|
|
15
22
|
* remembered per browser (`defaultOpen` for the first visit); the one
|
|
16
23
|
* holding the current page stays open, and the icon rail shows every
|
|
17
24
|
* section's items.
|
|
@@ -4,11 +4,12 @@ import { useTheme } from 'next-themes';
|
|
|
4
4
|
import { useEffect, useId, useState } from 'react';
|
|
5
5
|
import { Link, Navigate, Outlet, useLocation } from 'react-router';
|
|
6
6
|
import { useAuth } from '../../auth/use-auth.js';
|
|
7
|
-
import { activeNavTo, navGroups } from './app-shell-nav.js';
|
|
7
|
+
import { activeNavTo, navGroups, navPageLabel } from './app-shell-nav.js';
|
|
8
8
|
import { versionHref, versionLabel } from './app-shell-version.js';
|
|
9
9
|
import { ErrorState } from './error-state.js';
|
|
10
10
|
import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
|
|
11
11
|
import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
|
|
12
|
+
import { useShellDocumentTitle } from '../../hooks/use-document-title.js';
|
|
12
13
|
import { cn } from 'cn';
|
|
13
14
|
import { Button } from '../ui/button.js';
|
|
14
15
|
import { Skeleton } from '../ui/skeleton.js';
|
|
@@ -118,6 +119,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
|
|
|
118
119
|
// resolves: that runs before React re-renders, and the <Navigate> this
|
|
119
120
|
// component then renders starts a second navigation that wins.
|
|
120
121
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
122
|
+
useShellDocumentTitle(title, navPageLabel(nav, location.pathname));
|
|
121
123
|
if (status === 'loading') {
|
|
122
124
|
return _jsx(AppShellSkeleton, {});
|
|
123
125
|
}
|
|
@@ -24,6 +24,11 @@ export type EditControlProps<TDraft> = {
|
|
|
24
24
|
describedBy: string;
|
|
25
25
|
/** For a control that can't always produce a value to save (RichTextEditor's `onProblem`). */
|
|
26
26
|
onProblem: (message: string | null) => void;
|
|
27
|
+
/**
|
|
28
|
+
* The field is `inline`: always editable, drawn as the text it edits.
|
|
29
|
+
* Give it to RichTextEditor's `inline` (with `toolbar="floating"`).
|
|
30
|
+
*/
|
|
31
|
+
inline?: boolean;
|
|
27
32
|
};
|
|
28
33
|
/**
|
|
29
34
|
* How the value saves, which follows from its control:
|
|
@@ -60,6 +65,7 @@ export type EditInPlace = {
|
|
|
60
65
|
isEqual?: (a: unknown, b: unknown) => boolean;
|
|
61
66
|
autosave?: boolean;
|
|
62
67
|
alwaysEditable?: boolean;
|
|
68
|
+
inline?: boolean;
|
|
63
69
|
};
|
|
64
70
|
export type EditInPlaceOptions<TDraft, TValue> = {
|
|
65
71
|
kind: EditKind;
|
|
@@ -100,6 +106,15 @@ export type EditInPlaceOptions<TDraft, TValue> = {
|
|
|
100
106
|
* in rich text is edited there, not followed.
|
|
101
107
|
*/
|
|
102
108
|
alwaysEditable?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* For long text: always editable (implies `alwaysEditable`), and drawn
|
|
111
|
+
* as the read view, not as a field: no border, no box, typed into where
|
|
112
|
+
* it's read (Notion-style). The control gets `inline: true`; pass it to
|
|
113
|
+
* RichTextEditor's `inline`, with `toolbar="floating"` (the toolbar over
|
|
114
|
+
* a selection and the slash menu, no bar above the text). An empty one
|
|
115
|
+
* shows the editor's `placeholder`, not the view's "Not set".
|
|
116
|
+
*/
|
|
117
|
+
inline?: boolean;
|
|
103
118
|
};
|
|
104
119
|
/** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
|
|
105
120
|
export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
@@ -16,6 +16,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
16
|
// Ctrl/Cmd+Enter and Esc save what's left now and close it.
|
|
17
17
|
// - `alwaysEditable`: the control is shown at rest, with no pencil and no
|
|
18
18
|
// click to open, and never closes. Ctrl/Cmd+Enter and Esc save now.
|
|
19
|
+
// - `inline`: always editable, and the control is told to look like the
|
|
20
|
+
// value it edits (`inline` among its props).
|
|
19
21
|
import { CheckIcon, PencilIcon } from 'lucide-react';
|
|
20
22
|
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
21
23
|
import { useBlocker } from 'react-router';
|
|
@@ -58,10 +60,13 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
58
60
|
const key = useId();
|
|
59
61
|
const controlId = `${key}-control`;
|
|
60
62
|
const describedBy = `${key}-status`;
|
|
61
|
-
|
|
63
|
+
const always = Boolean(edit.alwaysEditable || edit.inline);
|
|
64
|
+
const autosave = always || Boolean(edit.autosave);
|
|
65
|
+
// The latest config, read by the store whenever it needs it (`inline`
|
|
66
|
+
// is always editable to it).
|
|
62
67
|
const config = useRef(edit);
|
|
63
68
|
useLayoutEffect(() => {
|
|
64
|
-
config.current = edit;
|
|
69
|
+
config.current = always && !edit.alwaysEditable ? { ...edit, alwaysEditable: true } : edit;
|
|
65
70
|
});
|
|
66
71
|
useEffect(() => store.register(key, () => config.current), [store, key]);
|
|
67
72
|
const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
|
|
@@ -72,8 +77,6 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
72
77
|
// Set by a keyboard save or Esc: the caret goes back to the edit button.
|
|
73
78
|
const refocus = useRef(false);
|
|
74
79
|
const blurTimer = useRef(undefined);
|
|
75
|
-
const always = Boolean(edit.alwaysEditable);
|
|
76
|
-
const autosave = always || Boolean(edit.autosave);
|
|
77
80
|
// An always-editable control starts over (it's uncontrolled: a rich-text
|
|
78
81
|
// editor reads its value once) when the value changed elsewhere while
|
|
79
82
|
// nothing of its own was unsaved and the caret wasn't in it.
|
|
@@ -209,7 +212,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
209
212
|
const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
|
|
210
213
|
const saving = open.status === 'saving';
|
|
211
214
|
const autosaving = open.autosave === 'saving';
|
|
212
|
-
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
|
|
215
|
+
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem), inline: Boolean(edit.inline) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
|
|
213
216
|
!open.error && (_jsxs(_Fragment, { children: [_jsx(CheckIcon, { "aria-hidden": "true", className: "size-3.5" }), "Saved"] }))) })] })) : saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.error && _jsx(FieldError, { className: "type-caption", children: open.error })) })] }));
|
|
214
217
|
}
|
|
215
218
|
/**
|
|
@@ -53,8 +53,15 @@ export type EntityViewPlacement = 'main' | 'rail';
|
|
|
53
53
|
*/
|
|
54
54
|
export type EntityViewLayout = 'column' | 'rail';
|
|
55
55
|
export type EntityViewProps = {
|
|
56
|
-
/** The record's name or title, the page's <h1
|
|
56
|
+
/** The record's name or title, the page's <h1>, and the browser tab's once it loads (`documentTitle`). */
|
|
57
57
|
title?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The browser tab's name for this record once it has loaded: `title`
|
|
60
|
+
* unless this says otherwise, read as "<this> · <app>" inside `AppShell`.
|
|
61
|
+
* `false` leaves the tab to the nav ("Widgets · <app>"). Loading, an
|
|
62
|
+
* error, and leaving the view all show the nav's title.
|
|
63
|
+
*/
|
|
64
|
+
documentTitle?: string | false;
|
|
58
65
|
/** The title is editable in place; `label` is the field's ("Name"). */
|
|
59
66
|
titleEdit?: EditInPlace & {
|
|
60
67
|
label: string;
|
|
@@ -94,4 +101,4 @@ export type EntityViewProps = {
|
|
|
94
101
|
label: string;
|
|
95
102
|
};
|
|
96
103
|
};
|
|
97
|
-
export declare function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
104
|
+
export declare function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -49,6 +49,7 @@ import { Button } from '../ui/button.js';
|
|
|
49
49
|
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
50
50
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
51
51
|
import { Skeleton } from '../ui/skeleton.js';
|
|
52
|
+
import { useDocumentTitle } from '../../hooks/use-document-title.js';
|
|
52
53
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
53
54
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
54
55
|
import { EditableList } from './editable-list.js';
|
|
@@ -140,11 +141,12 @@ function Rail({ label, sections }) {
|
|
|
140
141
|
const [ref, fits] = useFitsWindow();
|
|
141
142
|
return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
|
|
142
143
|
}
|
|
143
|
-
export function EntityView({ title, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
|
|
144
|
+
export function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
|
|
144
145
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
145
146
|
// while a field is open (or one is always editable).
|
|
146
147
|
const [store] = useState(() => new EditInPlaceStore());
|
|
147
148
|
const editing = useSyncExternalStore(store.subscribe, () => store.editing, () => store.editing);
|
|
149
|
+
useDocumentTitle(isLoading || error || documentTitle === false ? undefined : (documentTitle ?? title));
|
|
148
150
|
if (error?.kind === 'notfound') {
|
|
149
151
|
// A record that doesn't exist and one that belongs to another user are
|
|
150
152
|
// the same plain 404, so this never says which. Retrying can't help.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -33,11 +33,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
33
33
|
// length as it nears `maxLength`.
|
|
34
34
|
// - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
|
|
35
35
|
// floating one over selected words (rich-text-floating-toolbar.tsx),
|
|
36
|
-
// unless `toolbar={false}
|
|
37
|
-
// shows, else the fixed one.
|
|
36
|
+
// unless `toolbar={false}`; `toolbar="floating"` leaves out the fixed
|
|
37
|
+
// one. Alt+F10 reaches the floating one when it shows, else the fixed one.
|
|
38
38
|
// - A slash menu (rich-text-slash.ts, rich-text-slash-menu.tsx): "/" at a
|
|
39
39
|
// line's start lists the blocks it can become, also unless
|
|
40
40
|
// `toolbar={false}`.
|
|
41
|
+
// - `inline`: the field drawn as the text it edits (no border, no box),
|
|
42
|
+
// for notes that are always editable where they're read.
|
|
41
43
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
42
44
|
import { createPortal } from 'react-dom';
|
|
43
45
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
@@ -238,7 +240,7 @@ function applyLink(view, target, href) {
|
|
|
238
240
|
}
|
|
239
241
|
view.dispatch(tr.scrollIntoView());
|
|
240
242
|
}
|
|
241
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
243
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
242
244
|
const rootRef = useRef(null);
|
|
243
245
|
const editorRef = useRef(null);
|
|
244
246
|
const [linkBox, setLinkBox] = useState(null);
|
|
@@ -273,6 +275,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
273
275
|
useEffect(() => {
|
|
274
276
|
latest.current = { onChange, onProblem, readOnly, placeholder };
|
|
275
277
|
});
|
|
278
|
+
// The fixed bar above the text, and the menus over it (the floating
|
|
279
|
+
// toolbar and the slash menu), which `'floating'` keeps on their own.
|
|
280
|
+
const fixedToolbar = toolbar === true;
|
|
281
|
+
const menus = toolbar !== false;
|
|
276
282
|
const ids = useId();
|
|
277
283
|
const counterId = `${ids}-count`;
|
|
278
284
|
// The editable element's id: the app's, or one of its own, so the
|
|
@@ -297,11 +303,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
297
303
|
attributes['aria-invalid'] = 'true';
|
|
298
304
|
if (placeholder)
|
|
299
305
|
attributes['aria-placeholder'] = placeholder;
|
|
300
|
-
if (
|
|
306
|
+
if (menus)
|
|
301
307
|
attributes['aria-keyshortcuts'] = 'Alt+F10';
|
|
302
308
|
// The slash menu: open while a "/" typed at a line's start is followed
|
|
303
309
|
// by something some block answers to, and the field has focus.
|
|
304
|
-
const slash =
|
|
310
|
+
const slash = menus && !readOnly && focused && editorState ? slashQuery(editorState) : null;
|
|
305
311
|
const slashMatches = slash ? slashItems(slash.query) : [];
|
|
306
312
|
const slashOpen = slash !== null && slashMatches.length > 0;
|
|
307
313
|
const slashMenuKey = slash ? `${slash.from}:${slash.query}` : '';
|
|
@@ -674,7 +680,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
674
680
|
// The floating toolbar: words selected, focus in the field, the mouse up,
|
|
675
681
|
// and nothing else open over the text.
|
|
676
682
|
const selection = editorState?.selection;
|
|
677
|
-
const floatingOpen = Boolean(
|
|
683
|
+
const floatingOpen = Boolean(menus &&
|
|
678
684
|
!readOnly &&
|
|
679
685
|
editorView &&
|
|
680
686
|
focused &&
|
|
@@ -696,15 +702,19 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
696
702
|
setLinkBox(null);
|
|
697
703
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
698
704
|
};
|
|
699
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col
|
|
705
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
|
|
706
|
+
// Inline, it's the text itself: the caret shows where it's
|
|
707
|
+
// edited, and a refusal's reason under it says what's wrong.
|
|
708
|
+
!inline &&
|
|
709
|
+
'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
|
|
700
710
|
// Only once the editor is made: until then the editable element,
|
|
701
711
|
// and its id, aren't in the page (axe: aria-valid-attr-value).
|
|
702
|
-
controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
712
|
+
controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
|
|
703
713
|
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
704
714
|
'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
705
715
|
// The same type roles <Markdown> uses, so text reads the same
|
|
706
716
|
// being edited as being read.
|
|
707
|
-
'[&_.ProseMirror]:flex [&_.ProseMirror]:
|
|
717
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', !inline && '[&_.ProseMirror]:min-h-20',
|
|
708
718
|
// ProseMirror's own required style (prosemirror-view's CSS): spaces
|
|
709
719
|
// as typed, so one at the end of a line isn't kept as .
|
|
710
720
|
'[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary',
|
|
@@ -23,10 +23,19 @@ export type RichTextEditorProps = {
|
|
|
23
23
|
* lists, link, …), each button with its shortcut, and one floating over
|
|
24
24
|
* selected words (bold to link). Alt+F10 reaches them from the text.
|
|
25
25
|
* With them, "/" at a line's start lists the blocks the line can become
|
|
26
|
-
* (the slash menu). On by default; `
|
|
27
|
-
*
|
|
26
|
+
* (the slash menu). On by default; `'floating'` keeps the floating one
|
|
27
|
+
* and the slash menu but no bar above the text (Notion-style, with
|
|
28
|
+
* `inline`); `false` leaves the text alone, its shortcuts still working.
|
|
28
29
|
*/
|
|
29
|
-
toolbar?: boolean;
|
|
30
|
+
toolbar?: boolean | 'floating';
|
|
31
|
+
/**
|
|
32
|
+
* Looks like the text it edits: no border, no box, no padding, so it
|
|
33
|
+
* sits where `<Markdown>` would and reads the same. For notes that are
|
|
34
|
+
* always editable in place (`editInPlace`'s `inline`, which the control
|
|
35
|
+
* gets as `props.inline`); give it `toolbar="floating"` and a
|
|
36
|
+
* `placeholder`, which is all an empty one shows.
|
|
37
|
+
*/
|
|
38
|
+
inline?: boolean;
|
|
30
39
|
className?: string;
|
|
31
40
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
32
41
|
'aria-label'?: string;
|
|
@@ -21,5 +21,5 @@ import { cn } from 'cn';
|
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
22
|
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
23
|
export function RichTextEditor(props) {
|
|
24
|
-
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col rounded-lg border border-input', props.className), children: [props.toolbar
|
|
24
|
+
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
|
|
25
25
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const DOCUMENT_TITLE_SEPARATOR = " \u00B7 ";
|
|
2
|
+
type TitleTarget = {
|
|
3
|
+
title: string;
|
|
4
|
+
};
|
|
5
|
+
export type DocumentTitles = {
|
|
6
|
+
/** AppShell's: the app's name and the nav's page (none on the home page). `null` once it unmounts. */
|
|
7
|
+
setShell: (shell: {
|
|
8
|
+
app: string;
|
|
9
|
+
page?: string;
|
|
10
|
+
} | null) => void;
|
|
11
|
+
/** A view's page title, by the view's id. `undefined` (not loaded yet) leaves the tab to the others; its place is kept. */
|
|
12
|
+
setView: (id: string, page: string | undefined) => void;
|
|
13
|
+
/** The view unmounted. */
|
|
14
|
+
releaseView: (id: string) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function createDocumentTitles(target: TitleTarget): DocumentTitles;
|
|
17
|
+
/**
|
|
18
|
+
* Names the browser tab after this screen while it's mounted: "New Widget"
|
|
19
|
+
* reads "New Widget · <app>" inside `AppShell`. Wins over the page the nav
|
|
20
|
+
* names, whichever arrives first; when the screen goes, the nav's title
|
|
21
|
+
* comes back. `undefined` (a record still loading) leaves the nav's.
|
|
22
|
+
* `EntityView` already does this with its `title`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function useDocumentTitle(title: string | undefined): void;
|
|
25
|
+
/** AppShell's side: the app's name and the page its nav names. */
|
|
26
|
+
export declare function useShellDocumentTitle(app: string, page: string | undefined): void;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useId } from 'react';
|
|
2
|
+
// The browser tab's title, "<page> · <app>", from two sources that can
|
|
3
|
+
// arrive in either order: AppShell's (the app's name, and the page its nav
|
|
4
|
+
// names for the current path) and the views' own (useDocumentTitle, and
|
|
5
|
+
// EntityView's record once it loads). A view's title wins over the nav's
|
|
6
|
+
// while that view is mounted, whichever was set first, and the nav's comes
|
|
7
|
+
// back when it goes. With neither, the tab keeps the title it had before
|
|
8
|
+
// (index.html's).
|
|
9
|
+
//
|
|
10
|
+
// createDocumentTitles takes the document, so the rules are unit tested
|
|
11
|
+
// against a fake one (tests/use-document-title.test.ts).
|
|
12
|
+
export const DOCUMENT_TITLE_SEPARATOR = ' · ';
|
|
13
|
+
export function createDocumentTitles(target) {
|
|
14
|
+
let shell = null;
|
|
15
|
+
// In the order views first set a title: the latest one showing wins.
|
|
16
|
+
const views = new Map();
|
|
17
|
+
// The title before anything here set one, put back once nothing sets one.
|
|
18
|
+
let before = null;
|
|
19
|
+
function apply() {
|
|
20
|
+
const view = [...views.values()].reverse().find((page) => page);
|
|
21
|
+
const page = view ?? shell?.page;
|
|
22
|
+
const title = shell ? (page ? `${page}${DOCUMENT_TITLE_SEPARATOR}${shell.app}` : shell.app) : page;
|
|
23
|
+
if (title) {
|
|
24
|
+
before ??= target.title;
|
|
25
|
+
target.title = title;
|
|
26
|
+
}
|
|
27
|
+
else if (before !== null) {
|
|
28
|
+
target.title = before;
|
|
29
|
+
before = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
setShell(next) {
|
|
34
|
+
shell = next;
|
|
35
|
+
apply();
|
|
36
|
+
},
|
|
37
|
+
setView(id, page) {
|
|
38
|
+
views.set(id, page || undefined);
|
|
39
|
+
apply();
|
|
40
|
+
},
|
|
41
|
+
releaseView(id) {
|
|
42
|
+
views.delete(id);
|
|
43
|
+
apply();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// One per page, made on first use (never while rendering on a server).
|
|
48
|
+
let documentTitles;
|
|
49
|
+
const titles = () => (documentTitles ??= createDocumentTitles(document));
|
|
50
|
+
/**
|
|
51
|
+
* Names the browser tab after this screen while it's mounted: "New Widget"
|
|
52
|
+
* reads "New Widget · <app>" inside `AppShell`. Wins over the page the nav
|
|
53
|
+
* names, whichever arrives first; when the screen goes, the nav's title
|
|
54
|
+
* comes back. `undefined` (a record still loading) leaves the nav's.
|
|
55
|
+
* `EntityView` already does this with its `title`.
|
|
56
|
+
*/
|
|
57
|
+
export function useDocumentTitle(title) {
|
|
58
|
+
const id = useId();
|
|
59
|
+
// Two effects, so a changed title keeps this screen's place among others
|
|
60
|
+
// and only unmounting gives it up.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
titles().setView(id, title);
|
|
63
|
+
}, [id, title]);
|
|
64
|
+
useEffect(() => () => titles().releaseView(id), [id]);
|
|
65
|
+
}
|
|
66
|
+
/** AppShell's side: the app's name and the page its nav names. */
|
|
67
|
+
export function useShellDocumentTitle(app, page) {
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
titles().setShell({ app, page });
|
|
70
|
+
}, [app, page]);
|
|
71
|
+
useEffect(() => () => titles().setShell(null), []);
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
46
46
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
47
47
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
48
48
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
49
|
+
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
49
50
|
export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
|
|
50
51
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
51
52
|
export { useWakeLock } from './hooks/use-wake-lock.js';
|
package/dist/index.js
CHANGED
|
@@ -50,6 +50,7 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
50
50
|
// Hooks
|
|
51
51
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
52
52
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
53
|
+
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
53
54
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
|
54
55
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
55
56
|
// Timed screens (docs/foundation/timed-screens.md)
|
package/dist/testing/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// are optional peer dependencies, needed only by apps that import this.
|
|
14
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
15
15
|
import { expect, test } from '@playwright/test';
|
|
16
|
-
import {
|
|
16
|
+
import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
|
|
17
17
|
/**
|
|
18
18
|
* Forces a response for the page's *first* load: set before navigation, so
|
|
19
19
|
* it wins the race against the app's own first fetch. For states forced
|
|
@@ -80,7 +80,8 @@ export async function expectNoAxeViolations(page, options = {}) {
|
|
|
80
80
|
const results = await builder.analyze();
|
|
81
81
|
expect(results.violations).toEqual([]);
|
|
82
82
|
}
|
|
83
|
-
// The app's own pages, read from the Primary nav
|
|
83
|
+
// The app's own pages, read from the Primary nav: each link's path and
|
|
84
|
+
// query string, as it would be visited. Below the sidebar's
|
|
84
85
|
// mobile breakpoint the nav renders into an off-canvas Sheet *portaled to
|
|
85
86
|
// document.body*, outside the <nav aria-label="Primary"> landmark, so the
|
|
86
87
|
// links are read from the sheet there. Keyed off the `isMobile` fixture,
|
|
@@ -100,13 +101,14 @@ async function navRoutes(page, isMobile) {
|
|
|
100
101
|
.evaluateAll((links) => links.map((link) => link.href));
|
|
101
102
|
// Only this app's pages: a sidebarExtra group may link to other sites.
|
|
102
103
|
const origin = new URL(page.url()).origin;
|
|
103
|
-
|
|
104
|
+
const own = hrefs.map((href) => new URL(href)).filter((url) => url.origin === origin);
|
|
105
|
+
return [...new Set(own.map((url) => `${url.pathname}${url.search}`))];
|
|
104
106
|
}
|
|
107
|
+
// The pages a list of routes names, by path: a `to` that opens a list on a
|
|
108
|
+
// saved view ('/widgets?status=active') is the /widgets page.
|
|
109
|
+
const pagePaths = (routes) => [...new Set(routes.map(navPathname))].sort();
|
|
105
110
|
// A page's share of the one discovering test's time: load, data, axe.
|
|
106
111
|
const NAV_PAGE_BUDGET_MS = 10_000;
|
|
107
|
-
function navEntryRoutes(nav) {
|
|
108
|
-
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
109
|
-
}
|
|
110
112
|
/**
|
|
111
113
|
* Every data view on the page has loaded: at least one shows its data (or,
|
|
112
114
|
* with `{ success: false }`, the page has its screen up), and none still
|
|
@@ -144,11 +146,13 @@ async function expectNavPageAccessible(page, route, colorScheme) {
|
|
|
144
146
|
* emulateMedia switches it.
|
|
145
147
|
*/
|
|
146
148
|
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
|
|
147
|
-
|
|
149
|
+
// Each `to` as given, query string and all, so a page that opens on a
|
|
150
|
+
// saved view is checked on it.
|
|
151
|
+
const listed = nav && navTargets(nav);
|
|
148
152
|
if (listed) {
|
|
149
153
|
test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
|
|
150
154
|
const rendered = await navRoutes(page, isMobile);
|
|
151
|
-
expect(
|
|
155
|
+
expect(pagePaths(rendered), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual(pagePaths(listed));
|
|
152
156
|
});
|
|
153
157
|
}
|
|
154
158
|
for (const colorScheme of ['light', 'dark']) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.21.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": {
|