@quillmark/svelte 0.11.0 → 0.13.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/dist/core/address.d.ts +17 -10
- package/dist/core/address.js +26 -12
- package/dist/core/bloom.js +4 -3
- package/dist/core/codec/decode.d.ts +17 -0
- package/dist/core/codec/decode.js +78 -19
- package/dist/core/codec/encode.js +1 -1
- package/dist/core/codec/field.d.ts +64 -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/islands.d.ts +5 -4
- package/dist/core/codec/islands.js +23 -38
- 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/codec/slash.js +0 -0
- package/dist/core/errors.d.ts +3 -2
- package/dist/core/rungs.d.ts +8 -0
- package/dist/core/rungs.js +24 -0
- package/dist/core/theme.css +19 -6
- package/dist/preview/paint.js +21 -17
- package/dist/visual/ArrayField.svelte +649 -176
- package/dist/visual/ArrayField.svelte.d.ts +16 -1
- package/dist/visual/BooleanField.svelte +36 -11
- package/dist/visual/BooleanField.svelte.d.ts +2 -0
- package/dist/visual/Card.svelte +6 -25
- package/dist/visual/DateField.svelte +66 -31
- package/dist/visual/EnumField.svelte +27 -9
- package/dist/visual/EnumField.svelte.d.ts +5 -0
- package/dist/visual/Field.svelte +113 -38
- package/dist/visual/FormatPopover.svelte +4 -3
- package/dist/visual/MatrixField.svelte +353 -0
- package/dist/visual/MatrixField.svelte.d.ts +36 -0
- package/dist/visual/NumberField.svelte +31 -14
- package/dist/visual/NumberField.svelte.d.ts +5 -1
- package/dist/visual/ObjectField.svelte +404 -139
- package/dist/visual/ObjectField.svelte.d.ts +17 -2
- 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 +56 -2
- package/dist/visual/VariantField.svelte.d.ts +10 -1
- package/dist/visual/VisualEditorInner.svelte +77 -73
- package/dist/visual/controls.css +47 -5
- package/dist/visual/diagnostics.d.ts +26 -2
- package/dist/visual/diagnostics.js +52 -5
- package/dist/visual/hold.js +2 -2
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/leaves.d.ts +25 -12
- package/dist/visual/motion.d.ts +14 -5
- package/dist/visual/motion.js +13 -11
- package/dist/visual/strings.d.ts +27 -10
- package/dist/visual/strings.js +8 -2
- package/dist/visual/structure.d.ts +159 -40
- package/dist/visual/structure.js +337 -45
- package/package.json +2 -2
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
2
|
+
import { type ArrayLayout } from './structure.js';
|
|
3
|
+
import { type DeepDiagnostic } from './diagnostics.js';
|
|
4
|
+
import type { LandingBox } from './leaves.js';
|
|
2
5
|
import './controls.css';
|
|
3
6
|
interface Props {
|
|
4
7
|
value: unknown[] | undefined;
|
|
8
|
+
/** The declared `default:`: the rows an unset array draws, which a gesture takes. */
|
|
9
|
+
fallback?: unknown[];
|
|
5
10
|
items: QuillFieldSchema | undefined;
|
|
11
|
+
/** How the elements draw: the record list, or the table an `array<object>` of
|
|
12
|
+
* short cells asks for (`arrayLayout`). */
|
|
13
|
+
layout?: ArrayLayout;
|
|
14
|
+
/** The schema's `max:`, past which the add chip disables and Enter stops
|
|
15
|
+
* inserting; `undefined` is no cap. */
|
|
16
|
+
max?: number;
|
|
6
17
|
/** Accessible-name prefix for the element controls (`label` + 1-based index). */
|
|
7
18
|
label?: string;
|
|
8
19
|
/** No-default field → a persistent required `*` on the label. */
|
|
@@ -28,11 +39,15 @@ interface Props {
|
|
|
28
39
|
* control has spliced in and whose commit has yet to land, or was refused. */
|
|
29
40
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
30
41
|
onCommit: (arr: unknown[]) => void;
|
|
42
|
+
/** The diagnostics routed into this array, each with its steps still to walk: one
|
|
43
|
+
* naming a row draws under that row — under its head while it is collapsed, under
|
|
44
|
+
* the cell once it is open — and one naming no row draws at the list's foot. */
|
|
45
|
+
diagnostics?: readonly DeepDiagnostic[];
|
|
31
46
|
}
|
|
32
47
|
declare const ArrayField: import("svelte").Component<Props, {
|
|
33
48
|
focus: () => void;
|
|
34
49
|
washBox: () => HTMLElement | undefined;
|
|
35
|
-
|
|
50
|
+
focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
|
|
36
51
|
}, "">;
|
|
37
52
|
type ArrayField = ReturnType<typeof ArrayField>;
|
|
38
53
|
export default ArrayField;
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
Styled rather than a native checkbox: the native box's face is UA-owned shadow
|
|
5
5
|
DOM, so no dial reaches it. The a11y comes with the primitive:
|
|
6
6
|
Switch.Root renders `role="switch"` with its checked state and keyboard handling.
|
|
7
|
+
|
|
8
|
+
An unset `boolean?` prints `none`, which neither face of a switch says, so it draws a
|
|
9
|
+
third: the thumb at the track's centre. `role="switch"` admits no third state (a
|
|
10
|
+
`mixed` there reads as false), so an optional boolean's control is a `checkbox`, the
|
|
11
|
+
toggle role that does, announcing `mixed` while unset. A press from unset writes
|
|
12
|
+
`true`; nothing here returns the cell to unset.
|
|
7
13
|
-->
|
|
8
14
|
<script lang="ts">
|
|
9
15
|
import { Switch } from 'bits-ui';
|
|
@@ -13,6 +19,8 @@
|
|
|
13
19
|
interface Props {
|
|
14
20
|
value: boolean | undefined;
|
|
15
21
|
fallback?: boolean;
|
|
22
|
+
/** A `boolean?` cell, which has a third state: unset, printing `none`. */
|
|
23
|
+
optional?: boolean;
|
|
16
24
|
/** Accessible name for a switch nothing else names: an object property, whose
|
|
17
25
|
* name is the field label plus the property's. A field's own switch takes `id`
|
|
18
26
|
* instead and is named by the `<label for>` beside it. */
|
|
@@ -26,29 +34,42 @@
|
|
|
26
34
|
describedBy?: string;
|
|
27
35
|
onCommit: (v: boolean) => void;
|
|
28
36
|
}
|
|
29
|
-
let { value, fallback, label, id, describedBy, onCommit }: Props = $props();
|
|
37
|
+
let { value, fallback, optional = false, label, id, describedBy, onCommit }: Props = $props();
|
|
30
38
|
|
|
31
39
|
// Local toggle state synced to `value`; own-toggles stay local, only an external
|
|
32
40
|
// change reconciles back in (see `syncedLocal`). The primitive is driven
|
|
33
41
|
// controlled (`checked` + `onCheckedChange`, never `bind:`) so reconciliation
|
|
34
42
|
// stays the package's: a two-way bind hands the primitive a lane around it,
|
|
35
|
-
// which repeats the reconciliation hazard in miniature.
|
|
36
|
-
|
|
43
|
+
// which repeats the reconciliation hazard in miniature. `undefined` is the unset
|
|
44
|
+
// optional cell, the one state with no default to stand in for it.
|
|
45
|
+
const local = syncedLocal<boolean | undefined>(
|
|
46
|
+
() => value ?? fallback ?? (optional ? undefined : false)
|
|
47
|
+
);
|
|
48
|
+
const unset = $derived(local.value === undefined);
|
|
49
|
+
const tristate = $derived(
|
|
50
|
+
optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local.value } : {}
|
|
51
|
+
);
|
|
37
52
|
</script>
|
|
38
53
|
|
|
39
|
-
|
|
54
|
+
<!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
|
|
55
|
+
stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
|
|
56
|
+
its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
|
|
57
|
+
<span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
|
|
40
58
|
<Switch.Root
|
|
41
|
-
class="qm-
|
|
42
|
-
checked={local.value}
|
|
59
|
+
class="qm-toggle qm-focus-ring qm-tap-floor"
|
|
60
|
+
checked={!!local.value}
|
|
43
61
|
{id}
|
|
44
62
|
aria-label={id ? undefined : label}
|
|
45
63
|
aria-describedby={describedBy}
|
|
46
64
|
onCheckedChange={(v) => {
|
|
65
|
+
// From unset the primitive reads the unchecked face, so its toggle is `true`.
|
|
47
66
|
local.value = v;
|
|
48
67
|
onCommit(v);
|
|
49
68
|
}}
|
|
50
69
|
>
|
|
51
|
-
|
|
70
|
+
{#snippet child({ props })}
|
|
71
|
+
<button {...props} {...tristate}><Switch.Thumb class="qm-toggle-thumb" /></button>
|
|
72
|
+
{/snippet}
|
|
52
73
|
</Switch.Root>
|
|
53
74
|
</span>
|
|
54
75
|
|
|
@@ -60,7 +81,7 @@
|
|
|
60
81
|
2.5.8's floor is half again that. The `<label for>` beside it forwards a press but
|
|
61
82
|
stands at the label rung, so it is no taller: without the floor no region on the
|
|
62
83
|
surface reaches this control at the size every other control on it holds. */
|
|
63
|
-
.qm-
|
|
84
|
+
.qm-toggle-wrap :global(.qm-toggle) {
|
|
64
85
|
display: inline-flex;
|
|
65
86
|
align-items: center;
|
|
66
87
|
width: 1.75rem;
|
|
@@ -75,21 +96,25 @@
|
|
|
75
96
|
cursor: pointer;
|
|
76
97
|
transition: background var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
77
98
|
}
|
|
78
|
-
.qm-
|
|
99
|
+
.qm-toggle-wrap :global(.qm-toggle[data-state='checked']) {
|
|
79
100
|
background: var(--_qm-accent);
|
|
80
101
|
}
|
|
81
102
|
/* The focus ring rides `.qm-focus-ring` on the switch (controls.css). */
|
|
82
103
|
/* The thumb reads on tone alone: the base surface over the track's `hover` rung
|
|
83
104
|
unchecked, and over `--_qm-accent` checked. No edge of its own: a hairline around
|
|
84
105
|
a 12px pill is a second box inside the track's. */
|
|
85
|
-
.qm-
|
|
106
|
+
.qm-toggle-wrap :global(.qm-toggle-thumb) {
|
|
86
107
|
width: 0.75rem;
|
|
87
108
|
height: 0.75rem;
|
|
88
109
|
border-radius: var(--_qm-radius-pill);
|
|
89
110
|
background: var(--_qm-surface);
|
|
90
111
|
transition: translate var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
91
112
|
}
|
|
92
|
-
.qm-
|
|
113
|
+
.qm-toggle-wrap :global(.qm-toggle-thumb[data-state='checked']) {
|
|
93
114
|
translate: 0.75rem;
|
|
94
115
|
}
|
|
116
|
+
/* Unset, the thumb stands at neither end: halfway along its travel. */
|
|
117
|
+
.qm-toggle-wrap[data-unset] :global(.qm-toggle-thumb) {
|
|
118
|
+
translate: 0.375rem;
|
|
119
|
+
}
|
|
95
120
|
</style>
|
|
@@ -2,6 +2,8 @@ import './controls.css';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
value: boolean | undefined;
|
|
4
4
|
fallback?: boolean;
|
|
5
|
+
/** A `boolean?` cell, which has a third state: unset, printing `none`. */
|
|
6
|
+
optional?: boolean;
|
|
5
7
|
/** Accessible name for a switch nothing else names: an object property, whose
|
|
6
8
|
* name is the field label plus the property's. A field's own switch takes `id`
|
|
7
9
|
* instead and is named by the `<label for>` beside it. */
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
16
16
|
import type { LeafRegistry } from './leaves.js';
|
|
17
17
|
import type { CardModel, FieldModel } from './structure.js';
|
|
18
|
-
import { placeFields, humanize, initialExpandedGroup } from './structure.js';
|
|
18
|
+
import { placeFields, humanize, initialExpandedGroup, kindTitle, mountKey } from './structure.js';
|
|
19
19
|
import { holdInView } from './hold.js';
|
|
20
20
|
import type { FieldDomIds } from './domid.js';
|
|
21
21
|
import Field from './Field.svelte';
|
|
@@ -332,8 +332,8 @@
|
|
|
332
332
|
</section>
|
|
333
333
|
|
|
334
334
|
{#snippet sectionFields(fields: FieldModel[])}
|
|
335
|
-
<div class="qm-fields">
|
|
336
|
-
{#each placeFields(fields) as { field: f, span } (f.name)}
|
|
335
|
+
<div class="qm-fields qm-tracks">
|
|
336
|
+
{#each placeFields(fields) as { field: f, span } (mountKey(f.name, f.schema))}
|
|
337
337
|
<Field
|
|
338
338
|
field={f}
|
|
339
339
|
{span}
|
|
@@ -402,7 +402,7 @@
|
|
|
402
402
|
|
|
403
403
|
{#snippet kindOptions()}
|
|
404
404
|
{#each kinds as k (k)}
|
|
405
|
-
<option value={k}>{
|
|
405
|
+
<option value={k}>{kindTitle(quill.schema.card_kinds?.[k], k)}</option>
|
|
406
406
|
{/each}
|
|
407
407
|
{/snippet}
|
|
408
408
|
|
|
@@ -584,33 +584,14 @@
|
|
|
584
584
|
.qm-group-panel-inner {
|
|
585
585
|
container-type: inline-size;
|
|
586
586
|
}
|
|
587
|
-
/*
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
It steps 1 → 2 → 4, skipping 3: each rung is the width at which a track still clears
|
|
591
|
-
the comfortable field minimum, and an even capacity is what lets the half a subform
|
|
592
|
-
of one takes (`--cols-half`, `ObjectField`) land on a track boundary. A field ends on
|
|
593
|
-
its track's own edge, so a rung is `n * 220px + gaps`. */
|
|
587
|
+
/* The count is the ladder's (`.qm-tracks`, controls.css), stepped by the container
|
|
588
|
+
above; what this block owns is the grid the count fills. */
|
|
594
589
|
.qm-fields {
|
|
595
|
-
--cols: 1;
|
|
596
|
-
--cols-half: 1;
|
|
597
590
|
display: grid;
|
|
598
591
|
grid-template-columns: repeat(var(--cols), 1fr);
|
|
599
592
|
column-gap: var(--_qm-space-2);
|
|
600
593
|
row-gap: var(--_qm-space-3);
|
|
601
594
|
}
|
|
602
|
-
@container (min-width: 28rem) {
|
|
603
|
-
.qm-fields {
|
|
604
|
-
--cols: 2;
|
|
605
|
-
--cols-half: 1;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
@container (min-width: 57rem) {
|
|
609
|
-
.qm-fields {
|
|
610
|
-
--cols: 4;
|
|
611
|
-
--cols-half: 2;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
595
|
/* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
|
|
615
596
|
fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
|
|
616
597
|
not `--_qm-accent`: a card's edge and the verticals inside it are one stroke at three
|
|
@@ -23,16 +23,21 @@
|
|
|
23
23
|
make every reconcile fire and re-render all seven segments on each commit.
|
|
24
24
|
|
|
25
25
|
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
|
-
|
|
26
|
+
carrying a `default:` prints the default's digits in the segments at the default
|
|
27
|
+
rung (theme.css), instead of the primitive's `mm`/`dd`/`yyyy` hints, which say
|
|
28
|
+
"empty" where the rung says "will render 2026-01-01". At rest the ghost is painted
|
|
29
|
+
in the segment snippet, over an unset primitive: a default held as its value reads
|
|
30
|
+
as filled to every path that reads the primitive (`areAllSegmentsFilled`,
|
|
31
|
+
Backspace, the hidden input), and whether it stands as the value is the field's to
|
|
32
|
+
say only once the user enters it. `placeholder`
|
|
33
|
+
(the `DateValue` the segments count from, never one they display) carries the
|
|
34
|
+
default too, so arrowing an empty segment starts at the render's date rather than
|
|
35
|
+
today's.
|
|
36
|
+
|
|
37
|
+
Entering an unset field seats the default as the primitive's value, still at the
|
|
38
|
+
default rung, so an edit to one segment commits a whole date with the others the
|
|
39
|
+
default's. Leaving without an edit unseats it, and nothing is written; so does
|
|
40
|
+
clearing a segment.
|
|
36
41
|
-->
|
|
37
42
|
<script lang="ts">
|
|
38
43
|
import { DateField as BitsDateField } from 'bits-ui';
|
|
@@ -85,25 +90,42 @@
|
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
94
|
+
/** The default is the primitive's value: from entering an unset field until an
|
|
95
|
+
* edit, a cleared segment or leaving. Part of the projection, so a reconcile
|
|
96
|
+
* neither drops a seat nor restores one over the segments a clear left. */
|
|
97
|
+
let seat = $state(false);
|
|
88
98
|
// Local value synced to `value` as a string (see the identity note above);
|
|
89
99
|
// own-edits stay local, only an external change reconciles back in. Driven
|
|
90
100
|
// controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
|
|
91
101
|
// the package's.
|
|
92
|
-
const local = syncedLocal(
|
|
102
|
+
const local = syncedLocal(
|
|
103
|
+
() => value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
|
|
104
|
+
);
|
|
93
105
|
const parsed = $derived(toDateValue(local.value));
|
|
94
106
|
// The date the empty segments ghost, or undefined when there is nothing to ghost:
|
|
95
107
|
// the field is unset and the default has a date form. A non-blank local that
|
|
96
108
|
// fails to parse is AUTHORED-but-malformed, which the empty field states
|
|
97
109
|
// honestly: the ghost would claim it unset. Held as the parsed value rather than
|
|
98
110
|
// a boolean so the substitution below narrows on the one fact it needs.
|
|
99
|
-
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
100
111
|
const ghost = $derived(local.value === '' ? fallbackDate : undefined);
|
|
112
|
+
const seated = $derived(value == null && seat && local.value !== '');
|
|
101
113
|
|
|
102
|
-
|
|
114
|
+
/** Focus moving between segments is neither an entry nor a leaving. */
|
|
115
|
+
const within = (e: FocusEvent): boolean => !!wrapEl?.contains(e.relatedTarget as Node | null);
|
|
116
|
+
function enter(e: FocusEvent): void {
|
|
117
|
+
if (!within(e) && value == null && fallbackDate) seat = true;
|
|
118
|
+
}
|
|
119
|
+
function leave(e: FocusEvent): void {
|
|
120
|
+
if (!within(e)) seat = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// What one segment prints, and which rung it takes.
|
|
103
124
|
//
|
|
104
125
|
// An unfilled segment is always shown-never-written, whether it prints the
|
|
105
|
-
// default's digits or the primitive's own
|
|
106
|
-
//
|
|
126
|
+
// default's digits (`default`, the rung of what prints) or the primitive's own
|
|
127
|
+
// `mm`/`dd`/`yyyy` hint (`hint`, a word about the value): both state "nothing
|
|
128
|
+
// authored here". Only an unfilled segment ghosts: a half-entered date
|
|
107
129
|
// holds digits while the value is still undefined (one unfilled segment unsets
|
|
108
130
|
// the whole field), so ghosting unconditionally would paint over, and dim, the
|
|
109
131
|
// digits just typed. The segment's own text is the tell: its unfilled hint is
|
|
@@ -113,30 +135,36 @@
|
|
|
113
135
|
// The separators are `literal` parts, never unfilled; substitution covers the
|
|
114
136
|
// date parts only, so any time part keeps the primitive's text. Digits are
|
|
115
137
|
// zero-padded to the segment widths the field displays.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
138
|
+
type Rung = 'default' | 'hint' | undefined;
|
|
139
|
+
function segmentText(part: string, text: string): { text: string; ghosted: Rung } {
|
|
140
|
+
if (part === 'literal' || /\d/.test(text)) return { text, ghosted: undefined };
|
|
141
|
+
if (!ghost) return { text, ghosted: 'hint' };
|
|
119
142
|
switch (part) {
|
|
120
143
|
case 'year':
|
|
121
|
-
return { text: String(ghost.year).padStart(4, '0'), ghosted:
|
|
144
|
+
return { text: String(ghost.year).padStart(4, '0'), ghosted: 'default' };
|
|
122
145
|
case 'month':
|
|
123
|
-
return { text: String(ghost.month).padStart(2, '0'), ghosted:
|
|
146
|
+
return { text: String(ghost.month).padStart(2, '0'), ghosted: 'default' };
|
|
124
147
|
case 'day':
|
|
125
|
-
return { text: String(ghost.day).padStart(2, '0'), ghosted:
|
|
148
|
+
return { text: String(ghost.day).padStart(2, '0'), ghosted: 'default' };
|
|
126
149
|
default:
|
|
127
|
-
return { text, ghosted:
|
|
150
|
+
return { text, ghosted: 'hint' };
|
|
128
151
|
}
|
|
129
152
|
}
|
|
130
153
|
</script>
|
|
131
154
|
|
|
132
|
-
<span class="qm-date-wrap" bind:this={wrapEl}>
|
|
155
|
+
<span class="qm-date-wrap" bind:this={wrapEl} onfocusin={enter} onfocusout={leave}>
|
|
133
156
|
<BitsDateField.Root
|
|
134
157
|
value={parsed}
|
|
135
158
|
placeholder={fallbackDate}
|
|
136
159
|
onValueChange={(d) => {
|
|
137
160
|
// `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
|
|
138
|
-
// half-typed field yields undefined: the unset rung.
|
|
139
|
-
|
|
161
|
+
// half-typed field yields undefined: the unset rung. The primitive reports
|
|
162
|
+
// its own value again as a segment loses focus, which is no edit, and over a
|
|
163
|
+
// seated default would write it.
|
|
164
|
+
const next = d?.toString() ?? '';
|
|
165
|
+
if (next === local.value) return;
|
|
166
|
+
local.value = next;
|
|
167
|
+
if (!d) seat = false;
|
|
140
168
|
onCommit(d?.toString());
|
|
141
169
|
}}
|
|
142
170
|
>
|
|
@@ -151,6 +179,7 @@
|
|
|
151
179
|
aria-labelledby={labelledBy}
|
|
152
180
|
aria-describedby={describedBy}
|
|
153
181
|
data-ghosted={ghost ? '' : undefined}
|
|
182
|
+
data-default={seated ? '' : undefined}
|
|
154
183
|
>
|
|
155
184
|
{#snippet children({ segments })}
|
|
156
185
|
<!-- Keyed by index: `part` repeats (the `literal` separators between
|
|
@@ -160,7 +189,7 @@
|
|
|
160
189
|
<BitsDateField.Segment
|
|
161
190
|
class="qm-date-segment"
|
|
162
191
|
part={seg.part}
|
|
163
|
-
data-ghosted={shown.ghosted
|
|
192
|
+
data-ghosted={shown.ghosted}
|
|
164
193
|
>
|
|
165
194
|
{shown.text}
|
|
166
195
|
</BitsDateField.Segment>
|
|
@@ -193,11 +222,17 @@
|
|
|
193
222
|
.qm-date-wrap :global(.qm-date-segment:focus) {
|
|
194
223
|
background: var(--_qm-surface-hover);
|
|
195
224
|
}
|
|
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
|
-
|
|
225
|
+
/* An unfilled segment is ghost-toned whatever it prints, at the rung of what it
|
|
226
|
+
prints: the resolved `default:`'s digits at the default rung, the `dd`/`mm`/`yyyy`
|
|
227
|
+
hint at the label rung. Shown, never written, either way. The marker is the
|
|
228
|
+
component's own (see the snippet): the date primitive emits no placeholder
|
|
229
|
+
attribute of its own. A seated default is the value and still unwritten, so its
|
|
230
|
+
filled segments take the default rung too. */
|
|
231
|
+
.qm-date-wrap :global(.qm-date-segment[data-ghosted='hint']) {
|
|
201
232
|
color: var(--_qm-ink-label);
|
|
202
233
|
}
|
|
234
|
+
.qm-date-wrap :global(.qm-date-segment[data-ghosted='default']),
|
|
235
|
+
.qm-date-wrap :global(.qm-date[data-default] .qm-date-segment) {
|
|
236
|
+
color: var(--_qm-ink-default);
|
|
237
|
+
}
|
|
203
238
|
</style>
|
|
@@ -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
|