@quillmark/svelte 0.15.0 → 0.17.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.
@@ -1,28 +1,27 @@
1
1
  <!--
2
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.
3
+ (VISUAL_EDITOR §"The matrix", canon `SCHEMAS.md` §Matrix). The roster is one flat
4
+ list, one member to a row, or abreast on the section's track ladder under
5
+ `ui.compact`; each member is a real checkbox whose checked state is the member's
6
+ presence, with its title as the `<label for>`, an unheld member reading at the label
7
+ ink and a held one at the value ink. A held member unfolds its columns beneath its
8
+ title, drawn by {@link ObjectField} over the matrix's `properties` at the member's key;
9
+ zero columns is a checklist and unfolds nothing.
12
10
 
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.
11
+ An open matrix's added items follow the roster in id order, as the plate prints them:
12
+ held by being present, so each draws a fixed tick, its `title` as an input, a remove,
13
+ and the same columns. The add box closes the list ({@link MatrixAdd}): it searches
14
+ every checklist of the document before it offers to add what was typed.
16
15
 
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.
16
+ The control owns its label row, as an array does: the count of held items sits
17
+ where an array's add chip sits, so {@link Field} skips its own label for it.
18
+
19
+ Commit is whole: the sparse map, one `writer.set`. A tick writes the member, its
20
+ columns or the bare `true`; an untick removes it. Its columns are kept here, for this
21
+ mount, so a retick restores them: the document keeps no answer for a member it does not
22
+ hold. Every other member rides through as the document spelled it. An unset matrix
23
+ whose `default:` holds members draws that set at the default rung, and the first
24
+ gesture writes it whole with the change in it.
26
25
  -->
27
26
  <script lang="ts">
28
27
  import { tick as flush } from 'svelte';
@@ -33,28 +32,47 @@
33
32
  const t = wording();
34
33
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
35
34
  import {
36
- MATRIX_HELD,
35
+ MATRIX_TITLE,
36
+ addedId,
37
+ addedItems,
38
+ baseType,
37
39
  commitMember,
40
+ declaredContent,
38
41
  matrixColumns,
39
42
  matrixDeclares,
43
+ matrixEmpty,
40
44
  matrixHeld,
41
45
  matrixMembers,
42
46
  memberValue,
43
- memberWrite
47
+ memberWrite,
48
+ onRoster,
49
+ schemaAt
44
50
  } from './structure.js';
51
+ import {
52
+ checklists,
53
+ matrixPrinted,
54
+ searchChecklists,
55
+ type AddOption,
56
+ type Checklist,
57
+ type ChecklistHit,
58
+ type Searchable
59
+ } from './checklists.js';
45
60
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
46
61
  import type { LandingBox } from './leaves.js';
47
62
  import { propertyDomIds } from './domid.js';
63
+ import { bloomInside } from '../core/bloom.js';
48
64
  import Icon from './icons/Icon.svelte';
49
65
  import FieldLabel from './FieldLabel.svelte';
50
66
  import ObjectField from './ObjectField.svelte';
67
+ import MatrixAdd from './MatrixAdd.svelte';
51
68
  import DiagnosticList from './DiagnosticList.svelte';
52
69
  import './controls.css';
53
70
 
54
71
  interface Props {
55
72
  /** The stored sparse map, or undefined while the field is unset. */
56
73
  value: Record<string, unknown> | undefined;
57
- /** The field's own schema: `members` is the roster, `properties` the columns. */
74
+ /** The field's own schema: `members` is the roster, `properties` the columns,
75
+ * `open` whether a document may add items. */
58
76
  schema: QuillFieldSchema;
59
77
  /** Field label, the naming prefix a member's columns compose with. */
60
78
  label?: string;
@@ -65,15 +83,17 @@
65
83
  /** Where the description parks, for the group's `aria-describedby`. */
66
84
  descriptionId?: string;
67
85
  /** 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. */
86
+ * names from, one `-m-<member id>` segment down. */
69
87
  idBase?: string;
88
+ /** The field's leaf key, where it is a card's own field: how the add box tells
89
+ * this list from the rest of the document's (`checklists.ts`). */
90
+ listKey?: string;
70
91
  /** The boundary's nested content read, rooted at this field: a member's content
71
- * column reads at `[id, column]` (`reader.getContentAt`). */
92
+ * column reads at `[id, column]` (`reader.getContentAt`). */
72
93
  contentAt: (path: PathStep[]) => Content | undefined;
73
94
  onCommit: (v: Record<string, unknown> | undefined) => void;
74
95
  /** 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. */
96
+ * naming a drawn item draws under it, any other at the field's foot. */
77
97
  diagnostics?: readonly DeepDiagnostic[];
78
98
  }
79
99
  let {
@@ -84,33 +104,51 @@
84
104
  labelId,
85
105
  descriptionId,
86
106
  idBase,
107
+ listKey,
87
108
  contentAt,
88
109
  onCommit,
89
110
  diagnostics
90
111
  }: Props = $props();
91
112
 
113
+ // A card's own matrix searches the document's lists; one nested in a container is
114
+ // not indexed, and searches its own items alone.
115
+ const index = checklists();
116
+ const others = (): readonly Checklist[] => (listKey != null ? (index?.list() ?? []) : []);
92
117
  const members = $derived(matrixMembers(schema.members));
93
118
  const compact = $derived(!!schema.ui?.compact);
94
119
  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);
120
+ /** The held set as it prints: the stored map, else the declared `default:`. */
121
+ const map = $derived(matrixPrinted(value, schema));
122
+ const defaulted = $derived(value == null && map != null && Object.keys(map).length > 0);
123
+ const added = $derived(addedItems(schema, map));
124
+ const held = (id: string): boolean => matrixHeld(memberValue(map, id));
125
+ const heldCount = $derived(members.filter((m) => held(m.id)).length + added.length);
126
+ /** Whether `id` is an item this control draws: a roster member or a held added item. */
127
+ const drawn = (id: string): boolean =>
128
+ onRoster(schema, id) || added.some((item) => item.id === id);
97
129
  const routed = $derived(splitDeep(diagnostics));
98
- const foot = $derived(
99
- unrouted(routed, (step) => typeof step === 'string' && matrixDeclares(schema, step))
100
- );
130
+ const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && drawn(step)));
101
131
 
102
132
  // A member with no field id space around it still needs an id: the tick is what its
103
133
  // title's `for` names. Stable across SSR and hydration, which a counter is not.
104
134
  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;
135
+ const base = $derived(idBase ?? `qm-${uid}`);
136
+ const memberBase = (id: string): string => `${base}-m-${id}`;
137
+ /** A member's own address is its tick, so the tick takes the id its label names and
138
+ * the column subform under it derives its own names from the same base. */
139
+ const tickId = (id: string): string => `${memberBase(id)}-tick`;
140
+ const titleId = (id: string): string => `${memberBase(id)}-title`;
141
+ /** An added item's title input: the `title` cell's own control id. */
142
+ const itemTitleId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_TITLE).control;
143
+
144
+ /** The columns an untick took, by member id, for a retick in this mount to restore. */
145
+ const stash = new Map<string, Record<string, unknown>>();
146
+ /** Commit the map; one holding nothing is the field's empty answer (`matrixEmpty`). */
147
+ const commit = (next: Record<string, unknown> | undefined): void =>
148
+ onCommit(next ?? matrixEmpty(schema));
111
149
 
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
150
+ // The item boxes, the ticks, the title inputs and the column subforms, keyed by
151
+ // member id — stable for the item's life. `$state` for the binding's sake
114
152
  // (`ArrayField` keeps its element refs the same way).
115
153
  type Subform = {
116
154
  focus: () => void;
@@ -118,31 +156,132 @@
118
156
  };
119
157
  const memberEls: Record<string, HTMLElement | undefined> = $state({});
120
158
  const tickEls: Record<string, HTMLInputElement | undefined> = $state({});
159
+ const titleEls: Record<string, HTMLInputElement | undefined> = $state({});
121
160
  const colEls: Record<string, Subform | undefined> = $state({});
122
161
  let rosterEl = $state<HTMLElement | undefined>();
162
+ let addEl = $state<{ focus: () => void } | undefined>();
123
163
  /** 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;
164
+ const ref = <T,>(refs: Record<string, T | undefined>, id: string): T | undefined =>
165
+ Object.hasOwn(refs, id) ? refs[id] : undefined;
166
+
167
+ /** The rows' content read: the boundary's, or, while the set is the default, the
168
+ * literal's own leaf at the codec its declared type names. */
169
+ function readAt(path: PathStep[]): Content | undefined {
170
+ if (!defaulted) return contentAt(path);
171
+ const leaf = schemaAt(schema, path);
172
+ let v: unknown = map;
173
+ for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
174
+ return leaf ? declaredContent(v, baseType(leaf) === 'richtext') : undefined;
175
+ }
176
+
177
+ /** The held member `id` as a tick writes it: the columns its untick took, if any. */
178
+ function ticked(id: string): unknown {
179
+ const columns = stash.get(id) ?? {};
180
+ stash.delete(id);
181
+ return memberWrite(columns);
182
+ }
183
+ /** The map with `id` unticked, its columns stashed for a retick. */
184
+ function unticked(id: string): Record<string, unknown> | undefined {
185
+ const columns = matrixColumns(memberValue(map, id));
186
+ if (Object.keys(columns).length) stash.set(id, columns);
187
+ else stash.delete(id);
188
+ return commitMember(map, id, undefined);
189
+ }
126
190
 
127
191
  /** A native checkbox carries its own state, so a commit the document declines leaves
128
192
  * the face ticked over a map that says otherwise, `checked={held(id)}` having nothing
129
193
  * new to write. The reassert after the flush is what the styled controls get from
130
194
  * their synced local (`synced.svelte.ts`): the document is what the face reads. */
131
195
  async function tick(id: string, on: boolean, el: HTMLInputElement): Promise<void> {
132
- onCommit(commitMember(value, id, memberWrite(on, matrixColumns(memberValue(value, id)))));
196
+ commit(on ? commitMember(map, id, ticked(id)) : unticked(id));
133
197
  await flush();
134
198
  if (el.isConnected) el.checked = held(id);
135
199
  }
136
- /** A column edit lands on a held member: the subform draws under a tick alone. */
200
+ /** A column edit lands on a held item, an added one keeping its `title`. */
137
201
  function commitColumns(id: string, columns: Record<string, unknown>): void {
138
- onCommit(commitMember(value, id, memberWrite(true, columns)));
202
+ const title = onRoster(schema, id) ? undefined : itemTitle(id);
203
+ commit(commitMember(map, id, memberWrite(columns, title)));
204
+ }
205
+ function itemTitle(id: string): string {
206
+ const stored = memberValue(map, id) as Record<string, unknown> | undefined;
207
+ return String(stored?.[MATRIX_TITLE] ?? '');
208
+ }
209
+ function retitle(id: string, title: string): void {
210
+ commit(commitMember(map, id, memberWrite(matrixColumns(memberValue(map, id)), title)));
211
+ }
212
+ async function removeItem(id: string): Promise<void> {
213
+ const at = added.findIndex((item) => item.id === id);
214
+ commit(commitMember(map, id, undefined));
215
+ await flush();
216
+ // The caret goes where the row was: the next added item, else the add box.
217
+ const next = added[at] ?? added[at - 1];
218
+ if (next) ref(titleEls, next.id)?.focus();
219
+ else addEl?.focus();
139
220
  }
140
221
 
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. */
222
+ // ── The add box ────────────────────────────────────────────────────────────
223
+ /** A result's list: this one, or another of the document's. */
224
+ type Hit = ChecklistHit<Checklist | undefined>;
225
+ function options(query: string): AddOption[] {
226
+ const own: Searchable<Checklist | undefined> = { list: undefined, schema, value: map };
227
+ const lists: Searchable<Checklist | undefined>[] = others()
228
+ .filter((l) => l.key !== listKey)
229
+ .map((l) => ({ list: l, schema: l.schema, value: l.value }));
230
+ const hits = searchChecklists(query, [own, ...lists]);
231
+ const out: AddOption[] = hits.map((h, k) => ({
232
+ key: `hit-${k}`,
233
+ title: h.title,
234
+ note:
235
+ [
236
+ h.list ? t.strings.matrixIn(h.list.label) : undefined,
237
+ h.held ? t.strings.matrixHeldTag : undefined
238
+ ]
239
+ .filter(Boolean)
240
+ .join(' · ') || undefined,
241
+ strong: h.strong,
242
+ payload: h
243
+ }));
244
+ // Offered last, and not where an item of this list already carries the words.
245
+ const fold = (s: string) => s.trim().toLowerCase();
246
+ const dup = hits.some((h) => h.list === undefined && fold(h.title) === fold(query));
247
+ if (!dup) out.push({ key: 'add', title: t.strings.matrixAddNew(query), add: true });
248
+ return out;
249
+ }
250
+ async function choose(option: AddOption, query: string): Promise<void> {
251
+ if (option.add) return add(query);
252
+ const hit = option.payload as Hit;
253
+ const other = hit.list;
254
+ if (other) {
255
+ if (!hit.held) other.commit(commitMember(other.value, hit.id, memberWrite({})));
256
+ index?.land(`${other.path}.${hit.id}`);
257
+ return;
258
+ }
259
+ if (!hit.held) commit(commitMember(map, hit.id, ticked(hit.id)));
260
+ await flush();
261
+ focusPath([hit.id]);
262
+ const box = ref(memberEls, hit.id);
263
+ if (box) bloomInside(box);
264
+ }
265
+ async function add(title: string): Promise<void> {
266
+ const taken = (id: string) => onRoster(schema, id) || (map != null && Object.hasOwn(map, id));
267
+ const id = addedId(title, taken);
268
+ commit(commitMember(map, id, memberWrite({}, title)));
269
+ await flush();
270
+ const box = ref(memberEls, id);
271
+ // The item's columns are what the author came to fill; a checklist has none, and
272
+ // the box stays where the next item is typed.
273
+ const first = Object.keys(schema.properties ?? {})[0];
274
+ if (first !== undefined) ref(colEls, id)?.focusPath([first]);
275
+ if (box) bloomInside(box);
276
+ }
277
+
278
+ /** Take the caret: the first item's control, else the add box. */
143
279
  export function focus(): void {
144
280
  const first = members[0];
145
- if (first) ref(tickEls, first.id)?.focus();
281
+ if (first) return ref(tickEls, first.id)?.focus();
282
+ const item = added[0];
283
+ if (item) return ref(titleEls, item.id)?.focus();
284
+ addEl?.focus();
146
285
  }
147
286
  /** The box an arrival wash blooms in: the roster, not the label row above it, this
148
287
  * component owning the field's label as an array does. */
@@ -150,29 +289,29 @@
150
289
  return rosterEl;
151
290
  }
152
291
  /**
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.
292
+ * Land at `steps`: an item, or a cell under it ({@link FieldControl.focusPath}). A
293
+ * member's own address is its tick, and an added item's its title, as is its `title`
294
+ * cell; a column address focuses that column where the item is held and its columns
295
+ * are drawn, and the item's own control where they are not — the address names the
296
+ * column, and what is drawn for it is the tick that would open it. One rule keyed on
297
+ * the address, never on the document, so one address lands in one place. An item
298
+ * this control does not draw falls back to {@link focus}. The innermost row the walk
299
+ * opened comes back, else the item's box: the item is the row the address named.
161
300
  */
162
301
  export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
163
302
  const [id, ...rest] = steps;
164
- if (typeof id !== 'string' || !matrixDeclares(schema, id)) {
303
+ if (typeof id !== 'string' || !matrixDeclares(schema, id) || !drawn(id)) {
165
304
  focus();
166
305
  return undefined;
167
306
  }
168
307
  const column = rest[0];
169
308
  const cells = ref(colEls, id);
170
309
  const member = ref(memberEls, id);
171
- if (column !== undefined && column !== MATRIX_HELD && cells) {
310
+ if (column !== undefined && column !== MATRIX_TITLE && cells) {
172
311
  const inner = cells.focusPath(rest, pos);
173
312
  return inner instanceof Promise ? inner.then((box) => box ?? member) : (inner ?? member);
174
313
  }
175
- ref(tickEls, id)?.focus();
314
+ (ref(tickEls, id) ?? ref(titleEls, id))?.focus();
176
315
  return member;
177
316
  }
178
317
  </script>
@@ -189,12 +328,19 @@
189
328
  {:else}
190
329
  <span></span>
191
330
  {/if}
192
- <span class="qm-matrix-count">{t.strings.matrixHeld(heldCount, members.length)}</span>
331
+ <span class="qm-matrix-count">
332
+ {t.strings.matrixHeld(heldCount, members.length + added.length)}
333
+ </span>
193
334
  </div>
194
- <div class="qm-matrix-roster" class:qm-tracks={compact} class:compact bind:this={rosterEl}>
335
+ <div
336
+ class="qm-matrix-roster"
337
+ class:qm-tracks={compact}
338
+ class:compact
339
+ data-default={defaulted ? '' : undefined}
340
+ bind:this={rosterEl}
341
+ >
195
342
  {#each members as m (m.id)}
196
343
  {@const on = held(m.id)}
197
- {@const deep = routed.below.get(m.id)}
198
344
  <div class="qm-member" class:held={on} bind:this={memberEls[m.id]}>
199
345
  <div class="qm-member-head">
200
346
  <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
@@ -214,30 +360,77 @@
214
360
  </span>
215
361
  <label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
216
362
  </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}
363
+ {@render columns(m.id, m.title, on)}
364
+ </div>
365
+ {/each}
366
+ {#each added as item, k (item.id)}
367
+ {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
368
+ <div class="qm-member held added" bind:this={memberEls[item.id]}>
369
+ <div class="qm-member-head">
370
+ <!-- Held by being present: the tick is fixed, and the remove is its untick. -->
371
+ <span class="qm-tick" aria-hidden="true">
372
+ <span class="qm-tick-input qm-tick-fixed"></span>
373
+ <Icon name="check" class="qm-tick-mark" />
374
+ </span>
375
+ <input
376
+ class="qm-input qm-focus-ring qm-member-title-input"
377
+ type="text"
378
+ id={itemTitleId(item.id)}
379
+ aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
380
+ value={item.title}
381
+ bind:this={titleEls[item.id]}
382
+ oninput={(e) => retitle(item.id, e.currentTarget.value)}
231
383
  />
232
- {:else}
233
- <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
234
- {/if}
384
+ <span class="qm-member-title" id={titleId(item.id)} hidden>{name}</span>
385
+ <button
386
+ type="button"
387
+ class="qm-icon-btn qm-focus-ring qm-member-remove"
388
+ title={t.strings.matrixRemove(name)}
389
+ aria-label={t.strings.matrixRemove(name)}
390
+ onclick={() => removeItem(item.id)}><Icon name="x" /></button
391
+ >
392
+ </div>
393
+ {@render columns(item.id, name, true)}
235
394
  </div>
236
395
  {/each}
237
396
  </div>
397
+ {#if schema.open}
398
+ <MatrixAdd
399
+ bind:this={addEl}
400
+ id={`${base}-add`}
401
+ placeholder={t.strings.matrixAdd(label ?? '')}
402
+ {options}
403
+ onChoose={choose}
404
+ />
405
+ {/if}
238
406
  <DiagnosticList diagnostics={foot} />
239
407
  </div>
240
408
 
409
+ <!-- A held item's columns, one rung in, the way a variant's cells unfold under the
410
+ discriminant; an unheld one's diagnostics under its head. Keyed by item id above, so
411
+ a tick elsewhere leaves this subform mounted and the caret in it. -->
412
+ {#snippet columns(id: string, title: string, on: boolean)}
413
+ {@const deep = routed.below.get(id)}
414
+ {#if on && hasColumns}
415
+ <ObjectField
416
+ bind:this={colEls[id]}
417
+ value={matrixColumns(memberValue(map, id))}
418
+ properties={schema.properties}
419
+ label={`${label ?? ''} ${title}`.trim()}
420
+ idBase={memberBase(id)}
421
+ labelledBy={titleId(id)}
422
+ contentAt={(path) => readAt([id, ...path])}
423
+ onCommit={(cols) => commitColumns(id, cols)}
424
+ diagnostics={deep?.filter((d) => d.steps[0] !== MATRIX_TITLE)}
425
+ />
426
+ <DiagnosticList
427
+ diagnostics={deep?.filter((d) => d.steps[0] === MATRIX_TITLE).map((d) => d.diagnostic)}
428
+ />
429
+ {:else}
430
+ <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
431
+ {/if}
432
+ {/snippet}
433
+
241
434
  <style>
242
435
  .qm-matrix {
243
436
  display: flex;
@@ -317,10 +510,15 @@
317
510
  background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
318
511
  border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
319
512
  }
320
- .qm-tick-input:checked {
513
+ .qm-tick-input:checked,
514
+ .qm-tick-fixed {
321
515
  background: var(--_qm-accent);
322
516
  border-color: var(--_qm-accent);
323
517
  }
518
+ .qm-tick-fixed {
519
+ box-sizing: border-box;
520
+ cursor: default;
521
+ }
324
522
  /* The mark rides over the input and takes no press of its own; it is drawn by the
325
523
  input's state, in the ink the surface reads over the accent. */
326
524
  .qm-tick :global(.qm-tick-mark) {
@@ -333,7 +531,7 @@
333
531
  opacity: 0;
334
532
  transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
335
533
  }
336
- .qm-tick:has(:checked) :global(.qm-tick-mark) {
534
+ .qm-tick:has(:checked, .qm-tick-fixed) :global(.qm-tick-mark) {
337
535
  opacity: 1;
338
536
  }
339
537
  /* Unheld reads at the label ink, held at the value ink: the title is what the
@@ -350,4 +548,53 @@
350
548
  .qm-member-title:hover {
351
549
  color: var(--_qm-ink);
352
550
  }
551
+ /* An added item's title is its value, an input drawn as the roster's titles are: its
552
+ text starts where theirs does, one gap past the tick, and its well comes up under
553
+ the pointer and the focus, flush with the tick, so at rest the row reads as the rows
554
+ above it. */
555
+ .qm-member-title-input {
556
+ flex: 1;
557
+ min-width: 0;
558
+ margin-inline-start: calc(-1 * var(--_qm-space-2));
559
+ padding-inline-start: var(--_qm-space-2);
560
+ background: transparent;
561
+ transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
562
+ }
563
+ .qm-member-title-input:hover {
564
+ background: var(--_qm-surface-hover);
565
+ }
566
+ .qm-member-title-input:focus {
567
+ background: var(--_qm-surface-well);
568
+ }
569
+ /* The remove is offered by the row the pointer or the focus is in, as an array row's
570
+ is, and says destructive under the pointer. */
571
+ .qm-member-remove {
572
+ flex-shrink: 0;
573
+ color: var(--_qm-ink-label);
574
+ opacity: var(--_qm-opacity-idle);
575
+ transition:
576
+ opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
577
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
578
+ color var(--_qm-duration-fast) var(--_qm-ease-reverse);
579
+ }
580
+ .qm-member.added:hover > .qm-member-head > .qm-member-remove,
581
+ .qm-member.added:focus-within > .qm-member-head > .qm-member-remove {
582
+ opacity: 1;
583
+ }
584
+ .qm-member-remove:hover {
585
+ background: var(--_qm-danger-tint);
586
+ color: var(--_qm-danger);
587
+ }
588
+ .qm-member-remove :global(svg) {
589
+ width: var(--_qm-glyph-control);
590
+ height: var(--_qm-glyph-control);
591
+ }
592
+ /* The default's set is what prints and nothing written: its titles and every value
593
+ its columns draw take the default rung (theme.css), and the first gesture, writing
594
+ it, takes it off. */
595
+ .qm-matrix-roster[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
596
+ .qm-matrix-roster[data-default] :global(.qm-select:not([data-ghosted])),
597
+ .qm-matrix-roster[data-default] .qm-member.held .qm-member-title {
598
+ color: var(--_qm-ink-default);
599
+ }
353
600
  </style>
@@ -5,7 +5,8 @@ import './controls.css';
5
5
  interface Props {
6
6
  /** The stored sparse map, or undefined while the field is unset. */
7
7
  value: Record<string, unknown> | undefined;
8
- /** The field's own schema: `members` is the roster, `properties` the columns. */
8
+ /** The field's own schema: `members` is the roster, `properties` the columns,
9
+ * `open` whether a document may add items. */
9
10
  schema: QuillFieldSchema;
10
11
  /** Field label, the naming prefix a member's columns compose with. */
11
12
  label?: string;
@@ -16,15 +17,17 @@ interface Props {
16
17
  /** Where the description parks, for the group's `aria-describedby`. */
17
18
  descriptionId?: string;
18
19
  /** 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
+ * names from, one `-m-<member id>` segment down. */
20
21
  idBase?: string;
22
+ /** The field's leaf key, where it is a card's own field: how the add box tells
23
+ * this list from the rest of the document's (`checklists.ts`). */
24
+ listKey?: string;
21
25
  /** The boundary's nested content read, rooted at this field: a member's content
22
- * column reads at `[id, column]` (`reader.getContentAt`). */
26
+ * column reads at `[id, column]` (`reader.getContentAt`). */
23
27
  contentAt: (path: PathStep[]) => Content | undefined;
24
28
  onCommit: (v: Record<string, unknown> | undefined) => void;
25
29
  /** 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. */
30
+ * naming a drawn item draws under it, any other at the field's foot. */
28
31
  diagnostics?: readonly DeepDiagnostic[];
29
32
  }
30
33
  declare const MatrixField: import("svelte").Component<Props, {