@quillmark/svelte 0.21.0 → 0.23.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 (69) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/dist/core/address.d.ts +6 -1
  4. package/dist/core/address.js +12 -3
  5. package/dist/core/bloom.d.ts +9 -4
  6. package/dist/core/bloom.js +18 -49
  7. package/dist/core/codec/atoms.d.ts +1 -1
  8. package/dist/core/codec/atoms.js +1 -1
  9. package/dist/core/codec/blocks.js +4 -2
  10. package/dist/core/codec/cap.d.ts +21 -0
  11. package/dist/core/codec/cap.js +63 -0
  12. package/dist/core/codec/code.d.ts +1 -1
  13. package/dist/core/codec/code.js +1 -1
  14. package/dist/core/codec/decode.d.ts +5 -5
  15. package/dist/core/codec/decode.js +18 -12
  16. package/dist/core/codec/encode.js +6 -1
  17. package/dist/core/codec/field.d.ts +22 -10
  18. package/dist/core/codec/field.js +54 -18
  19. package/dist/core/codec/index.d.ts +3 -1
  20. package/dist/core/codec/index.js +3 -2
  21. package/dist/core/codec/inputrules.js +1 -1
  22. package/dist/core/codec/lists.d.ts +3 -2
  23. package/dist/core/codec/lists.js +21 -5
  24. package/dist/core/codec/nesting.d.ts +20 -0
  25. package/dist/core/codec/nesting.js +62 -0
  26. package/dist/core/codec/prose.css +74 -20
  27. package/dist/core/codec/schema.d.ts +2 -2
  28. package/dist/core/codec/schema.js +16 -7
  29. package/dist/core/codec/table-copy.d.ts +17 -0
  30. package/dist/core/codec/table-copy.js +84 -0
  31. package/dist/core/codec/table-view.d.ts +1 -7
  32. package/dist/core/codec/table-view.js +70 -119
  33. package/dist/core/codec/table.d.ts +2 -9
  34. package/dist/core/codec/table.js +10 -19
  35. package/dist/core/codec/tasks.d.ts +19 -0
  36. package/dist/core/codec/tasks.js +140 -0
  37. package/dist/core/errors.d.ts +2 -2
  38. package/dist/visual/ArrayField.svelte +103 -63
  39. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  40. package/dist/visual/Card.svelte +9 -3
  41. package/dist/visual/Card.svelte.d.ts +2 -2
  42. package/dist/visual/Field.svelte +33 -17
  43. package/dist/visual/Field.svelte.d.ts +4 -2
  44. package/dist/visual/FieldLabel.svelte +19 -33
  45. package/dist/visual/FieldLabel.svelte.d.ts +4 -2
  46. package/dist/visual/FormatPopover.svelte +54 -73
  47. package/dist/visual/MatrixField.svelte +18 -12
  48. package/dist/visual/NumberField.svelte +32 -3
  49. package/dist/visual/ObjectField.svelte +50 -22
  50. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  51. package/dist/visual/ProseField.svelte +20 -14
  52. package/dist/visual/ProseField.svelte.d.ts +8 -2
  53. package/dist/visual/ProseValue.svelte +30 -19
  54. package/dist/visual/ProseValue.svelte.d.ts +7 -4
  55. package/dist/visual/TextField.svelte +3 -2
  56. package/dist/visual/TipsCard.svelte +5 -2
  57. package/dist/visual/VariantField.svelte +1 -1
  58. package/dist/visual/VariantField.svelte.d.ts +1 -1
  59. package/dist/visual/VisualEditorInner.svelte +13 -13
  60. package/dist/visual/controls.css +19 -1
  61. package/dist/visual/icons/nodes.d.ts +0 -21
  62. package/dist/visual/icons/nodes.js +1 -7
  63. package/dist/visual/leaves.d.ts +12 -9
  64. package/dist/visual/leaves.js +19 -0
  65. package/dist/visual/strings.d.ts +16 -5
  66. package/dist/visual/strings.js +17 -9
  67. package/dist/visual/structure.d.ts +26 -32
  68. package/dist/visual/structure.js +71 -53
  69. package/package.json +3 -3
@@ -4,7 +4,8 @@
4
4
  (`-`, `1.`) 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.
7
+ region. Escape takes a pending entry back to what the field holds, as it takes a
8
+ card rename back, and the focus stays; with nothing pending the key is not claimed.
8
9
 
9
10
  An unset field holds its resolved `default:` as its text, at the default rung
10
11
  (theme.css), and an edit that settles writes it as authored, as `TextField` takes
@@ -52,7 +53,8 @@
52
53
  }
53
54
  let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
54
55
 
55
- let local = $derived(value != null ? String(value) : (fallback ?? ''));
56
+ const resting = $derived(value != null ? String(value) : (fallback ?? ''));
57
+ let local = $derived(resting);
56
58
  const defaulted = $derived(value == null && !!fallback && local === fallback);
57
59
 
58
60
  const partial = $derived(
@@ -76,13 +78,37 @@
76
78
  local = el.value;
77
79
  }
78
80
 
81
+ // Where the reverted text differs from the last settled one, a browser fires `change`
82
+ // on leaving; that one commits nothing. An entry that settled outside the grammar was
83
+ // handed up as a string and refused, so taking it back settles the held value again,
84
+ // which clears the refusal drawn on the field.
85
+ let reverted = false;
86
+ let offered = false;
87
+ function revert(e: KeyboardEvent): void {
88
+ if (e.key !== 'Escape' || e.isComposing) return;
89
+ const el = e.currentTarget as HTMLInputElement;
90
+ if (el.value === resting) return;
91
+ e.preventDefault();
92
+ local = el.value = resting;
93
+ reverted = true;
94
+ if (offered) {
95
+ offered = false;
96
+ onCommit(value);
97
+ }
98
+ }
99
+
79
100
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
80
101
  // `undefined`, and the input takes the default back: nothing else reconciles it, the
81
102
  // projection having read the default throughout. Onto the element as well, since the
82
103
  // attribute Svelte last wrote may already be the default, and it writes no repeat.
83
104
  function commit(el: HTMLInputElement): void {
105
+ if (reverted) {
106
+ reverted = false;
107
+ return;
108
+ }
84
109
  const raw = el.value;
85
110
  const entry = raw.trim();
111
+ offered = false;
86
112
  if (entry === '') {
87
113
  onCommit(undefined);
88
114
  local = el.value = fallback ?? '';
@@ -90,7 +116,8 @@
90
116
  }
91
117
  // Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
92
118
  const n = whole.test(entry) ? Number(entry) : NaN;
93
- onCommit(Number.isFinite(n) ? n : raw);
119
+ offered = !Number.isFinite(n);
120
+ onCommit(offered ? raw : n);
94
121
  }
95
122
  </script>
96
123
 
@@ -107,6 +134,8 @@
107
134
  onbeforeinput={filter}
108
135
  oninput={(e) => {
109
136
  local = (e.currentTarget as HTMLInputElement).value;
137
+ reverted = false;
110
138
  }}
139
+ onkeydown={revert}
111
140
  onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
112
141
  />
@@ -41,18 +41,22 @@
41
41
  baseType,
42
42
  controlKind,
43
43
  declaredContent,
44
+ fieldModels,
44
45
  flatObject,
45
46
  humanize,
46
47
  isContainer,
48
+ literalText,
47
49
  mountKey,
48
- obliged,
49
50
  optionalCell,
51
+ placeFields,
50
52
  printedText,
51
53
  renderedBoolean,
52
- shortCell
54
+ shortCell,
55
+ textCap
53
56
  } from './structure.js';
54
57
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
55
58
  import type { LandingBox } from './leaves.js';
59
+ import { isEmptyContent } from '../core/codec/index.js';
56
60
  import { wording } from './strings.js';
57
61
  import { propertyDomIds } from './domid.js';
58
62
  import FieldLabel from './FieldLabel.svelte';
@@ -102,6 +106,12 @@
102
106
  /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
103
107
  * list the subform stands in. */
104
108
  flat?: boolean;
109
+ /** A card-level object, or one nested in it: its properties are placed as a
110
+ * card's fields are (`placeFields`), so moving a field into a group leaves its row
111
+ * as it was. Unset,
112
+ * every short cell packs: an array's record, a variant's world and a matrix
113
+ * member's columns are one record's cells, not a card's fields. */
114
+ placed?: boolean;
105
115
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
106
116
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
107
117
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -121,23 +131,27 @@
121
131
  onCommit,
122
132
  bare = false,
123
133
  flat = false,
134
+ placed = false,
124
135
  onCellKey,
125
136
  diagnostics
126
137
  }: Props = $props();
127
138
 
128
139
  const t = wording();
140
+ /** A capped prose cell's character count, by property. */
141
+ const counts: Record<string, number> = $state({});
129
142
  const entries = $derived(Object.entries(properties ?? {}));
130
143
  const obj = $derived((value ?? {}) as Record<string, unknown>);
131
144
  const has = (key: string): boolean => Object.hasOwn(properties ?? {}, key);
145
+ const spans = $derived(
146
+ placed
147
+ ? new Map(
148
+ placeFields(fieldModels({ fields: properties ?? {} })).map((p) => [p.field.name, p.span])
149
+ )
150
+ : undefined
151
+ );
132
152
  const routed = $derived(splitDeep(diagnostics));
133
153
  const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && has(step)));
134
154
 
135
- /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
136
- * ({@link obliged}). `validate` anchors it per leaf, so a property with no
137
- * `default:` is obliged in its own right and the container around it holds none
138
- * (VISUAL_EDITOR §"Enum variants"). */
139
- const required = obliged;
140
-
141
155
  const title = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
142
156
  /** What an unset optional cell ghosts, which is what it prints: `none`. An answered
143
157
  * cell ghosts nothing, an empty answer printing empty. */
@@ -151,8 +165,7 @@
151
165
  /** The `aria-label` fallback, for a subform mounted without a field's id space:
152
166
  * the field's name and the property's, since nothing else names the control. */
153
167
  const fallbackName = (key: string, sub: QuillFieldSchema): string =>
154
- `${label != null ? `${label} ` : ''}${title(key, sub)}` +
155
- (required(sub) ? ` ${t.strings.fieldRequired}` : '');
168
+ `${label != null ? `${label} ` : ''}${title(key, sub)}`;
156
169
 
157
170
  /** The handles of the cells the DOM cannot answer for, keyed by property name —
158
171
  * which is stable, a subform drawing its properties by declaration and never by
@@ -285,10 +298,19 @@
285
298
  kind === 'boolean'}
286
299
  <!-- A container takes the full span at every depth: its rows are the document's
287
300
  to count, and a track beside it would stand in a column of whitespace
288
- (`packable`). A block prose cell spans it too, and keeps its label row. -->
301
+ (`packable`). A block prose cell spans it too, and keeps its label row, as
302
+ does a placed cell `placeFields` gives the whole row. -->
289
303
  {@const block = kind === 'prose' && !shortCell(sub)}
304
+ {@const cap = kind === 'prose' ? textCap(sub) : undefined}
305
+ {@const countId = cap && ids ? `${ids.control}-count` : undefined}
290
306
  <div
291
- class={['qm-object-prop', { 'qm-prop-full': isContainer(kind), 'qm-prop-wide': block }]}
307
+ class={[
308
+ 'qm-object-prop',
309
+ {
310
+ 'qm-prop-full': isContainer(kind),
311
+ 'qm-prop-wide': block || (!isContainer(kind) && spans?.get(key) === 'full')
312
+ }
313
+ ]}
292
314
  data-qm-prop={key}
293
315
  >
294
316
  {#if ids && kind !== 'array' && kind !== 'matrix'}
@@ -298,8 +320,11 @@
298
320
  id={ids.label}
299
321
  descriptionId={describes}
300
322
  onActivate={labelable ? undefined : () => focusProp(key)}
301
- required={required(sub)}
302
323
  description={sub.description}
324
+ count={cap && counts[key] != null
325
+ ? t.strings.textCount(counts[key], cap.max)
326
+ : undefined}
327
+ {countId}
303
328
  />
304
329
  {/if}
305
330
  {#if kind === 'enum'}
@@ -364,13 +389,15 @@
364
389
  content={() => contentAt([key])}
365
390
  value={obj[key]}
366
391
  fallback={contentDefault(sub)}
367
- plaintext={baseType(sub) === 'plaintext'}
392
+ plaintext={literalText(sub)}
368
393
  placeholder={noneOf(key, sub)}
369
394
  {block}
370
395
  label={named}
371
396
  labelledBy={ids?.label}
372
- describedBy={describes}
373
- onChange={(rt) => commitProp(key, rt)}
397
+ describedBy={countId ? [describes, countId].filter(Boolean).join(' ') : describes}
398
+ cap={cap && { ...cap, onCount: (n) => (counts[key] = n) }}
399
+ onChange={(rt) =>
400
+ commitProp(key, optionalCell(sub) && isEmptyContent(rt) ? undefined : rt)}
374
401
  onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
375
402
  />
376
403
  {:else if kind === 'array'}
@@ -386,7 +413,6 @@
386
413
  layout={arrayLayout(sub)}
387
414
  max={sub.max}
388
415
  label={title(key, sub)}
389
- required={required(sub)}
390
416
  description={sub.description}
391
417
  labelId={ids?.label}
392
418
  descriptionId={describes}
@@ -401,6 +427,7 @@
401
427
  value={obj[key] as Record<string, unknown> | undefined}
402
428
  properties={sub.properties}
403
429
  flat={flatObject(sub)}
430
+ {placed}
404
431
  label={ids ? undefined : fallbackName(key, sub)}
405
432
  idBase={ids?.control}
406
433
  labelledBy={ids?.label}
@@ -514,15 +541,16 @@
514
541
  align-items: start;
515
542
  min-width: 0;
516
543
  }
517
- /* A subform of one takes half the capacity: at full capacity a single track reads as
518
- truncated, and there are no sibling properties for it to line up with. A stranded
519
- field takes the whole row instead (`placeFields`): the width it would leave empty is
520
- the card's, where this one sits inside a figure the nesting has already narrowed. */
544
+ /* A record of one cell takes half the capacity: at full capacity a single track reads
545
+ as truncated, and there are no sibling properties for it to line up with. A placed
546
+ property's span is `placeFields`', which strands a lone one to the whole row as it
547
+ does a card's field. */
521
548
  .qm-object-prop:only-child {
522
549
  grid-column: span var(--cols-half);
523
550
  }
524
551
  /* A block prose property holds paragraphs, so a track beside it stands in whitespace
525
- once it grows (`packable`); its label still rides the row above it. */
552
+ once it grows (`packable`); its label still rides the row above it. A placed
553
+ property `placeFields` gives the whole row spans it the same way. */
526
554
  .qm-object-prop.qm-prop-wide,
527
555
  .qm-object-prop.qm-prop-wide:only-child {
528
556
  grid-column: 1 / -1;
@@ -35,6 +35,12 @@ interface Props {
35
35
  /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
36
36
  * list the subform stands in. */
37
37
  flat?: boolean;
38
+ /** A card-level object, or one nested in it: its properties are placed as a
39
+ * card's fields are (`placeFields`), so moving a field into a group leaves its row
40
+ * as it was. Unset,
41
+ * every short cell packs: an array's record, a variant's world and a matrix
42
+ * member's columns are one record's cells, not a card's fields. */
43
+ placed?: boolean;
38
44
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
39
45
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
40
46
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -6,7 +6,12 @@
6
6
  -->
7
7
  <script lang="ts">
8
8
  import { untrack } from 'svelte';
9
- import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
9
+ import {
10
+ createField,
11
+ type FieldController,
12
+ type SlashState,
13
+ type TextCap
14
+ } from '../core/codec/index.js';
10
15
  import type { LeafRegistry } from './leaves.js';
11
16
  import SlashMenu from './SlashMenu.svelte';
12
17
  import { wording } from './strings.js';
@@ -24,8 +29,9 @@
24
29
  plaintext?: boolean;
25
30
  /**
26
31
  * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
27
- * different predicate: a `richtext` field without `inline` is block prose and
28
- * still a control in a row of controls, so only the caller knows what is paper.
32
+ * different predicate: a `richtext` field declaring `multiline: true` is block
33
+ * prose and still a control in a row of controls, so only the caller knows what
34
+ * is paper.
29
35
  */
30
36
  unframed?: boolean;
31
37
  /** Accessible name for a leaf nothing else names: an array element, or the
@@ -45,6 +51,10 @@
45
51
  placeholder?: string;
46
52
  /** The placeholder goes at the leaf's first edit (`createField`). */
47
53
  placeholderUntilEdit?: boolean;
54
+ /** An emptied leaf removes the field (`createField`). */
55
+ emptyUnsets?: boolean;
56
+ /** The declared `max:`, a character count no edit takes the leaf past. */
57
+ cap?: TextCap;
48
58
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
49
59
  leafKey: string;
50
60
  onFocus?: (addr: Addr) => void;
@@ -73,6 +83,8 @@
73
83
  fallback,
74
84
  placeholder,
75
85
  placeholderUntilEdit,
86
+ emptyUnsets,
87
+ cap,
76
88
  leafKey,
77
89
  onFocus,
78
90
  onCaretMove,
@@ -122,6 +134,8 @@
122
134
  fallback,
123
135
  placeholder,
124
136
  placeholderUntilEdit,
137
+ emptyUnsets,
138
+ cap,
125
139
  tableStrings: () => t.strings,
126
140
  slashStrings: () => t.strings,
127
141
  onSlash: (next) => {
@@ -166,7 +180,7 @@
166
180
  <div
167
181
  {@attach mountField}
168
182
  class={[
169
- 'qm-prose',
183
+ 'qm-prose qm-wash',
170
184
  { 'qm-control-box': !unframed, 'qm-focus-ring-within': !unframed, 'qm-prose-block': block }
171
185
  ]}
172
186
  data-leaf-key={leafKey}
@@ -178,14 +192,6 @@
178
192
  <SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
179
193
 
180
194
  <style>
181
- /* The box is `.qm-control-box` (controls.css), the same rule the input beside it
182
- draws, so the two agree on height by construction. No `min-height` with it:
183
- `core/codec/prose.css` resets the paragraph box, so one line of prose in this box
184
- measures one line of text in that one. */
185
- .qm-prose {
186
- /* Containing block for the arrival wash's inset child (`core/bloom.ts`). */
187
- position: relative;
188
- }
189
195
  /* A leaf the inline schema does not constrain grows, and opens at one line box: the
190
196
  empty leaf is the height its first line will take, so the first keystroke displaces
191
197
  nothing below it. `1em`, not a size rung, which is the rule below's to name.
@@ -196,8 +202,8 @@
196
202
  .qm-prose-block :global(.ProseMirror) {
197
203
  min-height: calc(1em * var(--_qm-leading-body));
198
204
  }
199
- /* Paper, and the withheld box is what says so. Not `block` alone: `inline` is the
200
- quill's to declare, so a schema omitting it on a richtext field puts a block leaf in
205
+ /* Paper, and the withheld box is what says so. Not `block` alone: `multiline` is the
206
+ quill's to declare, so a schema spelling it on a richtext field puts a block leaf in
201
207
  a field row, where this rung would break the height agreement above. */
202
208
  .qm-prose-block:not(.qm-control-box) {
203
209
  font-size: var(--_qm-text-paper);
@@ -1,3 +1,4 @@
1
+ import { type TextCap } from '../core/codec/index.js';
1
2
  import type { LeafRegistry } from './leaves.js';
2
3
  import type { Document, Quill, Addr, Content, Diagnostic } from '@quillmark/wasm';
3
4
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -11,8 +12,9 @@ interface Props {
11
12
  plaintext?: boolean;
12
13
  /**
13
14
  * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
14
- * different predicate: a `richtext` field without `inline` is block prose and
15
- * still a control in a row of controls, so only the caller knows what is paper.
15
+ * different predicate: a `richtext` field declaring `multiline: true` is block
16
+ * prose and still a control in a row of controls, so only the caller knows what
17
+ * is paper.
16
18
  */
17
19
  unframed?: boolean;
18
20
  /** Accessible name for a leaf nothing else names: an array element, or the
@@ -32,6 +34,10 @@ interface Props {
32
34
  placeholder?: string;
33
35
  /** The placeholder goes at the leaf's first edit (`createField`). */
34
36
  placeholderUntilEdit?: boolean;
37
+ /** An emptied leaf removes the field (`createField`). */
38
+ emptyUnsets?: boolean;
39
+ /** The declared `max:`, a character count no edit takes the leaf past. */
40
+ cap?: TextCap;
35
41
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
36
42
  leafKey: string;
37
43
  onFocus?: (addr: Addr) => void;
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  A prose leaf the parent commits by value: an element of a `richtext` (fixture
3
- `keywords`) or `plaintext` (`errata`) array, and a content cell of a subform — an
3
+ `keywords`) or `text` (`authors`, `errata`) array, and a content cell of a subform — an
4
4
  `object`'s property, a variant's cell ({@link ObjectField}). None of them is
5
5
  `applyChange`-addressable: `Addr.field` is a flat name, so `keywords[0]` and
6
6
  `contact.note` have no op address. So this is not a `createField` leaf: it mounts a
@@ -19,7 +19,7 @@
19
19
 
20
20
  The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
21
21
  the codec the leaf's own declared type names, so what it rests as — the content
22
- object, a `plaintext` literal, the authored string a transport door left — stops
22
+ object, a `text` literal, the authored string a transport door left — stops
23
23
  being the row's or the cell's business. It is read at mount and again wherever the
24
24
  parent's re-derive hands down a `value` that moved, and a read differing from the
25
25
  content the leaf last committed or took replaces the view, the gate `createField`
@@ -28,8 +28,8 @@
28
28
  it, so the echo replaces nothing and the caret and the history stay.
29
29
 
30
30
  The schema is the declared type's where the parent has room for it: a subform cell
31
- declaring no `inline` takes the full row ({@link ObjectField}), a `richtext` one on the
32
- block schema and a `plaintext` one on the plain schema, paragraphs and hard breaks.
31
+ declaring `multiline: true` takes the full row ({@link ObjectField}), a `richtext` one
32
+ on the block schema and a `text` one on the plain schema, paragraphs and hard breaks.
33
33
  An array element is one textblock whatever it declares, since Enter there is the
34
34
  repeater's. There is no slash menu at either width, and no island view.
35
35
 
@@ -60,7 +60,8 @@
60
60
  pmToUsv,
61
61
  usvToPM,
62
62
  valueEqual,
63
- type LeafViews
63
+ type LeafViews,
64
+ type TextCap
64
65
  } from '../core/codec/index.js';
65
66
  import { valueLeafSeat } from './leaves.js';
66
67
  import { wording } from './strings.js';
@@ -80,8 +81,8 @@
80
81
  /** The default an unset leaf holds, where it prints: drawn at the default rung,
81
82
  * and handed up whole with the first edit in it, as `createField` takes one. */
82
83
  fallback?: Content;
83
- /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
84
- * exactly as the scalar field of that type mounts. */
84
+ /** The mark-free schema (a `text` leaf): literal text, no formatting, exactly
85
+ * as the field of that type mounts. */
85
86
  plaintext?: boolean;
86
87
  /** A multi-block schema, for a cell the parent draws at full width: the block
87
88
  * schema, or the plain one with `plaintext`. Absent, the leaf is one textblock. */
@@ -96,9 +97,11 @@
96
97
  labelledBy?: string;
97
98
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
98
99
  describedBy?: string;
99
- /** The empty leaf's ghost, a subform cell's, until the leaf's first edit: the
100
- * `none` an optional cell prints unset. Read per decoration pass. */
100
+ /** The empty leaf's ghost, a subform cell's: the `none` an optional cell prints
101
+ * unset. Read per decoration pass. */
101
102
  placeholder?: string;
103
+ /** The declared `max:`, a character count no edit takes the leaf past. */
104
+ cap?: TextCap;
102
105
  onChange: (rt: Content) => void;
103
106
  /** Raw keydown, for a container whose own keys run through this leaf: the
104
107
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
@@ -115,6 +118,7 @@
115
118
  labelledBy,
116
119
  describedBy,
117
120
  placeholder,
121
+ cap,
118
122
  onChange,
119
123
  onKey
120
124
  }: Props = $props();
@@ -130,9 +134,8 @@
130
134
  /** Take the caret: what a parent placing focus on this leaf calls. The view's
131
135
  * focus, not the element's: a PM view restores its selection, where a bare DOM
132
136
  * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
133
- * since PM's focus prevents the scroll a `string` element's input takes by
134
- * default — an array whose two item types revealed differently is one row of it
135
- * landing off screen. */
137
+ * since PM's focus prevents the scroll a form control takes by default — an array
138
+ * whose two item types revealed differently is one row of it landing off screen. */
136
139
  export function focus(): void {
137
140
  if (!view) return;
138
141
  view.focus();
@@ -157,11 +160,18 @@
157
160
  view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
158
161
  }
159
162
 
163
+ $effect(() => {
164
+ void [label, labelledBy, describedBy, placeholder];
165
+ view?.setProps({});
166
+ });
167
+
160
168
  function mountView(mount: HTMLDivElement): () => void {
161
169
  return untrack(() => {
162
170
  const inline = !block;
163
171
  const leaf = { inline, plaintext, islands: false };
164
- const names = { label, labelledBy, describedBy };
172
+ // Read per attribute pass, not captured: an element's name carries its index,
173
+ // which a remove above it moves.
174
+ const names = () => ({ label, labelledBy, describedBy });
165
175
  const shown = (stored: Content | undefined): Content => stored ?? fallback ?? emptyContent();
166
176
  const stored = content();
167
177
  let seen = value;
@@ -171,8 +181,8 @@
171
181
  // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
172
182
  // (`proseAttributes`), so a cell carrying a label element and a row carrying
173
183
  // none cannot name their regions by different rules.
174
- const plainNamed = proseAttributes(names) ?? {};
175
- const heldNamed = heldAttributes(names, heldId);
184
+ const plainNamed = () => proseAttributes(names()) ?? {};
185
+ const heldNamed = () => heldAttributes(names(), heldId);
176
186
  const build = (rt: Content): EditorState => {
177
187
  // The same keymap and plugin stack a `createField` leaf mounts (shared
178
188
  // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
@@ -185,7 +195,7 @@
185
195
  : proseLeafPlugins(schema, {
186
196
  inline,
187
197
  placeholder: () => placeholder,
188
- placeholderUntilEdit: true
198
+ cap
189
199
  })
190
200
  });
191
201
  };
@@ -195,11 +205,12 @@
195
205
  state: build(reconciler.last),
196
206
  editable: () => !held,
197
207
  attributes: () => {
198
- const named = held ? heldNamed : plainNamed;
208
+ const named = held ? heldNamed() : plainNamed();
199
209
  return defaulted ? { ...named, 'data-default': '' } : named;
200
210
  },
201
211
  dispatchTransaction(tr) {
202
212
  const next = mounted.state.apply(tr);
213
+ if (next === mounted.state) return;
203
214
  const edit = tr.docChanged && !held;
204
215
  if (edit) defaulted = false;
205
216
  mounted.updateState(next);
@@ -265,8 +276,8 @@
265
276
  </script>
266
277
 
267
278
  <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
268
- an array of `string` render rows of equal height, and a subform's prose cell measures
269
- like the text cell beside it. No floor: the reset in `core/codec/prose.css` makes one
279
+ an array of `text` render rows of equal height, and a subform's prose cell measures
280
+ like the form control beside it. No floor: the reset in `core/codec/prose.css` makes one
270
281
  line of prose measure one line. Width is the row's or the cell's to give: the leaf
271
282
  fills the track it is placed in. -->
272
283
  <div class="qm-prose-value qm-control-box qm-focus-ring-within">
@@ -1,3 +1,4 @@
1
+ import { type TextCap } from '../core/codec/index.js';
1
2
  import type { Content } from '@quillmark/wasm';
2
3
  interface Props {
3
4
  /** This leaf's content (the parent's boundary read), `undefined` where nothing is
@@ -12,8 +13,8 @@ interface Props {
12
13
  /** The default an unset leaf holds, where it prints: drawn at the default rung,
13
14
  * and handed up whole with the first edit in it, as `createField` takes one. */
14
15
  fallback?: Content;
15
- /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
16
- * exactly as the scalar field of that type mounts. */
16
+ /** The mark-free schema (a `text` leaf): literal text, no formatting, exactly
17
+ * as the field of that type mounts. */
17
18
  plaintext?: boolean;
18
19
  /** A multi-block schema, for a cell the parent draws at full width: the block
19
20
  * schema, or the plain one with `plaintext`. Absent, the leaf is one textblock. */
@@ -28,9 +29,11 @@ interface Props {
28
29
  labelledBy?: string;
29
30
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
30
31
  describedBy?: string;
31
- /** The empty leaf's ghost, a subform cell's, until the leaf's first edit: the
32
- * `none` an optional cell prints unset. Read per decoration pass. */
32
+ /** The empty leaf's ghost, a subform cell's: the `none` an optional cell prints
33
+ * unset. Read per decoration pass. */
33
34
  placeholder?: string;
35
+ /** The declared `max:`, a character count no edit takes the leaf past. */
36
+ cap?: TextCap;
34
37
  onChange: (rt: Content) => void;
35
38
  /** Raw keydown, for a container whose own keys run through this leaf: the
36
39
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
@@ -1,6 +1,7 @@
1
1
  <!--
2
- A `string` field → text input. Commits every edit live (on input) via the parent's
3
- typed `writer.set`, so the preview tracks typing.
2
+ A text input: the control a field of a type this build does not know falls to.
3
+ Commits every edit live (on input) via the parent's typed `writer.set`, so the
4
+ preview tracks typing.
4
5
 
5
6
  An unset field whose `default:` prints holds that default as its text, at the
6
7
  default rung (theme.css): the value it prints, drawn as one and a step off a written
@@ -69,7 +69,7 @@
69
69
  {#each tips as _, i (i)}
70
70
  <button
71
71
  type="button"
72
- class="qm-tips-dot"
72
+ class="qm-tips-dot qm-focus-ring"
73
73
  aria-label={t.strings.tipsDot(i + 1, tips.length)}
74
74
  aria-current={i === index}
75
75
  onclick={() => (cursor = i)}
@@ -77,7 +77,7 @@
77
77
  {/each}
78
78
  </div>
79
79
  {/if}
80
- <button type="button" class="qm-icon-btn qm-tips-action" onclick={advance}>
80
+ <button type="button" class="qm-icon-btn qm-tips-action qm-focus-ring" onclick={advance}>
81
81
  {#if isLast}
82
82
  {t.strings.tipsDismiss} <Icon name="trash-2" />
83
83
  {:else}
@@ -193,6 +193,9 @@
193
193
  .qm-tips-dot:hover {
194
194
  opacity: var(--_qm-opacity-muted);
195
195
  }
196
+ .qm-tips-dot:focus-visible {
197
+ opacity: 1;
198
+ }
196
199
  .qm-tips-dot[aria-current='true'] {
197
200
  opacity: 1;
198
201
  }
@@ -42,7 +42,7 @@
42
42
  describedBy?: string;
43
43
  /** The boundary's nested content read for this field, which the cells take
44
44
  * unprefixed: a variant's cell is one key in from the container it rests as, so
45
- * a `plaintext` cell's path is its own name ({@link ObjectField}). */
45
+ * a `text` cell's path is its own name ({@link ObjectField}). */
46
46
  contentAt: (path: PathStep[]) => Content | undefined;
47
47
  onCommit: (v: Record<string, unknown> | undefined) => void;
48
48
  optionAllowed?: (value: string) => boolean;
@@ -16,7 +16,7 @@ interface Props {
16
16
  describedBy?: string;
17
17
  /** The boundary's nested content read for this field, which the cells take
18
18
  * unprefixed: a variant's cell is one key in from the container it rests as, so
19
- * a `plaintext` cell's path is its own name ({@link ObjectField}). */
19
+ * a `text` cell's path is its own name ({@link ObjectField}). */
20
20
  contentAt: (path: PathStep[]) => Content | undefined;
21
21
  onCommit: (v: Record<string, unknown> | undefined) => void;
22
22
  optionAllowed?: (value: string) => boolean;