caracms 0.3.1 → 0.3.2
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/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New page's and Change address…'s "Inside" (Urban Return, 2026-10-24): a field that shows
|
|
3
|
+
* where the page goes and opens a searchable tree of every group and page in place of a
|
|
4
|
+
* select, which stopped working once every page was offered.
|
|
5
|
+
*
|
|
6
|
+
* - **Closed:** the chosen place's whole name (`Services › Advisory`) and the address a page
|
|
7
|
+
* inside it starts with.
|
|
8
|
+
* - **Open:** a find field over the tree (insideOptions' order: what holds pages first at each
|
|
9
|
+
* level), each row its own name, indented, with its address. Typing filters by name or
|
|
10
|
+
* address, every word, and shows each hit by its whole name. "New group…" is always last.
|
|
11
|
+
* - **Keys:** ↑ ↓ move, Enter picks, Esc closes the picker only (the dialog's Frame takes Esc
|
|
12
|
+
* first and, while `[data-inside-open]` is in it, sends INSIDE_CLOSE instead of closing), Tab
|
|
13
|
+
* closes it.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
|
|
16
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
17
|
+
import type { InsideOption } from './actions.ts';
|
|
18
|
+
|
|
19
|
+
/** The event Frame sends an open picker on Esc. */
|
|
20
|
+
export const INSIDE_CLOSE = 'caracms:inside-close';
|
|
21
|
+
|
|
22
|
+
const crumbs = (o: InsideOption) => o.label.split(' / ').join(' › ');
|
|
23
|
+
|
|
24
|
+
export function InsidePicker(props: { id: string; options: readonly InsideOption[]; value: string; newGroup: string; onChange: (value: string) => void }) {
|
|
25
|
+
const { id, options, value, newGroup } = props;
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const [query, setQuery] = useState('');
|
|
28
|
+
const [active, setActive] = useState(0);
|
|
29
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
30
|
+
const root = useRef<HTMLDivElement>(null);
|
|
31
|
+
const list = useRef<HTMLDivElement>(null);
|
|
32
|
+
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
33
|
+
const rows = useMemo(() => {
|
|
34
|
+
const hits = words.length ? options.filter((o) => words.every((w) => o.label.toLowerCase().includes(w) || o.base.toLowerCase().includes(w))) : options;
|
|
35
|
+
return [...hits.map((o) => ({ value: o.path, option: o })), { value: newGroup, option: null }];
|
|
36
|
+
}, [options, newGroup, words.join(' ')]);
|
|
37
|
+
const chosen = options.find((o) => o.path === value);
|
|
38
|
+
const close = (focus: boolean) => {
|
|
39
|
+
setOpen(false);
|
|
40
|
+
setQuery('');
|
|
41
|
+
if (focus) trigger.current?.focus();
|
|
42
|
+
};
|
|
43
|
+
const pick = (next: string) => {
|
|
44
|
+
props.onChange(next);
|
|
45
|
+
close(true);
|
|
46
|
+
};
|
|
47
|
+
// Opening starts on what's chosen; a new search starts at the top.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (open) setActive(Math.max(0, rows.findIndex((r) => r.value === value)));
|
|
50
|
+
}, [open]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (open && query) setActive(0);
|
|
53
|
+
}, [query]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const el = root.current;
|
|
56
|
+
if (!open || !el) return;
|
|
57
|
+
const onClose = () => close(true);
|
|
58
|
+
el.addEventListener(INSIDE_CLOSE, onClose);
|
|
59
|
+
return () => el.removeEventListener(INSIDE_CLOSE, onClose);
|
|
60
|
+
}, [open]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
list.current?.querySelector(`#${id}-o-${active}`)?.scrollIntoView({ block: 'nearest' });
|
|
63
|
+
}, [active, open, id]);
|
|
64
|
+
const onKey = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
65
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
68
|
+
setActive((a) => Math.min(rows.length - 1, Math.max(0, a + step)));
|
|
69
|
+
} else if (event.key === 'Enter') {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
const row = rows[active];
|
|
72
|
+
if (row) pick(row.value);
|
|
73
|
+
} else if (event.key === 'Tab') {
|
|
74
|
+
close(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
<div ref={root} className="inside-pick" data-inside-open={open ? '' : undefined}>
|
|
79
|
+
<button
|
|
80
|
+
ref={trigger}
|
|
81
|
+
type="button"
|
|
82
|
+
className="input inside-btn"
|
|
83
|
+
id={id}
|
|
84
|
+
aria-haspopup="listbox"
|
|
85
|
+
aria-expanded={open}
|
|
86
|
+
data-inside-value={value === newGroup ? '__new' : value}
|
|
87
|
+
// While open, keep focus in the find field so this click closes it (and doesn't blur-close, then reopen).
|
|
88
|
+
onMouseDown={(e) => open && e.preventDefault()}
|
|
89
|
+
onClick={() => (open ? close(true) : setOpen(true))}
|
|
90
|
+
>
|
|
91
|
+
<span className="nm">{value === newGroup ? 'New group…' : chosen ? crumbs(chosen) : 'Top level'}</span>
|
|
92
|
+
{value !== newGroup && chosen ? <span className="mono meta">{chosen.base}</span> : null}
|
|
93
|
+
<Icon name="chev-d" size="xs" />
|
|
94
|
+
</button>
|
|
95
|
+
{open ? (
|
|
96
|
+
<div className="lpop">
|
|
97
|
+
<div className="find">
|
|
98
|
+
<Icon name="search" />
|
|
99
|
+
<input
|
|
100
|
+
className="input"
|
|
101
|
+
value={query}
|
|
102
|
+
placeholder="Find a page or group"
|
|
103
|
+
aria-label="Find a page or group"
|
|
104
|
+
role="combobox"
|
|
105
|
+
aria-expanded="true"
|
|
106
|
+
aria-controls={`${id}-list`}
|
|
107
|
+
aria-activedescendant={`${id}-o-${active}`}
|
|
108
|
+
autoComplete="off"
|
|
109
|
+
spellCheck={false}
|
|
110
|
+
autoFocus
|
|
111
|
+
data-inside-find=""
|
|
112
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
113
|
+
onKeyDown={onKey}
|
|
114
|
+
onBlur={(e) => {
|
|
115
|
+
if (!e.relatedTarget || !(e.currentTarget.closest('.inside-pick')?.contains(e.relatedTarget as Node) ?? false)) close(false);
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<div ref={list} className="lpop-list inside-list" role="listbox" id={`${id}-list`} aria-label="Inside">
|
|
120
|
+
{rows.map((r, i) => {
|
|
121
|
+
const o = r.option;
|
|
122
|
+
const row = {
|
|
123
|
+
id: `${id}-o-${i}`,
|
|
124
|
+
role: 'option',
|
|
125
|
+
'aria-selected': r.value === value,
|
|
126
|
+
'data-inside-pick': o ? o.path : '__new',
|
|
127
|
+
onMouseDown: (e: MouseEvent) => e.preventDefault(),
|
|
128
|
+
onMouseEnter: () => setActive(i),
|
|
129
|
+
onClick: () => pick(r.value),
|
|
130
|
+
};
|
|
131
|
+
const on = i === active ? ' is-active' : '';
|
|
132
|
+
if (!o) {
|
|
133
|
+
return (
|
|
134
|
+
<div key="__new" className="inside-new">
|
|
135
|
+
{rows.length === 1 ? <div className="sw-more">Nothing called “{query.trim()}”.</div> : null}
|
|
136
|
+
<div {...row} className={`drow${on}`}>
|
|
137
|
+
<Icon name="plus" />
|
|
138
|
+
<span className="nm">New group…</span>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
<div key={o.path} {...row} className={`drow t${on}${r.value === value ? ' is-on' : ''}`} style={{ ['--depth' as string]: words.length ? 0 : o.depth }}>
|
|
145
|
+
<Icon name={o.page ? 'page' : 'folder'} />
|
|
146
|
+
<span className="nm">{words.length ? crumbs(o) : o.name}</span>
|
|
147
|
+
<span className="meta">
|
|
148
|
+
<span className="mono">{o.base}</span>
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -149,7 +149,16 @@ export function addressFor(collection: Pick<EditorAnyCollection, 'urlBase'>, pat
|
|
|
149
149
|
export interface InsideOption {
|
|
150
150
|
/** The folder's path in the collection ('' for the top level). */
|
|
151
151
|
path: string;
|
|
152
|
+
/** Its whole name, ancestors first: `Services / Advisory`. */
|
|
152
153
|
label: string;
|
|
154
|
+
/** Its own name: `Advisory` (`Top level` for the top level). */
|
|
155
|
+
name: string;
|
|
156
|
+
/** How deep it sits: 0 for the top level, 1 for what's straight under it. */
|
|
157
|
+
depth: number;
|
|
158
|
+
/** A page sits at this address (otherwise it's a group: a folder with no page of its own). */
|
|
159
|
+
page: boolean;
|
|
160
|
+
/** Pages sit under it already. */
|
|
161
|
+
holds: boolean;
|
|
153
162
|
/** The address a page inside it starts with (`/services/`). */
|
|
154
163
|
base: string;
|
|
155
164
|
}
|
|
@@ -158,6 +167,8 @@ export interface InsideOption {
|
|
|
158
167
|
* New page's "Inside" (Decision 11): the top level, then every group and every page, at
|
|
159
168
|
* any depth, in tree order. A page with nothing under it yet is offered too, so a page can
|
|
160
169
|
* go under it (Urban Return, 2026-10-24); Home isn't, since no address goes under `/index/`.
|
|
170
|
+
* At each level, what already holds pages comes first (as folders come first in a file
|
|
171
|
+
* list), so the site's shape is at the top while the rest are a scroll or a search away.
|
|
161
172
|
*/
|
|
162
173
|
export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
|
|
163
174
|
const base = collection.urlBase ?? '/';
|
|
@@ -169,14 +180,29 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
|
|
|
169
180
|
}
|
|
170
181
|
for (const f of folders) if (f.split('/').includes(RESERVED_SLUG)) folders.delete(f);
|
|
171
182
|
const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
const holds = (path: string) => docs.some((d) => d.path.startsWith(`${path}/`));
|
|
184
|
+
const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
|
|
185
|
+
const children = new Map<string, string[]>();
|
|
186
|
+
for (const f of folders) children.set(parentOf(f), [...(children.get(parentOf(f)) ?? []), f]);
|
|
187
|
+
const out: InsideOption[] = [{ path: '', label: 'Top level', name: 'Top level', depth: 0, page: false, holds: true, base }];
|
|
188
|
+
const walk = (parent: string) => {
|
|
189
|
+
const kids = (children.get(parent) ?? []).sort((a, b) => Number(holds(b)) - Number(holds(a)) || a.localeCompare(b));
|
|
190
|
+
for (const path of kids) {
|
|
175
191
|
const parts = path.split('/');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
out.push({
|
|
193
|
+
path,
|
|
194
|
+
label: parts.map((_, i) => labelOf(parts.slice(0, i + 1).join('/'))).join(' / '),
|
|
195
|
+
name: labelOf(path),
|
|
196
|
+
depth: parts.length,
|
|
197
|
+
page: docs.some((d) => d.path === path),
|
|
198
|
+
holds: holds(path),
|
|
199
|
+
base: `${base}${path}/`,
|
|
200
|
+
});
|
|
201
|
+
walk(path);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
walk('');
|
|
205
|
+
return out;
|
|
180
206
|
}
|
|
181
207
|
|
|
182
208
|
/** The pages under a folder (any depth), for "its pages stay" lines. */
|
|
@@ -25,6 +25,7 @@ import { docIcon } from '../nav/DocRow.tsx';
|
|
|
25
25
|
import { Icon } from '../shell/Icons.tsx';
|
|
26
26
|
import { createStore, type Store } from '../state/store.ts';
|
|
27
27
|
import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
|
|
28
|
+
import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
|
|
28
29
|
import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
29
30
|
import { filesLabel, inboxFor } from '../inbox/store.ts';
|
|
30
31
|
import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
|
|
@@ -92,7 +93,10 @@ export function Frame(props: { alert?: boolean; label: string; describedBy?: str
|
|
|
92
93
|
// Closes the dialog only: the editor's Esc (up a level) and the picker's don't run.
|
|
93
94
|
event.preventDefault();
|
|
94
95
|
event.stopImmediatePropagation();
|
|
95
|
-
|
|
96
|
+
// An open Inside picker closes instead, and the dialog stays.
|
|
97
|
+
const picker = box.current?.querySelector('[data-inside-open]');
|
|
98
|
+
if (picker) picker.dispatchEvent(new Event(INSIDE_CLOSE));
|
|
99
|
+
else closeDialog(editor);
|
|
96
100
|
} else if (event.key === 'Tab' && box.current) {
|
|
97
101
|
// Keep focus inside the dialog.
|
|
98
102
|
const items = [...box.current.querySelectorAll<HTMLElement>('input:not([disabled]),select:not([disabled]),button:not([disabled])')];
|
|
@@ -140,7 +144,7 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
|
|
|
140
144
|
const documents = useStore(editor.documents);
|
|
141
145
|
const collection = editor.site.collections.find((c) => c.name === props.collection);
|
|
142
146
|
const at = props.at ?? null;
|
|
143
|
-
const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.
|
|
147
|
+
const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.name ?? at) : null;
|
|
144
148
|
const [name, setName] = useState(atLabel ?? '');
|
|
145
149
|
const [inside, setInside] = useState(props.inside ?? '');
|
|
146
150
|
const [group, setGroup] = useState('');
|
|
@@ -208,16 +212,7 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
|
|
|
208
212
|
<label className="field-label" htmlFor="np-inside">
|
|
209
213
|
Inside
|
|
210
214
|
</label>
|
|
211
|
-
<
|
|
212
|
-
{options.map((o) => (
|
|
213
|
-
<option key={o.path} value={o.path}>
|
|
214
|
-
{o.label}
|
|
215
|
-
{o.path ? ` (${o.base}…)` : ''}
|
|
216
|
-
</option>
|
|
217
|
-
))}
|
|
218
|
-
<option disabled>──────────</option>
|
|
219
|
-
<option value={NEW_GROUP}>New group…</option>
|
|
220
|
-
</select>
|
|
215
|
+
<InsidePicker id="np-inside" options={options} value={inside} newGroup={NEW_GROUP} onChange={(v) => (setInside(v), setError(null))} />
|
|
221
216
|
</div>
|
|
222
217
|
) : null}
|
|
223
218
|
{newGroup ? (
|
|
@@ -364,16 +359,7 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
364
359
|
<label className="field-label" htmlFor="mv-inside">
|
|
365
360
|
Inside
|
|
366
361
|
</label>
|
|
367
|
-
<
|
|
368
|
-
{options.map((o) => (
|
|
369
|
-
<option key={o.path} value={o.path}>
|
|
370
|
-
{o.label}
|
|
371
|
-
{o.path ? ` (${o.base}…)` : ''}
|
|
372
|
-
</option>
|
|
373
|
-
))}
|
|
374
|
-
<option disabled>──────────</option>
|
|
375
|
-
<option value={NEW_GROUP}>New group…</option>
|
|
376
|
-
</select>
|
|
362
|
+
<InsidePicker id="mv-inside" options={options} value={inside} newGroup={NEW_GROUP} onChange={(v) => (setInside(v), setError(null))} />
|
|
377
363
|
</div>
|
|
378
364
|
) : null}
|
|
379
365
|
{newGroup ? (
|
|
@@ -49,3 +49,16 @@
|
|
|
49
49
|
.stage-aside p{color:var(--bar-muted);font-size:var(--text-sm)}
|
|
50
50
|
.stage-aside .btn{margin-top:var(--space-4);background:var(--bar-control);border-color:var(--bar-control);color:var(--bar-control-text)}
|
|
51
51
|
.stage-aside :focus-visible{outline-color:var(--focus-on-dark)}
|
|
52
|
+
/* New page's and Change address…'s Inside (Urban Return, 2026-10-24): a field that opens a
|
|
53
|
+
searchable tree of groups and pages, on the reference picker's popover (.lpop). */
|
|
54
|
+
.inside-pick{position:relative}
|
|
55
|
+
.input.inside-btn{display:flex;align-items:center;gap:var(--space-2);text-align:left;cursor:pointer}
|
|
56
|
+
.inside-btn .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
57
|
+
.inside-btn .meta{max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--text-2xs);color:var(--text-subtle)}
|
|
58
|
+
.inside-btn .i{flex:none;color:var(--icon)}
|
|
59
|
+
.inside-list{max-height:min(320px,45vh);overflow:auto}
|
|
60
|
+
.inside-list .drow{cursor:pointer}
|
|
61
|
+
.inside-list .drow .meta{max-width:50%;overflow:hidden}
|
|
62
|
+
.inside-list .drow .meta .mono{overflow:hidden;text-overflow:ellipsis}
|
|
63
|
+
.inside-list .drow.is-on .nm{color:var(--select-strong);font-weight:var(--weight-medium)}
|
|
64
|
+
.inside-new{margin-top:var(--space-1);padding-top:var(--space-1);border-top:1px solid var(--border)}
|