@quillmark/svelte 0.16.0 → 0.17.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.
package/README.md CHANGED
@@ -46,6 +46,8 @@ const session = await new Engine().open(quill, doc);
46
46
  // free() on teardown the handles you MINTED: this quill, this doc, this session.
47
47
  ```
48
48
 
49
+ `<VisualEditor>` needs no session, and the first `open` loads the render build, so a host that mounts the editor over `doc` and `quill` first shows it without waiting on that load. The session opens behind it: `await engine.load(quill)`, then `engine.open(quill, doc)`, so its first compile reads what was typed meanwhile ([HOSTING.md](prose/canon/HOSTING.md)).
50
+
49
51
  ## The quill resolves from the document
50
52
 
51
53
  A stored document carries none of its quill's bytes, only a reference: `doc.quillRef` is `name@version`, persisted in the markdown itself. Resolution is **host code**: read the ref, map it to a `Quill`, open. No surface resolves, so one resolution per document holds by construction.
@@ -58,7 +60,7 @@ const session = await new Engine().open(quill, doc);
58
60
 
59
61
  `@quillmark/quiver` is one such registry, and the quill it hands back is **borrowed**: it is cached per ref and shared with every caller, so freeing it strands the next one.
60
62
 
61
- Opening a document that names a **different** quill is the same sequence, in order: resolve the new ref, `engine.open(quill, next)`, swap the props, then free the replaced handles that were yours to free. `<VisualEditor>` re-keys itself on the new `doc` (see below); `<Preview>` swaps by remount (`{#key session}`).
63
+ Opening a document that names a **different** quill is the same sequence, in order: resolve the new ref, swap the editor's props, free the replaced handles that were yours to free, then open the next session behind the editor. `<VisualEditor>` re-keys itself on the new `doc` (see below); `<Preview>` swaps by remount (`{#key session}`).
62
64
 
63
65
  ## What a document is trusted to be
64
66
 
@@ -316,7 +316,14 @@
316
316
  for the type half. Asymmetric because the vertical is what leading leaves once
317
317
  it has taken the line box, and the horizontal is how far in the text starts,
318
318
  which nothing else fixes. */
319
- --_qm-inset-control: var(--_qm-space) var(--_qm-space-3);
319
+ --_qm-inset-control-block: var(--_qm-space);
320
+ --_qm-inset-control: var(--_qm-inset-control-block) var(--_qm-space-3);
321
+ /* A box's first line, drawn: its line box and the inset over and under it. What a
322
+ row of boxes lines up on however far one of them wraps, and so what a mark with no
323
+ line of its own centres on to stand in that row (`BooleanField`). */
324
+ --_qm-box-line: calc(
325
+ var(--_qm-text-body) * var(--_qm-leading-body) + 2 * var(--_qm-inset-control-block)
326
+ );
320
327
  /* The nesting step: the distance from one structural stroke to the next, and what
321
328
  makes the depths read as a ladder rather than as indents that happen to look
322
329
  alike. A rung because the boxes holding that distance are in two components and
@@ -349,6 +356,10 @@
349
356
  name it and no label of its own to fit. A threshold rather than a rhythm choice,
350
357
  so it does not move with `--qm-space`. */
351
358
  --_qm-track-min: 7rem;
359
+ /* A prose column's floor: its cell wraps, and at `--_qm-track-min` a line holds a
360
+ word, standing the row many lines tall beside cells one line tall. A multiple of
361
+ the floor, so the two move together. */
362
+ --_qm-track-prose: calc(var(--_qm-track-min) * 2);
352
363
 
353
364
  /* ── Type ────────────────────────────────────────────────────────────────
354
365
  `--qm-font-size` anchors the chrome rung; 1.125 is the ratio between adjacent
@@ -591,7 +591,11 @@
591
591
  column's rather than any one cell's. -->
592
592
  <div class="qm-array-table-head">
593
593
  {#each columns as [key, sub] (key)}
594
- <div class="qm-array-table-col" class:mark={controlKind(sub) === 'boolean'}>
594
+ <div
595
+ class="qm-array-table-col"
596
+ class:mark={controlKind(sub) === 'boolean'}
597
+ class:prose={controlKind(sub) === 'prose'}
598
+ >
595
599
  <FieldLabel
596
600
  label={columnTitle(key, sub)}
597
601
  required={obliged(sub)}
@@ -1044,6 +1048,9 @@
1044
1048
  .qm-array-table-col.mark {
1045
1049
  min-width: 0;
1046
1050
  }
1051
+ .qm-array-table-col.prose {
1052
+ min-width: var(--_qm-track-prose);
1053
+ }
1047
1054
  /* The remove is the row's last track, pinned to the end edge of the box the table
1048
1055
  scrolls in: sticky, so it rests in its track while the table fits and rides over
1049
1056
  the cells once the box scrolls, on the card's plane so what passes under it is
@@ -1066,8 +1073,12 @@
1066
1073
  background: var(--_qm-surface);
1067
1074
  box-shadow: 0 0 0 var(--_qm-ring-reach) var(--_qm-surface);
1068
1075
  }
1076
+ /* The glyph stands on the row's first line with the cells, however far the slab
1077
+ stretches under it. */
1069
1078
  .qm-array-table-row > .qm-remove {
1070
1079
  align-self: stretch;
1080
+ align-items: start;
1081
+ padding-block-start: calc((var(--_qm-box-line) - var(--_qm-glyph-control)) / 2);
1071
1082
  height: auto;
1072
1083
  border-radius: var(--_qm-radius-inner);
1073
1084
  color: var(--_qm-ink-label);
@@ -74,6 +74,13 @@
74
74
  </span>
75
75
 
76
76
  <style>
77
+ /* The switch stands on the first line of the boxes beside it: a row of controls tops
78
+ out together, and a mark carries no line of its own to sit on. */
79
+ .qm-toggle-wrap {
80
+ display: flex;
81
+ align-items: center;
82
+ min-height: var(--_qm-box-line);
83
+ }
77
84
  /* A primitive renders its own element, which a scoped selector cannot reach:
78
85
  styled through the wrapper with `:global`. */
79
86
  /* The track is the drawn box and the target is `.qm-tap-floor`'s (controls.css): a
@@ -19,6 +19,7 @@
19
19
  import { holdInView } from './hold.js';
20
20
  import type { FieldDomIds } from './domid.js';
21
21
  import Field from './Field.svelte';
22
+ import FieldHint from './FieldHint.svelte';
22
23
  import ProseField from './ProseField.svelte';
23
24
  import CardControls from './CardControls.svelte';
24
25
  import DiagnosticList from './DiagnosticList.svelte';
@@ -270,21 +271,37 @@
270
271
  {@const group = section.group as string}
271
272
  {@const isOpen = expanded === group}
272
273
  {@const panelId = ops.panelId(group)}
274
+ {@const headIds = section.head ? ops.domIds(section.head.name) : undefined}
275
+ {@const headHint = section.head?.description}
273
276
  <div class="qm-group" class:qm-open={isOpen}>
274
- <button
275
- type="button"
276
- class="qm-group-header"
277
- bind:this={headers[group]}
278
- aria-expanded={isOpen}
279
- aria-controls={panelId}
280
- onclick={() => toggleGroup(group)}
281
- >
282
- <Icon
283
- name="chevron-right"
284
- class="qm-group-chevron"
285
- size={CHEVRON}
286
- />{section.label}
287
- </button>
277
+ <!-- A flat field alone in its section is named by the header, which
278
+ takes its label id and parks its description: the field draws no
279
+ label of its own to say the section's name twice. -->
280
+ <div class="qm-group-head">
281
+ <button
282
+ type="button"
283
+ class="qm-group-header"
284
+ id={headIds?.label}
285
+ bind:this={headers[group]}
286
+ aria-expanded={isOpen}
287
+ aria-controls={panelId}
288
+ onclick={() => toggleGroup(group)}
289
+ >
290
+ <Icon
291
+ name="chevron-right"
292
+ class="qm-group-chevron"
293
+ size={CHEVRON}
294
+ />{section.label}
295
+ </button>
296
+ {#if headIds && headHint}
297
+ <FieldHint
298
+ description={headHint}
299
+ label={section.label}
300
+ describedBy={headIds.description}
301
+ />
302
+ <span class="qm-visually-hidden" id={headIds.description}>{headHint}</span>
303
+ {/if}
304
+ </div>
288
305
  <!-- The panel is clipped, not unmounted, so without `inert` every field
289
306
  in a hidden section keeps its place in the tab order and in the a11y
290
307
  tree, under a header announcing `aria-expanded="false"`. -->
@@ -295,7 +312,7 @@
295
312
  bind:this={panels[group]}
296
313
  >
297
314
  <div class="qm-group-panel-inner">
298
- {@render sectionFields(section.fields)}
315
+ {@render sectionFields(section.fields, section.head)}
299
316
  </div>
300
317
  </div>
301
318
  </div>
@@ -332,7 +349,7 @@
332
349
  {/if}
333
350
  </section>
334
351
 
335
- {#snippet sectionFields(fields: FieldModel[])}
352
+ {#snippet sectionFields(fields: FieldModel[], head?: FieldModel)}
336
353
  <div class="qm-fields qm-tracks">
337
354
  {#each placeFields(fields) as { field: f, span } (mountKey(f.name, f.schema))}
338
355
  <Field
@@ -354,6 +371,7 @@
354
371
  {onError}
355
372
  {leaves}
356
373
  diagnostics={ops.diagFor(f.name)}
374
+ headed={f === head}
357
375
  />
358
376
  {/each}
359
377
  </div>
@@ -591,7 +609,8 @@
591
609
  display: grid;
592
610
  grid-template-columns: repeat(var(--cols), 1fr);
593
611
  column-gap: var(--_qm-space-2);
594
- row-gap: var(--_qm-space-3);
612
+ --row-gap: var(--_qm-space-3);
613
+ row-gap: var(--row-gap);
595
614
  }
596
615
  /* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
597
616
  fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
@@ -680,6 +699,30 @@
680
699
  text-align: left;
681
700
  transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
682
701
  }
702
+ /* The header row: the press target, and beside it the guidance marker a headed
703
+ section carries (`FieldHint`), which is its own control and so not inside the
704
+ button. Positioned for the reason the header is, the row being the box that
705
+ stands in the band. */
706
+ .qm-group-head {
707
+ position: relative;
708
+ display: flex;
709
+ align-items: center;
710
+ gap: var(--_qm-space);
711
+ }
712
+ .qm-group-head > .qm-group-header {
713
+ flex: 1;
714
+ min-width: 0;
715
+ }
716
+ /* The parked description: `FieldLabel`'s recipe, for the one description a
717
+ header rather than a label parks. */
718
+ .qm-visually-hidden {
719
+ position: absolute;
720
+ width: 1px;
721
+ height: 1px;
722
+ overflow: hidden;
723
+ clip-path: inset(50%);
724
+ white-space: nowrap;
725
+ }
683
726
  .qm-group-header :global(.qm-group-chevron) {
684
727
  flex-shrink: 0;
685
728
  display: block;
@@ -33,6 +33,7 @@
33
33
  VARIANT_DISCRIMINANT,
34
34
  arrayLayout,
35
35
  baseType,
36
+ flatObject,
36
37
  ghostDefault,
37
38
  isContainer,
38
39
  optionalCell,
@@ -88,6 +89,9 @@
88
89
  * landing handle here, a prose leaf its controller from inside `ProseField`. */
89
90
  leaves?: LeafRegistry;
90
91
  diagnostics?: Diagnostic[];
92
+ /** The section's header names this field and parks its description, under this
93
+ * field's own label and description ids ({@link GroupSection.head}). */
94
+ headed?: boolean;
91
95
  }
92
96
  let {
93
97
  field,
@@ -107,7 +111,8 @@
107
111
  onChange,
108
112
  onError,
109
113
  leaves,
110
- diagnostics
114
+ diagnostics,
115
+ headed = false
111
116
  }: Props = $props();
112
117
 
113
118
  const t = wording();
@@ -157,6 +162,7 @@
157
162
  const describedBy = $derived(field.description ? domIds.description : undefined);
158
163
  // Two controls own their label row, the add chip and the held count sharing it.
159
164
  const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
165
+ const flat = $derived(flatObject(field.schema));
160
166
 
161
167
  // A container walks its diagnostics down to the cell each names and draws the rest
162
168
  // at its own foot (`diagnostics.ts`); every other control draws them here, under
@@ -257,7 +263,7 @@
257
263
  </script>
258
264
 
259
265
  <div class="qm-field" class:cell={span === 'cell'}>
260
- {#if !ownsLabel}
266
+ {#if !ownsLabel && !headed}
261
267
  <FieldLabel
262
268
  label={field.label}
263
269
  controlId={labelable ? domIds.control : undefined}
@@ -376,6 +382,7 @@
376
382
  bind:this={objectEl}
377
383
  value={value as Record<string, unknown> | undefined}
378
384
  properties={field.schema.properties}
385
+ {flat}
379
386
  label={field.label}
380
387
  idBase={domIds.control}
381
388
  labelledBy={domIds.label}
@@ -394,6 +401,7 @@
394
401
  labelId={domIds.label}
395
402
  descriptionId={domIds.description}
396
403
  idBase={domIds.control}
404
+ listKey={leafKey}
397
405
  {contentAt}
398
406
  onCommit={onCommitScalar}
399
407
  diagnostics={deep}
@@ -35,6 +35,9 @@ interface Props {
35
35
  * landing handle here, a prose leaf its controller from inside `ProseField`. */
36
36
  leaves?: LeafRegistry;
37
37
  diagnostics?: Diagnostic[];
38
+ /** The section's header names this field and parks its description, under this
39
+ * field's own label and description ids ({@link GroupSection.head}). */
40
+ headed?: boolean;
38
41
  }
39
42
  declare const Field: import("svelte").Component<Props, {}, "">;
40
43
  type Field = ReturnType<typeof Field>;
@@ -0,0 +1,211 @@
1
+ <!--
2
+ An open matrix's add box (VISUAL_EDITOR §"The matrix"): a combobox whose options are
3
+ what the typed words name across the document's checklists, best first, and last the
4
+ item they would add to this one. The list stands in flow under the input rather than
5
+ floating, so a closing group panel clips nothing of it, and it closes while the focus
6
+ is elsewhere. The input keeps the caret throughout and the active option is
7
+ `aria-activedescendant`, the combobox pattern: ↑/↓ walk, Enter picks, Escape clears.
8
+ Unwalked, the active option is the first result the words name outright, else the add
9
+ option: a result named only by abbreviation, which may stand in another card, is
10
+ taken by an arrow or a press and never by Enter alone.
11
+ -->
12
+ <script lang="ts">
13
+ import Icon from './icons/Icon.svelte';
14
+ import type { AddOption } from './checklists.js';
15
+ import './controls.css';
16
+
17
+ interface Props {
18
+ /** The input's id, and the base its listbox and options derive theirs from. */
19
+ id: string;
20
+ /** The input's accessible name and its words at rest. */
21
+ placeholder: string;
22
+ /** The options `query` offers, best first; the add option, where there is one, last. */
23
+ options: (query: string) => AddOption[];
24
+ onChoose: (option: AddOption, query: string) => void;
25
+ }
26
+ let { id, placeholder, options, onChoose }: Props = $props();
27
+
28
+ let query = $state('');
29
+ /** The walked option, `undefined` until an arrow or the pointer moves it. */
30
+ let walked = $state<number | undefined>();
31
+ let focused = $state(false);
32
+ let inputEl = $state<HTMLInputElement | undefined>();
33
+ const shown = $derived(query.trim() ? options(query.trim()) : []);
34
+ const open = $derived(focused && shown.length > 0);
35
+ const active = $derived.by(() => {
36
+ if (walked !== undefined) return Math.min(walked, shown.length - 1);
37
+ const strong = shown.findIndex((o) => o.strong);
38
+ if (strong >= 0) return strong;
39
+ const add = shown.findIndex((o) => o.add);
40
+ return add >= 0 ? add : 0;
41
+ });
42
+ const listId = $derived(`${id}-list`);
43
+ const optionId = (k: number): string => `${id}-opt-${k}`;
44
+
45
+ export function focus(): void {
46
+ inputEl?.focus();
47
+ }
48
+
49
+ function choose(k: number): void {
50
+ const option = shown[k];
51
+ if (!option) return;
52
+ const typed = query.trim();
53
+ query = '';
54
+ walked = undefined;
55
+ onChoose(option, typed);
56
+ }
57
+
58
+ function onkeydown(e: KeyboardEvent): void {
59
+ if (e.isComposing) return;
60
+ if (e.key === 'ArrowDown' && open) {
61
+ e.preventDefault();
62
+ walked = (active + 1) % shown.length;
63
+ } else if (e.key === 'ArrowUp' && open) {
64
+ e.preventDefault();
65
+ walked = (active - 1 + shown.length) % shown.length;
66
+ } else if (e.key === 'Enter' && open) {
67
+ e.preventDefault();
68
+ choose(active);
69
+ } else if (e.key === 'Escape' && query) {
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ query = '';
73
+ walked = undefined;
74
+ }
75
+ }
76
+ </script>
77
+
78
+ <div class="qm-matrix-add">
79
+ <span class="qm-matrix-add-field">
80
+ <Icon name="plus" class="qm-matrix-add-glyph" />
81
+ <input
82
+ bind:this={inputEl}
83
+ {id}
84
+ class="qm-input qm-focus-ring"
85
+ type="text"
86
+ role="combobox"
87
+ autocomplete="off"
88
+ aria-label={placeholder}
89
+ aria-autocomplete="list"
90
+ aria-expanded={open}
91
+ aria-controls={listId}
92
+ aria-activedescendant={open ? optionId(active) : undefined}
93
+ {placeholder}
94
+ value={query}
95
+ oninput={(e) => {
96
+ query = e.currentTarget.value;
97
+ walked = undefined;
98
+ }}
99
+ onfocus={() => (focused = true)}
100
+ onblur={() => (focused = false)}
101
+ {onkeydown}
102
+ />
103
+ </span>
104
+ <!-- Always mounted, so `aria-controls` names an element at rest too; empty, it draws
105
+ nothing. -->
106
+ <div
107
+ class="qm-menu-surface qm-matrix-add-list"
108
+ role="listbox"
109
+ id={listId}
110
+ aria-label={placeholder}
111
+ hidden={!open}
112
+ >
113
+ {#each shown as option, k (option.key)}
114
+ <!-- The input keeps focus and owns the keys; a press picks without taking it. -->
115
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
116
+ <div
117
+ class="qm-menu-item qm-matrix-add-option"
118
+ class:add={option.add}
119
+ role="option"
120
+ id={optionId(k)}
121
+ tabindex="-1"
122
+ aria-selected={k === active}
123
+ data-highlighted={k === active ? '' : undefined}
124
+ onpointerdown={(e) => e.preventDefault()}
125
+ onpointermove={() => (walked = k)}
126
+ onclick={() => choose(k)}
127
+ >
128
+ {#if option.add}<Icon name="plus" class="qm-matrix-add-glyph" />{/if}
129
+ <span class="qm-matrix-add-title">{option.title}</span>
130
+ {#if option.note}<span class="qm-matrix-add-note">{option.note}</span>{/if}
131
+ </div>
132
+ {/each}
133
+ </div>
134
+ </div>
135
+
136
+ <style>
137
+ /* The box's text, and every option's, starts past the glyph slot at its leading edge,
138
+ so a result reads in the column the typed words stand in and the add option's `+`
139
+ stands under the box's. */
140
+ .qm-matrix-add {
141
+ --_slot: calc(var(--_qm-space-2) * 2 + var(--_qm-glyph-control));
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: var(--_qm-space-half);
145
+ min-width: 0;
146
+ }
147
+ .qm-matrix-add-field {
148
+ position: relative;
149
+ display: flex;
150
+ align-items: center;
151
+ min-width: 0;
152
+ }
153
+ .qm-matrix-add :global(.qm-matrix-add-glyph) {
154
+ position: absolute;
155
+ inset-inline-start: var(--_qm-space-2);
156
+ width: var(--_qm-glyph-control);
157
+ height: var(--_qm-glyph-control);
158
+ color: var(--_qm-ink-label);
159
+ pointer-events: none;
160
+ }
161
+ /* At rest the box is the add chip it stands in for: no well, its words at the label
162
+ ink. The well comes up under the pointer, and stays while it holds the focus or
163
+ typed words. */
164
+ .qm-matrix-add-field .qm-input {
165
+ flex: 1;
166
+ min-width: 0;
167
+ padding-inline-start: var(--_slot);
168
+ transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
169
+ }
170
+ .qm-matrix-add-field .qm-input:placeholder-shown:not(:focus) {
171
+ background: transparent;
172
+ }
173
+ .qm-matrix-add-field .qm-input:placeholder-shown:not(:focus):hover {
174
+ background: var(--_qm-surface-hover);
175
+ }
176
+ /* The list is a menu, raised over the card rather than cut into it, so its options
177
+ read as choices and not as a second field. Its edge and inset come off each
178
+ option's, which keeps the column. */
179
+ .qm-matrix-add-list {
180
+ display: flex;
181
+ flex-direction: column;
182
+ }
183
+ .qm-matrix-add-list[hidden] {
184
+ display: none;
185
+ }
186
+ .qm-matrix-add-option {
187
+ position: relative;
188
+ display: flex;
189
+ align-items: baseline;
190
+ justify-content: space-between;
191
+ gap: var(--_qm-space-2);
192
+ padding-inline-start: calc(var(--_slot) - var(--_qm-space-half) - var(--_qm-border-width));
193
+ font-size: var(--_qm-text-body);
194
+ color: var(--_qm-ink);
195
+ }
196
+ .qm-matrix-add-option :global(.qm-matrix-add-glyph) {
197
+ inset-inline-start: calc(var(--_qm-space-2) - var(--_qm-space-half) - var(--_qm-border-width));
198
+ inset-block-start: 50%;
199
+ translate: 0 -50%;
200
+ }
201
+ /* The add option reads at the label ink: it offers a new item, where a result names
202
+ one the document already knows. */
203
+ .qm-matrix-add-option.add {
204
+ color: var(--_qm-ink-label);
205
+ }
206
+ .qm-matrix-add-note {
207
+ flex-shrink: 0;
208
+ font-size: var(--_qm-text-label);
209
+ color: var(--_qm-ink-label);
210
+ }
211
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { AddOption } from './checklists.js';
2
+ import './controls.css';
3
+ interface Props {
4
+ /** The input's id, and the base its listbox and options derive theirs from. */
5
+ id: string;
6
+ /** The input's accessible name and its words at rest. */
7
+ placeholder: string;
8
+ /** The options `query` offers, best first; the add option, where there is one, last. */
9
+ options: (query: string) => AddOption[];
10
+ onChoose: (option: AddOption, query: string) => void;
11
+ }
12
+ declare const MatrixAdd: import("svelte").Component<Props, {
13
+ focus: () => void;
14
+ }, "">;
15
+ type MatrixAdd = ReturnType<typeof MatrixAdd>;
16
+ export default MatrixAdd;