@quillmark/svelte 0.12.0 → 0.14.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 +3 -2
- package/dist/core/codec/decode.d.ts +17 -0
- package/dist/core/codec/decode.js +57 -4
- package/dist/core/codec/field.d.ts +63 -15
- package/dist/core/codec/field.js +201 -65
- package/dist/core/codec/index.d.ts +3 -3
- package/dist/core/codec/index.js +3 -3
- package/dist/core/codec/keymap.d.ts +5 -4
- package/dist/core/codec/keymap.js +5 -4
- package/dist/core/codec/prose.css +42 -0
- package/dist/core/codec/schema.d.ts +24 -5
- package/dist/core/codec/schema.js +40 -9
- package/dist/core/errors.d.ts +3 -2
- package/dist/core/theme.css +9 -6
- package/dist/preview/paint.js +21 -17
- package/dist/visual/ArrayField.svelte +66 -22
- package/dist/visual/ArrayField.svelte.d.ts +2 -0
- package/dist/visual/BooleanField.svelte +28 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -0
- package/dist/visual/Card.svelte +4 -3
- package/dist/visual/DateField.svelte +158 -40
- package/dist/visual/DateField.svelte.d.ts +5 -2
- package/dist/visual/EnumField.svelte +27 -9
- package/dist/visual/EnumField.svelte.d.ts +5 -0
- package/dist/visual/Field.svelte +48 -11
- package/dist/visual/FormatPopover.svelte +4 -3
- package/dist/visual/MatrixField.svelte +3 -2
- package/dist/visual/NumberField.svelte +73 -29
- package/dist/visual/NumberField.svelte.d.ts +5 -1
- package/dist/visual/ObjectField.svelte +63 -15
- package/dist/visual/ProseField.svelte +53 -23
- package/dist/visual/ProseField.svelte.d.ts +15 -3
- package/dist/visual/ProseValue.svelte +102 -46
- package/dist/visual/ProseValue.svelte.d.ts +17 -5
- package/dist/visual/TextField.svelte +35 -18
- package/dist/visual/TextField.svelte.d.ts +7 -0
- package/dist/visual/VariantField.svelte +1 -0
- package/dist/visual/VisualEditorInner.svelte +24 -24
- package/dist/visual/controls.css +16 -5
- package/dist/visual/icons/nodes.d.ts +28 -0
- package/dist/visual/icons/nodes.js +14 -1
- package/dist/visual/strings.d.ts +11 -1
- package/dist/visual/strings.js +3 -0
- package/dist/visual/structure.d.ts +77 -43
- package/dist/visual/structure.js +138 -64
- package/package.json +2 -2
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A `date` (or `datetime`) field → a styled segmented date field on bits-ui. The
|
|
3
|
-
stored value is a string
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits
|
|
4
|
+
`undefined` (the unset rung): the parent removes the field, and the quill's
|
|
5
|
+
`default:` applies. The value-object a date field lowers to is a render-time
|
|
6
|
+
concern: the editor only sees the stored string.
|
|
7
|
+
|
|
8
|
+
`today` is the render date rather than a written one (canon `SCHEMAS.md`), so its
|
|
9
|
+
digits take the default rung: they print, and nothing wrote them. The digits are
|
|
10
|
+
the local date, which is what `resolve` and a session opened without a date both
|
|
11
|
+
render. The Today toggle writes `today`, and pressed, writes those digits; `T` in a
|
|
12
|
+
segment writes `today` too. An edit to a segment writes a whole date, the other
|
|
13
|
+
segments the render date's. A `default: today` presses the toggle at the default
|
|
14
|
+
rung while the field is unset.
|
|
8
15
|
|
|
9
16
|
Styled rather than a native `<input type="date">`: that control's calendar popup
|
|
10
17
|
is UA-owned and reaches no dial. `DateField` (segments, no
|
|
@@ -23,27 +30,38 @@
|
|
|
23
30
|
make every reconcile fire and re-render all seven segments on each commit.
|
|
24
31
|
|
|
25
32
|
The ghost is the default's digits, not a format hint. An unset field
|
|
26
|
-
carrying a `default:` prints the default's digits in the segments
|
|
27
|
-
instead of the primitive's `mm`/`dd`/`yyyy` hints, which say
|
|
28
|
-
rung says "will render 2026-01-01".
|
|
29
|
-
over an unset primitive:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
carrying a `default:` prints the default's digits in the segments at the default
|
|
34
|
+
rung (theme.css), instead of the primitive's `mm`/`dd`/`yyyy` hints, which say
|
|
35
|
+
"empty" where the rung says "will render 2026-01-01". At rest the ghost is painted
|
|
36
|
+
in the segment snippet, over an unset primitive: a default held as its value reads
|
|
37
|
+
as filled to every path that reads the primitive (`areAllSegmentsFilled`,
|
|
38
|
+
Backspace, the hidden input), and whether it stands as the value is the field's to
|
|
39
|
+
say only once the user enters it. `placeholder`
|
|
40
|
+
(the `DateValue` the segments count from, never one they display) carries the
|
|
41
|
+
default too, so arrowing an empty segment starts at the render's date rather than
|
|
42
|
+
today's.
|
|
43
|
+
|
|
44
|
+
Entering an unset field seats the default as the primitive's value, still at the
|
|
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; so does
|
|
47
|
+
clearing a segment.
|
|
36
48
|
-->
|
|
37
49
|
<script lang="ts">
|
|
38
50
|
import { DateField as BitsDateField } from 'bits-ui';
|
|
39
|
-
import { parseDate, type DateValue } from '@internationalized/date';
|
|
51
|
+
import { getLocalTimeZone, parseDate, today, type DateValue } from '@internationalized/date';
|
|
52
|
+
import Icon from './icons/Icon.svelte';
|
|
53
|
+
import { wording } from './strings.js';
|
|
40
54
|
import { syncedLocal } from './synced.svelte.js';
|
|
41
55
|
import './controls.css';
|
|
42
56
|
|
|
57
|
+
const t = wording();
|
|
58
|
+
const TODAY = 'today';
|
|
59
|
+
const renderDate = today(getLocalTimeZone());
|
|
60
|
+
|
|
43
61
|
interface Props {
|
|
44
62
|
value: string | undefined;
|
|
45
|
-
/** The
|
|
46
|
-
*
|
|
63
|
+
/** The declared `default:` (`YYYY-MM-DD` or `today`): parsed for display only,
|
|
64
|
+
* shown while unset, never written. */
|
|
47
65
|
fallback?: string;
|
|
48
66
|
/** Accessible name for a field nothing else names: an object property, whose
|
|
49
67
|
* name is the field label plus the property's. A field's own date takes
|
|
@@ -60,9 +78,12 @@
|
|
|
60
78
|
labelledBy?: string;
|
|
61
79
|
/** The parked `description` (FieldLabel): announced after the name. */
|
|
62
80
|
describedBy?: string;
|
|
81
|
+
/** A `datetime`: `today` is no value of its type, so it draws no toggle and
|
|
82
|
+
* takes no `T`. */
|
|
83
|
+
datetime?: boolean;
|
|
63
84
|
onCommit: (v: string | undefined) => void;
|
|
64
85
|
}
|
|
65
|
-
let { value, fallback, label, labelledBy, describedBy, onCommit }: Props = $props();
|
|
86
|
+
let { value, fallback, label, labelledBy, describedBy, datetime, onCommit }: Props = $props();
|
|
66
87
|
|
|
67
88
|
let wrapEl: HTMLElement | undefined = $state();
|
|
68
89
|
/** Take the caret: what the label click, and a parent placing focus here, calls.
|
|
@@ -78,6 +99,7 @@
|
|
|
78
99
|
// throws on a malformed authored value: an empty field is the honest render.
|
|
79
100
|
function toDateValue(s: string): DateValue | undefined {
|
|
80
101
|
if (!s) return undefined;
|
|
102
|
+
if (s === TODAY) return renderDate;
|
|
81
103
|
try {
|
|
82
104
|
return parseDate(s);
|
|
83
105
|
} catch {
|
|
@@ -85,25 +107,63 @@
|
|
|
85
107
|
}
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
111
|
+
/** The default is the primitive's value: from entering an unset field until an
|
|
112
|
+
* edit, a cleared segment or leaving. Part of the projection, so a reconcile
|
|
113
|
+
* neither drops a seat nor restores one over the segments a clear left. */
|
|
114
|
+
let seat = $state(false);
|
|
88
115
|
// Local value synced to `value` as a string (see the identity note above);
|
|
89
116
|
// own-edits stay local, only an external change reconciles back in. Driven
|
|
90
117
|
// controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
|
|
91
118
|
// the package's.
|
|
92
|
-
const local = syncedLocal(
|
|
119
|
+
const local = syncedLocal(
|
|
120
|
+
() => value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
|
|
121
|
+
);
|
|
93
122
|
const parsed = $derived(toDateValue(local.value));
|
|
94
123
|
// The date the empty segments ghost, or undefined when there is nothing to ghost:
|
|
95
124
|
// the field is unset and the default has a date form. A non-blank local that
|
|
96
125
|
// fails to parse is AUTHORED-but-malformed, which the empty field states
|
|
97
126
|
// honestly: the ghost would claim it unset. Held as the parsed value rather than
|
|
98
127
|
// a boolean so the substitution below narrows on the one fact it needs.
|
|
99
|
-
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
100
128
|
const ghost = $derived(local.value === '' ? fallbackDate : undefined);
|
|
129
|
+
const seated = $derived(value == null && seat && local.value !== '');
|
|
130
|
+
/** Who put `today` here, while the field follows the render date. */
|
|
131
|
+
const follows = $derived(
|
|
132
|
+
local.value === TODAY ? 'authored' : value == null && fallback === TODAY ? 'default' : undefined
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
function write(next: string): void {
|
|
136
|
+
local.value = next;
|
|
137
|
+
seat = false;
|
|
138
|
+
onCommit(next);
|
|
139
|
+
}
|
|
140
|
+
function toggle(): void {
|
|
141
|
+
write(follows ? renderDate.toString() : TODAY);
|
|
142
|
+
}
|
|
143
|
+
function shortcut(e: KeyboardEvent): void {
|
|
144
|
+
if (datetime || e.key.toLowerCase() !== 't' || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
145
|
+
const seg = (e.target as HTMLElement).dataset.segment;
|
|
146
|
+
if (!seg || seg === 'literal') return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
write(TODAY);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Focus moving between segments is neither an entry nor a leaving. */
|
|
153
|
+
const within = (e: FocusEvent): boolean => !!wrapEl?.contains(e.relatedTarget as Node | null);
|
|
154
|
+
function enter(e: FocusEvent): void {
|
|
155
|
+
if (!within(e) && value == null && fallbackDate) seat = true;
|
|
156
|
+
}
|
|
157
|
+
function leave(e: FocusEvent): void {
|
|
158
|
+
if (!within(e)) seat = false;
|
|
159
|
+
}
|
|
101
160
|
|
|
102
|
-
// What one segment prints, and
|
|
161
|
+
// What one segment prints, and which rung it takes.
|
|
103
162
|
//
|
|
104
163
|
// An unfilled segment is always shown-never-written, whether it prints the
|
|
105
|
-
// default's digits or the primitive's own
|
|
106
|
-
//
|
|
164
|
+
// default's digits (`default`, the rung of what prints) or the primitive's own
|
|
165
|
+
// `mm`/`dd`/`yyyy` hint (`hint`, a word about the value): both state "nothing
|
|
166
|
+
// authored here". Only an unfilled segment ghosts: a half-entered date
|
|
107
167
|
// holds digits while the value is still undefined (one unfilled segment unsets
|
|
108
168
|
// the whole field), so ghosting unconditionally would paint over, and dim, the
|
|
109
169
|
// digits just typed. The segment's own text is the tell: its unfilled hint is
|
|
@@ -113,30 +173,42 @@
|
|
|
113
173
|
// The separators are `literal` parts, never unfilled; substitution covers the
|
|
114
174
|
// date parts only, so any time part keeps the primitive's text. Digits are
|
|
115
175
|
// zero-padded to the segment widths the field displays.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
176
|
+
type Rung = 'default' | 'hint' | undefined;
|
|
177
|
+
function segmentText(part: string, text: string): { text: string; ghosted: Rung } {
|
|
178
|
+
if (part === 'literal' || /\d/.test(text)) return { text, ghosted: undefined };
|
|
179
|
+
if (!ghost) return { text, ghosted: 'hint' };
|
|
119
180
|
switch (part) {
|
|
120
181
|
case 'year':
|
|
121
|
-
return { text: String(ghost.year).padStart(4, '0'), ghosted:
|
|
182
|
+
return { text: String(ghost.year).padStart(4, '0'), ghosted: 'default' };
|
|
122
183
|
case 'month':
|
|
123
|
-
return { text: String(ghost.month).padStart(2, '0'), ghosted:
|
|
184
|
+
return { text: String(ghost.month).padStart(2, '0'), ghosted: 'default' };
|
|
124
185
|
case 'day':
|
|
125
|
-
return { text: String(ghost.day).padStart(2, '0'), ghosted:
|
|
186
|
+
return { text: String(ghost.day).padStart(2, '0'), ghosted: 'default' };
|
|
126
187
|
default:
|
|
127
|
-
return { text, ghosted:
|
|
188
|
+
return { text, ghosted: 'hint' };
|
|
128
189
|
}
|
|
129
190
|
}
|
|
130
191
|
</script>
|
|
131
192
|
|
|
132
|
-
<span
|
|
193
|
+
<span
|
|
194
|
+
class="qm-date-wrap"
|
|
195
|
+
bind:this={wrapEl}
|
|
196
|
+
onfocusin={enter}
|
|
197
|
+
onfocusout={leave}
|
|
198
|
+
onkeydowncapture={shortcut}
|
|
199
|
+
>
|
|
133
200
|
<BitsDateField.Root
|
|
134
201
|
value={parsed}
|
|
135
202
|
placeholder={fallbackDate}
|
|
136
203
|
onValueChange={(d) => {
|
|
137
204
|
// `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
|
|
138
|
-
// half-typed field yields undefined: the unset rung.
|
|
139
|
-
|
|
205
|
+
// half-typed field yields undefined: the unset rung. The primitive reports
|
|
206
|
+
// its own value again as a segment loses focus, which is no edit, and over a
|
|
207
|
+
// seated default or `today` would write it.
|
|
208
|
+
const next = d?.toString() ?? '';
|
|
209
|
+
if (next === (parsed?.toString() ?? '')) return;
|
|
210
|
+
local.value = next;
|
|
211
|
+
if (!d) seat = false;
|
|
140
212
|
onCommit(d?.toString());
|
|
141
213
|
}}
|
|
142
214
|
>
|
|
@@ -151,6 +223,7 @@
|
|
|
151
223
|
aria-labelledby={labelledBy}
|
|
152
224
|
aria-describedby={describedBy}
|
|
153
225
|
data-ghosted={ghost ? '' : undefined}
|
|
226
|
+
data-default={seated || follows ? '' : undefined}
|
|
154
227
|
>
|
|
155
228
|
{#snippet children({ segments })}
|
|
156
229
|
<!-- Keyed by index: `part` repeats (the `literal` separators between
|
|
@@ -160,11 +233,22 @@
|
|
|
160
233
|
<BitsDateField.Segment
|
|
161
234
|
class="qm-date-segment"
|
|
162
235
|
part={seg.part}
|
|
163
|
-
data-ghosted={shown.ghosted
|
|
236
|
+
data-ghosted={shown.ghosted}
|
|
164
237
|
>
|
|
165
238
|
{shown.text}
|
|
166
239
|
</BitsDateField.Segment>
|
|
167
240
|
{/each}
|
|
241
|
+
{#if !datetime}
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
class="qm-chip qm-tap-floor qm-focus-ring qm-date-today"
|
|
245
|
+
aria-pressed={!!follows}
|
|
246
|
+
data-default={follows === 'default' ? '' : undefined}
|
|
247
|
+
onclick={toggle}
|
|
248
|
+
>
|
|
249
|
+
<Icon name={follows ? 'calendar-check' : 'calendar'} />{t.strings.dateToday}
|
|
250
|
+
</button>
|
|
251
|
+
{/if}
|
|
168
252
|
{/snippet}
|
|
169
253
|
</BitsDateField.Input>
|
|
170
254
|
</BitsDateField.Root>
|
|
@@ -193,11 +277,45 @@
|
|
|
193
277
|
.qm-date-wrap :global(.qm-date-segment:focus) {
|
|
194
278
|
background: var(--_qm-surface-hover);
|
|
195
279
|
}
|
|
196
|
-
/* An unfilled segment is ghost-toned whatever it prints
|
|
197
|
-
|
|
198
|
-
never written, either way. The marker is the
|
|
199
|
-
the date primitive emits no placeholder
|
|
200
|
-
|
|
280
|
+
/* An unfilled segment is ghost-toned whatever it prints, at the rung of what it
|
|
281
|
+
prints: the resolved `default:`'s digits at the default rung, the `dd`/`mm`/`yyyy`
|
|
282
|
+
hint at the label rung. Shown, never written, either way. The marker is the
|
|
283
|
+
component's own (see the snippet): the date primitive emits no placeholder
|
|
284
|
+
attribute of its own. A seated default is the value and still unwritten, so its
|
|
285
|
+
filled segments take the default rung too. */
|
|
286
|
+
.qm-date-wrap :global(.qm-date-segment[data-ghosted='hint']) {
|
|
201
287
|
color: var(--_qm-ink-label);
|
|
202
288
|
}
|
|
289
|
+
.qm-date-wrap :global(.qm-date-segment[data-ghosted='default']),
|
|
290
|
+
.qm-date-wrap :global(.qm-date[data-default] .qm-date-segment) {
|
|
291
|
+
color: var(--_qm-ink-default);
|
|
292
|
+
}
|
|
293
|
+
/* The toggle's state is its fill and its glyph, never the pointer: a hover fill
|
|
294
|
+
would read as pressed. Off, it rests hidden where a pointer can reveal it, still
|
|
295
|
+
holding its width so the digits never shift. It sits inside the box's line, so
|
|
296
|
+
the box stands as tall as the inputs beside it. */
|
|
297
|
+
.qm-date-today {
|
|
298
|
+
margin-inline-start: auto;
|
|
299
|
+
padding-block: var(--_qm-space-half);
|
|
300
|
+
line-height: 1;
|
|
301
|
+
border-radius: var(--_qm-radius-inner);
|
|
302
|
+
}
|
|
303
|
+
.qm-date-today:hover {
|
|
304
|
+
background: transparent;
|
|
305
|
+
color: var(--_qm-ink-label);
|
|
306
|
+
}
|
|
307
|
+
.qm-date-today[aria-pressed='true'] {
|
|
308
|
+
background: var(--_qm-accent-tint);
|
|
309
|
+
color: var(--_qm-ink);
|
|
310
|
+
}
|
|
311
|
+
.qm-date-today[data-default] {
|
|
312
|
+
background: transparent;
|
|
313
|
+
box-shadow: inset 0 0 0 var(--_qm-border-width) var(--_qm-border);
|
|
314
|
+
color: var(--_qm-ink-default);
|
|
315
|
+
}
|
|
316
|
+
@media (hover: hover) {
|
|
317
|
+
.qm-date-wrap:not(:hover, :focus-within) .qm-date-today[aria-pressed='false'] {
|
|
318
|
+
opacity: 0;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
203
321
|
</style>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import './controls.css';
|
|
2
2
|
interface Props {
|
|
3
3
|
value: string | undefined;
|
|
4
|
-
/** The
|
|
5
|
-
*
|
|
4
|
+
/** The declared `default:` (`YYYY-MM-DD` or `today`): parsed for display only,
|
|
5
|
+
* shown while unset, never written. */
|
|
6
6
|
fallback?: string;
|
|
7
7
|
/** Accessible name for a field nothing else names: an object property, whose
|
|
8
8
|
* name is the field label plus the property's. A field's own date takes
|
|
@@ -19,6 +19,9 @@ interface Props {
|
|
|
19
19
|
labelledBy?: string;
|
|
20
20
|
/** The parked `description` (FieldLabel): announced after the name. */
|
|
21
21
|
describedBy?: string;
|
|
22
|
+
/** A `datetime`: `today` is no value of its type, so it draws no toggle and
|
|
23
|
+
* takes no `T`. */
|
|
24
|
+
datetime?: boolean;
|
|
22
25
|
onCommit: (v: string | undefined) => void;
|
|
23
26
|
}
|
|
24
27
|
declare const DateField: import("svelte").Component<Props, {
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
value: string | undefined;
|
|
26
26
|
values: string[];
|
|
27
27
|
fallback?: string;
|
|
28
|
+
/** The schema's `ui.blank_title`: what the blank draws as, in place of the em dash. */
|
|
29
|
+
blankTitle?: string;
|
|
30
|
+
/** An `enum?` cell, which prints `none` unset rather than its blank, and so ghosts
|
|
31
|
+
* `strings.optionalGhost` there. */
|
|
32
|
+
optional?: boolean;
|
|
28
33
|
/** Accessible name for a trigger nothing else names: an object property, whose
|
|
29
34
|
* name is the field label plus the property's. A field's own trigger takes `id`
|
|
30
35
|
* instead and is named by the `<label for>` beside it (the trigger is a
|
|
@@ -47,6 +52,8 @@
|
|
|
47
52
|
value,
|
|
48
53
|
values,
|
|
49
54
|
fallback,
|
|
55
|
+
blankTitle,
|
|
56
|
+
optional = false,
|
|
50
57
|
label,
|
|
51
58
|
id,
|
|
52
59
|
describedBy,
|
|
@@ -66,11 +73,15 @@
|
|
|
66
73
|
// the package's rather than the primitive's.
|
|
67
74
|
const local = syncedLocal(() => value ?? UNSET);
|
|
68
75
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
76
|
+
/** The blank has no glyph of its own: the quill's `ui.blank_title` stands in for
|
|
77
|
+
* it, else an em dash. */
|
|
78
|
+
const dash = (v: string | undefined) => v || blankTitle || '—';
|
|
71
79
|
|
|
72
80
|
const unset = $derived(local.value === UNSET);
|
|
73
|
-
|
|
81
|
+
/** The ghost is a member the render prints, rather than a word for the blank or for
|
|
82
|
+
* `none`: it draws at the default rung (theme.css). */
|
|
83
|
+
const ghostsMember = $derived(!optional && !!fallback);
|
|
84
|
+
const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
|
|
74
85
|
/** What the closed trigger shows: the pick, or the ghosted default while unset. */
|
|
75
86
|
const shown = $derived(unset ? ghostText : dash(local.value));
|
|
76
87
|
|
|
@@ -102,7 +113,7 @@
|
|
|
102
113
|
{id}
|
|
103
114
|
aria-label={id ? undefined : label}
|
|
104
115
|
aria-describedby={describedBy}
|
|
105
|
-
data-ghosted={unset ? '' : undefined}
|
|
116
|
+
data-ghosted={unset ? (ghostsMember ? 'default' : '') : undefined}
|
|
106
117
|
>
|
|
107
118
|
<span class="qm-select-shown">{shown}</span>
|
|
108
119
|
<Icon name="chevron-down" class="qm-select-chevron" size={14} />
|
|
@@ -125,13 +136,17 @@
|
|
|
125
136
|
and while the field is unset this is the selected row, so the weight
|
|
126
137
|
step pulls the other way. It rides the accessible name for the same
|
|
127
138
|
reason. -->
|
|
139
|
+
<!-- An optional cell's row is untagged: it ghosts no default, and `None`
|
|
140
|
+
names no member to tell it from. -->
|
|
128
141
|
<Select.Item
|
|
129
142
|
class="qm-menu-item qm-select-item qm-select-unset"
|
|
130
143
|
value={UNSET}
|
|
131
|
-
label={`${ghostText} ${t.strings.enumUnsetTag}`}
|
|
144
|
+
label={optional ? ghostText : `${ghostText} ${t.strings.enumUnsetTag}`}
|
|
132
145
|
>
|
|
133
146
|
<span class="qm-select-ghost">{ghostText}</span>
|
|
134
|
-
|
|
147
|
+
{#if !optional}
|
|
148
|
+
<span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
|
|
149
|
+
{/if}
|
|
135
150
|
</Select.Item>
|
|
136
151
|
<!-- A refused option still draws under `'disable'`, and under either
|
|
137
152
|
policy when it is the one selected: a listbox whose selected value
|
|
@@ -192,11 +207,14 @@
|
|
|
192
207
|
flex-shrink: 0;
|
|
193
208
|
}
|
|
194
209
|
/* The focus ring rides `.qm-focus-ring` on the trigger (controls.css). */
|
|
195
|
-
/* Shown-never-written: the closed control reads muted while unset,
|
|
196
|
-
|
|
210
|
+
/* Shown-never-written: the closed control reads muted while unset, at the rung of
|
|
211
|
+
what it shows — the default member the render prints, or a word for the blank. */
|
|
197
212
|
.qm-select-wrap :global(.qm-select[data-ghosted]) {
|
|
198
213
|
color: var(--_qm-ink-label);
|
|
199
214
|
}
|
|
215
|
+
.qm-select-wrap :global(.qm-select[data-ghosted='default']) {
|
|
216
|
+
color: var(--_qm-ink-default);
|
|
217
|
+
}
|
|
200
218
|
/* The open list is `.qm-menu-surface` and its rows `.qm-menu-item` (controls.css):
|
|
201
219
|
the lift, the inset and the highlight are the shared menu recipe. What a listbox
|
|
202
220
|
adds over a menu is here: it spans its trigger rather than its own content, it
|
|
@@ -229,7 +247,7 @@
|
|
|
229
247
|
/* A step down the size ramp, which is what says this is chrome and the word beside
|
|
230
248
|
it is the value. The ink is the ghost's own: a second tone here would rank the
|
|
231
249
|
tag against the thing it annotates, and there is no rung under `label` to take
|
|
232
|
-
(theme.css, "
|
|
250
|
+
(theme.css, "nothing under the last"). */
|
|
233
251
|
.qm-select-tag {
|
|
234
252
|
flex-shrink: 0;
|
|
235
253
|
font-size: var(--_qm-text-label);
|
|
@@ -3,6 +3,11 @@ interface Props {
|
|
|
3
3
|
value: string | undefined;
|
|
4
4
|
values: string[];
|
|
5
5
|
fallback?: string;
|
|
6
|
+
/** The schema's `ui.blank_title`: what the blank draws as, in place of the em dash. */
|
|
7
|
+
blankTitle?: string;
|
|
8
|
+
/** An `enum?` cell, which prints `none` unset rather than its blank, and so ghosts
|
|
9
|
+
* `strings.optionalGhost` there. */
|
|
10
|
+
optional?: boolean;
|
|
6
11
|
/** Accessible name for a trigger nothing else names: an object property, whose
|
|
7
12
|
* name is the field label plus the property's. A field's own trigger takes `id`
|
|
8
13
|
* instead and is named by the `<label for>` beside it (the trigger is a
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
`diagnostics` is the routed `Diagnostic[]` for this field (VisualEditor's
|
|
14
14
|
`diagByKey`, merging `quill.validate`, local commit errors, and the external
|
|
15
15
|
`diagnostics` prop (VISUAL_EDITOR §Diagnostics)) rendered via the shared
|
|
16
|
-
`DiagnosticList`, non-gating
|
|
16
|
+
`DiagnosticList`, non-gating; each set also re-evaluates a prose leaf's hold
|
|
17
|
+
(`ProseField`).
|
|
17
18
|
-->
|
|
18
19
|
<script lang="ts">
|
|
19
20
|
import type {
|
|
@@ -31,10 +32,16 @@
|
|
|
31
32
|
import {
|
|
32
33
|
VARIANT_DISCRIMINANT,
|
|
33
34
|
arrayLayout,
|
|
35
|
+
baseType,
|
|
36
|
+
exampleGhost,
|
|
34
37
|
ghostDefault,
|
|
35
38
|
isContainer,
|
|
36
|
-
|
|
39
|
+
optionalCell,
|
|
40
|
+
printedText,
|
|
41
|
+
stringifyGhost,
|
|
42
|
+
titleText
|
|
37
43
|
} from './structure.js';
|
|
44
|
+
import { wording } from './strings.js';
|
|
38
45
|
import { deepen } from './diagnostics.js';
|
|
39
46
|
import type { FieldDomIds } from './domid.js';
|
|
40
47
|
import ProseField from './ProseField.svelte';
|
|
@@ -104,12 +111,30 @@
|
|
|
104
111
|
diagnostics
|
|
105
112
|
}: Props = $props();
|
|
106
113
|
|
|
107
|
-
|
|
114
|
+
const t = wording();
|
|
115
|
+
|
|
116
|
+
// What an unset field shows of its `default:`: the resolved value (provenance,
|
|
108
117
|
// `source === 'default'`). `ghost` is the raw typed value (enum/number/boolean
|
|
109
|
-
// fallbacks)
|
|
110
|
-
//
|
|
118
|
+
// fallbacks, a content leaf's `Content`), `defaultStr` its string form; an
|
|
119
|
+
// object-valued default has none. An array draws its declared literal instead,
|
|
120
|
+
// which is what a take writes (`ArrayField`).
|
|
121
|
+
// A default that prints stands in the control as the value it is, at the default
|
|
122
|
+
// rung, and the first edit takes it (VISUAL_EDITOR §"The commitment ladder"). Where
|
|
123
|
+
// nothing prints, a control draws words: the `none` an optional cell prints, and
|
|
124
|
+
// a free-text field's `example:`.
|
|
111
125
|
const ghost = $derived(ghostDefault(provenance));
|
|
112
|
-
const defaultStr = $derived(
|
|
126
|
+
const defaultStr = $derived(
|
|
127
|
+
field.control === 'prose' ? titleText(ghost) || undefined : stringifyGhost(ghost)
|
|
128
|
+
);
|
|
129
|
+
const printed = $derived(printedText(defaultStr));
|
|
130
|
+
// A text, number or date control reads its default off the schema, not the resolved
|
|
131
|
+
// row: the row stops naming a default once the field is written, and emptying a
|
|
132
|
+
// written field still has to know that the default prints. A date's `default: today`
|
|
133
|
+
// resolves to digits, and the control has to know it follows the render date.
|
|
134
|
+
const declared = $derived(printedText(field.schema.default));
|
|
135
|
+
const optional = $derived(optionalCell(field.schema));
|
|
136
|
+
const none = $derived(optional && value == null ? t.strings.optionalGhost : undefined);
|
|
137
|
+
const example = $derived(value == null ? exampleGhost(field.schema) : undefined);
|
|
113
138
|
// A variant resolves as one rung whose value is the whole container, so the
|
|
114
139
|
// discriminant's ghost is that container's own discriminant cell.
|
|
115
140
|
const ghostMember = $derived(
|
|
@@ -260,7 +285,10 @@
|
|
|
260
285
|
{addr}
|
|
261
286
|
inline={field.inline}
|
|
262
287
|
plaintext={field.plaintext}
|
|
263
|
-
|
|
288
|
+
fallback={printed ? (ghost as Content) : undefined}
|
|
289
|
+
placeholder={none}
|
|
290
|
+
placeholderUntilEdit
|
|
291
|
+
{example}
|
|
264
292
|
labelledBy={domIds.label}
|
|
265
293
|
{describedBy}
|
|
266
294
|
{leafKey}
|
|
@@ -269,12 +297,15 @@
|
|
|
269
297
|
{onChange}
|
|
270
298
|
{onError}
|
|
271
299
|
{leaves}
|
|
300
|
+
{diagnostics}
|
|
272
301
|
/>
|
|
273
302
|
{:else if field.control === 'enum'}
|
|
274
303
|
<EnumField
|
|
275
304
|
value={value as string | undefined}
|
|
276
305
|
values={field.schema.values ?? []}
|
|
277
306
|
fallback={ghost as string | undefined}
|
|
307
|
+
blankTitle={field.schema.ui?.blank_title}
|
|
308
|
+
{optional}
|
|
278
309
|
id={domIds.control}
|
|
279
310
|
{describedBy}
|
|
280
311
|
onCommit={onCommitScalar}
|
|
@@ -300,8 +331,9 @@
|
|
|
300
331
|
{:else if field.control === 'number'}
|
|
301
332
|
<NumberField
|
|
302
333
|
value={value as number | undefined}
|
|
303
|
-
integer={field.schema
|
|
304
|
-
fallback={
|
|
334
|
+
integer={baseType(field.schema) === 'integer'}
|
|
335
|
+
fallback={declared}
|
|
336
|
+
placeholder={none}
|
|
305
337
|
id={domIds.control}
|
|
306
338
|
{describedBy}
|
|
307
339
|
onCommit={onCommitScalar}
|
|
@@ -310,6 +342,7 @@
|
|
|
310
342
|
<BooleanField
|
|
311
343
|
value={value as boolean | undefined}
|
|
312
344
|
fallback={ghost as boolean | undefined}
|
|
345
|
+
{optional}
|
|
313
346
|
id={domIds.control}
|
|
314
347
|
{describedBy}
|
|
315
348
|
onCommit={onCommitScalar}
|
|
@@ -318,7 +351,8 @@
|
|
|
318
351
|
<DateField
|
|
319
352
|
bind:this={dateEl}
|
|
320
353
|
value={value as string | undefined}
|
|
321
|
-
fallback={
|
|
354
|
+
fallback={stringifyGhost(field.schema.default)}
|
|
355
|
+
datetime={baseType(field.schema) === 'datetime'}
|
|
322
356
|
labelledBy={domIds.label}
|
|
323
357
|
{describedBy}
|
|
324
358
|
onCommit={onCommitScalar}
|
|
@@ -327,6 +361,7 @@
|
|
|
327
361
|
<ArrayField
|
|
328
362
|
bind:this={arrayEl}
|
|
329
363
|
value={value as unknown[] | undefined}
|
|
364
|
+
fallback={Array.isArray(field.schema.default) ? field.schema.default : undefined}
|
|
330
365
|
items={field.schema.items}
|
|
331
366
|
layout={arrayLayout(field.schema)}
|
|
332
367
|
max={field.schema.max}
|
|
@@ -370,7 +405,9 @@
|
|
|
370
405
|
{:else}
|
|
371
406
|
<TextField
|
|
372
407
|
value={value as string | undefined}
|
|
373
|
-
|
|
408
|
+
fallback={declared}
|
|
409
|
+
placeholder={none}
|
|
410
|
+
{example}
|
|
374
411
|
id={domIds.control}
|
|
375
412
|
{describedBy}
|
|
376
413
|
onCommit={onCommitScalar}
|
|
@@ -142,9 +142,10 @@
|
|
|
142
142
|
const selection = view?.state.selection;
|
|
143
143
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
144
144
|
// …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
|
|
145
|
-
// types (`plaintextSchema`), so this surface withholds itself over
|
|
146
|
-
// the way it never rises over the text input a `string` field draws:
|
|
147
|
-
// literal text, and a mark on it is a value the boundary refuses to
|
|
145
|
+
// types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
|
|
146
|
+
// one entirely, the way it never rises over the text input a `string` field draws:
|
|
147
|
+
// the value is literal text, and a mark on it is a value the boundary refuses to
|
|
148
|
+
// coerce back.
|
|
148
149
|
if (
|
|
149
150
|
!view ||
|
|
150
151
|
!view.hasFocus() ||
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
Commit is whole: the sparse map, one `writer.set`. A tick writes the member object
|
|
18
18
|
with an explicit `held`, an untick keeps the columns under `held: false`, and a member
|
|
19
19
|
unheld with no columns leaves the map (`memberWrite`). What is blank per column is the
|
|
20
|
-
engine's rule and not restated: a column control that clears drops its key,
|
|
21
|
-
editor drops a member it has emptied rather than deciding skippability for
|
|
20
|
+
engine's rule and not restated: a column control that clears to unset drops its key,
|
|
21
|
+
so the editor drops a member it has emptied rather than deciding skippability for
|
|
22
|
+
itself; one whose default prints clears to the empty answer, which the member holds.
|
|
22
23
|
The read takes both rest forms — a bare tick and `{held?, …columns}`, whose `held` rests
|
|
23
24
|
at `false` where the mapping names none — and the write lands one, on the member edited
|
|
24
25
|
alone; every other member rides through as the document spelled it.
|