@quillmark/svelte 0.5.0 → 0.6.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 +8 -2
- package/THEMING.md +22 -27
- package/dist/core/address.d.ts +17 -3
- package/dist/core/address.js +20 -3
- package/dist/core/codec/blocks.d.ts +37 -0
- package/dist/core/codec/blocks.js +163 -0
- package/dist/core/codec/field.js +3 -5
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +3 -2
- package/dist/core/codec/inputrules.js +24 -144
- package/dist/core/codec/prose.css +14 -25
- package/dist/core/codec/schema.d.ts +8 -0
- package/dist/core/codec/schema.js +33 -1
- package/dist/core/codec/slash.d.ts +18 -16
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.js +127 -157
- package/dist/core/codec/table.d.ts +0 -10
- package/dist/core/codec/table.js +0 -15
- package/dist/core/theme.css +350 -347
- package/dist/preset/recipes.css +10 -21
- package/dist/preset/scale.css +6 -7
- package/dist/preview/Preview.svelte +4 -2
- package/dist/preview/geometry.d.ts +10 -9
- package/dist/preview/geometry.js +11 -13
- package/dist/visual/ArrayField.svelte +242 -63
- package/dist/visual/ArrayField.svelte.d.ts +5 -1
- package/dist/visual/Card.svelte +21 -2
- package/dist/visual/DiagnosticList.svelte +6 -11
- package/dist/visual/EnumField.svelte +33 -1
- package/dist/visual/Field.svelte +29 -5
- package/dist/visual/FieldLabel.svelte +2 -2
- package/dist/visual/FormatPopover.svelte +13 -1
- package/dist/visual/ObjectField.svelte +157 -35
- package/dist/visual/ObjectField.svelte.d.ts +13 -4
- package/dist/visual/ProseArrayElement.svelte +22 -2
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
- package/dist/visual/TipsCard.svelte +33 -13
- package/dist/visual/VariantField.svelte +98 -0
- package/dist/visual/VariantField.svelte.d.ts +21 -0
- package/dist/visual/VisualEditorInner.svelte +18 -15
- package/dist/visual/controls.css +282 -283
- package/dist/visual/diagnostics.d.ts +15 -24
- package/dist/visual/diagnostics.js +14 -30
- package/dist/visual/domid.d.ts +13 -0
- package/dist/visual/domid.js +16 -0
- package/dist/visual/leaves.d.ts +9 -7
- package/dist/visual/links.d.ts +5 -2
- package/dist/visual/links.js +9 -3
- package/dist/visual/props.d.ts +1 -1
- package/dist/visual/strings.d.ts +21 -6
- package/dist/visual/strings.js +3 -0
- package/dist/visual/structure.d.ts +32 -7
- package/dist/visual/structure.js +49 -3
- package/package.json +2 -2
- package/dist/core/codec/clipboard.d.ts +0 -59
- package/dist/core/codec/clipboard.js +0 -202
package/dist/visual/Card.svelte
CHANGED
|
@@ -140,6 +140,10 @@
|
|
|
140
140
|
let expanded = $state<string | null>(initialExpandedGroup(card.sections));
|
|
141
141
|
let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
|
|
142
142
|
let panels = $state<Record<string, HTMLElement | undefined>>({});
|
|
143
|
+
/** Whether the panels animate the move `expanded` is about to make. False for a reveal
|
|
144
|
+
* ({@link revealLeaf}), restored by the header gesture the motion is there for, so its
|
|
145
|
+
* return needs no timer. */
|
|
146
|
+
let animate = $state(true);
|
|
143
147
|
/**
|
|
144
148
|
* Move to `next` (`null` = all closed). A closing panel goes `inert` with whatever it
|
|
145
149
|
* held still focused, and `inert` does not say where focus should go — the browser
|
|
@@ -154,6 +158,7 @@
|
|
|
154
158
|
expanded = next;
|
|
155
159
|
}
|
|
156
160
|
function toggleGroup(group: string): void {
|
|
161
|
+
animate = true;
|
|
157
162
|
setExpanded(expanded === group ? null : group);
|
|
158
163
|
}
|
|
159
164
|
|
|
@@ -163,13 +168,20 @@
|
|
|
163
168
|
* flush before landing, since `inert` clears when the panel renders open, not when
|
|
164
169
|
* `expanded` is assigned.
|
|
165
170
|
*
|
|
171
|
+
* The move is instant, because that same flush is when the landing measures its target:
|
|
172
|
+
* an animating track still reads its start value there, and the trip would be computed
|
|
173
|
+
* against a panel that has not moved yet. The motion explains a header click, and a
|
|
174
|
+
* click in the preview is not one.
|
|
175
|
+
*
|
|
166
176
|
* A call, not a prop: a reveal is an event, and modelling it as state needs a
|
|
167
177
|
* fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
|
|
168
178
|
* re-derive from reopening the accordion under the user.
|
|
169
179
|
*/
|
|
170
180
|
export function revealLeaf(key: string): void {
|
|
171
181
|
const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
|
|
172
|
-
if (section?.group)
|
|
182
|
+
if (!section?.group) return;
|
|
183
|
+
animate = false;
|
|
184
|
+
setExpanded(section.group);
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
let el = $state<HTMLElement | undefined>(undefined);
|
|
@@ -252,7 +264,7 @@
|
|
|
252
264
|
accordion is not the bracket's last child: the bottom rule's inset belongs
|
|
253
265
|
to whichever block abuts it. -->
|
|
254
266
|
{#if grouped.length}
|
|
255
|
-
<div class="qm-groups">
|
|
267
|
+
<div class="qm-groups" class:qm-instant={!animate}>
|
|
256
268
|
{#each grouped as section (section.group)}
|
|
257
269
|
{@const group = section.group as string}
|
|
258
270
|
{@const isOpen = expanded === group}
|
|
@@ -618,6 +630,13 @@
|
|
|
618
630
|
.qm-group.qm-open .qm-group-panel {
|
|
619
631
|
grid-template-rows: 1fr;
|
|
620
632
|
}
|
|
633
|
+
/* The track is the accordion's only stroke that moves layout, and a reveal's landing
|
|
634
|
+
measures its target one flush after asking — where an animating track still reads its
|
|
635
|
+
start value. Both panels of the move are inside the group box, so the opening one and
|
|
636
|
+
the closing one above it stand still together (`revealLeaf`). */
|
|
637
|
+
.qm-groups.qm-instant .qm-group-panel {
|
|
638
|
+
transition: none;
|
|
639
|
+
}
|
|
621
640
|
/* Two boxes because the axes clip differently, and the track is the only thing that
|
|
622
641
|
animates: a field sits at its open position from the first frame of the reveal.
|
|
623
642
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
Inline diagnostic display (VISUAL_EDITOR §Diagnostics),
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
Inline diagnostic display (VISUAL_EDITOR §Diagnostics). Errors only, non-gating:
|
|
3
|
+
purely additive, never disables the field it sits under. Shared by `Field.svelte`
|
|
4
|
+
(a scalar/prose field) and `Card.svelte` (the body leaf, which has no
|
|
5
|
+
`Field.svelte` wrapper), so a card's body diagnostics render the same way a
|
|
6
|
+
field's do.
|
|
7
7
|
-->
|
|
8
8
|
<script lang="ts">
|
|
9
9
|
import type { Diagnostic } from '@quillmark/wasm';
|
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
<!-- role=status: commit errors appear mid-typing; announce without stealing focus. -->
|
|
28
28
|
<div class="qm-diag-list" role="status">
|
|
29
29
|
{#each diagnostics as d, i (i)}
|
|
30
|
-
<span class="qm-diag-line"
|
|
31
|
-
>{diagnosticText(d, t.formatDiagnostic)}</span
|
|
32
|
-
>
|
|
30
|
+
<span class="qm-diag-line">{diagnosticText(d, t.formatDiagnostic)}</span>
|
|
33
31
|
{/each}
|
|
34
32
|
</div>
|
|
35
33
|
{/if}
|
|
@@ -42,9 +40,6 @@
|
|
|
42
40
|
}
|
|
43
41
|
.qm-diag-line {
|
|
44
42
|
font-size: var(--_qm-text-label);
|
|
45
|
-
color: var(--_qm-warning);
|
|
46
|
-
}
|
|
47
|
-
.qm-diag-line[data-severity='error'] {
|
|
48
43
|
color: var(--_qm-danger);
|
|
49
44
|
}
|
|
50
45
|
</style>
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
import { Select } from 'bits-ui';
|
|
17
17
|
import Icon from './icons/Icon.svelte';
|
|
18
18
|
import { syncedLocal } from './synced.svelte.js';
|
|
19
|
+
import { wording } from './strings.js';
|
|
19
20
|
import './controls.css';
|
|
20
21
|
|
|
22
|
+
const t = wording();
|
|
23
|
+
|
|
21
24
|
interface Props {
|
|
22
25
|
value: string | undefined;
|
|
23
26
|
values: string[];
|
|
@@ -114,8 +117,19 @@
|
|
|
114
117
|
plain string and never picks up the scoping hash. -->
|
|
115
118
|
<div class="qm-menu-surface qm-select-content" data-qm-root>
|
|
116
119
|
<Select.Viewport>
|
|
117
|
-
|
|
120
|
+
<!-- The sentinel renders the `default:`'s own text, so a defaulted enum
|
|
121
|
+
lists that word twice: this row, and the member below it. The tag has
|
|
122
|
+
to be a word rather than a tone — the ghost is already `ink-label`,
|
|
123
|
+
and while the field is unset this is the selected row, so the weight
|
|
124
|
+
step pulls the other way. It rides the accessible name for the same
|
|
125
|
+
reason. -->
|
|
126
|
+
<Select.Item
|
|
127
|
+
class="qm-menu-item qm-select-item qm-select-unset"
|
|
128
|
+
value={UNSET}
|
|
129
|
+
label={`${ghostText} ${t.strings.enumUnsetTag}`}
|
|
130
|
+
>
|
|
118
131
|
<span class="qm-select-ghost">{ghostText}</span>
|
|
132
|
+
<span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
|
|
119
133
|
</Select.Item>
|
|
120
134
|
<!-- A refused option still draws under `'disable'`, and under either
|
|
121
135
|
policy when it is the one selected: a listbox whose selected value
|
|
@@ -186,4 +200,22 @@
|
|
|
186
200
|
.qm-select-ghost {
|
|
187
201
|
color: var(--_qm-ink-label);
|
|
188
202
|
}
|
|
203
|
+
/* The sentinel's row: the ghost at one end, the tag at the other, so the pair reads
|
|
204
|
+
as a value and a note about it rather than as two words. Only this row takes the
|
|
205
|
+
split; a member's row is one string and has nothing to distribute. */
|
|
206
|
+
.qm-select-content :global(.qm-select-unset) {
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: baseline;
|
|
209
|
+
justify-content: space-between;
|
|
210
|
+
gap: var(--_qm-space-2);
|
|
211
|
+
}
|
|
212
|
+
/* A step down the size ramp, which is what says this is chrome and the word beside
|
|
213
|
+
it is the value. The ink is the ghost's own: a second tone here would rank the
|
|
214
|
+
tag against the thing it annotates, and there is no rung under `label` to take
|
|
215
|
+
(theme.css, "Two rungs, and there is no third"). */
|
|
216
|
+
.qm-select-tag {
|
|
217
|
+
flex-shrink: 0;
|
|
218
|
+
font-size: var(--_qm-text-label);
|
|
219
|
+
color: var(--_qm-ink-label);
|
|
220
|
+
}
|
|
189
221
|
</style>
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
`diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
|
|
12
12
|
`diagByKey`, merging `quill.validate`, local commit errors, and the external
|
|
13
13
|
`diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
|
|
14
|
-
`DiagnosticList`,
|
|
14
|
+
`DiagnosticList`, non-gating.
|
|
15
15
|
-->
|
|
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
19
|
import type { LeafRegistry } from './leaves.js';
|
|
20
20
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
21
|
-
import { ghostDefault, stringifyGhost } from './structure.js';
|
|
21
|
+
import { VARIANT_DISCRIMINANT, ghostDefault, stringifyGhost } from './structure.js';
|
|
22
22
|
import type { FieldDomIds } from './domid.js';
|
|
23
23
|
import ProseField from './ProseField.svelte';
|
|
24
24
|
import TextField from './TextField.svelte';
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import DateField from './DateField.svelte';
|
|
29
29
|
import ArrayField from './ArrayField.svelte';
|
|
30
30
|
import ObjectField from './ObjectField.svelte';
|
|
31
|
+
import VariantField from './VariantField.svelte';
|
|
31
32
|
import DiagnosticList from './DiagnosticList.svelte';
|
|
32
33
|
import FieldLabel from './FieldLabel.svelte';
|
|
33
34
|
|
|
@@ -91,14 +92,22 @@
|
|
|
91
92
|
// control's `YYYY-MM-DD`. An object-valued default does not ghost.
|
|
92
93
|
const ghost = $derived(ghostDefault(provenance));
|
|
93
94
|
const defaultStr = $derived(stringifyGhost(ghost));
|
|
95
|
+
// A variant resolves as one rung whose value is the whole container, so the
|
|
96
|
+
// discriminant's ghost is that container's own discriminant cell.
|
|
97
|
+
const ghostMember = $derived(
|
|
98
|
+
(ghost as Record<string, unknown> | undefined)?.[VARIANT_DISCRIMINANT] as string | undefined
|
|
99
|
+
);
|
|
94
100
|
|
|
95
101
|
// `for` reaches a labelable control and the browser does the rest. The other four
|
|
96
102
|
// are not labelable (the prose leaf's `contenteditable`, the date field's segment
|
|
97
103
|
// container, the object subform, an array's N inputs) so `for` there would be
|
|
98
104
|
// both inert and invalid markup; they take `aria-labelledby` and a click handoff.
|
|
105
|
+
// A variant is labelable through its discriminant: the select is the one control
|
|
106
|
+
// the field's label names, and its cells carry composed names of their own.
|
|
99
107
|
const labelable = $derived(
|
|
100
108
|
field.control === 'text' ||
|
|
101
109
|
field.control === 'enum' ||
|
|
110
|
+
field.control === 'variant' ||
|
|
102
111
|
field.control === 'number' ||
|
|
103
112
|
field.control === 'boolean'
|
|
104
113
|
);
|
|
@@ -120,7 +129,7 @@
|
|
|
120
129
|
let arrayEl = $state<
|
|
121
130
|
| {
|
|
122
131
|
focus: () => void;
|
|
123
|
-
focusElement: (k: number) => void;
|
|
132
|
+
focusElement: (k: number, pos?: number) => void;
|
|
124
133
|
washBox: () => HTMLElement | undefined;
|
|
125
134
|
}
|
|
126
135
|
| undefined
|
|
@@ -133,8 +142,8 @@
|
|
|
133
142
|
}
|
|
134
143
|
/** The array's per-ELEMENT landing, for the addresses the preview mints under an
|
|
135
144
|
* array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
|
|
136
|
-
function focusElement(k: number): void {
|
|
137
|
-
arrayEl?.focusElement(k);
|
|
145
|
+
function focusElement(k: number, pos?: number): void {
|
|
146
|
+
arrayEl?.focusElement(k, pos);
|
|
138
147
|
}
|
|
139
148
|
// Only where `for` cannot reach; the labelable four are the browser's own, and a
|
|
140
149
|
// second handler over them would be a focus the label already placed.
|
|
@@ -229,6 +238,19 @@
|
|
|
229
238
|
{optionAllowed}
|
|
230
239
|
{enumDisallowed}
|
|
231
240
|
/>
|
|
241
|
+
{:else if field.control === 'variant'}
|
|
242
|
+
<VariantField
|
|
243
|
+
value={value as Record<string, unknown> | undefined}
|
|
244
|
+
schema={field.schema}
|
|
245
|
+
{ghostMember}
|
|
246
|
+
label={field.label}
|
|
247
|
+
id={domIds.control}
|
|
248
|
+
labelledBy={domIds.label}
|
|
249
|
+
{describedBy}
|
|
250
|
+
onCommit={onCommitScalar}
|
|
251
|
+
{optionAllowed}
|
|
252
|
+
{enumDisallowed}
|
|
253
|
+
/>
|
|
232
254
|
{:else if field.control === 'number'}
|
|
233
255
|
<NumberField
|
|
234
256
|
value={value as number | undefined}
|
|
@@ -265,6 +287,7 @@
|
|
|
265
287
|
description={field.description}
|
|
266
288
|
labelId={domIds.label}
|
|
267
289
|
descriptionId={domIds.description}
|
|
290
|
+
idBase={domIds.control}
|
|
268
291
|
elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
|
|
269
292
|
onCommit={onCommitScalar}
|
|
270
293
|
/>
|
|
@@ -274,6 +297,7 @@
|
|
|
274
297
|
value={value as Record<string, unknown> | undefined}
|
|
275
298
|
properties={field.schema.properties}
|
|
276
299
|
label={field.label}
|
|
300
|
+
idBase={domIds.control}
|
|
277
301
|
labelledBy={domIds.label}
|
|
278
302
|
{describedBy}
|
|
279
303
|
onCommit={onCommitScalar}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
element is inert and invalid.
|
|
12
12
|
|
|
13
13
|
Two non-gating label decorations:
|
|
14
|
-
• a persistent required `*` for a field the schema obliges
|
|
15
|
-
|
|
14
|
+
• a persistent required `*` for a field the schema obliges — one that declares no
|
|
15
|
+
`default:` (DOCUMENT_MODEL). Its
|
|
16
16
|
accessible name is "required", so a screen reader announces the word, not the
|
|
17
17
|
glyph; inside the `<label>`, so it names the control along with the text
|
|
18
18
|
("Subject required") rather than announcing as a node adjacent to it;
|
|
@@ -277,6 +277,18 @@
|
|
|
277
277
|
sync();
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* A fresh anchor id: 128 random bits as hex, the id being opaque to the
|
|
282
|
+
* boundary and needing only to be unique. `crypto.randomUUID` is
|
|
283
|
+
* secure-context-only, and absent on a page served over plain `http` to
|
|
284
|
+
* something other than localhost; `getRandomValues` is there either way.
|
|
285
|
+
*/
|
|
286
|
+
function mintAnchorId(): string {
|
|
287
|
+
return [...crypto.getRandomValues(new Uint8Array(16))]
|
|
288
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
289
|
+
.join('');
|
|
290
|
+
}
|
|
291
|
+
|
|
280
292
|
/**
|
|
281
293
|
* Toggle an identity anchor over the selection: the `anchor`
|
|
282
294
|
* button's answer to a formatting toggle. If the selection already covers
|
|
@@ -292,7 +304,7 @@
|
|
|
292
304
|
const { from, to } = leaf.selectionRange();
|
|
293
305
|
const covered = leaf.anchorsInRange(from, to);
|
|
294
306
|
if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
|
|
295
|
-
else leaf.insertAnchor(
|
|
307
|
+
else leaf.insertAnchor(mintAnchorId(), from);
|
|
296
308
|
view.focus();
|
|
297
309
|
sync();
|
|
298
310
|
}
|
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
only: a nested prose/array/object property renders a placeholder instead of
|
|
5
5
|
recursing.
|
|
6
6
|
|
|
7
|
+
The nesting is a band: two full-width horizontals at `--_qm-border`, the figure the
|
|
8
|
+
card draws around its own metadata (`.qm-meta-top` / `.qm-meta-bottom`) one octave
|
|
9
|
+
down. Nothing insets, so a property's control keeps the card's left and right columns
|
|
10
|
+
exactly as a field's does, and the field's own label caps the band from outside it —
|
|
11
|
+
name, rule under the name, content, closing rule.
|
|
12
|
+
|
|
13
|
+
That absent inset is also what puts the properties on the section's own column tracks
|
|
14
|
+
(below), so a subform's cells share edges with the fields above them rather than
|
|
15
|
+
keeping a rhythm of their own.
|
|
16
|
+
|
|
17
|
+
Which edges draw is {@link Props.edges}, and the rule is that a boundary is stated
|
|
18
|
+
once: where a box already sits against the subform, that box IS the boundary and a
|
|
19
|
+
stroke under it would say so twice. So a field-level subform takes the band, and a
|
|
20
|
+
subform hanging off a control — a variant's cells under their discriminant, an
|
|
21
|
+
array element's properties under its summary row — takes the closing rule alone.
|
|
22
|
+
Sometimes two strokes and sometimes one: the open figure the card's bracket is.
|
|
23
|
+
|
|
7
24
|
A property's ghosted `default:` is the static schema `sub.default`, not the
|
|
8
25
|
resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
|
|
9
26
|
carries no per-property row (an object field resolves as one row whose value is
|
|
@@ -11,7 +28,10 @@
|
|
|
11
28
|
-->
|
|
12
29
|
<script lang="ts">
|
|
13
30
|
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
14
|
-
import { controlKind, humanize } from './structure.js';
|
|
31
|
+
import { controlKind, humanize, obliged } from './structure.js';
|
|
32
|
+
import { wording } from './strings.js';
|
|
33
|
+
import { propertyDomIds } from './domid.js';
|
|
34
|
+
import FieldLabel from './FieldLabel.svelte';
|
|
15
35
|
import TextField from './TextField.svelte';
|
|
16
36
|
import EnumField from './EnumField.svelte';
|
|
17
37
|
import NumberField from './NumberField.svelte';
|
|
@@ -21,21 +41,53 @@
|
|
|
21
41
|
interface Props {
|
|
22
42
|
value: Record<string, unknown> | undefined;
|
|
23
43
|
properties: Record<string, QuillFieldSchema> | undefined;
|
|
24
|
-
/** Accessible-name prefix for the property controls. */
|
|
25
|
-
label?: string;
|
|
26
44
|
/** The field label's own id. A subform is a group of controls, not one control
|
|
27
|
-
* `for` could reach, so the field's label names the set
|
|
28
|
-
*
|
|
45
|
+
* `for` could reach, so the field's label names the set; each property carries
|
|
46
|
+
* its own `<label for>` inside it. */
|
|
29
47
|
labelledBy?: string;
|
|
30
48
|
/** The parked `description` (FieldLabel): announced on entering the group. */
|
|
31
49
|
describedBy?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The parent control's DOM id: the base each property's own three names derive
|
|
52
|
+
* from ({@link propertyDomIds}). Absent — a subform mounted with no field around
|
|
53
|
+
* it — the properties fall back to `aria-label`, which names them without a
|
|
54
|
+
* `for` target to click.
|
|
55
|
+
*/
|
|
56
|
+
idBase?: string;
|
|
57
|
+
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
58
|
+
label?: string;
|
|
59
|
+
/** Which strokes the subform draws; see the note above. */
|
|
60
|
+
edges?: 'band' | 'close';
|
|
32
61
|
onCommit: (obj: Record<string, unknown>) => void;
|
|
33
62
|
}
|
|
34
|
-
let {
|
|
63
|
+
let {
|
|
64
|
+
value,
|
|
65
|
+
properties,
|
|
66
|
+
labelledBy,
|
|
67
|
+
describedBy,
|
|
68
|
+
idBase,
|
|
69
|
+
label,
|
|
70
|
+
edges = 'band',
|
|
71
|
+
onCommit
|
|
72
|
+
}: Props = $props();
|
|
35
73
|
|
|
74
|
+
const t = wording();
|
|
36
75
|
const entries = $derived(Object.entries(properties ?? {}));
|
|
37
76
|
const obj = $derived((value ?? {}) as Record<string, unknown>);
|
|
38
77
|
|
|
78
|
+
/** Obligation, read off the cell exactly as `fieldModels` reads it off a field
|
|
79
|
+
* ({@link obliged}). `validate` anchors it per leaf, so a property with no
|
|
80
|
+
* `default:` is obliged in its own right and the container around it holds none
|
|
81
|
+
* (VISUAL_EDITOR §"Enum variants"). */
|
|
82
|
+
const required = obliged;
|
|
83
|
+
|
|
84
|
+
const title = (key: string, sub: QuillFieldSchema): string => sub.ui?.title ?? humanize(key);
|
|
85
|
+
/** The `aria-label` fallback, for a subform mounted without a field's id space:
|
|
86
|
+
* the field's name and the property's, since nothing else names the control. */
|
|
87
|
+
const fallbackName = (key: string, sub: QuillFieldSchema): string =>
|
|
88
|
+
`${label != null ? `${label} ` : ''}${title(key, sub)}` +
|
|
89
|
+
(required(sub) ? ` ${t.strings.fieldRequired}` : '');
|
|
90
|
+
|
|
39
91
|
/** Take the caret: the first property's control, a subform having no single control
|
|
40
92
|
* of its own to land on. Resolved off the DOM rather than a ref per property: every
|
|
41
93
|
* property is a scalar control the DOM already knows how to focus (the date field's
|
|
@@ -43,10 +95,19 @@
|
|
|
43
95
|
* handle each would be five refs restating document order. An empty or wholly
|
|
44
96
|
* unsupported subform lands nothing. */
|
|
45
97
|
let rootEl = $state<HTMLElement | undefined>();
|
|
98
|
+
const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
|
|
46
99
|
export function focus(): void {
|
|
47
|
-
rootEl
|
|
48
|
-
|
|
49
|
-
|
|
100
|
+
rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
101
|
+
}
|
|
102
|
+
/** A label click for the one property `for` cannot reach: the date control, whose
|
|
103
|
+
* focus lives on a segment. Scoped to that property's own cell, so the click lands
|
|
104
|
+
* where the label is rather than on the subform's first control. */
|
|
105
|
+
function focusProp(key: string): void {
|
|
106
|
+
// Matched on the dataset: a key is the schema's own string, and spelling one into
|
|
107
|
+
// a selector needs `CSS.escape`, which the test DOM does not carry.
|
|
108
|
+
for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
|
|
109
|
+
if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
110
|
+
}
|
|
50
111
|
}
|
|
51
112
|
|
|
52
113
|
function commitProp(key: string, v: unknown): void {
|
|
@@ -66,18 +127,38 @@
|
|
|
66
127
|
<div
|
|
67
128
|
bind:this={rootEl}
|
|
68
129
|
class="qm-object"
|
|
130
|
+
class:close={edges === 'close'}
|
|
69
131
|
role="group"
|
|
70
132
|
aria-labelledby={labelledBy}
|
|
71
133
|
aria-describedby={describedBy}
|
|
72
134
|
>
|
|
73
135
|
{#each entries as [key, sub] (key)}
|
|
74
136
|
{@const kind = controlKind(sub)}
|
|
75
|
-
{@const
|
|
76
|
-
|
|
77
|
-
|
|
137
|
+
{@const ids = idBase ? propertyDomIds(idBase, key) : undefined}
|
|
138
|
+
{@const named = ids ? undefined : fallbackName(key, sub)}
|
|
139
|
+
{@const describes = sub.description && ids ? ids.description : undefined}
|
|
140
|
+
<!-- `for` reaches the four labelable controls; the date field's focus lives on a
|
|
141
|
+
segment, so it takes the click handoff instead, exactly as `Field` does one
|
|
142
|
+
level up. -->
|
|
143
|
+
{@const labelable =
|
|
144
|
+
kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
|
|
145
|
+
<div class="qm-object-prop" data-qm-prop={key}>
|
|
146
|
+
{#if ids}
|
|
147
|
+
<FieldLabel
|
|
148
|
+
label={title(key, sub)}
|
|
149
|
+
controlId={labelable ? ids.control : undefined}
|
|
150
|
+
id={ids.label}
|
|
151
|
+
descriptionId={describes}
|
|
152
|
+
onActivate={labelable ? undefined : () => focusProp(key)}
|
|
153
|
+
required={required(sub)}
|
|
154
|
+
description={sub.description}
|
|
155
|
+
/>
|
|
156
|
+
{/if}
|
|
78
157
|
{#if kind === 'enum'}
|
|
79
158
|
<EnumField
|
|
80
|
-
label={
|
|
159
|
+
label={named}
|
|
160
|
+
id={ids?.control}
|
|
161
|
+
describedBy={describes}
|
|
81
162
|
value={obj[key] as string | undefined}
|
|
82
163
|
values={sub.values ?? []}
|
|
83
164
|
fallback={sub.default as string | undefined}
|
|
@@ -85,7 +166,9 @@
|
|
|
85
166
|
/>
|
|
86
167
|
{:else if kind === 'number'}
|
|
87
168
|
<NumberField
|
|
88
|
-
label={
|
|
169
|
+
label={named}
|
|
170
|
+
id={ids?.control}
|
|
171
|
+
describedBy={describes}
|
|
89
172
|
value={obj[key] as number | undefined}
|
|
90
173
|
integer={sub.type === 'integer'}
|
|
91
174
|
fallback={sub.default as number | undefined}
|
|
@@ -93,54 +176,93 @@
|
|
|
93
176
|
/>
|
|
94
177
|
{:else if kind === 'boolean'}
|
|
95
178
|
<BooleanField
|
|
96
|
-
label={
|
|
179
|
+
label={named}
|
|
180
|
+
id={ids?.control}
|
|
181
|
+
describedBy={describes}
|
|
97
182
|
value={obj[key] as boolean | undefined}
|
|
98
183
|
fallback={sub.default as boolean | undefined}
|
|
99
184
|
onCommit={(v) => commitProp(key, v)}
|
|
100
185
|
/>
|
|
101
186
|
{:else if kind === 'date'}
|
|
102
187
|
<DateField
|
|
103
|
-
label={
|
|
188
|
+
label={named}
|
|
189
|
+
labelledBy={ids?.label}
|
|
190
|
+
describedBy={describes}
|
|
104
191
|
value={obj[key] as string | undefined}
|
|
105
192
|
onCommit={(v) => commitProp(key, v)}
|
|
106
193
|
/>
|
|
107
194
|
{:else if kind === 'text'}
|
|
108
195
|
<TextField
|
|
109
|
-
label={
|
|
196
|
+
label={named}
|
|
197
|
+
id={ids?.control}
|
|
198
|
+
describedBy={describes}
|
|
110
199
|
value={obj[key] as string | undefined}
|
|
111
200
|
placeholder={sub.default != null ? String(sub.default) : undefined}
|
|
112
201
|
onCommit={(v) => commitProp(key, v)}
|
|
113
202
|
/>
|
|
114
203
|
{:else}
|
|
115
|
-
|
|
204
|
+
<!-- A property the subform does not recurse into. What it says is what the
|
|
205
|
+
user can do about it, which is edit the field from the source surface;
|
|
206
|
+
a version number is the roadmap talking to somebody filling in a form. -->
|
|
207
|
+
<span class="qm-unsupported">{t.strings.nestedUnsupported(kind)}</span>
|
|
116
208
|
{/if}
|
|
117
209
|
</div>
|
|
118
210
|
{/each}
|
|
119
211
|
</div>
|
|
120
212
|
|
|
121
213
|
<style>
|
|
214
|
+
/* The band. `border-block` draws both strokes as one declaration, which is what
|
|
215
|
+
makes the figure symmetric by construction rather than by two lines agreeing;
|
|
216
|
+
`padding-block` at the same rung on both sides is the other half of that.
|
|
217
|
+
|
|
218
|
+
`--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
|
|
219
|
+
(ARCHITECTURE §"A plane is a tone"), the one the metadata bracket and the open
|
|
220
|
+
section's vertical read. `faint` separates without structuring — a table's
|
|
221
|
+
interior lines under a frame that is doing the structuring — which is the other
|
|
222
|
+
job.
|
|
223
|
+
|
|
224
|
+
The columns are the section's, arrived at by arithmetic rather than by `subgrid`:
|
|
225
|
+
the band insets nothing horizontally, so this box spans exactly what `.qm-fields`
|
|
226
|
+
spans, and equal tracks at the same count over the same width with the same column
|
|
227
|
+
gap fall on the same edges. `subgrid` would want the tracks chained through the
|
|
228
|
+
three wrappers between the section grid and here (`Field.svelte`), none of which is
|
|
229
|
+
a grid. `--cols` inherits instead, so the container query stepping the section's
|
|
230
|
+
capacity steps the subform's with it — no second query container, no second set of
|
|
231
|
+
rungs, nothing measured. */
|
|
122
232
|
.qm-object {
|
|
123
|
-
display:
|
|
124
|
-
|
|
233
|
+
display: grid;
|
|
234
|
+
grid-template-columns: repeat(var(--cols), 1fr);
|
|
125
235
|
gap: var(--_qm-space-2);
|
|
126
|
-
border-
|
|
127
|
-
padding-
|
|
236
|
+
border-block: var(--_qm-border-width) solid var(--_qm-border);
|
|
237
|
+
padding-block: var(--_qm-space-2);
|
|
238
|
+
}
|
|
239
|
+
/* Hanging off a control (a variant's discriminant, an array element's summary row):
|
|
240
|
+
that box is the top boundary already, and a stroke under it would state the
|
|
241
|
+
boundary twice. The closing rule is not optional in the same breath — without it
|
|
242
|
+
the properties float between the box above and whatever follows. */
|
|
243
|
+
.qm-object.close {
|
|
244
|
+
border-block-start: none;
|
|
245
|
+
padding-block-start: 0;
|
|
128
246
|
}
|
|
247
|
+
/* A property measures like a field: two tracks over the subform's own rows, so a
|
|
248
|
+
label wrapping in one column leaves its control on the row's baseline rather than a
|
|
249
|
+
line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
|
|
250
|
+
tracks are this grid's own; the section's belong to the fields.
|
|
251
|
+
|
|
252
|
+
`min-width: 0` is what holds the arithmetic above: a property overflowing its `1fr`
|
|
253
|
+
grows the track, and the subform's edges leave the section's with it. */
|
|
129
254
|
.qm-object-prop {
|
|
130
|
-
display:
|
|
131
|
-
|
|
132
|
-
|
|
255
|
+
display: grid;
|
|
256
|
+
grid-row: span 2;
|
|
257
|
+
grid-template-rows: subgrid;
|
|
258
|
+
row-gap: var(--_qm-space-half);
|
|
259
|
+
align-items: start;
|
|
260
|
+
min-width: 0;
|
|
133
261
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
here would state it twice and rank two names the ramp does not separate. */
|
|
139
|
-
font-weight: var(--_qm-weight-mid);
|
|
140
|
-
/* A label is a line, not a passage, so it overrides the root's reading
|
|
141
|
-
rhythm the way every other label rung does. */
|
|
142
|
-
line-height: var(--_qm-leading-tight);
|
|
143
|
-
color: var(--_qm-ink-label);
|
|
262
|
+
/* A subform of one takes half the capacity, the way a packable run of one does
|
|
263
|
+
(`.qm-field.lone`): at full capacity a single track reads as truncated. */
|
|
264
|
+
.qm-object-prop:only-child {
|
|
265
|
+
grid-column: span var(--cols-half);
|
|
144
266
|
}
|
|
145
267
|
.qm-unsupported {
|
|
146
268
|
font-size: var(--_qm-text-body);
|
|
@@ -2,14 +2,23 @@ import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
value: Record<string, unknown> | undefined;
|
|
4
4
|
properties: Record<string, QuillFieldSchema> | undefined;
|
|
5
|
-
/** Accessible-name prefix for the property controls. */
|
|
6
|
-
label?: string;
|
|
7
5
|
/** The field label's own id. A subform is a group of controls, not one control
|
|
8
|
-
* `for` could reach, so the field's label names the set
|
|
9
|
-
*
|
|
6
|
+
* `for` could reach, so the field's label names the set; each property carries
|
|
7
|
+
* its own `<label for>` inside it. */
|
|
10
8
|
labelledBy?: string;
|
|
11
9
|
/** The parked `description` (FieldLabel): announced on entering the group. */
|
|
12
10
|
describedBy?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The parent control's DOM id: the base each property's own three names derive
|
|
13
|
+
* from ({@link propertyDomIds}). Absent — a subform mounted with no field around
|
|
14
|
+
* it — the properties fall back to `aria-label`, which names them without a
|
|
15
|
+
* `for` target to click.
|
|
16
|
+
*/
|
|
17
|
+
idBase?: string;
|
|
18
|
+
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Which strokes the subform draws; see the note above. */
|
|
21
|
+
edges?: 'band' | 'close';
|
|
13
22
|
onCommit: (obj: Record<string, unknown>) => void;
|
|
14
23
|
}
|
|
15
24
|
declare const ObjectField: import("svelte").Component<Props, {
|
|
@@ -17,14 +17,16 @@
|
|
|
17
17
|
-->
|
|
18
18
|
<script lang="ts">
|
|
19
19
|
import { onMount } from 'svelte';
|
|
20
|
-
import { EditorState } from 'prosemirror-state';
|
|
20
|
+
import { EditorState, Selection } from 'prosemirror-state';
|
|
21
21
|
import { EditorView } from 'prosemirror-view';
|
|
22
22
|
import {
|
|
23
23
|
decode,
|
|
24
24
|
pmToContent,
|
|
25
25
|
inlineSchema,
|
|
26
26
|
plaintextSchema,
|
|
27
|
-
proseLeafPlugins
|
|
27
|
+
proseLeafPlugins,
|
|
28
|
+
buildLineIndex,
|
|
29
|
+
usvToPM
|
|
28
30
|
} from '../core/codec/index.js';
|
|
29
31
|
import './controls.css';
|
|
30
32
|
import type { Content } from '@quillmark/wasm';
|
|
@@ -61,6 +63,24 @@
|
|
|
61
63
|
view.focus();
|
|
62
64
|
view.dom.scrollIntoView({ block: 'nearest' });
|
|
63
65
|
}
|
|
66
|
+
/** Take the caret to USV `pos` in this element's own content: what a landing on an
|
|
67
|
+
* element address resolves to when the compile answered a cluster-exact offset
|
|
68
|
+
* (`leaves.ts`). `createField`'s body over the view this component owns.
|
|
69
|
+
*
|
|
70
|
+
* The index is built here rather than held: a landing is a discrete act, and one
|
|
71
|
+
* doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
|
|
72
|
+
* clamps, so an offset past a value the compile has moved on from lands the end.
|
|
73
|
+
*
|
|
74
|
+
* `Selection.near`, not `TextSelection.create`: the mapped position can be
|
|
75
|
+
* non-inline. Focus first, then the caret, flagged for scroll — the order and the
|
|
76
|
+
* reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
|
|
77
|
+
export function setCaret(pos: number): void {
|
|
78
|
+
if (!view) return;
|
|
79
|
+
const pm = usvToPM(buildLineIndex(view.state.doc), pos);
|
|
80
|
+
const sel = Selection.near(view.state.doc.resolve(pm));
|
|
81
|
+
view.focus();
|
|
82
|
+
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
|
|
83
|
+
}
|
|
64
84
|
|
|
65
85
|
onMount(() => {
|
|
66
86
|
if (!containerEl) return;
|
|
@@ -19,6 +19,7 @@ interface Props {
|
|
|
19
19
|
}
|
|
20
20
|
declare const ProseArrayElement: import("svelte").Component<Props, {
|
|
21
21
|
focus: () => void;
|
|
22
|
+
setCaret: (pos: number) => void;
|
|
22
23
|
}, "">;
|
|
23
24
|
type ProseArrayElement = ReturnType<typeof ProseArrayElement>;
|
|
24
25
|
export default ProseArrayElement;
|