@quillmark/svelte 0.5.1 → 0.7.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 +6 -0
- package/THEMING.md +13 -17
- 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 +5 -6
- 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.d.ts +4 -0
- package/dist/core/codec/table-view.js +137 -158
- package/dist/core/codec/table.d.ts +0 -10
- package/dist/core/codec/table.js +0 -15
- package/dist/core/theme.css +357 -346
- package/dist/preset/recipes.css +25 -21
- package/dist/preview/Preview.svelte +4 -2
- package/dist/preview/bridge.d.ts +23 -0
- package/dist/preview/bridge.js +92 -35
- package/dist/preview/controller.d.ts +6 -5
- package/dist/preview/controller.js +13 -7
- package/dist/preview/geometry.d.ts +10 -9
- package/dist/preview/geometry.js +11 -13
- package/dist/preview/index.js +3 -2
- package/dist/preview/paint.js +9 -1
- package/dist/visual/ArrayField.svelte +246 -63
- package/dist/visual/ArrayField.svelte.d.ts +5 -1
- package/dist/visual/Card.svelte +84 -50
- package/dist/visual/EnumField.svelte +37 -3
- package/dist/visual/Field.svelte +28 -4
- package/dist/visual/FieldLabel.svelte +2 -2
- package/dist/visual/FormatPopover.svelte +13 -1
- package/dist/visual/ObjectField.svelte +128 -35
- package/dist/visual/ObjectField.svelte.d.ts +11 -4
- package/dist/visual/ProseArrayElement.svelte +22 -2
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
- package/dist/visual/SlashMenu.svelte +7 -0
- package/dist/visual/TipsCard.svelte +33 -13
- package/dist/visual/VariantField.svelte +95 -0
- package/dist/visual/VariantField.svelte.d.ts +21 -0
- package/dist/visual/VisualEditorInner.svelte +44 -21
- package/dist/visual/controls.css +282 -283
- package/dist/visual/diagnostics.d.ts +13 -11
- package/dist/visual/diagnostics.js +11 -9
- package/dist/visual/domid.d.ts +13 -0
- package/dist/visual/domid.js +16 -0
- package/dist/visual/hold.d.ts +17 -0
- package/dist/visual/hold.js +39 -0
- package/dist/visual/index.js +1 -1
- 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/motion.d.ts +9 -0
- package/dist/visual/motion.js +10 -2
- package/dist/visual/strings.d.ts +15 -0
- package/dist/visual/strings.js +3 -0
- package/dist/visual/structure.d.ts +32 -6
- 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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import type { LeafRegistry } from './leaves.js';
|
|
17
17
|
import type { CardModel, FieldModel } from './structure.js';
|
|
18
18
|
import { placeFields, humanize, initialExpandedGroup } from './structure.js';
|
|
19
|
+
import { holdInView } from './hold.js';
|
|
19
20
|
import type { FieldDomIds } from './domid.js';
|
|
20
21
|
import Field from './Field.svelte';
|
|
21
22
|
import ProseField from './ProseField.svelte';
|
|
@@ -127,9 +128,9 @@
|
|
|
127
128
|
const ungrouped = $derived(card.sections.filter((s) => s.group == null));
|
|
128
129
|
const grouped = $derived(card.sections.filter((s) => s.group != null));
|
|
129
130
|
|
|
130
|
-
// A schema that declares no fields still gets a body, and
|
|
131
|
-
//
|
|
132
|
-
//
|
|
131
|
+
// A schema that declares no fields still gets a body, and an empty metadata block
|
|
132
|
+
// draws nothing while the card's gap still counts it twice, standing a rung of dead
|
|
133
|
+
// space over the body.
|
|
133
134
|
const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
|
|
134
135
|
|
|
135
136
|
// Seeded once from the card's shape. Card is keyed by stable id, so this survives the
|
|
@@ -140,6 +141,10 @@
|
|
|
140
141
|
let expanded = $state<string | null>(initialExpandedGroup(card.sections));
|
|
141
142
|
let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
|
|
142
143
|
let panels = $state<Record<string, HTMLElement | undefined>>({});
|
|
144
|
+
/** Whether the panels animate the move `expanded` is about to make. False for a reveal
|
|
145
|
+
* ({@link revealLeaf}), restored by the header gesture the motion is there for, so its
|
|
146
|
+
* return needs no timer. */
|
|
147
|
+
let animate = $state(true);
|
|
143
148
|
/**
|
|
144
149
|
* Move to `next` (`null` = all closed). A closing panel goes `inert` with whatever it
|
|
145
150
|
* held still focused, and `inert` does not say where focus should go — the browser
|
|
@@ -153,8 +158,11 @@
|
|
|
153
158
|
headers[closing]?.focus();
|
|
154
159
|
expanded = next;
|
|
155
160
|
}
|
|
161
|
+
/** The header is the anchor: a section closing above it is what would carry it off the
|
|
162
|
+
* fold, and a section opens under its own header (`hold.ts`). */
|
|
156
163
|
function toggleGroup(group: string): void {
|
|
157
|
-
|
|
164
|
+
animate = true;
|
|
165
|
+
holdInView(headers[group], () => setExpanded(expanded === group ? null : group));
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
/**
|
|
@@ -163,13 +171,20 @@
|
|
|
163
171
|
* flush before landing, since `inert` clears when the panel renders open, not when
|
|
164
172
|
* `expanded` is assigned.
|
|
165
173
|
*
|
|
174
|
+
* The move is instant, because that same flush is when the landing measures its target:
|
|
175
|
+
* an animating track still reads its start value there, and the trip would be computed
|
|
176
|
+
* against a panel that has not moved yet. The motion explains a header click, and a
|
|
177
|
+
* click in the preview is not one.
|
|
178
|
+
*
|
|
166
179
|
* A call, not a prop: a reveal is an event, and modelling it as state needs a
|
|
167
180
|
* fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
|
|
168
181
|
* re-derive from reopening the accordion under the user.
|
|
169
182
|
*/
|
|
170
183
|
export function revealLeaf(key: string): void {
|
|
171
184
|
const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
|
|
172
|
-
if (section?.group)
|
|
185
|
+
if (!section?.group) return;
|
|
186
|
+
animate = false;
|
|
187
|
+
setExpanded(section.group);
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
let el = $state<HTMLElement | undefined>(undefined);
|
|
@@ -178,6 +193,9 @@
|
|
|
178
193
|
* an insert, `nearest` for a reorder. The reduced-motion guard is JS rather than the
|
|
179
194
|
* CSS the accordion uses: a scroll's behaviour is an argument, not a transition a
|
|
180
195
|
* media query can cancel.
|
|
196
|
+
*
|
|
197
|
+
* The caller is what waits out a reorder's own trip before asking: the run holds the
|
|
198
|
+
* slot this card sits in, and the slot is the stack's (`VisualEditorInner`).
|
|
181
199
|
*/
|
|
182
200
|
export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
|
|
183
201
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
@@ -232,16 +250,8 @@
|
|
|
232
250
|
{/if}
|
|
233
251
|
|
|
234
252
|
<div class="qm-card-body">
|
|
235
|
-
<!-- One element whose top and bottom edges are the bracket's two horizontals.
|
|
236
|
-
Anchored here rather than on the header and on the accordion so an open section's
|
|
237
|
-
vertical reaches them, and so a card with no groups still terminates above its
|
|
238
|
-
body. -->
|
|
239
253
|
{#if hasMeta}
|
|
240
|
-
<div
|
|
241
|
-
class="qm-card-meta"
|
|
242
|
-
class:qm-meta-top={!card.isMain}
|
|
243
|
-
class:qm-meta-bottom={card.hasBody}
|
|
244
|
-
>
|
|
254
|
+
<div class="qm-card-meta" class:qm-meta-bottom={card.hasBody}>
|
|
245
255
|
{#each ungrouped as section (section.group ?? '_ungrouped')}
|
|
246
256
|
<div class="qm-section">
|
|
247
257
|
{@render sectionFields(section.fields)}
|
|
@@ -249,10 +259,9 @@
|
|
|
249
259
|
{/each}
|
|
250
260
|
|
|
251
261
|
<!-- One open at a time. Rendered only when a card has groups, so an empty
|
|
252
|
-
accordion
|
|
253
|
-
to whichever block abuts it. -->
|
|
262
|
+
accordion does not stand a gap under the ungrouped fields. -->
|
|
254
263
|
{#if grouped.length}
|
|
255
|
-
<div class="qm-groups">
|
|
264
|
+
<div class="qm-groups" class:qm-instant={!animate}>
|
|
256
265
|
{#each grouped as section (section.group)}
|
|
257
266
|
{@const group = section.group as string}
|
|
258
267
|
{@const isOpen = expanded === group}
|
|
@@ -397,11 +406,12 @@
|
|
|
397
406
|
/* An island: the card is the base plane, the column behind it the sunken one, and the
|
|
398
407
|
hairline closes it (ARCHITECTURE §Styling). The inset is the same rung as the gap, so
|
|
399
408
|
what separates the header from the fields is what separates the fields from the
|
|
400
|
-
card's edge.
|
|
409
|
+
card's edge. The inline half reads `--_qm-nest` at that same number: this edge is the
|
|
410
|
+
nesting ladder's first stroke, and its inset the first step. */
|
|
401
411
|
.qm-card {
|
|
402
412
|
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
403
413
|
border-radius: var(--_qm-radius);
|
|
404
|
-
padding: var(--_qm-space-3);
|
|
414
|
+
padding: var(--_qm-space-3) var(--_qm-nest);
|
|
405
415
|
background: var(--_qm-surface);
|
|
406
416
|
display: flex;
|
|
407
417
|
flex-direction: column;
|
|
@@ -485,37 +495,44 @@
|
|
|
485
495
|
.qm-card-title:focus {
|
|
486
496
|
color: var(--_qm-ink);
|
|
487
497
|
}
|
|
488
|
-
.qm-card-body
|
|
489
|
-
.qm-card-meta {
|
|
498
|
+
.qm-card-body {
|
|
490
499
|
display: flex;
|
|
491
500
|
flex-direction: column;
|
|
492
501
|
gap: var(--_qm-space-3);
|
|
493
502
|
}
|
|
494
|
-
/*
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
border-top: var(--_qm-border-width) solid var(--_qm-border);
|
|
503
|
+
/* Where two sections meet the distance is three of one rung — the cap closing the one
|
|
504
|
+
above, this gap, the cap opening the one below — the same whether either side is a
|
|
505
|
+
group or the ungrouped block. That total is what the fields inside a section keep
|
|
506
|
+
between their rows (`.qm-fields` row-gap), so a section boundary stands no further
|
|
507
|
+
apart than a field boundary: what ranks them is the stroke, not the air. */
|
|
508
|
+
.qm-card-meta,
|
|
509
|
+
.qm-groups {
|
|
510
|
+
display: flex;
|
|
511
|
+
flex-direction: column;
|
|
512
|
+
gap: var(--_qm-space);
|
|
505
513
|
}
|
|
514
|
+
/* The one horizontal the card draws, and it is the one thing a horizontal is for: it
|
|
515
|
+
ranks the payload against the body rather than saying what is inside what, which is
|
|
516
|
+
the verticals' job (ARCHITECTURE §"A plane is a tone"). Conditional on a body, since
|
|
517
|
+
with none it would divide the payload from the card's own edge a rung below it.
|
|
518
|
+
|
|
519
|
+
The inset stands the rule off the last control at the rung the body sits under it by,
|
|
520
|
+
taking the section's own end cap into account: `--_qm-space-2` here, plus that cap,
|
|
521
|
+
is the `--_qm-space-3` the card spends between its blocks. */
|
|
506
522
|
.qm-meta-bottom {
|
|
507
523
|
border-bottom: var(--_qm-border-width) solid var(--_qm-border);
|
|
524
|
+
padding-bottom: var(--_qm-space-2);
|
|
508
525
|
}
|
|
509
|
-
/*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
.qm-
|
|
518
|
-
padding-
|
|
526
|
+
/* A section's share of the boundary between sections: one rung at each end, so two of
|
|
527
|
+
them stand three of it apart whichever kind they are (above). The accordion spends the
|
|
528
|
+
same rung from its header and its panel, where it also caps the vertical those two
|
|
529
|
+
edges hold.
|
|
530
|
+
|
|
531
|
+
No stroke and no inset: fields declaring no `ui.group` are the card's own, at the rank
|
|
532
|
+
its body is at, and the card's edge is the only container there is to state. A
|
|
533
|
+
vertical here would mark a section a reader has no name for. */
|
|
534
|
+
.qm-section {
|
|
535
|
+
padding-block: var(--_qm-space);
|
|
519
536
|
}
|
|
520
537
|
/* Two query containers, one per kind of section, so capacity follows the width the
|
|
521
538
|
fields actually get: a panel's inset makes that narrower than the card. */
|
|
@@ -550,15 +567,21 @@
|
|
|
550
567
|
--cols-half: 2;
|
|
551
568
|
}
|
|
552
569
|
}
|
|
553
|
-
|
|
570
|
+
/* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
|
|
571
|
+
fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
|
|
572
|
+
not `--_qm-accent`: a card's edge and the verticals inside it are one stroke at three
|
|
573
|
+
depths, and a tone that changed as it went in would read as unrelated lines rather
|
|
574
|
+
than one figure. A named section is what earns it, which is why the ungrouped block
|
|
575
|
+
above draws none.
|
|
576
|
+
|
|
577
|
+
On the group rather than on its panel: what the stroke marks is the section, and a
|
|
578
|
+
section is its header and what hangs under it. Drawn transparent when closed rather
|
|
579
|
+
than absent, so a toggle moves no text — and closed, there is nothing under the header
|
|
580
|
+
for it to gather. Its caps are the header's block padding above and the panel's below,
|
|
581
|
+
at the rung the ungrouped section spends on both of its own ends. */
|
|
554
582
|
.qm-group {
|
|
555
583
|
display: flex;
|
|
556
584
|
flex-direction: column;
|
|
557
|
-
}
|
|
558
|
-
/* The bracket's third stroke, on the section rather than on its panel: spanning the
|
|
559
|
-
header is what gives it a corner to close into at the block's edge. Drawn transparent
|
|
560
|
-
when closed rather than absent, so a toggle moves no text. */
|
|
561
|
-
.qm-group {
|
|
562
585
|
border-left: var(--_qm-border-width) solid transparent;
|
|
563
586
|
transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
|
|
564
587
|
}
|
|
@@ -571,6 +594,9 @@
|
|
|
571
594
|
because at zero the chevron stands on the section's vertical with only the icon box's
|
|
572
595
|
own bearing between them, and the glyph's rotation swaps which bearing faces the
|
|
573
596
|
stroke; right, because the row is the target and an inset there is target given back.
|
|
597
|
+
The rung is the glyph's own and not the nesting step the fields under it take: what
|
|
598
|
+
the heading names is the section, so it rides the vertical rather than the column its
|
|
599
|
+
fields start on.
|
|
574
600
|
|
|
575
601
|
`font: inherit` because a UA button inherits no face, then the body rung, one step
|
|
576
602
|
over the field labels beneath it: at the label rung the two read as one register and
|
|
@@ -618,6 +644,13 @@
|
|
|
618
644
|
.qm-group.qm-open .qm-group-panel {
|
|
619
645
|
grid-template-rows: 1fr;
|
|
620
646
|
}
|
|
647
|
+
/* The track is the accordion's only stroke that moves layout, and a reveal's landing
|
|
648
|
+
measures its target one flush after asking — where an animating track still reads its
|
|
649
|
+
start value. Both panels of the move are inside the group box, so the opening one and
|
|
650
|
+
the closing one above it stand still together (`revealLeaf`). */
|
|
651
|
+
.qm-groups.qm-instant .qm-group-panel {
|
|
652
|
+
transition: none;
|
|
653
|
+
}
|
|
621
654
|
/* Two boxes because the axes clip differently, and the track is the only thing that
|
|
622
655
|
animates: a field sits at its open position from the first frame of the reveal.
|
|
623
656
|
|
|
@@ -632,12 +665,13 @@
|
|
|
632
665
|
.qm-group-panel-inner {
|
|
633
666
|
min-height: 0;
|
|
634
667
|
overflow: hidden;
|
|
635
|
-
padding-inline-start: var(--_qm-
|
|
668
|
+
padding-inline-start: var(--_qm-nest);
|
|
636
669
|
padding-inline-end: var(--_qm-ring-reach);
|
|
637
670
|
margin-inline-end: calc(-1 * var(--_qm-ring-reach));
|
|
638
671
|
}
|
|
672
|
+
/* The lower of the two caps an open section's vertical takes (`.qm-group`). */
|
|
639
673
|
.qm-group-panel-inner > .qm-fields {
|
|
640
|
-
padding-block: var(--_qm-space
|
|
674
|
+
padding-block: var(--_qm-space);
|
|
641
675
|
}
|
|
642
676
|
.qm-body-leaf {
|
|
643
677
|
display: flex;
|
|
@@ -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[];
|
|
@@ -105,17 +108,30 @@
|
|
|
105
108
|
<Icon name="chevron-down" size={14} />
|
|
106
109
|
</Select.Trigger>
|
|
107
110
|
<Select.Portal to={portalTarget}>
|
|
108
|
-
<Select.Content sideOffset={4}>
|
|
111
|
+
<Select.Content collisionBoundary={portalTarget ?? []} sideOffset={4}>
|
|
109
112
|
<!-- The list portals out of the trigger's DOM but into the nearest
|
|
110
113
|
`[data-qm-root]`, and carries the marker itself, like FormatPopover.
|
|
111
|
-
|
|
114
|
+
That root is the boundary a flip is measured against too, being the box
|
|
115
|
+
that clips the surface where the consumer scrolls one; the viewport
|
|
116
|
+
default knows nothing of that. The pill is this
|
|
112
117
|
element (not the primitive's) because scoped CSS keys off which
|
|
113
118
|
component owns the markup: a `class` passed to a primitive is a
|
|
114
119
|
plain string and never picks up the scoping hash. -->
|
|
115
120
|
<div class="qm-menu-surface qm-select-content" data-qm-root>
|
|
116
121
|
<Select.Viewport>
|
|
117
|
-
|
|
122
|
+
<!-- The sentinel renders the `default:`'s own text, so a defaulted enum
|
|
123
|
+
lists that word twice: this row, and the member below it. The tag has
|
|
124
|
+
to be a word rather than a tone — the ghost is already `ink-label`,
|
|
125
|
+
and while the field is unset this is the selected row, so the weight
|
|
126
|
+
step pulls the other way. It rides the accessible name for the same
|
|
127
|
+
reason. -->
|
|
128
|
+
<Select.Item
|
|
129
|
+
class="qm-menu-item qm-select-item qm-select-unset"
|
|
130
|
+
value={UNSET}
|
|
131
|
+
label={`${ghostText} ${t.strings.enumUnsetTag}`}
|
|
132
|
+
>
|
|
118
133
|
<span class="qm-select-ghost">{ghostText}</span>
|
|
134
|
+
<span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
|
|
119
135
|
</Select.Item>
|
|
120
136
|
<!-- A refused option still draws under `'disable'`, and under either
|
|
121
137
|
policy when it is the one selected: a listbox whose selected value
|
|
@@ -186,4 +202,22 @@
|
|
|
186
202
|
.qm-select-ghost {
|
|
187
203
|
color: var(--_qm-ink-label);
|
|
188
204
|
}
|
|
205
|
+
/* The sentinel's row: the ghost at one end, the tag at the other, so the pair reads
|
|
206
|
+
as a value and a note about it rather than as two words. Only this row takes the
|
|
207
|
+
split; a member's row is one string and has nothing to distribute. */
|
|
208
|
+
.qm-select-content :global(.qm-select-unset) {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: baseline;
|
|
211
|
+
justify-content: space-between;
|
|
212
|
+
gap: var(--_qm-space-2);
|
|
213
|
+
}
|
|
214
|
+
/* A step down the size ramp, which is what says this is chrome and the word beside
|
|
215
|
+
it is the value. The ink is the ghost's own: a second tone here would rank the
|
|
216
|
+
tag against the thing it annotates, and there is no rung under `label` to take
|
|
217
|
+
(theme.css, "Two rungs, and there is no third"). */
|
|
218
|
+
.qm-select-tag {
|
|
219
|
+
flex-shrink: 0;
|
|
220
|
+
font-size: var(--_qm-text-label);
|
|
221
|
+
color: var(--_qm-ink-label);
|
|
222
|
+
}
|
|
189
223
|
</style>
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
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
|
}
|