@vespercms/admin 0.0.0-stage → 0.1.0-next.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/README.md +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPlainObject,
|
|
3
|
+
resolveMediaPath,
|
|
4
|
+
variantFor,
|
|
5
|
+
type DataObject,
|
|
6
|
+
type DataValue,
|
|
7
|
+
type FieldModel,
|
|
8
|
+
type FieldVariant,
|
|
9
|
+
} from '@vespercms/core';
|
|
10
|
+
import { Button, Chevron, cx, Dialog, focusRing, Icon, Menu, type MenuItem } from '@vespercms/ui';
|
|
11
|
+
import { useEffect, useRef, useState, type DragEvent, type ReactNode, type RefObject } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
Button as RACButton,
|
|
14
|
+
Disclosure,
|
|
15
|
+
DisclosurePanel,
|
|
16
|
+
Heading,
|
|
17
|
+
useDrag,
|
|
18
|
+
useDrop,
|
|
19
|
+
type KeyboardEvent,
|
|
20
|
+
} from 'react-aria-components';
|
|
21
|
+
import { useFileUrl } from '../app/file-url.tsx';
|
|
22
|
+
import { formatCount } from '../app/format.ts';
|
|
23
|
+
import { useEditor, type EditorState } from '../editor/context.tsx';
|
|
24
|
+
import { Field } from '../editor/Field.tsx';
|
|
25
|
+
import { REVEAL_EVENT } from '../editor/reveal.ts';
|
|
26
|
+
import { keyOf, pathKey, type ValuePath } from '../editor/values.ts';
|
|
27
|
+
import type { WidgetProps } from '../types.ts';
|
|
28
|
+
import { changeKind, changeSummary, kindRow, kindValue } from './union.tsx';
|
|
29
|
+
|
|
30
|
+
type GroupProps = WidgetProps<DataValue> & { path: ValuePath };
|
|
31
|
+
|
|
32
|
+
/** An object: its fields, in schema order, inside the field's fieldset. */
|
|
33
|
+
export function GroupWidget({ field, path }: GroupProps) {
|
|
34
|
+
const children = (field.children ?? []).filter((child) => !child.hidden);
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col gap-4">
|
|
37
|
+
{children.map((child) => (
|
|
38
|
+
<Field key={keyOf(child)} field={child} path={[...path, keyOf(child)]} />
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What one item and several are called: "point" and "points", from the list's own label. */
|
|
45
|
+
export function itemNames(list: FieldModel): { one: string; many: string } {
|
|
46
|
+
const item = list.children?.[0];
|
|
47
|
+
const many = list.label.toLowerCase();
|
|
48
|
+
const own = item?.label && item.label !== '[]' && item.label !== 'Item' ? item.label : '';
|
|
49
|
+
const one = own ? own.toLowerCase() : /[^s]s$/.test(many) ? many.slice(0, -1) : many;
|
|
50
|
+
return { one, many };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const capital = (text: string) => text.charAt(0).toLocaleUpperCase() + text.slice(1);
|
|
54
|
+
|
|
55
|
+
const NAME_KEYS = ['title', 'name', 'label', 'heading', 'text'];
|
|
56
|
+
|
|
57
|
+
function isImageItem(item: FieldModel | undefined): boolean {
|
|
58
|
+
return item?.kind === 'image' || item?.widget === 'image' || item?.inferredWidget === 'image';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fileName(path: string): string {
|
|
62
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function imageSource(value: DataValue): string {
|
|
66
|
+
const src = isPlainObject(value) ? value.src : value;
|
|
67
|
+
return typeof src === 'string' ? src : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Markdown's marks would read as noise in a one-line name. */
|
|
71
|
+
const plain = (text: string) => text.replace(/[*_`]/g, '').trim();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A list item's name, from its own content: an image's alt text or file
|
|
75
|
+
* name; otherwise the `summary` hint's field, a title-like field, or its
|
|
76
|
+
* first text, in that order; else "Point 3".
|
|
77
|
+
*/
|
|
78
|
+
export function itemSummary(
|
|
79
|
+
value: DataValue,
|
|
80
|
+
index: number,
|
|
81
|
+
list: FieldModel,
|
|
82
|
+
): { name: string; key: string | undefined } {
|
|
83
|
+
const fallback = { name: `${capital(itemNames(list).one)} ${String(index + 1)}`, key: undefined };
|
|
84
|
+
if (typeof value === 'string')
|
|
85
|
+
return value.trim() ? { name: plain(value), key: undefined } : fallback;
|
|
86
|
+
if (isImageItem(list.children?.[0])) {
|
|
87
|
+
const alt = isPlainObject(value) && typeof value.alt === 'string' ? value.alt.trim() : '';
|
|
88
|
+
const src = imageSource(value);
|
|
89
|
+
if (alt) return { name: alt, key: 'alt' };
|
|
90
|
+
return src ? { name: fileName(src), key: 'src' } : fallback;
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(value)) return fallback;
|
|
93
|
+
// The hint, then a title-like field, then the item's first text field, in schema order.
|
|
94
|
+
const order = (list.children?.[0]?.children ?? []).filter((c) => !c.hidden).map(keyOf);
|
|
95
|
+
for (const key of [list.summary, ...NAME_KEYS, ...order]) {
|
|
96
|
+
if (!key) continue;
|
|
97
|
+
const text = value[key];
|
|
98
|
+
if (typeof text === 'string' && text.trim()) return { name: plain(text), key };
|
|
99
|
+
}
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The muted words after an item's name: how many of its first list it holds, else its next text. */
|
|
104
|
+
function itemDetail(value: DataValue, item: FieldModel, nameKey: string | undefined): string {
|
|
105
|
+
if (!isPlainObject(value)) return '';
|
|
106
|
+
const children = item.children ?? [];
|
|
107
|
+
const list = children.find((child) => child.kind === 'array' && !child.hidden);
|
|
108
|
+
if (list) {
|
|
109
|
+
const held = value[keyOf(list)];
|
|
110
|
+
const { one, many } = itemNames(list);
|
|
111
|
+
return formatCount(Array.isArray(held) ? held.length : 0, one, many);
|
|
112
|
+
}
|
|
113
|
+
for (const child of children) {
|
|
114
|
+
const key = keyOf(child);
|
|
115
|
+
const text = value[key];
|
|
116
|
+
if (key !== nameKey && !child.hidden && typeof text === 'string' && text.trim()) {
|
|
117
|
+
return plain(text);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How many of the item's fields have something to say: problems from the last save, or notes. */
|
|
124
|
+
function countUnder(map: ReadonlyMap<string, readonly unknown[]> | undefined, key: string): number {
|
|
125
|
+
if (!map) return 0;
|
|
126
|
+
let count = 0;
|
|
127
|
+
for (const [at, list] of map) {
|
|
128
|
+
if ((at === key || at.startsWith(`${key}.`)) && list.length > 0) count += list.length;
|
|
129
|
+
}
|
|
130
|
+
return count;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a new item holds: `{}` for objects, nothing for scalars. */
|
|
134
|
+
function emptyItem(item: FieldModel | undefined): DataValue {
|
|
135
|
+
if (!item) return null;
|
|
136
|
+
if (item.default !== undefined && !item.defaultDynamic)
|
|
137
|
+
return structuredClone(item.default) as DataValue;
|
|
138
|
+
if (item.kind === 'object') return {};
|
|
139
|
+
if (item.kind === 'boolean') return false;
|
|
140
|
+
if (item.kind === 'array') return [];
|
|
141
|
+
if (item.kind === 'number') return 0;
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let lastItemId = 0;
|
|
146
|
+
const newItemId = () => `item${String(++lastItemId)}`;
|
|
147
|
+
|
|
148
|
+
/** A dragged row carries only this, so nothing else on the page (a Markdown field) takes it as text. */
|
|
149
|
+
const DRAG_TYPE = 'application/x-vesper-list-item';
|
|
150
|
+
|
|
151
|
+
/** Where a dragged row would land: before the row at `slot`, drawn as a line `top` pixels down the list. */
|
|
152
|
+
type Landing = { slot: number; top: number };
|
|
153
|
+
|
|
154
|
+
/** A change of kind that would remove content, waiting for the person to confirm it. */
|
|
155
|
+
type PendingChange = {
|
|
156
|
+
index: number;
|
|
157
|
+
itemId: string;
|
|
158
|
+
from: string;
|
|
159
|
+
to: FieldVariant;
|
|
160
|
+
/** Labels of the fields with content the new kind has no place for. */
|
|
161
|
+
dropped: string[];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** "Heading and Cards": a short list of field labels in words. */
|
|
165
|
+
function inWords(labels: readonly string[]): string {
|
|
166
|
+
return labels.length <= 1
|
|
167
|
+
? (labels[0] ?? '')
|
|
168
|
+
: `${labels.slice(0, -1).join(', ')} and ${labels.at(-1) ?? ''}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The repeater (spec §6.3.2). A list of objects or images shows each item as
|
|
173
|
+
* a row named by its content, which opens in place (DECISIONS.md, "Top-level
|
|
174
|
+
* groups are sections, and list items are rows that open in place"). Rows
|
|
175
|
+
* are closed when the entry opens, except a new item and any holding a field
|
|
176
|
+
* that a refused save names. Order never depends on drag-and-drop (spec
|
|
177
|
+
* §10.6): each item's "…" menu moves it, and Alt+↑/↓ on a row does too. A
|
|
178
|
+
* row's handle drags it as well, for the pointer only (DECISIONS.md, "List
|
|
179
|
+
* rows drag by a handle"). Closed items stay in the page
|
|
180
|
+
* (`hidden="until-found"`), so the browser's find, the error summary and Go
|
|
181
|
+
* to field still reach every field.
|
|
182
|
+
*
|
|
183
|
+
* A list of kinds (a discriminated union) names each row by its kind. Add
|
|
184
|
+
* offers the kinds, each row's "…" menu adds one below it or changes its
|
|
185
|
+
* kind, and a change that would remove content asks first (DECISIONS.md,
|
|
186
|
+
* "Blocks are a discriminated union of objects").
|
|
187
|
+
*/
|
|
188
|
+
export function RepeaterWidget({ value, onChange, field, path, disabled, id }: GroupProps) {
|
|
189
|
+
const editor = useEditor();
|
|
190
|
+
const items: DataValue[] = Array.isArray(value) ? value : [];
|
|
191
|
+
const item = field.children?.[0];
|
|
192
|
+
const names = itemNames(field);
|
|
193
|
+
const kinds = item?.kind === 'union' ? (item.variants ?? []) : null;
|
|
194
|
+
const asRows = item?.kind === 'object' || kinds !== null || isImageItem(item);
|
|
195
|
+
const max = field.constraints?.max;
|
|
196
|
+
const full = max !== undefined && items.length >= max;
|
|
197
|
+
const status = useRef<HTMLParagraphElement>(null);
|
|
198
|
+
const list = useRef<HTMLOListElement>(null);
|
|
199
|
+
// What each item held as each kind it left, so changing back before saving brings it back.
|
|
200
|
+
const setAside = useRef(new Map<string, Map<string, DataObject>>());
|
|
201
|
+
const [pending, setPending] = useState<PendingChange | null>(null);
|
|
202
|
+
|
|
203
|
+
// Items have no identity in the file. Each gets one for this visit, so the
|
|
204
|
+
// open rows and focus follow an item through moves; the file never sees it.
|
|
205
|
+
const [kept, setKept] = useState<readonly string[]>(() => items.map(() => newItemId()));
|
|
206
|
+
let ids = kept;
|
|
207
|
+
if (kept.length !== items.length) {
|
|
208
|
+
// A change from elsewhere (a reload, a restore) changed the count: ids follow by position.
|
|
209
|
+
ids = items.map((_, index) => kept[index] ?? newItemId());
|
|
210
|
+
setKept(ids);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set());
|
|
214
|
+
const [menuFor, setMenuFor] = useState<string | null>(null);
|
|
215
|
+
const toggle = (itemId: string, expanded: boolean) => {
|
|
216
|
+
setOpen((now) => {
|
|
217
|
+
if (now.has(itemId) === expanded) return now;
|
|
218
|
+
const next = new Set(now);
|
|
219
|
+
if (expanded) next.add(itemId);
|
|
220
|
+
else next.delete(itemId);
|
|
221
|
+
return next;
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// A refused save opens every item holding a field it names, so the message shows by its field.
|
|
226
|
+
const [seenIssues, setSeenIssues] = useState(editor.issues);
|
|
227
|
+
if (seenIssues !== editor.issues) {
|
|
228
|
+
setSeenIssues(editor.issues);
|
|
229
|
+
const failed = ids.filter(
|
|
230
|
+
(_, index) => countUnder(editor.issues, pathKey([...path, index])) > 0,
|
|
231
|
+
);
|
|
232
|
+
if (failed.length > 0) setOpen((now) => new Set([...now, ...failed]));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Going to a field inside a closed item (the error summary, Go to field) opens it first.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const element = list.current;
|
|
238
|
+
if (!element) return;
|
|
239
|
+
const reveal = (event: Event) => {
|
|
240
|
+
const target = event.target as Node;
|
|
241
|
+
const index = [...element.children].findIndex((child) => child.contains(target));
|
|
242
|
+
const itemId = ids[index];
|
|
243
|
+
if (itemId) setOpen((now) => (now.has(itemId) ? now : new Set([...now, itemId])));
|
|
244
|
+
};
|
|
245
|
+
element.addEventListener(REVEAL_EVENT, reveal);
|
|
246
|
+
return () => {
|
|
247
|
+
element.removeEventListener(REVEAL_EVENT, reveal);
|
|
248
|
+
};
|
|
249
|
+
}, [ids]);
|
|
250
|
+
|
|
251
|
+
const announce = (text: string) => {
|
|
252
|
+
if (status.current) status.current.textContent = text;
|
|
253
|
+
};
|
|
254
|
+
/** After the next render: a row's own button, a new item's first control, or Add. */
|
|
255
|
+
const focusLater = (selector: () => HTMLElement | null) => {
|
|
256
|
+
// A timeout, not an animation frame: React Aria returns focus to a closed menu's button in one.
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
selector()?.focus();
|
|
259
|
+
}, 0);
|
|
260
|
+
};
|
|
261
|
+
const moreButton = (itemId: string) =>
|
|
262
|
+
document.getElementById(`${id}-more-${itemId}`)?.querySelector('button') ?? null;
|
|
263
|
+
const rowButton = (itemId: string) => () =>
|
|
264
|
+
asRows ? document.getElementById(`${id}-row-${itemId}`) : moreButton(itemId);
|
|
265
|
+
const update = (next: DataValue[]) => {
|
|
266
|
+
onChange(next.length === 0 && !field.required ? undefined : next);
|
|
267
|
+
};
|
|
268
|
+
/** A row's name and the muted words after it. */
|
|
269
|
+
const rowOf = (entry: DataValue, index: number): { name: string; detail: string } => {
|
|
270
|
+
if (kinds && item) return kindRow(entry, item);
|
|
271
|
+
const { name, key } = itemSummary(entry, index, field);
|
|
272
|
+
return { name, detail: item && asRows ? itemDetail(entry, item, key) : '' };
|
|
273
|
+
};
|
|
274
|
+
const nameAt = (index: number) => rowOf(items[index] ?? null, index).name;
|
|
275
|
+
const kindNamed = (named: string) => kinds?.find((kind) => kind.value === named);
|
|
276
|
+
|
|
277
|
+
const move = (from: number, to: number) => {
|
|
278
|
+
if (to < 0 || to >= items.length || disabled) return;
|
|
279
|
+
const name = nameAt(from);
|
|
280
|
+
const next = [...items];
|
|
281
|
+
const [moved] = next.splice(from, 1);
|
|
282
|
+
next.splice(to, 0, moved ?? null);
|
|
283
|
+
const nextIds = [...ids];
|
|
284
|
+
const [movedId = newItemId()] = nextIds.splice(from, 1);
|
|
285
|
+
nextIds.splice(to, 0, movedId);
|
|
286
|
+
setKept(nextIds);
|
|
287
|
+
update(next);
|
|
288
|
+
announce(`Moved ${name} to position ${String(to + 1)} of ${String(next.length)}.`);
|
|
289
|
+
focusLater(rowButton(movedId));
|
|
290
|
+
};
|
|
291
|
+
const duplicate = (index: number) => {
|
|
292
|
+
if (full) return;
|
|
293
|
+
const copy = structuredClone(items[index] ?? null);
|
|
294
|
+
const copyId = newItemId();
|
|
295
|
+
setKept([...ids.slice(0, index + 1), copyId, ...ids.slice(index + 1)]);
|
|
296
|
+
update([...items.slice(0, index + 1), copy, ...items.slice(index + 1)]);
|
|
297
|
+
announce(`Duplicated ${nameAt(index)} as ${names.one} ${String(index + 2)}.`);
|
|
298
|
+
focusLater(rowButton(copyId));
|
|
299
|
+
};
|
|
300
|
+
const remove = (index: number) => {
|
|
301
|
+
const name = nameAt(index);
|
|
302
|
+
const nextIds = ids.filter((_, i) => i !== index);
|
|
303
|
+
const next = items.filter((_, i) => i !== index);
|
|
304
|
+
setKept(nextIds);
|
|
305
|
+
update(next);
|
|
306
|
+
announce(`Removed ${name}.`);
|
|
307
|
+
const neighbour = nextIds[Math.min(index, next.length - 1)];
|
|
308
|
+
focusLater(neighbour ? rowButton(neighbour) : () => document.getElementById(`${id}-add`));
|
|
309
|
+
};
|
|
310
|
+
/** A new item at `at`: of `kind` in a list of kinds, otherwise empty. */
|
|
311
|
+
const insertAt = (at: number, kind?: FieldVariant) => {
|
|
312
|
+
if (full || disabled) return;
|
|
313
|
+
const newId = newItemId();
|
|
314
|
+
setKept([...ids.slice(0, at), newId, ...ids.slice(at)]);
|
|
315
|
+
if (asRows) toggle(newId, true);
|
|
316
|
+
const fresh = kind && item ? kindValue(item, kind) : emptyItem(item);
|
|
317
|
+
update([...items.slice(0, at), fresh, ...items.slice(at)]);
|
|
318
|
+
const position = `${names.one} ${String(at + 1)}`;
|
|
319
|
+
announce(
|
|
320
|
+
kind
|
|
321
|
+
? `Added ${kind.label}, ${position} of ${String(items.length + 1)}.`
|
|
322
|
+
: `Added ${position}.`,
|
|
323
|
+
);
|
|
324
|
+
focusLater(
|
|
325
|
+
() =>
|
|
326
|
+
document
|
|
327
|
+
.getElementById(`${id}-panel-${newId}`)
|
|
328
|
+
?.querySelector<HTMLElement>('input, textarea, [contenteditable="true"], button') ??
|
|
329
|
+
rowButton(newId)(),
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Only the list a row is dragged from takes the drop. Until then every list stands aside,
|
|
334
|
+
// so the lists inside its open rows let the drag through, and no row lands in another list.
|
|
335
|
+
const [dragged, setDragged] = useState<string | null>(null);
|
|
336
|
+
const [landing, setLanding] = useState<Landing | null>(null);
|
|
337
|
+
const landingAt = (y: number): Landing | null => {
|
|
338
|
+
const element = list.current;
|
|
339
|
+
const from = dragged === null ? -1 : ids.indexOf(dragged);
|
|
340
|
+
if (!element || from === -1) return null;
|
|
341
|
+
const box = element.getBoundingClientRect();
|
|
342
|
+
const rows = [...element.children].map((child) => child.getBoundingClientRect());
|
|
343
|
+
// Before the first row whose middle is below the pointer.
|
|
344
|
+
const found = rows.findIndex((row) => box.top + y < row.top + row.height / 2);
|
|
345
|
+
const slot = found === -1 ? rows.length : found;
|
|
346
|
+
const before = rows[slot - 1];
|
|
347
|
+
const after = rows[slot];
|
|
348
|
+
// Either side of the dragged row is where it is already.
|
|
349
|
+
if (slot === from || slot === from + 1 || (!before && !after)) return null;
|
|
350
|
+
// The line sits in the middle of the gap, or as far outside the first or last row.
|
|
351
|
+
const half = (Number.parseFloat(getComputedStyle(element).rowGap) || 0) / 2;
|
|
352
|
+
const top =
|
|
353
|
+
before && after
|
|
354
|
+
? (before.bottom + after.top) / 2
|
|
355
|
+
: before
|
|
356
|
+
? before.bottom + half
|
|
357
|
+
: (after?.top ?? 0) - half;
|
|
358
|
+
// Drawn beside the list, in the widget's own box.
|
|
359
|
+
const frame = element.parentElement?.getBoundingClientRect() ?? box;
|
|
360
|
+
return { slot, top: top - frame.top };
|
|
361
|
+
};
|
|
362
|
+
const { dropProps } = useDrop({
|
|
363
|
+
ref: list,
|
|
364
|
+
isDisabled: dragged === null,
|
|
365
|
+
// Its drags are the pointer's: the keyboard's drag and drop isn't offered here.
|
|
366
|
+
hasDropButton: true,
|
|
367
|
+
getDropOperation: (types) => (types.has(DRAG_TYPE) ? 'move' : 'cancel'),
|
|
368
|
+
onDropEnter: ({ y }) => {
|
|
369
|
+
setLanding(landingAt(y));
|
|
370
|
+
},
|
|
371
|
+
onDropMove: ({ y }) => {
|
|
372
|
+
const next = landingAt(y);
|
|
373
|
+
setLanding((now) => (now?.slot === next?.slot ? now : next));
|
|
374
|
+
},
|
|
375
|
+
onDropExit: () => {
|
|
376
|
+
setLanding(null);
|
|
377
|
+
},
|
|
378
|
+
onDrop: ({ y }) => {
|
|
379
|
+
const at = landingAt(y);
|
|
380
|
+
const from = dragged === null ? -1 : ids.indexOf(dragged);
|
|
381
|
+
setLanding(null);
|
|
382
|
+
// The same move as the menu's and the keyboard's, announced the same way.
|
|
383
|
+
if (at && from !== -1) move(from, at.slot > from ? at.slot - 1 : at.slot);
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
const drag = disabled
|
|
387
|
+
? undefined
|
|
388
|
+
: (itemId: string) => ({
|
|
389
|
+
onStart: () => {
|
|
390
|
+
setDragged(itemId);
|
|
391
|
+
},
|
|
392
|
+
onEnd: () => {
|
|
393
|
+
setDragged(null);
|
|
394
|
+
setLanding(null);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/** Moves an item to another kind; one that would remove content waits for a confirmation. */
|
|
399
|
+
const changeTo = (index: number, to: FieldVariant, confirmed = false) => {
|
|
400
|
+
const itemId = ids[index];
|
|
401
|
+
if (!item || !itemId || disabled) return;
|
|
402
|
+
const entry = items[index] ?? null;
|
|
403
|
+
const asides = setAside.current.get(itemId) ?? new Map<string, DataObject>();
|
|
404
|
+
const change = changeKind(item, entry, to, asides.get(to.value));
|
|
405
|
+
if (change.dropped.length > 0 && !confirmed) {
|
|
406
|
+
setPending({ index, itemId, from: nameAt(index), to, dropped: change.dropped });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const from = variantFor(item, entry);
|
|
410
|
+
if (from && isPlainObject(entry)) asides.set(from.value, entry);
|
|
411
|
+
setAside.current.set(itemId, asides);
|
|
412
|
+
update(items.map((one, i) => (i === index ? change.value : one)));
|
|
413
|
+
announce(`Changed to ${to.label}. ${changeSummary(change)}`.trim());
|
|
414
|
+
focusLater(rowButton(itemId));
|
|
415
|
+
};
|
|
416
|
+
/** The kinds as menu items, each with its description: "Add below" and "Change to" open these. */
|
|
417
|
+
const kindItems = (prefix: string, except?: string): MenuItem[] =>
|
|
418
|
+
(kinds ?? [])
|
|
419
|
+
.filter((kind) => kind.value !== except)
|
|
420
|
+
.map((kind) => ({
|
|
421
|
+
id: `${prefix}:${kind.value}`,
|
|
422
|
+
label: kind.label,
|
|
423
|
+
...(kind.description ? { description: kind.description } : {}),
|
|
424
|
+
}));
|
|
425
|
+
const actions = (index: number): MenuItem[] => {
|
|
426
|
+
const moves: MenuItem[] = [
|
|
427
|
+
{ id: 'up', label: 'Move up', icon: 'move-up', isDisabled: disabled || index === 0 },
|
|
428
|
+
{
|
|
429
|
+
id: 'down',
|
|
430
|
+
label: 'Move down',
|
|
431
|
+
icon: 'move-down',
|
|
432
|
+
isDisabled: disabled || index === items.length - 1,
|
|
433
|
+
},
|
|
434
|
+
];
|
|
435
|
+
const last: MenuItem[] = [
|
|
436
|
+
{ id: 'duplicate', label: 'Duplicate', icon: 'duplicate', isDisabled: disabled || full },
|
|
437
|
+
{ id: 'remove', label: 'Remove', icon: 'delete', isDanger: true, isDisabled: disabled },
|
|
438
|
+
];
|
|
439
|
+
if (!kinds || !item) return [...moves, ...last];
|
|
440
|
+
const [only] = kinds;
|
|
441
|
+
const below: MenuItem =
|
|
442
|
+
kinds.length === 1 && only
|
|
443
|
+
? { id: `below:${only.value}`, label: 'Add below', icon: 'add' }
|
|
444
|
+
: { id: 'below', label: 'Add below', icon: 'add', items: kindItems('below') };
|
|
445
|
+
const others = kindItems('change', variantFor(item, items[index] ?? null)?.value);
|
|
446
|
+
return [
|
|
447
|
+
...moves,
|
|
448
|
+
{ ...below, isDisabled: disabled || full },
|
|
449
|
+
...(others.length > 0
|
|
450
|
+
? [
|
|
451
|
+
{
|
|
452
|
+
id: 'change',
|
|
453
|
+
label: 'Change to',
|
|
454
|
+
icon: 'change-kind' as const,
|
|
455
|
+
isDisabled: disabled,
|
|
456
|
+
items: others,
|
|
457
|
+
},
|
|
458
|
+
]
|
|
459
|
+
: []),
|
|
460
|
+
...last,
|
|
461
|
+
];
|
|
462
|
+
};
|
|
463
|
+
const act = (index: number) => (action: string) => {
|
|
464
|
+
if (action === 'up') move(index, index - 1);
|
|
465
|
+
if (action === 'down') move(index, index + 1);
|
|
466
|
+
if (action === 'duplicate') duplicate(index);
|
|
467
|
+
if (action === 'remove') remove(index);
|
|
468
|
+
const below = action.startsWith('below:') ? kindNamed(action.slice(6)) : undefined;
|
|
469
|
+
if (below) insertAt(index + 1, below);
|
|
470
|
+
const change = action.startsWith('change:') ? kindNamed(action.slice(7)) : undefined;
|
|
471
|
+
if (change) changeTo(index, change);
|
|
472
|
+
};
|
|
473
|
+
const itemMenu = (index: number, itemId: string, spoken: string) => (
|
|
474
|
+
<ItemMenu
|
|
475
|
+
id={`${id}-more-${itemId}`}
|
|
476
|
+
label={`Actions for ${spoken}`}
|
|
477
|
+
items={actions(index)}
|
|
478
|
+
onAction={act(index)}
|
|
479
|
+
isOpen={menuFor === itemId}
|
|
480
|
+
onOpenChange={(isOpen) => {
|
|
481
|
+
setMenuFor(isOpen ? itemId : null);
|
|
482
|
+
}}
|
|
483
|
+
/>
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const addLabel = `Add ${names.one}`;
|
|
487
|
+
const allOpen = items.length > 0 && ids.every((itemId) => open.has(itemId));
|
|
488
|
+
return (
|
|
489
|
+
<div className="relative flex flex-col gap-3">
|
|
490
|
+
{asRows && items.length >= 3 && (
|
|
491
|
+
<div className="-mt-1 flex justify-end">
|
|
492
|
+
<Button
|
|
493
|
+
variant="quiet"
|
|
494
|
+
onPress={() => {
|
|
495
|
+
setOpen(allOpen ? new Set() : new Set(ids));
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
{allOpen ? 'Close all' : 'Open all'}
|
|
499
|
+
</Button>
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
{items.length === 0 && <p className="text-small text-content-muted">None yet.</p>}
|
|
503
|
+
<ol {...dropProps} ref={list} className={cx('flex flex-col', asRows ? 'gap-2' : 'gap-3')}>
|
|
504
|
+
{items.map((entry, index) => {
|
|
505
|
+
const itemId = ids[index] ?? String(index);
|
|
506
|
+
const { name, detail } = rowOf(entry, index);
|
|
507
|
+
// Several rows can share a kind ("Card grid"), so its words go into the menu's name.
|
|
508
|
+
const spoken = kinds && detail ? `${name}, ${detail}` : name;
|
|
509
|
+
if (!item) return null;
|
|
510
|
+
if (!asRows) {
|
|
511
|
+
return (
|
|
512
|
+
<li key={itemId} className="flex items-end gap-2">
|
|
513
|
+
<div className="min-w-0 flex-1">
|
|
514
|
+
<Field
|
|
515
|
+
field={item}
|
|
516
|
+
path={[...path, index]}
|
|
517
|
+
label={`${capital(names.one)} ${String(index + 1)}`}
|
|
518
|
+
item="inline"
|
|
519
|
+
/>
|
|
520
|
+
</div>
|
|
521
|
+
{itemMenu(index, itemId, spoken)}
|
|
522
|
+
</li>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
const at = pathKey([...path, index]);
|
|
526
|
+
return (
|
|
527
|
+
<li key={itemId}>
|
|
528
|
+
<ItemRow
|
|
529
|
+
id={id}
|
|
530
|
+
itemId={itemId}
|
|
531
|
+
name={name}
|
|
532
|
+
detail={detail}
|
|
533
|
+
problems={countUnder(editor.issues, at)}
|
|
534
|
+
notes={countUnder(editor.notes, at)}
|
|
535
|
+
image={isImageItem(item) ? imageSource(entry) : null}
|
|
536
|
+
headingLevel={path.length === 1 ? 3 : null}
|
|
537
|
+
isOpen={open.has(itemId)}
|
|
538
|
+
onOpenChange={(expanded) => {
|
|
539
|
+
toggle(itemId, expanded);
|
|
540
|
+
}}
|
|
541
|
+
onKeyDown={(event) => {
|
|
542
|
+
if (event.altKey && event.key === 'ArrowUp') {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
move(index, index - 1);
|
|
545
|
+
} else if (event.altKey && event.key === 'ArrowDown') {
|
|
546
|
+
event.preventDefault();
|
|
547
|
+
move(index, index + 1);
|
|
548
|
+
} else if (
|
|
549
|
+
(event.shiftKey && event.key === 'F10') ||
|
|
550
|
+
event.key === 'ContextMenu'
|
|
551
|
+
) {
|
|
552
|
+
event.preventDefault();
|
|
553
|
+
setMenuFor(itemId);
|
|
554
|
+
} else {
|
|
555
|
+
// React Aria stops a button's keys unless told otherwise, and ⌘S listens on the window.
|
|
556
|
+
event.continuePropagation();
|
|
557
|
+
}
|
|
558
|
+
}}
|
|
559
|
+
menu={itemMenu(index, itemId, spoken)}
|
|
560
|
+
drag={drag?.(itemId)}
|
|
561
|
+
editor={editor}
|
|
562
|
+
>
|
|
563
|
+
<Field field={item} path={[...path, index]} label={name} item="row" />
|
|
564
|
+
</ItemRow>
|
|
565
|
+
</li>
|
|
566
|
+
);
|
|
567
|
+
})}
|
|
568
|
+
</ol>
|
|
569
|
+
{/* Beside the list, not in it: a list holds only its items. */}
|
|
570
|
+
{landing && (
|
|
571
|
+
<span
|
|
572
|
+
aria-hidden="true"
|
|
573
|
+
className="pointer-events-none absolute inset-x-0 h-0.5 -translate-y-1/2 rounded-full bg-accent"
|
|
574
|
+
style={{ top: landing.top }}
|
|
575
|
+
/>
|
|
576
|
+
)}
|
|
577
|
+
<div className="flex flex-col items-start gap-1">
|
|
578
|
+
{kinds && kinds.length > 1 ? (
|
|
579
|
+
<Menu
|
|
580
|
+
label={addLabel}
|
|
581
|
+
trigger={
|
|
582
|
+
<Button id={`${id}-add`} icon="add" isDisabled={disabled || full}>
|
|
583
|
+
{addLabel}
|
|
584
|
+
</Button>
|
|
585
|
+
}
|
|
586
|
+
items={kindItems('add')}
|
|
587
|
+
onAction={(action) => {
|
|
588
|
+
const kind = kindNamed(action.slice(4));
|
|
589
|
+
if (kind) insertAt(items.length, kind);
|
|
590
|
+
}}
|
|
591
|
+
/>
|
|
592
|
+
) : (
|
|
593
|
+
<Button
|
|
594
|
+
id={`${id}-add`}
|
|
595
|
+
icon="add"
|
|
596
|
+
isDisabled={disabled || full}
|
|
597
|
+
onPress={() => {
|
|
598
|
+
insertAt(items.length, kinds?.[0]);
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
{addLabel}
|
|
602
|
+
</Button>
|
|
603
|
+
)}
|
|
604
|
+
{full && (
|
|
605
|
+
<p className="text-small text-content-muted">
|
|
606
|
+
At most {formatCount(max, names.one, names.many)}.
|
|
607
|
+
</p>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
<p ref={status} role="status" className="sr-only" />
|
|
611
|
+
<Dialog
|
|
612
|
+
title={pending ? `Change ${pending.from} to ${pending.to.label}?` : ''}
|
|
613
|
+
role="alertdialog"
|
|
614
|
+
isOpen={pending !== null}
|
|
615
|
+
onOpenChange={(isOpen) => {
|
|
616
|
+
if (!isOpen) setPending(null);
|
|
617
|
+
}}
|
|
618
|
+
>
|
|
619
|
+
{pending && (
|
|
620
|
+
<>
|
|
621
|
+
<p className="mb-6">
|
|
622
|
+
{inWords(pending.dropped)} {pending.dropped.length === 1 ? 'has' : 'have'} no place in{' '}
|
|
623
|
+
{pending.to.label}, so {pending.dropped.length === 1 ? 'it is' : 'they are'} removed.
|
|
624
|
+
Changing back before you save brings {pending.dropped.length === 1 ? 'it' : 'them'}{' '}
|
|
625
|
+
back.
|
|
626
|
+
</p>
|
|
627
|
+
<div className="flex flex-wrap justify-end gap-3">
|
|
628
|
+
<Button
|
|
629
|
+
onPress={() => {
|
|
630
|
+
setPending(null);
|
|
631
|
+
focusLater(rowButton(pending.itemId));
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
Cancel
|
|
635
|
+
</Button>
|
|
636
|
+
<Button
|
|
637
|
+
variant="danger"
|
|
638
|
+
onPress={() => {
|
|
639
|
+
setPending(null);
|
|
640
|
+
changeTo(pending.index, pending.to, true);
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
Change to {pending.to.label}
|
|
644
|
+
</Button>
|
|
645
|
+
</div>
|
|
646
|
+
</>
|
|
647
|
+
)}
|
|
648
|
+
</Dialog>
|
|
649
|
+
</div>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** An item's "…": named for the item, so a list of them reads as "Actions for No second schema". */
|
|
654
|
+
function ItemMenu(props: {
|
|
655
|
+
id: string;
|
|
656
|
+
label: string;
|
|
657
|
+
items: MenuItem[];
|
|
658
|
+
onAction: (action: string) => void;
|
|
659
|
+
isOpen: boolean;
|
|
660
|
+
onOpenChange: (isOpen: boolean) => void;
|
|
661
|
+
}) {
|
|
662
|
+
const { id, ...menu } = props;
|
|
663
|
+
// The kit's "…" button takes no id, so the wrapper carries one to find it by.
|
|
664
|
+
return (
|
|
665
|
+
<span id={id} className="flex shrink-0">
|
|
666
|
+
<Menu {...menu} />
|
|
667
|
+
</span>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function RowThumbnail({ src, entryPath }: { src: string; entryPath: string }) {
|
|
672
|
+
const url = useFileUrl(src ? resolveMediaPath(src, entryPath) : null);
|
|
673
|
+
return url ? (
|
|
674
|
+
<img src={url} alt="" className="size-10 shrink-0 rounded-sm object-cover" />
|
|
675
|
+
) : (
|
|
676
|
+
<span aria-hidden="true" className="size-10 shrink-0 rounded-sm bg-surface-sunken" />
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* A row's drag handle, for the pointer only: no Tab stop, and hidden from
|
|
682
|
+
* screen readers, which move items with Alt+↑/↓ and the "…" menu. Those say
|
|
683
|
+
* where the item lands; a drag mode would only add a stop to every row.
|
|
684
|
+
*/
|
|
685
|
+
function DragHandle({
|
|
686
|
+
dragProps,
|
|
687
|
+
header,
|
|
688
|
+
}: {
|
|
689
|
+
dragProps: ReturnType<typeof useDrag>['dragProps'];
|
|
690
|
+
header: RefObject<HTMLDivElement | null>;
|
|
691
|
+
}) {
|
|
692
|
+
return (
|
|
693
|
+
<span
|
|
694
|
+
{...dragProps}
|
|
695
|
+
aria-hidden="true"
|
|
696
|
+
onDragStart={(event: DragEvent<HTMLElement>) => {
|
|
697
|
+
dragProps.onDragStart?.(event);
|
|
698
|
+
// The row's header follows the pointer, not the handle alone and not an open row's fields.
|
|
699
|
+
const row = header.current;
|
|
700
|
+
if (!row || event.defaultPrevented) return;
|
|
701
|
+
const box = row.getBoundingClientRect();
|
|
702
|
+
event.dataTransfer.setDragImage(row, event.clientX - box.left, event.clientY - box.top);
|
|
703
|
+
}}
|
|
704
|
+
className="ml-1 inline-flex size-8 shrink-0 cursor-grab items-center justify-center rounded-md text-content-muted hover:text-content active:cursor-grabbing"
|
|
705
|
+
>
|
|
706
|
+
<Icon name="drag" />
|
|
707
|
+
</span>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** One item: a row that names it and opens in place, with its "…" menu beside it. */
|
|
712
|
+
function ItemRow({
|
|
713
|
+
id,
|
|
714
|
+
itemId,
|
|
715
|
+
name,
|
|
716
|
+
detail,
|
|
717
|
+
problems,
|
|
718
|
+
notes,
|
|
719
|
+
image,
|
|
720
|
+
headingLevel,
|
|
721
|
+
isOpen,
|
|
722
|
+
onOpenChange,
|
|
723
|
+
onKeyDown,
|
|
724
|
+
menu,
|
|
725
|
+
drag,
|
|
726
|
+
editor,
|
|
727
|
+
children,
|
|
728
|
+
}: {
|
|
729
|
+
id: string;
|
|
730
|
+
itemId: string;
|
|
731
|
+
name: string;
|
|
732
|
+
detail: string;
|
|
733
|
+
problems: number;
|
|
734
|
+
notes: number;
|
|
735
|
+
image: string | null;
|
|
736
|
+
headingLevel: 3 | null;
|
|
737
|
+
isOpen: boolean;
|
|
738
|
+
onOpenChange: (expanded: boolean) => void;
|
|
739
|
+
onKeyDown: (event: KeyboardEvent) => void;
|
|
740
|
+
menu: ReactNode;
|
|
741
|
+
/** Its handle's start and end; none while the list can't change. */
|
|
742
|
+
drag: { onStart: () => void; onEnd: () => void } | undefined;
|
|
743
|
+
editor: EditorState;
|
|
744
|
+
children: ReactNode;
|
|
745
|
+
}) {
|
|
746
|
+
const header = useRef<HTMLDivElement>(null);
|
|
747
|
+
const { dragProps, isDragging } = useDrag({
|
|
748
|
+
isDisabled: !drag,
|
|
749
|
+
// No drag button: the keyboard moves items with Alt+↑/↓ and the menu instead.
|
|
750
|
+
hasDragButton: true,
|
|
751
|
+
getItems: () => [{ [DRAG_TYPE]: itemId }],
|
|
752
|
+
getAllowedDropOperations: () => ['move'],
|
|
753
|
+
onDragStart: () => drag?.onStart(),
|
|
754
|
+
onDragEnd: () => drag?.onEnd(),
|
|
755
|
+
});
|
|
756
|
+
const trigger = (
|
|
757
|
+
<RACButton
|
|
758
|
+
slot="trigger"
|
|
759
|
+
id={`${id}-row-${itemId}`}
|
|
760
|
+
onKeyDown={onKeyDown}
|
|
761
|
+
className={cx(
|
|
762
|
+
'flex min-h-11 w-full min-w-0 items-center gap-2 rounded-md py-1 pl-2 text-left text-body text-content',
|
|
763
|
+
focusRing,
|
|
764
|
+
)}
|
|
765
|
+
>
|
|
766
|
+
{/* Turned at once: a transition here would hold back React Aria's focus moves. */}
|
|
767
|
+
<Chevron className="shrink-0 -rotate-90 group-data-[expanded]:rotate-0" />
|
|
768
|
+
{image !== null && <RowThumbnail src={image} entryPath={editor.entryPath} />}
|
|
769
|
+
{/* The name wins the room; the detail takes what's left. */}
|
|
770
|
+
<span className="max-w-2/3 min-w-0 shrink-0 truncate">{name}</span>
|
|
771
|
+
{/* Spoken as "No second schema, 3 points": the parts are apart on screen, not in words. */}
|
|
772
|
+
{detail && (
|
|
773
|
+
<span className="min-w-0 flex-1 truncate text-small text-content-muted">
|
|
774
|
+
<span className="sr-only">,</span> {detail}
|
|
775
|
+
</span>
|
|
776
|
+
)}
|
|
777
|
+
{problems > 0 && (
|
|
778
|
+
<span className="shrink-0 text-small text-danger">
|
|
779
|
+
<span className="sr-only">,</span> {formatCount(problems, 'problem', 'problems')}
|
|
780
|
+
</span>
|
|
781
|
+
)}
|
|
782
|
+
{notes > 0 && (
|
|
783
|
+
<span className="shrink-0 text-small text-content-muted">
|
|
784
|
+
<span className="sr-only">,</span> {formatCount(notes, 'note', 'notes')}
|
|
785
|
+
</span>
|
|
786
|
+
)}
|
|
787
|
+
</RACButton>
|
|
788
|
+
);
|
|
789
|
+
return (
|
|
790
|
+
<Disclosure
|
|
791
|
+
id={itemId}
|
|
792
|
+
isExpanded={isOpen}
|
|
793
|
+
onExpandedChange={onOpenChange}
|
|
794
|
+
className={cx(
|
|
795
|
+
'group rounded-md border border-border bg-surface-raised',
|
|
796
|
+
isDragging && 'opacity-50',
|
|
797
|
+
)}
|
|
798
|
+
>
|
|
799
|
+
{/*
|
|
800
|
+
* Its own background, so the picture that follows a drag is the row as it looks.
|
|
801
|
+
* A closed row answers the pointer, like the rows of a pane; an open one is a form.
|
|
802
|
+
*/}
|
|
803
|
+
<div
|
|
804
|
+
ref={header}
|
|
805
|
+
className={cx(
|
|
806
|
+
'flex items-center gap-1 rounded-md bg-surface-raised pr-1',
|
|
807
|
+
!isOpen && 'hover:bg-surface-sunken',
|
|
808
|
+
)}
|
|
809
|
+
>
|
|
810
|
+
{drag && <DragHandle dragProps={dragProps} header={header} />}
|
|
811
|
+
{headingLevel ? (
|
|
812
|
+
<Heading level={headingLevel} className="m-0 min-w-0 flex-1">
|
|
813
|
+
{trigger}
|
|
814
|
+
</Heading>
|
|
815
|
+
) : (
|
|
816
|
+
<div className="min-w-0 flex-1">{trigger}</div>
|
|
817
|
+
)}
|
|
818
|
+
{menu}
|
|
819
|
+
</div>
|
|
820
|
+
<DisclosurePanel>
|
|
821
|
+
<div id={`${id}-panel-${itemId}`} className="border-t border-border px-4 pt-3 pb-4">
|
|
822
|
+
{children}
|
|
823
|
+
</div>
|
|
824
|
+
</DisclosurePanel>
|
|
825
|
+
</Disclosure>
|
|
826
|
+
);
|
|
827
|
+
}
|