@quillmark/svelte 0.16.0 → 0.18.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 (54) hide show
  1. package/README.md +4 -2
  2. package/dist/core/codec/index.d.ts +0 -3
  3. package/dist/core/rebind.svelte.js +5 -8
  4. package/dist/core/theme.css +12 -1
  5. package/dist/preset/index.d.ts +1 -0
  6. package/dist/preview/Preview.svelte +14 -19
  7. package/dist/preview/Preview.svelte.d.ts +3 -3
  8. package/dist/preview/index.d.ts +0 -1
  9. package/dist/visual/ArrayField.svelte +44 -41
  10. package/dist/visual/ArrayField.svelte.d.ts +0 -1
  11. package/dist/visual/BooleanField.svelte +16 -14
  12. package/dist/visual/BooleanField.svelte.d.ts +0 -1
  13. package/dist/visual/Card.svelte +65 -35
  14. package/dist/visual/CardControls.svelte.d.ts +0 -1
  15. package/dist/visual/DateField.svelte +14 -17
  16. package/dist/visual/DateField.svelte.d.ts +0 -1
  17. package/dist/visual/EnumField.svelte +9 -12
  18. package/dist/visual/EnumField.svelte.d.ts +0 -1
  19. package/dist/visual/Field.svelte +17 -10
  20. package/dist/visual/Field.svelte.d.ts +3 -0
  21. package/dist/visual/FieldHint.svelte.d.ts +0 -1
  22. package/dist/visual/FieldLabel.svelte +1 -2
  23. package/dist/visual/FormatPopover.svelte +22 -27
  24. package/dist/visual/FormatPopover.svelte.d.ts +0 -1
  25. package/dist/visual/MatrixAdd.svelte +210 -0
  26. package/dist/visual/MatrixAdd.svelte.d.ts +15 -0
  27. package/dist/visual/MatrixField.svelte +329 -84
  28. package/dist/visual/MatrixField.svelte.d.ts +8 -6
  29. package/dist/visual/NumberField.svelte +6 -10
  30. package/dist/visual/NumberField.svelte.d.ts +0 -1
  31. package/dist/visual/ObjectField.svelte +30 -17
  32. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  33. package/dist/visual/ProseField.svelte +42 -41
  34. package/dist/visual/ProseField.svelte.d.ts +0 -1
  35. package/dist/visual/ProseValue.svelte +56 -56
  36. package/dist/visual/ProseValue.svelte.d.ts +0 -1
  37. package/dist/visual/SlashMenu.svelte +5 -15
  38. package/dist/visual/SlashMenu.svelte.d.ts +0 -1
  39. package/dist/visual/TextField.svelte +5 -9
  40. package/dist/visual/TextField.svelte.d.ts +0 -1
  41. package/dist/visual/TipsCard.svelte +10 -11
  42. package/dist/visual/TipsCard.svelte.d.ts +0 -1
  43. package/dist/visual/VisualEditorInner.svelte +21 -14
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
  45. package/dist/visual/checklists.d.ts +82 -0
  46. package/dist/visual/checklists.js +171 -0
  47. package/dist/visual/index.d.ts +0 -1
  48. package/dist/visual/strings.d.ts +15 -1
  49. package/dist/visual/strings.js +6 -0
  50. package/dist/visual/structure.d.ts +63 -31
  51. package/dist/visual/structure.js +122 -44
  52. package/package.json +20 -16
  53. package/dist/visual/synced.svelte.d.ts +0 -15
  54. package/dist/visual/synced.svelte.js +0 -34
@@ -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
- * their synced local (`synced.svelte.ts`): the document is what the face reads. */
194
+ * their local, a `$derived` of the value: 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,13 +328,18 @@
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', { 'qm-tracks': compact, compact }]}
337
+ data-default={defaulted ? '' : undefined}
338
+ bind:this={rosterEl}
339
+ >
195
340
  {#each members as m (m.id)}
196
341
  {@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]}>
342
+ <div class={['qm-member', { held: on }]} bind:this={memberEls[m.id]}>
199
343
  <div class="qm-member-head">
200
344
  <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
201
345
  dial reaches, so the input is `appearance: none` and the box and the
@@ -214,30 +358,77 @@
214
358
  </span>
215
359
  <label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
216
360
  </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}
361
+ {@render columns(m.id, m.title, on)}
362
+ </div>
363
+ {/each}
364
+ {#each added as item, k (item.id)}
365
+ {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
366
+ <div class="qm-member held added" bind:this={memberEls[item.id]}>
367
+ <div class="qm-member-head">
368
+ <!-- Held by being present: the tick is fixed, and the remove is its untick. -->
369
+ <span class="qm-tick" aria-hidden="true">
370
+ <span class="qm-tick-input qm-tick-fixed"></span>
371
+ <Icon name="check" class="qm-tick-mark" />
372
+ </span>
373
+ <input
374
+ class="qm-input qm-focus-ring qm-member-title-input"
375
+ type="text"
376
+ id={itemTitleId(item.id)}
377
+ aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
378
+ value={item.title}
379
+ bind:this={titleEls[item.id]}
380
+ oninput={(e) => retitle(item.id, e.currentTarget.value)}
231
381
  />
232
- {:else}
233
- <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
234
- {/if}
382
+ <span class="qm-member-title" id={titleId(item.id)} hidden>{name}</span>
383
+ <button
384
+ type="button"
385
+ class="qm-icon-btn qm-focus-ring qm-member-remove"
386
+ title={t.strings.matrixRemove(name)}
387
+ aria-label={t.strings.matrixRemove(name)}
388
+ onclick={() => removeItem(item.id)}><Icon name="x" /></button
389
+ >
390
+ </div>
391
+ {@render columns(item.id, name, true)}
235
392
  </div>
236
393
  {/each}
237
394
  </div>
395
+ {#if schema.open}
396
+ <MatrixAdd
397
+ bind:this={addEl}
398
+ id={`${base}-add`}
399
+ placeholder={t.strings.matrixAdd(label ?? '')}
400
+ {options}
401
+ onChoose={choose}
402
+ />
403
+ {/if}
238
404
  <DiagnosticList diagnostics={foot} />
239
405
  </div>
240
406
 
407
+ <!-- A held item's columns, one rung in, the way a variant's cells unfold under the
408
+ discriminant; an unheld one's diagnostics under its head. Keyed by item id above, so
409
+ a tick elsewhere leaves this subform mounted and the caret in it. -->
410
+ {#snippet columns(id: string, title: string, on: boolean)}
411
+ {@const deep = routed.below.get(id)}
412
+ {#if on && hasColumns}
413
+ <ObjectField
414
+ bind:this={colEls[id]}
415
+ value={matrixColumns(memberValue(map, id))}
416
+ properties={schema.properties}
417
+ label={`${label ?? ''} ${title}`.trim()}
418
+ idBase={memberBase(id)}
419
+ labelledBy={titleId(id)}
420
+ contentAt={(path) => readAt([id, ...path])}
421
+ onCommit={(cols) => commitColumns(id, cols)}
422
+ diagnostics={deep?.filter((d) => d.steps[0] !== MATRIX_TITLE)}
423
+ />
424
+ <DiagnosticList
425
+ diagnostics={deep?.filter((d) => d.steps[0] === MATRIX_TITLE).map((d) => d.diagnostic)}
426
+ />
427
+ {:else}
428
+ <DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
429
+ {/if}
430
+ {/snippet}
431
+
241
432
  <style>
242
433
  .qm-matrix {
243
434
  display: flex;
@@ -317,10 +508,15 @@
317
508
  background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
318
509
  border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
319
510
  }
320
- .qm-tick-input:checked {
511
+ .qm-tick-input:checked,
512
+ .qm-tick-fixed {
321
513
  background: var(--_qm-accent);
322
514
  border-color: var(--_qm-accent);
323
515
  }
516
+ .qm-tick-fixed {
517
+ box-sizing: border-box;
518
+ cursor: default;
519
+ }
324
520
  /* The mark rides over the input and takes no press of its own; it is drawn by the
325
521
  input's state, in the ink the surface reads over the accent. */
326
522
  .qm-tick :global(.qm-tick-mark) {
@@ -333,7 +529,7 @@
333
529
  opacity: 0;
334
530
  transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
335
531
  }
336
- .qm-tick:has(:checked) :global(.qm-tick-mark) {
532
+ .qm-tick:has(:checked, .qm-tick-fixed) :global(.qm-tick-mark) {
337
533
  opacity: 1;
338
534
  }
339
535
  /* Unheld reads at the label ink, held at the value ink: the title is what the
@@ -350,4 +546,53 @@
350
546
  .qm-member-title:hover {
351
547
  color: var(--_qm-ink);
352
548
  }
549
+ /* An added item's title is its value, an input drawn as the roster's titles are: its
550
+ text starts where theirs does, one gap past the tick, and its well comes up under
551
+ the pointer and the focus, flush with the tick, so at rest the row reads as the rows
552
+ above it. */
553
+ .qm-member-title-input {
554
+ flex: 1;
555
+ min-width: 0;
556
+ margin-inline-start: calc(-1 * var(--_qm-space-2));
557
+ padding-inline-start: var(--_qm-space-2);
558
+ background: transparent;
559
+ transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
560
+ }
561
+ .qm-member-title-input:hover {
562
+ background: var(--_qm-surface-hover);
563
+ }
564
+ .qm-member-title-input:focus {
565
+ background: var(--_qm-surface-well);
566
+ }
567
+ /* The remove is offered by the row the pointer or the focus is in, as an array row's
568
+ is, and says destructive under the pointer. */
569
+ .qm-member-remove {
570
+ flex-shrink: 0;
571
+ color: var(--_qm-ink-label);
572
+ opacity: var(--_qm-opacity-idle);
573
+ transition:
574
+ opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
575
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
576
+ color var(--_qm-duration-fast) var(--_qm-ease-reverse);
577
+ }
578
+ .qm-member.added:hover > .qm-member-head > .qm-member-remove,
579
+ .qm-member.added:focus-within > .qm-member-head > .qm-member-remove {
580
+ opacity: 1;
581
+ }
582
+ .qm-member-remove:hover {
583
+ background: var(--_qm-danger-tint);
584
+ color: var(--_qm-danger);
585
+ }
586
+ .qm-member-remove :global(svg) {
587
+ width: var(--_qm-glyph-control);
588
+ height: var(--_qm-glyph-control);
589
+ }
590
+ /* The default's set is what prints and nothing written: its titles and every value
591
+ its columns draw take the default rung (theme.css), and the first gesture, writing
592
+ it, takes it off. */
593
+ .qm-matrix-roster[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
594
+ .qm-matrix-roster[data-default] :global(.qm-select:not([data-ghosted])),
595
+ .qm-matrix-roster[data-default] .qm-member.held .qm-member-title {
596
+ color: var(--_qm-ink-default);
597
+ }
353
598
  </style>
@@ -1,11 +1,11 @@
1
1
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import { type DeepDiagnostic } from './diagnostics.js';
3
3
  import type { LandingBox } from './leaves.js';
4
- import './controls.css';
5
4
  interface Props {
6
5
  /** The stored sparse map, or undefined while the field is unset. */
7
6
  value: Record<string, unknown> | undefined;
8
- /** The field's own schema: `members` is the roster, `properties` the columns. */
7
+ /** The field's own schema: `members` is the roster, `properties` the columns,
8
+ * `open` whether a document may add items. */
9
9
  schema: QuillFieldSchema;
10
10
  /** Field label, the naming prefix a member's columns compose with. */
11
11
  label?: string;
@@ -16,15 +16,17 @@ interface Props {
16
16
  /** Where the description parks, for the group's `aria-describedby`. */
17
17
  descriptionId?: string;
18
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. */
19
+ * names from, one `-m-<member id>` segment down. */
20
20
  idBase?: string;
21
+ /** The field's leaf key, where it is a card's own field: how the add box tells
22
+ * this list from the rest of the document's (`checklists.ts`). */
23
+ listKey?: string;
21
24
  /** The boundary's nested content read, rooted at this field: a member's content
22
- * column reads at `[id, column]` (`reader.getContentAt`). */
25
+ * column reads at `[id, column]` (`reader.getContentAt`). */
23
26
  contentAt: (path: PathStep[]) => Content | undefined;
24
27
  onCommit: (v: Record<string, unknown> | undefined) => void;
25
28
  /** 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. */
29
+ * naming a drawn item draws under it, any other at the field's foot. */
28
30
  diagnostics?: readonly DeepDiagnostic[];
29
31
  }
30
32
  declare const MatrixField: import("svelte").Component<Props, {
@@ -29,7 +29,6 @@
29
29
  numbers, and `1.000` as a whole one.
30
30
  -->
31
31
  <script lang="ts">
32
- import { syncedLocal } from './synced.svelte.js';
33
32
  import './controls.css';
34
33
 
35
34
  interface Props {
@@ -53,11 +52,8 @@
53
52
  }
54
53
  let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
55
54
 
56
- // Local input state synced to `value` (as a string projection), or to the default
57
- // an unset field holds; own-typing stays local, only an external change reconciles
58
- // back in (see `syncedLocal`).
59
- const local = syncedLocal(() => (value != null ? String(value) : (fallback ?? '')));
60
- const defaulted = $derived(value == null && !!fallback && local.value === fallback);
55
+ let local = $derived(value != null ? String(value) : (fallback ?? ''));
56
+ const defaulted = $derived(value == null && !!fallback && local === fallback);
61
57
 
62
58
  const partial = $derived(
63
59
  integer ? /^\s*[+-]?\d*\s*$/ : /^\s*[+-]?\d*\.?\d*(?:[eE][+-]?\d*)?\s*$/
@@ -77,7 +73,7 @@
77
73
  // `insertText` lands on the undo stack and fires `input`; `setRangeText` does neither.
78
74
  if (document.execCommand?.('insertText', false, data)) return;
79
75
  el.setRangeText(data, from, to, 'end');
80
- local.value = el.value;
76
+ local = el.value;
81
77
  }
82
78
 
83
79
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
@@ -89,7 +85,7 @@
89
85
  const entry = raw.trim();
90
86
  if (entry === '') {
91
87
  onCommit(undefined);
92
- local.value = el.value = fallback ?? '';
88
+ local = el.value = fallback ?? '';
93
89
  return;
94
90
  }
95
91
  // Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
@@ -102,7 +98,7 @@
102
98
  class="qm-input qm-focus-ring"
103
99
  type="text"
104
100
  inputmode={integer ? 'numeric' : 'decimal'}
105
- value={local.value}
101
+ value={local}
106
102
  {id}
107
103
  {placeholder}
108
104
  data-default={defaulted ? '' : undefined}
@@ -110,7 +106,7 @@
110
106
  aria-describedby={describedBy}
111
107
  onbeforeinput={filter}
112
108
  oninput={(e) => {
113
- local.value = (e.currentTarget as HTMLInputElement).value;
109
+ local = (e.currentTarget as HTMLInputElement).value;
114
110
  }}
115
111
  onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
116
112
  />