@quillmark/svelte 0.1.1 → 0.2.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 +15 -11
- package/THEMING.md +42 -23
- package/dist/core/address.d.ts +39 -4
- package/dist/core/address.js +47 -19
- package/dist/core/codec/encode.js +4 -1
- package/dist/core/codec/field.d.ts +1 -1
- package/dist/core/codec/prose.css +41 -18
- package/dist/core/codec/table.js +3 -1
- package/dist/core/errors.d.ts +15 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +5 -4
- package/dist/core/lifecycle.d.ts +21 -5
- package/dist/core/lifecycle.js +34 -8
- package/dist/core/rebind.svelte.d.ts +16 -2
- package/dist/core/rebind.svelte.js +26 -8
- package/dist/core/theme.css +43 -18
- package/dist/preset/recipes.css +55 -8
- package/dist/preset/scale.css +5 -4
- package/dist/preview/Preview.svelte +10 -9
- package/dist/preview/Preview.svelte.d.ts +5 -5
- package/dist/preview/bridge.d.ts +6 -4
- package/dist/preview/bridge.js +17 -7
- package/dist/preview/controller.d.ts +13 -6
- package/dist/preview/controller.js +5 -2
- package/dist/preview/geometry.d.ts +16 -1
- package/dist/preview/geometry.js +20 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/index.js +7 -0
- package/dist/preview/overlay.d.ts +2 -1
- package/dist/preview/overlay.js +24 -9
- package/dist/preview/paint.js +4 -0
- package/dist/visual/ArrayField.svelte +26 -20
- package/dist/visual/ArrayField.svelte.d.ts +4 -3
- package/dist/visual/Card.svelte +21 -19
- package/dist/visual/Card.svelte.d.ts +8 -3
- package/dist/visual/DiagnosticList.svelte +1 -1
- package/dist/visual/EnumField.svelte +34 -14
- package/dist/visual/EnumField.svelte.d.ts +4 -3
- package/dist/visual/Field.svelte +87 -25
- package/dist/visual/Field.svelte.d.ts +11 -7
- package/dist/visual/FieldHint.svelte +59 -10
- package/dist/visual/FormatPopover.svelte +3 -1
- package/dist/visual/ObjectField.svelte +21 -2
- package/dist/visual/ObjectField.svelte.d.ts +3 -1
- package/dist/visual/ProseArrayElement.svelte +1 -6
- package/dist/visual/ProseArrayElement.svelte.d.ts +0 -1
- package/dist/visual/ProseField.svelte +20 -12
- package/dist/visual/ProseField.svelte.d.ts +5 -3
- package/dist/visual/TipsCard.svelte +1 -1
- package/dist/visual/VisualEditor.svelte +12 -13
- package/dist/visual/VisualEditor.svelte.d.ts +2 -3
- package/dist/visual/VisualEditorInner.svelte +140 -66
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
- package/dist/visual/controls.css +14 -11
- package/dist/visual/diagnostics.d.ts +12 -20
- package/dist/visual/diagnostics.js +6 -18
- package/dist/visual/index.d.ts +1 -0
- package/dist/visual/index.js +7 -0
- package/dist/visual/leaves.d.ts +47 -0
- package/dist/visual/leaves.js +31 -0
- package/dist/visual/props.d.ts +27 -6
- package/dist/visual/tips.d.ts +3 -1
- package/dist/visual/tips.js +5 -2
- package/package.json +2 -2
package/dist/preview/index.js
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
// (the codec, the VisualEditor) so a `@quillmark/preview` promotion stays a
|
|
6
6
|
// re-export, not a refactor: it may reach `/core` (the shared WASM boundary) and
|
|
7
7
|
// nothing else. Enforced by `check:deps`, which walks the subpath's import graph.
|
|
8
|
+
// The theme derivation, which the page slots and the overlay read through `var()`.
|
|
9
|
+
// Imported at the BARREL because a subpath is what a consumer gets: this surface
|
|
10
|
+
// reaches modules inside `core/` and never its entry, so a sheet hanging off that
|
|
11
|
+
// entry arrives only for a consumer importing `/core` for some other reason. The
|
|
12
|
+
// sheet rather than the entry, because the barrel needs the derivation and not
|
|
13
|
+
// `init`. `check:deps` (rule 4) walks this file's own graph and holds the reach.
|
|
14
|
+
import '../core/theme.css';
|
|
8
15
|
export { createPreview } from './controller.js';
|
|
9
16
|
export { default as Preview } from './Preview.svelte';
|
|
10
17
|
// What the preview SAYS when there is nothing to paint. Keyed and partial; the
|
|
@@ -3,7 +3,8 @@ import type { PageSlot } from './paint.js';
|
|
|
3
3
|
export interface OverlayController {
|
|
4
4
|
/** Re-read geometry and rebuild every box; call after a layout-affecting refresh. */
|
|
5
5
|
refresh(): void;
|
|
6
|
-
/** Bloom `field`
|
|
6
|
+
/** Bloom the boxes at `field` AND under it: transient, and a no-op when `field`
|
|
7
|
+
* is already the marked one. */
|
|
7
8
|
flashField(field: string): void;
|
|
8
9
|
destroy(): void;
|
|
9
10
|
}
|
package/dist/preview/overlay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bloom, bloomTiming, primeWash } from '../core/bloom.js';
|
|
2
|
-
import { rectToPercent, applyPercentRect } from './geometry.js';
|
|
2
|
+
import { boxesForField, rectToPercent, applyPercentRect } from './geometry.js';
|
|
3
3
|
const FIELD_ATTR = 'data-qm-field';
|
|
4
4
|
export function createOverlay(session, slots) {
|
|
5
5
|
const layers = slots.map((slot) => {
|
|
@@ -20,32 +20,47 @@ export function createOverlay(session, slots) {
|
|
|
20
20
|
let flash;
|
|
21
21
|
/** field → its boxes, rebuilt with them. The bloom's only lookup. */
|
|
22
22
|
let byField = new Map();
|
|
23
|
+
// The boxes a flash covers: the address's own, plus every one UNDER it, since the
|
|
24
|
+
// boxes are keyed as `regions()` names them (`main.references.0`) and an
|
|
25
|
+
// editor-side signal names the declared field (`main.references`). Resolved per
|
|
26
|
+
// apply rather than at `flashField`, so a rebuild picks up boxes the recompile
|
|
27
|
+
// moved or added.
|
|
28
|
+
function flashed(field) {
|
|
29
|
+
const els = [];
|
|
30
|
+
for (const [key, group] of byField) {
|
|
31
|
+
if (key === field || key.startsWith(`${field}.`))
|
|
32
|
+
els.push(...group);
|
|
33
|
+
}
|
|
34
|
+
return els;
|
|
35
|
+
}
|
|
23
36
|
// Start or RESUME the bloom on whatever boxes exist for it now: one path, because
|
|
24
37
|
// starting is resuming at ~0. A field surfaces several boxes and they share
|
|
25
38
|
// `startedAt`, so they bloom in step rather than shimmering unevenly.
|
|
26
39
|
function applyFlash() {
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
if (!flash)
|
|
41
|
+
return;
|
|
42
|
+
const els = flashed(flash.field);
|
|
43
|
+
if (!els.length)
|
|
29
44
|
return;
|
|
30
45
|
const elapsed = performance.now() - flash.startedAt;
|
|
31
46
|
const timing = bloomTiming(els[0]);
|
|
32
47
|
for (const el of els)
|
|
33
48
|
bloom(el, elapsed, timing);
|
|
34
49
|
}
|
|
35
|
-
// Field names come from `regions()` (the only session query that enumerates
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// or widget field, so a nameless-of-content field contributes no boxes.
|
|
50
|
+
// Field names come from `regions()` (the only session query that enumerates them;
|
|
51
|
+
// Preview carries no schema); the rects from `boxesForField`, whose fallback is
|
|
52
|
+
// what makes every address `regions()` names draw.
|
|
39
53
|
function build() {
|
|
40
54
|
for (const layer of layers)
|
|
41
55
|
layer.replaceChildren();
|
|
42
56
|
byField = new Map();
|
|
57
|
+
const regions = session.regions();
|
|
43
58
|
const fields = new Set();
|
|
44
|
-
for (const region of
|
|
59
|
+
for (const region of regions)
|
|
45
60
|
fields.add(region.field);
|
|
46
61
|
for (const field of fields) {
|
|
47
62
|
const els = [];
|
|
48
|
-
for (const box of session.fieldBoxes(field)) {
|
|
63
|
+
for (const box of boxesForField(field, session.fieldBoxes(field), regions)) {
|
|
49
64
|
const layer = layers[box.page];
|
|
50
65
|
const slot = slots[box.page];
|
|
51
66
|
if (!layer || !slot)
|
package/dist/preview/paint.js
CHANGED
|
@@ -43,6 +43,10 @@ onPaintError) {
|
|
|
43
43
|
// the paper.
|
|
44
44
|
const el = document.createElement('div');
|
|
45
45
|
el.className = 'qm-page';
|
|
46
|
+
// The page's index, in the DOM. `pageByEl` is this module's and a consumer
|
|
47
|
+
// drawing an overlay of its own needs the number: position among siblings is
|
|
48
|
+
// the only handle without it, which is right today and is not a contract.
|
|
49
|
+
el.dataset.page = String(page);
|
|
46
50
|
Object.assign(el.style, {
|
|
47
51
|
position: 'relative',
|
|
48
52
|
boxSizing: 'border-box',
|
|
@@ -55,20 +55,9 @@
|
|
|
55
55
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
56
56
|
descriptionId?: string;
|
|
57
57
|
onCommit: (arr: unknown[]) => void;
|
|
58
|
-
/** A prose element gained focus: joins the field in the focus federation. */
|
|
59
|
-
onFocusEl?: () => void;
|
|
60
58
|
}
|
|
61
|
-
let {
|
|
62
|
-
|
|
63
|
-
items,
|
|
64
|
-
label,
|
|
65
|
-
required,
|
|
66
|
-
description,
|
|
67
|
-
labelId,
|
|
68
|
-
descriptionId,
|
|
69
|
-
onCommit,
|
|
70
|
-
onFocusEl
|
|
71
|
-
}: Props = $props();
|
|
59
|
+
let { value, items, label, required, description, labelId, descriptionId, onCommit }: Props =
|
|
60
|
+
$props();
|
|
72
61
|
|
|
73
62
|
// The ELEMENT control is the item schema's own; an array declaring no `items`
|
|
74
63
|
// has text elements.
|
|
@@ -102,7 +91,14 @@
|
|
|
102
91
|
// Every path that drops an id deletes its entry: `bind:this` teardown nulls the
|
|
103
92
|
// VALUE on unmount and leaves the key, so a card that outlives its elements
|
|
104
93
|
// accumulates one dead key per element ever created.
|
|
105
|
-
|
|
94
|
+
//
|
|
95
|
+
// `$state` for the BINDING's sake, not this component's: nothing here reads `els`
|
|
96
|
+
// reactively (every read is inside an event handler or a post-flush focus hop), but
|
|
97
|
+
// `bind:this` into a property of a plain object is a write Svelte cannot track, and
|
|
98
|
+
// it says so once per element per render. Thirteen lines on one memo's first paint,
|
|
99
|
+
// in the console a consumer is reading to find its own defects. Same shape
|
|
100
|
+
// {@link Card} keeps its header/panel refs in.
|
|
101
|
+
const els: Record<string, { focus: () => void } | undefined> = $state({});
|
|
106
102
|
let addEl: HTMLButtonElement | undefined = $state();
|
|
107
103
|
let rootEl: HTMLElement | undefined = $state();
|
|
108
104
|
|
|
@@ -151,10 +147,11 @@
|
|
|
151
147
|
// key does: the button under the pointer is part of what it destroys.
|
|
152
148
|
focusAfterFlush(next[Math.max(k - 1, 0)]);
|
|
153
149
|
}
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
150
|
+
/** Take the caret: the FIRST element, or the add affordance when the list is empty;
|
|
151
|
+
* which is then the only thing there is to land on, and the next thing the user
|
|
152
|
+
* wants anyway. Reached by a label click and by the editor's landing verbs, which
|
|
153
|
+
* ask one function so they cannot disagree (`Field`, `leaves.ts`). */
|
|
154
|
+
export function focus(): void {
|
|
158
155
|
if (ids.length === 0) return void addEl?.focus();
|
|
159
156
|
const first = els[ids[0]];
|
|
160
157
|
if (first) return first.focus();
|
|
@@ -162,6 +159,16 @@
|
|
|
162
159
|
// nothing about focusing it that the DOM does not already know.
|
|
163
160
|
rootEl?.querySelector<HTMLTextAreaElement>('.qm-array-row textarea')?.focus();
|
|
164
161
|
}
|
|
162
|
+
/** Take the caret to element `k`: what a landing on an element ADDRESS resolves to
|
|
163
|
+
* (`leaves.ts`). The index resolves to the element's session id here, at the call,
|
|
164
|
+
* never carried as one — an index is stale the moment anything above it splices.
|
|
165
|
+
* Past the live list it falls back to {@link focus}: the field is right and the row
|
|
166
|
+
* is gone, which is a landing off a compile the document has moved past. */
|
|
167
|
+
export function focusElement(k: number): void {
|
|
168
|
+
const el = els[ids[k]];
|
|
169
|
+
if (el) el.focus();
|
|
170
|
+
else focus();
|
|
171
|
+
}
|
|
165
172
|
/** Focus element `id` after the flush, never in the same tick: a mutation commits
|
|
166
173
|
* the array BY VALUE, so the parent re-derives and the row does not exist until
|
|
167
174
|
* then. `undefined` is the empty list: the add affordance.
|
|
@@ -216,7 +223,7 @@
|
|
|
216
223
|
{label}
|
|
217
224
|
id={labelId}
|
|
218
225
|
{descriptionId}
|
|
219
|
-
onActivate={
|
|
226
|
+
onActivate={focus}
|
|
220
227
|
{required}
|
|
221
228
|
{description}
|
|
222
229
|
/>
|
|
@@ -237,7 +244,6 @@
|
|
|
237
244
|
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
238
245
|
onChange={(rt) => commitElement(k, rt)}
|
|
239
246
|
onKey={(e) => onElementKey(e, k)}
|
|
240
|
-
{onFocusEl}
|
|
241
247
|
/>
|
|
242
248
|
{:else if control === 'object'}
|
|
243
249
|
<textarea
|
|
@@ -16,9 +16,10 @@ interface Props {
|
|
|
16
16
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
17
17
|
descriptionId?: string;
|
|
18
18
|
onCommit: (arr: unknown[]) => void;
|
|
19
|
-
/** A prose element gained focus: joins the field in the focus federation. */
|
|
20
|
-
onFocusEl?: () => void;
|
|
21
19
|
}
|
|
22
|
-
declare const ArrayField: import("svelte").Component<Props, {
|
|
20
|
+
declare const ArrayField: import("svelte").Component<Props, {
|
|
21
|
+
focus: () => void;
|
|
22
|
+
focusElement: (k: number) => void;
|
|
23
|
+
}, "">;
|
|
23
24
|
type ArrayField = ReturnType<typeof ArrayField>;
|
|
24
25
|
export default ArrayField;
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import Icon from './icons/Icon.svelte';
|
|
17
17
|
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
18
18
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
19
|
-
import type {
|
|
19
|
+
import type { LeafRegistry } from './leaves.js';
|
|
20
20
|
import type { CardModel, FieldModel } from './structure.js';
|
|
21
21
|
import { placeFields, humanize, initialExpandedGroup } from './structure.js';
|
|
22
22
|
import type { FieldDomIds } from './domid.js';
|
|
@@ -51,12 +51,17 @@
|
|
|
51
51
|
isLast: boolean;
|
|
52
52
|
kinds: string[];
|
|
53
53
|
ops: CardOps;
|
|
54
|
+
/** How an option `ops.enumAllowed` refuses draws. Nothing about it is per-card,
|
|
55
|
+
* so it sits beside the bundle rather than in it. */
|
|
56
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
54
57
|
onFocus?: (addr: Addr) => void;
|
|
55
58
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
56
59
|
onChange?: (addr: Addr) => void;
|
|
57
60
|
onError?: EditorErrorHandler;
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
/** The editor's leaf registry (`leaves.ts`): every field in this card registers
|
|
62
|
+
* its landing handle, which is what the editor's `focusField`/`setCaret`
|
|
63
|
+
* resolve a `DocPath` to. */
|
|
64
|
+
leaves?: LeafRegistry;
|
|
60
65
|
}
|
|
61
66
|
let {
|
|
62
67
|
card,
|
|
@@ -67,12 +72,12 @@
|
|
|
67
72
|
isLast,
|
|
68
73
|
kinds,
|
|
69
74
|
ops,
|
|
75
|
+
enumDisallowed,
|
|
70
76
|
onFocus,
|
|
71
77
|
onCaretMove,
|
|
72
78
|
onChange,
|
|
73
79
|
onError,
|
|
74
|
-
|
|
75
|
-
unregister
|
|
80
|
+
leaves
|
|
76
81
|
}: Props = $props();
|
|
77
82
|
|
|
78
83
|
// Local title reconcile (external change only), like the scalar controls.
|
|
@@ -229,10 +234,9 @@
|
|
|
229
234
|
not a persistent box. `data-value` falls back to the
|
|
230
235
|
placeholder so an empty title still reserves its resolved-title width. -->
|
|
231
236
|
<span class="qm-card-title-sizer" data-value={localTitle || card.titlePlaceholder}>
|
|
232
|
-
<!-- `qm-focus-ring` is
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
edit unringed while a tab into it draws one. -->
|
|
237
|
+
<!-- `qm-focus-ring` is this control's ONLY focus indicator: the ink step
|
|
238
|
+
cannot be one, since hover already takes that step. `:focus-visible`, so
|
|
239
|
+
a press enters the edit unringed while a tab into it draws one. -->
|
|
236
240
|
<input
|
|
237
241
|
class="qm-card-title qm-focus-ring"
|
|
238
242
|
value={localTitle}
|
|
@@ -361,8 +365,7 @@
|
|
|
361
365
|
{onCaretMove}
|
|
362
366
|
{onChange}
|
|
363
367
|
{onError}
|
|
364
|
-
{
|
|
365
|
-
{unregister}
|
|
368
|
+
{leaves}
|
|
366
369
|
/>
|
|
367
370
|
<DiagnosticList diagnostics={ops.diagFor(undefined)} />
|
|
368
371
|
</div>
|
|
@@ -385,17 +388,17 @@
|
|
|
385
388
|
provenance={card.provenance[f.name]}
|
|
386
389
|
{doc}
|
|
387
390
|
{quill}
|
|
388
|
-
|
|
391
|
+
addr={ops.makeAddr(f.name)}
|
|
389
392
|
leafKey={ops.leafKey(f.name)}
|
|
390
393
|
domIds={ops.domIds(f.name)}
|
|
391
394
|
onCommitScalar={(v) => ops.commit(f.name, v)}
|
|
392
395
|
optionAllowed={(v) => ops.enumAllowed(f.name, v)}
|
|
396
|
+
{enumDisallowed}
|
|
393
397
|
{onFocus}
|
|
394
398
|
{onCaretMove}
|
|
395
399
|
{onChange}
|
|
396
400
|
{onError}
|
|
397
|
-
{
|
|
398
|
-
{unregister}
|
|
401
|
+
{leaves}
|
|
399
402
|
diagnostics={ops.diagFor(f.name)}
|
|
400
403
|
/>
|
|
401
404
|
{/each}
|
|
@@ -636,9 +639,8 @@
|
|
|
636
639
|
The ACTION COLUMN is minted here and spent one level down, on the field
|
|
637
640
|
(`Field.svelte` holds why it is the field's inset and not this container's). It
|
|
638
641
|
is declared on the query container because both of its consumers hang off one:
|
|
639
|
-
the fields inside, and the capacity ramp below, which
|
|
640
|
-
|
|
641
|
-
reservation already narrowed. */
|
|
642
|
+
the fields inside, and the capacity ramp below, which carries the column in its
|
|
643
|
+
own arithmetic because the query reads a box the reservation has not narrowed. */
|
|
642
644
|
.qm-section,
|
|
643
645
|
.qm-group-panel-inner {
|
|
644
646
|
--action-col: calc(var(--_qm-tap-min) + var(--_qm-space-2));
|
|
@@ -741,10 +743,10 @@
|
|
|
741
743
|
padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-space);
|
|
742
744
|
font: inherit;
|
|
743
745
|
font-size: var(--_qm-text-label);
|
|
744
|
-
font-weight: var(--_qm-weight-
|
|
746
|
+
font-weight: var(--_qm-weight-label);
|
|
745
747
|
line-height: var(--_qm-leading-tight);
|
|
746
748
|
cursor: pointer;
|
|
747
|
-
color: var(--_qm-ink-
|
|
749
|
+
color: var(--_qm-ink-label);
|
|
748
750
|
text-align: left;
|
|
749
751
|
transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
750
752
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
2
2
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { LeafRegistry } from './leaves.js';
|
|
4
4
|
import type { CardModel } from './structure.js';
|
|
5
5
|
import type { FieldDomIds } from './domid.js';
|
|
6
6
|
/** The per-card operation bundle the VisualEditor builds (all resolve id→index lazily). */
|
|
@@ -28,12 +28,17 @@ interface Props {
|
|
|
28
28
|
isLast: boolean;
|
|
29
29
|
kinds: string[];
|
|
30
30
|
ops: CardOps;
|
|
31
|
+
/** How an option `ops.enumAllowed` refuses draws. Nothing about it is per-card,
|
|
32
|
+
* so it sits beside the bundle rather than in it. */
|
|
33
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
31
34
|
onFocus?: (addr: Addr) => void;
|
|
32
35
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
33
36
|
onChange?: (addr: Addr) => void;
|
|
34
37
|
onError?: EditorErrorHandler;
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
/** The editor's leaf registry (`leaves.ts`): every field in this card registers
|
|
39
|
+
* its landing handle, which is what the editor's `focusField`/`setCaret`
|
|
40
|
+
* resolve a `DocPath` to. */
|
|
41
|
+
leaves?: LeafRegistry;
|
|
37
42
|
}
|
|
38
43
|
declare const Card: import("svelte").Component<Props, {
|
|
39
44
|
revealLeaf: (key: string) => void;
|
|
@@ -34,13 +34,23 @@
|
|
|
34
34
|
/** The parked `description` (FieldLabel): announced after the name. */
|
|
35
35
|
describedBy?: string;
|
|
36
36
|
onCommit: (v: string | undefined) => void;
|
|
37
|
-
/** Consumer policy: `false`
|
|
38
|
-
*
|
|
39
|
-
* stripped or mutated. The UNSET sentinel is exempt: clear-to-default always works. */
|
|
37
|
+
/** Consumer policy: `false` marks an option unavailable, so it can't be picked.
|
|
38
|
+
* The UNSET sentinel is exempt: clear-to-default always works. */
|
|
40
39
|
optionAllowed?: (value: string) => boolean;
|
|
40
|
+
/** How a refused option draws: greyed and unpickable, or left out of the list. */
|
|
41
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
41
42
|
}
|
|
42
|
-
let {
|
|
43
|
-
|
|
43
|
+
let {
|
|
44
|
+
value,
|
|
45
|
+
values,
|
|
46
|
+
fallback,
|
|
47
|
+
label,
|
|
48
|
+
id,
|
|
49
|
+
describedBy,
|
|
50
|
+
onCommit,
|
|
51
|
+
optionAllowed,
|
|
52
|
+
enumDisallowed = 'disable'
|
|
53
|
+
}: Props = $props();
|
|
44
54
|
|
|
45
55
|
// The sentinel's option value: a namespaced marker that no schema-authored
|
|
46
56
|
// enum member would ever be (`values` are classification markings, seal ids, and
|
|
@@ -107,15 +117,25 @@
|
|
|
107
117
|
<Select.Item class="qm-menu-item qm-select-item" value={UNSET} label={ghostText}>
|
|
108
118
|
<span class="qm-select-ghost">{ghostText}</span>
|
|
109
119
|
</Select.Item>
|
|
120
|
+
<!-- A refused option still draws under `'disable'`, and under EITHER
|
|
121
|
+
policy when it is the one selected: a listbox whose selected value
|
|
122
|
+
has no row shows nothing for what the document says, and offers none
|
|
123
|
+
for the primitive to mark, type-ahead to, or key onto
|
|
124
|
+
(VISUAL_EDITOR §"Enum policy"). Off `local.value`, not `value`: the
|
|
125
|
+
row that exists is the row the control has selected, through the
|
|
126
|
+
window where an own pick has not reconciled back. -->
|
|
110
127
|
{#each values as v (v)}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
{@const allowed = optionAllowed?.(v) !== false}
|
|
129
|
+
{#if allowed || enumDisallowed === 'disable' || v === local.value}
|
|
130
|
+
<Select.Item
|
|
131
|
+
class="qm-menu-item qm-select-item"
|
|
132
|
+
value={v}
|
|
133
|
+
label={dash(v)}
|
|
134
|
+
disabled={!allowed}
|
|
135
|
+
>
|
|
136
|
+
{dash(v)}
|
|
137
|
+
</Select.Item>
|
|
138
|
+
{/if}
|
|
119
139
|
{/each}
|
|
120
140
|
</Select.Viewport>
|
|
121
141
|
</div>
|
|
@@ -158,7 +178,7 @@
|
|
|
158
178
|
.qm-select-content :global(.qm-select-item[data-selected]) {
|
|
159
179
|
font-weight: var(--_qm-weight-label);
|
|
160
180
|
}
|
|
161
|
-
/* Consumer policy: offered
|
|
181
|
+
/* Consumer policy, for a refused option the list still draws: offered, unpickable. */
|
|
162
182
|
.qm-select-content :global(.qm-select-item[data-disabled]) {
|
|
163
183
|
opacity: var(--_qm-opacity-muted);
|
|
164
184
|
cursor: default;
|
|
@@ -15,10 +15,11 @@ interface Props {
|
|
|
15
15
|
/** The parked `description` (FieldLabel): announced after the name. */
|
|
16
16
|
describedBy?: string;
|
|
17
17
|
onCommit: (v: string | undefined) => void;
|
|
18
|
-
/** Consumer policy: `false`
|
|
19
|
-
*
|
|
20
|
-
* stripped or mutated. The UNSET sentinel is exempt: clear-to-default always works. */
|
|
18
|
+
/** Consumer policy: `false` marks an option unavailable, so it can't be picked.
|
|
19
|
+
* The UNSET sentinel is exempt: clear-to-default always works. */
|
|
21
20
|
optionAllowed?: (value: string) => boolean;
|
|
21
|
+
/** How a refused option draws: greyed and unpickable, or left out of the list. */
|
|
22
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
22
23
|
}
|
|
23
24
|
declare const EnumField: import("svelte").Component<Props, {}, "">;
|
|
24
25
|
type EnumField = ReturnType<typeof EnumField>;
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<script lang="ts">
|
|
17
17
|
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
18
18
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
19
|
-
import type {
|
|
19
|
+
import type { LeafRegistry } from './leaves.js';
|
|
20
20
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
21
21
|
import { enumValues, ghostDefault, stringifyGhost } from './structure.js';
|
|
22
22
|
import type { FieldDomIds } from './domid.js';
|
|
@@ -42,22 +42,26 @@
|
|
|
42
42
|
doc: Document;
|
|
43
43
|
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
44
44
|
quill: Quill;
|
|
45
|
-
/** LIVE
|
|
46
|
-
|
|
45
|
+
/** This field's LIVE address (getter-`card`, so a card reorder re-targets in
|
|
46
|
+
* place): the prose leaf commits to it, and a focus reports it. */
|
|
47
|
+
addr: Addr;
|
|
47
48
|
leafKey: string;
|
|
48
49
|
/** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
|
|
49
50
|
* the label and the control find each other. */
|
|
50
51
|
domIds: FieldDomIds;
|
|
51
52
|
onCommitScalar: (value: unknown) => void;
|
|
52
|
-
/** Enum-option policy: `false`
|
|
53
|
-
* control reads
|
|
53
|
+
/** Enum-option policy: `false` marks that option unavailable. Only the enum
|
|
54
|
+
* control reads this pair. */
|
|
54
55
|
optionAllowed?: (value: string) => boolean;
|
|
56
|
+
/** How a refused option draws: greyed (default) or left out. */
|
|
57
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
55
58
|
onFocus?: (addr: Addr) => void;
|
|
56
59
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
57
60
|
onChange?: (addr: Addr) => void;
|
|
58
61
|
onError?: EditorErrorHandler;
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
/** The editor's leaf registry (`leaves.ts`): a form control registers its
|
|
63
|
+
* landing handle here, a prose leaf its controller from inside `ProseField`. */
|
|
64
|
+
leaves?: LeafRegistry;
|
|
61
65
|
diagnostics?: Diagnostic[];
|
|
62
66
|
}
|
|
63
67
|
let {
|
|
@@ -67,17 +71,17 @@
|
|
|
67
71
|
provenance,
|
|
68
72
|
doc,
|
|
69
73
|
quill,
|
|
70
|
-
|
|
74
|
+
addr,
|
|
71
75
|
leafKey,
|
|
72
76
|
domIds,
|
|
73
77
|
onCommitScalar,
|
|
74
78
|
optionAllowed,
|
|
79
|
+
enumDisallowed,
|
|
75
80
|
onFocus,
|
|
76
81
|
onCaretMove,
|
|
77
82
|
onChange,
|
|
78
83
|
onError,
|
|
79
|
-
|
|
80
|
-
unregister,
|
|
84
|
+
leaves,
|
|
81
85
|
diagnostics
|
|
82
86
|
}: Props = $props();
|
|
83
87
|
|
|
@@ -103,18 +107,66 @@
|
|
|
103
107
|
// nothing, and silently.
|
|
104
108
|
const describedBy = $derived(field.description ? domIds.description : undefined);
|
|
105
109
|
|
|
106
|
-
// Focus
|
|
107
|
-
//
|
|
108
|
-
//
|
|
110
|
+
// ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
|
|
111
|
+
// A label click and the editor's `focusField`/`setCaret` ask the same question, so
|
|
112
|
+
// they read the same function and cannot land in different places. The four
|
|
113
|
+
// labelable controls are reached through the DOM id `for` already points at; the
|
|
114
|
+
// rest own what focusing means and expose `focus()`, because it differs: a PM view
|
|
115
|
+
// restores a selection, a date field lands on its first segment, an array lands on
|
|
116
|
+
// its first element or on the add affordance that is all an empty one has, an
|
|
117
|
+
// object on its first property.
|
|
109
118
|
let proseEl = $state<{ focus: () => void } | undefined>();
|
|
110
119
|
let dateEl = $state<{ focus: () => void } | undefined>();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
let arrayEl = $state<{ focus: () => void; focusElement: (k: number) => void } | undefined>();
|
|
121
|
+
let objectEl = $state<{ focus: () => void } | undefined>();
|
|
122
|
+
function focusControl(): void {
|
|
123
|
+
const owner = proseEl ?? dateEl ?? arrayEl ?? objectEl;
|
|
124
|
+
if (owner) return owner.focus();
|
|
125
|
+
document.getElementById(domIds.control)?.focus();
|
|
126
|
+
}
|
|
127
|
+
/** The array's per-ELEMENT landing, for the addresses the preview mints under an
|
|
128
|
+
* array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
|
|
129
|
+
function focusElement(k: number): void {
|
|
130
|
+
arrayEl?.focusElement(k);
|
|
131
|
+
}
|
|
132
|
+
// Only where `for` cannot reach; the labelable four are the browser's own, and a
|
|
133
|
+
// second handler over them would be a focus the label already placed.
|
|
134
|
+
const onActivate = $derived(labelable ? undefined : focusControl);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
138
|
+
* control: `bloomInside` appends an inset child and an `<input>` holds none.
|
|
139
|
+
*
|
|
140
|
+
* A prose leaf is absent here — it registers its own controller from inside
|
|
141
|
+
* `ProseField`, carrying the codec seam this handle has no half of — and reactive
|
|
142
|
+
* rather than mount-once, because a retype can swap the control under a leaf key
|
|
143
|
+
* that does not remount.
|
|
144
|
+
*
|
|
145
|
+
* An array carries the per-element lane as well; no other control has elements for
|
|
146
|
+
* an address to name.
|
|
147
|
+
*/
|
|
148
|
+
let controlEl = $state<HTMLElement | undefined>();
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (field.control === 'prose' || !controlEl || !leaves) return;
|
|
151
|
+
const key = leafKey;
|
|
152
|
+
const registry = leaves;
|
|
153
|
+
registry.registerControl(key, {
|
|
154
|
+
focus: focusControl,
|
|
155
|
+
focusElement: field.control === 'array' ? focusElement : undefined,
|
|
156
|
+
el: controlEl
|
|
157
|
+
});
|
|
158
|
+
return () => registry.unregisterControl(key);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A form control has no controller to report its focus through, so the WRAPPER
|
|
163
|
+
* reports: `focusin` bubbles, so one handler covers a plain input, an array's N
|
|
164
|
+
* elements and an object's properties alike, and the active leaf names a scalar
|
|
165
|
+
* field the way it names a prose one. A prose leaf reports through its own
|
|
166
|
+
* controller — the source that drives its caret signals too — and is excluded here
|
|
167
|
+
* rather than counted twice.
|
|
168
|
+
*/
|
|
169
|
+
const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
|
|
118
170
|
</script>
|
|
119
171
|
|
|
120
172
|
<div class="qm-field" class:cell={span === 'cell'} class:lone={span === 'lone'}>
|
|
@@ -129,13 +181,13 @@
|
|
|
129
181
|
description={field.description}
|
|
130
182
|
/>
|
|
131
183
|
{/if}
|
|
132
|
-
<div class="qm-field-control">
|
|
184
|
+
<div class="qm-field-control" bind:this={controlEl} onfocusin={reportFocus}>
|
|
133
185
|
{#if field.control === 'prose'}
|
|
134
186
|
<ProseField
|
|
135
187
|
{quill}
|
|
136
188
|
bind:this={proseEl}
|
|
137
189
|
{doc}
|
|
138
|
-
addr
|
|
190
|
+
{addr}
|
|
139
191
|
inline={field.inline}
|
|
140
192
|
plaintext={field.plaintext}
|
|
141
193
|
labelledBy={domIds.label}
|
|
@@ -145,8 +197,7 @@
|
|
|
145
197
|
{onCaretMove}
|
|
146
198
|
{onChange}
|
|
147
199
|
{onError}
|
|
148
|
-
{
|
|
149
|
-
{unregister}
|
|
200
|
+
{leaves}
|
|
150
201
|
/>
|
|
151
202
|
{:else if field.control === 'enum'}
|
|
152
203
|
<EnumField
|
|
@@ -157,6 +208,7 @@
|
|
|
157
208
|
{describedBy}
|
|
158
209
|
onCommit={onCommitScalar}
|
|
159
210
|
{optionAllowed}
|
|
211
|
+
{enumDisallowed}
|
|
160
212
|
/>
|
|
161
213
|
{:else if field.control === 'number'}
|
|
162
214
|
<NumberField
|
|
@@ -186,6 +238,7 @@
|
|
|
186
238
|
/>
|
|
187
239
|
{:else if field.control === 'array'}
|
|
188
240
|
<ArrayField
|
|
241
|
+
bind:this={arrayEl}
|
|
189
242
|
value={value as unknown[] | undefined}
|
|
190
243
|
items={field.schema.items}
|
|
191
244
|
label={field.label}
|
|
@@ -194,10 +247,10 @@
|
|
|
194
247
|
labelId={domIds.label}
|
|
195
248
|
descriptionId={domIds.description}
|
|
196
249
|
onCommit={onCommitScalar}
|
|
197
|
-
onFocusEl={() => onFocus?.(proseAddr)}
|
|
198
250
|
/>
|
|
199
251
|
{:else if field.control === 'object'}
|
|
200
252
|
<ObjectField
|
|
253
|
+
bind:this={objectEl}
|
|
201
254
|
value={value as Record<string, unknown> | undefined}
|
|
202
255
|
properties={field.schema.properties}
|
|
203
256
|
label={field.label}
|
|
@@ -262,6 +315,15 @@
|
|
|
262
315
|
.qm-field.cell {
|
|
263
316
|
grid-column: span 1;
|
|
264
317
|
}
|
|
318
|
+
/* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
|
|
319
|
+
`.qm-prose` is: an inset child over the control, since an `<input>` takes none.
|
|
320
|
+
The radius is the control's own, so the wash's corners are the box's rather than
|
|
321
|
+
square over a rounded one; on an array or a subform it bounds a group, where
|
|
322
|
+
there is no single box for it to disagree with. */
|
|
323
|
+
.qm-field-control {
|
|
324
|
+
position: relative;
|
|
325
|
+
border-radius: var(--_qm-radius-inner);
|
|
326
|
+
}
|
|
265
327
|
/* A run of one takes half the capacity from column 1: `--cols-half` is the section's
|
|
266
328
|
capacity halved, so the edge lands on a track boundary at every capacity. */
|
|
267
329
|
.qm-field.lone {
|