@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,35 +1,57 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
An `array` field → an add/remove repeater. Elements commit by value: every
|
|
3
|
-
edit / add / remove rebuilds the whole array and hands it to the parent's typed
|
|
3
|
+
edit / add / remove / move rebuilds the whole array and hands it to the parent's typed
|
|
4
4
|
`writer.set(field, wholeArray)` (arrays are not op-addressed). Element control
|
|
5
5
|
by `items.type`: `richtext` / `plaintext` → a prose element
|
|
6
|
-
({@link ProseValue}), `object` → a summary row that opens onto a subform,
|
|
7
|
-
|
|
8
|
-
add chip sits in the label header row (space-between with the field
|
|
9
|
-
{@link Field} skips its own label for array controls and hands this component
|
|
10
|
-
label track with it.
|
|
6
|
+
({@link ProseValue}), `object` → a summary row that opens onto a subform, or a
|
|
7
|
+
table's row where the array asks for one and the cells are short, everything else →
|
|
8
|
+
a text input. The add chip sits in the label header row (space-between with the field
|
|
9
|
+
label); {@link Field} skips its own label for array controls and hands this component
|
|
10
|
+
the label track with it. An array declaring `max:` disables the chip at the cap and
|
|
11
|
+
draws the count beside it: a disabled button takes no focus, so a `title` reaches
|
|
12
|
+
neither a screen reader nor a touch user, and the count is the carrier.
|
|
11
13
|
|
|
12
14
|
An `object` element collapses: the row is its own summary — a box, titled by the
|
|
13
|
-
|
|
14
|
-
Stacking the subforms instead would nest a field one level past the
|
|
15
|
-
subform's own vertical draws, once per row.
|
|
16
|
-
|
|
15
|
+
row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField},
|
|
16
|
+
one at a time. Stacking the subforms instead would nest a field one level past the
|
|
17
|
+
depth the subform's own vertical draws, once per row.
|
|
18
|
+
Opening is therefore part of a landing rather than something the user does first:
|
|
19
|
+
`focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
|
|
20
|
+
walk to the subform it opened, which is how a row two closed boxes down is reached.
|
|
17
21
|
|
|
18
|
-
The
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
The table (`layout: 'table'`, VISUAL_EDITOR §"Structure mirrors the schema") is the
|
|
23
|
+
same row machine in another presentation: the same ids, the same splices, the same
|
|
24
|
+
landing and the same cell keys, over rows that are always open, each cell the
|
|
25
|
+
property's ordinary control on the track the header names ({@link ObjectField}
|
|
26
|
+
`bare`). Every row of it open, the whole of it stands where an open row's subform
|
|
27
|
+
stands: a rung inside the field, behind the subform's vertical. A table composes by
|
|
28
|
+
position, so it recurses into nothing: `arrayLayout` declines it for a row holding a
|
|
29
|
+
container or a block prose cell.
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
The row's controls are inside the element: a slab over the end of the element's own
|
|
32
|
+
box, taking its two end-side corners — the remove, and on an `object` row the reorder
|
|
33
|
+
pair before it. So a row's box is the element's box, and an array's rows end where
|
|
34
|
+
every other field's control does. On an object element that box is the summary rather
|
|
35
|
+
than the open row: a destructive control belongs to the line it sits on, not to
|
|
36
|
+
everything that line has unfolded. A table's row carries the remove alone, in its last
|
|
37
|
+
track and pinned to the end edge of the box the table scrolls in: every cell of the
|
|
38
|
+
row is on the line, so a mis-order is a retype as it is in a list of strings, and the
|
|
39
|
+
one gesture the row keeps never needs a scroll to reach.
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
insert
|
|
41
|
+
Keys carry the list without the mouse: Enter inserts a sibling below and takes the
|
|
42
|
+
caret there, Backspace on an empty element removes it and hands focus back up the
|
|
43
|
+
list, Alt+↑/↓ anywhere in an `object` row of the list moves it, the nearest row
|
|
44
|
+
answering and never the record around a nested one. A move is one splice of the ids
|
|
45
|
+
and of the values together, the mechanism insert and remove use, so an element keeps
|
|
46
|
+
its id for life and no prose leaf inside it remounts; the open row stays open across
|
|
47
|
+
its own move, and `animate:reorder` holds the moving row as it holds a card.
|
|
48
|
+
|
|
49
|
+
An unset array whose `default:` holds elements draws them as its rows, at the default
|
|
50
|
+
rung (`data-default` on the rows' box), since they are what prints. The rows are the
|
|
51
|
+
declared literal rather than resolve's form, which completes a record against `items`:
|
|
52
|
+
every gesture — an edit in a row, Enter, a remove, a move, the add chip — commits the
|
|
53
|
+
whole array with the change in it, and a take writes only what the author declared.
|
|
54
|
+
Removing the last row commits `[]`, the empty answer.
|
|
33
55
|
-->
|
|
34
56
|
<script lang="ts">
|
|
35
57
|
import { wording } from './strings.js';
|
|
@@ -41,13 +63,27 @@
|
|
|
41
63
|
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
42
64
|
import { emptyContent } from '../core/codec/index.js';
|
|
43
65
|
import { createLifespan } from '../core/teardown.js';
|
|
44
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
IdSeq,
|
|
68
|
+
baseType,
|
|
69
|
+
controlKind,
|
|
70
|
+
declaredContent,
|
|
71
|
+
humanize,
|
|
72
|
+
obliged,
|
|
73
|
+
rowSummary,
|
|
74
|
+
schemaAt,
|
|
75
|
+
type ArrayLayout
|
|
76
|
+
} from './structure.js';
|
|
77
|
+
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
78
|
+
import type { LandingBox } from './leaves.js';
|
|
45
79
|
import { holdInView } from './hold.js';
|
|
80
|
+
import { reorder, reorderArm } from './motion.js';
|
|
46
81
|
import Icon from './icons/Icon.svelte';
|
|
47
82
|
import TextField from './TextField.svelte';
|
|
48
83
|
import ObjectField from './ObjectField.svelte';
|
|
49
84
|
import ProseValue from './ProseValue.svelte';
|
|
50
85
|
import FieldLabel from './FieldLabel.svelte';
|
|
86
|
+
import DiagnosticList from './DiagnosticList.svelte';
|
|
51
87
|
import './controls.css';
|
|
52
88
|
|
|
53
89
|
/** The disclosure glyph, at the size the accordion's own chevron takes: one
|
|
@@ -56,7 +92,15 @@
|
|
|
56
92
|
|
|
57
93
|
interface Props {
|
|
58
94
|
value: unknown[] | undefined;
|
|
95
|
+
/** The declared `default:`: the rows an unset array draws, which a gesture takes. */
|
|
96
|
+
fallback?: unknown[];
|
|
59
97
|
items: QuillFieldSchema | undefined;
|
|
98
|
+
/** How the elements draw: the record list, or the table an `array<object>` of
|
|
99
|
+
* short cells asks for (`arrayLayout`). */
|
|
100
|
+
layout?: ArrayLayout;
|
|
101
|
+
/** The schema's `max:`, past which the add chip disables and Enter stops
|
|
102
|
+
* inserting; `undefined` is no cap. */
|
|
103
|
+
max?: number;
|
|
60
104
|
/** Accessible-name prefix for the element controls (`label` + 1-based index). */
|
|
61
105
|
label?: string;
|
|
62
106
|
/** No-default field → a persistent required `*` on the label. */
|
|
@@ -82,10 +126,17 @@
|
|
|
82
126
|
* control has spliced in and whose commit has yet to land, or was refused. */
|
|
83
127
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
84
128
|
onCommit: (arr: unknown[]) => void;
|
|
129
|
+
/** The diagnostics routed into this array, each with its steps still to walk: one
|
|
130
|
+
* naming a row draws under that row — under its head while it is collapsed, under
|
|
131
|
+
* the cell once it is open — and one naming no row draws at the list's foot. */
|
|
132
|
+
diagnostics?: readonly DeepDiagnostic[];
|
|
85
133
|
}
|
|
86
134
|
let {
|
|
87
135
|
value,
|
|
136
|
+
fallback,
|
|
88
137
|
items,
|
|
138
|
+
layout = 'list',
|
|
139
|
+
max,
|
|
89
140
|
label,
|
|
90
141
|
required,
|
|
91
142
|
description,
|
|
@@ -93,7 +144,8 @@
|
|
|
93
144
|
descriptionId,
|
|
94
145
|
idBase,
|
|
95
146
|
contentAt,
|
|
96
|
-
onCommit
|
|
147
|
+
onCommit,
|
|
148
|
+
diagnostics
|
|
97
149
|
}: Props = $props();
|
|
98
150
|
|
|
99
151
|
// The element control is the item schema's own, with no departure: a content-typed
|
|
@@ -101,14 +153,31 @@
|
|
|
101
153
|
// `contentAt` whatever the element rests as. An array declaring no `items`
|
|
102
154
|
// has text elements.
|
|
103
155
|
const control = $derived(items ? controlKind(items) : 'text');
|
|
104
|
-
const
|
|
156
|
+
const table = $derived(layout === 'table' && control === 'object');
|
|
157
|
+
const columns = $derived(Object.entries(items?.properties ?? {}));
|
|
158
|
+
const defaulted = $derived(value == null && (fallback?.length ?? 0) > 0);
|
|
159
|
+
const arr = $derived((value ?? fallback ?? []) as unknown[]);
|
|
160
|
+
/** The rows' content read: the boundary's, or, while the rows are the default, the
|
|
161
|
+
* literal's own leaf at the codec its declared type names. */
|
|
162
|
+
function readAt(path: PathStep[]): Content | undefined {
|
|
163
|
+
if (!defaulted) return contentAt(path);
|
|
164
|
+
const leaf = schemaAt({ type: 'array', items }, path);
|
|
165
|
+
let v: unknown = arr;
|
|
166
|
+
for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
|
|
167
|
+
return leaf ? declaredContent(v, baseType(leaf) === 'richtext') : undefined;
|
|
168
|
+
}
|
|
169
|
+
const atCap = $derived(max != null && arr.length >= max);
|
|
170
|
+
const routed = $derived(splitDeep(diagnostics));
|
|
171
|
+
const foot = $derived(
|
|
172
|
+
unrouted(routed, (step) => typeof step === 'number' && step >= 0 && step < arr.length)
|
|
173
|
+
);
|
|
105
174
|
|
|
106
175
|
// Parallel stable ids, one per element, kept in lockstep with the data below.
|
|
107
176
|
// Seeded eagerly so a non-empty array renders its rows on the first pass:
|
|
108
177
|
// an effect-only seed mounts every element editor in a second render.
|
|
109
178
|
const seq = new IdSeq();
|
|
110
179
|
// svelte-ignore state_referenced_locally
|
|
111
|
-
let ids = $state<string[]>(seq.take((value ?? []).length));
|
|
180
|
+
let ids = $state<string[]>(seq.take((value ?? fallback ?? []).length));
|
|
112
181
|
// Length reconcile (defend against an out-of-band length change);
|
|
113
182
|
// order is maintained by the mutators, not here.
|
|
114
183
|
$effect(() => {
|
|
@@ -116,10 +185,7 @@
|
|
|
116
185
|
if (ids.length === n) return;
|
|
117
186
|
if (ids.length < n) ids = [...ids, ...seq.take(n - ids.length)];
|
|
118
187
|
else {
|
|
119
|
-
for (const id of ids.slice(n))
|
|
120
|
-
delete els[id];
|
|
121
|
-
delete rowEls[id];
|
|
122
|
-
}
|
|
188
|
+
for (const id of ids.slice(n)) drop(id);
|
|
123
189
|
ids = ids.slice(0, n);
|
|
124
190
|
}
|
|
125
191
|
});
|
|
@@ -142,34 +208,36 @@
|
|
|
142
208
|
const els: Record<string, { focus: () => void; setCaret?: (pos: number) => void } | undefined> =
|
|
143
209
|
$state({});
|
|
144
210
|
// The rows' own boxes, on the same key and dropped on the same paths: what an
|
|
145
|
-
// element landing blooms in ({@link
|
|
211
|
+
// element landing blooms in ({@link focusPath}), and what an object row's
|
|
146
212
|
// summary is found through. A row is a box whatever its element type, so the two
|
|
147
213
|
// element shapes register alike.
|
|
148
214
|
const rowEls: Record<string, HTMLElement | undefined> = $state({});
|
|
215
|
+
// A table row's cells, on the same key: the subform a landing and the column-keeping
|
|
216
|
+
// Enter hop reach into. Every table row is open, so there is one per row where the
|
|
217
|
+
// list holds one for the open row alone.
|
|
218
|
+
type Subform = {
|
|
219
|
+
focus: () => void;
|
|
220
|
+
focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
|
|
221
|
+
};
|
|
222
|
+
const cellEls: Record<string, Subform | undefined> = $state({});
|
|
149
223
|
let addEl: HTMLButtonElement | undefined = $state();
|
|
150
224
|
let rowsEl: HTMLElement | undefined = $state();
|
|
225
|
+
function drop(id: string): void {
|
|
226
|
+
delete els[id];
|
|
227
|
+
delete rowEls[id];
|
|
228
|
+
delete cellEls[id];
|
|
229
|
+
}
|
|
151
230
|
|
|
152
231
|
// ── Object elements: one open at a time ──────────────────────────────────────
|
|
153
232
|
// So an array of ten records is ten lines and one figure, whatever its length.
|
|
154
233
|
let openId = $state<string | undefined>(undefined);
|
|
155
234
|
// The open row's subform, for the landing below. One entry, never a map: only one
|
|
156
235
|
// row is open, so the ref is singular by the same rule the state is.
|
|
157
|
-
let openObjEl = $state<
|
|
236
|
+
let openObjEl = $state<Subform | undefined>();
|
|
158
237
|
|
|
159
|
-
/**
|
|
160
|
-
* declaration order, which is the order a schema states its own priority in. */
|
|
161
|
-
const titleKey = $derived.by(() => {
|
|
162
|
-
for (const [k, sub] of Object.entries(items?.properties ?? {})) {
|
|
163
|
-
if (controlKind(sub) === 'text') return k;
|
|
164
|
-
}
|
|
165
|
-
return undefined;
|
|
166
|
-
});
|
|
167
|
-
/** A collapsed row's own words: the title cell's committed value, or `undefined`
|
|
168
|
-
* while it has none. */
|
|
238
|
+
/** A collapsed row's own words, or `undefined` while it has none. */
|
|
169
239
|
function elementTitle(k: number): string | undefined {
|
|
170
|
-
|
|
171
|
-
const v = titleKey ? el?.[titleKey] : undefined;
|
|
172
|
-
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
240
|
+
return rowSummary(items, arr[k]);
|
|
173
241
|
}
|
|
174
242
|
/** What an untitled row reads as: the name its `aria-label` already spends,
|
|
175
243
|
* `label` + the 1-based index. */
|
|
@@ -183,10 +251,25 @@
|
|
|
183
251
|
openId = openId === id ? undefined : id;
|
|
184
252
|
});
|
|
185
253
|
}
|
|
254
|
+
const rowName = (k: number): string | undefined =>
|
|
255
|
+
label != null ? `${label} ${k + 1}` : undefined;
|
|
256
|
+
/** A row control's name: its action, and the row, where the row has a name. */
|
|
257
|
+
const rowAction = (action: string, k: number): string => {
|
|
258
|
+
const row = rowName(k);
|
|
259
|
+
return row != null ? t.strings.arrayRowAction(action, row) : action;
|
|
260
|
+
};
|
|
261
|
+
// The count's id, for the chip it describes: off the field's id space where there is
|
|
262
|
+
// one, else this instance's own.
|
|
263
|
+
const uid = $props.id();
|
|
264
|
+
const countId = $derived(`${idBase ?? `qm-${uid}`}-count`);
|
|
265
|
+
const columnTitle = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
|
|
186
266
|
|
|
187
267
|
// The awaited flush below is the only work that outlives a gesture here, so the
|
|
188
|
-
// span carries
|
|
268
|
+
// span carries the reorder's frame and nothing else: it is the liveness
|
|
269
|
+
// `focusAfterFlush` asks for.
|
|
189
270
|
const span = createLifespan();
|
|
271
|
+
const arm = reorderArm();
|
|
272
|
+
span.onEnd(arm.cancel);
|
|
190
273
|
onDestroy(() => span.end());
|
|
191
274
|
|
|
192
275
|
function emptyElement(): unknown {
|
|
@@ -204,8 +287,11 @@
|
|
|
204
287
|
copy[k] = next === undefined ? emptyElement() : next;
|
|
205
288
|
onCommit(copy);
|
|
206
289
|
}
|
|
207
|
-
/** Insert an empty element after `k` (`-1` prepends) and take focus to it
|
|
208
|
-
|
|
290
|
+
/** Insert an empty element after `k` (`-1` prepends) and take focus to it: into
|
|
291
|
+
* `column` where the gesture came from a table cell, so Enter keeps its column. A
|
|
292
|
+
* no-op at the cap: no editor gesture commits an element past `max:`. */
|
|
293
|
+
function insertAfter(k: number, column?: string): void {
|
|
294
|
+
if (atCap) return;
|
|
209
295
|
const id = seq.next();
|
|
210
296
|
const at = k + 1;
|
|
211
297
|
ids = [...ids.slice(0, at), id, ...ids.slice(at)];
|
|
@@ -214,27 +300,62 @@
|
|
|
214
300
|
onCommit(next);
|
|
215
301
|
// A row added is a row to fill in, so an object element arrives open: landing on
|
|
216
302
|
// a collapsed empty summary would make adding one a two-press gesture.
|
|
217
|
-
if (control === 'object') openId = id;
|
|
218
|
-
focusAfterFlush(id);
|
|
303
|
+
if (control === 'object' && !table) openId = id;
|
|
304
|
+
void focusAfterFlush(id, column);
|
|
219
305
|
}
|
|
220
306
|
function add(): void {
|
|
221
307
|
insertAfter(ids.length - 1);
|
|
222
308
|
}
|
|
223
|
-
|
|
309
|
+
/** Remove element `k`. Focus lands on the element before it, or on the one that
|
|
310
|
+
* slid into its place; on the add affordance once the list is empty, which is then
|
|
311
|
+
* the only thing left to hold it. Clicking the remove needs this as much as the key
|
|
312
|
+
* does: the button under the pointer is part of what it destroys. A table lands on
|
|
313
|
+
* the control the gesture came from: `column`, so Backspace keeps its column as
|
|
314
|
+
* Enter does, or the remove where `fromRemove` says one was pressed, which is
|
|
315
|
+
* pinned and so keeps the clip where the pointer left it, where the first cell
|
|
316
|
+
* would scroll the box back to its start on every press. */
|
|
317
|
+
function remove(k: number, column?: string, fromRemove = false): void {
|
|
224
318
|
const dropped = ids[k];
|
|
225
319
|
const next = ids.filter((_, i) => i !== k);
|
|
226
320
|
ids = next;
|
|
227
|
-
|
|
228
|
-
delete rowEls[dropped];
|
|
321
|
+
drop(dropped);
|
|
229
322
|
// The open row can be the one removed; `openId` is cleared with it rather than
|
|
230
323
|
// left naming an element that has gone.
|
|
231
324
|
if (openId === dropped) openId = undefined;
|
|
232
325
|
onCommit(arr.filter((_, i) => i !== k));
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
326
|
+
void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Move element `k` one slot: one splice of the ids and of the values together, so
|
|
330
|
+
* the element keeps its id and everything mounted under it. A no-op at either edge.
|
|
331
|
+
* The control that was pressed rides with the row, so it is focused again once the
|
|
332
|
+
* flush has moved it: a node moved in the DOM is a node the browser blurred. An arrow
|
|
333
|
+
* the move carried to its edge is disabled and takes no focus, so its twin does.
|
|
334
|
+
*/
|
|
335
|
+
function move(k: number, dir: -1 | 1): void {
|
|
336
|
+
const to = k + dir;
|
|
337
|
+
if (to < 0 || to >= ids.length) return;
|
|
338
|
+
const pressed = document.activeElement;
|
|
339
|
+
arm.arm();
|
|
340
|
+
const nextIds = ids.slice();
|
|
341
|
+
const [id] = nextIds.splice(k, 1);
|
|
342
|
+
nextIds.splice(to, 0, id);
|
|
343
|
+
ids = nextIds;
|
|
344
|
+
const next = arr.slice();
|
|
345
|
+
const [v] = next.splice(k, 1);
|
|
346
|
+
next.splice(to, 0, v);
|
|
347
|
+
onCommit(next);
|
|
348
|
+
void (async () => {
|
|
349
|
+
if (!(await span.resumes(tick()))) return;
|
|
350
|
+
if (!(pressed instanceof HTMLElement) || !pressed.isConnected) return;
|
|
351
|
+
const target =
|
|
352
|
+
pressed instanceof HTMLButtonElement && pressed.disabled
|
|
353
|
+
? [pressed.previousElementSibling, pressed.nextElementSibling].find(
|
|
354
|
+
(b) => b instanceof HTMLButtonElement && !b.disabled
|
|
355
|
+
)
|
|
356
|
+
: pressed;
|
|
357
|
+
if (target instanceof HTMLElement) target.focus();
|
|
358
|
+
})();
|
|
238
359
|
}
|
|
239
360
|
/** Take the caret: the first element, or the add affordance when the list is empty;
|
|
240
361
|
* which is then the only thing there is to land on, and the next thing the user
|
|
@@ -242,6 +363,7 @@
|
|
|
242
363
|
* ask one function so they cannot disagree (`Field`, `leaves.ts`). */
|
|
243
364
|
export function focus(): void {
|
|
244
365
|
if (ids.length === 0) return void addEl?.focus();
|
|
366
|
+
if (table) return void cellEls[ids[0]]?.focus();
|
|
245
367
|
if (control === 'object') return focusObjectRow(ids[0]);
|
|
246
368
|
els[ids[0]]?.focus();
|
|
247
369
|
}
|
|
@@ -259,35 +381,47 @@
|
|
|
259
381
|
export function washBox(): HTMLElement | undefined {
|
|
260
382
|
return rowsEl;
|
|
261
383
|
}
|
|
262
|
-
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
384
|
+
/**
|
|
385
|
+
* Land at `steps`: element `steps[0]`, and the rest of the walk inside its subform,
|
|
386
|
+
* at USV `pos` where the leaf the walk ends in can take one ({@link
|
|
387
|
+
* FieldControl.focusPath}). The index resolves to the element's session id here, at
|
|
388
|
+
* the call, never carried as one — an index is stale the moment anything above it
|
|
389
|
+
* splices. Past the live list it falls back to {@link focus}: the field is right and
|
|
390
|
+
* the row is gone, which is a landing off a compile the document has moved past.
|
|
268
391
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
392
|
+
* A collapsed row holds no control for a caret to land in, so opening it is part of
|
|
393
|
+
* the landing rather than something the user does first, and the subform it opens
|
|
394
|
+
* exists one flush later: that arm answers after the flush, with the rest of the
|
|
395
|
+
* walk handed to the subform then. An absent `pos` is the placement rung, exactly as
|
|
396
|
+
* on `Landing`; a row that takes no offset gets the bare focus.
|
|
272
397
|
*
|
|
273
|
-
* The row's box comes back with the landing
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
398
|
+
* The row's box comes back with the landing, or the innermost row a deeper walk
|
|
399
|
+
* opened: the arrival wash is the address's own granularity. A fallback to the field
|
|
400
|
+
* answers `undefined`, which is the field's box again (`leaves.ts`).
|
|
401
|
+
*/
|
|
402
|
+
export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
|
|
403
|
+
const [k, ...rest] = steps;
|
|
404
|
+
const id = typeof k === 'number' ? ids[k] : undefined;
|
|
405
|
+
if (id === undefined) {
|
|
406
|
+
focus();
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
if (table) {
|
|
410
|
+
const cells = cellEls[id];
|
|
411
|
+
if (rest.length && cells) void cells.focusPath(rest, pos);
|
|
412
|
+
else cells?.focus();
|
|
413
|
+
return rowEls[id];
|
|
414
|
+
}
|
|
281
415
|
if (control === 'object') {
|
|
282
|
-
if (id === undefined) {
|
|
283
|
-
focus();
|
|
284
|
-
return undefined;
|
|
285
|
-
}
|
|
286
416
|
openId = id;
|
|
287
|
-
|
|
288
|
-
|
|
417
|
+
return (async () => {
|
|
418
|
+
if (!(await span.resumes(tick()))) return undefined;
|
|
419
|
+
if (rest.length && openObjEl) return (await openObjEl.focusPath(rest, pos)) ?? rowEls[id];
|
|
420
|
+
focusObjectRow(id);
|
|
421
|
+
return rowEls[id];
|
|
422
|
+
})();
|
|
289
423
|
}
|
|
290
|
-
const el =
|
|
424
|
+
const el = els[id];
|
|
291
425
|
if (!el) {
|
|
292
426
|
focus();
|
|
293
427
|
return undefined;
|
|
@@ -298,49 +432,110 @@
|
|
|
298
432
|
}
|
|
299
433
|
/** Focus element `id` after the flush, never in the same tick: a mutation commits
|
|
300
434
|
* the array by value, so the parent re-derives and the row does not exist until
|
|
301
|
-
* then. `undefined` is the empty list: the add affordance.
|
|
435
|
+
* then. `undefined` is the empty list: the add affordance. `column` is a table
|
|
436
|
+
* cell to land in rather than the row's first, and `fromRemove` the row's remove.
|
|
302
437
|
*
|
|
303
438
|
* The commit that schedules this can also remove the card holding the field, which
|
|
304
439
|
* unmounts this component inside the window (core/teardown.ts). */
|
|
305
|
-
async function focusAfterFlush(
|
|
440
|
+
async function focusAfterFlush(
|
|
441
|
+
id: string | undefined,
|
|
442
|
+
column?: string,
|
|
443
|
+
fromRemove = false
|
|
444
|
+
): Promise<void> {
|
|
306
445
|
if (!(await span.resumes(tick()))) return;
|
|
307
446
|
if (id === undefined) return void addEl?.focus();
|
|
447
|
+
if (table) {
|
|
448
|
+
// Without the focus scroll: the remove is pinned, so it is in view where it
|
|
449
|
+
// stands, and the scroll the browser would make is to its unpinned place at
|
|
450
|
+
// the row's end.
|
|
451
|
+
if (fromRemove)
|
|
452
|
+
return void rowEls[id]
|
|
453
|
+
?.querySelector<HTMLElement>('.qm-remove')
|
|
454
|
+
?.focus({ preventScroll: true });
|
|
455
|
+
const cells = cellEls[id];
|
|
456
|
+
if (column && cells) void cells.focusPath([column]);
|
|
457
|
+
else cells?.focus();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
308
460
|
if (control === 'object') return focusObjectRow(id);
|
|
309
461
|
els[id]?.focus();
|
|
310
462
|
}
|
|
311
|
-
/** Whether
|
|
312
|
-
*
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
463
|
+
/** Whether a cell's committed value is at its blank: unset, the empty string, or a
|
|
464
|
+
* `Content` with no characters. A boolean or a number is never blank. */
|
|
465
|
+
function cellBlank(v: unknown): boolean {
|
|
466
|
+
if (v === undefined || v === '') return true;
|
|
467
|
+
return typeof v === 'object' && v !== null && 'text' in v && (v as Content).text === '';
|
|
468
|
+
}
|
|
469
|
+
/** Whether element `k` reads empty to the user. A text element's input is the truth,
|
|
470
|
+
* being the control the keystroke lands in. A prose element commits every edit, so
|
|
471
|
+
* the committed `Content` is; an authored string, the transport-door rest, is empty
|
|
472
|
+
* when it has no characters. A table's row is empty when the cell under the caret is
|
|
473
|
+
* and so is every cell beside it, read off the row: a cell showing only its schema
|
|
474
|
+
* default holds nothing written, as in a row just added. The caret's cell is read off
|
|
475
|
+
* its control, which a default the cell holds unwritten leaves full while the
|
|
476
|
+
* committed row has no key. */
|
|
477
|
+
function elementEmpty(k: number, target: EventTarget | null, column?: string): boolean {
|
|
317
478
|
if (control === 'prose') {
|
|
318
479
|
const el = arr[k];
|
|
319
480
|
if (typeof el === 'string') return el.length === 0;
|
|
320
481
|
return !(el as Content | undefined)?.text;
|
|
321
482
|
}
|
|
483
|
+
if (table) {
|
|
484
|
+
const row = (arr[k] ?? {}) as Record<string, unknown>;
|
|
485
|
+
const under =
|
|
486
|
+
target instanceof HTMLInputElement
|
|
487
|
+
? !target.value
|
|
488
|
+
: !target || !(target as HTMLElement).textContent;
|
|
489
|
+
const beside = Object.entries(row).every(([c, v]) => c === column || cellBlank(v));
|
|
490
|
+
return under && beside;
|
|
491
|
+
}
|
|
322
492
|
return target instanceof HTMLInputElement && !target.value;
|
|
323
493
|
}
|
|
324
494
|
/**
|
|
325
495
|
* The element keyboard contract. Both keys ride the element control's own keydown
|
|
326
496
|
* (the input's, or the PM view's through `handleDOMEvents`) since neither
|
|
327
|
-
* surface is a place a keymap of this component's could sit.
|
|
328
|
-
* wires neither: its row is a summary button, whose Enter is the
|
|
497
|
+
* surface is a place a keymap of this component's could sit. A collapsed `object`
|
|
498
|
+
* element wires neither: its row is a summary button, whose Enter is the
|
|
499
|
+
* disclosure's. A table's cell wires both through the subform ({@link ObjectField}
|
|
500
|
+
* `onCellKey`), Enter keeping the column it was pressed in.
|
|
501
|
+
*
|
|
502
|
+
* Riding the control is what decides which cells answer: a text cell and a prose
|
|
503
|
+
* cell leave both keys free, where a select opens its list on Enter, a switch
|
|
504
|
+
* toggles on it and a date segment edits on Backspace. So a table's row keys answer
|
|
505
|
+
* from its text and prose columns.
|
|
329
506
|
*/
|
|
330
|
-
function onElementKey(e: KeyboardEvent, k: number): void {
|
|
331
|
-
if (
|
|
507
|
+
function onElementKey(e: KeyboardEvent, k: number, column?: string): void {
|
|
508
|
+
if (e.isComposing) return;
|
|
332
509
|
if (e.key === 'Enter') {
|
|
333
510
|
e.preventDefault();
|
|
334
|
-
insertAfter(k);
|
|
335
|
-
} else if (e.key === 'Backspace' && !e.repeat && elementEmpty(k, e.target)) {
|
|
511
|
+
insertAfter(k, column);
|
|
512
|
+
} else if (e.key === 'Backspace' && !e.repeat && elementEmpty(k, e.target, column)) {
|
|
336
513
|
// Destructive with nothing to undo it, so it takes a deliberate press:
|
|
337
514
|
// `repeat` is a held key running on past the character it just cleared, and
|
|
338
515
|
// the emptiness test reads the state before this keystroke applies; so the
|
|
339
516
|
// press that empties an element never also removes it.
|
|
340
517
|
e.preventDefault();
|
|
341
|
-
remove(k);
|
|
518
|
+
remove(k, column);
|
|
342
519
|
}
|
|
343
520
|
}
|
|
521
|
+
/** The reorder's keyboard twin, on the row so it answers from the summary and from
|
|
522
|
+
* any cell an open row holds: Alt+↑/↓, the table island's own binding. The nearest
|
|
523
|
+
* row answers, or none does: a press inside a nested row is that row's whether it
|
|
524
|
+
* holds a reorder or not, so a key in a table's cell or a list of strings' input
|
|
525
|
+
* moves nothing, and never the record around it. */
|
|
526
|
+
function onRowKey(e: KeyboardEvent, k: number): void {
|
|
527
|
+
if (!e.altKey || e.shiftKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
|
528
|
+
// A cell that took the key — a select opening, a date segment stepping, a prose
|
|
529
|
+
// leaf joining — owns it.
|
|
530
|
+
if (e.defaultPrevented) return;
|
|
531
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
532
|
+
const nearest = e.target instanceof Element ? e.target.closest('.qm-array-row') : null;
|
|
533
|
+
if (nearest !== e.currentTarget) return;
|
|
534
|
+
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
|
535
|
+
if (k + dir < 0 || k + dir >= ids.length) return;
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
move(k, dir);
|
|
538
|
+
}
|
|
344
539
|
</script>
|
|
345
540
|
|
|
346
541
|
<div
|
|
@@ -363,67 +558,144 @@
|
|
|
363
558
|
{:else}
|
|
364
559
|
<span></span>
|
|
365
560
|
{/if}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
561
|
+
<!-- The cap is drawn, not parked in a `title`: a disabled chip takes no focus and
|
|
562
|
+
announces nothing, and disabled is a promise that there is a state in which
|
|
563
|
+
you could, so the count says which state. -->
|
|
564
|
+
<span class="qm-array-add-slot">
|
|
565
|
+
{#if max != null}
|
|
566
|
+
<span class="qm-array-count" id={countId}>{t.strings.arrayCount(arr.length, max)}</span>
|
|
567
|
+
{/if}
|
|
568
|
+
<button
|
|
569
|
+
type="button"
|
|
570
|
+
class="qm-add-el qm-chip qm-focus-ring qm-tap-floor"
|
|
571
|
+
bind:this={addEl}
|
|
572
|
+
aria-describedby={max != null ? countId : undefined}
|
|
573
|
+
disabled={atCap}
|
|
574
|
+
onclick={add}>{t.strings.arrayAdd}</button
|
|
575
|
+
>
|
|
576
|
+
</span>
|
|
372
577
|
</div>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
578
|
+
<!-- Three lists, one per row shape, because `animate:` is granted only to a keyed
|
|
579
|
+
each block's one child: a branch inside the block would stand between them. -->
|
|
580
|
+
{#if table}
|
|
581
|
+
<div
|
|
582
|
+
class="qm-array-rows qm-array-table"
|
|
583
|
+
class:empty={ids.length === 0}
|
|
584
|
+
data-default={defaulted ? '' : undefined}
|
|
585
|
+
bind:this={rowsEl}
|
|
586
|
+
>
|
|
587
|
+
<div class="qm-array-table-scroller" style:--table-cols={columns.length}>
|
|
588
|
+
<!-- The header names the columns, so a cell carries no label of its own: its
|
|
589
|
+
accessible name is the row's and the column's composed (`ObjectField`). The
|
|
590
|
+
obligation mark and the guidance affordance ride the header, being the
|
|
591
|
+
column's rather than any one cell's. -->
|
|
592
|
+
<div class="qm-array-table-head">
|
|
593
|
+
{#each columns as [key, sub] (key)}
|
|
594
|
+
<div class="qm-array-table-col" class:mark={controlKind(sub) === 'boolean'}>
|
|
595
|
+
<FieldLabel
|
|
596
|
+
label={columnTitle(key, sub)}
|
|
597
|
+
required={obliged(sub)}
|
|
598
|
+
description={sub.description}
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
601
|
+
{/each}
|
|
602
|
+
<span class="qm-array-table-head-end"></span>
|
|
603
|
+
</div>
|
|
604
|
+
{#each ids as id, k (id)}
|
|
605
|
+
<div class="qm-array-row qm-array-table-row" bind:this={rowEls[id]}>
|
|
606
|
+
<ObjectField
|
|
607
|
+
bare
|
|
608
|
+
bind:this={cellEls[id]}
|
|
609
|
+
value={(arr[k] ?? {}) as Record<string, unknown>}
|
|
610
|
+
properties={items?.properties}
|
|
611
|
+
label={rowName(k)}
|
|
612
|
+
contentAt={(path) => readAt([k, ...path])}
|
|
613
|
+
onCommit={(obj) => commitElement(k, obj)}
|
|
614
|
+
onCellKey={(e, column) => onElementKey(e, k, column)}
|
|
615
|
+
diagnostics={routed.below.get(k)}
|
|
616
|
+
/>
|
|
617
|
+
{@render removeButton(k)}
|
|
618
|
+
</div>
|
|
619
|
+
{/each}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
{:else if control === 'object'}
|
|
623
|
+
<div
|
|
624
|
+
class="qm-array-rows"
|
|
625
|
+
class:empty={ids.length === 0}
|
|
626
|
+
data-default={defaulted ? '' : undefined}
|
|
627
|
+
bind:this={rowsEl}
|
|
628
|
+
>
|
|
629
|
+
{#each ids as id, k (id)}
|
|
630
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
631
|
+
<!-- The handler catches a key from the controls inside the row and adds no
|
|
632
|
+
interaction of the row's own: the row is no tab stop. -->
|
|
633
|
+
<div
|
|
634
|
+
class="qm-array-row qm-element"
|
|
635
|
+
class:open={openId === id}
|
|
636
|
+
bind:this={rowEls[id]}
|
|
637
|
+
animate:reorder={arm.armed}
|
|
638
|
+
onkeydown={(e) => onRowKey(e, k)}
|
|
639
|
+
>
|
|
379
640
|
<!-- The head is the row in collapsed form, and it is a box: the element IS
|
|
380
|
-
a value, the way the enum trigger is, and the
|
|
381
|
-
|
|
382
|
-
|
|
641
|
+
a value, the way the enum trigger is, and the slab's grammar (the box's
|
|
642
|
+
end-side corners) needs corners to take. So a list of records measures
|
|
643
|
+
like a list of inputs whatever the element type. -->
|
|
383
644
|
<div class="qm-element-head">
|
|
384
645
|
<button
|
|
385
646
|
type="button"
|
|
386
647
|
class="qm-control-box qm-focus-ring qm-element-summary"
|
|
387
|
-
aria-expanded={
|
|
648
|
+
aria-expanded={openId === id}
|
|
388
649
|
onclick={(e) => toggleRow(id, e.currentTarget)}
|
|
389
650
|
>
|
|
390
651
|
<!-- Leading, and it rotates: trailing is the figure for pushing a new
|
|
391
652
|
screen, where this unfolds in place. Same glyph, same rotation and
|
|
392
653
|
same rung as the accordion's, so the surface has one disclosure. -->
|
|
393
654
|
<Icon name="chevron-right" class="qm-el-chevron" size={CHEVRON} />
|
|
394
|
-
{#if
|
|
395
|
-
<span class="qm-element-title">{
|
|
655
|
+
{#if elementTitle(k)}
|
|
656
|
+
<span class="qm-element-title">{elementTitle(k)}</span>
|
|
396
657
|
{:else}
|
|
397
658
|
<span class="qm-element-title untitled">{untitled(k)}</span>
|
|
398
659
|
{/if}
|
|
399
660
|
</button>
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
title={t.strings.arrayRemove}
|
|
404
|
-
onclick={() => remove(k)}><Icon name="minus" /></button
|
|
405
|
-
>
|
|
661
|
+
<div class="qm-row-actions">
|
|
662
|
+
{@render rowActions(k)}
|
|
663
|
+
</div>
|
|
406
664
|
</div>
|
|
407
|
-
{#if
|
|
665
|
+
{#if openId === id}
|
|
408
666
|
<ObjectField
|
|
409
667
|
bind:this={openObjEl}
|
|
410
668
|
value={(arr[k] ?? {}) as Record<string, unknown>}
|
|
411
669
|
properties={items?.properties}
|
|
412
|
-
label={
|
|
670
|
+
label={rowName(k)}
|
|
413
671
|
idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
|
|
414
|
-
contentAt={(path) =>
|
|
672
|
+
contentAt={(path) => readAt([k, ...path])}
|
|
415
673
|
onCommit={(obj) => commitElement(k, obj)}
|
|
674
|
+
diagnostics={routed.below.get(k)}
|
|
416
675
|
/>
|
|
676
|
+
{:else}
|
|
677
|
+
<!-- A collapsed row draws no cell, so what its cells would have said is said
|
|
678
|
+
under the head, where the row can be seen: the message names the leaf. -->
|
|
679
|
+
<DiagnosticList diagnostics={routed.below.get(k)?.map((d) => d.diagnostic)} />
|
|
417
680
|
{/if}
|
|
418
681
|
</div>
|
|
419
|
-
{
|
|
682
|
+
{/each}
|
|
683
|
+
</div>
|
|
684
|
+
{:else}
|
|
685
|
+
<div
|
|
686
|
+
class="qm-array-rows"
|
|
687
|
+
class:empty={ids.length === 0}
|
|
688
|
+
data-default={defaulted ? '' : undefined}
|
|
689
|
+
bind:this={rowsEl}
|
|
690
|
+
>
|
|
691
|
+
{#each ids as id, k (id)}
|
|
420
692
|
<div class="qm-array-row" bind:this={rowEls[id]}>
|
|
421
693
|
{#if control === 'prose'}
|
|
422
694
|
<ProseValue
|
|
423
695
|
bind:this={els[id]}
|
|
424
|
-
content={() =>
|
|
425
|
-
plaintext={items
|
|
426
|
-
label={
|
|
696
|
+
content={() => readAt([k])}
|
|
697
|
+
plaintext={items != null && baseType(items) === 'plaintext'}
|
|
698
|
+
label={rowName(k)}
|
|
427
699
|
onChange={(rt) => commitElement(k, rt)}
|
|
428
700
|
onKey={(e) => onElementKey(e, k)}
|
|
429
701
|
/>
|
|
@@ -431,23 +703,56 @@
|
|
|
431
703
|
<TextField
|
|
432
704
|
bind:this={els[id]}
|
|
433
705
|
value={String(arr[k] ?? '')}
|
|
434
|
-
label={
|
|
706
|
+
label={rowName(k)}
|
|
435
707
|
onCommit={(v) => commitElement(k, v)}
|
|
436
708
|
onKey={(e) => onElementKey(e, k)}
|
|
437
709
|
/>
|
|
438
710
|
{/if}
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
onclick={() => remove(k)}><Icon name="minus" /></button
|
|
444
|
-
>
|
|
711
|
+
<div class="qm-row-actions">
|
|
712
|
+
{@render removeButton(k)}
|
|
713
|
+
</div>
|
|
714
|
+
<DiagnosticList diagnostics={routed.below.get(k)?.map((d) => d.diagnostic)} />
|
|
445
715
|
</div>
|
|
446
|
-
{/
|
|
447
|
-
|
|
448
|
-
|
|
716
|
+
{/each}
|
|
717
|
+
</div>
|
|
718
|
+
{/if}
|
|
719
|
+
<DiagnosticList diagnostics={foot} />
|
|
449
720
|
</div>
|
|
450
721
|
|
|
722
|
+
<!-- The remove, one button wherever a row ends: a scalar row's slab, a record row's
|
|
723
|
+
slab after its reorder pair, a table row's last track. -->
|
|
724
|
+
{#snippet removeButton(k: number)}
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
class="qm-icon-btn qm-row-btn qm-remove qm-focus-ring"
|
|
728
|
+
title={t.strings.arrayRemove}
|
|
729
|
+
aria-label={rowAction(t.strings.arrayRemove, k)}
|
|
730
|
+
onclick={() => remove(k, undefined, true)}><Icon name="minus" /></button
|
|
731
|
+
>
|
|
732
|
+
{/snippet}
|
|
733
|
+
|
|
734
|
+
<!-- An `object` row's controls: the reorder pair, disabled at its edge as the card
|
|
735
|
+
header's is, then the remove. -->
|
|
736
|
+
{#snippet rowActions(k: number)}
|
|
737
|
+
<button
|
|
738
|
+
type="button"
|
|
739
|
+
class="qm-icon-btn qm-row-btn qm-focus-ring"
|
|
740
|
+
title={t.strings.arrayMoveUp}
|
|
741
|
+
aria-label={rowAction(t.strings.arrayMoveUp, k)}
|
|
742
|
+
disabled={k === 0}
|
|
743
|
+
onclick={() => move(k, -1)}><Icon name="chevron-up" /></button
|
|
744
|
+
>
|
|
745
|
+
<button
|
|
746
|
+
type="button"
|
|
747
|
+
class="qm-icon-btn qm-row-btn qm-focus-ring"
|
|
748
|
+
title={t.strings.arrayMoveDown}
|
|
749
|
+
aria-label={rowAction(t.strings.arrayMoveDown, k)}
|
|
750
|
+
disabled={k === ids.length - 1}
|
|
751
|
+
onclick={() => move(k, 1)}><Icon name="chevron-down" /></button
|
|
752
|
+
>
|
|
753
|
+
{@render removeButton(k)}
|
|
754
|
+
{/snippet}
|
|
755
|
+
|
|
451
756
|
<style>
|
|
452
757
|
.qm-array {
|
|
453
758
|
display: flex;
|
|
@@ -464,6 +769,18 @@
|
|
|
464
769
|
justify-content: space-between;
|
|
465
770
|
gap: var(--_qm-space-2);
|
|
466
771
|
}
|
|
772
|
+
.qm-array-add-slot {
|
|
773
|
+
display: inline-flex;
|
|
774
|
+
align-items: center;
|
|
775
|
+
gap: var(--_qm-space-2);
|
|
776
|
+
}
|
|
777
|
+
/* The count reads at the label rung: it is a fact about the field, stated beside the
|
|
778
|
+
chip in the register the chip rests at, and never a value. */
|
|
779
|
+
.qm-array-count {
|
|
780
|
+
font-size: var(--_qm-text-label);
|
|
781
|
+
color: var(--_qm-ink-label);
|
|
782
|
+
font-variant-numeric: tabular-nums;
|
|
783
|
+
}
|
|
467
784
|
/* The elements, in a box of their own: the arrival wash blooms here rather than over
|
|
468
785
|
the wrapper `Field` hands this component (`washBox`), which is the label's box too.
|
|
469
786
|
Positioned for the wash's inset child, and rounded to the rung a row's own box
|
|
@@ -482,6 +799,13 @@
|
|
|
482
799
|
.qm-array-rows.empty {
|
|
483
800
|
display: none;
|
|
484
801
|
}
|
|
802
|
+
/* The default's rows are what prints and nothing written: every value they draw takes
|
|
803
|
+
the default rung (theme.css), and the first gesture, writing them, takes it off. */
|
|
804
|
+
.qm-array-rows[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
|
|
805
|
+
.qm-array-rows[data-default] :global(.qm-select:not([data-ghosted])),
|
|
806
|
+
.qm-array-rows[data-default] .qm-element-title:not(.untitled) {
|
|
807
|
+
color: var(--_qm-ink-default);
|
|
808
|
+
}
|
|
485
809
|
/* A grid rather than a block: an `<input>` is inline-level and would sit on a
|
|
486
810
|
baseline, standing the row a descender taller than the box the slab measures itself
|
|
487
811
|
against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
|
|
@@ -490,65 +814,106 @@
|
|
|
490
814
|
position: relative;
|
|
491
815
|
display: grid;
|
|
492
816
|
grid-template-columns: minmax(0, 1fr);
|
|
817
|
+
row-gap: var(--_qm-space);
|
|
493
818
|
/* The corners the row's wash takes, an element landing blooming here rather than
|
|
494
|
-
over the whole list (`
|
|
819
|
+
over the whole list (`focusPath`): the row draws no box of its own, so the
|
|
495
820
|
radius it lends the wash is the one its element's box draws. */
|
|
496
821
|
border-radius: var(--_qm-radius-inner);
|
|
497
822
|
}
|
|
498
|
-
/* The end inset the
|
|
499
|
-
`.qm-input` is the text element, `.qm-control-box` the prose one
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
823
|
+
/* The end inset the slabs stand in, taken off whichever box the element drew:
|
|
824
|
+
`.qm-input` is the text element, `.qm-control-box` the prose one — the row's own
|
|
825
|
+
child, so a table's cells, which are boxes too, keep their inset — and an object
|
|
826
|
+
row's summary, which stands three slabs off its end. `:global`, because the box
|
|
827
|
+
belongs to the child component's markup and the scope class stops at this
|
|
828
|
+
component's. The longhand beats the family's `padding` shorthand without a
|
|
829
|
+
specificity fight: `controls.css` ranks the family at zero inside `:where()`. */
|
|
830
|
+
.qm-array-row > :global(.qm-input),
|
|
831
|
+
.qm-array-row > :global(.qm-control-box) {
|
|
505
832
|
padding-inline-end: var(--_qm-tap-min);
|
|
506
833
|
}
|
|
834
|
+
.qm-element-summary {
|
|
835
|
+
padding-inline-end: calc(3 * var(--_qm-tap-min));
|
|
836
|
+
}
|
|
507
837
|
/* The box's end wall, floor to ceiling: a height of its own would leave a sliver of
|
|
508
838
|
well above or below, and the corners it takes are the box's — the end-side pair keeps
|
|
509
|
-
`.qm-icon-btn`'s radius, which is the same rung the box draws
|
|
839
|
+
`.qm-icon-btn`'s radius, which is the same rung the box draws; a slab standing before
|
|
840
|
+
another gives up its corners on both sides.
|
|
510
841
|
|
|
511
842
|
It comes up on its own row rather than on the field: a destructive control is offered
|
|
512
|
-
by the row the pointer is on, not by every row at once
|
|
513
|
-
|
|
514
|
-
|
|
843
|
+
by the row the pointer is on, not by every row at once, and the reorder pair beside
|
|
844
|
+
it comes up with it. Hover is where the remove says destructive, ink with fill, a
|
|
845
|
+
tint alone being a wash under a label-toned glyph. */
|
|
846
|
+
.qm-row-actions {
|
|
515
847
|
position: absolute;
|
|
516
848
|
inset-block: 0;
|
|
517
849
|
inset-inline-end: 0;
|
|
518
|
-
|
|
850
|
+
display: flex;
|
|
519
851
|
color: var(--_qm-ink-label);
|
|
520
852
|
opacity: var(--_qm-opacity-idle);
|
|
521
|
-
|
|
522
|
-
|
|
853
|
+
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
854
|
+
}
|
|
855
|
+
.qm-row-btn {
|
|
856
|
+
height: 100%;
|
|
857
|
+
width: var(--_qm-tap-min);
|
|
858
|
+
padding: 0;
|
|
859
|
+
border-radius: 0;
|
|
523
860
|
transition:
|
|
524
|
-
opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
525
861
|
background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
526
862
|
color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
527
863
|
}
|
|
528
|
-
.qm-
|
|
864
|
+
.qm-row-btn:last-child {
|
|
865
|
+
border-start-end-radius: var(--_qm-radius-inner);
|
|
866
|
+
border-end-end-radius: var(--_qm-radius-inner);
|
|
867
|
+
}
|
|
868
|
+
.qm-row-btn :global(svg) {
|
|
529
869
|
width: var(--_qm-glyph-control);
|
|
530
870
|
height: var(--_qm-glyph-control);
|
|
531
871
|
}
|
|
532
|
-
|
|
533
|
-
|
|
872
|
+
/* Child combinators: a nested array shares this scope, and a row's hover is not its
|
|
873
|
+
open subform's rows'. */
|
|
874
|
+
.qm-array-row:hover > .qm-row-actions,
|
|
875
|
+
.qm-array-row:focus-within > .qm-row-actions,
|
|
876
|
+
.qm-array-row:hover > .qm-element-head > .qm-row-actions,
|
|
877
|
+
.qm-array-row:focus-within > .qm-element-head > .qm-row-actions {
|
|
534
878
|
opacity: 1;
|
|
535
879
|
}
|
|
536
|
-
.qm-remove:hover {
|
|
880
|
+
.qm-remove:hover:not(:disabled) {
|
|
537
881
|
background: var(--_qm-danger-tint);
|
|
538
882
|
color: var(--_qm-danger);
|
|
539
883
|
}
|
|
540
884
|
/* ── An object element ──────────────────────────────────────────────────────
|
|
541
|
-
The row is a summary and the subform hangs under it. `row-gap` rather than a
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
885
|
+
The row is a summary and the subform hangs under it. `row-gap` rather than a margin
|
|
886
|
+
on the subform, because the distance between a control and what it has unfolded
|
|
887
|
+
belongs to the thing stacking them; what `ObjectField` adds is the cap on its own
|
|
888
|
+
stroke, equal at both of its ends.
|
|
889
|
+
|
|
890
|
+
The distance inside an open row is the list's own, and the row then takes a rung of
|
|
891
|
+
air from the rows either side of it, so what it unfolded is nearer the summary it
|
|
892
|
+
hangs off than the sibling below. The wider distance on the inside is the inversion:
|
|
893
|
+
a subform reading as the next row's preamble. A variant spends the wider rung on the
|
|
894
|
+
same join and needs no such guard, a field carrying one control and no siblings for
|
|
895
|
+
its cells to drift toward. */
|
|
546
896
|
.qm-element {
|
|
547
|
-
row-gap: var(--_qm-space
|
|
897
|
+
row-gap: var(--_qm-space);
|
|
898
|
+
}
|
|
899
|
+
.qm-element.open {
|
|
900
|
+
margin-block: var(--_qm-space);
|
|
548
901
|
}
|
|
549
|
-
/* The
|
|
550
|
-
|
|
551
|
-
|
|
902
|
+
/* The inline distance is the stacker's too, and here it is a rung: the summary stands
|
|
903
|
+
for the row rather than being a cell of it, so its subform's vertical takes the
|
|
904
|
+
disclosure's own column — `--_qm-nest` in, the distance every stroke on the ladder
|
|
905
|
+
keeps from the one outside it (ARCHITECTURE §"A plane is a tone"). Flush with the
|
|
906
|
+
row's edge that stroke is collinear with the box above it and with the sibling row
|
|
907
|
+
below, so a row's cells read as the list's own rung and the content of an open row
|
|
908
|
+
stands outboard of the chevron that opened it. A variant's cells and a matrix's
|
|
909
|
+
columns keep the flush edge: those sit beside the discriminant and the tick they are
|
|
910
|
+
stored with, not under them. */
|
|
911
|
+
.qm-element > :global(.qm-object) {
|
|
912
|
+
margin-inline-start: var(--_qm-nest);
|
|
913
|
+
}
|
|
914
|
+
/* The slabs measure the head, not the row: an open element is the head plus
|
|
915
|
+
everything it unfolded, and the controls belong to the line they sit on rather than
|
|
916
|
+
to all of that. Positioned, so `.qm-row-actions` anchors here. */
|
|
552
917
|
.qm-element-head {
|
|
553
918
|
position: relative;
|
|
554
919
|
display: grid;
|
|
@@ -558,8 +923,7 @@
|
|
|
558
923
|
in collapsed form the way the enum trigger is one, and the slab's grammar — the
|
|
559
924
|
box's two end-side corners — needs a box with corners to take. It carries
|
|
560
925
|
`.qm-control-box`, so the fill, the radius, the inset and the type are the recipe's
|
|
561
|
-
(controls.css) and a list of records measures like the list of inputs beside it.
|
|
562
|
-
The end inset the slab stands in arrives from the row's own rule above. */
|
|
926
|
+
(controls.css) and a list of records measures like the list of inputs beside it. */
|
|
563
927
|
.qm-element-summary {
|
|
564
928
|
display: flex;
|
|
565
929
|
align-items: center;
|
|
@@ -570,7 +934,10 @@
|
|
|
570
934
|
cursor: pointer;
|
|
571
935
|
}
|
|
572
936
|
/* No hover fill: a well does not fill under the pointer anywhere on this surface.
|
|
573
|
-
The chevron's ink is the cue, which is the accordion header's own ladder.
|
|
937
|
+
The chevron's ink is the cue, which is the accordion header's own ladder. An open
|
|
938
|
+
row's rule reaches its own head and stops: a row's subform holds rows of this same
|
|
939
|
+
shape under this same scope, so a descendant's would turn every collapsed chevron
|
|
940
|
+
inside an open row to the open face. */
|
|
574
941
|
.qm-element-summary :global(.qm-el-chevron) {
|
|
575
942
|
flex-shrink: 0;
|
|
576
943
|
display: block;
|
|
@@ -582,10 +949,10 @@
|
|
|
582
949
|
color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
583
950
|
}
|
|
584
951
|
.qm-element-summary:hover :global(.qm-el-chevron),
|
|
585
|
-
.qm-element.open :global(.qm-el-chevron) {
|
|
952
|
+
.qm-element.open > .qm-element-head :global(.qm-el-chevron) {
|
|
586
953
|
color: var(--_qm-ink);
|
|
587
954
|
}
|
|
588
|
-
.qm-element.open :global(.qm-el-chevron) {
|
|
955
|
+
.qm-element.open > .qm-element-head :global(.qm-el-chevron) {
|
|
589
956
|
transform: rotate(90deg);
|
|
590
957
|
}
|
|
591
958
|
/* The title is the element's own value, so it reads at the ink a written value
|
|
@@ -600,11 +967,117 @@
|
|
|
600
967
|
color: var(--_qm-ink-label);
|
|
601
968
|
font-style: italic;
|
|
602
969
|
}
|
|
970
|
+
/* ── A table ────────────────────────────────────────────────────────────────
|
|
971
|
+
Every row of it is open, so it stands where an open row's subform stands: a rung
|
|
972
|
+
inside the field, behind the subform's vertical and between its caps (`ObjectField`,
|
|
973
|
+
ARCHITECTURE §"A plane is a tone"). The stroke is what says the header's labels are
|
|
974
|
+
the field's insides: on the field's own edge at the label's register, labels abreast
|
|
975
|
+
over boxes are the figure a row of compact fields draws. A corner reads square at a
|
|
976
|
+
stroke, so the wash takes none here.
|
|
977
|
+
|
|
978
|
+
The grid is a box inside the stroke, and the one that scrolls: the stroke and the
|
|
979
|
+
inset hold still while the columns move, and the clip is the inset's edge rather than
|
|
980
|
+
the stroke's. One grid over the header and every row: a column per property, then a
|
|
981
|
+
track for the row's remove. The header and each row subgrid onto it, so a
|
|
982
|
+
cell's edge is its column's whatever the row above it holds. The row packs at the
|
|
983
|
+
start rather than sharing the field's leftover width between the columns: a table
|
|
984
|
+
carries its own rhythm, and stretching four short cells across a field sets them
|
|
985
|
+
apart by the width the card happens to have.
|
|
986
|
+
|
|
987
|
+
A track rests at what its cells cannot shrink below and grows no further than its
|
|
988
|
+
widest, so a field too narrow for them all overflows the grid's box and scrolls
|
|
989
|
+
sideways. A fixed floor as the track's base would instead hand each column a share of
|
|
990
|
+
the width and leave a control that does not shrink — a date's segments — painting
|
|
991
|
+
over the column beside it; the floor rides the header cell, which every column has.
|
|
992
|
+
|
|
993
|
+
The pad is the clip box's, the ring on a cell at that box's own edge reaching past
|
|
994
|
+
its content; the margin takes the same distance back, so the tracks stand a nest in
|
|
995
|
+
from the label row above them and the caps keep their rung. The box keeps the
|
|
996
|
+
remove's track clear of the scrolls it makes itself, so a cell scrolled into view
|
|
997
|
+
lands beside the pinned button rather than under it.
|
|
998
|
+
|
|
999
|
+
The names are the array's own: `.qm-table` is the codec's island (`prose.css`), an
|
|
1000
|
+
unscoped stylesheet in this package, and a box wearing that name takes the island's
|
|
1001
|
+
hairline whatever this block says. */
|
|
1002
|
+
.qm-array-rows.qm-array-table {
|
|
1003
|
+
border-inline-start: var(--_qm-vertical-width) solid var(--_qm-border);
|
|
1004
|
+
padding-inline-start: var(--_qm-nest);
|
|
1005
|
+
padding-block: var(--_qm-space);
|
|
1006
|
+
border-radius: 0;
|
|
1007
|
+
}
|
|
1008
|
+
.qm-array-table-scroller {
|
|
1009
|
+
display: grid;
|
|
1010
|
+
grid-template-columns:
|
|
1011
|
+
repeat(var(--table-cols), minmax(min-content, max-content))
|
|
1012
|
+
auto;
|
|
1013
|
+
justify-content: start;
|
|
1014
|
+
gap: var(--_qm-space);
|
|
1015
|
+
overflow-x: auto;
|
|
1016
|
+
scroll-padding-inline-end: calc(var(--_qm-tap-min) + var(--_qm-space));
|
|
1017
|
+
padding: var(--_qm-ring-reach);
|
|
1018
|
+
margin: calc(-1 * var(--_qm-ring-reach));
|
|
1019
|
+
}
|
|
1020
|
+
.qm-array-table-head,
|
|
1021
|
+
.qm-array-row.qm-array-table-row {
|
|
1022
|
+
display: grid;
|
|
1023
|
+
grid-column: 1 / -1;
|
|
1024
|
+
grid-template-columns: subgrid;
|
|
1025
|
+
column-gap: var(--_qm-space);
|
|
1026
|
+
}
|
|
1027
|
+
.qm-array-row.qm-array-table-row {
|
|
1028
|
+
align-items: start;
|
|
1029
|
+
}
|
|
1030
|
+
/* The header's cells are labels standing over columns, at the rung a field label
|
|
1031
|
+
stands at over its control, and where the column's floor is stated: a column is a
|
|
1032
|
+
property and every one of them has a header, so the label carries the track's.
|
|
1033
|
+
|
|
1034
|
+
A switch column carries none. The floor is the width a control that fills its track
|
|
1035
|
+
stays usable at, and a switch is a mark: one width at every width, so the rest of
|
|
1036
|
+
the floor is whitespace the column spends on nothing and the table scrolls that much
|
|
1037
|
+
sooner. Without it the column rests at its header's own width, its cells still
|
|
1038
|
+
clearing the mark, which no track can shrink below. It is the table's to state
|
|
1039
|
+
because the table is where a column is a width of its own; in a section's grid and
|
|
1040
|
+
in a subform's, a boolean stands in a track its neighbours share. */
|
|
1041
|
+
.qm-array-table-col {
|
|
1042
|
+
min-width: var(--_qm-track-min);
|
|
1043
|
+
}
|
|
1044
|
+
.qm-array-table-col.mark {
|
|
1045
|
+
min-width: 0;
|
|
1046
|
+
}
|
|
1047
|
+
/* The remove is the row's last track, pinned to the end edge of the box the table
|
|
1048
|
+
scrolls in: sticky, so it rests in its track while the table fits and rides over
|
|
1049
|
+
the cells once the box scrolls, on the card's plane so what passes under it is
|
|
1050
|
+
hidden rather than overprinted, and stretched to the row so no cell shows above or
|
|
1051
|
+
below it. Always drawn: a table's rows are many and the eye finds the control by
|
|
1052
|
+
its column. The header's end cell pins with it, or the labels would scroll under
|
|
1053
|
+
nothing while the cells scroll under a plane.
|
|
1054
|
+
|
|
1055
|
+
The inset is the content box's, so the ring's reach fits inside the clip; the plane
|
|
1056
|
+
spread over that same reach hides the sliver of cell the pad would show past the
|
|
1057
|
+
button, and the ring paints over it.
|
|
1058
|
+
|
|
1059
|
+
The slab's grammar goes with the slab: a button on the row's own plane takes the
|
|
1060
|
+
icon family's radius at all four corners, where one cut into a box takes that box's
|
|
1061
|
+
end-side pair and squares the rest. */
|
|
1062
|
+
.qm-array-table-row > .qm-remove,
|
|
1063
|
+
.qm-array-table-head-end {
|
|
1064
|
+
position: sticky;
|
|
1065
|
+
inset-inline-end: 0;
|
|
1066
|
+
background: var(--_qm-surface);
|
|
1067
|
+
box-shadow: 0 0 0 var(--_qm-ring-reach) var(--_qm-surface);
|
|
1068
|
+
}
|
|
1069
|
+
.qm-array-table-row > .qm-remove {
|
|
1070
|
+
align-self: stretch;
|
|
1071
|
+
height: auto;
|
|
1072
|
+
border-radius: var(--_qm-radius-inner);
|
|
1073
|
+
color: var(--_qm-ink-label);
|
|
1074
|
+
}
|
|
603
1075
|
/* The label line's own type: size, weight and leading are `.qm-field-label`'s, so
|
|
604
1076
|
the two read as one register. Inner radius: the chip family is the card's, and this
|
|
605
1077
|
box is a line of type. The chip's tap floor is given back (`.qm-tap-floor`),
|
|
606
1078
|
and the padding with it, so the drawn box is the line and the target is the floor.
|
|
607
|
-
It rests dim and comes up under the pointer; empty, it is the way in
|
|
1079
|
+
It rests dim and comes up under the pointer; empty, it is the way in; at the cap it
|
|
1080
|
+
rests at the family's disabled rung and the count beside it says why. */
|
|
608
1081
|
.qm-add-el {
|
|
609
1082
|
padding: 0 var(--_qm-space);
|
|
610
1083
|
font-weight: var(--_qm-weight-mid);
|
|
@@ -612,7 +1085,7 @@
|
|
|
612
1085
|
border-radius: var(--_qm-radius-inner);
|
|
613
1086
|
opacity: var(--_qm-opacity-idle);
|
|
614
1087
|
}
|
|
615
|
-
.qm-add-el:hover,
|
|
1088
|
+
.qm-add-el:hover:not(:disabled),
|
|
616
1089
|
.qm-add-el:focus-visible {
|
|
617
1090
|
opacity: 1;
|
|
618
1091
|
}
|
|
@@ -621,7 +1094,7 @@
|
|
|
621
1094
|
}
|
|
622
1095
|
@media (hover: none) {
|
|
623
1096
|
.qm-add-el,
|
|
624
|
-
.qm-
|
|
1097
|
+
.qm-row-actions {
|
|
625
1098
|
opacity: var(--_qm-opacity-muted);
|
|
626
1099
|
}
|
|
627
1100
|
}
|