@quillmark/svelte 0.20.1 → 0.22.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/NOTICE +6 -7
- package/README.md +1 -1
- package/THEMING.md +1 -1
- package/dist/core/address.d.ts +6 -1
- package/dist/core/address.js +12 -3
- package/dist/core/bloom.d.ts +9 -4
- package/dist/core/bloom.js +18 -49
- package/dist/core/codec/atoms.d.ts +1 -1
- package/dist/core/codec/atoms.js +26 -11
- package/dist/core/codec/blocks.d.ts +5 -5
- package/dist/core/codec/blocks.js +27 -8
- package/dist/core/codec/code.d.ts +4 -4
- package/dist/core/codec/code.js +52 -22
- package/dist/core/codec/decode.d.ts +5 -5
- package/dist/core/codec/decode.js +18 -12
- package/dist/core/codec/encode.js +6 -1
- package/dist/core/codec/field.d.ts +22 -15
- package/dist/core/codec/field.js +66 -26
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +2 -2
- package/dist/core/codec/inputrules.d.ts +4 -0
- package/dist/core/codec/inputrules.js +44 -6
- package/dist/core/codec/link-paste.d.ts +11 -0
- package/dist/core/codec/link-paste.js +32 -0
- package/dist/core/codec/lists.d.ts +5 -4
- package/dist/core/codec/lists.js +87 -31
- package/dist/core/codec/nesting.d.ts +20 -0
- package/dist/core/codec/nesting.js +62 -0
- package/dist/core/codec/prose.css +145 -63
- package/dist/core/codec/schema.d.ts +2 -2
- package/dist/core/codec/schema.js +71 -18
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-copy.d.ts +17 -0
- package/dist/core/codec/table-copy.js +84 -0
- package/dist/core/codec/table-view.d.ts +6 -7
- package/dist/core/codec/table-view.js +281 -201
- package/dist/core/codec/table.d.ts +2 -9
- package/dist/core/codec/table.js +10 -19
- package/dist/core/codec/tasks.d.ts +19 -0
- package/dist/core/codec/tasks.js +140 -0
- package/dist/core/errors.d.ts +2 -2
- package/dist/core/rungs.d.ts +6 -0
- package/dist/core/rungs.js +11 -0
- package/dist/core/theme.css +24 -12
- package/dist/visual/ArrayField.svelte +236 -78
- package/dist/visual/ArrayField.svelte.d.ts +0 -2
- package/dist/visual/BooleanField.svelte +12 -1
- package/dist/visual/Card.svelte +41 -23
- package/dist/visual/Card.svelte.d.ts +3 -2
- package/dist/visual/CardControls.svelte +15 -15
- package/dist/visual/DateField.svelte +93 -64
- package/dist/visual/EnumField.svelte +9 -4
- package/dist/visual/Field.svelte +20 -16
- package/dist/visual/Field.svelte.d.ts +4 -2
- package/dist/visual/FieldHint.svelte +22 -5
- package/dist/visual/FieldLabel.svelte +5 -33
- package/dist/visual/FieldLabel.svelte.d.ts +0 -2
- package/dist/visual/FormatPopover.svelte +78 -81
- package/dist/visual/MatrixField.svelte +28 -19
- package/dist/visual/NumberField.svelte +32 -3
- package/dist/visual/ObjectField.svelte +40 -24
- package/dist/visual/ObjectField.svelte.d.ts +6 -0
- package/dist/visual/ProseField.svelte +10 -13
- package/dist/visual/ProseField.svelte.d.ts +5 -2
- package/dist/visual/ProseValue.svelte +25 -19
- package/dist/visual/ProseValue.svelte.d.ts +4 -4
- package/dist/visual/TextField.svelte +3 -2
- package/dist/visual/TipsCard.svelte +7 -7
- package/dist/visual/VariantField.svelte +1 -1
- package/dist/visual/VariantField.svelte.d.ts +1 -1
- package/dist/visual/VisualEditorInner.svelte +121 -39
- package/dist/visual/controls.css +21 -2
- package/dist/visual/icons/Icon.svelte +3 -2
- package/dist/visual/icons/Icon.svelte.d.ts +3 -2
- package/dist/visual/icons/nodes.d.ts +25 -21
- package/dist/visual/icons/nodes.js +9 -7
- package/dist/visual/leaves.d.ts +12 -9
- package/dist/visual/leaves.js +19 -0
- package/dist/visual/motion.d.ts +25 -3
- package/dist/visual/motion.js +58 -9
- package/dist/visual/strings.d.ts +15 -9
- package/dist/visual/strings.js +19 -12
- package/dist/visual/structure.d.ts +21 -31
- package/dist/visual/structure.js +54 -51
- package/package.json +3 -3
package/dist/visual/Card.svelte
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// component renders standalone too.
|
|
11
11
|
const t = wording();
|
|
12
12
|
import Icon from './icons/Icon.svelte';
|
|
13
|
-
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
13
|
+
import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
|
|
14
14
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
15
15
|
import type { LeafRegistry } from './leaves.js';
|
|
16
16
|
import type { CardModel, FieldModel } from './structure.js';
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
kinds: string[];
|
|
49
49
|
ops: CardOps;
|
|
50
50
|
enumDisallowed?: 'hide' | 'disable';
|
|
51
|
-
onFocus?: (addr: Addr) => void;
|
|
51
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
52
52
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
53
53
|
onChange?: (addr: Addr) => void;
|
|
54
54
|
onError?: EditorErrorHandler;
|
|
@@ -72,13 +72,23 @@
|
|
|
72
72
|
leaves
|
|
73
73
|
}: Props = $props();
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// The rename in flight, written once on blur or Enter. Held apart from the stored
|
|
76
|
+
// title because a re-derive mid-edit hands `card` back with the title it stored.
|
|
77
|
+
let draft = $state<string | undefined>(undefined);
|
|
78
|
+
const shownTitle = $derived(draft ?? card.titleOverride);
|
|
76
79
|
|
|
77
|
-
//
|
|
78
|
-
|
|
80
|
+
// A focus the shell hands over (`focusHeader`) is not armed for replacement: the key
|
|
81
|
+
// that pressed a delete, pressed again, would retitle the card it landed on.
|
|
82
|
+
let handed = false;
|
|
79
83
|
function onTitleFocus(e: FocusEvent): void {
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
draft = card.titleOverride;
|
|
85
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
86
|
+
if (handed) input.setSelectionRange(input.value.length, input.value.length);
|
|
87
|
+
else input.select();
|
|
88
|
+
}
|
|
89
|
+
function onTitleBlur(): void {
|
|
90
|
+
if (draft !== undefined && draft !== card.titleOverride) ops.rename(draft);
|
|
91
|
+
draft = undefined;
|
|
82
92
|
}
|
|
83
93
|
// A press on an unfocused title would place a caret on mouseup, collapsing the
|
|
84
94
|
// focus-time select-all. A press while already focused stays normal, so clicking
|
|
@@ -102,13 +112,10 @@
|
|
|
102
112
|
const el = e.currentTarget as HTMLInputElement;
|
|
103
113
|
if (e.key === 'Enter') {
|
|
104
114
|
e.preventDefault();
|
|
105
|
-
el.blur();
|
|
115
|
+
el.blur();
|
|
106
116
|
} else if (e.key === 'Escape') {
|
|
107
117
|
e.preventDefault();
|
|
108
|
-
|
|
109
|
-
localTitle = titleAtFocus;
|
|
110
|
-
ops.rename(titleAtFocus); // roll back the live edits
|
|
111
|
-
}
|
|
118
|
+
draft = undefined;
|
|
112
119
|
el.blur();
|
|
113
120
|
}
|
|
114
121
|
}
|
|
@@ -193,6 +200,13 @@
|
|
|
193
200
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
194
201
|
el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block });
|
|
195
202
|
}
|
|
203
|
+
/** Focus the title with the caret at its end, or the header's first live control on a
|
|
204
|
+
* recovery shell, which has no title to focus. */
|
|
205
|
+
export function focusHeader(): void {
|
|
206
|
+
handed = true;
|
|
207
|
+
el?.querySelector<HTMLElement>('.qm-card-header :is(input, button:not(:disabled))')?.focus();
|
|
208
|
+
handed = false;
|
|
209
|
+
}
|
|
196
210
|
</script>
|
|
197
211
|
|
|
198
212
|
<!-- `data-qm-card` names the island for a floating surface that has to stay inside one
|
|
@@ -210,20 +224,18 @@
|
|
|
210
224
|
<header class="qm-card-header">
|
|
211
225
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
212
226
|
<div class="qm-card-rename" onmousedown={onRenameMousedown}>
|
|
213
|
-
<span class="qm-card-title-sizer" data-value={
|
|
227
|
+
<span class="qm-card-title-sizer" data-value={shownTitle || card.titlePlaceholder}>
|
|
214
228
|
<input
|
|
215
229
|
class="qm-card-title qm-focus-ring"
|
|
216
|
-
value={
|
|
230
|
+
value={shownTitle}
|
|
217
231
|
placeholder={card.titlePlaceholder}
|
|
218
232
|
aria-label={t.strings.cardTitle}
|
|
219
233
|
size="1"
|
|
220
234
|
onmousedown={onTitleMousedown}
|
|
221
235
|
onfocus={onTitleFocus}
|
|
236
|
+
onblur={onTitleBlur}
|
|
222
237
|
onkeydown={onTitleKeydown}
|
|
223
|
-
oninput={(e) =>
|
|
224
|
-
localTitle = (e.currentTarget as HTMLInputElement).value;
|
|
225
|
-
ops.rename(localTitle);
|
|
226
|
-
}}
|
|
238
|
+
oninput={(e) => (draft = (e.currentTarget as HTMLInputElement).value)}
|
|
227
239
|
/>
|
|
228
240
|
</span>
|
|
229
241
|
</div>
|
|
@@ -267,7 +279,7 @@
|
|
|
267
279
|
<div class="qm-group-head">
|
|
268
280
|
<button
|
|
269
281
|
type="button"
|
|
270
|
-
class="qm-group-header"
|
|
282
|
+
class="qm-group-header qm-focus-ring"
|
|
271
283
|
id={headIds?.label}
|
|
272
284
|
bind:this={headers[group]}
|
|
273
285
|
aria-expanded={isOpen}
|
|
@@ -463,10 +475,10 @@
|
|
|
463
475
|
what crosses the line. */
|
|
464
476
|
margin-inline-end: calc(-1 * (var(--_qm-tap-min) - var(--_qm-glyph-control)) / 2);
|
|
465
477
|
}
|
|
466
|
-
/* Focus as well as hover, so a caret in any leaf, the title, or a
|
|
467
|
-
reveal: hover alone would strand the
|
|
468
|
-
.qm-card:hover :global(.qm-card-
|
|
469
|
-
.qm-card:focus-within :global(.qm-card-
|
|
478
|
+
/* Focus as well as hover, so a caret in any leaf, the title, or a control holds the
|
|
479
|
+
reveal: hover alone would strand the row on the keyboard. */
|
|
480
|
+
.qm-card:hover :global(.qm-card-controls),
|
|
481
|
+
.qm-card:focus-within :global(.qm-card-controls) {
|
|
470
482
|
opacity: 1;
|
|
471
483
|
}
|
|
472
484
|
/* The rename hit region is the header's free width at full height, so a press
|
|
@@ -526,6 +538,12 @@
|
|
|
526
538
|
.qm-card-title:focus {
|
|
527
539
|
color: var(--_qm-ink);
|
|
528
540
|
}
|
|
541
|
+
/* The automatic title, at the label ink every placeholder takes rather than the UA's
|
|
542
|
+
grey, and steady under focus, where a typed title steps to the ink. */
|
|
543
|
+
.qm-card-title::placeholder {
|
|
544
|
+
color: var(--_qm-ink-label);
|
|
545
|
+
opacity: 1;
|
|
546
|
+
}
|
|
529
547
|
.qm-card-body {
|
|
530
548
|
display: flex;
|
|
531
549
|
flex-direction: column;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
1
|
+
import type { Document, Quill, Addr, Diagnostic, PathStep } from '@quillmark/wasm';
|
|
2
2
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
3
|
import type { LeafRegistry } from './leaves.js';
|
|
4
4
|
import type { CardModel } from './structure.js';
|
|
@@ -27,7 +27,7 @@ interface Props {
|
|
|
27
27
|
kinds: string[];
|
|
28
28
|
ops: CardOps;
|
|
29
29
|
enumDisallowed?: 'hide' | 'disable';
|
|
30
|
-
onFocus?: (addr: Addr) => void;
|
|
30
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
31
31
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
32
32
|
onChange?: (addr: Addr) => void;
|
|
33
33
|
onError?: EditorErrorHandler;
|
|
@@ -38,6 +38,7 @@ interface Props {
|
|
|
38
38
|
declare const Card: import("svelte").Component<Props, {
|
|
39
39
|
revealLeaf: (key: string) => void;
|
|
40
40
|
scrollIntoViewCard: (block: ScrollLogicalPosition) => void;
|
|
41
|
+
focusHeader: () => void;
|
|
41
42
|
}, "">;
|
|
42
43
|
type Card = ReturnType<typeof Card>;
|
|
43
44
|
export default Card;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
Card controls, composable cards only.
|
|
3
|
-
Right-aligned in the header: a move-up/move-down chevron pair
|
|
4
|
-
|
|
3
|
+
Right-aligned in the header: a move-up/move-down chevron pair (each disabled at its
|
|
4
|
+
edge), then delete, the row revealed while the pointer or the caret is in the card.
|
|
5
5
|
Reorder is buttons, not drag (V1). All three are chips (controls.css): fill arrives
|
|
6
|
-
on hover, and delete
|
|
7
|
-
that reveal, so its condition lives in the parent's CSS; the
|
|
8
|
-
guarantee here, that a focused
|
|
6
|
+
on hover, and delete draws faded danger, full under the pointer. The card scopes
|
|
7
|
+
that reveal, so its condition lives in the parent's CSS; the row keeps the narrower
|
|
8
|
+
guarantee here, that a focused control is a visible one.
|
|
9
9
|
-->
|
|
10
10
|
<script lang="ts">
|
|
11
11
|
import { wording } from './strings.js';
|
|
@@ -56,21 +56,21 @@
|
|
|
56
56
|
display: flex;
|
|
57
57
|
align-items: center;
|
|
58
58
|
gap: var(--_qm-space-half);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
opacity: 0;
|
|
60
|
+
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
61
|
+
/* One compositor layer for the row at rest as well as mid-fade: a box handed
|
|
62
|
+
back to the document paint when its fade ends snaps off the header's
|
|
63
|
+
half-pixel as it lands. */
|
|
62
64
|
transform: translateZ(0);
|
|
63
65
|
}
|
|
64
66
|
.qm-card-reorder {
|
|
65
67
|
display: flex;
|
|
66
|
-
opacity: 0;
|
|
67
|
-
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
68
68
|
}
|
|
69
|
-
/* Opacity hides the
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
hidden state is. */
|
|
73
|
-
.qm-card-
|
|
69
|
+
/* Opacity hides the row without taking it out of the tab order, which keeps
|
|
70
|
+
delete the keyboard's way to remove a card, so the component holds its own
|
|
71
|
+
floor: a control that has focus is drawn, and its ring with it. The card's
|
|
72
|
+
reveal already covers this; the rule is here because the hidden state is. */
|
|
73
|
+
.qm-card-controls:focus-within {
|
|
74
74
|
opacity: 1;
|
|
75
75
|
}
|
|
76
76
|
/* Squares: the chip's inline pad is for a label. Width and height are one rung
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A `date` (or `datetime`) field → a styled segmented date field on bits-ui. The
|
|
3
|
-
stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits as the
|
|
4
|
+
focus leaves it, the blank `""` over a `default:`, which stays blank, and `undefined`
|
|
5
|
+
(the unset rung) where nothing is declared, the parent removing the field. The value-object a date field lowers to is a render-time
|
|
6
6
|
concern: the editor only sees the stored string.
|
|
7
7
|
|
|
8
8
|
`today` is the render date rather than a written one (canon `SCHEMAS.md`), so its
|
|
@@ -43,8 +43,15 @@
|
|
|
43
43
|
|
|
44
44
|
Entering an unset field seats the default as the primitive's value, still at the
|
|
45
45
|
default rung, so an edit to one segment commits a whole date with the others the
|
|
46
|
-
default's. Leaving without an edit unseats it, and nothing is written
|
|
47
|
-
|
|
46
|
+
default's. Leaving without an edit unseats it, and nothing is written.
|
|
47
|
+
|
|
48
|
+
A segment edit that leaves a segment empty empties the primitive's value, which is
|
|
49
|
+
held rather than committed while the focus stays inside: the first Backspace through
|
|
50
|
+
a date writes nothing unset, and the empty segment shows its hint, not the default's
|
|
51
|
+
digits beside the ones still standing. Leaving settles it: blank where every segment
|
|
52
|
+
is empty, otherwise the stored date back. A field storing no date puts its segments
|
|
53
|
+
back by remounting the primitive, which re-reads an unchanged empty value as nothing
|
|
54
|
+
to sync.
|
|
48
55
|
-->
|
|
49
56
|
<script lang="ts">
|
|
50
57
|
import { DateField as BitsDateField } from 'bits-ui';
|
|
@@ -108,13 +115,18 @@
|
|
|
108
115
|
|
|
109
116
|
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
110
117
|
/** The default is the primitive's value: from entering an unset field until an
|
|
111
|
-
* edit
|
|
112
|
-
* neither drops a seat nor restores one over the segments a clear left. */
|
|
118
|
+
* edit or leaving. Part of the projection, so a re-derivation keeps it. */
|
|
113
119
|
let seat = $state(false);
|
|
120
|
+
/** An emptied value held while the focus stays inside (see the header). Part of the
|
|
121
|
+
* projection too, so a re-derivation restores neither the stored date nor the seat
|
|
122
|
+
* over the segments it emptied. */
|
|
123
|
+
let held = $state(false);
|
|
124
|
+
/** Remounts the primitive when bumped (see the header). */
|
|
125
|
+
let generation = $state(0);
|
|
114
126
|
// Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
|
|
115
127
|
// stays the package's.
|
|
116
128
|
let local = $derived(
|
|
117
|
-
value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
|
|
129
|
+
held ? '' : (value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : ''))
|
|
118
130
|
);
|
|
119
131
|
const parsed = $derived(toDateValue(local));
|
|
120
132
|
// The date the empty segments ghost, or undefined when there is nothing to ghost:
|
|
@@ -122,7 +134,7 @@
|
|
|
122
134
|
// fails to parse is AUTHORED-but-malformed, which the empty field states
|
|
123
135
|
// honestly: the ghost would claim it unset. Held as the parsed value rather than
|
|
124
136
|
// a boolean so the substitution below narrows on the one fact it needs.
|
|
125
|
-
const ghost = $derived(local === '' ? fallbackDate : undefined);
|
|
137
|
+
const ghost = $derived(value == null && local === '' && !held ? fallbackDate : undefined);
|
|
126
138
|
const seated = $derived(value == null && seat && local !== '');
|
|
127
139
|
/** Who put `today` here, while the field follows the render date. */
|
|
128
140
|
const follows = $derived(
|
|
@@ -130,6 +142,7 @@
|
|
|
130
142
|
);
|
|
131
143
|
|
|
132
144
|
function write(next: string): void {
|
|
145
|
+
held = false;
|
|
133
146
|
local = next;
|
|
134
147
|
seat = false;
|
|
135
148
|
onCommit(next);
|
|
@@ -152,7 +165,17 @@
|
|
|
152
165
|
if (!within(e) && value == null && fallbackDate) seat = true;
|
|
153
166
|
}
|
|
154
167
|
function leave(e: FocusEvent): void {
|
|
155
|
-
if (
|
|
168
|
+
if (within(e)) return;
|
|
169
|
+
seat = false;
|
|
170
|
+
if (!held) return;
|
|
171
|
+
held = false;
|
|
172
|
+
const filled = [...(wrapEl?.querySelectorAll('[data-segment]') ?? [])].some(
|
|
173
|
+
(s) => s.getAttribute('data-segment') !== 'literal' && !s.hasAttribute('data-ghosted')
|
|
174
|
+
);
|
|
175
|
+
const blank = fallbackDate ? '' : undefined;
|
|
176
|
+
if (!filled) {
|
|
177
|
+
if (value !== blank) onCommit(blank);
|
|
178
|
+
} else if (!value) generation++;
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
// What one segment prints, and which rung it takes.
|
|
@@ -194,61 +217,67 @@
|
|
|
194
217
|
onfocusout={leave}
|
|
195
218
|
onkeydowncapture={shortcut}
|
|
196
219
|
>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
digits at full ink while the segments around them ghost. -->
|
|
217
|
-
<BitsDateField.Input
|
|
218
|
-
class="qm-date qm-control-box qm-focus-ring-within"
|
|
219
|
-
aria-label={labelledBy ? undefined : label}
|
|
220
|
-
aria-labelledby={labelledBy}
|
|
221
|
-
aria-describedby={describedBy}
|
|
222
|
-
data-ghosted={ghost ? '' : undefined}
|
|
223
|
-
data-default={seated || follows ? '' : undefined}
|
|
220
|
+
{#key generation}
|
|
221
|
+
<BitsDateField.Root
|
|
222
|
+
value={parsed}
|
|
223
|
+
placeholder={fallbackDate}
|
|
224
|
+
onValueChange={(d) => {
|
|
225
|
+
// `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
|
|
226
|
+
// half-typed field yields undefined, held until the focus leaves. The
|
|
227
|
+
// primitive reports its own value again as a segment loses focus, which is
|
|
228
|
+
// no edit, and over a seated default or `today` would write it.
|
|
229
|
+
const next = d?.toString() ?? '';
|
|
230
|
+
if (next === (parsed?.toString() ?? '')) return;
|
|
231
|
+
if (!d) {
|
|
232
|
+
held = true;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
held = false;
|
|
236
|
+
local = next;
|
|
237
|
+
onCommit(next);
|
|
238
|
+
}}
|
|
224
239
|
>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
{#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
240
|
+
<!-- `data-ghosted` states the rung the way the enum trigger does. The date
|
|
241
|
+
primitive emits no per-segment placeholder marker (only bits' `select`
|
|
242
|
+
does), so the tone rides the same attribute, set per segment from the
|
|
243
|
+
substitution itself; which is also what keeps a half-typed date's own
|
|
244
|
+
digits at full ink while the segments around them ghost. -->
|
|
245
|
+
<BitsDateField.Input
|
|
246
|
+
class="qm-date qm-control-box qm-focus-ring-within"
|
|
247
|
+
aria-label={labelledBy ? undefined : label}
|
|
248
|
+
aria-labelledby={labelledBy}
|
|
249
|
+
aria-describedby={describedBy}
|
|
250
|
+
data-ghosted={ghost ? '' : undefined}
|
|
251
|
+
data-default={seated || follows ? '' : undefined}
|
|
252
|
+
>
|
|
253
|
+
{#snippet children({ segments })}
|
|
254
|
+
<!-- Keyed by index: `part` repeats (the `literal` separators between
|
|
255
|
+
segments all carry it) so a part-keyed block collides. -->
|
|
256
|
+
{#each segments as seg, i (i)}
|
|
257
|
+
{@const shown = segmentText(seg.part, seg.value)}
|
|
258
|
+
<BitsDateField.Segment
|
|
259
|
+
class="qm-date-segment"
|
|
260
|
+
part={seg.part}
|
|
261
|
+
data-ghosted={shown.ghosted}
|
|
262
|
+
>
|
|
263
|
+
{shown.text}
|
|
264
|
+
</BitsDateField.Segment>
|
|
265
|
+
{/each}
|
|
266
|
+
{#if !datetime}
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
class="qm-chip qm-tap-floor qm-focus-ring qm-date-today"
|
|
270
|
+
aria-pressed={!!follows}
|
|
271
|
+
data-default={follows === 'default' ? '' : undefined}
|
|
272
|
+
onclick={toggle}
|
|
273
|
+
>
|
|
274
|
+
<Icon name={follows ? 'calendar-check' : 'calendar'} />{t.strings.dateToday}
|
|
275
|
+
</button>
|
|
276
|
+
{/if}
|
|
277
|
+
{/snippet}
|
|
278
|
+
</BitsDateField.Input>
|
|
279
|
+
</BitsDateField.Root>
|
|
280
|
+
{/key}
|
|
252
281
|
</span>
|
|
253
282
|
|
|
254
283
|
<style>
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { Select } from 'bits-ui';
|
|
17
17
|
import Icon from './icons/Icon.svelte';
|
|
18
18
|
import { wording } from './strings.js';
|
|
19
|
+
import { spacePx } from '../core/rungs.js';
|
|
19
20
|
import './controls.css';
|
|
20
21
|
|
|
21
22
|
const t = wording();
|
|
@@ -79,6 +80,8 @@
|
|
|
79
80
|
* `none`: it draws at the default rung (theme.css). */
|
|
80
81
|
const ghostsMember = $derived(!optional && !!fallback);
|
|
81
82
|
const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
|
|
83
|
+
/** The sentinel's tag says what picking it does: return to the default, or unset. */
|
|
84
|
+
const tag = $derived(ghostsMember ? t.strings.enumUnsetTag : t.strings.enumUnsetTagNone);
|
|
82
85
|
/** What the closed trigger shows: the pick, or the ghosted default while unset. */
|
|
83
86
|
const shown = $derived(unset ? ghostText : dash(local));
|
|
84
87
|
|
|
@@ -113,10 +116,10 @@
|
|
|
113
116
|
data-ghosted={unset ? (ghostsMember ? 'default' : '') : undefined}
|
|
114
117
|
>
|
|
115
118
|
<span class="qm-select-shown">{shown}</span>
|
|
116
|
-
<Icon name="chevron-down" class="qm-select-chevron"
|
|
119
|
+
<Icon name="chevron-down" class="qm-select-chevron" />
|
|
117
120
|
</Select.Trigger>
|
|
118
121
|
<Select.Portal to={portalTarget}>
|
|
119
|
-
<Select.Content collisionBoundary={portalTarget ?? []} sideOffset={
|
|
122
|
+
<Select.Content collisionBoundary={portalTarget ?? []} sideOffset={spacePx(wrapEl)}>
|
|
120
123
|
<!-- The list portals out of the trigger's DOM but into the nearest
|
|
121
124
|
`[data-qm-root]`, and carries the marker itself, like FormatPopover.
|
|
122
125
|
That root is the boundary a flip is measured against too, being the box
|
|
@@ -138,11 +141,11 @@
|
|
|
138
141
|
<Select.Item
|
|
139
142
|
class="qm-menu-item qm-select-item qm-select-unset"
|
|
140
143
|
value={UNSET}
|
|
141
|
-
label={optional ? ghostText : `${ghostText} ${
|
|
144
|
+
label={optional ? ghostText : `${ghostText} ${tag}`}
|
|
142
145
|
>
|
|
143
146
|
<span class="qm-select-ghost">{ghostText}</span>
|
|
144
147
|
{#if !optional}
|
|
145
|
-
<span class="qm-select-tag">{
|
|
148
|
+
<span class="qm-select-tag">{tag}</span>
|
|
146
149
|
{/if}
|
|
147
150
|
</Select.Item>
|
|
148
151
|
<!-- A refused option still draws under `'disable'`, and under either
|
|
@@ -202,6 +205,8 @@
|
|
|
202
205
|
}
|
|
203
206
|
.qm-select-wrap :global(.qm-select-chevron) {
|
|
204
207
|
flex-shrink: 0;
|
|
208
|
+
width: var(--_qm-glyph-control);
|
|
209
|
+
height: var(--_qm-glyph-control);
|
|
205
210
|
}
|
|
206
211
|
/* The focus ring rides `.qm-focus-ring` on the trigger (controls.css). */
|
|
207
212
|
/* Shown-never-written: the closed control reads muted while unset, at the rung of
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
ResolvedField
|
|
28
28
|
} from '@quillmark/wasm';
|
|
29
29
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
30
|
-
import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
|
|
30
|
+
import { seatValueLeaves, stepsTo, type LandingBox, type LeafRegistry } from './leaves.js';
|
|
31
31
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
32
32
|
import {
|
|
33
33
|
VARIANT_DISCRIMINANT,
|
|
@@ -82,7 +82,9 @@
|
|
|
82
82
|
optionAllowed?: (value: string) => boolean;
|
|
83
83
|
/** How a refused option draws: greyed (default) or left out. */
|
|
84
84
|
enumDisallowed?: 'hide' | 'disable';
|
|
85
|
-
|
|
85
|
+
/** A focus inside the field: its address, and the steps down to the cell holding
|
|
86
|
+
* the focus ({@link stepsTo}), `[]` on the field's own control. */
|
|
87
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
86
88
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
87
89
|
onChange?: (addr: Addr) => void;
|
|
88
90
|
onError?: EditorErrorHandler;
|
|
@@ -221,7 +223,7 @@
|
|
|
221
223
|
|
|
222
224
|
/**
|
|
223
225
|
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
224
|
-
* control:
|
|
226
|
+
* control: the wash is a box's `::after`, which an `<input>` draws none of. The
|
|
225
227
|
* label is outside it, and stays out of the wash: an arrival marks where the caret
|
|
226
228
|
* landed, which is the control. A control that owns its label (`ownsLabel`: the array
|
|
227
229
|
* and the matrix) holds that row inside itself, so it names its own box
|
|
@@ -251,7 +253,8 @@
|
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
// A by-value prose leaf anywhere inside this field's containers is reached under
|
|
254
|
-
// this field's key: the active address
|
|
256
|
+
// this field's key: the registry and the active address name the field, never the
|
|
257
|
+
// element or cell.
|
|
255
258
|
seatValueLeaves({
|
|
256
259
|
focus: (leaf) => leaves?.focusValue(leafKey, leaf),
|
|
257
260
|
release: (leaf) => leaves?.releaseValue(leafKey, leaf)
|
|
@@ -261,11 +264,16 @@
|
|
|
261
264
|
* A form control has no controller to report its focus through, so the wrapper
|
|
262
265
|
* reports: `focusin` bubbles, so one handler covers a plain input, an array's N
|
|
263
266
|
* elements and an object's properties alike, and the active leaf names a scalar
|
|
264
|
-
* field the way it names a prose one. A prose leaf reports through its own
|
|
267
|
+
* field the way it names a prose one, down to the element or cell the focus is in. A prose leaf reports through its own
|
|
265
268
|
* controller — the source that drives its caret signals too — and is excluded here
|
|
266
269
|
* rather than counted twice.
|
|
267
270
|
*/
|
|
268
|
-
const reportFocus = $derived(
|
|
271
|
+
const reportFocus = $derived(
|
|
272
|
+
field.control === 'prose'
|
|
273
|
+
? undefined
|
|
274
|
+
: (e: FocusEvent & { currentTarget: HTMLElement }) =>
|
|
275
|
+
onFocus?.(addr, stepsTo(e.target, e.currentTarget))
|
|
276
|
+
);
|
|
269
277
|
</script>
|
|
270
278
|
|
|
271
279
|
<div class={['qm-field', { cell: span === 'cell' }]}>
|
|
@@ -276,7 +284,6 @@
|
|
|
276
284
|
id={domIds.label}
|
|
277
285
|
descriptionId={domIds.description}
|
|
278
286
|
{onActivate}
|
|
279
|
-
required={field.required}
|
|
280
287
|
description={field.description}
|
|
281
288
|
/>
|
|
282
289
|
{/if}
|
|
@@ -285,7 +292,7 @@
|
|
|
285
292
|
rule below holds why). One markup for both spans; a field owning its row nests a
|
|
286
293
|
level and measures the same. -->
|
|
287
294
|
<div class="qm-field-stack">
|
|
288
|
-
<div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
|
|
295
|
+
<div class="qm-field-control qm-wash" {@attach registerControl} onfocusin={reportFocus}>
|
|
289
296
|
{#if field.control === 'prose'}
|
|
290
297
|
<ProseField
|
|
291
298
|
{quill}
|
|
@@ -296,7 +303,7 @@
|
|
|
296
303
|
plaintext={field.plaintext}
|
|
297
304
|
fallback={printed ? (ghost as Content) : undefined}
|
|
298
305
|
placeholder={none}
|
|
299
|
-
|
|
306
|
+
emptyUnsets={optional}
|
|
300
307
|
labelledBy={domIds.label}
|
|
301
308
|
{describedBy}
|
|
302
309
|
{leafKey}
|
|
@@ -374,7 +381,6 @@
|
|
|
374
381
|
layout={arrayLayout(field.schema)}
|
|
375
382
|
max={field.schema.max}
|
|
376
383
|
label={field.label}
|
|
377
|
-
required={field.required}
|
|
378
384
|
description={field.description}
|
|
379
385
|
labelId={domIds.label}
|
|
380
386
|
descriptionId={domIds.description}
|
|
@@ -389,6 +395,7 @@
|
|
|
389
395
|
value={value as Record<string, unknown> | undefined}
|
|
390
396
|
properties={field.schema.properties}
|
|
391
397
|
{flat}
|
|
398
|
+
placed
|
|
392
399
|
label={field.label}
|
|
393
400
|
idBase={domIds.control}
|
|
394
401
|
labelledBy={domIds.label}
|
|
@@ -476,13 +483,10 @@
|
|
|
476
483
|
gap: var(--_qm-space);
|
|
477
484
|
min-width: 0;
|
|
478
485
|
}
|
|
479
|
-
/*
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
square over a rounded one; on an array or a subform it bounds a group, where
|
|
483
|
-
there is no single box for it to disagree with. */
|
|
486
|
+
/* The arrival wash's host (`.qm-wash`). The radius is the control's own, so the
|
|
487
|
+
wash's corners are the box's rather than square over a rounded one; on an array or
|
|
488
|
+
a subform it bounds a group, where there is no single box for it to disagree with. */
|
|
484
489
|
.qm-field-control {
|
|
485
|
-
position: relative;
|
|
486
490
|
border-radius: var(--_qm-radius-inner);
|
|
487
491
|
}
|
|
488
492
|
</style>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
1
|
+
import type { Document, Quill, Addr, Diagnostic, PathStep, ResolvedField } from '@quillmark/wasm';
|
|
2
2
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
3
|
import { type LeafRegistry } from './leaves.js';
|
|
4
4
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
@@ -27,7 +27,9 @@ interface Props {
|
|
|
27
27
|
optionAllowed?: (value: string) => boolean;
|
|
28
28
|
/** How a refused option draws: greyed (default) or left out. */
|
|
29
29
|
enumDisallowed?: 'hide' | 'disable';
|
|
30
|
-
|
|
30
|
+
/** A focus inside the field: its address, and the steps down to the cell holding
|
|
31
|
+
* the focus ({@link stepsTo}), `[]` on the field's own control. */
|
|
32
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
31
33
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
32
34
|
onChange?: (addr: Addr) => void;
|
|
33
35
|
onError?: EditorErrorHandler;
|
|
@@ -180,7 +180,7 @@
|
|
|
180
180
|
e.stopPropagation();
|
|
181
181
|
open = false;
|
|
182
182
|
}
|
|
183
|
-
}}><Icon name="info"
|
|
183
|
+
}}><Icon name="info" /></button
|
|
184
184
|
>
|
|
185
185
|
|
|
186
186
|
<!-- Placement is the description's height. One line rides the label rung beside its own
|
|
@@ -249,8 +249,15 @@
|
|
|
249
249
|
So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
|
|
250
250
|
carries that split and the giving-up of the family floor together.
|
|
251
251
|
|
|
252
|
-
It takes no fill either: the
|
|
253
|
-
|
|
252
|
+
It takes no fill either: the surface itself opens on hover.
|
|
253
|
+
|
|
254
|
+
At rest it recedes to the idle rung, a card describing every field otherwise
|
|
255
|
+
standing a mark beside each label before anything is touched. It lifts with the
|
|
256
|
+
scope that owns its row (the field, the property, the array or matrix, the table
|
|
257
|
+
its column heads, the section a lone field is named by), each reached by child
|
|
258
|
+
steps so a nested field's marker stays at rest under its container's hover, and
|
|
259
|
+
while its surface is open. The keyboard's `Mod-/` and the parked description do not
|
|
260
|
+
depend on the lift. */
|
|
254
261
|
.qm-field-hint {
|
|
255
262
|
/* The marker recedes to the label rung; the surface carries the text. */
|
|
256
263
|
color: var(--_qm-ink-label);
|
|
@@ -258,10 +265,20 @@
|
|
|
258
265
|
cursor: help;
|
|
259
266
|
padding: 0;
|
|
260
267
|
background: none;
|
|
268
|
+
opacity: var(--_qm-opacity-idle);
|
|
269
|
+
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
261
270
|
}
|
|
271
|
+
:global(:is(.qm-field, .qm-object-prop):is(:hover, :focus-within) > .qm-field-label-row)
|
|
272
|
+
> .qm-field-hint,
|
|
273
|
+
:global(:is(.qm-array, .qm-matrix):is(:hover, :focus-within) > * > .qm-field-label-row)
|
|
274
|
+
> .qm-field-hint,
|
|
275
|
+
:global(.qm-array-table:is(:hover, :focus-within) .qm-array-table-col > .qm-field-label-row)
|
|
276
|
+
> .qm-field-hint,
|
|
277
|
+
:global(.qm-group:is(:hover, :focus-within) > .qm-group-head) > .qm-field-hint,
|
|
262
278
|
.qm-field-hint:hover,
|
|
263
|
-
.qm-field-hint:focus-visible
|
|
264
|
-
|
|
279
|
+
.qm-field-hint:focus-visible,
|
|
280
|
+
.qm-field-hint[aria-expanded='true'] {
|
|
281
|
+
opacity: 1;
|
|
265
282
|
}
|
|
266
283
|
/* The lift, the translucency, the blur and the scale-in come from
|
|
267
284
|
`.qm-popover-surface` (controls.css). This surface's own is a measure: guidance
|