@quillmark/svelte 0.12.0 → 0.13.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 (42) hide show
  1. package/dist/core/codec/decode.d.ts +17 -0
  2. package/dist/core/codec/decode.js +57 -4
  3. package/dist/core/codec/field.d.ts +64 -15
  4. package/dist/core/codec/field.js +201 -65
  5. package/dist/core/codec/index.d.ts +3 -3
  6. package/dist/core/codec/index.js +3 -3
  7. package/dist/core/codec/keymap.d.ts +5 -4
  8. package/dist/core/codec/keymap.js +5 -4
  9. package/dist/core/codec/prose.css +42 -0
  10. package/dist/core/codec/schema.d.ts +24 -5
  11. package/dist/core/codec/schema.js +40 -9
  12. package/dist/core/errors.d.ts +3 -2
  13. package/dist/core/theme.css +9 -6
  14. package/dist/preview/paint.js +21 -17
  15. package/dist/visual/ArrayField.svelte +66 -22
  16. package/dist/visual/ArrayField.svelte.d.ts +2 -0
  17. package/dist/visual/BooleanField.svelte +28 -6
  18. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  19. package/dist/visual/Card.svelte +3 -3
  20. package/dist/visual/DateField.svelte +66 -31
  21. package/dist/visual/EnumField.svelte +27 -9
  22. package/dist/visual/EnumField.svelte.d.ts +5 -0
  23. package/dist/visual/Field.svelte +45 -10
  24. package/dist/visual/FormatPopover.svelte +4 -3
  25. package/dist/visual/MatrixField.svelte +3 -2
  26. package/dist/visual/NumberField.svelte +31 -14
  27. package/dist/visual/NumberField.svelte.d.ts +5 -1
  28. package/dist/visual/ObjectField.svelte +62 -15
  29. package/dist/visual/ProseField.svelte +53 -23
  30. package/dist/visual/ProseField.svelte.d.ts +15 -3
  31. package/dist/visual/ProseValue.svelte +102 -46
  32. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  33. package/dist/visual/TextField.svelte +35 -18
  34. package/dist/visual/TextField.svelte.d.ts +7 -0
  35. package/dist/visual/VariantField.svelte +1 -0
  36. package/dist/visual/VisualEditorInner.svelte +24 -24
  37. package/dist/visual/controls.css +16 -5
  38. package/dist/visual/strings.d.ts +8 -1
  39. package/dist/visual/strings.js +2 -0
  40. package/dist/visual/structure.d.ts +77 -43
  41. package/dist/visual/structure.js +132 -57
  42. package/package.json +2 -2
@@ -23,16 +23,21 @@
23
23
  make every reconcile fire and re-render all seven segments on each commit.
24
24
 
25
25
  The ghost is the default's digits, not a format hint. An unset field
26
- carrying a `default:` prints the default's digits in the segments, ghost-toned,
27
- instead of the primitive's `mm`/`dd`/`yyyy` hints, which say "empty" where the
28
- rung says "will render 2026-01-01". The ghost is painted in the segment snippet,
29
- over an unset primitive: substituting the default for `value` instead would make
30
- the field indistinguishable from an authored one to every path that reads it
31
- (`areAllSegmentsFilled`, Backspace, the hidden input), and the primitive shadows
32
- a written-back `value` prop it was not `bind:`-ed to, so re-seating the ghost
33
- after a clear never lands. `placeholder` (the `DateValue` the segments count
34
- from, never one they display) carries the default too, so arrowing an empty
35
- segment starts at the render's date rather than today's.
26
+ carrying a `default:` prints the default's digits in the segments at the default
27
+ rung (theme.css), instead of the primitive's `mm`/`dd`/`yyyy` hints, which say
28
+ "empty" where the rung says "will render 2026-01-01". At rest the ghost is painted
29
+ in the segment snippet, over an unset primitive: a default held as its value reads
30
+ as filled to every path that reads the primitive (`areAllSegmentsFilled`,
31
+ Backspace, the hidden input), and whether it stands as the value is the field's to
32
+ say only once the user enters it. `placeholder`
33
+ (the `DateValue` the segments count from, never one they display) carries the
34
+ default too, so arrowing an empty segment starts at the render's date rather than
35
+ today's.
36
+
37
+ Entering an unset field seats the default as the primitive's value, still at the
38
+ default rung, so an edit to one segment commits a whole date with the others the
39
+ default's. Leaving without an edit unseats it, and nothing is written; so does
40
+ clearing a segment.
36
41
  -->
37
42
  <script lang="ts">
38
43
  import { DateField as BitsDateField } from 'bits-ui';
@@ -85,25 +90,42 @@
85
90
  }
86
91
  }
87
92
 
93
+ const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
94
+ /** The default is the primitive's value: from entering an unset field until an
95
+ * edit, a cleared segment or leaving. Part of the projection, so a reconcile
96
+ * neither drops a seat nor restores one over the segments a clear left. */
97
+ let seat = $state(false);
88
98
  // Local value synced to `value` as a string (see the identity note above);
89
99
  // own-edits stay local, only an external change reconciles back in. Driven
90
100
  // controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
91
101
  // the package's.
92
- const local = syncedLocal(() => value?.slice(0, 10) ?? '');
102
+ const local = syncedLocal(
103
+ () => value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
104
+ );
93
105
  const parsed = $derived(toDateValue(local.value));
94
106
  // The date the empty segments ghost, or undefined when there is nothing to ghost:
95
107
  // the field is unset and the default has a date form. A non-blank local that
96
108
  // fails to parse is AUTHORED-but-malformed, which the empty field states
97
109
  // honestly: the ghost would claim it unset. Held as the parsed value rather than
98
110
  // a boolean so the substitution below narrows on the one fact it needs.
99
- const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
100
111
  const ghost = $derived(local.value === '' ? fallbackDate : undefined);
112
+ const seated = $derived(value == null && seat && local.value !== '');
101
113
 
102
- // What one segment prints, and whether that text is shown-never-written.
114
+ /** Focus moving between segments is neither an entry nor a leaving. */
115
+ const within = (e: FocusEvent): boolean => !!wrapEl?.contains(e.relatedTarget as Node | null);
116
+ function enter(e: FocusEvent): void {
117
+ if (!within(e) && value == null && fallbackDate) seat = true;
118
+ }
119
+ function leave(e: FocusEvent): void {
120
+ if (!within(e)) seat = false;
121
+ }
122
+
123
+ // What one segment prints, and which rung it takes.
103
124
  //
104
125
  // An unfilled segment is always shown-never-written, whether it prints the
105
- // default's digits or the primitive's own `mm`/`dd`/`yyyy` hint: both state
106
- // "nothing authored here". Only an unfilled segment ghosts: a half-entered date
126
+ // default's digits (`default`, the rung of what prints) or the primitive's own
127
+ // `mm`/`dd`/`yyyy` hint (`hint`, a word about the value): both state "nothing
128
+ // authored here". Only an unfilled segment ghosts: a half-entered date
107
129
  // holds digits while the value is still undefined (one unfilled segment unsets
108
130
  // the whole field), so ghosting unconditionally would paint over, and dim, the
109
131
  // digits just typed. The segment's own text is the tell: its unfilled hint is
@@ -113,30 +135,36 @@
113
135
  // The separators are `literal` parts, never unfilled; substitution covers the
114
136
  // date parts only, so any time part keeps the primitive's text. Digits are
115
137
  // zero-padded to the segment widths the field displays.
116
- function segmentText(part: string, text: string): { text: string; ghosted: boolean } {
117
- if (part === 'literal' || /\d/.test(text)) return { text, ghosted: false };
118
- if (!ghost) return { text, ghosted: true };
138
+ type Rung = 'default' | 'hint' | undefined;
139
+ function segmentText(part: string, text: string): { text: string; ghosted: Rung } {
140
+ if (part === 'literal' || /\d/.test(text)) return { text, ghosted: undefined };
141
+ if (!ghost) return { text, ghosted: 'hint' };
119
142
  switch (part) {
120
143
  case 'year':
121
- return { text: String(ghost.year).padStart(4, '0'), ghosted: true };
144
+ return { text: String(ghost.year).padStart(4, '0'), ghosted: 'default' };
122
145
  case 'month':
123
- return { text: String(ghost.month).padStart(2, '0'), ghosted: true };
146
+ return { text: String(ghost.month).padStart(2, '0'), ghosted: 'default' };
124
147
  case 'day':
125
- return { text: String(ghost.day).padStart(2, '0'), ghosted: true };
148
+ return { text: String(ghost.day).padStart(2, '0'), ghosted: 'default' };
126
149
  default:
127
- return { text, ghosted: true };
150
+ return { text, ghosted: 'hint' };
128
151
  }
129
152
  }
130
153
  </script>
131
154
 
132
- <span class="qm-date-wrap" bind:this={wrapEl}>
155
+ <span class="qm-date-wrap" bind:this={wrapEl} onfocusin={enter} onfocusout={leave}>
133
156
  <BitsDateField.Root
134
157
  value={parsed}
135
158
  placeholder={fallbackDate}
136
159
  onValueChange={(d) => {
137
160
  // `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
138
- // half-typed field yields undefined: the unset rung.
139
- local.value = d?.toString() ?? '';
161
+ // half-typed field yields undefined: the unset rung. The primitive reports
162
+ // its own value again as a segment loses focus, which is no edit, and over a
163
+ // seated default would write it.
164
+ const next = d?.toString() ?? '';
165
+ if (next === local.value) return;
166
+ local.value = next;
167
+ if (!d) seat = false;
140
168
  onCommit(d?.toString());
141
169
  }}
142
170
  >
@@ -151,6 +179,7 @@
151
179
  aria-labelledby={labelledBy}
152
180
  aria-describedby={describedBy}
153
181
  data-ghosted={ghost ? '' : undefined}
182
+ data-default={seated ? '' : undefined}
154
183
  >
155
184
  {#snippet children({ segments })}
156
185
  <!-- Keyed by index: `part` repeats (the `literal` separators between
@@ -160,7 +189,7 @@
160
189
  <BitsDateField.Segment
161
190
  class="qm-date-segment"
162
191
  part={seg.part}
163
- data-ghosted={shown.ghosted ? '' : undefined}
192
+ data-ghosted={shown.ghosted}
164
193
  >
165
194
  {shown.text}
166
195
  </BitsDateField.Segment>
@@ -193,11 +222,17 @@
193
222
  .qm-date-wrap :global(.qm-date-segment:focus) {
194
223
  background: var(--_qm-surface-hover);
195
224
  }
196
- /* An unfilled segment is ghost-toned whatever it prints: the resolved `default:`
197
- when there is one to ghost, the `dd`/`mm`/`yyyy` hint when there is not. Shown,
198
- never written, either way. The marker is the component's own (see the snippet):
199
- the date primitive emits no placeholder attribute of its own. */
200
- .qm-date-wrap :global(.qm-date-segment[data-ghosted]) {
225
+ /* An unfilled segment is ghost-toned whatever it prints, at the rung of what it
226
+ prints: the resolved `default:`'s digits at the default rung, the `dd`/`mm`/`yyyy`
227
+ hint at the label rung. Shown, never written, either way. The marker is the
228
+ component's own (see the snippet): the date primitive emits no placeholder
229
+ attribute of its own. A seated default is the value and still unwritten, so its
230
+ filled segments take the default rung too. */
231
+ .qm-date-wrap :global(.qm-date-segment[data-ghosted='hint']) {
201
232
  color: var(--_qm-ink-label);
202
233
  }
234
+ .qm-date-wrap :global(.qm-date-segment[data-ghosted='default']),
235
+ .qm-date-wrap :global(.qm-date[data-default] .qm-date-segment) {
236
+ color: var(--_qm-ink-default);
237
+ }
203
238
  </style>
@@ -25,6 +25,11 @@
25
25
  value: string | undefined;
26
26
  values: string[];
27
27
  fallback?: string;
28
+ /** The schema's `ui.blank_title`: what the blank draws as, in place of the em dash. */
29
+ blankTitle?: string;
30
+ /** An `enum?` cell, which prints `none` unset rather than its blank, and so ghosts
31
+ * `strings.optionalGhost` there. */
32
+ optional?: boolean;
28
33
  /** Accessible name for a trigger nothing else names: an object property, whose
29
34
  * name is the field label plus the property's. A field's own trigger takes `id`
30
35
  * instead and is named by the `<label for>` beside it (the trigger is a
@@ -47,6 +52,8 @@
47
52
  value,
48
53
  values,
49
54
  fallback,
55
+ blankTitle,
56
+ optional = false,
50
57
  label,
51
58
  id,
52
59
  describedBy,
@@ -66,11 +73,15 @@
66
73
  // the package's rather than the primitive's.
67
74
  const local = syncedLocal(() => value ?? UNSET);
68
75
 
69
- /** An empty enum member has no glyph of its own; an em dash stands in for it. */
70
- const dash = (v: string | undefined) => v || '—';
76
+ /** The blank has no glyph of its own: the quill's `ui.blank_title` stands in for
77
+ * it, else an em dash. */
78
+ const dash = (v: string | undefined) => v || blankTitle || '—';
71
79
 
72
80
  const unset = $derived(local.value === UNSET);
73
- const ghostText = $derived(dash(fallback));
81
+ /** The ghost is a member the render prints, rather than a word for the blank or for
82
+ * `none`: it draws at the default rung (theme.css). */
83
+ const ghostsMember = $derived(!optional && !!fallback);
84
+ const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
74
85
  /** What the closed trigger shows: the pick, or the ghosted default while unset. */
75
86
  const shown = $derived(unset ? ghostText : dash(local.value));
76
87
 
@@ -102,7 +113,7 @@
102
113
  {id}
103
114
  aria-label={id ? undefined : label}
104
115
  aria-describedby={describedBy}
105
- data-ghosted={unset ? '' : undefined}
116
+ data-ghosted={unset ? (ghostsMember ? 'default' : '') : undefined}
106
117
  >
107
118
  <span class="qm-select-shown">{shown}</span>
108
119
  <Icon name="chevron-down" class="qm-select-chevron" size={14} />
@@ -125,13 +136,17 @@
125
136
  and while the field is unset this is the selected row, so the weight
126
137
  step pulls the other way. It rides the accessible name for the same
127
138
  reason. -->
139
+ <!-- An optional cell's row is untagged: it ghosts no default, and `None`
140
+ names no member to tell it from. -->
128
141
  <Select.Item
129
142
  class="qm-menu-item qm-select-item qm-select-unset"
130
143
  value={UNSET}
131
- label={`${ghostText} ${t.strings.enumUnsetTag}`}
144
+ label={optional ? ghostText : `${ghostText} ${t.strings.enumUnsetTag}`}
132
145
  >
133
146
  <span class="qm-select-ghost">{ghostText}</span>
134
- <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
147
+ {#if !optional}
148
+ <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
149
+ {/if}
135
150
  </Select.Item>
136
151
  <!-- A refused option still draws under `'disable'`, and under either
137
152
  policy when it is the one selected: a listbox whose selected value
@@ -192,11 +207,14 @@
192
207
  flex-shrink: 0;
193
208
  }
194
209
  /* The focus ring rides `.qm-focus-ring` on the trigger (controls.css). */
195
- /* Shown-never-written: the closed control reads muted while unset, matching the
196
- ghosted placeholder the text/number controls show. */
210
+ /* Shown-never-written: the closed control reads muted while unset, at the rung of
211
+ what it shows — the default member the render prints, or a word for the blank. */
197
212
  .qm-select-wrap :global(.qm-select[data-ghosted]) {
198
213
  color: var(--_qm-ink-label);
199
214
  }
215
+ .qm-select-wrap :global(.qm-select[data-ghosted='default']) {
216
+ color: var(--_qm-ink-default);
217
+ }
200
218
  /* The open list is `.qm-menu-surface` and its rows `.qm-menu-item` (controls.css):
201
219
  the lift, the inset and the highlight are the shared menu recipe. What a listbox
202
220
  adds over a menu is here: it spans its trigger rather than its own content, it
@@ -229,7 +247,7 @@
229
247
  /* A step down the size ramp, which is what says this is chrome and the word beside
230
248
  it is the value. The ink is the ghost's own: a second tone here would rank the
231
249
  tag against the thing it annotates, and there is no rung under `label` to take
232
- (theme.css, "Two rungs, and there is no third"). */
250
+ (theme.css, "nothing under the last"). */
233
251
  .qm-select-tag {
234
252
  flex-shrink: 0;
235
253
  font-size: var(--_qm-text-label);
@@ -3,6 +3,11 @@ interface Props {
3
3
  value: string | undefined;
4
4
  values: string[];
5
5
  fallback?: string;
6
+ /** The schema's `ui.blank_title`: what the blank draws as, in place of the em dash. */
7
+ blankTitle?: string;
8
+ /** An `enum?` cell, which prints `none` unset rather than its blank, and so ghosts
9
+ * `strings.optionalGhost` there. */
10
+ optional?: boolean;
6
11
  /** Accessible name for a trigger nothing else names: an object property, whose
7
12
  * name is the field label plus the property's. A field's own trigger takes `id`
8
13
  * instead and is named by the `<label for>` beside it (the trigger is a
@@ -13,7 +13,8 @@
13
13
  `diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
14
14
  `diagByKey`, merging `quill.validate`, local commit errors, and the external
15
15
  `diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
16
- `DiagnosticList`, non-gating.
16
+ `DiagnosticList`, non-gating; each set also re-evaluates a prose leaf's hold
17
+ (`ProseField`).
17
18
  -->
18
19
  <script lang="ts">
19
20
  import type {
@@ -31,10 +32,16 @@
31
32
  import {
32
33
  VARIANT_DISCRIMINANT,
33
34
  arrayLayout,
35
+ baseType,
36
+ exampleGhost,
34
37
  ghostDefault,
35
38
  isContainer,
36
- stringifyGhost
39
+ optionalCell,
40
+ printedText,
41
+ stringifyGhost,
42
+ titleText
37
43
  } from './structure.js';
44
+ import { wording } from './strings.js';
38
45
  import { deepen } from './diagnostics.js';
39
46
  import type { FieldDomIds } from './domid.js';
40
47
  import ProseField from './ProseField.svelte';
@@ -104,12 +111,29 @@
104
111
  diagnostics
105
112
  }: Props = $props();
106
113
 
107
- // The ghost the control shows when unset: the resolved `default:` (provenance,
114
+ const t = wording();
115
+
116
+ // What an unset field shows of its `default:`: the resolved value (provenance,
108
117
  // `source === 'default'`). `ghost` is the raw typed value (enum/number/boolean
109
- // fallbacks); `defaultStr` its string form: the text placeholder and the date
110
- // control's `YYYY-MM-DD`. An object-valued default does not ghost.
118
+ // fallbacks, a content leaf's `Content`), `defaultStr` its string form (the date
119
+ // control's `YYYY-MM-DD`); an object-valued default has none. An array draws its
120
+ // declared literal instead, which is what a take writes (`ArrayField`).
121
+ // A default that prints stands in the control as the value it is, at the default
122
+ // rung, and the first edit takes it (VISUAL_EDITOR §"The commitment ladder"). Where
123
+ // nothing prints, a control draws words: the `none` an optional cell prints, and
124
+ // a free-text field's `example:`.
111
125
  const ghost = $derived(ghostDefault(provenance));
112
- const defaultStr = $derived(stringifyGhost(ghost));
126
+ const defaultStr = $derived(
127
+ field.control === 'prose' ? titleText(ghost) || undefined : stringifyGhost(ghost)
128
+ );
129
+ const printed = $derived(printedText(defaultStr));
130
+ // A text or number control reads its default off the schema, not the resolved row:
131
+ // the row stops naming a default once the field is written, and emptying a written
132
+ // field still has to know that the default prints.
133
+ const declared = $derived(printedText(field.schema.default));
134
+ const optional = $derived(optionalCell(field.schema));
135
+ const none = $derived(optional && value == null ? t.strings.optionalGhost : undefined);
136
+ const example = $derived(value == null ? exampleGhost(field.schema) : undefined);
113
137
  // A variant resolves as one rung whose value is the whole container, so the
114
138
  // discriminant's ghost is that container's own discriminant cell.
115
139
  const ghostMember = $derived(
@@ -260,7 +284,10 @@
260
284
  {addr}
261
285
  inline={field.inline}
262
286
  plaintext={field.plaintext}
263
- placeholder={defaultStr}
287
+ fallback={printed ? (ghost as Content) : undefined}
288
+ placeholder={none}
289
+ placeholderUntilEdit
290
+ {example}
264
291
  labelledBy={domIds.label}
265
292
  {describedBy}
266
293
  {leafKey}
@@ -269,12 +296,15 @@
269
296
  {onChange}
270
297
  {onError}
271
298
  {leaves}
299
+ {diagnostics}
272
300
  />
273
301
  {:else if field.control === 'enum'}
274
302
  <EnumField
275
303
  value={value as string | undefined}
276
304
  values={field.schema.values ?? []}
277
305
  fallback={ghost as string | undefined}
306
+ blankTitle={field.schema.ui?.blank_title}
307
+ {optional}
278
308
  id={domIds.control}
279
309
  {describedBy}
280
310
  onCommit={onCommitScalar}
@@ -300,8 +330,9 @@
300
330
  {:else if field.control === 'number'}
301
331
  <NumberField
302
332
  value={value as number | undefined}
303
- integer={field.schema.type === 'integer'}
304
- fallback={ghost as number | undefined}
333
+ integer={baseType(field.schema) === 'integer'}
334
+ fallback={declared}
335
+ placeholder={none}
305
336
  id={domIds.control}
306
337
  {describedBy}
307
338
  onCommit={onCommitScalar}
@@ -310,6 +341,7 @@
310
341
  <BooleanField
311
342
  value={value as boolean | undefined}
312
343
  fallback={ghost as boolean | undefined}
344
+ {optional}
313
345
  id={domIds.control}
314
346
  {describedBy}
315
347
  onCommit={onCommitScalar}
@@ -327,6 +359,7 @@
327
359
  <ArrayField
328
360
  bind:this={arrayEl}
329
361
  value={value as unknown[] | undefined}
362
+ fallback={Array.isArray(field.schema.default) ? field.schema.default : undefined}
330
363
  items={field.schema.items}
331
364
  layout={arrayLayout(field.schema)}
332
365
  max={field.schema.max}
@@ -370,7 +403,9 @@
370
403
  {:else}
371
404
  <TextField
372
405
  value={value as string | undefined}
373
- placeholder={defaultStr}
406
+ fallback={declared}
407
+ placeholder={none}
408
+ {example}
374
409
  id={domIds.control}
375
410
  {describedBy}
376
411
  onCommit={onCommitScalar}
@@ -142,9 +142,10 @@
142
142
  const selection = view?.state.selection;
143
143
  const formattable = selection instanceof TextSelection && !selection.empty;
144
144
  // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
145
- // types (`plaintextSchema`), so this surface withholds itself over one entirely,
146
- // the way it never rises over the text input a `string` field draws: the value is
147
- // literal text, and a mark on it is a value the boundary refuses to coerce back.
145
+ // types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
146
+ // one entirely, the way it never rises over the text input a `string` field draws:
147
+ // the value is literal text, and a mark on it is a value the boundary refuses to
148
+ // coerce back.
148
149
  if (
149
150
  !view ||
150
151
  !view.hasFocus() ||
@@ -17,8 +17,9 @@
17
17
  Commit is whole: the sparse map, one `writer.set`. A tick writes the member object
18
18
  with an explicit `held`, an untick keeps the columns under `held: false`, and a member
19
19
  unheld with no columns leaves the map (`memberWrite`). What is blank per column is the
20
- engine's rule and not restated: a column control that clears drops its key, so the
21
- editor drops a member it has emptied rather than deciding skippability for itself.
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.
22
23
  The read takes both rest forms — a bare tick and `{held?, …columns}`, whose `held` rests
23
24
  at `false` where the mapping names none — and the write lands one, on the member edited
24
25
  alone; every other member rides through as the document spelled it.
@@ -4,10 +4,13 @@
4
4
  (`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
5
5
  committing it live flashes a coercion diagnostic + `console.error` on every
6
6
  intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
7
- region. A blank entry commits `undefined`: the unset rung of the
8
- commitment ladder: the parent removes the field and the engine renders the
9
- ghosted `default:`. Settling at `change` also keeps
10
- select-all-and-retype from flashing the preview through the default mid-keystroke.
7
+ region.
8
+
9
+ An unset field holds its resolved `default:` as its text, at the default rung
10
+ (theme.css), and an edit that settles writes it as authored, as `TextField` takes
11
+ its default. A number has no empty answer (canon `SCHEMAS.md` §"Native validation"),
12
+ so a blank entry commits `undefined`, the unset rung: the parent removes the field,
13
+ the engine renders the default, and the input holds it again.
11
14
 
12
15
  `type="text"`, not `type="number"`: a native number input sanitizes an
13
16
  invalid string to `""` before the DOM `value` setter even runs (verified:
@@ -26,7 +29,11 @@
26
29
  interface Props {
27
30
  value: number | undefined;
28
31
  integer?: boolean;
29
- fallback?: number;
32
+ /** The resolved `default:` as text: what an unset field holds. */
33
+ fallback?: string;
34
+ /** Words at rest about an unset field that holds no text: the `None` an optional
35
+ * cell prints. */
36
+ placeholder?: string;
30
37
  /** Accessible name for an input nothing else names: an object property, whose
31
38
  * name is the field label plus the property's. A field's own input takes `id`
32
39
  * instead and is named by the `<label for>` beside it. */
@@ -38,16 +45,25 @@
38
45
  describedBy?: string;
39
46
  onCommit: (v: number | string | undefined) => void;
40
47
  }
41
- let { value, integer, fallback, label, id, describedBy, onCommit }: Props = $props();
48
+ let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
42
49
 
43
- // Local input state synced to `value` (as a string projection); own-typing
44
- // stays local, only an external change reconciles back in (see `syncedLocal`).
45
- const local = syncedLocal(() => (value != null ? String(value) : ''));
50
+ // Local input state synced to `value` (as a string projection), or to the default
51
+ // an unset field holds; own-typing stays local, only an external change reconciles
52
+ // back in (see `syncedLocal`).
53
+ const local = syncedLocal(() => (value != null ? String(value) : (fallback ?? '')));
54
+ const defaulted = $derived(value == null && !!fallback && local.value === fallback);
46
55
 
47
56
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
48
- // `undefined` (the unset rung: parent removes the field, default renders).
49
- function commit(raw: string): void {
50
- if (raw.trim() === '') return void onCommit(undefined);
57
+ // `undefined`, and the input takes the default back: nothing else reconciles it, the
58
+ // projection having read the default throughout. Onto the element as well, since the
59
+ // attribute Svelte last wrote may already be the default, and it writes no repeat.
60
+ function commit(el: HTMLInputElement): void {
61
+ const raw = el.value;
62
+ if (raw.trim() === '') {
63
+ onCommit(undefined);
64
+ local.value = el.value = fallback ?? '';
65
+ return;
66
+ }
51
67
  // Number(), not parseFloat/parseInt: a prefix parse would silently commit
52
68
  // `14.5` for `14.5x` (and truncate `11.9` → 11 on integer fields) instead
53
69
  // of letting the boundary judge the full entry.
@@ -62,11 +78,12 @@
62
78
  inputmode={integer ? 'numeric' : 'decimal'}
63
79
  value={local.value}
64
80
  {id}
65
- placeholder={fallback != null ? String(fallback) : ''}
81
+ {placeholder}
82
+ data-default={defaulted ? '' : undefined}
66
83
  aria-label={id ? undefined : label}
67
84
  aria-describedby={describedBy}
68
85
  oninput={(e) => {
69
86
  local.value = (e.currentTarget as HTMLInputElement).value;
70
87
  }}
71
- onchange={(e) => commit((e.currentTarget as HTMLInputElement).value)}
88
+ onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
72
89
  />
@@ -2,7 +2,11 @@ import './controls.css';
2
2
  interface Props {
3
3
  value: number | undefined;
4
4
  integer?: boolean;
5
- fallback?: number;
5
+ /** The resolved `default:` as text: what an unset field holds. */
6
+ fallback?: string;
7
+ /** Words at rest about an unset field that holds no text: the `None` an optional
8
+ * cell prints. */
9
+ placeholder?: string;
6
10
  /** Accessible name for an input nothing else names: an object property, whose
7
11
  * name is the field label plus the property's. A field's own input takes `id`
8
12
  * instead and is named by the `<label for>` beside it. */