@quillmark/svelte 0.5.1 → 0.7.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 (66) hide show
  1. package/README.md +6 -0
  2. package/THEMING.md +13 -17
  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 +5 -6
  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.d.ts +4 -0
  17. package/dist/core/codec/table-view.js +137 -158
  18. package/dist/core/codec/table.d.ts +0 -10
  19. package/dist/core/codec/table.js +0 -15
  20. package/dist/core/theme.css +357 -346
  21. package/dist/preset/recipes.css +25 -21
  22. package/dist/preview/Preview.svelte +4 -2
  23. package/dist/preview/bridge.d.ts +23 -0
  24. package/dist/preview/bridge.js +92 -35
  25. package/dist/preview/controller.d.ts +6 -5
  26. package/dist/preview/controller.js +13 -7
  27. package/dist/preview/geometry.d.ts +10 -9
  28. package/dist/preview/geometry.js +11 -13
  29. package/dist/preview/index.js +3 -2
  30. package/dist/preview/paint.js +9 -1
  31. package/dist/visual/ArrayField.svelte +246 -63
  32. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  33. package/dist/visual/Card.svelte +84 -50
  34. package/dist/visual/EnumField.svelte +37 -3
  35. package/dist/visual/Field.svelte +28 -4
  36. package/dist/visual/FieldLabel.svelte +2 -2
  37. package/dist/visual/FormatPopover.svelte +13 -1
  38. package/dist/visual/ObjectField.svelte +128 -35
  39. package/dist/visual/ObjectField.svelte.d.ts +11 -4
  40. package/dist/visual/ProseArrayElement.svelte +22 -2
  41. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  42. package/dist/visual/SlashMenu.svelte +7 -0
  43. package/dist/visual/TipsCard.svelte +33 -13
  44. package/dist/visual/VariantField.svelte +95 -0
  45. package/dist/visual/VariantField.svelte.d.ts +21 -0
  46. package/dist/visual/VisualEditorInner.svelte +44 -21
  47. package/dist/visual/controls.css +282 -283
  48. package/dist/visual/diagnostics.d.ts +13 -11
  49. package/dist/visual/diagnostics.js +11 -9
  50. package/dist/visual/domid.d.ts +13 -0
  51. package/dist/visual/domid.js +16 -0
  52. package/dist/visual/hold.d.ts +17 -0
  53. package/dist/visual/hold.js +39 -0
  54. package/dist/visual/index.js +1 -1
  55. package/dist/visual/leaves.d.ts +9 -7
  56. package/dist/visual/links.d.ts +5 -2
  57. package/dist/visual/links.js +9 -3
  58. package/dist/visual/motion.d.ts +9 -0
  59. package/dist/visual/motion.js +10 -2
  60. package/dist/visual/strings.d.ts +15 -0
  61. package/dist/visual/strings.js +3 -0
  62. package/dist/visual/structure.d.ts +32 -6
  63. package/dist/visual/structure.js +49 -3
  64. package/package.json +2 -2
  65. package/dist/core/codec/clipboard.d.ts +0 -59
  66. package/dist/core/codec/clipboard.js +0 -202
@@ -4,6 +4,14 @@
4
4
  only: a nested prose/array/object property renders a placeholder instead of
5
5
  recursing.
6
6
 
7
+ The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
8
+ an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
9
+ further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
10
+
11
+ One figure wherever it mounts: a variant's cells under their discriminant and an array
12
+ element's properties under its summary row sit at the depth a field-level subform sits
13
+ at, and the box above them states position rather than depth.
14
+
7
15
  A property's ghosted `default:` is the static schema `sub.default`, not the
8
16
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
9
17
  carries no per-property row (an object field resolves as one row whose value is
@@ -11,7 +19,10 @@
11
19
  -->
12
20
  <script lang="ts">
13
21
  import type { QuillFieldSchema } from '@quillmark/wasm';
14
- import { controlKind, humanize } from './structure.js';
22
+ import { controlKind, humanize, obliged } from './structure.js';
23
+ import { wording } from './strings.js';
24
+ import { propertyDomIds } from './domid.js';
25
+ import FieldLabel from './FieldLabel.svelte';
15
26
  import TextField from './TextField.svelte';
16
27
  import EnumField from './EnumField.svelte';
17
28
  import NumberField from './NumberField.svelte';
@@ -21,21 +32,42 @@
21
32
  interface Props {
22
33
  value: Record<string, unknown> | undefined;
23
34
  properties: Record<string, QuillFieldSchema> | undefined;
24
- /** Accessible-name prefix for the property controls. */
25
- label?: string;
26
35
  /** 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`. */
36
+ * `for` could reach, so the field's label names the set; each property carries
37
+ * its own `<label for>` inside it. */
29
38
  labelledBy?: string;
30
39
  /** The parked `description` (FieldLabel): announced on entering the group. */
31
40
  describedBy?: string;
41
+ /**
42
+ * The parent control's DOM id: the base each property's own three names derive
43
+ * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
44
+ * it — the properties fall back to `aria-label`, which names them without a
45
+ * `for` target to click.
46
+ */
47
+ idBase?: string;
48
+ /** Accessible-name prefix used only on the `aria-label` fallback above. */
49
+ label?: string;
32
50
  onCommit: (obj: Record<string, unknown>) => void;
33
51
  }
34
- let { value, properties, label, labelledBy, describedBy, onCommit }: Props = $props();
52
+ let { value, properties, labelledBy, describedBy, idBase, label, onCommit }: Props = $props();
35
53
 
54
+ const t = wording();
36
55
  const entries = $derived(Object.entries(properties ?? {}));
37
56
  const obj = $derived((value ?? {}) as Record<string, unknown>);
38
57
 
58
+ /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
59
+ * ({@link obliged}). `validate` anchors it per leaf, so a property with no
60
+ * `default:` is obliged in its own right and the container around it holds none
61
+ * (VISUAL_EDITOR §"Enum variants"). */
62
+ const required = obliged;
63
+
64
+ const title = (key: string, sub: QuillFieldSchema): string => sub.ui?.title ?? humanize(key);
65
+ /** The `aria-label` fallback, for a subform mounted without a field's id space:
66
+ * the field's name and the property's, since nothing else names the control. */
67
+ const fallbackName = (key: string, sub: QuillFieldSchema): string =>
68
+ `${label != null ? `${label} ` : ''}${title(key, sub)}` +
69
+ (required(sub) ? ` ${t.strings.fieldRequired}` : '');
70
+
39
71
  /** Take the caret: the first property's control, a subform having no single control
40
72
  * of its own to land on. Resolved off the DOM rather than a ref per property: every
41
73
  * property is a scalar control the DOM already knows how to focus (the date field's
@@ -43,10 +75,19 @@
43
75
  * handle each would be five refs restating document order. An empty or wholly
44
76
  * unsupported subform lands nothing. */
45
77
  let rootEl = $state<HTMLElement | undefined>();
78
+ const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
46
79
  export function focus(): void {
47
- rootEl
48
- ?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
49
- ?.focus();
80
+ rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
81
+ }
82
+ /** A label click for the one property `for` cannot reach: the date control, whose
83
+ * focus lives on a segment. Scoped to that property's own cell, so the click lands
84
+ * where the label is rather than on the subform's first control. */
85
+ function focusProp(key: string): void {
86
+ // Matched on the dataset: a key is the schema's own string, and spelling one into
87
+ // a selector needs `CSS.escape`, which the test DOM does not carry.
88
+ for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
89
+ if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
90
+ }
50
91
  }
51
92
 
52
93
  function commitProp(key: string, v: unknown): void {
@@ -72,12 +113,31 @@
72
113
  >
73
114
  {#each entries as [key, sub] (key)}
74
115
  {@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>
116
+ {@const ids = idBase ? propertyDomIds(idBase, key) : undefined}
117
+ {@const named = ids ? undefined : fallbackName(key, sub)}
118
+ {@const describes = sub.description && ids ? ids.description : undefined}
119
+ <!-- `for` reaches the four labelable controls; the date field's focus lives on a
120
+ segment, so it takes the click handoff instead, exactly as `Field` does one
121
+ level up. -->
122
+ {@const labelable =
123
+ kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
124
+ <div class="qm-object-prop" data-qm-prop={key}>
125
+ {#if ids}
126
+ <FieldLabel
127
+ label={title(key, sub)}
128
+ controlId={labelable ? ids.control : undefined}
129
+ id={ids.label}
130
+ descriptionId={describes}
131
+ onActivate={labelable ? undefined : () => focusProp(key)}
132
+ required={required(sub)}
133
+ description={sub.description}
134
+ />
135
+ {/if}
78
136
  {#if kind === 'enum'}
79
137
  <EnumField
80
- label={propLabel}
138
+ label={named}
139
+ id={ids?.control}
140
+ describedBy={describes}
81
141
  value={obj[key] as string | undefined}
82
142
  values={sub.values ?? []}
83
143
  fallback={sub.default as string | undefined}
@@ -85,7 +145,9 @@
85
145
  />
86
146
  {:else if kind === 'number'}
87
147
  <NumberField
88
- label={propLabel}
148
+ label={named}
149
+ id={ids?.control}
150
+ describedBy={describes}
89
151
  value={obj[key] as number | undefined}
90
152
  integer={sub.type === 'integer'}
91
153
  fallback={sub.default as number | undefined}
@@ -93,54 +155,85 @@
93
155
  />
94
156
  {:else if kind === 'boolean'}
95
157
  <BooleanField
96
- label={propLabel}
158
+ label={named}
159
+ id={ids?.control}
160
+ describedBy={describes}
97
161
  value={obj[key] as boolean | undefined}
98
162
  fallback={sub.default as boolean | undefined}
99
163
  onCommit={(v) => commitProp(key, v)}
100
164
  />
101
165
  {:else if kind === 'date'}
102
166
  <DateField
103
- label={propLabel}
167
+ label={named}
168
+ labelledBy={ids?.label}
169
+ describedBy={describes}
104
170
  value={obj[key] as string | undefined}
105
171
  onCommit={(v) => commitProp(key, v)}
106
172
  />
107
173
  {:else if kind === 'text'}
108
174
  <TextField
109
- label={propLabel}
175
+ label={named}
176
+ id={ids?.control}
177
+ describedBy={describes}
110
178
  value={obj[key] as string | undefined}
111
179
  placeholder={sub.default != null ? String(sub.default) : undefined}
112
180
  onCommit={(v) => commitProp(key, v)}
113
181
  />
114
182
  {:else}
115
- <span class="qm-unsupported">({kind} property — not editable in V1)</span>
183
+ <!-- A property the subform does not recurse into. What it says is what the
184
+ user can do about it, which is edit the field from the source surface;
185
+ a version number is the roadmap talking to somebody filling in a form. -->
186
+ <span class="qm-unsupported">{t.strings.nestedUnsupported(kind)}</span>
116
187
  {/if}
117
188
  </div>
118
189
  {/each}
119
190
  </div>
120
191
 
121
192
  <style>
193
+ /* The vertical and the rung between it and the properties, `--_qm-nest` being the step
194
+ the card and the section hold too.
195
+
196
+ `--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
197
+ (ARCHITECTURE §"A plane is a tone"), the one the card's edge and the section's
198
+ vertical read. `faint` separates without structuring — a table's interior lines
199
+ under a frame that is doing the structuring — which is the other job.
200
+
201
+ `padding-block` is the stroke's end caps, the same rung and the same reason as a
202
+ section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
203
+ the label or the box above it is still that stacker's own.
204
+
205
+ The tracks are the section's count over the subform's own width — `--cols` inherits,
206
+ so the container query stepping the section's capacity steps this with it, and there
207
+ is no second query container and nothing measured. The edges are not the section's:
208
+ the inset is the nesting, and a cell lining up with the field above it would claim a
209
+ depth it is not at. */
122
210
  .qm-object {
123
- display: flex;
124
- flex-direction: column;
211
+ display: grid;
212
+ grid-template-columns: repeat(var(--cols), 1fr);
125
213
  gap: var(--_qm-space-2);
126
- border-left: var(--_qm-border-width) solid var(--_qm-border);
127
- padding-left: var(--_qm-space-2);
214
+ border-inline-start: var(--_qm-border-width) solid var(--_qm-border);
215
+ padding-inline-start: var(--_qm-nest);
216
+ padding-block: var(--_qm-space);
128
217
  }
218
+ /* A property measures like a field: two tracks over the subform's own rows, so a
219
+ label wrapping in one column leaves its control on the row's baseline rather than a
220
+ line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
221
+ tracks are this grid's own; the section's belong to the fields.
222
+
223
+ `min-width: 0` because a property overflowing its `1fr` grows the track, and the
224
+ subform with it, past the field that holds it. */
129
225
  .qm-object-prop {
130
- display: flex;
131
- flex-direction: column;
132
- gap: var(--_qm-space-half);
226
+ display: grid;
227
+ grid-row: span 2;
228
+ grid-template-rows: subgrid;
229
+ row-gap: var(--_qm-space-half);
230
+ align-items: start;
231
+ min-width: 0;
133
232
  }
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);
233
+ /* A subform of one takes half the capacity, the way a packable run of one does
234
+ (`.qm-field.lone`): at full capacity a single track reads as truncated. */
235
+ .qm-object-prop:only-child {
236
+ grid-column: span var(--cols-half);
144
237
  }
145
238
  .qm-unsupported {
146
239
  font-size: var(--_qm-text-body);
@@ -2,14 +2,21 @@ 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;
13
20
  onCommit: (obj: Record<string, unknown>) => void;
14
21
  }
15
22
  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;
@@ -72,8 +72,15 @@
72
72
  edge under the character that opened the menu, and stays there as the query is
73
73
  typed. Zero offset, because a gap reads as a surface floating near the caret
74
74
  rather than one growing out of it. -->
75
+ <!-- The boundary is the root the surface portals into, the box that clips it where
76
+ the consumer scrolls one ({@link FormatPopover} states it in full). It is also
77
+ what keeps the row scroll above inside the list: `nearest` scrolls any ancestor
78
+ the row is not visible in, and a surface raised into room the pane clips leaves
79
+ the pane one of them — so the scroll moves the pane, and with it the caret this
80
+ menu is anchored to. -->
75
81
  <Popover.Content
76
82
  customAnchor={menu?.anchor ?? null}
83
+ collisionBoundary={portalTarget ?? []}
77
84
  side="bottom"
78
85
  align="start"
79
86
  trapFocus={false}
@@ -97,27 +97,34 @@
97
97
  paints over the rest. The two read as one block, which is what document-level
98
98
  guidance is: the main card's, not a card of its own. Still in-flow, no lift.
99
99
 
100
- It takes the card's hairline, and the tuck makes the two outlines one: the slip's top
101
- stroke and the tops of its sides run under `main`, so what shows starts at the card's
102
- bottom corners.
100
+ No stroke of its own. A tint below the card reads as a slip; the same tint inside an
101
+ outline reads as a second card, which is the one thing the tuck exists to deny. What
102
+ shows below the seam is fill, and `main`'s hairline is the pair's only edge.
103
103
 
104
104
  The bottom corners take the inner rung where the card above takes the outer: a slip
105
105
  is cut crisper than the thing it is tucked under, at every setting of the dial.
106
106
 
107
- The insets are asymmetric so the ink is not: the top takes the tuck back, and the
108
- bottom gives up a rung to the foot's control, whose box stands on the tap floor
109
- rather than on its own one-line label. The horizontal is the card's inset plus the
110
- body's margin (ProseField), so the tip starts under the prose rather than under the
111
- fields. */
107
+ Down, the insets are the document's own rhythm and they are measured to the ink, not
108
+ to the boxes. The top is a block gap below the seam, what two of the body's paragraphs
109
+ take, so the tip reads as the block after the prose; the bottom is the card's inset
110
+ rung; and the foot sits at half of either, being the message's own foot rather than
111
+ the next thing after it. The control gives its tap floor back as margin for that to
112
+ hold: a box standing on the floor rather than on its one-line label carries a rung of
113
+ slack at each edge, and spent inside the gap that is enough to make the tightest space
114
+ in the card measure as the widest.
115
+
116
+ Across, the inset is `main`'s stroke and the two nested insets under it, the card's
117
+ own and the prose control's, so the tip starts on the prose's edge rather than short
118
+ of it. */
112
119
  .qm-tips {
113
120
  margin-top: calc(var(--_qm-radius) * -1);
114
- border: var(--_qm-border-width) solid var(--_qm-border);
115
121
  border-radius: 0 0 var(--_qm-radius-inner) var(--_qm-radius-inner);
116
- padding: calc(var(--_qm-space-3) + var(--_qm-radius)) var(--_qm-space-5) var(--_qm-space-2);
122
+ padding: calc(var(--_qm-space-3) + var(--_qm-radius))
123
+ calc(var(--_qm-border-width) + var(--_qm-space-3) * 2) var(--_qm-space-3);
117
124
  background: var(--_qm-tips-surface);
118
125
  display: flex;
119
126
  flex-direction: column;
120
- gap: var(--_qm-space-2);
127
+ gap: var(--_qm-space);
121
128
  font-family: var(--_qm-tips-font);
122
129
  font-size: var(--_qm-text-body);
123
130
  line-height: var(--_qm-leading-body);
@@ -168,7 +175,11 @@
168
175
  the spacing one — the foot's button clears the floor and walks the whole set from
169
176
  the tip the card opens on, so no tip is reachable only by a dot. State is three
170
177
  steps of one ink on the recede ladder, never a second hue and never a size: a dot
171
- that grew would move the row it sits in. */
178
+ that grew would move the row it sits in.
179
+
180
+ `color` is restated because a button takes the UA's and not its parent's, so
181
+ `currentColor` is the card's ink only once it is inherited; left to the UA it is
182
+ `buttontext`, which is the one neutral on a card that carries no neutrals. */
172
183
  .qm-tips-dot {
173
184
  box-sizing: content-box;
174
185
  width: 5px;
@@ -176,6 +187,7 @@
176
187
  padding: var(--_qm-space-half);
177
188
  border: none;
178
189
  border-radius: var(--_qm-radius-pill);
190
+ color: inherit;
179
191
  background: currentColor;
180
192
  background-clip: content-box;
181
193
  opacity: var(--_qm-opacity-idle);
@@ -192,8 +204,16 @@
192
204
  overrides is everything the family sets for a glyph among callers of its own
193
205
  colour, a labelled control on a warm card wanting none of it. The hover included:
194
206
  the family's neutral step would read as lent from the document above. Unlayered,
195
- so it beats the base rule without out-specifying it. */
207
+ so it beats the base rule without out-specifying it.
208
+
209
+ The box is the tap target and the label is what the rhythm measures, so both axes
210
+ give the difference back as margin: the inline rung lands the label on the body's
211
+ edge above, and the block rung takes the floor's slack out of the foot's height.
212
+ The box, and the hover it paints, still reaches past the label the way a control
213
+ does. */
196
214
  .qm-tips-action {
215
+ margin-block: calc(var(--_qm-space) * -1);
216
+ margin-right: calc(var(--_qm-space-2) * -1);
197
217
  padding: var(--_qm-space-half) var(--_qm-space-2);
198
218
  font-family: inherit;
199
219
  font-size: inherit;
@@ -0,0 +1,95 @@
1
+ <!--
2
+ An `enum` declaring `variants:` → the discriminant's select, and under it the cells
3
+ the chosen world brings into play (VISUAL_EDITOR §"Enum variants"). The field rests as
4
+ a container, `{value: <member>, …that member's fields}`, and commits whole: it is one
5
+ cell to `Addr`, so there is no per-cell write address.
6
+
7
+ The cells are {@link ObjectField}, which already draws a scalar field set keyed by
8
+ name over a container its parent commits by value. It takes the whole container as its
9
+ `value` and the world's declaration as its `properties`, so what it hands back is the
10
+ container with one cell written — and cells outside the drawn world ride through
11
+ untouched rather than needing to be merged back.
12
+ -->
13
+ <script lang="ts">
14
+ import type { QuillFieldSchema } from '@quillmark/wasm';
15
+ import EnumField from './EnumField.svelte';
16
+ import ObjectField from './ObjectField.svelte';
17
+ import {
18
+ VARIANT_DISCRIMINANT,
19
+ commitDiscriminant,
20
+ variantCells,
21
+ variantMember
22
+ } from './structure.js';
23
+
24
+ interface Props {
25
+ /** The stored container, or undefined while the field is unset. */
26
+ value: Record<string, unknown> | undefined;
27
+ schema: QuillFieldSchema;
28
+ /** The resolved `default:`'s member: what an unset field renders as. */
29
+ ghostMember: string | undefined;
30
+ /** Field label, the naming prefix the cells' controls compose with. */
31
+ label: string;
32
+ /** `<label for>` target: the discriminant trigger, this control's one labelable
33
+ * element. The cells are named by their own composed labels. */
34
+ id?: string;
35
+ labelledBy?: string;
36
+ describedBy?: string;
37
+ onCommit: (v: Record<string, unknown> | undefined) => void;
38
+ optionAllowed?: (value: string) => boolean;
39
+ enumDisallowed?: 'hide' | 'disable';
40
+ }
41
+ let {
42
+ value,
43
+ schema,
44
+ ghostMember,
45
+ label,
46
+ id,
47
+ labelledBy,
48
+ describedBy,
49
+ onCommit,
50
+ optionAllowed,
51
+ enumDisallowed
52
+ }: Props = $props();
53
+
54
+ const member = $derived(variantMember(value, ghostMember));
55
+ const cells = $derived(variantCells(schema, member));
56
+ const discriminant = $derived(value?.[VARIANT_DISCRIMINANT] as string | undefined);
57
+ </script>
58
+
59
+ <div class="qm-variant">
60
+ <EnumField
61
+ value={discriminant}
62
+ values={schema.values ?? []}
63
+ fallback={ghostMember}
64
+ {id}
65
+ {describedBy}
66
+ onCommit={(v) => onCommit(commitDiscriminant(value, v))}
67
+ {optionAllowed}
68
+ {enumDisallowed}
69
+ />
70
+ <!-- Keyed on the member so a flip remounts the cells rather than re-targeting
71
+ them: two worlds' cells are different fields that happen to occupy one place,
72
+ and a control carrying local state (an unreconciled pick, a caret) across the
73
+ flip would carry it between them. -->
74
+ {#if cells}
75
+ {#key member}
76
+ <ObjectField
77
+ value={value ?? {}}
78
+ properties={cells}
79
+ {label}
80
+ idBase={id}
81
+ {labelledBy}
82
+ {describedBy}
83
+ onCommit={(obj) => onCommit(obj)}
84
+ />
85
+ {/key}
86
+ {/if}
87
+ </div>
88
+
89
+ <style>
90
+ .qm-variant {
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: var(--_qm-space-2);
94
+ }
95
+ </style>
@@ -0,0 +1,21 @@
1
+ import type { QuillFieldSchema } from '@quillmark/wasm';
2
+ interface Props {
3
+ /** The stored container, or undefined while the field is unset. */
4
+ value: Record<string, unknown> | undefined;
5
+ schema: QuillFieldSchema;
6
+ /** The resolved `default:`'s member: what an unset field renders as. */
7
+ ghostMember: string | undefined;
8
+ /** Field label, the naming prefix the cells' controls compose with. */
9
+ label: string;
10
+ /** `<label for>` target: the discriminant trigger, this control's one labelable
11
+ * element. The cells are named by their own composed labels. */
12
+ id?: string;
13
+ labelledBy?: string;
14
+ describedBy?: string;
15
+ onCommit: (v: Record<string, unknown> | undefined) => void;
16
+ optionAllowed?: (value: string) => boolean;
17
+ enumDisallowed?: 'hide' | 'disable';
18
+ }
19
+ declare const VariantField: import("svelte").Component<Props, {}, "">;
20
+ type VariantField = ReturnType<typeof VariantField>;
21
+ export default VariantField;