@quillmark/svelte 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +8 -2
  2. package/THEMING.md +22 -27
  3. package/dist/core/address.d.ts +17 -3
  4. package/dist/core/address.js +20 -3
  5. package/dist/core/codec/blocks.d.ts +37 -0
  6. package/dist/core/codec/blocks.js +163 -0
  7. package/dist/core/codec/field.js +3 -5
  8. package/dist/core/codec/index.d.ts +1 -1
  9. package/dist/core/codec/index.js +3 -2
  10. package/dist/core/codec/inputrules.js +24 -144
  11. package/dist/core/codec/prose.css +14 -25
  12. package/dist/core/codec/schema.d.ts +8 -0
  13. package/dist/core/codec/schema.js +33 -1
  14. package/dist/core/codec/slash.d.ts +18 -16
  15. package/dist/core/codec/slash.js +0 -0
  16. package/dist/core/codec/table-view.js +127 -157
  17. package/dist/core/codec/table.d.ts +0 -10
  18. package/dist/core/codec/table.js +0 -15
  19. package/dist/core/theme.css +350 -347
  20. package/dist/preset/recipes.css +10 -21
  21. package/dist/preset/scale.css +6 -7
  22. package/dist/preview/Preview.svelte +4 -2
  23. package/dist/preview/geometry.d.ts +10 -9
  24. package/dist/preview/geometry.js +11 -13
  25. package/dist/visual/ArrayField.svelte +242 -63
  26. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  27. package/dist/visual/Card.svelte +21 -2
  28. package/dist/visual/DiagnosticList.svelte +6 -11
  29. package/dist/visual/EnumField.svelte +33 -1
  30. package/dist/visual/Field.svelte +29 -5
  31. package/dist/visual/FieldLabel.svelte +2 -2
  32. package/dist/visual/FormatPopover.svelte +13 -1
  33. package/dist/visual/ObjectField.svelte +157 -35
  34. package/dist/visual/ObjectField.svelte.d.ts +13 -4
  35. package/dist/visual/ProseArrayElement.svelte +22 -2
  36. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  37. package/dist/visual/TipsCard.svelte +33 -13
  38. package/dist/visual/VariantField.svelte +98 -0
  39. package/dist/visual/VariantField.svelte.d.ts +21 -0
  40. package/dist/visual/VisualEditorInner.svelte +18 -15
  41. package/dist/visual/controls.css +282 -283
  42. package/dist/visual/diagnostics.d.ts +15 -24
  43. package/dist/visual/diagnostics.js +14 -30
  44. package/dist/visual/domid.d.ts +13 -0
  45. package/dist/visual/domid.js +16 -0
  46. package/dist/visual/leaves.d.ts +9 -7
  47. package/dist/visual/links.d.ts +5 -2
  48. package/dist/visual/links.js +9 -3
  49. package/dist/visual/props.d.ts +1 -1
  50. package/dist/visual/strings.d.ts +21 -6
  51. package/dist/visual/strings.js +3 -0
  52. package/dist/visual/structure.d.ts +32 -7
  53. package/dist/visual/structure.js +49 -3
  54. package/package.json +2 -2
  55. package/dist/core/codec/clipboard.d.ts +0 -59
  56. package/dist/core/codec/clipboard.js +0 -202
@@ -140,6 +140,10 @@
140
140
  let expanded = $state<string | null>(initialExpandedGroup(card.sections));
141
141
  let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
142
142
  let panels = $state<Record<string, HTMLElement | undefined>>({});
143
+ /** Whether the panels animate the move `expanded` is about to make. False for a reveal
144
+ * ({@link revealLeaf}), restored by the header gesture the motion is there for, so its
145
+ * return needs no timer. */
146
+ let animate = $state(true);
143
147
  /**
144
148
  * Move to `next` (`null` = all closed). A closing panel goes `inert` with whatever it
145
149
  * held still focused, and `inert` does not say where focus should go — the browser
@@ -154,6 +158,7 @@
154
158
  expanded = next;
155
159
  }
156
160
  function toggleGroup(group: string): void {
161
+ animate = true;
157
162
  setExpanded(expanded === group ? null : group);
158
163
  }
159
164
 
@@ -163,13 +168,20 @@
163
168
  * flush before landing, since `inert` clears when the panel renders open, not when
164
169
  * `expanded` is assigned.
165
170
  *
171
+ * The move is instant, because that same flush is when the landing measures its target:
172
+ * an animating track still reads its start value there, and the trip would be computed
173
+ * against a panel that has not moved yet. The motion explains a header click, and a
174
+ * click in the preview is not one.
175
+ *
166
176
  * A call, not a prop: a reveal is an event, and modelling it as state needs a
167
177
  * fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
168
178
  * re-derive from reopening the accordion under the user.
169
179
  */
170
180
  export function revealLeaf(key: string): void {
171
181
  const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
172
- if (section?.group) setExpanded(section.group);
182
+ if (!section?.group) return;
183
+ animate = false;
184
+ setExpanded(section.group);
173
185
  }
174
186
 
175
187
  let el = $state<HTMLElement | undefined>(undefined);
@@ -252,7 +264,7 @@
252
264
  accordion is not the bracket's last child: the bottom rule's inset belongs
253
265
  to whichever block abuts it. -->
254
266
  {#if grouped.length}
255
- <div class="qm-groups">
267
+ <div class="qm-groups" class:qm-instant={!animate}>
256
268
  {#each grouped as section (section.group)}
257
269
  {@const group = section.group as string}
258
270
  {@const isOpen = expanded === group}
@@ -618,6 +630,13 @@
618
630
  .qm-group.qm-open .qm-group-panel {
619
631
  grid-template-rows: 1fr;
620
632
  }
633
+ /* The track is the accordion's only stroke that moves layout, and a reveal's landing
634
+ measures its target one flush after asking — where an animating track still reads its
635
+ start value. Both panels of the move are inside the group box, so the opening one and
636
+ the closing one above it stand still together (`revealLeaf`). */
637
+ .qm-groups.qm-instant .qm-group-panel {
638
+ transition: none;
639
+ }
621
640
  /* Two boxes because the axes clip differently, and the track is the only thing that
622
641
  animates: a field sits at its open position from the first frame of the reveal.
623
642
 
@@ -1,9 +1,9 @@
1
1
  <!--
2
- Inline diagnostic display (VISUAL_EDITOR §Diagnostics), severity-styled via
3
- CSS vars, non-gating: purely additive, never disables the field it sits
4
- under. Shared by `Field.svelte` (a scalar/prose field) and `Card.svelte` (the
5
- body leaf, which has no `Field.svelte` wrapper), so a card's body diagnostics
6
- render the same way a field's do.
2
+ Inline diagnostic display (VISUAL_EDITOR §Diagnostics). Errors only, non-gating:
3
+ purely additive, never disables the field it sits under. Shared by `Field.svelte`
4
+ (a scalar/prose field) and `Card.svelte` (the body leaf, which has no
5
+ `Field.svelte` wrapper), so a card's body diagnostics render the same way a
6
+ field's do.
7
7
  -->
8
8
  <script lang="ts">
9
9
  import type { Diagnostic } from '@quillmark/wasm';
@@ -27,9 +27,7 @@
27
27
  <!-- role=status: commit errors appear mid-typing; announce without stealing focus. -->
28
28
  <div class="qm-diag-list" role="status">
29
29
  {#each diagnostics as d, i (i)}
30
- <span class="qm-diag-line" data-severity={d.severity}
31
- >{diagnosticText(d, t.formatDiagnostic)}</span
32
- >
30
+ <span class="qm-diag-line">{diagnosticText(d, t.formatDiagnostic)}</span>
33
31
  {/each}
34
32
  </div>
35
33
  {/if}
@@ -42,9 +40,6 @@
42
40
  }
43
41
  .qm-diag-line {
44
42
  font-size: var(--_qm-text-label);
45
- color: var(--_qm-warning);
46
- }
47
- .qm-diag-line[data-severity='error'] {
48
43
  color: var(--_qm-danger);
49
44
  }
50
45
  </style>
@@ -16,8 +16,11 @@
16
16
  import { Select } from 'bits-ui';
17
17
  import Icon from './icons/Icon.svelte';
18
18
  import { syncedLocal } from './synced.svelte.js';
19
+ import { wording } from './strings.js';
19
20
  import './controls.css';
20
21
 
22
+ const t = wording();
23
+
21
24
  interface Props {
22
25
  value: string | undefined;
23
26
  values: string[];
@@ -114,8 +117,19 @@
114
117
  plain string and never picks up the scoping hash. -->
115
118
  <div class="qm-menu-surface qm-select-content" data-qm-root>
116
119
  <Select.Viewport>
117
- <Select.Item class="qm-menu-item qm-select-item" value={UNSET} label={ghostText}>
120
+ <!-- The sentinel renders the `default:`'s own text, so a defaulted enum
121
+ lists that word twice: this row, and the member below it. The tag has
122
+ to be a word rather than a tone — the ghost is already `ink-label`,
123
+ and while the field is unset this is the selected row, so the weight
124
+ step pulls the other way. It rides the accessible name for the same
125
+ reason. -->
126
+ <Select.Item
127
+ class="qm-menu-item qm-select-item qm-select-unset"
128
+ value={UNSET}
129
+ label={`${ghostText} ${t.strings.enumUnsetTag}`}
130
+ >
118
131
  <span class="qm-select-ghost">{ghostText}</span>
132
+ <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
119
133
  </Select.Item>
120
134
  <!-- A refused option still draws under `'disable'`, and under either
121
135
  policy when it is the one selected: a listbox whose selected value
@@ -186,4 +200,22 @@
186
200
  .qm-select-ghost {
187
201
  color: var(--_qm-ink-label);
188
202
  }
203
+ /* The sentinel's row: the ghost at one end, the tag at the other, so the pair reads
204
+ as a value and a note about it rather than as two words. Only this row takes the
205
+ split; a member's row is one string and has nothing to distribute. */
206
+ .qm-select-content :global(.qm-select-unset) {
207
+ display: flex;
208
+ align-items: baseline;
209
+ justify-content: space-between;
210
+ gap: var(--_qm-space-2);
211
+ }
212
+ /* A step down the size ramp, which is what says this is chrome and the word beside
213
+ it is the value. The ink is the ghost's own: a second tone here would rank the
214
+ tag against the thing it annotates, and there is no rung under `label` to take
215
+ (theme.css, "Two rungs, and there is no third"). */
216
+ .qm-select-tag {
217
+ flex-shrink: 0;
218
+ font-size: var(--_qm-text-label);
219
+ color: var(--_qm-ink-label);
220
+ }
189
221
  </style>
@@ -11,14 +11,14 @@
11
11
  `diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
12
12
  `diagByKey`, merging `quill.validate`, local commit errors, and the external
13
13
  `diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
14
- `DiagnosticList`, severity-styled, non-gating.
14
+ `DiagnosticList`, non-gating.
15
15
  -->
16
16
  <script lang="ts">
17
17
  import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
18
18
  import type { EditorErrorHandler } from '../core/errors.js';
19
19
  import type { LeafRegistry } from './leaves.js';
20
20
  import type { FieldModel, FieldSpan } from './structure.js';
21
- import { ghostDefault, stringifyGhost } from './structure.js';
21
+ import { VARIANT_DISCRIMINANT, ghostDefault, stringifyGhost } from './structure.js';
22
22
  import type { FieldDomIds } from './domid.js';
23
23
  import ProseField from './ProseField.svelte';
24
24
  import TextField from './TextField.svelte';
@@ -28,6 +28,7 @@
28
28
  import DateField from './DateField.svelte';
29
29
  import ArrayField from './ArrayField.svelte';
30
30
  import ObjectField from './ObjectField.svelte';
31
+ import VariantField from './VariantField.svelte';
31
32
  import DiagnosticList from './DiagnosticList.svelte';
32
33
  import FieldLabel from './FieldLabel.svelte';
33
34
 
@@ -91,14 +92,22 @@
91
92
  // control's `YYYY-MM-DD`. An object-valued default does not ghost.
92
93
  const ghost = $derived(ghostDefault(provenance));
93
94
  const defaultStr = $derived(stringifyGhost(ghost));
95
+ // A variant resolves as one rung whose value is the whole container, so the
96
+ // discriminant's ghost is that container's own discriminant cell.
97
+ const ghostMember = $derived(
98
+ (ghost as Record<string, unknown> | undefined)?.[VARIANT_DISCRIMINANT] as string | undefined
99
+ );
94
100
 
95
101
  // `for` reaches a labelable control and the browser does the rest. The other four
96
102
  // are not labelable (the prose leaf's `contenteditable`, the date field's segment
97
103
  // container, the object subform, an array's N inputs) so `for` there would be
98
104
  // both inert and invalid markup; they take `aria-labelledby` and a click handoff.
105
+ // A variant is labelable through its discriminant: the select is the one control
106
+ // the field's label names, and its cells carry composed names of their own.
99
107
  const labelable = $derived(
100
108
  field.control === 'text' ||
101
109
  field.control === 'enum' ||
110
+ field.control === 'variant' ||
102
111
  field.control === 'number' ||
103
112
  field.control === 'boolean'
104
113
  );
@@ -120,7 +129,7 @@
120
129
  let arrayEl = $state<
121
130
  | {
122
131
  focus: () => void;
123
- focusElement: (k: number) => void;
132
+ focusElement: (k: number, pos?: number) => void;
124
133
  washBox: () => HTMLElement | undefined;
125
134
  }
126
135
  | undefined
@@ -133,8 +142,8 @@
133
142
  }
134
143
  /** The array's per-ELEMENT landing, for the addresses the preview mints under an
135
144
  * array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
136
- function focusElement(k: number): void {
137
- arrayEl?.focusElement(k);
145
+ function focusElement(k: number, pos?: number): void {
146
+ arrayEl?.focusElement(k, pos);
138
147
  }
139
148
  // Only where `for` cannot reach; the labelable four are the browser's own, and a
140
149
  // second handler over them would be a focus the label already placed.
@@ -229,6 +238,19 @@
229
238
  {optionAllowed}
230
239
  {enumDisallowed}
231
240
  />
241
+ {:else if field.control === 'variant'}
242
+ <VariantField
243
+ value={value as Record<string, unknown> | undefined}
244
+ schema={field.schema}
245
+ {ghostMember}
246
+ label={field.label}
247
+ id={domIds.control}
248
+ labelledBy={domIds.label}
249
+ {describedBy}
250
+ onCommit={onCommitScalar}
251
+ {optionAllowed}
252
+ {enumDisallowed}
253
+ />
232
254
  {:else if field.control === 'number'}
233
255
  <NumberField
234
256
  value={value as number | undefined}
@@ -265,6 +287,7 @@
265
287
  description={field.description}
266
288
  labelId={domIds.label}
267
289
  descriptionId={domIds.description}
290
+ idBase={domIds.control}
268
291
  elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
269
292
  onCommit={onCommitScalar}
270
293
  />
@@ -274,6 +297,7 @@
274
297
  value={value as Record<string, unknown> | undefined}
275
298
  properties={field.schema.properties}
276
299
  label={field.label}
300
+ idBase={domIds.control}
277
301
  labelledBy={domIds.label}
278
302
  {describedBy}
279
303
  onCommit={onCommitScalar}
@@ -11,8 +11,8 @@
11
11
  element is inert and invalid.
12
12
 
13
13
  Two non-gating label decorations:
14
- • a persistent required `*` for a field the schema obliges (`must_fill`, which
15
- derives from a missing `default:`; DOCUMENT_MODEL). Its
14
+ • a persistent required `*` for a field the schema obliges — one that declares no
15
+ `default:` (DOCUMENT_MODEL). Its
16
16
  accessible name is "required", so a screen reader announces the word, not the
17
17
  glyph; inside the `<label>`, so it names the control along with the text
18
18
  ("Subject required") rather than announcing as a node adjacent to it;
@@ -277,6 +277,18 @@
277
277
  sync();
278
278
  }
279
279
 
280
+ /**
281
+ * A fresh anchor id: 128 random bits as hex, the id being opaque to the
282
+ * boundary and needing only to be unique. `crypto.randomUUID` is
283
+ * secure-context-only, and absent on a page served over plain `http` to
284
+ * something other than localhost; `getRandomValues` is there either way.
285
+ */
286
+ function mintAnchorId(): string {
287
+ return [...crypto.getRandomValues(new Uint8Array(16))]
288
+ .map((b) => b.toString(16).padStart(2, '0'))
289
+ .join('');
290
+ }
291
+
280
292
  /**
281
293
  * Toggle an identity anchor over the selection: the `anchor`
282
294
  * button's answer to a formatting toggle. If the selection already covers
@@ -292,7 +304,7 @@
292
304
  const { from, to } = leaf.selectionRange();
293
305
  const covered = leaf.anchorsInRange(from, to);
294
306
  if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
295
- else leaf.insertAnchor(crypto.randomUUID(), from);
307
+ else leaf.insertAnchor(mintAnchorId(), from);
296
308
  view.focus();
297
309
  sync();
298
310
  }
@@ -4,6 +4,23 @@
4
4
  only: a nested prose/array/object property renders a placeholder instead of
5
5
  recursing.
6
6
 
7
+ The nesting is a band: two full-width horizontals at `--_qm-border`, the figure the
8
+ card draws around its own metadata (`.qm-meta-top` / `.qm-meta-bottom`) one octave
9
+ down. Nothing insets, so a property's control keeps the card's left and right columns
10
+ exactly as a field's does, and the field's own label caps the band from outside it —
11
+ name, rule under the name, content, closing rule.
12
+
13
+ That absent inset is also what puts the properties on the section's own column tracks
14
+ (below), so a subform's cells share edges with the fields above them rather than
15
+ keeping a rhythm of their own.
16
+
17
+ Which edges draw is {@link Props.edges}, and the rule is that a boundary is stated
18
+ once: where a box already sits against the subform, that box IS the boundary and a
19
+ stroke under it would say so twice. So a field-level subform takes the band, and a
20
+ subform hanging off a control — a variant's cells under their discriminant, an
21
+ array element's properties under its summary row — takes the closing rule alone.
22
+ Sometimes two strokes and sometimes one: the open figure the card's bracket is.
23
+
7
24
  A property's ghosted `default:` is the static schema `sub.default`, not the
8
25
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
9
26
  carries no per-property row (an object field resolves as one row whose value is
@@ -11,7 +28,10 @@
11
28
  -->
12
29
  <script lang="ts">
13
30
  import type { QuillFieldSchema } from '@quillmark/wasm';
14
- import { controlKind, humanize } from './structure.js';
31
+ import { controlKind, humanize, obliged } from './structure.js';
32
+ import { wording } from './strings.js';
33
+ import { propertyDomIds } from './domid.js';
34
+ import FieldLabel from './FieldLabel.svelte';
15
35
  import TextField from './TextField.svelte';
16
36
  import EnumField from './EnumField.svelte';
17
37
  import NumberField from './NumberField.svelte';
@@ -21,21 +41,53 @@
21
41
  interface Props {
22
42
  value: Record<string, unknown> | undefined;
23
43
  properties: Record<string, QuillFieldSchema> | undefined;
24
- /** Accessible-name prefix for the property controls. */
25
- label?: string;
26
44
  /** The field label's own id. A subform is a group of controls, not one control
27
- * `for` could reach, so the field's label names the set and each property
28
- * control keeps its own composed `aria-label`. */
45
+ * `for` could reach, so the field's label names the set; each property carries
46
+ * its own `<label for>` inside it. */
29
47
  labelledBy?: string;
30
48
  /** The parked `description` (FieldLabel): announced on entering the group. */
31
49
  describedBy?: string;
50
+ /**
51
+ * The parent control's DOM id: the base each property's own three names derive
52
+ * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
53
+ * it — the properties fall back to `aria-label`, which names them without a
54
+ * `for` target to click.
55
+ */
56
+ idBase?: string;
57
+ /** Accessible-name prefix used only on the `aria-label` fallback above. */
58
+ label?: string;
59
+ /** Which strokes the subform draws; see the note above. */
60
+ edges?: 'band' | 'close';
32
61
  onCommit: (obj: Record<string, unknown>) => void;
33
62
  }
34
- let { value, properties, label, labelledBy, describedBy, onCommit }: Props = $props();
63
+ let {
64
+ value,
65
+ properties,
66
+ labelledBy,
67
+ describedBy,
68
+ idBase,
69
+ label,
70
+ edges = 'band',
71
+ onCommit
72
+ }: Props = $props();
35
73
 
74
+ const t = wording();
36
75
  const entries = $derived(Object.entries(properties ?? {}));
37
76
  const obj = $derived((value ?? {}) as Record<string, unknown>);
38
77
 
78
+ /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
79
+ * ({@link obliged}). `validate` anchors it per leaf, so a property with no
80
+ * `default:` is obliged in its own right and the container around it holds none
81
+ * (VISUAL_EDITOR §"Enum variants"). */
82
+ const required = obliged;
83
+
84
+ const title = (key: string, sub: QuillFieldSchema): string => sub.ui?.title ?? humanize(key);
85
+ /** The `aria-label` fallback, for a subform mounted without a field's id space:
86
+ * the field's name and the property's, since nothing else names the control. */
87
+ const fallbackName = (key: string, sub: QuillFieldSchema): string =>
88
+ `${label != null ? `${label} ` : ''}${title(key, sub)}` +
89
+ (required(sub) ? ` ${t.strings.fieldRequired}` : '');
90
+
39
91
  /** Take the caret: the first property's control, a subform having no single control
40
92
  * of its own to land on. Resolved off the DOM rather than a ref per property: every
41
93
  * property is a scalar control the DOM already knows how to focus (the date field's
@@ -43,10 +95,19 @@
43
95
  * handle each would be five refs restating document order. An empty or wholly
44
96
  * unsupported subform lands nothing. */
45
97
  let rootEl = $state<HTMLElement | undefined>();
98
+ const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
46
99
  export function focus(): void {
47
- rootEl
48
- ?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
49
- ?.focus();
100
+ rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
101
+ }
102
+ /** A label click for the one property `for` cannot reach: the date control, whose
103
+ * focus lives on a segment. Scoped to that property's own cell, so the click lands
104
+ * where the label is rather than on the subform's first control. */
105
+ function focusProp(key: string): void {
106
+ // Matched on the dataset: a key is the schema's own string, and spelling one into
107
+ // a selector needs `CSS.escape`, which the test DOM does not carry.
108
+ for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
109
+ if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
110
+ }
50
111
  }
51
112
 
52
113
  function commitProp(key: string, v: unknown): void {
@@ -66,18 +127,38 @@
66
127
  <div
67
128
  bind:this={rootEl}
68
129
  class="qm-object"
130
+ class:close={edges === 'close'}
69
131
  role="group"
70
132
  aria-labelledby={labelledBy}
71
133
  aria-describedby={describedBy}
72
134
  >
73
135
  {#each entries as [key, sub] (key)}
74
136
  {@const kind = controlKind(sub)}
75
- {@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
76
- <div class="qm-object-prop">
77
- <span class="qm-object-label">{sub.ui?.title ?? humanize(key)}</span>
137
+ {@const ids = idBase ? propertyDomIds(idBase, key) : undefined}
138
+ {@const named = ids ? undefined : fallbackName(key, sub)}
139
+ {@const describes = sub.description && ids ? ids.description : undefined}
140
+ <!-- `for` reaches the four labelable controls; the date field's focus lives on a
141
+ segment, so it takes the click handoff instead, exactly as `Field` does one
142
+ level up. -->
143
+ {@const labelable =
144
+ kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
145
+ <div class="qm-object-prop" data-qm-prop={key}>
146
+ {#if ids}
147
+ <FieldLabel
148
+ label={title(key, sub)}
149
+ controlId={labelable ? ids.control : undefined}
150
+ id={ids.label}
151
+ descriptionId={describes}
152
+ onActivate={labelable ? undefined : () => focusProp(key)}
153
+ required={required(sub)}
154
+ description={sub.description}
155
+ />
156
+ {/if}
78
157
  {#if kind === 'enum'}
79
158
  <EnumField
80
- label={propLabel}
159
+ label={named}
160
+ id={ids?.control}
161
+ describedBy={describes}
81
162
  value={obj[key] as string | undefined}
82
163
  values={sub.values ?? []}
83
164
  fallback={sub.default as string | undefined}
@@ -85,7 +166,9 @@
85
166
  />
86
167
  {:else if kind === 'number'}
87
168
  <NumberField
88
- label={propLabel}
169
+ label={named}
170
+ id={ids?.control}
171
+ describedBy={describes}
89
172
  value={obj[key] as number | undefined}
90
173
  integer={sub.type === 'integer'}
91
174
  fallback={sub.default as number | undefined}
@@ -93,54 +176,93 @@
93
176
  />
94
177
  {:else if kind === 'boolean'}
95
178
  <BooleanField
96
- label={propLabel}
179
+ label={named}
180
+ id={ids?.control}
181
+ describedBy={describes}
97
182
  value={obj[key] as boolean | undefined}
98
183
  fallback={sub.default as boolean | undefined}
99
184
  onCommit={(v) => commitProp(key, v)}
100
185
  />
101
186
  {:else if kind === 'date'}
102
187
  <DateField
103
- label={propLabel}
188
+ label={named}
189
+ labelledBy={ids?.label}
190
+ describedBy={describes}
104
191
  value={obj[key] as string | undefined}
105
192
  onCommit={(v) => commitProp(key, v)}
106
193
  />
107
194
  {:else if kind === 'text'}
108
195
  <TextField
109
- label={propLabel}
196
+ label={named}
197
+ id={ids?.control}
198
+ describedBy={describes}
110
199
  value={obj[key] as string | undefined}
111
200
  placeholder={sub.default != null ? String(sub.default) : undefined}
112
201
  onCommit={(v) => commitProp(key, v)}
113
202
  />
114
203
  {:else}
115
- <span class="qm-unsupported">({kind} property — not editable in V1)</span>
204
+ <!-- A property the subform does not recurse into. What it says is what the
205
+ user can do about it, which is edit the field from the source surface;
206
+ a version number is the roadmap talking to somebody filling in a form. -->
207
+ <span class="qm-unsupported">{t.strings.nestedUnsupported(kind)}</span>
116
208
  {/if}
117
209
  </div>
118
210
  {/each}
119
211
  </div>
120
212
 
121
213
  <style>
214
+ /* The band. `border-block` draws both strokes as one declaration, which is what
215
+ makes the figure symmetric by construction rather than by two lines agreeing;
216
+ `padding-block` at the same rung on both sides is the other half of that.
217
+
218
+ `--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
219
+ (ARCHITECTURE §"A plane is a tone"), the one the metadata bracket and the open
220
+ section's vertical read. `faint` separates without structuring — a table's
221
+ interior lines under a frame that is doing the structuring — which is the other
222
+ job.
223
+
224
+ The columns are the section's, arrived at by arithmetic rather than by `subgrid`:
225
+ the band insets nothing horizontally, so this box spans exactly what `.qm-fields`
226
+ spans, and equal tracks at the same count over the same width with the same column
227
+ gap fall on the same edges. `subgrid` would want the tracks chained through the
228
+ three wrappers between the section grid and here (`Field.svelte`), none of which is
229
+ a grid. `--cols` inherits instead, so the container query stepping the section's
230
+ capacity steps the subform's with it — no second query container, no second set of
231
+ rungs, nothing measured. */
122
232
  .qm-object {
123
- display: flex;
124
- flex-direction: column;
233
+ display: grid;
234
+ grid-template-columns: repeat(var(--cols), 1fr);
125
235
  gap: var(--_qm-space-2);
126
- border-left: var(--_qm-border-width) solid var(--_qm-border);
127
- padding-left: var(--_qm-space-2);
236
+ border-block: var(--_qm-border-width) solid var(--_qm-border);
237
+ padding-block: var(--_qm-space-2);
238
+ }
239
+ /* Hanging off a control (a variant's discriminant, an array element's summary row):
240
+ that box is the top boundary already, and a stroke under it would state the
241
+ boundary twice. The closing rule is not optional in the same breath — without it
242
+ the properties float between the box above and whatever follows. */
243
+ .qm-object.close {
244
+ border-block-start: none;
245
+ padding-block-start: 0;
128
246
  }
247
+ /* A property measures like a field: two tracks over the subform's own rows, so a
248
+ label wrapping in one column leaves its control on the row's baseline rather than a
249
+ line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
250
+ tracks are this grid's own; the section's belong to the fields.
251
+
252
+ `min-width: 0` is what holds the arithmetic above: a property overflowing its `1fr`
253
+ grows the track, and the subform's edges leave the section's with it. */
129
254
  .qm-object-prop {
130
- display: flex;
131
- flex-direction: column;
132
- gap: var(--_qm-space-half);
255
+ display: grid;
256
+ grid-row: span 2;
257
+ grid-template-rows: subgrid;
258
+ row-gap: var(--_qm-space-half);
259
+ align-items: start;
260
+ min-width: 0;
133
261
  }
134
- .qm-object-label {
135
- font-size: var(--_qm-text-label);
136
- /* The chrome weight, the same one the field label above it takes: what says
137
- this name is nested is the subform's rule and its inset, so a weight step
138
- here would state it twice and rank two names the ramp does not separate. */
139
- font-weight: var(--_qm-weight-mid);
140
- /* A label is a line, not a passage, so it overrides the root's reading
141
- rhythm the way every other label rung does. */
142
- line-height: var(--_qm-leading-tight);
143
- color: var(--_qm-ink-label);
262
+ /* A subform of one takes half the capacity, the way a packable run of one does
263
+ (`.qm-field.lone`): at full capacity a single track reads as truncated. */
264
+ .qm-object-prop:only-child {
265
+ grid-column: span var(--cols-half);
144
266
  }
145
267
  .qm-unsupported {
146
268
  font-size: var(--_qm-text-body);
@@ -2,14 +2,23 @@ import type { QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  value: Record<string, unknown> | undefined;
4
4
  properties: Record<string, QuillFieldSchema> | undefined;
5
- /** Accessible-name prefix for the property controls. */
6
- label?: string;
7
5
  /** The field label's own id. A subform is a group of controls, not one control
8
- * `for` could reach, so the field's label names the set and each property
9
- * control keeps its own composed `aria-label`. */
6
+ * `for` could reach, so the field's label names the set; each property carries
7
+ * its own `<label for>` inside it. */
10
8
  labelledBy?: string;
11
9
  /** The parked `description` (FieldLabel): announced on entering the group. */
12
10
  describedBy?: string;
11
+ /**
12
+ * The parent control's DOM id: the base each property's own three names derive
13
+ * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
14
+ * it — the properties fall back to `aria-label`, which names them without a
15
+ * `for` target to click.
16
+ */
17
+ idBase?: string;
18
+ /** Accessible-name prefix used only on the `aria-label` fallback above. */
19
+ label?: string;
20
+ /** Which strokes the subform draws; see the note above. */
21
+ edges?: 'band' | 'close';
13
22
  onCommit: (obj: Record<string, unknown>) => void;
14
23
  }
15
24
  declare const ObjectField: import("svelte").Component<Props, {
@@ -17,14 +17,16 @@
17
17
  -->
18
18
  <script lang="ts">
19
19
  import { onMount } from 'svelte';
20
- import { EditorState } from 'prosemirror-state';
20
+ import { EditorState, Selection } from 'prosemirror-state';
21
21
  import { EditorView } from 'prosemirror-view';
22
22
  import {
23
23
  decode,
24
24
  pmToContent,
25
25
  inlineSchema,
26
26
  plaintextSchema,
27
- proseLeafPlugins
27
+ proseLeafPlugins,
28
+ buildLineIndex,
29
+ usvToPM
28
30
  } from '../core/codec/index.js';
29
31
  import './controls.css';
30
32
  import type { Content } from '@quillmark/wasm';
@@ -61,6 +63,24 @@
61
63
  view.focus();
62
64
  view.dom.scrollIntoView({ block: 'nearest' });
63
65
  }
66
+ /** Take the caret to USV `pos` in this element's own content: what a landing on an
67
+ * element address resolves to when the compile answered a cluster-exact offset
68
+ * (`leaves.ts`). `createField`'s body over the view this component owns.
69
+ *
70
+ * The index is built here rather than held: a landing is a discrete act, and one
71
+ * doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
72
+ * clamps, so an offset past a value the compile has moved on from lands the end.
73
+ *
74
+ * `Selection.near`, not `TextSelection.create`: the mapped position can be
75
+ * non-inline. Focus first, then the caret, flagged for scroll — the order and the
76
+ * reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
77
+ export function setCaret(pos: number): void {
78
+ if (!view) return;
79
+ const pm = usvToPM(buildLineIndex(view.state.doc), pos);
80
+ const sel = Selection.near(view.state.doc.resolve(pm));
81
+ view.focus();
82
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
83
+ }
64
84
 
65
85
  onMount(() => {
66
86
  if (!containerEl) return;
@@ -19,6 +19,7 @@ interface Props {
19
19
  }
20
20
  declare const ProseArrayElement: import("svelte").Component<Props, {
21
21
  focus: () => void;
22
+ setCaret: (pos: number) => void;
22
23
  }, "">;
23
24
  type ProseArrayElement = ReturnType<typeof ProseArrayElement>;
24
25
  export default ProseArrayElement;