@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.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. 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;