@quillmark/svelte 0.5.1 → 0.6.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 +6 -0
- package/THEMING.md +12 -16
- package/dist/core/address.d.ts +17 -3
- package/dist/core/address.js +20 -3
- package/dist/core/codec/blocks.d.ts +37 -0
- package/dist/core/codec/blocks.js +163 -0
- package/dist/core/codec/field.js +3 -5
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +3 -2
- package/dist/core/codec/inputrules.js +24 -144
- package/dist/core/codec/prose.css +14 -25
- package/dist/core/codec/schema.d.ts +8 -0
- package/dist/core/codec/schema.js +33 -1
- package/dist/core/codec/slash.d.ts +18 -16
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.js +127 -157
- package/dist/core/codec/table.d.ts +0 -10
- package/dist/core/codec/table.js +0 -15
- package/dist/core/theme.css +350 -344
- package/dist/preset/recipes.css +10 -17
- package/dist/preview/Preview.svelte +4 -2
- package/dist/preview/geometry.d.ts +10 -9
- package/dist/preview/geometry.js +11 -13
- package/dist/visual/ArrayField.svelte +242 -63
- package/dist/visual/ArrayField.svelte.d.ts +5 -1
- package/dist/visual/Card.svelte +21 -2
- package/dist/visual/EnumField.svelte +33 -1
- package/dist/visual/Field.svelte +28 -4
- package/dist/visual/FieldLabel.svelte +2 -2
- package/dist/visual/FormatPopover.svelte +13 -1
- package/dist/visual/ObjectField.svelte +157 -35
- package/dist/visual/ObjectField.svelte.d.ts +13 -4
- package/dist/visual/ProseArrayElement.svelte +22 -2
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
- package/dist/visual/TipsCard.svelte +33 -13
- package/dist/visual/VariantField.svelte +98 -0
- package/dist/visual/VariantField.svelte.d.ts +21 -0
- package/dist/visual/VisualEditorInner.svelte +15 -10
- package/dist/visual/controls.css +282 -283
- package/dist/visual/diagnostics.d.ts +13 -11
- package/dist/visual/diagnostics.js +11 -9
- package/dist/visual/domid.d.ts +13 -0
- package/dist/visual/domid.js +16 -0
- package/dist/visual/leaves.d.ts +9 -7
- package/dist/visual/links.d.ts +5 -2
- package/dist/visual/links.js +9 -3
- package/dist/visual/strings.d.ts +15 -0
- package/dist/visual/strings.js +3 -0
- package/dist/visual/structure.d.ts +32 -6
- package/dist/visual/structure.js +49 -3
- package/package.json +2 -2
- package/dist/core/codec/clipboard.d.ts +0 -59
- package/dist/core/codec/clipboard.js +0 -202
package/dist/preset/recipes.css
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
they freeze nothing about the surfaces' interior: the reason the package withholds
|
|
9
9
|
its own internal classes does not reach them.
|
|
10
10
|
|
|
11
|
-
Unlayered, deliberately
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
precedence and the focus rule works with nothing declared. */
|
|
11
|
+
Unlayered, deliberately: a layer of the preset's own would put a name in the contract,
|
|
12
|
+
and unlayered a consumer's rules win over these by ordinary precedence. Ordinary
|
|
13
|
+
specificity too, not the surfaces' `:where()` zero: a consumer takes these classes on
|
|
14
|
+
purpose, so a rule of their own beside one is aimed rather than incidental. */
|
|
16
15
|
|
|
17
16
|
/* ── The page ──────────────────────────────────────────────────────────────── */
|
|
18
17
|
|
|
@@ -308,18 +307,12 @@ pre.qm-readout {
|
|
|
308
307
|
|
|
309
308
|
/* One visible indicator for the whole host, since none of these controls draws a ring
|
|
310
309
|
of its own. It reads the ink rung rather than a hue: the page is monochrome, and a
|
|
311
|
-
focus ring is a position, not a destination.
|
|
312
|
-
|
|
310
|
+
focus ring is a position, not a destination.
|
|
311
|
+
|
|
312
|
+
The mounted surfaces are cut out of it: they draw their own ring, and a blanket would
|
|
313
|
+
repaint the controls that already have one. `:not(:where(…))` contributes no
|
|
314
|
+
specificity, so the cut-out leaves this rule's own rank where it was. */
|
|
315
|
+
:focus-visible:not(:where([data-qm-root], [data-qm-root] *)) {
|
|
313
316
|
outline: var(--qmh-ring-width) solid var(--qmh-ink);
|
|
314
317
|
outline-offset: var(--qmh-space-half);
|
|
315
318
|
}
|
|
316
|
-
|
|
317
|
-
/* The mounted surfaces are the exception, and the rule above is why: an unlayered host
|
|
318
|
-
declaration beats `@layer qm.chrome` by design, so a ring written for controls that
|
|
319
|
-
draw none repaints the ones that do. `revert-layer` hands each control back to the
|
|
320
|
-
layer under this one, which is the package's own ring at its own offset, and is
|
|
321
|
-
what makes this file's own lack of a layer load-bearing rather than a preference. */
|
|
322
|
-
[data-qm-root] :focus-visible {
|
|
323
|
-
outline: revert-layer;
|
|
324
|
-
outline-offset: revert-layer;
|
|
325
|
-
}
|
|
@@ -101,8 +101,10 @@
|
|
|
101
101
|
the surface's own, so a bare `<div>` is a mounting site. The
|
|
102
102
|
padding is safe against the paint loop: a slot is a `width: 100%` child and
|
|
103
103
|
measures the content box, and the container's own `clientWidth` is read only as
|
|
104
|
-
a change detector, which a constant inset does not disturb.
|
|
105
|
-
|
|
104
|
+
a change detector, which a constant inset does not disturb.
|
|
105
|
+
|
|
106
|
+
`:global(:where(…))` puts this promised class at zero rank (ARCHITECTURE §Styling). */
|
|
107
|
+
:global(:where(.qm-preview)) {
|
|
106
108
|
box-sizing: border-box;
|
|
107
109
|
position: relative;
|
|
108
110
|
width: 100%;
|
|
@@ -14,17 +14,18 @@ export interface PdfPoint {
|
|
|
14
14
|
y: number;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* An address's boxes: `fieldBoxes`'s union, else
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it.
|
|
18
|
+
* What the scroll reads, so an address `regions()` names is never one the preview
|
|
19
|
+
* cannot place (PREVIEW.md §"Two responsibilities").
|
|
20
20
|
*
|
|
21
21
|
* `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
|
|
22
|
-
* its content and for
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* its content and for an array itself; runtime.d.ts says what such a field's box is, a
|
|
23
|
+
* single `regions()` rect. The second rung takes an address and its descendants
|
|
24
|
+
* together, since a container holds both: a plate composing an array's presentation
|
|
25
|
+
* regions the array on the ink it composed — a separator, a heading — where each
|
|
26
|
+
* element regions the ink it carries itself, and the field is placed at all of it. The
|
|
27
|
+
* boundary character (`.` for a nested field, `[` for an array element) keeps the
|
|
28
|
+
* prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
|
|
28
29
|
* `main.keywords_note`.
|
|
29
30
|
*/
|
|
30
31
|
export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: readonly FieldRegion[]): readonly FieldRegion[];
|
package/dist/preview/geometry.js
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* An address's boxes: `fieldBoxes`'s union, else
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it.
|
|
3
|
+
* What the scroll reads, so an address `regions()` names is never one the preview
|
|
4
|
+
* cannot place (PREVIEW.md §"Two responsibilities").
|
|
5
5
|
*
|
|
6
6
|
* `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
|
|
7
|
-
* its content and for
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* its content and for an array itself; runtime.d.ts says what such a field's box is, a
|
|
8
|
+
* single `regions()` rect. The second rung takes an address and its descendants
|
|
9
|
+
* together, since a container holds both: a plate composing an array's presentation
|
|
10
|
+
* regions the array on the ink it composed — a separator, a heading — where each
|
|
11
|
+
* element regions the ink it carries itself, and the field is placed at all of it. The
|
|
12
|
+
* boundary character (`.` for a nested field, `[` for an array element) keeps the
|
|
13
|
+
* prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
|
|
13
14
|
* `main.keywords_note`.
|
|
14
15
|
*/
|
|
15
16
|
export function boxesForField(field, boxes, regions) {
|
|
16
17
|
if (boxes.length)
|
|
17
18
|
return boxes;
|
|
18
|
-
|
|
19
|
-
if (own.length)
|
|
20
|
-
return own;
|
|
21
|
-
return regions.filter((r) => r.field.startsWith(`${field}.`) || r.field.startsWith(`${field}[`));
|
|
19
|
+
return regions.filter((r) => r.field === field || r.field.startsWith(`${field}.`) || r.field.startsWith(`${field}[`));
|
|
22
20
|
}
|
|
23
21
|
/**
|
|
24
22
|
* Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
|
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
edit / add / remove rebuilds the whole array and hands it to the parent's typed
|
|
4
4
|
`writer.set(field, wholeArray)` (arrays are not op-addressed). Element control
|
|
5
5
|
by `items.type`: `richtext` / `plaintext` → a prose element
|
|
6
|
-
({@link ProseArrayElement}), `object` → a
|
|
7
|
-
|
|
6
|
+
({@link ProseArrayElement}), `object` → a summary row that opens onto a subform,
|
|
7
|
+
everything else → a text input. The
|
|
8
8
|
add affordance sits in the label header row (space-between with the field label);
|
|
9
9
|
{@link Field} skips its own label for array controls and hands this component the
|
|
10
10
|
label track with it.
|
|
11
11
|
|
|
12
|
+
An `object` element collapses: the row is its own summary — a box, titled by the
|
|
13
|
+
element's first `string` cell — and opens onto {@link ObjectField}, one at a time.
|
|
14
|
+
Stacking the subforms instead would nest a field one level past the depth the band
|
|
15
|
+
draws, once per row. Opening is therefore part of a landing rather than something the
|
|
16
|
+
user does first: `focusElement` opens the row it is aimed at before it focuses.
|
|
17
|
+
|
|
12
18
|
The remove is inside the element: a slab over the end of the element's own box, taking
|
|
13
19
|
its two end-side corners. So a row's box is the element's box, and an array's rows end
|
|
14
|
-
where every other field's control does.
|
|
20
|
+
where every other field's control does. On an object element that box is the summary
|
|
21
|
+
rather than the open row: a destructive control belongs to the line it sits on, not to
|
|
22
|
+
everything that line has unfolded.
|
|
15
23
|
|
|
16
24
|
Keys carry the list without the mouse: Enter inserts a
|
|
17
25
|
sibling below and takes the caret there, Backspace on an empty element removes it
|
|
@@ -36,10 +44,15 @@
|
|
|
36
44
|
import { IdSeq, controlKind } from './structure.js';
|
|
37
45
|
import Icon from './icons/Icon.svelte';
|
|
38
46
|
import TextField from './TextField.svelte';
|
|
47
|
+
import ObjectField from './ObjectField.svelte';
|
|
39
48
|
import ProseArrayElement from './ProseArrayElement.svelte';
|
|
40
49
|
import FieldLabel from './FieldLabel.svelte';
|
|
41
50
|
import './controls.css';
|
|
42
51
|
|
|
52
|
+
/** The disclosure glyph, at the size the accordion's own chevron takes: one
|
|
53
|
+
* disclosure figure per surface, so it is one glyph at one size. */
|
|
54
|
+
const CHEVRON = 16;
|
|
55
|
+
|
|
43
56
|
interface Props {
|
|
44
57
|
value: unknown[] | undefined;
|
|
45
58
|
items: QuillFieldSchema | undefined;
|
|
@@ -55,6 +68,10 @@
|
|
|
55
68
|
labelId?: string;
|
|
56
69
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
57
70
|
descriptionId?: string;
|
|
71
|
+
/** The field's control id: the base an `object` element's subform derives its
|
|
72
|
+
* properties' own names from, one `-e-<element id>` segment down, so each open
|
|
73
|
+
* record's cells carry real `<label for>` pairs like every other control. */
|
|
74
|
+
idBase?: string;
|
|
58
75
|
/** Element `i`'s content, decoded by the boundary through the codec `items`
|
|
59
76
|
* names (`Field`, `reader.getContentAt`). Asked only for a content-typed
|
|
60
77
|
* element: on any other the read is not content and throws. `undefined` for a
|
|
@@ -71,6 +88,7 @@
|
|
|
71
88
|
description,
|
|
72
89
|
labelId,
|
|
73
90
|
descriptionId,
|
|
91
|
+
idBase,
|
|
74
92
|
elementContent,
|
|
75
93
|
onCommit
|
|
76
94
|
}: Props = $props();
|
|
@@ -115,11 +133,43 @@
|
|
|
115
133
|
// it says so once per element per render. Thirteen lines on one memo's first paint,
|
|
116
134
|
// in the console a consumer is reading to find its own defects. Same shape
|
|
117
135
|
// {@link Card} keeps its header/panel refs in.
|
|
118
|
-
const els: Record<string, { focus: () => void } | undefined> =
|
|
136
|
+
const els: Record<string, { focus: () => void; setCaret?: (pos: number) => void } | undefined> =
|
|
137
|
+
$state({});
|
|
119
138
|
let addEl: HTMLButtonElement | undefined = $state();
|
|
120
139
|
let rootEl: HTMLElement | undefined = $state();
|
|
121
140
|
let rowsEl: HTMLElement | undefined = $state();
|
|
122
141
|
|
|
142
|
+
// ── Object elements: one open at a time ──────────────────────────────────────
|
|
143
|
+
// So an array of ten records is ten lines and one figure, whatever its length.
|
|
144
|
+
let openId = $state<string | undefined>(undefined);
|
|
145
|
+
// The open row's subform, for the landing below. One entry, never a map: only one
|
|
146
|
+
// row is open, so the ref is singular by the same rule the state is.
|
|
147
|
+
let openObjEl = $state<{ focus: () => void } | undefined>();
|
|
148
|
+
|
|
149
|
+
/** The property whose value titles a collapsed row: the first `string` cell in
|
|
150
|
+
* declaration order, which is the order a schema states its own priority in. */
|
|
151
|
+
const titleKey = $derived.by(() => {
|
|
152
|
+
for (const [k, sub] of Object.entries(items?.properties ?? {})) {
|
|
153
|
+
if (controlKind(sub) === 'text') return k;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
});
|
|
157
|
+
/** A collapsed row's own words: the title cell's committed value, or `undefined`
|
|
158
|
+
* while it has none. */
|
|
159
|
+
function elementTitle(k: number): string | undefined {
|
|
160
|
+
const el = arr[k] as Record<string, unknown> | undefined;
|
|
161
|
+
const v = titleKey ? el?.[titleKey] : undefined;
|
|
162
|
+
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
163
|
+
}
|
|
164
|
+
/** What an untitled row reads as: the name its `aria-label` already spends,
|
|
165
|
+
* `label` + the 1-based index. */
|
|
166
|
+
function untitled(k: number): string {
|
|
167
|
+
return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
|
|
168
|
+
}
|
|
169
|
+
function toggleRow(id: string): void {
|
|
170
|
+
openId = openId === id ? undefined : id;
|
|
171
|
+
}
|
|
172
|
+
|
|
123
173
|
// The awaited flush below is the only work that outlives a gesture here, so the
|
|
124
174
|
// span carries no cancellers: it is the liveness `focusAfterFlush` asks for.
|
|
125
175
|
const span = createLifespan();
|
|
@@ -148,6 +198,9 @@
|
|
|
148
198
|
const next = arr.slice();
|
|
149
199
|
next.splice(at, 0, emptyElement());
|
|
150
200
|
onCommit(next);
|
|
201
|
+
// A row added is a row to fill in, so an object element arrives open: landing on
|
|
202
|
+
// a collapsed empty summary would make adding one a two-press gesture.
|
|
203
|
+
if (control === 'object') openId = id;
|
|
151
204
|
focusAfterFlush(id);
|
|
152
205
|
}
|
|
153
206
|
function add(): void {
|
|
@@ -158,6 +211,9 @@
|
|
|
158
211
|
const next = ids.filter((_, i) => i !== k);
|
|
159
212
|
ids = next;
|
|
160
213
|
delete els[dropped];
|
|
214
|
+
// The open row can be the one removed; `openId` is cleared with it rather than
|
|
215
|
+
// left naming an element that has gone.
|
|
216
|
+
if (openId === dropped) openId = undefined;
|
|
161
217
|
onCommit(arr.filter((_, i) => i !== k));
|
|
162
218
|
// Focus lands on the element before the removed one, or on the one that slid
|
|
163
219
|
// into its place; on the add affordance once the list is empty, which is then
|
|
@@ -171,11 +227,20 @@
|
|
|
171
227
|
* ask one function so they cannot disagree (`Field`, `leaves.ts`). */
|
|
172
228
|
export function focus(): void {
|
|
173
229
|
if (ids.length === 0) return void addEl?.focus();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
230
|
+
if (control === 'object') return focusObjectRow(ids[0]);
|
|
231
|
+
els[ids[0]]?.focus();
|
|
232
|
+
}
|
|
233
|
+
/** An object row's landing: inside the subform when that row is the open one, on
|
|
234
|
+
* the row's own summary otherwise — a collapsed row's control is its summary. */
|
|
235
|
+
function focusObjectRow(id: string): void {
|
|
236
|
+
if (id === openId && openObjEl) return openObjEl.focus();
|
|
237
|
+
// Matched on the dataset: spelling an id into a selector needs `CSS.escape`,
|
|
238
|
+
// which the test DOM does not carry.
|
|
239
|
+
const rows = rowsEl?.querySelectorAll<HTMLElement>('[data-qm-el]') ?? [];
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
if (row.dataset.qmEl === id)
|
|
242
|
+
return row.querySelector<HTMLElement>('.qm-element-summary')?.focus();
|
|
243
|
+
}
|
|
179
244
|
}
|
|
180
245
|
/** The box an arrival wash blooms in (`leaves.ts`, `core/bloom.ts`): the elements,
|
|
181
246
|
* not the header above them. This component owns the field's label, so the wrapper
|
|
@@ -185,15 +250,30 @@
|
|
|
185
250
|
export function washBox(): HTMLElement | undefined {
|
|
186
251
|
return rowsEl;
|
|
187
252
|
}
|
|
188
|
-
/** Take the caret to element `k
|
|
189
|
-
* (`leaves.ts`). The index resolves
|
|
190
|
-
* never carried as one — an index is
|
|
191
|
-
*
|
|
192
|
-
* is gone, which is a landing off a
|
|
193
|
-
|
|
253
|
+
/** Take the caret to element `k`, at USV `pos` where the row's control can take one:
|
|
254
|
+
* what a landing on an element address resolves to (`leaves.ts`). The index resolves
|
|
255
|
+
* to the element's session id here, at the call, never carried as one — an index is
|
|
256
|
+
* stale the moment anything above it splices. Past the live list it falls back to
|
|
257
|
+
* {@link focus}: the field is right and the row is gone, which is a landing off a
|
|
258
|
+
* compile the document has moved past.
|
|
259
|
+
*
|
|
260
|
+
* An absent `pos` is the placement rung, exactly as on `Landing`. A row that takes
|
|
261
|
+
* no offset gets the bare focus: the JSON element, whose textarea has no coordinate
|
|
262
|
+
* to spend one in, and a `string` element, which the compile never addresses. */
|
|
263
|
+
export function focusElement(k: number, pos?: number): void {
|
|
264
|
+
// A collapsed row holds no control for a caret to land in, so opening it is part
|
|
265
|
+
// of the landing rather than something the user does first: a preview click that
|
|
266
|
+
// resolves into an element has to arrive somewhere the caret can sit.
|
|
267
|
+
if (control === 'object') {
|
|
268
|
+
const id = ids[k];
|
|
269
|
+
if (id === undefined) return focus();
|
|
270
|
+
openId = id;
|
|
271
|
+
return void focusAfterFlush(id);
|
|
272
|
+
}
|
|
194
273
|
const el = els[ids[k]];
|
|
195
|
-
if (el)
|
|
196
|
-
|
|
274
|
+
if (!el) return focus();
|
|
275
|
+
if (pos != null && el.setCaret) return el.setCaret(pos);
|
|
276
|
+
el.focus();
|
|
197
277
|
}
|
|
198
278
|
/** Focus element `id` after the flush, never in the same tick: a mutation commits
|
|
199
279
|
* the array by value, so the parent re-derives and the row does not exist until
|
|
@@ -203,8 +283,9 @@
|
|
|
203
283
|
* unmounts this component inside the window (core/teardown.ts). */
|
|
204
284
|
async function focusAfterFlush(id: string | undefined): Promise<void> {
|
|
205
285
|
if (!(await span.resumes(tick()))) return;
|
|
206
|
-
if (id === undefined) addEl?.focus();
|
|
207
|
-
|
|
286
|
+
if (id === undefined) return void addEl?.focus();
|
|
287
|
+
if (control === 'object') return focusObjectRow(id);
|
|
288
|
+
els[id]?.focus();
|
|
208
289
|
}
|
|
209
290
|
/** Whether element `k` reads empty to the user. A text element's committed value
|
|
210
291
|
* lags the input: a cleared field commits at `change`, not per keystroke
|
|
@@ -244,6 +325,7 @@
|
|
|
244
325
|
<div
|
|
245
326
|
bind:this={rootEl}
|
|
246
327
|
class="qm-array"
|
|
328
|
+
class:empty={ids.length === 0}
|
|
247
329
|
role="group"
|
|
248
330
|
aria-labelledby={label != null ? labelId : undefined}
|
|
249
331
|
aria-describedby={description ? descriptionId : undefined}
|
|
@@ -270,45 +352,78 @@
|
|
|
270
352
|
</div>
|
|
271
353
|
<div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
|
|
272
354
|
{#each ids as id, k (id)}
|
|
273
|
-
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
355
|
+
{#if control === 'object'}
|
|
356
|
+
{@const open = openId === id}
|
|
357
|
+
{@const shown = elementTitle(k)}
|
|
358
|
+
<div class="qm-array-row qm-element" class:open data-qm-el={id}>
|
|
359
|
+
<!-- The head is the row in collapsed form, and it is a box: the element IS
|
|
360
|
+
a value, the way the enum trigger is, and the remove slab's grammar
|
|
361
|
+
(the box's two end-side corners) needs corners to take. So a list of
|
|
362
|
+
records measures like a list of inputs whatever the element type. -->
|
|
363
|
+
<div class="qm-element-head">
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
class="qm-control-box qm-focus-ring qm-element-summary"
|
|
367
|
+
aria-expanded={open}
|
|
368
|
+
onclick={() => toggleRow(id)}
|
|
369
|
+
>
|
|
370
|
+
<!-- Leading, and it rotates: trailing is the figure for pushing a new
|
|
371
|
+
screen, where this unfolds in place. Same glyph, same rotation and
|
|
372
|
+
same rung as the accordion's, so the surface has one disclosure. -->
|
|
373
|
+
<Icon name="chevron-right" class="qm-el-chevron" size={CHEVRON} />
|
|
374
|
+
{#if shown}
|
|
375
|
+
<span class="qm-element-title">{shown}</span>
|
|
376
|
+
{:else}
|
|
377
|
+
<span class="qm-element-title untitled">{untitled(k)}</span>
|
|
378
|
+
{/if}
|
|
379
|
+
</button>
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
class="qm-icon-btn qm-remove qm-focus-ring"
|
|
383
|
+
title={t.strings.arrayRemove}
|
|
384
|
+
onclick={() => remove(k)}><Icon name="minus" /></button
|
|
385
|
+
>
|
|
386
|
+
</div>
|
|
387
|
+
{#if open}
|
|
388
|
+
<ObjectField
|
|
389
|
+
bind:this={openObjEl}
|
|
390
|
+
value={(arr[k] ?? {}) as Record<string, unknown>}
|
|
391
|
+
properties={items?.properties}
|
|
392
|
+
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
393
|
+
idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
|
|
394
|
+
edges="close"
|
|
395
|
+
onCommit={(obj) => commitElement(k, obj)}
|
|
396
|
+
/>
|
|
397
|
+
{/if}
|
|
398
|
+
</div>
|
|
399
|
+
{:else}
|
|
400
|
+
<div class="qm-array-row">
|
|
401
|
+
{#if control === 'prose'}
|
|
402
|
+
<ProseArrayElement
|
|
403
|
+
bind:this={els[id]}
|
|
404
|
+
content={() => elementContent(k) ?? emptyContent()}
|
|
405
|
+
plaintext={items?.type === 'plaintext'}
|
|
406
|
+
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
407
|
+
onChange={(rt) => commitElement(k, rt)}
|
|
408
|
+
onKey={(e) => onElementKey(e, k)}
|
|
409
|
+
/>
|
|
410
|
+
{:else}
|
|
411
|
+
<TextField
|
|
412
|
+
bind:this={els[id]}
|
|
413
|
+
value={String(arr[k] ?? '')}
|
|
414
|
+
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
415
|
+
onCommit={(v) => commitElement(k, v)}
|
|
416
|
+
onKey={(e) => onElementKey(e, k)}
|
|
417
|
+
/>
|
|
418
|
+
{/if}
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
class="qm-icon-btn qm-remove qm-focus-ring"
|
|
422
|
+
title={t.strings.arrayRemove}
|
|
423
|
+
onclick={() => remove(k)}><Icon name="minus" /></button
|
|
424
|
+
>
|
|
425
|
+
</div>
|
|
426
|
+
{/if}
|
|
312
427
|
{/each}
|
|
313
428
|
</div>
|
|
314
429
|
</div>
|
|
@@ -399,11 +514,68 @@
|
|
|
399
514
|
background: var(--_qm-danger-tint);
|
|
400
515
|
color: var(--_qm-danger);
|
|
401
516
|
}
|
|
402
|
-
/*
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
517
|
+
/* ── An object element ──────────────────────────────────────────────────────
|
|
518
|
+
The row is a summary and the subform hangs under it. `row-gap` rather than a
|
|
519
|
+
margin on the subform, because the distance between a control and what it has
|
|
520
|
+
unfolded belongs to the thing stacking them: the variant field spends its own
|
|
521
|
+
`gap` on exactly this and `ObjectField` therefore opens flush, carrying no
|
|
522
|
+
leading space of its own for a caller to cancel. */
|
|
523
|
+
.qm-element {
|
|
524
|
+
row-gap: var(--_qm-space-2);
|
|
525
|
+
}
|
|
526
|
+
/* The slab measures the head, not the row: an open element is the head plus
|
|
527
|
+
everything it unfolded, and a destructive control belongs to the line it sits on
|
|
528
|
+
rather than to all of that. Positioned, so `.qm-remove` anchors here. */
|
|
529
|
+
.qm-element-head {
|
|
530
|
+
position: relative;
|
|
531
|
+
display: grid;
|
|
532
|
+
grid-template-columns: minmax(0, 1fr);
|
|
533
|
+
}
|
|
534
|
+
/* A box, which the button family otherwise is not: the row IS the element, a value
|
|
535
|
+
in collapsed form the way the enum trigger is one, and the slab's grammar — the
|
|
536
|
+
box's two end-side corners — needs a box with corners to take. It carries
|
|
537
|
+
`.qm-control-box`, so the fill, the radius, the inset and the type are the recipe's
|
|
538
|
+
(controls.css) and a list of records measures like the list of inputs beside it.
|
|
539
|
+
The end inset the slab stands in arrives from the row's own rule above. */
|
|
540
|
+
.qm-element-summary {
|
|
541
|
+
display: flex;
|
|
542
|
+
align-items: center;
|
|
543
|
+
gap: var(--_qm-space);
|
|
544
|
+
width: 100%;
|
|
545
|
+
box-sizing: border-box;
|
|
546
|
+
text-align: start;
|
|
547
|
+
cursor: pointer;
|
|
548
|
+
}
|
|
549
|
+
/* No hover fill: a well does not fill under the pointer anywhere on this surface.
|
|
550
|
+
The chevron's ink is the cue, which is the accordion header's own ladder. */
|
|
551
|
+
.qm-element-summary :global(.qm-el-chevron) {
|
|
552
|
+
flex-shrink: 0;
|
|
553
|
+
display: block;
|
|
554
|
+
color: var(--_qm-ink-label);
|
|
555
|
+
transform: rotate(0deg);
|
|
556
|
+
transform-origin: center;
|
|
557
|
+
transition:
|
|
558
|
+
transform var(--_qm-duration-slow) var(--_qm-ease-reverse),
|
|
559
|
+
color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
560
|
+
}
|
|
561
|
+
.qm-element-summary:hover :global(.qm-el-chevron),
|
|
562
|
+
.qm-element.open :global(.qm-el-chevron) {
|
|
563
|
+
color: var(--_qm-ink);
|
|
564
|
+
}
|
|
565
|
+
.qm-element.open :global(.qm-el-chevron) {
|
|
566
|
+
transform: rotate(90deg);
|
|
567
|
+
}
|
|
568
|
+
/* The title is the element's own value, so it reads at the ink a written value
|
|
569
|
+
takes, on one line however long the cell runs. An untitled row has nothing written
|
|
570
|
+
in it yet and says so the way every other empty rung does. */
|
|
571
|
+
.qm-element-title {
|
|
572
|
+
overflow: hidden;
|
|
573
|
+
text-overflow: ellipsis;
|
|
574
|
+
white-space: nowrap;
|
|
575
|
+
}
|
|
576
|
+
.qm-element-title.untitled {
|
|
577
|
+
color: var(--_qm-ink-label);
|
|
578
|
+
font-style: italic;
|
|
407
579
|
}
|
|
408
580
|
/* It rests dim and comes up on hover of the field rather than of itself: one trigger
|
|
409
581
|
at the head of a row of controls is found by looking at the array, not by grazing its
|
|
@@ -424,6 +596,13 @@
|
|
|
424
596
|
.qm-add-el:focus-visible {
|
|
425
597
|
opacity: 1;
|
|
426
598
|
}
|
|
599
|
+
/* Empty, the rows box is `display: none` and the field is a label and this trigger:
|
|
600
|
+
nothing left for it to compete with, and nothing else in the field to act on. So
|
|
601
|
+
it rests full-strength there and takes the recede back once it heads a list. A
|
|
602
|
+
receded trigger beside no content reads as a caption rather than as a way in. */
|
|
603
|
+
.qm-array.empty .qm-add-el {
|
|
604
|
+
opacity: 1;
|
|
605
|
+
}
|
|
427
606
|
@media (hover: none) {
|
|
428
607
|
.qm-add-el,
|
|
429
608
|
.qm-remove {
|
|
@@ -15,6 +15,10 @@ interface Props {
|
|
|
15
15
|
labelId?: string;
|
|
16
16
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
17
17
|
descriptionId?: string;
|
|
18
|
+
/** The field's control id: the base an `object` element's subform derives its
|
|
19
|
+
* properties' own names from, one `-e-<element id>` segment down, so each open
|
|
20
|
+
* record's cells carry real `<label for>` pairs like every other control. */
|
|
21
|
+
idBase?: string;
|
|
18
22
|
/** Element `i`'s content, decoded by the boundary through the codec `items`
|
|
19
23
|
* names (`Field`, `reader.getContentAt`). Asked only for a content-typed
|
|
20
24
|
* element: on any other the read is not content and throws. `undefined` for a
|
|
@@ -26,7 +30,7 @@ interface Props {
|
|
|
26
30
|
declare const ArrayField: import("svelte").Component<Props, {
|
|
27
31
|
focus: () => void;
|
|
28
32
|
washBox: () => HTMLElement | undefined;
|
|
29
|
-
focusElement: (k: number) => void;
|
|
33
|
+
focusElement: (k: number, pos?: number) => void;
|
|
30
34
|
}, "">;
|
|
31
35
|
type ArrayField = ReturnType<typeof ArrayField>;
|
|
32
36
|
export default ArrayField;
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -140,6 +140,10 @@
|
|
|
140
140
|
let expanded = $state<string | null>(initialExpandedGroup(card.sections));
|
|
141
141
|
let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
|
|
142
142
|
let panels = $state<Record<string, HTMLElement | undefined>>({});
|
|
143
|
+
/** Whether the panels animate the move `expanded` is about to make. False for a reveal
|
|
144
|
+
* ({@link revealLeaf}), restored by the header gesture the motion is there for, so its
|
|
145
|
+
* return needs no timer. */
|
|
146
|
+
let animate = $state(true);
|
|
143
147
|
/**
|
|
144
148
|
* Move to `next` (`null` = all closed). A closing panel goes `inert` with whatever it
|
|
145
149
|
* held still focused, and `inert` does not say where focus should go — the browser
|
|
@@ -154,6 +158,7 @@
|
|
|
154
158
|
expanded = next;
|
|
155
159
|
}
|
|
156
160
|
function toggleGroup(group: string): void {
|
|
161
|
+
animate = true;
|
|
157
162
|
setExpanded(expanded === group ? null : group);
|
|
158
163
|
}
|
|
159
164
|
|
|
@@ -163,13 +168,20 @@
|
|
|
163
168
|
* flush before landing, since `inert` clears when the panel renders open, not when
|
|
164
169
|
* `expanded` is assigned.
|
|
165
170
|
*
|
|
171
|
+
* The move is instant, because that same flush is when the landing measures its target:
|
|
172
|
+
* an animating track still reads its start value there, and the trip would be computed
|
|
173
|
+
* against a panel that has not moved yet. The motion explains a header click, and a
|
|
174
|
+
* click in the preview is not one.
|
|
175
|
+
*
|
|
166
176
|
* A call, not a prop: a reveal is an event, and modelling it as state needs a
|
|
167
177
|
* fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
|
|
168
178
|
* re-derive from reopening the accordion under the user.
|
|
169
179
|
*/
|
|
170
180
|
export function revealLeaf(key: string): void {
|
|
171
181
|
const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
|
|
172
|
-
if (section?.group)
|
|
182
|
+
if (!section?.group) return;
|
|
183
|
+
animate = false;
|
|
184
|
+
setExpanded(section.group);
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
let el = $state<HTMLElement | undefined>(undefined);
|
|
@@ -252,7 +264,7 @@
|
|
|
252
264
|
accordion is not the bracket's last child: the bottom rule's inset belongs
|
|
253
265
|
to whichever block abuts it. -->
|
|
254
266
|
{#if grouped.length}
|
|
255
|
-
<div class="qm-groups">
|
|
267
|
+
<div class="qm-groups" class:qm-instant={!animate}>
|
|
256
268
|
{#each grouped as section (section.group)}
|
|
257
269
|
{@const group = section.group as string}
|
|
258
270
|
{@const isOpen = expanded === group}
|
|
@@ -618,6 +630,13 @@
|
|
|
618
630
|
.qm-group.qm-open .qm-group-panel {
|
|
619
631
|
grid-template-rows: 1fr;
|
|
620
632
|
}
|
|
633
|
+
/* The track is the accordion's only stroke that moves layout, and a reveal's landing
|
|
634
|
+
measures its target one flush after asking — where an animating track still reads its
|
|
635
|
+
start value. Both panels of the move are inside the group box, so the opening one and
|
|
636
|
+
the closing one above it stand still together (`revealLeaf`). */
|
|
637
|
+
.qm-groups.qm-instant .qm-group-panel {
|
|
638
|
+
transition: none;
|
|
639
|
+
}
|
|
621
640
|
/* Two boxes because the axes clip differently, and the track is the only thing that
|
|
622
641
|
animates: a field sits at its open position from the first frame of the reveal.
|
|
623
642
|
|