@quillmark/svelte 0.1.1 → 0.3.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 +16 -12
- package/THEMING.md +48 -25
- package/dist/core/address.d.ts +42 -7
- package/dist/core/address.js +49 -21
- package/dist/core/bloom.d.ts +5 -5
- package/dist/core/bloom.js +8 -8
- package/dist/core/codec/anchor.d.ts +1 -1
- package/dist/core/codec/anchor.js +1 -1
- package/dist/core/codec/atoms.d.ts +10 -0
- package/dist/core/codec/atoms.js +78 -0
- package/dist/core/codec/clipboard.d.ts +59 -0
- package/dist/core/codec/clipboard.js +202 -0
- package/dist/core/codec/code.d.ts +1 -1
- package/dist/core/codec/code.js +4 -4
- package/dist/core/codec/decode.d.ts +1 -1
- package/dist/core/codec/decode.js +8 -8
- package/dist/core/codec/encode.d.ts +4 -4
- package/dist/core/codec/encode.js +14 -11
- package/dist/core/codec/field.d.ts +22 -27
- package/dist/core/codec/field.js +33 -32
- package/dist/core/codec/index.js +5 -8
- package/dist/core/codec/inputrules.js +56 -18
- package/dist/core/codec/islands.d.ts +11 -7
- package/dist/core/codec/islands.js +16 -10
- package/dist/core/codec/keymap.d.ts +5 -6
- package/dist/core/codec/keymap.js +8 -7
- package/dist/core/codec/lists.d.ts +3 -3
- package/dist/core/codec/lists.js +32 -13
- package/dist/core/codec/marks.d.ts +1 -1
- package/dist/core/codec/marks.js +1 -1
- package/dist/core/codec/positions.js +1 -1
- package/dist/core/codec/prose.css +266 -228
- package/dist/core/codec/schema.js +4 -4
- package/dist/core/codec/slash.d.ts +39 -41
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +12 -16
- package/dist/core/codec/table-view.js +488 -292
- package/dist/core/codec/table.d.ts +42 -15
- package/dist/core/codec/table.js +73 -27
- package/dist/core/errors.d.ts +17 -7
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +6 -5
- package/dist/core/lifecycle.d.ts +21 -5
- package/dist/core/lifecycle.js +34 -8
- package/dist/core/rebind.svelte.d.ts +18 -4
- package/dist/core/rebind.svelte.js +30 -12
- package/dist/core/teardown.d.ts +1 -1
- package/dist/core/teardown.js +1 -1
- package/dist/core/theme.css +173 -58
- package/dist/preset/index.css +1 -1
- package/dist/preset/recipes.css +79 -30
- package/dist/preset/scale.css +40 -28
- package/dist/preview/Preview.svelte +22 -19
- package/dist/preview/Preview.svelte.d.ts +8 -8
- package/dist/preview/bridge.d.ts +6 -4
- package/dist/preview/bridge.js +19 -9
- package/dist/preview/controller.d.ts +13 -6
- package/dist/preview/controller.js +8 -5
- package/dist/preview/geometry.d.ts +17 -2
- package/dist/preview/geometry.js +21 -1
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/index.js +9 -2
- package/dist/preview/overlay.d.ts +2 -1
- package/dist/preview/overlay.js +26 -11
- package/dist/preview/paint.js +19 -17
- package/dist/visual/ArrayField.svelte +103 -83
- package/dist/visual/ArrayField.svelte.d.ts +5 -4
- package/dist/visual/BooleanField.svelte +8 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -2
- package/dist/visual/Card.svelte +161 -333
- package/dist/visual/Card.svelte.d.ts +5 -4
- package/dist/visual/CardControls.svelte +1 -1
- package/dist/visual/DateField.svelte +19 -19
- package/dist/visual/DateField.svelte.d.ts +3 -3
- package/dist/visual/DiagnosticList.svelte +2 -2
- package/dist/visual/EnumField.svelte +48 -28
- package/dist/visual/EnumField.svelte.d.ts +5 -4
- package/dist/visual/Field.svelte +207 -132
- package/dist/visual/Field.svelte.d.ts +11 -7
- package/dist/visual/FieldHint.svelte +79 -48
- package/dist/visual/FieldHint.svelte.d.ts +1 -1
- package/dist/visual/FieldLabel.svelte +8 -8
- package/dist/visual/FormatPopover.svelte +82 -35
- package/dist/visual/NumberField.svelte +5 -5
- package/dist/visual/NumberField.svelte.d.ts +1 -1
- package/dist/visual/ObjectField.svelte +28 -9
- package/dist/visual/ObjectField.svelte.d.ts +4 -2
- package/dist/visual/ProseArrayElement.svelte +12 -19
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -2
- package/dist/visual/ProseField.svelte +62 -85
- package/dist/visual/ProseField.svelte.d.ts +14 -17
- package/dist/visual/SlashMenu.svelte +42 -16
- package/dist/visual/TextField.svelte +5 -5
- package/dist/visual/TextField.svelte.d.ts +1 -1
- package/dist/visual/TipsCard.svelte +43 -28
- package/dist/visual/VisualEditor.svelte +18 -19
- package/dist/visual/VisualEditor.svelte.d.ts +2 -3
- package/dist/visual/VisualEditorInner.svelte +251 -184
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
- package/dist/visual/controls.css +85 -38
- package/dist/visual/diagnostics.d.ts +13 -21
- package/dist/visual/diagnostics.js +7 -19
- package/dist/visual/domid.js +3 -3
- package/dist/visual/ext.d.ts +1 -1
- package/dist/visual/ext.js +1 -1
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/index.d.ts +1 -0
- package/dist/visual/index.js +10 -3
- package/dist/visual/leaves.d.ts +47 -0
- package/dist/visual/leaves.js +31 -0
- package/dist/visual/links.d.ts +24 -0
- package/dist/visual/links.js +67 -0
- package/dist/visual/props.d.ts +33 -12
- package/dist/visual/signals.d.ts +6 -6
- package/dist/visual/strings.d.ts +14 -19
- package/dist/visual/strings.js +5 -7
- package/dist/visual/structure.d.ts +24 -17
- package/dist/visual/structure.js +8 -8
- package/dist/visual/synced.svelte.d.ts +1 -1
- package/dist/visual/synced.svelte.js +4 -4
- package/dist/visual/tips.d.ts +4 -2
- package/dist/visual/tips.js +7 -4
- package/package.json +2 -2
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The schema `description` as a themed floating surface: the second that is
|
|
2
|
+
The schema `description` as a themed floating surface: the second that is read
|
|
3
3
|
rather than picked from, so it shares {@link FormatPopover}'s translucent recipe
|
|
4
4
|
and portals into the nearest `[data-qm-root]` like every
|
|
5
5
|
other floating surface, so a consumer's dials still reach it.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Not the native `title` tooltip: that reaches no dial, waits ~1s, cannot be
|
|
8
|
+
dismissed, and (the part that makes it a hole rather than a blemish) does not exist
|
|
9
|
+
on touch, which leaves guidance the reference quill puts on nearly every field
|
|
10
|
+
reachable on a tablet only through a screen reader.
|
|
11
11
|
|
|
12
12
|
Floating rather than inline under the label: the reference quill's descriptions run
|
|
13
13
|
to a median of ~110 characters and past 250 at the tail: permanently in flow that
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
to avoid. Measured, not assumed; a quill whose descriptions are all one short line
|
|
16
16
|
would be better served inline.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
The popover is chrome, not the announcement. Its content is `aria-hidden`: the
|
|
19
19
|
description reaches assistive tech through the always-present parked node
|
|
20
20
|
{@link FieldLabel} renders and the `aria-describedby` the control carries, which
|
|
21
21
|
holds whether the surface is open or not. So no reader depends on a hover.
|
|
22
22
|
|
|
23
23
|
Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
|
|
24
24
|
(touch); wired here rather than taken from `Tooltip`, whose trigger returns early
|
|
25
|
-
on `pointerType === 'touch'` and closes on click
|
|
25
|
+
on `pointerType === 'touch'` and closes on click.
|
|
26
26
|
-->
|
|
27
27
|
<script lang="ts">
|
|
28
28
|
import { Popover } from 'bits-ui';
|
|
@@ -35,65 +35,110 @@
|
|
|
35
35
|
* do not all announce alike. */
|
|
36
36
|
label: string;
|
|
37
37
|
/** The parked description node; the trigger points at it too, so a reader
|
|
38
|
-
* that lands
|
|
38
|
+
* that lands here announces the guidance rather than only the field's own. */
|
|
39
39
|
describedBy?: string;
|
|
40
40
|
}
|
|
41
41
|
let { description, label, describedBy }: Props = $props();
|
|
42
42
|
|
|
43
43
|
let open = $state(false);
|
|
44
44
|
let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
|
|
45
|
-
/** The root to portal
|
|
45
|
+
/** The root to portal into: `document.body` would escape the consumer's dials
|
|
46
46
|
* along with the editor's subtree. `undefined` falls back to bits-ui's default. */
|
|
47
47
|
const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
48
|
+
|
|
49
|
+
/** Placement, re-measured on each opening: the surface rides the label rung beside
|
|
50
|
+
* the glyph, or stands over the field. */
|
|
51
|
+
let beside = $state(false);
|
|
52
|
+
|
|
53
|
+
/** Whether this description sets one line. Off a probe rather than the surface
|
|
54
|
+
* itself, since the side is a prop the surface is positioned from, and a side chosen
|
|
55
|
+
* after the fact moves a surface the reader is already looking at. The probe wears
|
|
56
|
+
* the surface's own class inside the portal target, so it wraps to the same measure
|
|
57
|
+
* under the same dials; `width: max-content` keeps that measure the class's rather
|
|
58
|
+
* than the containing block's. Line boxes are counted off a range over the text
|
|
59
|
+
* rather than derived from a height, which would need the line rung and the inset
|
|
60
|
+
* restated here as numbers. A DOM with no layout yields none, which stands the
|
|
61
|
+
* surface over the field. */
|
|
62
|
+
function ridesTheRung(): boolean {
|
|
63
|
+
const host = portalTarget ?? document.body;
|
|
64
|
+
const probe = document.createElement('div');
|
|
65
|
+
probe.className = 'qm-hint-popover';
|
|
66
|
+
probe.style.cssText =
|
|
67
|
+
'position:absolute;width:max-content;visibility:hidden;pointer-events:none';
|
|
68
|
+
probe.textContent = description;
|
|
69
|
+
host.appendChild(probe);
|
|
70
|
+
const range = document.createRange();
|
|
71
|
+
range.selectNodeContents(probe);
|
|
72
|
+
const lines = range.getClientRects().length;
|
|
73
|
+
probe.remove();
|
|
74
|
+
return lines === 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function raise(): void {
|
|
78
|
+
beside = ridesTheRung();
|
|
79
|
+
open = true;
|
|
80
|
+
}
|
|
48
81
|
</script>
|
|
49
82
|
|
|
50
|
-
<!-- The trigger is
|
|
83
|
+
<!-- The trigger is ours, not `Popover.Trigger`, and the surface anchors to it:
|
|
51
84
|
the same shape {@link FormatPopover} uses. The primitive's trigger owns a click
|
|
52
|
-
|
|
85
|
+
toggle, which fights every one of the three openings below: a mouse click after
|
|
53
86
|
a hover-open would close, and on touch the focus that precedes the click opens
|
|
54
87
|
it just in time for the click to close it again. -->
|
|
55
88
|
<button
|
|
56
89
|
bind:this={triggerEl}
|
|
57
90
|
type="button"
|
|
58
|
-
class="qm-field-hint qm-icon-btn qm-focus-ring"
|
|
91
|
+
class="qm-field-hint qm-icon-btn qm-focus-ring qm-tap-floor"
|
|
59
92
|
aria-label="{label} guidance"
|
|
60
93
|
aria-expanded={open}
|
|
61
94
|
aria-describedby={describedBy}
|
|
62
95
|
onpointerenter={(e) => {
|
|
63
|
-
if (e.pointerType !== 'touch')
|
|
96
|
+
if (e.pointerType !== 'touch') raise();
|
|
64
97
|
}}
|
|
65
98
|
onpointerleave={(e) => {
|
|
66
99
|
if (e.pointerType !== 'touch') open = false;
|
|
67
100
|
}}
|
|
68
101
|
onpointerup={(e) => {
|
|
69
|
-
// Touch only, and a
|
|
102
|
+
// Touch only, and a toggle: the one modality with no hover to leave and no
|
|
70
103
|
// blur on the way out. A mouse press falls through to hover, which already
|
|
71
104
|
// has it open and will close it on the way off.
|
|
72
|
-
if (e.pointerType === 'touch')
|
|
105
|
+
if (e.pointerType === 'touch') {
|
|
106
|
+
if (open) open = false;
|
|
107
|
+
else raise();
|
|
108
|
+
}
|
|
73
109
|
}}
|
|
74
110
|
onfocus={(e) => {
|
|
75
111
|
// Keyboard arrival only. A pointer press focuses the button too, and opening
|
|
76
112
|
// on that would re-open what the tap just toggled shut; `:focus-visible` is
|
|
77
113
|
// the UA's own answer to which arrival this was.
|
|
78
|
-
if (e.currentTarget.matches(':focus-visible'))
|
|
114
|
+
if (e.currentTarget.matches(':focus-visible')) raise();
|
|
79
115
|
}}
|
|
80
116
|
onblur={() => (open = false)}
|
|
81
117
|
onkeydown={(e) => {
|
|
82
118
|
if (e.key === 'Escape' && open) {
|
|
83
|
-
//
|
|
84
|
-
//
|
|
119
|
+
// Stopped here so the key does not travel on to a card- or editor-level
|
|
120
|
+
// Escape.
|
|
85
121
|
e.stopPropagation();
|
|
86
122
|
open = false;
|
|
87
123
|
}
|
|
88
124
|
}}><Icon name="info" size={13} /></button
|
|
89
125
|
>
|
|
90
126
|
|
|
127
|
+
<!-- Placement is the description'S height. One line rides the label rung beside its own
|
|
128
|
+
glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
|
|
129
|
+
Anything taller stands over the field: beside the glyph it would hang onto the control
|
|
130
|
+
it describes, the one overlap a reader cannot clear by reaching for what is hidden,
|
|
131
|
+
since a pointer leaving the trigger closes the surface. Over the field it covers a
|
|
132
|
+
neighbour's control instead, which reaching for it dismisses. -->
|
|
133
|
+
<!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
|
|
134
|
+
than the glyph's line box would hang half its height onto the field's own control. -->
|
|
135
|
+
|
|
91
136
|
<Popover.Root bind:open>
|
|
92
137
|
<Popover.Portal to={portalTarget}>
|
|
93
138
|
<Popover.Content
|
|
94
139
|
customAnchor={triggerEl ?? null}
|
|
95
|
-
side=
|
|
96
|
-
align=
|
|
140
|
+
side={beside ? 'right' : 'top'}
|
|
141
|
+
align={beside ? 'center' : 'start'}
|
|
97
142
|
sideOffset={6}
|
|
98
143
|
trapFocus={false}
|
|
99
144
|
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
@@ -128,18 +173,15 @@
|
|
|
128
173
|
<style>
|
|
129
174
|
/* Chrome, type and ring are the glyph-button family's (`.qm-icon-btn` +
|
|
130
175
|
`.qm-focus-ring`, controls.css): this trigger is one more glyph button and
|
|
131
|
-
assembles none of that itself. What it does
|
|
176
|
+
assembles none of that itself. What it does not take is the family's tap floor
|
|
132
177
|
and the fill that floor sizes, because this is the one glyph button that sits in
|
|
133
|
-
a
|
|
178
|
+
a line of text rather than in a row of its own: the floor is half again the label
|
|
134
179
|
rung's line box, so a target-sized paint box overhangs the line at each end by
|
|
135
180
|
most of the field's label-to-control gap: the hover fill landing on the input's
|
|
136
181
|
top border, the focus ring crossing it.
|
|
137
182
|
|
|
138
|
-
So the box is the
|
|
139
|
-
|
|
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.
|
|
183
|
+
So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
|
|
184
|
+
carries that split and the giving-up of the family floor together.
|
|
143
185
|
|
|
144
186
|
It takes no fill either: the ink step below says hover, and the surface itself
|
|
145
187
|
opens on it. */
|
|
@@ -148,38 +190,27 @@
|
|
|
148
190
|
color: var(--_qm-ink-ghost);
|
|
149
191
|
/* Not the family's `pointer`: the glyph raises guidance, it does not act. */
|
|
150
192
|
cursor: help;
|
|
151
|
-
position: relative;
|
|
152
|
-
min-width: 0;
|
|
153
|
-
min-height: 0;
|
|
154
193
|
padding: 0;
|
|
155
194
|
background: none;
|
|
156
195
|
}
|
|
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
196
|
.qm-field-hint:hover,
|
|
170
197
|
.qm-field-hint:focus-visible {
|
|
171
|
-
color: var(--_qm-ink-
|
|
198
|
+
color: var(--_qm-ink-label);
|
|
172
199
|
}
|
|
173
200
|
/* The lift, the translucency, the blur and the scale-in come from
|
|
174
|
-
`.qm-popover-surface` (controls.css). This surface's own is a
|
|
201
|
+
`.qm-popover-surface` (controls.css). This surface's own is a measure: guidance
|
|
175
202
|
is prose, and prose past ~40 characters a line stops being scannable; plus the
|
|
176
203
|
inset and the meta type rung that keep it a note about a field rather than a
|
|
177
|
-
second field.
|
|
178
|
-
|
|
204
|
+
second field.
|
|
205
|
+
|
|
206
|
+
Unscoped, because the placement probe wears this class on a node Svelte never
|
|
207
|
+
compiled: a scoped rule would leave the probe wrapping to a different measure
|
|
208
|
+
than the surface it stands in for, which is the one thing the probe must not do. */
|
|
209
|
+
:global(.qm-hint-popover) {
|
|
179
210
|
max-width: 22rem;
|
|
180
211
|
padding: var(--_qm-space) var(--_qm-space-2);
|
|
181
|
-
font-size: var(--_qm-text-
|
|
212
|
+
font-size: var(--_qm-text-label);
|
|
182
213
|
line-height: var(--_qm-leading-body);
|
|
183
|
-
color: var(--_qm-ink-
|
|
214
|
+
color: var(--_qm-ink-label);
|
|
184
215
|
}
|
|
185
216
|
</style>
|
|
@@ -5,7 +5,7 @@ interface Props {
|
|
|
5
5
|
* do not all announce alike. */
|
|
6
6
|
label: string;
|
|
7
7
|
/** The parked description node; the trigger points at it too, so a reader
|
|
8
|
-
* that lands
|
|
8
|
+
* that lands here announces the guidance rather than only the field's own. */
|
|
9
9
|
describedBy?: string;
|
|
10
10
|
}
|
|
11
11
|
declare const FieldHint: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A field's label plus its guidance chrome.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
It names the control. A real `<label>`, so a click lands the caret in the field:
|
|
5
5
|
the widest, easiest target in a dense row, and the most conventional affordance a
|
|
6
|
-
form has. `for` reaches a
|
|
6
|
+
form has. `for` reaches a labelable control (the inputs, the enum trigger, the
|
|
7
7
|
switch) and the browser does the rest; a control `for` cannot reach (the prose
|
|
8
8
|
leaf's `contenteditable`, the date field's segments, an array's N inputs) leaves
|
|
9
9
|
`controlId` unset, points `aria-labelledby` back at this label's own `id`, and
|
|
10
10
|
hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
|
|
11
|
-
element is inert
|
|
11
|
+
element is inert and invalid.
|
|
12
12
|
|
|
13
13
|
Two non-gating label decorations:
|
|
14
14
|
• a persistent required `*` when the field has no `default:`: the
|
|
15
15
|
"Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
|
|
16
16
|
accessible name is "required", so a screen reader announces the word, not the
|
|
17
|
-
glyph;
|
|
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;
|
|
19
19
|
|
|
20
20
|
• the `description` as an info marker raising a themed popover
|
|
21
21
|
({@link FieldHint}).
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The description describes, it does not name. It is parked in an always-present
|
|
24
24
|
visually-hidden node here and the control carries `aria-describedby`: name then
|
|
25
25
|
role then description, which is the announcement the markup visually implies. In
|
|
26
26
|
the name it would instead prepend ~110 characters of guidance (the reference
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
{/if}
|
|
78
78
|
</label>
|
|
79
79
|
{#if description}
|
|
80
|
-
<!--
|
|
80
|
+
<!-- Outside the `<label>`, both of them: a trigger inside it would put its own
|
|
81
81
|
name in the control's, and the parked copy would put all ~110 characters
|
|
82
82
|
there. A `<button>` in a label is at least safe from the other hazard:
|
|
83
83
|
the spec makes a label's activation behaviour do nothing for a click
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
|
|
91
91
|
<style>
|
|
92
92
|
/* The row, not the label, is the field's grid child: `Field`'s subgrid counts
|
|
93
|
-
|
|
93
|
+
two tracks and the hint sits beside the label rather than under it.
|
|
94
94
|
|
|
95
95
|
A rung wider than the label's own gap below, so the two separations rank: the
|
|
96
96
|
required glyph is part of the name and sits at the tighter one, the guidance
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
align-items: center;
|
|
108
108
|
gap: var(--_qm-space-half);
|
|
109
109
|
font-size: var(--_qm-text-label);
|
|
110
|
-
font-weight: var(--_qm-weight-
|
|
110
|
+
font-weight: var(--_qm-weight-mid);
|
|
111
111
|
line-height: var(--_qm-leading-tight);
|
|
112
112
|
color: var(--_qm-ink-label);
|
|
113
113
|
min-width: 0;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The formatting selection popover (VISUAL_EDITOR §Chrome).
|
|
3
|
-
shell-owned, observing the
|
|
2
|
+
The formatting selection popover (VISUAL_EDITOR §Chrome). One popover,
|
|
3
|
+
shell-owned, observing the active leaf through
|
|
4
4
|
`getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
|
|
5
5
|
the whole of what this surface knows about the editor. A non-empty selection in
|
|
6
6
|
the active leaf raises it over
|
|
@@ -11,20 +11,20 @@
|
|
|
11
11
|
content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
|
|
12
12
|
(field.ts); this is the pointer affordance for all six marks.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Selection observation. The view's `dispatchTransaction` is the codec's
|
|
15
15
|
(read-only), so there is no hook to observe transactions from outside.
|
|
16
16
|
Fallback: a document-level `selectionchange` listener, coalesced to one
|
|
17
17
|
`requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
|
|
18
|
-
microtask is
|
|
18
|
+
microtask is not late enough; verified a "select all" gesture fires several
|
|
19
19
|
transient `selectionchange` events first). `focusout` keeps the popover honest
|
|
20
20
|
when focus leaves the leaf.
|
|
21
21
|
|
|
22
|
-
Nothing here watches
|
|
22
|
+
Nothing here watches scroll. `sync` decides whether the surface is up and what
|
|
23
23
|
its buttons read, which a scroll changes neither of; where it sits is the
|
|
24
24
|
anchor's own question, and the anchor answers it by measuring when floating-ui
|
|
25
25
|
asks rather than by being handed a rect (`codec/anchor.ts`).
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Focus discipline. Three failure modes a naive Popover.Content invites:
|
|
28
28
|
(1) its default `trapFocus` (true) redirects any focus landing outside its
|
|
29
29
|
DOM back inside; which would make it impossible to click back into
|
|
30
30
|
the editor to change the selection while the popover is showing. Fixed
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
the viewport off the clicked field. Fixed by `onCloseAutoFocus`
|
|
42
42
|
calling `preventDefault`: the intentional returns after mark toggle
|
|
43
43
|
/ link submit go through `view.focus` in `toggle`/`submitLink`.
|
|
44
|
-
A mark button
|
|
44
|
+
A mark button also swallows its own `mousedown` (prosemirror-menu's own
|
|
45
45
|
trick): without it, the browser's default mousedown action focuses the
|
|
46
46
|
button before `click` fires, blurring the editor and collapsing the
|
|
47
47
|
selection the command is supposed to act on.
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
// off-tree, so this component renders standalone too.
|
|
54
54
|
const t = wording();
|
|
55
55
|
import { toggleMark } from 'prosemirror-commands';
|
|
56
|
-
import { TextSelection } from 'prosemirror-state';
|
|
56
|
+
import { TextSelection, type Command } from 'prosemirror-state';
|
|
57
57
|
import type { EditorView } from 'prosemirror-view';
|
|
58
58
|
import type { MarkType } from 'prosemirror-model';
|
|
59
59
|
import { Popover } from 'bits-ui';
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
type LeafViews,
|
|
66
66
|
type RangeAnchor
|
|
67
67
|
} from '../core/codec/index.js';
|
|
68
|
+
import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
|
|
68
69
|
import './controls.css';
|
|
69
70
|
|
|
70
71
|
type LeafWithView = FieldController & Partial<LeafViews>;
|
|
@@ -77,7 +78,9 @@
|
|
|
77
78
|
|
|
78
79
|
/** Mark size: the shared control-glyph rule for the popover icons. */
|
|
79
80
|
const GLYPH = 15;
|
|
80
|
-
/** The six content formatting marks
|
|
81
|
+
/** The six content formatting marks. `anchor` is a seventh, drawn separately below:
|
|
82
|
+
* a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
|
|
83
|
+
* the tooltip and the accessible name; the anchor button spells the two separately. */
|
|
81
84
|
const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
|
|
82
85
|
{ name: 'strong', icon: 'bold', title: t.strings.formatBold },
|
|
83
86
|
{ name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
|
|
@@ -89,23 +92,24 @@
|
|
|
89
92
|
|
|
90
93
|
let open = $state(false);
|
|
91
94
|
let activeMarks = $state<Record<string, boolean>>({});
|
|
92
|
-
/** Whether the selection is in the
|
|
95
|
+
/** Whether the selection is in the field's coordinate space, which an anchor needs
|
|
93
96
|
* and a table cell is not (see `sync`). */
|
|
94
97
|
let anchorAvailable = $state(true);
|
|
95
98
|
let linkPromptOpen = $state(false);
|
|
96
99
|
let linkValue = $state('');
|
|
100
|
+
let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
|
|
97
101
|
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
98
|
-
/** The root to portal
|
|
102
|
+
/** The root to portal into: `document.body` escapes the editor's subtree and
|
|
99
103
|
* the consumer's dials with it, so a pane-scoped palette misses this surface.
|
|
100
104
|
* Resolved from the active leaf's own DOM, so the popover lands inside whichever
|
|
101
105
|
* root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
|
|
102
106
|
* mounted outside any root. */
|
|
103
107
|
let portalTarget = $state<HTMLElement | undefined>(undefined);
|
|
104
108
|
|
|
105
|
-
/** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the
|
|
109
|
+
/** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the range moves. A new object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
|
|
106
110
|
let anchor = $state<RangeAnchor | undefined>(undefined);
|
|
107
111
|
|
|
108
|
-
/** The view the caret is actually in: the leaf's own, or the
|
|
112
|
+
/** The view the caret is actually in: the leaf's own, or the nested cell view of a
|
|
109
113
|
* table island (`codec/table-view.ts`). The six marks are the inline schema's
|
|
110
114
|
* too, so a selection in a cell raises the same popover and toggles the same
|
|
111
115
|
* commands; only `anchor` is withheld (below). */
|
|
@@ -114,7 +118,7 @@
|
|
|
114
118
|
return leaf?.focusedView?.() ?? leaf?.view;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
/** Recompute open/anchor/active-marks from the active leaf's
|
|
121
|
+
/** Recompute open/anchor/active-marks from the active leaf's current PM selection. */
|
|
118
122
|
function sync(): void {
|
|
119
123
|
const insidePopover =
|
|
120
124
|
!!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
|
|
@@ -124,7 +128,7 @@
|
|
|
124
128
|
if (insidePopover) return;
|
|
125
129
|
const leaf = getActiveLeaf() as LeafWithView | undefined;
|
|
126
130
|
const view = leaf?.focusedView?.() ?? leaf?.view;
|
|
127
|
-
// A non-empty
|
|
131
|
+
// A non-empty text selection. A node selection is non-empty too and has nothing
|
|
128
132
|
// to format: it covers a leaf block (an island, a rule), which is exactly where
|
|
129
133
|
// Escape out of a table cell lands.
|
|
130
134
|
const selection = view?.state.selection;
|
|
@@ -145,7 +149,7 @@
|
|
|
145
149
|
// `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
|
|
146
150
|
// whether the selection covers an identity anchor, read off the leaf in USV.
|
|
147
151
|
//
|
|
148
|
-
// A selection inside a table
|
|
152
|
+
// A selection inside a table cell is not in that coordinate space at all: the
|
|
149
153
|
// field's position map holds one `atom` run for the whole island, so there is
|
|
150
154
|
// no USV offset to mint an anchor at. The button is withheld rather than
|
|
151
155
|
// disabled, because what it would toggle does not exist there (CODEC §Islands).
|
|
@@ -159,8 +163,8 @@
|
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
// A burst of DOM events (verified: browser "select all" processing fires
|
|
162
|
-
// `selectionchange` several times, transiently
|
|
163
|
-
// final range) must coalesce to
|
|
166
|
+
// `selectionchange` several times, transiently empty before landing on the
|
|
167
|
+
// final range) must coalesce to one settled check, not one `sync()` per
|
|
164
168
|
// event: reacting to each intermediate state mounts/unmounts
|
|
165
169
|
// `Popover.Content` repeatedly within a few event-loop turns, which is both
|
|
166
170
|
// visibly unstable (a moving, momentarily detached click target) and races
|
|
@@ -169,7 +173,7 @@
|
|
|
169
173
|
// microtask is not late enough: those transient events are themselves
|
|
170
174
|
// separated by microtasks. `requestAnimationFrame` is: it runs once
|
|
171
175
|
// per-frame, after the browser has finished dispatching every synchronous
|
|
172
|
-
// consequence of the user gesture, so it always reads the
|
|
176
|
+
// consequence of the user gesture, so it always reads the final, settled
|
|
173
177
|
// selection. `pending` collapses a burst to a single scheduled callback.
|
|
174
178
|
let pending = false;
|
|
175
179
|
let rafHandle = 0;
|
|
@@ -202,19 +206,36 @@
|
|
|
202
206
|
if (!open) linkPromptOpen = false;
|
|
203
207
|
});
|
|
204
208
|
|
|
209
|
+
// The prompt takes its own focus. Nothing else moves it: open-auto-focus is
|
|
210
|
+
// prevented (focus discipline above), and the prompt replaces the buttons on a
|
|
211
|
+
// surface already open, so without this the URL is typed into the document.
|
|
212
|
+
// Selected rather than merely focused, the input arriving seeded and replacing
|
|
213
|
+
// that value being what the prompt is usually raised for.
|
|
214
|
+
$effect(() => {
|
|
215
|
+
if (linkPromptOpen && linkInputEl) {
|
|
216
|
+
linkInputEl.focus();
|
|
217
|
+
linkInputEl.select();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
205
221
|
/** Swallow the button's mousedown so focus/selection never leave the leaf. */
|
|
206
222
|
function keepFocus(e: MouseEvent): void {
|
|
207
223
|
e.preventDefault();
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
// A link is a value, so its button raises the prompt rather than toggling — one
|
|
227
|
+
// meaning whether or not the selection carries one, and the seeded prompt is the
|
|
228
|
+
// only place the document's href is legible. Removal is an arm inside the
|
|
229
|
+
// prompt, not a second meaning for the button. The five value-less marks keep
|
|
230
|
+
// the toggle.
|
|
210
231
|
function toggle(name: string): void {
|
|
211
232
|
const view = activeLeafView();
|
|
212
233
|
if (!view) return;
|
|
213
234
|
const type: MarkType | undefined = view.state.schema.marks[name];
|
|
214
235
|
if (!type) return;
|
|
215
|
-
if (name === 'link'
|
|
236
|
+
if (name === 'link') {
|
|
237
|
+
linkValue = hrefInSelection(view.state);
|
|
216
238
|
linkPromptOpen = true;
|
|
217
|
-
linkValue = '';
|
|
218
239
|
return;
|
|
219
240
|
}
|
|
220
241
|
toggleMark(type)(view.state, view.dispatch);
|
|
@@ -242,21 +263,31 @@
|
|
|
242
263
|
sync();
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
266
|
+
/** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
|
|
267
|
+
* leaf back its focus and the surface its buttons. */
|
|
268
|
+
function closePrompt(cmd?: Command): void {
|
|
248
269
|
linkPromptOpen = false;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
const view = activeLeafView();
|
|
271
|
+
if (view) {
|
|
272
|
+
cmd?.(view.state, view.dispatch);
|
|
273
|
+
view.focus();
|
|
274
|
+
}
|
|
253
275
|
sync();
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
/** Apply the typed value, normalized (`links.ts`). A blank value is nothing to
|
|
279
|
+
* apply and only closes the prompt: removal has its own arm, and a second door
|
|
280
|
+
* onto it would be a second thing to keep true. */
|
|
281
|
+
function submitLink(): void {
|
|
282
|
+
closePrompt(setLink(normalizeHref(linkValue)));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function removeLink(): void {
|
|
286
|
+
closePrompt(clearLink);
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
function cancelLink(): void {
|
|
257
|
-
|
|
258
|
-
activeLeafView()?.focus();
|
|
259
|
-
sync();
|
|
290
|
+
closePrompt();
|
|
260
291
|
}
|
|
261
292
|
</script>
|
|
262
293
|
|
|
@@ -271,7 +302,7 @@
|
|
|
271
302
|
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
272
303
|
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
273
304
|
>
|
|
274
|
-
<!-- The `child` snippet, so the surface
|
|
305
|
+
<!-- The `child` snippet, so the surface is the primitive's content node
|
|
275
306
|
rather than a pill inside it: the dismissal is a CSS animation and the
|
|
276
307
|
primitive unmounts on the content node's own animations finishing. It
|
|
277
308
|
also keeps the recipe reachable from this
|
|
@@ -299,12 +330,24 @@
|
|
|
299
330
|
}}
|
|
300
331
|
>
|
|
301
332
|
<input
|
|
333
|
+
bind:this={linkInputEl}
|
|
302
334
|
class="qm-link-input"
|
|
303
335
|
type="text"
|
|
304
336
|
placeholder={t.strings.linkPlaceholder}
|
|
337
|
+
aria-label={t.strings.formatLink}
|
|
305
338
|
bind:value={linkValue}
|
|
306
339
|
/>
|
|
307
340
|
<button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
|
|
341
|
+
<!-- Only where there is a link to remove: an arm that does nothing is
|
|
342
|
+
an arm to read past on every other visit to the prompt. -->
|
|
343
|
+
{#if activeMarks.link}
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
class="qm-icon-btn qm-mark-btn"
|
|
347
|
+
onmousedown={keepFocus}
|
|
348
|
+
onclick={removeLink}>{t.strings.linkRemove}</button
|
|
349
|
+
>
|
|
350
|
+
{/if}
|
|
308
351
|
<button
|
|
309
352
|
type="button"
|
|
310
353
|
class="qm-icon-btn qm-mark-btn"
|
|
@@ -349,7 +392,7 @@
|
|
|
349
392
|
</Popover.Root>
|
|
350
393
|
|
|
351
394
|
<style>
|
|
352
|
-
/* This surface
|
|
395
|
+
/* This surface portals out of the leaf's DOM but into the nearest `[data-qm-root]`
|
|
353
396
|
(the target is resolved in `sync`), so it inherits the consumer's dials like
|
|
354
397
|
every other surface. It carries the marker itself too: floating over content is
|
|
355
398
|
still a detached subtree for the derivation's purposes, and the marker is what
|
|
@@ -385,12 +428,16 @@
|
|
|
385
428
|
gap: var(--_qm-space);
|
|
386
429
|
align-items: center;
|
|
387
430
|
}
|
|
431
|
+
/* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
|
|
432
|
+
stretch this to the popover. The fill is the box's own rung and there is no
|
|
433
|
+
edge, as everywhere else a value is typed (controls.css). */
|
|
388
434
|
.qm-link-input {
|
|
389
435
|
font: inherit;
|
|
390
436
|
font-size: var(--_qm-text-body);
|
|
391
|
-
padding: var(--_qm-
|
|
392
|
-
border:
|
|
437
|
+
padding: var(--_qm-inset-control);
|
|
438
|
+
border: none;
|
|
393
439
|
border-radius: var(--_qm-radius-inner);
|
|
440
|
+
background: var(--_qm-surface-well);
|
|
394
441
|
min-width: 12rem;
|
|
395
442
|
}
|
|
396
443
|
</style>
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
|
|
3
|
-
Commits at `change` (blur/Enter),
|
|
3
|
+
Commits at `change` (blur/Enter), not per keystroke: a partial numeric entry
|
|
4
4
|
(`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
|
|
5
5
|
committing it live flashes a coercion diagnostic + `console.error` on every
|
|
6
6
|
intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
|
|
7
|
-
region. A blank entry commits `undefined`: the
|
|
7
|
+
region. A blank entry commits `undefined`: the unset rung of the
|
|
8
8
|
commitment ladder: the parent removes the field and the engine renders the
|
|
9
9
|
ghosted `default:`. Settling at `change` also keeps
|
|
10
10
|
select-all-and-retype from flashing the preview through the default mid-keystroke.
|
|
11
11
|
|
|
12
|
-
`type="text"`, not `type="number"`: a native number input
|
|
12
|
+
`type="text"`, not `type="number"`: a native number input sanitizes an
|
|
13
13
|
invalid string to `""` before the DOM `value` setter even runs (verified:
|
|
14
14
|
`.value = "abc"` on `type="number"` never lands), which would make a
|
|
15
15
|
genuinely bad entry untypeable. The commit-time coercion diagnostic
|
|
16
16
|
(VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
|
|
17
|
-
UI, so a non-blank entry that fails to parse forwards the
|
|
17
|
+
UI, so a non-blank entry that fails to parse forwards the raw string to
|
|
18
18
|
`onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
|
|
19
19
|
(throws a `QuillmarkError` the parent turns into a field diagnostic), not a
|
|
20
20
|
client-side guess. `inputmode` keeps the numeric mobile keyboard.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
value: number | undefined;
|
|
28
28
|
integer?: boolean;
|
|
29
29
|
fallback?: number;
|
|
30
|
-
/** Accessible name for an input
|
|
30
|
+
/** Accessible name for an input nothing else names: an object property, whose
|
|
31
31
|
* name is the field label plus the property's. A field's own input takes `id`
|
|
32
32
|
* instead and is named by the `<label for>` beside it. */
|
|
33
33
|
label?: string;
|
|
@@ -3,7 +3,7 @@ interface Props {
|
|
|
3
3
|
value: number | undefined;
|
|
4
4
|
integer?: boolean;
|
|
5
5
|
fallback?: number;
|
|
6
|
-
/** Accessible name for an input
|
|
6
|
+
/** Accessible name for an input nothing else names: an object property, whose
|
|
7
7
|
* name is the field label plus the property's. A field's own input takes `id`
|
|
8
8
|
* instead and is named by the `<label for>` beside it. */
|
|
9
9
|
label?: string;
|