@quillmark/svelte 0.11.0 → 0.12.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.
Files changed (37) hide show
  1. package/dist/core/address.d.ts +17 -10
  2. package/dist/core/address.js +26 -12
  3. package/dist/core/bloom.js +4 -3
  4. package/dist/core/codec/decode.js +21 -15
  5. package/dist/core/codec/encode.js +1 -1
  6. package/dist/core/codec/islands.d.ts +5 -4
  7. package/dist/core/codec/islands.js +23 -38
  8. package/dist/core/codec/slash.js +0 -0
  9. package/dist/core/rungs.d.ts +8 -0
  10. package/dist/core/rungs.js +24 -0
  11. package/dist/core/theme.css +10 -0
  12. package/dist/visual/ArrayField.svelte +596 -167
  13. package/dist/visual/ArrayField.svelte.d.ts +14 -1
  14. package/dist/visual/BooleanField.svelte +10 -7
  15. package/dist/visual/Card.svelte +3 -22
  16. package/dist/visual/Field.svelte +69 -29
  17. package/dist/visual/MatrixField.svelte +352 -0
  18. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  19. package/dist/visual/ObjectField.svelte +351 -133
  20. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  21. package/dist/visual/VariantField.svelte +55 -2
  22. package/dist/visual/VariantField.svelte.d.ts +10 -1
  23. package/dist/visual/VisualEditorInner.svelte +53 -49
  24. package/dist/visual/controls.css +31 -0
  25. package/dist/visual/diagnostics.d.ts +26 -2
  26. package/dist/visual/diagnostics.js +52 -5
  27. package/dist/visual/hold.js +2 -2
  28. package/dist/visual/icons/nodes.d.ts +3 -0
  29. package/dist/visual/icons/nodes.js +2 -1
  30. package/dist/visual/leaves.d.ts +25 -12
  31. package/dist/visual/motion.d.ts +14 -5
  32. package/dist/visual/motion.js +13 -11
  33. package/dist/visual/strings.d.ts +19 -9
  34. package/dist/visual/strings.js +6 -2
  35. package/dist/visual/structure.d.ts +94 -9
  36. package/dist/visual/structure.js +229 -12
  37. package/package.json +2 -2
@@ -0,0 +1,352 @@
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 drops its key, so the
21
+ editor drops a member it has emptied rather than deciding skippability for itself.
22
+ The read takes both rest forms — a bare tick and `{held?, …columns}`, whose `held` rests
23
+ at `false` where the mapping names none — and the write lands one, on the member edited
24
+ alone; every other member rides through as the document spelled it.
25
+ -->
26
+ <script lang="ts">
27
+ import { tick as flush } from 'svelte';
28
+ import { wording } from './strings.js';
29
+
30
+ // The surface's words, ambient from the editor root; the package's English
31
+ // off-tree, so this component renders standalone too.
32
+ const t = wording();
33
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
34
+ import {
35
+ MATRIX_HELD,
36
+ commitMember,
37
+ matrixColumns,
38
+ matrixDeclares,
39
+ matrixHeld,
40
+ matrixMembers,
41
+ memberValue,
42
+ memberWrite
43
+ } from './structure.js';
44
+ import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
45
+ import type { LandingBox } from './leaves.js';
46
+ import { propertyDomIds } from './domid.js';
47
+ import Icon from './icons/Icon.svelte';
48
+ import FieldLabel from './FieldLabel.svelte';
49
+ import ObjectField from './ObjectField.svelte';
50
+ import DiagnosticList from './DiagnosticList.svelte';
51
+ import './controls.css';
52
+
53
+ interface Props {
54
+ /** The stored sparse map, or undefined while the field is unset. */
55
+ value: Record<string, unknown> | undefined;
56
+ /** The field's own schema: `members` is the roster, `properties` the columns. */
57
+ schema: QuillFieldSchema;
58
+ /** Field label, the naming prefix a member's columns compose with. */
59
+ label?: string;
60
+ /** Schema `description`: the label's help affordance. */
61
+ description?: string;
62
+ /** The label's own DOM id: the group's name, each tick keeping its own `<label>`. */
63
+ labelId?: string;
64
+ /** Where the description parks, for the group's `aria-describedby`. */
65
+ descriptionId?: string;
66
+ /** The field's control id: the base a member's tick and columns derive their own
67
+ * names from, one `-m-<member id>` segment down. */
68
+ idBase?: string;
69
+ /** The boundary's nested content read, rooted at this field: a member's content
70
+ * column reads at `[id, column]` (`reader.getContentAt`). */
71
+ contentAt: (path: PathStep[]) => Content | undefined;
72
+ onCommit: (v: Record<string, unknown> | undefined) => void;
73
+ /** The diagnostics routed into this field, each with its steps still to walk: one
74
+ * naming a member draws under that member, one naming nothing on the roster at the
75
+ * field's foot. */
76
+ diagnostics?: readonly DeepDiagnostic[];
77
+ }
78
+ let {
79
+ value,
80
+ schema,
81
+ label,
82
+ description,
83
+ labelId,
84
+ descriptionId,
85
+ idBase,
86
+ contentAt,
87
+ onCommit,
88
+ diagnostics
89
+ }: Props = $props();
90
+
91
+ const members = $derived(matrixMembers(schema.members));
92
+ const compact = $derived(!!schema.ui?.compact);
93
+ const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
94
+ const held = (id: string): boolean => matrixHeld(memberValue(value, id));
95
+ const heldCount = $derived(members.filter((m) => held(m.id)).length);
96
+ const routed = $derived(splitDeep(diagnostics));
97
+ const foot = $derived(
98
+ unrouted(routed, (step) => typeof step === 'string' && matrixDeclares(schema, step))
99
+ );
100
+
101
+ // A member with no field id space around it still needs an id: the tick is what its
102
+ // title's `for` names. Stable across SSR and hydration, which a counter is not.
103
+ const uid = $props.id();
104
+ const memberBase = (id: string): string =>
105
+ idBase != null ? `${idBase}-m-${id}` : `qm-${uid}-${id}`;
106
+ /** The tick is the member's `held` cell, so its id is the one that cell's label
107
+ * would take: the column subform below it derives its own names from the same base. */
108
+ const tickId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).control;
109
+ const titleId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_HELD).label;
110
+
111
+ // The member boxes, the ticks and the column subforms, keyed by member id — the
112
+ // roster's own key, stable for the schema's life. `$state` for the binding's sake
113
+ // (`ArrayField` keeps its element refs the same way).
114
+ type Subform = {
115
+ focus: () => void;
116
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
117
+ };
118
+ const memberEls: Record<string, HTMLElement | undefined> = $state({});
119
+ const tickEls: Record<string, HTMLInputElement | undefined> = $state({});
120
+ const colEls: Record<string, Subform | undefined> = $state({});
121
+ let rosterEl = $state<HTMLElement | undefined>();
122
+ /** A ref by member id, own keys only: an id can be spelled `constructor`. */
123
+ const ref = <T,>(map: Record<string, T | undefined>, id: string): T | undefined =>
124
+ Object.hasOwn(map, id) ? map[id] : undefined;
125
+
126
+ /** A native checkbox carries its own state, so a commit the document declines leaves
127
+ * the face ticked over a map that says otherwise, `checked={held(id)}` having nothing
128
+ * new to write. The reassert after the flush is what the styled controls get from
129
+ * their synced local (`synced.svelte.ts`): the document is what the face reads. */
130
+ async function tick(id: string, on: boolean, el: HTMLInputElement): Promise<void> {
131
+ onCommit(commitMember(value, id, memberWrite(on, matrixColumns(memberValue(value, id)))));
132
+ await flush();
133
+ if (el.isConnected) el.checked = held(id);
134
+ }
135
+ /** A column edit lands on a held member: the subform draws under a tick alone. */
136
+ function commitColumns(id: string, columns: Record<string, unknown>): void {
137
+ onCommit(commitMember(value, id, memberWrite(true, columns)));
138
+ }
139
+
140
+ /** Take the caret: the first member's tick. Reached by a label click and by the
141
+ * editor's landing verbs, which ask one function so they cannot disagree. */
142
+ export function focus(): void {
143
+ const first = members[0];
144
+ if (first) ref(tickEls, first.id)?.focus();
145
+ }
146
+ /** The box an arrival wash blooms in: the roster, not the label row above it, this
147
+ * component owning the field's label as an array does. */
148
+ export function washBox(): HTMLElement | undefined {
149
+ return rosterEl;
150
+ }
151
+ /**
152
+ * Land at `steps`: a member, or a column under it ({@link FieldControl.focusPath}).
153
+ * A member address and its `held` cell focus the tick; a column address focuses
154
+ * that column where the member is held and its columns are drawn, and the tick
155
+ * where they are not — the address names the column, and what is drawn for it is
156
+ * the tick that would open it. One rule keyed on the address, never on the
157
+ * document, so one address lands in one place. A member off the roster falls back
158
+ * to {@link focus}. The innermost row the walk opened comes back, else the member's
159
+ * box: the member is the row the address named.
160
+ */
161
+ export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
162
+ const [id, ...rest] = steps;
163
+ if (typeof id !== 'string' || !matrixDeclares(schema, id)) {
164
+ focus();
165
+ return undefined;
166
+ }
167
+ const column = rest[0];
168
+ const cells = ref(colEls, id);
169
+ const member = ref(memberEls, id);
170
+ if (column !== undefined && column !== MATRIX_HELD && cells) {
171
+ const inner = cells.focusPath(rest, pos);
172
+ return inner instanceof Promise ? inner.then((box) => box ?? member) : (inner ?? member);
173
+ }
174
+ ref(tickEls, id)?.focus();
175
+ return member;
176
+ }
177
+ </script>
178
+
179
+ <div
180
+ class="qm-matrix"
181
+ role="group"
182
+ aria-labelledby={label != null ? labelId : undefined}
183
+ aria-describedby={description ? descriptionId : undefined}
184
+ >
185
+ <div class="qm-matrix-header">
186
+ {#if label != null}
187
+ <FieldLabel {label} id={labelId} {descriptionId} onActivate={focus} {description} />
188
+ {:else}
189
+ <span></span>
190
+ {/if}
191
+ <span class="qm-matrix-count">{t.strings.matrixHeld(heldCount, members.length)}</span>
192
+ </div>
193
+ <div class="qm-matrix-roster" class:qm-tracks={compact} class:compact bind:this={rosterEl}>
194
+ {#each members as m (m.id)}
195
+ {@const on = held(m.id)}
196
+ {@const deep = routed.below.get(m.id)}
197
+ <div class="qm-member" class:held={on} bind:this={memberEls[m.id]}>
198
+ <div class="qm-member-head">
199
+ <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
200
+ dial reaches, so the input is `appearance: none` and the box and the
201
+ mark beside it read the rungs. The mark is the surface's one check
202
+ glyph, shown by the input's own state. -->
203
+ <span class="qm-tick">
204
+ <input
205
+ type="checkbox"
206
+ class="qm-tick-input qm-focus-ring"
207
+ id={tickId(m.id)}
208
+ checked={on}
209
+ bind:this={tickEls[m.id]}
210
+ onchange={(e) => tick(m.id, e.currentTarget.checked, e.currentTarget)}
211
+ />
212
+ <Icon name="check" class="qm-tick-mark" />
213
+ </span>
214
+ <label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
215
+ </div>
216
+ {#if on && hasColumns}
217
+ <!-- The columns, one rung in, the way a variant's cells unfold under the
218
+ discriminant. Keyed by member id above, so a tick elsewhere leaves this
219
+ subform mounted and the caret in it. -->
220
+ <ObjectField
221
+ bind:this={colEls[m.id]}
222
+ value={matrixColumns(memberValue(value, m.id))}
223
+ properties={schema.properties}
224
+ label={`${label ?? ''} ${m.title}`.trim()}
225
+ idBase={memberBase(m.id)}
226
+ labelledBy={titleId(m.id)}
227
+ contentAt={(path) => contentAt([m.id, ...path])}
228
+ onCommit={(columns) => commitColumns(m.id, columns)}
229
+ diagnostics={deep}
230
+ />
231
+ {:else}
232
+ <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
233
+ {/if}
234
+ </div>
235
+ {/each}
236
+ </div>
237
+ <DiagnosticList diagnostics={foot} />
238
+ </div>
239
+
240
+ <style>
241
+ .qm-matrix {
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: var(--_qm-space);
245
+ }
246
+ /* The label row, the array's own shape: this component owns the label track
247
+ (`Field.svelte`), and the count stands where the add chip would. */
248
+ .qm-matrix-header {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: space-between;
252
+ gap: var(--_qm-space-2);
253
+ }
254
+ /* The count reads at the label rung: a fact about the field, never a value. */
255
+ .qm-matrix-count {
256
+ font-size: var(--_qm-text-label);
257
+ color: var(--_qm-ink-label);
258
+ font-variant-numeric: tabular-nums;
259
+ }
260
+ /* The roster, one member to a row in declaration order. Positioned for the wash's
261
+ inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
262
+ rows are. */
263
+ .qm-matrix-roster {
264
+ position: relative;
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: var(--_qm-space);
268
+ min-width: 0;
269
+ border-radius: var(--_qm-radius-inner);
270
+ }
271
+ /* Compact, the members stand abreast in reading order at the count the ladder gives
272
+ the width (`.qm-tracks`, controls.css), a held member's columns unfolding inside its
273
+ own track. */
274
+ .qm-matrix-roster.compact {
275
+ display: grid;
276
+ grid-template-columns: repeat(var(--cols), 1fr);
277
+ align-items: start;
278
+ column-gap: var(--_qm-space-2);
279
+ }
280
+ /* A member is a row: positioned for the wash a landing on it blooms, rounded to the
281
+ rung the rows around it draw. */
282
+ .qm-member {
283
+ position: relative;
284
+ display: flex;
285
+ flex-direction: column;
286
+ gap: var(--_qm-space);
287
+ border-radius: var(--_qm-radius-inner);
288
+ }
289
+ /* The tick and its title on one line, the line holding the tap floor a tick alone
290
+ would not reach: the title is the target, the `for` carrying its press to the input. */
291
+ .qm-member-head {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: var(--_qm-space-2);
295
+ min-height: var(--_qm-tap-min);
296
+ }
297
+ .qm-tick {
298
+ position: relative;
299
+ display: inline-flex;
300
+ flex-shrink: 0;
301
+ width: var(--_qm-glyph-control);
302
+ height: var(--_qm-glyph-control);
303
+ }
304
+ /* The box: a well, edged, since a mark this small has no plane under it to step off
305
+ by tone alone; held, it takes the accent the switch's track takes when checked. */
306
+ .qm-tick-input {
307
+ appearance: none;
308
+ margin: 0;
309
+ width: 100%;
310
+ height: 100%;
311
+ border: var(--_qm-border-width) solid var(--_qm-border);
312
+ border-radius: calc(var(--_qm-radius-inner) / 2);
313
+ background: var(--_qm-surface-well);
314
+ cursor: pointer;
315
+ transition:
316
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
317
+ border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
318
+ }
319
+ .qm-tick-input:checked {
320
+ background: var(--_qm-accent);
321
+ border-color: var(--_qm-accent);
322
+ }
323
+ /* The mark rides over the input and takes no press of its own; it is drawn by the
324
+ input's state, in the ink the surface reads over the accent. */
325
+ .qm-tick :global(.qm-tick-mark) {
326
+ position: absolute;
327
+ inset: 0;
328
+ width: 100%;
329
+ height: 100%;
330
+ pointer-events: none;
331
+ color: var(--_qm-surface);
332
+ opacity: 0;
333
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
334
+ }
335
+ .qm-tick:has(:checked) :global(.qm-tick-mark) {
336
+ opacity: 1;
337
+ }
338
+ /* Unheld reads at the label ink, held at the value ink: the title is what the
339
+ document says once it is ticked, and until then it is the vocabulary. */
340
+ .qm-member-title {
341
+ font-size: var(--_qm-text-body);
342
+ line-height: var(--_qm-leading-tight);
343
+ color: var(--_qm-ink-label);
344
+ cursor: pointer;
345
+ min-width: 0;
346
+ transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
347
+ }
348
+ .qm-member.held .qm-member-title,
349
+ .qm-member-title:hover {
350
+ color: var(--_qm-ink);
351
+ }
352
+ </style>
@@ -0,0 +1,36 @@
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
+ import { type DeepDiagnostic } from './diagnostics.js';
3
+ import type { LandingBox } from './leaves.js';
4
+ import './controls.css';
5
+ interface Props {
6
+ /** The stored sparse map, or undefined while the field is unset. */
7
+ value: Record<string, unknown> | undefined;
8
+ /** The field's own schema: `members` is the roster, `properties` the columns. */
9
+ schema: QuillFieldSchema;
10
+ /** Field label, the naming prefix a member's columns compose with. */
11
+ label?: string;
12
+ /** Schema `description`: the label's help affordance. */
13
+ description?: string;
14
+ /** The label's own DOM id: the group's name, each tick keeping its own `<label>`. */
15
+ labelId?: string;
16
+ /** Where the description parks, for the group's `aria-describedby`. */
17
+ descriptionId?: string;
18
+ /** The field's control id: the base a member's tick and columns derive their own
19
+ * names from, one `-m-<member id>` segment down. */
20
+ idBase?: string;
21
+ /** The boundary's nested content read, rooted at this field: a member's content
22
+ * column reads at `[id, column]` (`reader.getContentAt`). */
23
+ contentAt: (path: PathStep[]) => Content | undefined;
24
+ onCommit: (v: Record<string, unknown> | undefined) => void;
25
+ /** The diagnostics routed into this field, each with its steps still to walk: one
26
+ * naming a member draws under that member, one naming nothing on the roster at the
27
+ * field's foot. */
28
+ diagnostics?: readonly DeepDiagnostic[];
29
+ }
30
+ declare const MatrixField: import("svelte").Component<Props, {
31
+ focus: () => void;
32
+ washBox: () => HTMLElement | undefined;
33
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
34
+ }, "">;
35
+ type MatrixField = ReturnType<typeof MatrixField>;
36
+ export default MatrixField;