@quillmark/svelte 0.11.0 → 0.13.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/dist/core/address.d.ts +17 -10
- package/dist/core/address.js +26 -12
- package/dist/core/bloom.js +4 -3
- package/dist/core/codec/decode.d.ts +17 -0
- package/dist/core/codec/decode.js +78 -19
- package/dist/core/codec/encode.js +1 -1
- package/dist/core/codec/field.d.ts +64 -15
- package/dist/core/codec/field.js +201 -65
- package/dist/core/codec/index.d.ts +3 -3
- package/dist/core/codec/index.js +3 -3
- package/dist/core/codec/islands.d.ts +5 -4
- package/dist/core/codec/islands.js +23 -38
- package/dist/core/codec/keymap.d.ts +5 -4
- package/dist/core/codec/keymap.js +5 -4
- package/dist/core/codec/prose.css +42 -0
- package/dist/core/codec/schema.d.ts +24 -5
- package/dist/core/codec/schema.js +40 -9
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/errors.d.ts +3 -2
- package/dist/core/rungs.d.ts +8 -0
- package/dist/core/rungs.js +24 -0
- package/dist/core/theme.css +19 -6
- package/dist/preview/paint.js +21 -17
- package/dist/visual/ArrayField.svelte +649 -176
- package/dist/visual/ArrayField.svelte.d.ts +16 -1
- package/dist/visual/BooleanField.svelte +36 -11
- package/dist/visual/BooleanField.svelte.d.ts +2 -0
- package/dist/visual/Card.svelte +6 -25
- package/dist/visual/DateField.svelte +66 -31
- package/dist/visual/EnumField.svelte +27 -9
- package/dist/visual/EnumField.svelte.d.ts +5 -0
- package/dist/visual/Field.svelte +113 -38
- package/dist/visual/FormatPopover.svelte +4 -3
- package/dist/visual/MatrixField.svelte +353 -0
- package/dist/visual/MatrixField.svelte.d.ts +36 -0
- package/dist/visual/NumberField.svelte +31 -14
- package/dist/visual/NumberField.svelte.d.ts +5 -1
- package/dist/visual/ObjectField.svelte +404 -139
- package/dist/visual/ObjectField.svelte.d.ts +17 -2
- package/dist/visual/ProseField.svelte +53 -23
- package/dist/visual/ProseField.svelte.d.ts +15 -3
- package/dist/visual/ProseValue.svelte +102 -46
- package/dist/visual/ProseValue.svelte.d.ts +17 -5
- package/dist/visual/TextField.svelte +35 -18
- package/dist/visual/TextField.svelte.d.ts +7 -0
- package/dist/visual/VariantField.svelte +56 -2
- package/dist/visual/VariantField.svelte.d.ts +10 -1
- package/dist/visual/VisualEditorInner.svelte +77 -73
- package/dist/visual/controls.css +47 -5
- package/dist/visual/diagnostics.d.ts +26 -2
- package/dist/visual/diagnostics.js +52 -5
- package/dist/visual/hold.js +2 -2
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/leaves.d.ts +25 -12
- package/dist/visual/motion.d.ts +14 -5
- package/dist/visual/motion.js +13 -11
- package/dist/visual/strings.d.ts +27 -10
- package/dist/visual/strings.js +8 -2
- package/dist/visual/structure.d.ts +159 -40
- package/dist/visual/structure.js +337 -45
- package/package.json +2 -2
package/dist/visual/Field.svelte
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
Type dispatch (VISUAL_EDITOR §"Structure mirrors the schema"). Given one projected
|
|
3
3
|
{@link FieldModel} and its live value, render the label + the control the type
|
|
4
|
-
maps to. Array controls own their label (paired with the add affordance in
|
|
5
|
-
{@link ArrayField}
|
|
4
|
+
maps to. Array and matrix controls own their label (paired with the add affordance in
|
|
5
|
+
{@link ArrayField}, with the held count in {@link MatrixField}); other types render
|
|
6
|
+
the label here. A container control also takes the walk a nested address makes into
|
|
7
|
+
it ({@link FieldControl.focusPath}) and the diagnostics anchored inside it. Prose leaves take a
|
|
6
8
|
parent-built live `addr` (its `card` a getter over the stable-id→index map) so
|
|
7
9
|
a reorder re-targets without a remount; scalars, arrays, and objects commit
|
|
8
10
|
their value up through `onCommitScalar`, which the parent lowers to the typed
|
|
@@ -11,7 +13,8 @@
|
|
|
11
13
|
`diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
|
|
12
14
|
`diagByKey`, merging `quill.validate`, local commit errors, and the external
|
|
13
15
|
`diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
|
|
14
|
-
`DiagnosticList`, non-gating
|
|
16
|
+
`DiagnosticList`, non-gating; each set also re-evaluates a prose leaf's hold
|
|
17
|
+
(`ProseField`).
|
|
15
18
|
-->
|
|
16
19
|
<script lang="ts">
|
|
17
20
|
import type {
|
|
@@ -24,9 +27,22 @@
|
|
|
24
27
|
ResolvedField
|
|
25
28
|
} from '@quillmark/wasm';
|
|
26
29
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
27
|
-
import type { LeafRegistry } from './leaves.js';
|
|
30
|
+
import type { LandingBox, LeafRegistry } from './leaves.js';
|
|
28
31
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
29
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
VARIANT_DISCRIMINANT,
|
|
34
|
+
arrayLayout,
|
|
35
|
+
baseType,
|
|
36
|
+
exampleGhost,
|
|
37
|
+
ghostDefault,
|
|
38
|
+
isContainer,
|
|
39
|
+
optionalCell,
|
|
40
|
+
printedText,
|
|
41
|
+
stringifyGhost,
|
|
42
|
+
titleText
|
|
43
|
+
} from './structure.js';
|
|
44
|
+
import { wording } from './strings.js';
|
|
45
|
+
import { deepen } from './diagnostics.js';
|
|
30
46
|
import type { FieldDomIds } from './domid.js';
|
|
31
47
|
import ProseField from './ProseField.svelte';
|
|
32
48
|
import TextField from './TextField.svelte';
|
|
@@ -37,6 +53,7 @@
|
|
|
37
53
|
import ArrayField from './ArrayField.svelte';
|
|
38
54
|
import ObjectField from './ObjectField.svelte';
|
|
39
55
|
import VariantField from './VariantField.svelte';
|
|
56
|
+
import MatrixField from './MatrixField.svelte';
|
|
40
57
|
import DiagnosticList from './DiagnosticList.svelte';
|
|
41
58
|
import FieldLabel from './FieldLabel.svelte';
|
|
42
59
|
|
|
@@ -94,12 +111,29 @@
|
|
|
94
111
|
diagnostics
|
|
95
112
|
}: Props = $props();
|
|
96
113
|
|
|
97
|
-
|
|
114
|
+
const t = wording();
|
|
115
|
+
|
|
116
|
+
// What an unset field shows of its `default:`: the resolved value (provenance,
|
|
98
117
|
// `source === 'default'`). `ghost` is the raw typed value (enum/number/boolean
|
|
99
|
-
// fallbacks)
|
|
100
|
-
// control's `YYYY-MM-DD
|
|
118
|
+
// fallbacks, a content leaf's `Content`), `defaultStr` its string form (the date
|
|
119
|
+
// control's `YYYY-MM-DD`); an object-valued default has none. An array draws its
|
|
120
|
+
// declared literal instead, which is what a take writes (`ArrayField`).
|
|
121
|
+
// A default that prints stands in the control as the value it is, at the default
|
|
122
|
+
// rung, and the first edit takes it (VISUAL_EDITOR §"The commitment ladder"). Where
|
|
123
|
+
// nothing prints, a control draws words: the `none` an optional cell prints, and
|
|
124
|
+
// a free-text field's `example:`.
|
|
101
125
|
const ghost = $derived(ghostDefault(provenance));
|
|
102
|
-
const defaultStr = $derived(
|
|
126
|
+
const defaultStr = $derived(
|
|
127
|
+
field.control === 'prose' ? titleText(ghost) || undefined : stringifyGhost(ghost)
|
|
128
|
+
);
|
|
129
|
+
const printed = $derived(printedText(defaultStr));
|
|
130
|
+
// A text or number control reads its default off the schema, not the resolved row:
|
|
131
|
+
// the row stops naming a default once the field is written, and emptying a written
|
|
132
|
+
// field still has to know that the default prints.
|
|
133
|
+
const declared = $derived(printedText(field.schema.default));
|
|
134
|
+
const optional = $derived(optionalCell(field.schema));
|
|
135
|
+
const none = $derived(optional && value == null ? t.strings.optionalGhost : undefined);
|
|
136
|
+
const example = $derived(value == null ? exampleGhost(field.schema) : undefined);
|
|
103
137
|
// A variant resolves as one rung whose value is the whole container, so the
|
|
104
138
|
// discriminant's ghost is that container's own discriminant cell.
|
|
105
139
|
const ghostMember = $derived(
|
|
@@ -123,6 +157,13 @@
|
|
|
123
157
|
// reference must vanish with it: `aria-describedby` pointing at nothing describes
|
|
124
158
|
// nothing, and silently.
|
|
125
159
|
const describedBy = $derived(field.description ? domIds.description : undefined);
|
|
160
|
+
// Two controls own their label row, the add chip and the held count sharing it.
|
|
161
|
+
const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
|
|
162
|
+
|
|
163
|
+
// A container walks its diagnostics down to the cell each names and draws the rest
|
|
164
|
+
// at its own foot (`diagnostics.ts`); every other control draws them here, under
|
|
165
|
+
// itself, there being nothing inside it for a path to name.
|
|
166
|
+
const deep = $derived(deepen(diagnostics));
|
|
126
167
|
|
|
127
168
|
/** The boundary's nested content read with this field's address already bound: an
|
|
128
169
|
* array's elements, an object's properties, a variant's cells, and a subform's cells
|
|
@@ -145,28 +186,29 @@
|
|
|
145
186
|
// restores a selection, a date field lands on its first segment, an array lands on
|
|
146
187
|
// its first element or on the add affordance that is all an empty one has, an
|
|
147
188
|
// object on its first property.
|
|
189
|
+
/** What a container control exports: its focus, and the walk into it. */
|
|
190
|
+
type Inside = {
|
|
191
|
+
focus: () => void;
|
|
192
|
+
focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
|
|
193
|
+
washBox?: () => HTMLElement | undefined;
|
|
194
|
+
};
|
|
148
195
|
let proseEl = $state<{ focus: () => void } | undefined>();
|
|
149
196
|
let dateEl = $state<{ focus: () => void } | undefined>();
|
|
150
|
-
let arrayEl = $state<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
| undefined
|
|
157
|
-
>();
|
|
158
|
-
let objectEl = $state<{ focus: () => void } | undefined>();
|
|
197
|
+
let arrayEl = $state<Inside | undefined>();
|
|
198
|
+
let objectEl = $state<Inside | undefined>();
|
|
199
|
+
let variantEl = $state<Inside | undefined>();
|
|
200
|
+
let matrixEl = $state<Inside | undefined>();
|
|
201
|
+
const inside = (): Inside | undefined => arrayEl ?? objectEl ?? variantEl ?? matrixEl;
|
|
159
202
|
function focusControl(): void {
|
|
160
|
-
const owner = proseEl ?? dateEl ??
|
|
203
|
+
const owner = proseEl ?? dateEl ?? inside();
|
|
161
204
|
if (owner) return owner.focus();
|
|
162
205
|
document.getElementById(domIds.control)?.focus();
|
|
163
206
|
}
|
|
164
|
-
/** The
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return arrayEl?.focusElement(k, pos);
|
|
207
|
+
/** The landing at depth, for the addresses the boundary mints under a container
|
|
208
|
+
* (`leaves.ts`); read at the call, so it tracks the mounted control. The innermost
|
|
209
|
+
* row it settled in comes back with it, the wash being the landing's own granularity. */
|
|
210
|
+
function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
|
|
211
|
+
return inside()?.focusPath(steps, pos);
|
|
170
212
|
}
|
|
171
213
|
// Only where `for` cannot reach; the labelable four are the browser's own, and a
|
|
172
214
|
// second handler over them would be a focus the label already placed.
|
|
@@ -176,17 +218,17 @@
|
|
|
176
218
|
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
177
219
|
* control: `bloomInside` appends an inset child and an `<input>` holds none. The
|
|
178
220
|
* label is outside it, and stays out of the wash: an arrival marks where the caret
|
|
179
|
-
* landed, which is the control.
|
|
180
|
-
*
|
|
181
|
-
* `
|
|
221
|
+
* landed, which is the control. A control that owns its label (`ownsLabel`: the array
|
|
222
|
+
* and the matrix) holds that row inside itself, so it names its own box
|
|
223
|
+
* (`washBox`) and the bloom reads that in the wrapper's place.
|
|
182
224
|
*
|
|
183
225
|
* A prose leaf is absent here — it registers its own controller from inside
|
|
184
226
|
* `ProseField`, carrying the codec seam this handle has no half of — and reactive
|
|
185
227
|
* rather than mount-once, because a retype can swap the control under a leaf key
|
|
186
228
|
* that does not remount.
|
|
187
229
|
*
|
|
188
|
-
*
|
|
189
|
-
* an address to name. `el` is the field-wide box, which
|
|
230
|
+
* A container carries the walk into it as well; no other control has an inside for
|
|
231
|
+
* an address to name. `el` is the field-wide box, which a landing at depth does not
|
|
190
232
|
* wash: that lane hands back its own row (`leaves.ts`).
|
|
191
233
|
*/
|
|
192
234
|
let controlEl = $state<HTMLElement | undefined>();
|
|
@@ -197,9 +239,9 @@
|
|
|
197
239
|
const wrapper = controlEl;
|
|
198
240
|
registry.registerControl(key, {
|
|
199
241
|
focus: focusControl,
|
|
200
|
-
|
|
242
|
+
focusPath: isContainer(field.control) ? focusPath : undefined,
|
|
201
243
|
get el() {
|
|
202
|
-
return
|
|
244
|
+
return inside()?.washBox?.() ?? wrapper;
|
|
203
245
|
}
|
|
204
246
|
});
|
|
205
247
|
return () => registry.unregisterControl(key);
|
|
@@ -217,7 +259,7 @@
|
|
|
217
259
|
</script>
|
|
218
260
|
|
|
219
261
|
<div class="qm-field" class:cell={span === 'cell'}>
|
|
220
|
-
{#if
|
|
262
|
+
{#if !ownsLabel}
|
|
221
263
|
<FieldLabel
|
|
222
264
|
label={field.label}
|
|
223
265
|
controlId={labelable ? domIds.control : undefined}
|
|
@@ -242,7 +284,10 @@
|
|
|
242
284
|
{addr}
|
|
243
285
|
inline={field.inline}
|
|
244
286
|
plaintext={field.plaintext}
|
|
245
|
-
|
|
287
|
+
fallback={printed ? (ghost as Content) : undefined}
|
|
288
|
+
placeholder={none}
|
|
289
|
+
placeholderUntilEdit
|
|
290
|
+
{example}
|
|
246
291
|
labelledBy={domIds.label}
|
|
247
292
|
{describedBy}
|
|
248
293
|
{leafKey}
|
|
@@ -251,12 +296,15 @@
|
|
|
251
296
|
{onChange}
|
|
252
297
|
{onError}
|
|
253
298
|
{leaves}
|
|
299
|
+
{diagnostics}
|
|
254
300
|
/>
|
|
255
301
|
{:else if field.control === 'enum'}
|
|
256
302
|
<EnumField
|
|
257
303
|
value={value as string | undefined}
|
|
258
304
|
values={field.schema.values ?? []}
|
|
259
305
|
fallback={ghost as string | undefined}
|
|
306
|
+
blankTitle={field.schema.ui?.blank_title}
|
|
307
|
+
{optional}
|
|
260
308
|
id={domIds.control}
|
|
261
309
|
{describedBy}
|
|
262
310
|
onCommit={onCommitScalar}
|
|
@@ -265,6 +313,7 @@
|
|
|
265
313
|
/>
|
|
266
314
|
{:else if field.control === 'variant'}
|
|
267
315
|
<VariantField
|
|
316
|
+
bind:this={variantEl}
|
|
268
317
|
value={value as Record<string, unknown> | undefined}
|
|
269
318
|
schema={field.schema}
|
|
270
319
|
{ghostMember}
|
|
@@ -276,12 +325,14 @@
|
|
|
276
325
|
onCommit={onCommitScalar}
|
|
277
326
|
{optionAllowed}
|
|
278
327
|
{enumDisallowed}
|
|
328
|
+
diagnostics={deep}
|
|
279
329
|
/>
|
|
280
330
|
{:else if field.control === 'number'}
|
|
281
331
|
<NumberField
|
|
282
332
|
value={value as number | undefined}
|
|
283
|
-
integer={field.schema
|
|
284
|
-
fallback={
|
|
333
|
+
integer={baseType(field.schema) === 'integer'}
|
|
334
|
+
fallback={declared}
|
|
335
|
+
placeholder={none}
|
|
285
336
|
id={domIds.control}
|
|
286
337
|
{describedBy}
|
|
287
338
|
onCommit={onCommitScalar}
|
|
@@ -290,6 +341,7 @@
|
|
|
290
341
|
<BooleanField
|
|
291
342
|
value={value as boolean | undefined}
|
|
292
343
|
fallback={ghost as boolean | undefined}
|
|
344
|
+
{optional}
|
|
293
345
|
id={domIds.control}
|
|
294
346
|
{describedBy}
|
|
295
347
|
onCommit={onCommitScalar}
|
|
@@ -307,7 +359,10 @@
|
|
|
307
359
|
<ArrayField
|
|
308
360
|
bind:this={arrayEl}
|
|
309
361
|
value={value as unknown[] | undefined}
|
|
362
|
+
fallback={Array.isArray(field.schema.default) ? field.schema.default : undefined}
|
|
310
363
|
items={field.schema.items}
|
|
364
|
+
layout={arrayLayout(field.schema)}
|
|
365
|
+
max={field.schema.max}
|
|
311
366
|
label={field.label}
|
|
312
367
|
required={field.required}
|
|
313
368
|
description={field.description}
|
|
@@ -316,6 +371,7 @@
|
|
|
316
371
|
idBase={domIds.control}
|
|
317
372
|
{contentAt}
|
|
318
373
|
onCommit={onCommitScalar}
|
|
374
|
+
diagnostics={deep}
|
|
319
375
|
/>
|
|
320
376
|
{:else if field.control === 'object'}
|
|
321
377
|
<ObjectField
|
|
@@ -328,11 +384,28 @@
|
|
|
328
384
|
{describedBy}
|
|
329
385
|
{contentAt}
|
|
330
386
|
onCommit={onCommitScalar}
|
|
387
|
+
diagnostics={deep}
|
|
388
|
+
/>
|
|
389
|
+
{:else if field.control === 'matrix'}
|
|
390
|
+
<MatrixField
|
|
391
|
+
bind:this={matrixEl}
|
|
392
|
+
value={value as Record<string, unknown> | undefined}
|
|
393
|
+
schema={field.schema}
|
|
394
|
+
label={field.label}
|
|
395
|
+
description={field.description}
|
|
396
|
+
labelId={domIds.label}
|
|
397
|
+
descriptionId={domIds.description}
|
|
398
|
+
idBase={domIds.control}
|
|
399
|
+
{contentAt}
|
|
400
|
+
onCommit={onCommitScalar}
|
|
401
|
+
diagnostics={deep}
|
|
331
402
|
/>
|
|
332
403
|
{:else}
|
|
333
404
|
<TextField
|
|
334
405
|
value={value as string | undefined}
|
|
335
|
-
|
|
406
|
+
fallback={declared}
|
|
407
|
+
placeholder={none}
|
|
408
|
+
{example}
|
|
336
409
|
id={domIds.control}
|
|
337
410
|
{describedBy}
|
|
338
411
|
onCommit={onCommitScalar}
|
|
@@ -340,7 +413,9 @@
|
|
|
340
413
|
{/if}
|
|
341
414
|
</div>
|
|
342
415
|
|
|
343
|
-
|
|
416
|
+
{#if !isContainer(field.control)}
|
|
417
|
+
<DiagnosticList {diagnostics} />
|
|
418
|
+
{/if}
|
|
344
419
|
</div>
|
|
345
420
|
</div>
|
|
346
421
|
|
|
@@ -142,9 +142,10 @@
|
|
|
142
142
|
const selection = view?.state.selection;
|
|
143
143
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
144
144
|
// …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
|
|
145
|
-
// types (`plaintextSchema`), so this surface withholds itself over
|
|
146
|
-
// the way it never rises over the text input a `string` field draws:
|
|
147
|
-
// literal text, and a mark on it is a value the boundary refuses to
|
|
145
|
+
// types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
|
|
146
|
+
// one entirely, the way it never rises over the text input a `string` field draws:
|
|
147
|
+
// the value is literal text, and a mark on it is a value the boundary refuses to
|
|
148
|
+
// coerce back.
|
|
148
149
|
if (
|
|
149
150
|
!view ||
|
|
150
151
|
!view.hasFocus() ||
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A `matrix` field → ticks over the roster, columns unfolding under a held member
|
|
3
|
+
(VISUAL_EDITOR §"Structure mirrors the schema", canon `SCHEMAS.md` §Matrix). The
|
|
4
|
+
roster is one flat list, one member to a row, or abreast on the section's track ladder
|
|
5
|
+
under `ui.compact`; each member is a real checkbox bound to its `held` cell with
|
|
6
|
+
its title as the `<label for>`, an unheld member reading at the label ink and a held
|
|
7
|
+
one at the value ink. A held member unfolds its columns beneath its title, drawn by
|
|
8
|
+
{@link ObjectField} over the matrix's `properties` at the member's key, the way a
|
|
9
|
+
variant unfolds its cells; zero columns is a checklist and unfolds nothing. An unheld
|
|
10
|
+
member's retained columns are held but undrawn, the variant rule: in the document and
|
|
11
|
+
not on the page.
|
|
12
|
+
|
|
13
|
+
The control owns its label row, as an array does: the count of held members sits
|
|
14
|
+
where an array's add chip sits, one piece of chrome with one answer, so {@link Field}
|
|
15
|
+
skips its own label for it.
|
|
16
|
+
|
|
17
|
+
Commit is whole: the sparse map, one `writer.set`. A tick writes the member object
|
|
18
|
+
with an explicit `held`, an untick keeps the columns under `held: false`, and a member
|
|
19
|
+
unheld with no columns leaves the map (`memberWrite`). What is blank per column is the
|
|
20
|
+
engine's rule and not restated: a column control that clears to unset drops its key,
|
|
21
|
+
so the editor drops a member it has emptied rather than deciding skippability for
|
|
22
|
+
itself; one whose default prints clears to the empty answer, which the member holds.
|
|
23
|
+
The read takes both rest forms — a bare tick and `{held?, …columns}`, whose `held` rests
|
|
24
|
+
at `false` where the mapping names none — and the write lands one, on the member edited
|
|
25
|
+
alone; every other member rides through as the document spelled it.
|
|
26
|
+
-->
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { tick as flush } from 'svelte';
|
|
29
|
+
import { wording } from './strings.js';
|
|
30
|
+
|
|
31
|
+
// The surface's words, ambient from the editor root; the package's English
|
|
32
|
+
// off-tree, so this component renders standalone too.
|
|
33
|
+
const t = wording();
|
|
34
|
+
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
35
|
+
import {
|
|
36
|
+
MATRIX_HELD,
|
|
37
|
+
commitMember,
|
|
38
|
+
matrixColumns,
|
|
39
|
+
matrixDeclares,
|
|
40
|
+
matrixHeld,
|
|
41
|
+
matrixMembers,
|
|
42
|
+
memberValue,
|
|
43
|
+
memberWrite
|
|
44
|
+
} from './structure.js';
|
|
45
|
+
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
46
|
+
import type { LandingBox } from './leaves.js';
|
|
47
|
+
import { propertyDomIds } from './domid.js';
|
|
48
|
+
import Icon from './icons/Icon.svelte';
|
|
49
|
+
import FieldLabel from './FieldLabel.svelte';
|
|
50
|
+
import ObjectField from './ObjectField.svelte';
|
|
51
|
+
import DiagnosticList from './DiagnosticList.svelte';
|
|
52
|
+
import './controls.css';
|
|
53
|
+
|
|
54
|
+
interface Props {
|
|
55
|
+
/** The stored sparse map, or undefined while the field is unset. */
|
|
56
|
+
value: Record<string, unknown> | undefined;
|
|
57
|
+
/** The field's own schema: `members` is the roster, `properties` the columns. */
|
|
58
|
+
schema: QuillFieldSchema;
|
|
59
|
+
/** Field label, the naming prefix a member's columns compose with. */
|
|
60
|
+
label?: string;
|
|
61
|
+
/** Schema `description`: the label's help affordance. */
|
|
62
|
+
description?: string;
|
|
63
|
+
/** The label's own DOM id: the group's name, each tick keeping its own `<label>`. */
|
|
64
|
+
labelId?: string;
|
|
65
|
+
/** Where the description parks, for the group's `aria-describedby`. */
|
|
66
|
+
descriptionId?: string;
|
|
67
|
+
/** The field's control id: the base a member's tick and columns derive their own
|
|
68
|
+
* names from, one `-m-<member id>` segment down. */
|
|
69
|
+
idBase?: string;
|
|
70
|
+
/** The boundary's nested content read, rooted at this field: a member's content
|
|
71
|
+
* column reads at `[id, column]` (`reader.getContentAt`). */
|
|
72
|
+
contentAt: (path: PathStep[]) => Content | undefined;
|
|
73
|
+
onCommit: (v: Record<string, unknown> | undefined) => void;
|
|
74
|
+
/** The diagnostics routed into this field, each with its steps still to walk: one
|
|
75
|
+
* naming a member draws under that member, one naming nothing on the roster at the
|
|
76
|
+
* field's foot. */
|
|
77
|
+
diagnostics?: readonly DeepDiagnostic[];
|
|
78
|
+
}
|
|
79
|
+
let {
|
|
80
|
+
value,
|
|
81
|
+
schema,
|
|
82
|
+
label,
|
|
83
|
+
description,
|
|
84
|
+
labelId,
|
|
85
|
+
descriptionId,
|
|
86
|
+
idBase,
|
|
87
|
+
contentAt,
|
|
88
|
+
onCommit,
|
|
89
|
+
diagnostics
|
|
90
|
+
}: Props = $props();
|
|
91
|
+
|
|
92
|
+
const members = $derived(matrixMembers(schema.members));
|
|
93
|
+
const compact = $derived(!!schema.ui?.compact);
|
|
94
|
+
const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
|
|
95
|
+
const held = (id: string): boolean => matrixHeld(memberValue(value, id));
|
|
96
|
+
const heldCount = $derived(members.filter((m) => held(m.id)).length);
|
|
97
|
+
const routed = $derived(splitDeep(diagnostics));
|
|
98
|
+
const foot = $derived(
|
|
99
|
+
unrouted(routed, (step) => typeof step === 'string' && matrixDeclares(schema, step))
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// A member with no field id space around it still needs an id: the tick is what its
|
|
103
|
+
// title's `for` names. Stable across SSR and hydration, which a counter is not.
|
|
104
|
+
const uid = $props.id();
|
|
105
|
+
const memberBase = (id: string): string =>
|
|
106
|
+
idBase != null ? `${idBase}-m-${id}` : `qm-${uid}-${id}`;
|
|
107
|
+
/** The tick is the member's `held` cell, so its id is the one that cell's label
|
|
108
|
+
* would take: the column subform below it derives its own names from the same base. */
|
|
109
|
+
const tickId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).control;
|
|
110
|
+
const titleId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).label;
|
|
111
|
+
|
|
112
|
+
// The member boxes, the ticks and the column subforms, keyed by member id — the
|
|
113
|
+
// roster's own key, stable for the schema's life. `$state` for the binding's sake
|
|
114
|
+
// (`ArrayField` keeps its element refs the same way).
|
|
115
|
+
type Subform = {
|
|
116
|
+
focus: () => void;
|
|
117
|
+
focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
|
|
118
|
+
};
|
|
119
|
+
const memberEls: Record<string, HTMLElement | undefined> = $state({});
|
|
120
|
+
const tickEls: Record<string, HTMLInputElement | undefined> = $state({});
|
|
121
|
+
const colEls: Record<string, Subform | undefined> = $state({});
|
|
122
|
+
let rosterEl = $state<HTMLElement | undefined>();
|
|
123
|
+
/** A ref by member id, own keys only: an id can be spelled `constructor`. */
|
|
124
|
+
const ref = <T,>(map: Record<string, T | undefined>, id: string): T | undefined =>
|
|
125
|
+
Object.hasOwn(map, id) ? map[id] : undefined;
|
|
126
|
+
|
|
127
|
+
/** A native checkbox carries its own state, so a commit the document declines leaves
|
|
128
|
+
* the face ticked over a map that says otherwise, `checked={held(id)}` having nothing
|
|
129
|
+
* new to write. The reassert after the flush is what the styled controls get from
|
|
130
|
+
* their synced local (`synced.svelte.ts`): the document is what the face reads. */
|
|
131
|
+
async function tick(id: string, on: boolean, el: HTMLInputElement): Promise<void> {
|
|
132
|
+
onCommit(commitMember(value, id, memberWrite(on, matrixColumns(memberValue(value, id)))));
|
|
133
|
+
await flush();
|
|
134
|
+
if (el.isConnected) el.checked = held(id);
|
|
135
|
+
}
|
|
136
|
+
/** A column edit lands on a held member: the subform draws under a tick alone. */
|
|
137
|
+
function commitColumns(id: string, columns: Record<string, unknown>): void {
|
|
138
|
+
onCommit(commitMember(value, id, memberWrite(true, columns)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Take the caret: the first member's tick. Reached by a label click and by the
|
|
142
|
+
* editor's landing verbs, which ask one function so they cannot disagree. */
|
|
143
|
+
export function focus(): void {
|
|
144
|
+
const first = members[0];
|
|
145
|
+
if (first) ref(tickEls, first.id)?.focus();
|
|
146
|
+
}
|
|
147
|
+
/** The box an arrival wash blooms in: the roster, not the label row above it, this
|
|
148
|
+
* component owning the field's label as an array does. */
|
|
149
|
+
export function washBox(): HTMLElement | undefined {
|
|
150
|
+
return rosterEl;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Land at `steps`: a member, or a column under it ({@link FieldControl.focusPath}).
|
|
154
|
+
* A member address and its `held` cell focus the tick; a column address focuses
|
|
155
|
+
* that column where the member is held and its columns are drawn, and the tick
|
|
156
|
+
* where they are not — the address names the column, and what is drawn for it is
|
|
157
|
+
* the tick that would open it. One rule keyed on the address, never on the
|
|
158
|
+
* document, so one address lands in one place. A member off the roster falls back
|
|
159
|
+
* to {@link focus}. The innermost row the walk opened comes back, else the member's
|
|
160
|
+
* box: the member is the row the address named.
|
|
161
|
+
*/
|
|
162
|
+
export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
|
|
163
|
+
const [id, ...rest] = steps;
|
|
164
|
+
if (typeof id !== 'string' || !matrixDeclares(schema, id)) {
|
|
165
|
+
focus();
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const column = rest[0];
|
|
169
|
+
const cells = ref(colEls, id);
|
|
170
|
+
const member = ref(memberEls, id);
|
|
171
|
+
if (column !== undefined && column !== MATRIX_HELD && cells) {
|
|
172
|
+
const inner = cells.focusPath(rest, pos);
|
|
173
|
+
return inner instanceof Promise ? inner.then((box) => box ?? member) : (inner ?? member);
|
|
174
|
+
}
|
|
175
|
+
ref(tickEls, id)?.focus();
|
|
176
|
+
return member;
|
|
177
|
+
}
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
<div
|
|
181
|
+
class="qm-matrix"
|
|
182
|
+
role="group"
|
|
183
|
+
aria-labelledby={label != null ? labelId : undefined}
|
|
184
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
185
|
+
>
|
|
186
|
+
<div class="qm-matrix-header">
|
|
187
|
+
{#if label != null}
|
|
188
|
+
<FieldLabel {label} id={labelId} {descriptionId} onActivate={focus} {description} />
|
|
189
|
+
{:else}
|
|
190
|
+
<span></span>
|
|
191
|
+
{/if}
|
|
192
|
+
<span class="qm-matrix-count">{t.strings.matrixHeld(heldCount, members.length)}</span>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="qm-matrix-roster" class:qm-tracks={compact} class:compact bind:this={rosterEl}>
|
|
195
|
+
{#each members as m (m.id)}
|
|
196
|
+
{@const on = held(m.id)}
|
|
197
|
+
{@const deep = routed.below.get(m.id)}
|
|
198
|
+
<div class="qm-member" class:held={on} bind:this={memberEls[m.id]}>
|
|
199
|
+
<div class="qm-member-head">
|
|
200
|
+
<!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
|
|
201
|
+
dial reaches, so the input is `appearance: none` and the box and the
|
|
202
|
+
mark beside it read the rungs. The mark is the surface's one check
|
|
203
|
+
glyph, shown by the input's own state. -->
|
|
204
|
+
<span class="qm-tick">
|
|
205
|
+
<input
|
|
206
|
+
type="checkbox"
|
|
207
|
+
class="qm-tick-input qm-focus-ring"
|
|
208
|
+
id={tickId(m.id)}
|
|
209
|
+
checked={on}
|
|
210
|
+
bind:this={tickEls[m.id]}
|
|
211
|
+
onchange={(e) => tick(m.id, e.currentTarget.checked, e.currentTarget)}
|
|
212
|
+
/>
|
|
213
|
+
<Icon name="check" class="qm-tick-mark" />
|
|
214
|
+
</span>
|
|
215
|
+
<label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
|
|
216
|
+
</div>
|
|
217
|
+
{#if on && hasColumns}
|
|
218
|
+
<!-- The columns, one rung in, the way a variant's cells unfold under the
|
|
219
|
+
discriminant. Keyed by member id above, so a tick elsewhere leaves this
|
|
220
|
+
subform mounted and the caret in it. -->
|
|
221
|
+
<ObjectField
|
|
222
|
+
bind:this={colEls[m.id]}
|
|
223
|
+
value={matrixColumns(memberValue(value, m.id))}
|
|
224
|
+
properties={schema.properties}
|
|
225
|
+
label={`${label ?? ''} ${m.title}`.trim()}
|
|
226
|
+
idBase={memberBase(m.id)}
|
|
227
|
+
labelledBy={titleId(m.id)}
|
|
228
|
+
contentAt={(path) => contentAt([m.id, ...path])}
|
|
229
|
+
onCommit={(columns) => commitColumns(m.id, columns)}
|
|
230
|
+
diagnostics={deep}
|
|
231
|
+
/>
|
|
232
|
+
{:else}
|
|
233
|
+
<DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
236
|
+
{/each}
|
|
237
|
+
</div>
|
|
238
|
+
<DiagnosticList diagnostics={foot} />
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<style>
|
|
242
|
+
.qm-matrix {
|
|
243
|
+
display: flex;
|
|
244
|
+
flex-direction: column;
|
|
245
|
+
gap: var(--_qm-space);
|
|
246
|
+
}
|
|
247
|
+
/* The label row, the array's own shape: this component owns the label track
|
|
248
|
+
(`Field.svelte`), and the count stands where the add chip would. */
|
|
249
|
+
.qm-matrix-header {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
justify-content: space-between;
|
|
253
|
+
gap: var(--_qm-space-2);
|
|
254
|
+
}
|
|
255
|
+
/* The count reads at the label rung: a fact about the field, never a value. */
|
|
256
|
+
.qm-matrix-count {
|
|
257
|
+
font-size: var(--_qm-text-label);
|
|
258
|
+
color: var(--_qm-ink-label);
|
|
259
|
+
font-variant-numeric: tabular-nums;
|
|
260
|
+
}
|
|
261
|
+
/* The roster, one member to a row in declaration order. Positioned for the wash's
|
|
262
|
+
inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
|
|
263
|
+
rows are. */
|
|
264
|
+
.qm-matrix-roster {
|
|
265
|
+
position: relative;
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
gap: var(--_qm-space);
|
|
269
|
+
min-width: 0;
|
|
270
|
+
border-radius: var(--_qm-radius-inner);
|
|
271
|
+
}
|
|
272
|
+
/* Compact, the members stand abreast in reading order at the count the ladder gives
|
|
273
|
+
the width (`.qm-tracks`, controls.css), a held member's columns unfolding inside its
|
|
274
|
+
own track. */
|
|
275
|
+
.qm-matrix-roster.compact {
|
|
276
|
+
display: grid;
|
|
277
|
+
grid-template-columns: repeat(var(--cols), 1fr);
|
|
278
|
+
align-items: start;
|
|
279
|
+
column-gap: var(--_qm-space-2);
|
|
280
|
+
}
|
|
281
|
+
/* A member is a row: positioned for the wash a landing on it blooms, rounded to the
|
|
282
|
+
rung the rows around it draw. */
|
|
283
|
+
.qm-member {
|
|
284
|
+
position: relative;
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
gap: var(--_qm-space);
|
|
288
|
+
border-radius: var(--_qm-radius-inner);
|
|
289
|
+
}
|
|
290
|
+
/* The tick and its title on one line, the line holding the tap floor a tick alone
|
|
291
|
+
would not reach: the title is the target, the `for` carrying its press to the input. */
|
|
292
|
+
.qm-member-head {
|
|
293
|
+
display: flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
gap: var(--_qm-space-2);
|
|
296
|
+
min-height: var(--_qm-tap-min);
|
|
297
|
+
}
|
|
298
|
+
.qm-tick {
|
|
299
|
+
position: relative;
|
|
300
|
+
display: inline-flex;
|
|
301
|
+
flex-shrink: 0;
|
|
302
|
+
width: var(--_qm-glyph-control);
|
|
303
|
+
height: var(--_qm-glyph-control);
|
|
304
|
+
}
|
|
305
|
+
/* The box: a well, edged, since a mark this small has no plane under it to step off
|
|
306
|
+
by tone alone; held, it takes the accent the switch's track takes when checked. */
|
|
307
|
+
.qm-tick-input {
|
|
308
|
+
appearance: none;
|
|
309
|
+
margin: 0;
|
|
310
|
+
width: 100%;
|
|
311
|
+
height: 100%;
|
|
312
|
+
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
313
|
+
border-radius: calc(var(--_qm-radius-inner) / 2);
|
|
314
|
+
background: var(--_qm-surface-well);
|
|
315
|
+
cursor: pointer;
|
|
316
|
+
transition:
|
|
317
|
+
background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
318
|
+
border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
319
|
+
}
|
|
320
|
+
.qm-tick-input:checked {
|
|
321
|
+
background: var(--_qm-accent);
|
|
322
|
+
border-color: var(--_qm-accent);
|
|
323
|
+
}
|
|
324
|
+
/* The mark rides over the input and takes no press of its own; it is drawn by the
|
|
325
|
+
input's state, in the ink the surface reads over the accent. */
|
|
326
|
+
.qm-tick :global(.qm-tick-mark) {
|
|
327
|
+
position: absolute;
|
|
328
|
+
inset: 0;
|
|
329
|
+
width: 100%;
|
|
330
|
+
height: 100%;
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
color: var(--_qm-surface);
|
|
333
|
+
opacity: 0;
|
|
334
|
+
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
335
|
+
}
|
|
336
|
+
.qm-tick:has(:checked) :global(.qm-tick-mark) {
|
|
337
|
+
opacity: 1;
|
|
338
|
+
}
|
|
339
|
+
/* Unheld reads at the label ink, held at the value ink: the title is what the
|
|
340
|
+
document says once it is ticked, and until then it is the vocabulary. */
|
|
341
|
+
.qm-member-title {
|
|
342
|
+
font-size: var(--_qm-text-body);
|
|
343
|
+
line-height: var(--_qm-leading-tight);
|
|
344
|
+
color: var(--_qm-ink-label);
|
|
345
|
+
cursor: pointer;
|
|
346
|
+
min-width: 0;
|
|
347
|
+
transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
348
|
+
}
|
|
349
|
+
.qm-member.held .qm-member-title,
|
|
350
|
+
.qm-member-title:hover {
|
|
351
|
+
color: var(--_qm-ink);
|
|
352
|
+
}
|
|
353
|
+
</style>
|