@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 +3 -1
- package/dist/core/theme.css +12 -1
- package/dist/visual/ArrayField.svelte +12 -1
- package/dist/visual/BooleanField.svelte +7 -0
- package/dist/visual/Card.svelte +60 -17
- package/dist/visual/Field.svelte +10 -2
- package/dist/visual/Field.svelte.d.ts +3 -0
- package/dist/visual/MatrixAdd.svelte +211 -0
- package/dist/visual/MatrixAdd.svelte.d.ts +16 -0
- package/dist/visual/MatrixField.svelte +329 -82
- package/dist/visual/MatrixField.svelte.d.ts +8 -5
- package/dist/visual/ObjectField.svelte +23 -6
- package/dist/visual/ObjectField.svelte.d.ts +3 -0
- package/dist/visual/VisualEditorInner.svelte +15 -0
- package/dist/visual/checklists.d.ts +82 -0
- package/dist/visual/checklists.js +171 -0
- package/dist/visual/strings.d.ts +15 -1
- package/dist/visual/strings.js +6 -0
- package/dist/visual/structure.d.ts +59 -31
- package/dist/visual/structure.js +110 -44
- package/package.json +2 -2
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,
|
|
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
|
|
package/dist/core/theme.css
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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;
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -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;
|