@quillmark/svelte 0.0.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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Type dispatch (VISUAL_EDITOR §"Structure mirrors the schema"). Given one projected
|
|
3
|
+
{@link FieldModel} and its live value, render the label + the control the type
|
|
4
|
+
maps to. Array controls own their label (paired with the add affordance in
|
|
5
|
+
{@link ArrayField}); other types render the label here. Prose leaves take a
|
|
6
|
+
parent-built LIVE `addr` (its `card` a getter over the stable-id→index map) so
|
|
7
|
+
a reorder re-targets without a remount; scalars, arrays, and objects commit
|
|
8
|
+
their value UP through `onCommitScalar`, which the parent lowers to the typed
|
|
9
|
+
writer.
|
|
10
|
+
|
|
11
|
+
`diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
|
|
12
|
+
`diagByKey`, merging `quill.validate`, local commit errors, and the external
|
|
13
|
+
`diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
|
|
14
|
+
`DiagnosticList`, severity-styled, NON-GATING.
|
|
15
|
+
-->
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
18
|
+
import type { EditorErrorHandler } from '../core/errors.js';
|
|
19
|
+
import type { FieldController } from '../core/codec/index.js';
|
|
20
|
+
import type { FieldModel, FieldSpan } from './structure.js';
|
|
21
|
+
import { enumValues, ghostDefault, stringifyGhost } from './structure.js';
|
|
22
|
+
import type { FieldDomIds } from './domid.js';
|
|
23
|
+
import ProseField from './ProseField.svelte';
|
|
24
|
+
import TextField from './TextField.svelte';
|
|
25
|
+
import EnumField from './EnumField.svelte';
|
|
26
|
+
import NumberField from './NumberField.svelte';
|
|
27
|
+
import BooleanField from './BooleanField.svelte';
|
|
28
|
+
import DateField from './DateField.svelte';
|
|
29
|
+
import ArrayField from './ArrayField.svelte';
|
|
30
|
+
import ObjectField from './ObjectField.svelte';
|
|
31
|
+
import DiagnosticList from './DiagnosticList.svelte';
|
|
32
|
+
import FieldLabel from './FieldLabel.svelte';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
field: FieldModel;
|
|
36
|
+
/** This field's width in the section grid, from `placeFields`. */
|
|
37
|
+
span: FieldSpan;
|
|
38
|
+
value: unknown;
|
|
39
|
+
/** This field's resolved provenance row (FIELD_PROVENANCE): the ghost's
|
|
40
|
+
* source. Feeds the placeholder / fallback only, never `value`. */
|
|
41
|
+
provenance?: ResolvedField;
|
|
42
|
+
doc: Document;
|
|
43
|
+
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
44
|
+
quill: Quill;
|
|
45
|
+
/** LIVE prose address (getter-`card`); used only when control === 'prose'. */
|
|
46
|
+
proseAddr: Addr;
|
|
47
|
+
leafKey: string;
|
|
48
|
+
/** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
|
|
49
|
+
* the label and the control find each other. */
|
|
50
|
+
domIds: FieldDomIds;
|
|
51
|
+
onCommitScalar: (value: unknown) => void;
|
|
52
|
+
/** Enum-option policy: `false` disables that option. Only the enum
|
|
53
|
+
* control reads it; other controls ignore it. */
|
|
54
|
+
optionAllowed?: (value: string) => boolean;
|
|
55
|
+
onFocus?: (addr: Addr) => void;
|
|
56
|
+
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
57
|
+
onChange?: (addr: Addr) => void;
|
|
58
|
+
onError?: EditorErrorHandler;
|
|
59
|
+
register?: (key: string, controller: FieldController) => void;
|
|
60
|
+
unregister?: (key: string) => void;
|
|
61
|
+
diagnostics?: Diagnostic[];
|
|
62
|
+
}
|
|
63
|
+
let {
|
|
64
|
+
field,
|
|
65
|
+
span,
|
|
66
|
+
value,
|
|
67
|
+
provenance,
|
|
68
|
+
doc,
|
|
69
|
+
quill,
|
|
70
|
+
proseAddr,
|
|
71
|
+
leafKey,
|
|
72
|
+
domIds,
|
|
73
|
+
onCommitScalar,
|
|
74
|
+
optionAllowed,
|
|
75
|
+
onFocus,
|
|
76
|
+
onCaretMove,
|
|
77
|
+
onChange,
|
|
78
|
+
onError,
|
|
79
|
+
register,
|
|
80
|
+
unregister,
|
|
81
|
+
diagnostics
|
|
82
|
+
}: Props = $props();
|
|
83
|
+
|
|
84
|
+
// The ghost the control shows when unset: the resolved `default:` (provenance,
|
|
85
|
+
// `source === 'default'`). `ghost` is the raw typed value (enum/number/boolean
|
|
86
|
+
// fallbacks); `defaultStr` its string form: the text placeholder and the date
|
|
87
|
+
// control's `YYYY-MM-DD`. An object-valued default does not ghost.
|
|
88
|
+
const ghost = $derived(ghostDefault(provenance));
|
|
89
|
+
const defaultStr = $derived(stringifyGhost(ghost));
|
|
90
|
+
|
|
91
|
+
// `for` reaches a LABELABLE control and the browser does the rest. The other four
|
|
92
|
+
// are not labelable (the prose leaf's `contenteditable`, the date field's segment
|
|
93
|
+
// container, the object subform, an array's N inputs) so `for` there would be
|
|
94
|
+
// both inert and invalid markup; they take `aria-labelledby` and a click handoff.
|
|
95
|
+
const labelable = $derived(
|
|
96
|
+
field.control === 'text' ||
|
|
97
|
+
field.control === 'enum' ||
|
|
98
|
+
field.control === 'number' ||
|
|
99
|
+
field.control === 'boolean'
|
|
100
|
+
);
|
|
101
|
+
// The parked description node renders only when the schema carries one, so the
|
|
102
|
+
// reference must vanish with it: `aria-describedby` pointing at nothing describes
|
|
103
|
+
// nothing, and silently.
|
|
104
|
+
const describedBy = $derived(field.description ? domIds.description : undefined);
|
|
105
|
+
|
|
106
|
+
// Focus handoff for the two controls a label click cannot reach natively. Both
|
|
107
|
+
// expose their own `focus()` because "focusing" differs: a PM view restores a
|
|
108
|
+
// selection, a date field lands on its first segment.
|
|
109
|
+
let proseEl = $state<{ focus: () => void } | undefined>();
|
|
110
|
+
let dateEl = $state<{ focus: () => void } | undefined>();
|
|
111
|
+
const onActivate = $derived(
|
|
112
|
+
field.control === 'prose'
|
|
113
|
+
? () => proseEl?.focus()
|
|
114
|
+
: field.control === 'date'
|
|
115
|
+
? () => dateEl?.focus()
|
|
116
|
+
: undefined
|
|
117
|
+
);
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<div class="qm-field" class:cell={span === 'cell'} class:lone={span === 'lone'}>
|
|
121
|
+
{#if field.control !== 'array'}
|
|
122
|
+
<FieldLabel
|
|
123
|
+
label={field.label}
|
|
124
|
+
controlId={labelable ? domIds.control : undefined}
|
|
125
|
+
id={domIds.label}
|
|
126
|
+
descriptionId={domIds.description}
|
|
127
|
+
{onActivate}
|
|
128
|
+
required={field.required}
|
|
129
|
+
description={field.description}
|
|
130
|
+
/>
|
|
131
|
+
{/if}
|
|
132
|
+
<div class="qm-field-control">
|
|
133
|
+
{#if field.control === 'prose'}
|
|
134
|
+
<ProseField
|
|
135
|
+
{quill}
|
|
136
|
+
bind:this={proseEl}
|
|
137
|
+
{doc}
|
|
138
|
+
addr={proseAddr}
|
|
139
|
+
inline={field.inline}
|
|
140
|
+
plaintext={field.plaintext}
|
|
141
|
+
labelledBy={domIds.label}
|
|
142
|
+
{describedBy}
|
|
143
|
+
{leafKey}
|
|
144
|
+
{onFocus}
|
|
145
|
+
{onCaretMove}
|
|
146
|
+
{onChange}
|
|
147
|
+
{onError}
|
|
148
|
+
{register}
|
|
149
|
+
{unregister}
|
|
150
|
+
/>
|
|
151
|
+
{:else if field.control === 'enum'}
|
|
152
|
+
<EnumField
|
|
153
|
+
value={value as string | undefined}
|
|
154
|
+
values={enumValues(field.schema) ?? []}
|
|
155
|
+
fallback={ghost as string | undefined}
|
|
156
|
+
id={domIds.control}
|
|
157
|
+
{describedBy}
|
|
158
|
+
onCommit={onCommitScalar}
|
|
159
|
+
{optionAllowed}
|
|
160
|
+
/>
|
|
161
|
+
{:else if field.control === 'number'}
|
|
162
|
+
<NumberField
|
|
163
|
+
value={value as number | undefined}
|
|
164
|
+
integer={field.schema.type === 'integer'}
|
|
165
|
+
fallback={ghost as number | undefined}
|
|
166
|
+
id={domIds.control}
|
|
167
|
+
{describedBy}
|
|
168
|
+
onCommit={onCommitScalar}
|
|
169
|
+
/>
|
|
170
|
+
{:else if field.control === 'boolean'}
|
|
171
|
+
<BooleanField
|
|
172
|
+
value={value as boolean | undefined}
|
|
173
|
+
fallback={ghost as boolean | undefined}
|
|
174
|
+
id={domIds.control}
|
|
175
|
+
{describedBy}
|
|
176
|
+
onCommit={onCommitScalar}
|
|
177
|
+
/>
|
|
178
|
+
{:else if field.control === 'date'}
|
|
179
|
+
<DateField
|
|
180
|
+
bind:this={dateEl}
|
|
181
|
+
value={value as string | undefined}
|
|
182
|
+
fallback={defaultStr}
|
|
183
|
+
labelledBy={domIds.label}
|
|
184
|
+
{describedBy}
|
|
185
|
+
onCommit={onCommitScalar}
|
|
186
|
+
/>
|
|
187
|
+
{:else if field.control === 'array'}
|
|
188
|
+
<ArrayField
|
|
189
|
+
value={value as unknown[] | undefined}
|
|
190
|
+
items={field.schema.items}
|
|
191
|
+
label={field.label}
|
|
192
|
+
required={field.required}
|
|
193
|
+
description={field.description}
|
|
194
|
+
labelId={domIds.label}
|
|
195
|
+
descriptionId={domIds.description}
|
|
196
|
+
onCommit={onCommitScalar}
|
|
197
|
+
onFocusEl={() => onFocus?.(proseAddr)}
|
|
198
|
+
/>
|
|
199
|
+
{:else if field.control === 'object'}
|
|
200
|
+
<ObjectField
|
|
201
|
+
value={value as Record<string, unknown> | undefined}
|
|
202
|
+
properties={field.schema.properties}
|
|
203
|
+
label={field.label}
|
|
204
|
+
labelledBy={domIds.label}
|
|
205
|
+
{describedBy}
|
|
206
|
+
onCommit={onCommitScalar}
|
|
207
|
+
/>
|
|
208
|
+
{:else}
|
|
209
|
+
<TextField
|
|
210
|
+
value={value as string | undefined}
|
|
211
|
+
placeholder={defaultStr}
|
|
212
|
+
id={domIds.control}
|
|
213
|
+
{describedBy}
|
|
214
|
+
onCommit={onCommitScalar}
|
|
215
|
+
/>
|
|
216
|
+
{/if}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<DiagnosticList {diagnostics} />
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<style>
|
|
223
|
+
/* The `full` span, and the base every field starts from: its own row, a plain
|
|
224
|
+
stack. Nothing shares the row, so there are no internals to align against.
|
|
225
|
+
|
|
226
|
+
THE ACTION COLUMN IS THE FIELD'S INSET, not the section's:
|
|
227
|
+
a row action's tap target plus the grid's own column gutter, held clear at the
|
|
228
|
+
end of whatever the field spans. On the SECTION it reserves one trailing column
|
|
229
|
+
for the whole grid, which reaches only a field that stops short of it. The array
|
|
230
|
+
carries the only row action there is, and it always owns its row (structure.ts
|
|
231
|
+
`packable`), so it spans that reservation with every other column: the one field
|
|
232
|
+
a section could reserve for is the one it would miss. Per field the reservation
|
|
233
|
+
travels with the thing that uses it: one right edge per track, every control in a
|
|
234
|
+
row ending on it, and an array reaching back across its own to put its remove
|
|
235
|
+
past it (ArrayField). Every field pays the width, including one with no action in
|
|
236
|
+
it; a right edge that moved with a field's contents is the raggedness this
|
|
237
|
+
removes. */
|
|
238
|
+
.qm-field {
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
gap: var(--_qm-space);
|
|
242
|
+
grid-column: 1 / -1;
|
|
243
|
+
min-width: 0;
|
|
244
|
+
padding-right: var(--action-col);
|
|
245
|
+
}
|
|
246
|
+
/* A row-sharing field subgrids onto the section's row tracks instead of sizing its
|
|
247
|
+
own: three tracks (label, control, diagnostics) taken from the parent, so every
|
|
248
|
+
control in a visual row starts at the same y however tall a neighbour's label
|
|
249
|
+
wrapped, and one field's diagnostic lifts none of the others out of line. Source
|
|
250
|
+
order IS track order; `align-items: start` keeps a short control from stretching
|
|
251
|
+
to a taller sibling's track. `row-gap` overrides the section's inter-row gutter
|
|
252
|
+
for the tracks this field spans: inside a field the rhythm is tighter than
|
|
253
|
+
between rows. */
|
|
254
|
+
.qm-field.cell,
|
|
255
|
+
.qm-field.lone {
|
|
256
|
+
display: grid;
|
|
257
|
+
grid-row: span 3;
|
|
258
|
+
grid-template-rows: subgrid;
|
|
259
|
+
row-gap: var(--_qm-space);
|
|
260
|
+
align-items: start;
|
|
261
|
+
}
|
|
262
|
+
.qm-field.cell {
|
|
263
|
+
grid-column: span 1;
|
|
264
|
+
}
|
|
265
|
+
/* A run of one takes half the capacity from column 1: `--cols-half` is the section's
|
|
266
|
+
capacity halved, so the edge lands on a track boundary at every capacity. */
|
|
267
|
+
.qm-field.lone {
|
|
268
|
+
grid-column: span var(--cols-half);
|
|
269
|
+
}
|
|
270
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
2
|
+
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
|
+
import type { FieldController } from '../core/codec/index.js';
|
|
4
|
+
import type { FieldModel, FieldSpan } from './structure.js';
|
|
5
|
+
import type { FieldDomIds } from './domid.js';
|
|
6
|
+
interface Props {
|
|
7
|
+
field: FieldModel;
|
|
8
|
+
/** This field's width in the section grid, from `placeFields`. */
|
|
9
|
+
span: FieldSpan;
|
|
10
|
+
value: unknown;
|
|
11
|
+
/** This field's resolved provenance row (FIELD_PROVENANCE): the ghost's
|
|
12
|
+
* source. Feeds the placeholder / fallback only, never `value`. */
|
|
13
|
+
provenance?: ResolvedField;
|
|
14
|
+
doc: Document;
|
|
15
|
+
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
16
|
+
quill: Quill;
|
|
17
|
+
/** LIVE prose address (getter-`card`); used only when control === 'prose'. */
|
|
18
|
+
proseAddr: Addr;
|
|
19
|
+
leafKey: string;
|
|
20
|
+
/** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
|
|
21
|
+
* the label and the control find each other. */
|
|
22
|
+
domIds: FieldDomIds;
|
|
23
|
+
onCommitScalar: (value: unknown) => void;
|
|
24
|
+
/** Enum-option policy: `false` disables that option. Only the enum
|
|
25
|
+
* control reads it; other controls ignore it. */
|
|
26
|
+
optionAllowed?: (value: string) => boolean;
|
|
27
|
+
onFocus?: (addr: Addr) => void;
|
|
28
|
+
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
29
|
+
onChange?: (addr: Addr) => void;
|
|
30
|
+
onError?: EditorErrorHandler;
|
|
31
|
+
register?: (key: string, controller: FieldController) => void;
|
|
32
|
+
unregister?: (key: string) => void;
|
|
33
|
+
diagnostics?: Diagnostic[];
|
|
34
|
+
}
|
|
35
|
+
declare const Field: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type Field = ReturnType<typeof Field>;
|
|
37
|
+
export default Field;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The schema `description` as a themed floating surface: the second that is READ
|
|
3
|
+
rather than picked from, so it shares {@link FormatPopover}'s translucent recipe
|
|
4
|
+
and portals into the nearest `[data-qm-root]` like every
|
|
5
|
+
other floating surface, so a consumer's dials still reach it.
|
|
6
|
+
|
|
7
|
+
It replaces the native `title` tooltip, which reached no dial, waited ~1s, could
|
|
8
|
+
not be dismissed, and (the part that made it a hole rather than a blemish)
|
|
9
|
+
DOES NOT EXIST ON TOUCH. Guidance the reference quill puts on nearly every field
|
|
10
|
+
was, on a tablet, reachable only through a screen reader.
|
|
11
|
+
|
|
12
|
+
Floating rather than inline under the label: the reference quill's descriptions run
|
|
13
|
+
to a median of ~110 characters and past 250 at the tail: permanently in flow that
|
|
14
|
+
is several wrapped lines under every field, which is the density the card is built
|
|
15
|
+
to avoid. Measured, not assumed; a quill whose descriptions are all one short line
|
|
16
|
+
would be better served inline.
|
|
17
|
+
|
|
18
|
+
THE POPOVER IS CHROME, NOT THE ANNOUNCEMENT. Its content is `aria-hidden`: the
|
|
19
|
+
description reaches assistive tech through the always-present parked node
|
|
20
|
+
{@link FieldLabel} renders and the `aria-describedby` the control carries, which
|
|
21
|
+
holds whether the surface is open or not. So no reader depends on a hover.
|
|
22
|
+
|
|
23
|
+
Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
|
|
24
|
+
(touch); wired here rather than taken from `Tooltip`, whose trigger returns early
|
|
25
|
+
on `pointerType === 'touch'` and closes on click (the exact gap being fixed).
|
|
26
|
+
-->
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { Popover } from 'bits-ui';
|
|
29
|
+
import Icon from './icons/Icon.svelte';
|
|
30
|
+
import './controls.css';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
description: string;
|
|
34
|
+
/** The field's label: the trigger's own name, so 30 info buttons on a card
|
|
35
|
+
* do not all announce alike. */
|
|
36
|
+
label: string;
|
|
37
|
+
/** The parked description node; the trigger points at it too, so a reader
|
|
38
|
+
* that lands HERE announces the guidance rather than only the field's own. */
|
|
39
|
+
describedBy?: string;
|
|
40
|
+
}
|
|
41
|
+
let { description, label, describedBy }: Props = $props();
|
|
42
|
+
|
|
43
|
+
let open = $state(false);
|
|
44
|
+
let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
|
|
45
|
+
/** The root to portal INTO: `document.body` would escape the consumer's dials
|
|
46
|
+
* along with the editor's subtree. `undefined` falls back to bits-ui's default. */
|
|
47
|
+
const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<!-- The trigger is OURS, not `Popover.Trigger`, and the surface anchors to it:
|
|
51
|
+
the same shape {@link FormatPopover} uses. The primitive's trigger owns a click
|
|
52
|
+
TOGGLE, which fights every one of the three openings below: a mouse click after
|
|
53
|
+
a hover-open would close, and on touch the focus that precedes the click opens
|
|
54
|
+
it just in time for the click to close it again. -->
|
|
55
|
+
<button
|
|
56
|
+
bind:this={triggerEl}
|
|
57
|
+
type="button"
|
|
58
|
+
class="qm-field-hint qm-icon-btn qm-focus-ring"
|
|
59
|
+
aria-label="{label} guidance"
|
|
60
|
+
aria-expanded={open}
|
|
61
|
+
aria-describedby={describedBy}
|
|
62
|
+
onpointerenter={(e) => {
|
|
63
|
+
if (e.pointerType !== 'touch') open = true;
|
|
64
|
+
}}
|
|
65
|
+
onpointerleave={(e) => {
|
|
66
|
+
if (e.pointerType !== 'touch') open = false;
|
|
67
|
+
}}
|
|
68
|
+
onpointerup={(e) => {
|
|
69
|
+
// Touch only, and a TOGGLE: the one modality with no hover to leave and no
|
|
70
|
+
// blur on the way out. A mouse press falls through to hover, which already
|
|
71
|
+
// has it open and will close it on the way off.
|
|
72
|
+
if (e.pointerType === 'touch') open = !open;
|
|
73
|
+
}}
|
|
74
|
+
onfocus={(e) => {
|
|
75
|
+
// Keyboard arrival only. A pointer press focuses the button too, and opening
|
|
76
|
+
// on that would re-open what the tap just toggled shut; `:focus-visible` is
|
|
77
|
+
// the UA's own answer to which arrival this was.
|
|
78
|
+
if (e.currentTarget.matches(':focus-visible')) open = true;
|
|
79
|
+
}}
|
|
80
|
+
onblur={() => (open = false)}
|
|
81
|
+
onkeydown={(e) => {
|
|
82
|
+
if (e.key === 'Escape' && open) {
|
|
83
|
+
// Dismissable, which the `title` tooltip never was. Stopped here so the
|
|
84
|
+
// key does not travel on to a card- or editor-level Escape.
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
open = false;
|
|
87
|
+
}
|
|
88
|
+
}}><Icon name="info" size={13} /></button
|
|
89
|
+
>
|
|
90
|
+
|
|
91
|
+
<Popover.Root bind:open>
|
|
92
|
+
<Popover.Portal to={portalTarget}>
|
|
93
|
+
<Popover.Content
|
|
94
|
+
customAnchor={triggerEl ?? null}
|
|
95
|
+
side="top"
|
|
96
|
+
align="start"
|
|
97
|
+
sideOffset={6}
|
|
98
|
+
trapFocus={false}
|
|
99
|
+
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
100
|
+
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
101
|
+
>
|
|
102
|
+
<!-- The `child` snippet, the same shape {@link FormatPopover} takes and for
|
|
103
|
+
the same reason: the surface is the primitive's own content node, so the
|
|
104
|
+
dismissal animation is one the primitive waits on before unmounting.
|
|
105
|
+
`data-qm-root` because a portalled subtree is
|
|
106
|
+
detached for the derivation's purposes, and the marker is what applies
|
|
107
|
+
it; `wrapperProps` is floating-ui's positioning box, spread and never
|
|
108
|
+
styled. `inert` is the half of the dismissal the recipe cannot carry: the
|
|
109
|
+
surface is still on screen for the length of the fade, and one on its way
|
|
110
|
+
out is not a thing to click. -->
|
|
111
|
+
{#snippet child({ props, wrapperProps, open: raised })}
|
|
112
|
+
<div {...wrapperProps}>
|
|
113
|
+
<div
|
|
114
|
+
{...props}
|
|
115
|
+
class="qm-hint-popover qm-popover-surface"
|
|
116
|
+
data-qm-root
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
inert={!raised}
|
|
119
|
+
>
|
|
120
|
+
{description}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{/snippet}
|
|
124
|
+
</Popover.Content>
|
|
125
|
+
</Popover.Portal>
|
|
126
|
+
</Popover.Root>
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
/* Chrome, type and ring are the glyph-button family's (`.qm-icon-btn` +
|
|
130
|
+
`.qm-focus-ring`, controls.css): this trigger is one more glyph button and
|
|
131
|
+
assembles none of that itself. What it does NOT take is the family's tap floor
|
|
132
|
+
and the fill that floor sizes, because this is the one glyph button that sits in
|
|
133
|
+
a LINE OF TEXT rather than in a row of its own: the floor is half again the label
|
|
134
|
+
rung's line box, so a target-sized paint box overhangs the line at each end by
|
|
135
|
+
most of the field's label-to-control gap: the hover fill landing on the input's
|
|
136
|
+
top border, the focus ring crossing it.
|
|
137
|
+
|
|
138
|
+
So the box is the GLYPH and the target is the `::after` below. A target is a
|
|
139
|
+
region of the screen, not a paint box, and separating them is what lets both be
|
|
140
|
+
correct at once: the row keeps the line box's height and the press keeps the
|
|
141
|
+
floor. Every other glyph button has a row to itself and keeps the floor as its
|
|
142
|
+
box.
|
|
143
|
+
|
|
144
|
+
It takes no fill either: the ink step below says hover, and the surface itself
|
|
145
|
+
opens on it. */
|
|
146
|
+
.qm-field-hint {
|
|
147
|
+
/* The marker recedes to the label's ghost tone; the surface carries the text. */
|
|
148
|
+
color: var(--_qm-ink-ghost);
|
|
149
|
+
/* Not the family's `pointer`: the glyph raises guidance, it does not act. */
|
|
150
|
+
cursor: help;
|
|
151
|
+
position: relative;
|
|
152
|
+
min-width: 0;
|
|
153
|
+
min-height: 0;
|
|
154
|
+
padding: 0;
|
|
155
|
+
background: none;
|
|
156
|
+
}
|
|
157
|
+
/* The tap floor, out of flow and centred on the glyph: WCAG 2.5.8 measures the
|
|
158
|
+
target, and this is it. Unpainted, so it enlarges the press and not the mark, and
|
|
159
|
+
out of the accessibility tree by construction: a pseudo-element has no node. */
|
|
160
|
+
.qm-field-hint::after {
|
|
161
|
+
content: '';
|
|
162
|
+
position: absolute;
|
|
163
|
+
top: 50%;
|
|
164
|
+
left: 50%;
|
|
165
|
+
width: var(--_qm-tap-min);
|
|
166
|
+
height: var(--_qm-tap-min);
|
|
167
|
+
transform: translate(-50%, -50%);
|
|
168
|
+
}
|
|
169
|
+
.qm-field-hint:hover,
|
|
170
|
+
.qm-field-hint:focus-visible {
|
|
171
|
+
color: var(--_qm-ink-meta);
|
|
172
|
+
}
|
|
173
|
+
/* The lift, the translucency, the blur and the scale-in come from
|
|
174
|
+
`.qm-popover-surface` (controls.css). This surface's own is a MEASURE: guidance
|
|
175
|
+
is prose, and prose past ~40 characters a line stops being scannable; plus the
|
|
176
|
+
inset and the meta type rung that keep it a note about a field rather than a
|
|
177
|
+
second field. */
|
|
178
|
+
.qm-hint-popover {
|
|
179
|
+
max-width: 22rem;
|
|
180
|
+
padding: var(--_qm-space) var(--_qm-space-2);
|
|
181
|
+
font-size: var(--_qm-text-meta);
|
|
182
|
+
line-height: var(--_qm-leading-body);
|
|
183
|
+
color: var(--_qm-ink-meta);
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
interface Props {
|
|
3
|
+
description: string;
|
|
4
|
+
/** The field's label: the trigger's own name, so 30 info buttons on a card
|
|
5
|
+
* do not all announce alike. */
|
|
6
|
+
label: string;
|
|
7
|
+
/** The parked description node; the trigger points at it too, so a reader
|
|
8
|
+
* that lands HERE announces the guidance rather than only the field's own. */
|
|
9
|
+
describedBy?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const FieldHint: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type FieldHint = ReturnType<typeof FieldHint>;
|
|
13
|
+
export default FieldHint;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A field's label plus its guidance chrome.
|
|
3
|
+
|
|
4
|
+
IT NAMES THE CONTROL. A real `<label>`, so a click lands the caret in the field:
|
|
5
|
+
the widest, easiest target in a dense row, and the most conventional affordance a
|
|
6
|
+
form has. `for` reaches a LABELABLE control (the inputs, the enum trigger, the
|
|
7
|
+
switch) and the browser does the rest; a control `for` cannot reach (the prose
|
|
8
|
+
leaf's `contenteditable`, the date field's segments, an array's N inputs) leaves
|
|
9
|
+
`controlId` unset, points `aria-labelledby` back at this label's own `id`, and
|
|
10
|
+
hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
|
|
11
|
+
element is inert AND invalid.
|
|
12
|
+
|
|
13
|
+
Two non-gating label decorations:
|
|
14
|
+
• a persistent required `*` when the field has no `default:`: the
|
|
15
|
+
"Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
|
|
16
|
+
accessible name is "required", so a screen reader announces the word, not the
|
|
17
|
+
glyph; INSIDE the `<label>`, so it names the control along with the text
|
|
18
|
+
("Subject required") rather than announcing as a node adjacent to it;
|
|
19
|
+
|
|
20
|
+
• the `description` as an info marker raising a themed popover
|
|
21
|
+
({@link FieldHint}).
|
|
22
|
+
|
|
23
|
+
THE DESCRIPTION DESCRIBES, IT DOES NOT NAME. It is parked in an always-present
|
|
24
|
+
visually-hidden node here and the control carries `aria-describedby`: name then
|
|
25
|
+
role then description, which is the announcement the markup visually implies. In
|
|
26
|
+
the name it would instead prepend ~110 characters of guidance (the reference
|
|
27
|
+
quill's median) to every field's label on every focus. Parked rather than read off
|
|
28
|
+
the popover because the popover exists only while open, and a dangling
|
|
29
|
+
`aria-describedby` describes nothing.
|
|
30
|
+
|
|
31
|
+
Shared by {@link Field} (scalars, object, prose) and {@link ArrayField} so every
|
|
32
|
+
control's label decorates the same way.
|
|
33
|
+
-->
|
|
34
|
+
<script lang="ts">
|
|
35
|
+
import { wording } from './strings.js';
|
|
36
|
+
|
|
37
|
+
// The surface's words, ambient from the editor root; the package's English
|
|
38
|
+
// off-tree, so this component renders standalone too.
|
|
39
|
+
const t = wording();
|
|
40
|
+
import FieldHint from './FieldHint.svelte';
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
label: string;
|
|
44
|
+
/** The labelable control this names: a `for` target. Unset for a control
|
|
45
|
+
* `for` cannot reach, which takes `id` + `onActivate` instead. */
|
|
46
|
+
controlId?: string;
|
|
47
|
+
/** This label's own id: what such a control's `aria-labelledby` points at. */
|
|
48
|
+
id?: string;
|
|
49
|
+
/** Where the description parks for the control's `aria-describedby`. */
|
|
50
|
+
descriptionId?: string;
|
|
51
|
+
/** Focus handoff for a control `for` cannot reach; a label click calls it. */
|
|
52
|
+
onActivate?: () => void;
|
|
53
|
+
/** No-default field → a persistent required `*`. */
|
|
54
|
+
required?: boolean;
|
|
55
|
+
/** Schema `description`, or undefined: the affordance renders only when set. */
|
|
56
|
+
description?: string;
|
|
57
|
+
}
|
|
58
|
+
let { label, controlId, id, descriptionId, onActivate, required, description }: Props = $props();
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<div class="qm-field-label-row">
|
|
62
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
|
|
63
|
+
<!-- The handler stands in for exactly what `for` would have done, on the controls
|
|
64
|
+
where `for` is inert. It adds no keyboard surface because it needs none: a
|
|
65
|
+
label is not a tab stop, and the control it hands focus to is already the
|
|
66
|
+
keyboard's own way in. -->
|
|
67
|
+
<label
|
|
68
|
+
class="qm-field-label"
|
|
69
|
+
class:targeted={controlId != null || onActivate != null}
|
|
70
|
+
{id}
|
|
71
|
+
for={controlId}
|
|
72
|
+
onclick={onActivate}
|
|
73
|
+
>
|
|
74
|
+
<span>{label}</span>
|
|
75
|
+
{#if required}
|
|
76
|
+
<span class="qm-field-required" aria-label={t.strings.fieldRequired}>*</span>
|
|
77
|
+
{/if}
|
|
78
|
+
</label>
|
|
79
|
+
{#if description}
|
|
80
|
+
<!-- OUTSIDE the `<label>`, both of them: a trigger inside it would put its own
|
|
81
|
+
name in the control's, and the parked copy would put all ~110 characters
|
|
82
|
+
there. A `<button>` in a label is at least safe from the other hazard:
|
|
83
|
+
the spec makes a label's activation behaviour do nothing for a click
|
|
84
|
+
targeted at interactive content inside it; but the naming is reason
|
|
85
|
+
enough to keep both out. -->
|
|
86
|
+
<FieldHint {description} {label} describedBy={descriptionId} />
|
|
87
|
+
<span class="qm-visually-hidden" id={descriptionId}>{description}</span>
|
|
88
|
+
{/if}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<style>
|
|
92
|
+
/* The row, not the label, is the field's grid child: `Field`'s subgrid counts
|
|
93
|
+
three tracks and the hint sits beside the label rather than under it.
|
|
94
|
+
|
|
95
|
+
A rung wider than the label's own gap below, so the two separations rank: the
|
|
96
|
+
required glyph is part of the name and sits at the tighter one, the guidance
|
|
97
|
+
marker is a second affordance beside it. The marker's box is its glyph
|
|
98
|
+
(`FieldHint`), so this gap is the whole of what stands between them. */
|
|
99
|
+
.qm-field-label-row {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: var(--_qm-space);
|
|
103
|
+
min-width: 0;
|
|
104
|
+
}
|
|
105
|
+
.qm-field-label {
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: var(--_qm-space-half);
|
|
109
|
+
font-size: var(--_qm-text-label);
|
|
110
|
+
font-weight: var(--_qm-weight-label);
|
|
111
|
+
line-height: var(--_qm-leading-tight);
|
|
112
|
+
color: var(--_qm-ink-label);
|
|
113
|
+
min-width: 0;
|
|
114
|
+
}
|
|
115
|
+
/* A label that lands focus says so. Only when it has somewhere to land: an
|
|
116
|
+
`ObjectField` property's label names a subform, not one control. */
|
|
117
|
+
.qm-field-label.targeted {
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
/* Required marker: a quiet accent glyph, not an alarm; required-ness is guidance,
|
|
121
|
+
the document still edits and renders unmet. */
|
|
122
|
+
.qm-field-required {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
color: var(--_qm-danger);
|
|
126
|
+
line-height: 1;
|
|
127
|
+
}
|
|
128
|
+
/* The parked description: in the accessibility tree, out of the layout, and never
|
|
129
|
+
a focus stop. Clipped rather than `display: none` / `hidden`, which take a node
|
|
130
|
+
out of the tree entirely and would make the `aria-describedby` dangle. Out of
|
|
131
|
+
flow, so the classic recipe's `margin: -1px` has nothing left to counteract:
|
|
132
|
+
and it would mint a literal off every rung. */
|
|
133
|
+
.qm-visually-hidden {
|
|
134
|
+
position: absolute;
|
|
135
|
+
width: 1px;
|
|
136
|
+
height: 1px;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
clip-path: inset(50%);
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
label: string;
|
|
3
|
+
/** The labelable control this names: a `for` target. Unset for a control
|
|
4
|
+
* `for` cannot reach, which takes `id` + `onActivate` instead. */
|
|
5
|
+
controlId?: string;
|
|
6
|
+
/** This label's own id: what such a control's `aria-labelledby` points at. */
|
|
7
|
+
id?: string;
|
|
8
|
+
/** Where the description parks for the control's `aria-describedby`. */
|
|
9
|
+
descriptionId?: string;
|
|
10
|
+
/** Focus handoff for a control `for` cannot reach; a label click calls it. */
|
|
11
|
+
onActivate?: () => void;
|
|
12
|
+
/** No-default field → a persistent required `*`. */
|
|
13
|
+
required?: boolean;
|
|
14
|
+
/** Schema `description`, or undefined: the affordance renders only when set. */
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
declare const FieldLabel: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type FieldLabel = ReturnType<typeof FieldLabel>;
|
|
19
|
+
export default FieldLabel;
|