@quillmark/svelte 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/core/codec/index.d.ts +0 -3
- package/dist/core/rebind.svelte.js +5 -8
- package/dist/core/theme.css +12 -1
- package/dist/preset/index.d.ts +1 -0
- package/dist/preview/Preview.svelte +14 -19
- package/dist/preview/Preview.svelte.d.ts +3 -3
- package/dist/preview/index.d.ts +0 -1
- package/dist/visual/ArrayField.svelte +44 -41
- package/dist/visual/ArrayField.svelte.d.ts +0 -1
- package/dist/visual/BooleanField.svelte +16 -14
- package/dist/visual/BooleanField.svelte.d.ts +0 -1
- package/dist/visual/Card.svelte +65 -35
- package/dist/visual/CardControls.svelte.d.ts +0 -1
- package/dist/visual/DateField.svelte +14 -17
- package/dist/visual/DateField.svelte.d.ts +0 -1
- package/dist/visual/EnumField.svelte +9 -12
- package/dist/visual/EnumField.svelte.d.ts +0 -1
- package/dist/visual/Field.svelte +17 -10
- package/dist/visual/Field.svelte.d.ts +3 -0
- package/dist/visual/FieldHint.svelte.d.ts +0 -1
- package/dist/visual/FieldLabel.svelte +1 -2
- package/dist/visual/FormatPopover.svelte +22 -27
- package/dist/visual/FormatPopover.svelte.d.ts +0 -1
- package/dist/visual/MatrixAdd.svelte +210 -0
- package/dist/visual/MatrixAdd.svelte.d.ts +15 -0
- package/dist/visual/MatrixField.svelte +329 -84
- package/dist/visual/MatrixField.svelte.d.ts +8 -6
- package/dist/visual/NumberField.svelte +6 -10
- package/dist/visual/NumberField.svelte.d.ts +0 -1
- package/dist/visual/ObjectField.svelte +30 -17
- package/dist/visual/ObjectField.svelte.d.ts +3 -1
- package/dist/visual/ProseField.svelte +42 -41
- package/dist/visual/ProseField.svelte.d.ts +0 -1
- package/dist/visual/ProseValue.svelte +56 -56
- package/dist/visual/ProseValue.svelte.d.ts +0 -1
- package/dist/visual/SlashMenu.svelte +5 -15
- package/dist/visual/SlashMenu.svelte.d.ts +0 -1
- package/dist/visual/TextField.svelte +5 -9
- package/dist/visual/TextField.svelte.d.ts +0 -1
- package/dist/visual/TipsCard.svelte +10 -11
- package/dist/visual/TipsCard.svelte.d.ts +0 -1
- package/dist/visual/VisualEditorInner.svelte +21 -14
- package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
- package/dist/visual/checklists.d.ts +82 -0
- package/dist/visual/checklists.js +171 -0
- package/dist/visual/index.d.ts +0 -1
- package/dist/visual/strings.d.ts +15 -1
- package/dist/visual/strings.js +6 -0
- package/dist/visual/structure.d.ts +63 -31
- package/dist/visual/structure.js +122 -44
- package/package.json +20 -16
- package/dist/visual/synced.svelte.d.ts +0 -15
- package/dist/visual/synced.svelte.js +0 -34
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A `matrix` field → ticks over the roster, columns unfolding under a held member
|
|
3
|
-
(VISUAL_EDITOR §"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
its title as the `<label for>`, an unheld member reading at the label
|
|
7
|
-
one at the value ink. A held member unfolds its columns beneath its
|
|
8
|
-
{@link ObjectField} over the matrix's `properties` at the member's key
|
|
9
|
-
|
|
10
|
-
member's retained columns are held but undrawn, the variant rule: in the document and
|
|
11
|
-
not on the page.
|
|
3
|
+
(VISUAL_EDITOR §"The matrix", canon `SCHEMAS.md` §Matrix). The roster is one flat
|
|
4
|
+
list, one member to a row, or abreast on the section's track ladder under
|
|
5
|
+
`ui.compact`; each member is a real checkbox whose checked state is the member's
|
|
6
|
+
presence, with its title as the `<label for>`, an unheld member reading at the label
|
|
7
|
+
ink and a held one at the value ink. A held member unfolds its columns beneath its
|
|
8
|
+
title, drawn by {@link ObjectField} over the matrix's `properties` at the member's key;
|
|
9
|
+
zero columns is a checklist and unfolds nothing.
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
An open matrix's added items follow the roster in id order, as the plate prints them:
|
|
12
|
+
held by being present, so each draws a fixed tick, its `title` as an input, a remove,
|
|
13
|
+
and the same columns. The add box closes the list ({@link MatrixAdd}): it searches
|
|
14
|
+
every checklist of the document before it offers to add what was typed.
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
The control owns its label row, as an array does: the count of held items sits
|
|
17
|
+
where an array's add chip sits, so {@link Field} skips its own label for it.
|
|
18
|
+
|
|
19
|
+
Commit is whole: the sparse map, one `writer.set`. A tick writes the member, its
|
|
20
|
+
columns or the bare `true`; an untick removes it. Its columns are kept here, for this
|
|
21
|
+
mount, so a retick restores them: the document keeps no answer for a member it does not
|
|
22
|
+
hold. Every other member rides through as the document spelled it. An unset matrix
|
|
23
|
+
whose `default:` holds members draws that set at the default rung, and the first
|
|
24
|
+
gesture writes it whole with the change in it.
|
|
26
25
|
-->
|
|
27
26
|
<script lang="ts">
|
|
28
27
|
import { tick as flush } from 'svelte';
|
|
@@ -33,28 +32,47 @@
|
|
|
33
32
|
const t = wording();
|
|
34
33
|
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
35
34
|
import {
|
|
36
|
-
|
|
35
|
+
MATRIX_TITLE,
|
|
36
|
+
addedId,
|
|
37
|
+
addedItems,
|
|
38
|
+
baseType,
|
|
37
39
|
commitMember,
|
|
40
|
+
declaredContent,
|
|
38
41
|
matrixColumns,
|
|
39
42
|
matrixDeclares,
|
|
43
|
+
matrixEmpty,
|
|
40
44
|
matrixHeld,
|
|
41
45
|
matrixMembers,
|
|
42
46
|
memberValue,
|
|
43
|
-
memberWrite
|
|
47
|
+
memberWrite,
|
|
48
|
+
onRoster,
|
|
49
|
+
schemaAt
|
|
44
50
|
} from './structure.js';
|
|
51
|
+
import {
|
|
52
|
+
checklists,
|
|
53
|
+
matrixPrinted,
|
|
54
|
+
searchChecklists,
|
|
55
|
+
type AddOption,
|
|
56
|
+
type Checklist,
|
|
57
|
+
type ChecklistHit,
|
|
58
|
+
type Searchable
|
|
59
|
+
} from './checklists.js';
|
|
45
60
|
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
46
61
|
import type { LandingBox } from './leaves.js';
|
|
47
62
|
import { propertyDomIds } from './domid.js';
|
|
63
|
+
import { bloomInside } from '../core/bloom.js';
|
|
48
64
|
import Icon from './icons/Icon.svelte';
|
|
49
65
|
import FieldLabel from './FieldLabel.svelte';
|
|
50
66
|
import ObjectField from './ObjectField.svelte';
|
|
67
|
+
import MatrixAdd from './MatrixAdd.svelte';
|
|
51
68
|
import DiagnosticList from './DiagnosticList.svelte';
|
|
52
69
|
import './controls.css';
|
|
53
70
|
|
|
54
71
|
interface Props {
|
|
55
72
|
/** The stored sparse map, or undefined while the field is unset. */
|
|
56
73
|
value: Record<string, unknown> | undefined;
|
|
57
|
-
/** The field's own schema: `members` is the roster, `properties` the columns
|
|
74
|
+
/** The field's own schema: `members` is the roster, `properties` the columns,
|
|
75
|
+
* `open` whether a document may add items. */
|
|
58
76
|
schema: QuillFieldSchema;
|
|
59
77
|
/** Field label, the naming prefix a member's columns compose with. */
|
|
60
78
|
label?: string;
|
|
@@ -65,15 +83,17 @@
|
|
|
65
83
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
66
84
|
descriptionId?: string;
|
|
67
85
|
/** The field's control id: the base a member's tick and columns derive their own
|
|
68
|
-
*
|
|
86
|
+
* names from, one `-m-<member id>` segment down. */
|
|
69
87
|
idBase?: string;
|
|
88
|
+
/** The field's leaf key, where it is a card's own field: how the add box tells
|
|
89
|
+
* this list from the rest of the document's (`checklists.ts`). */
|
|
90
|
+
listKey?: string;
|
|
70
91
|
/** The boundary's nested content read, rooted at this field: a member's content
|
|
71
|
-
*
|
|
92
|
+
* column reads at `[id, column]` (`reader.getContentAt`). */
|
|
72
93
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
73
94
|
onCommit: (v: Record<string, unknown> | undefined) => void;
|
|
74
95
|
/** The diagnostics routed into this field, each with its steps still to walk: one
|
|
75
|
-
*
|
|
76
|
-
* field's foot. */
|
|
96
|
+
* naming a drawn item draws under it, any other at the field's foot. */
|
|
77
97
|
diagnostics?: readonly DeepDiagnostic[];
|
|
78
98
|
}
|
|
79
99
|
let {
|
|
@@ -84,33 +104,51 @@
|
|
|
84
104
|
labelId,
|
|
85
105
|
descriptionId,
|
|
86
106
|
idBase,
|
|
107
|
+
listKey,
|
|
87
108
|
contentAt,
|
|
88
109
|
onCommit,
|
|
89
110
|
diagnostics
|
|
90
111
|
}: Props = $props();
|
|
91
112
|
|
|
113
|
+
// A card's own matrix searches the document's lists; one nested in a container is
|
|
114
|
+
// not indexed, and searches its own items alone.
|
|
115
|
+
const index = checklists();
|
|
116
|
+
const others = (): readonly Checklist[] => (listKey != null ? (index?.list() ?? []) : []);
|
|
92
117
|
const members = $derived(matrixMembers(schema.members));
|
|
93
118
|
const compact = $derived(!!schema.ui?.compact);
|
|
94
119
|
const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
|
|
95
|
-
|
|
96
|
-
const
|
|
120
|
+
/** The held set as it prints: the stored map, else the declared `default:`. */
|
|
121
|
+
const map = $derived(matrixPrinted(value, schema));
|
|
122
|
+
const defaulted = $derived(value == null && map != null && Object.keys(map).length > 0);
|
|
123
|
+
const added = $derived(addedItems(schema, map));
|
|
124
|
+
const held = (id: string): boolean => matrixHeld(memberValue(map, id));
|
|
125
|
+
const heldCount = $derived(members.filter((m) => held(m.id)).length + added.length);
|
|
126
|
+
/** Whether `id` is an item this control draws: a roster member or a held added item. */
|
|
127
|
+
const drawn = (id: string): boolean =>
|
|
128
|
+
onRoster(schema, id) || added.some((item) => item.id === id);
|
|
97
129
|
const routed = $derived(splitDeep(diagnostics));
|
|
98
|
-
const foot = $derived(
|
|
99
|
-
unrouted(routed, (step) => typeof step === 'string' && matrixDeclares(schema, step))
|
|
100
|
-
);
|
|
130
|
+
const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && drawn(step)));
|
|
101
131
|
|
|
102
132
|
// A member with no field id space around it still needs an id: the tick is what its
|
|
103
133
|
// title's `for` names. Stable across SSR and hydration, which a counter is not.
|
|
104
134
|
const uid = $props.id();
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
const tickId = (id: string): string =>
|
|
110
|
-
const titleId = (id: string): string =>
|
|
135
|
+
const base = $derived(idBase ?? `qm-${uid}`);
|
|
136
|
+
const memberBase = (id: string): string => `${base}-m-${id}`;
|
|
137
|
+
/** A member's own address is its tick, so the tick takes the id its label names and
|
|
138
|
+
* the column subform under it derives its own names from the same base. */
|
|
139
|
+
const tickId = (id: string): string => `${memberBase(id)}-tick`;
|
|
140
|
+
const titleId = (id: string): string => `${memberBase(id)}-title`;
|
|
141
|
+
/** An added item's title input: the `title` cell's own control id. */
|
|
142
|
+
const itemTitleId = (id: string): string => propertyDomIds(memberBase(id), MATRIX_TITLE).control;
|
|
143
|
+
|
|
144
|
+
/** The columns an untick took, by member id, for a retick in this mount to restore. */
|
|
145
|
+
const stash = new Map<string, Record<string, unknown>>();
|
|
146
|
+
/** Commit the map; one holding nothing is the field's empty answer (`matrixEmpty`). */
|
|
147
|
+
const commit = (next: Record<string, unknown> | undefined): void =>
|
|
148
|
+
onCommit(next ?? matrixEmpty(schema));
|
|
111
149
|
|
|
112
|
-
// The
|
|
113
|
-
//
|
|
150
|
+
// The item boxes, the ticks, the title inputs and the column subforms, keyed by
|
|
151
|
+
// member id — stable for the item's life. `$state` for the binding's sake
|
|
114
152
|
// (`ArrayField` keeps its element refs the same way).
|
|
115
153
|
type Subform = {
|
|
116
154
|
focus: () => void;
|
|
@@ -118,31 +156,132 @@
|
|
|
118
156
|
};
|
|
119
157
|
const memberEls: Record<string, HTMLElement | undefined> = $state({});
|
|
120
158
|
const tickEls: Record<string, HTMLInputElement | undefined> = $state({});
|
|
159
|
+
const titleEls: Record<string, HTMLInputElement | undefined> = $state({});
|
|
121
160
|
const colEls: Record<string, Subform | undefined> = $state({});
|
|
122
161
|
let rosterEl = $state<HTMLElement | undefined>();
|
|
162
|
+
let addEl = $state<{ focus: () => void } | undefined>();
|
|
123
163
|
/** A ref by member id, own keys only: an id can be spelled `constructor`. */
|
|
124
|
-
const ref = <T,>(
|
|
125
|
-
Object.hasOwn(
|
|
164
|
+
const ref = <T,>(refs: Record<string, T | undefined>, id: string): T | undefined =>
|
|
165
|
+
Object.hasOwn(refs, id) ? refs[id] : undefined;
|
|
166
|
+
|
|
167
|
+
/** The rows' content read: the boundary's, or, while the set is the default, the
|
|
168
|
+
* literal's own leaf at the codec its declared type names. */
|
|
169
|
+
function readAt(path: PathStep[]): Content | undefined {
|
|
170
|
+
if (!defaulted) return contentAt(path);
|
|
171
|
+
const leaf = schemaAt(schema, path);
|
|
172
|
+
let v: unknown = map;
|
|
173
|
+
for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
|
|
174
|
+
return leaf ? declaredContent(v, baseType(leaf) === 'richtext') : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The held member `id` as a tick writes it: the columns its untick took, if any. */
|
|
178
|
+
function ticked(id: string): unknown {
|
|
179
|
+
const columns = stash.get(id) ?? {};
|
|
180
|
+
stash.delete(id);
|
|
181
|
+
return memberWrite(columns);
|
|
182
|
+
}
|
|
183
|
+
/** The map with `id` unticked, its columns stashed for a retick. */
|
|
184
|
+
function unticked(id: string): Record<string, unknown> | undefined {
|
|
185
|
+
const columns = matrixColumns(memberValue(map, id));
|
|
186
|
+
if (Object.keys(columns).length) stash.set(id, columns);
|
|
187
|
+
else stash.delete(id);
|
|
188
|
+
return commitMember(map, id, undefined);
|
|
189
|
+
}
|
|
126
190
|
|
|
127
191
|
/** A native checkbox carries its own state, so a commit the document declines leaves
|
|
128
192
|
* the face ticked over a map that says otherwise, `checked={held(id)}` having nothing
|
|
129
193
|
* new to write. The reassert after the flush is what the styled controls get from
|
|
130
|
-
* their
|
|
194
|
+
* their local, a `$derived` of the value: the document is what the face reads. */
|
|
131
195
|
async function tick(id: string, on: boolean, el: HTMLInputElement): Promise<void> {
|
|
132
|
-
|
|
196
|
+
commit(on ? commitMember(map, id, ticked(id)) : unticked(id));
|
|
133
197
|
await flush();
|
|
134
198
|
if (el.isConnected) el.checked = held(id);
|
|
135
199
|
}
|
|
136
|
-
/** A column edit lands on a held
|
|
200
|
+
/** A column edit lands on a held item, an added one keeping its `title`. */
|
|
137
201
|
function commitColumns(id: string, columns: Record<string, unknown>): void {
|
|
138
|
-
|
|
202
|
+
const title = onRoster(schema, id) ? undefined : itemTitle(id);
|
|
203
|
+
commit(commitMember(map, id, memberWrite(columns, title)));
|
|
204
|
+
}
|
|
205
|
+
function itemTitle(id: string): string {
|
|
206
|
+
const stored = memberValue(map, id) as Record<string, unknown> | undefined;
|
|
207
|
+
return String(stored?.[MATRIX_TITLE] ?? '');
|
|
208
|
+
}
|
|
209
|
+
function retitle(id: string, title: string): void {
|
|
210
|
+
commit(commitMember(map, id, memberWrite(matrixColumns(memberValue(map, id)), title)));
|
|
211
|
+
}
|
|
212
|
+
async function removeItem(id: string): Promise<void> {
|
|
213
|
+
const at = added.findIndex((item) => item.id === id);
|
|
214
|
+
commit(commitMember(map, id, undefined));
|
|
215
|
+
await flush();
|
|
216
|
+
// The caret goes where the row was: the next added item, else the add box.
|
|
217
|
+
const next = added[at] ?? added[at - 1];
|
|
218
|
+
if (next) ref(titleEls, next.id)?.focus();
|
|
219
|
+
else addEl?.focus();
|
|
139
220
|
}
|
|
140
221
|
|
|
141
|
-
|
|
142
|
-
|
|
222
|
+
// ── The add box ────────────────────────────────────────────────────────────
|
|
223
|
+
/** A result's list: this one, or another of the document's. */
|
|
224
|
+
type Hit = ChecklistHit<Checklist | undefined>;
|
|
225
|
+
function options(query: string): AddOption[] {
|
|
226
|
+
const own: Searchable<Checklist | undefined> = { list: undefined, schema, value: map };
|
|
227
|
+
const lists: Searchable<Checklist | undefined>[] = others()
|
|
228
|
+
.filter((l) => l.key !== listKey)
|
|
229
|
+
.map((l) => ({ list: l, schema: l.schema, value: l.value }));
|
|
230
|
+
const hits = searchChecklists(query, [own, ...lists]);
|
|
231
|
+
const out: AddOption[] = hits.map((h, k) => ({
|
|
232
|
+
key: `hit-${k}`,
|
|
233
|
+
title: h.title,
|
|
234
|
+
note:
|
|
235
|
+
[
|
|
236
|
+
h.list ? t.strings.matrixIn(h.list.label) : undefined,
|
|
237
|
+
h.held ? t.strings.matrixHeldTag : undefined
|
|
238
|
+
]
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.join(' · ') || undefined,
|
|
241
|
+
strong: h.strong,
|
|
242
|
+
payload: h
|
|
243
|
+
}));
|
|
244
|
+
// Offered last, and not where an item of this list already carries the words.
|
|
245
|
+
const fold = (s: string) => s.trim().toLowerCase();
|
|
246
|
+
const dup = hits.some((h) => h.list === undefined && fold(h.title) === fold(query));
|
|
247
|
+
if (!dup) out.push({ key: 'add', title: t.strings.matrixAddNew(query), add: true });
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
async function choose(option: AddOption, query: string): Promise<void> {
|
|
251
|
+
if (option.add) return add(query);
|
|
252
|
+
const hit = option.payload as Hit;
|
|
253
|
+
const other = hit.list;
|
|
254
|
+
if (other) {
|
|
255
|
+
if (!hit.held) other.commit(commitMember(other.value, hit.id, memberWrite({})));
|
|
256
|
+
index?.land(`${other.path}.${hit.id}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!hit.held) commit(commitMember(map, hit.id, ticked(hit.id)));
|
|
260
|
+
await flush();
|
|
261
|
+
focusPath([hit.id]);
|
|
262
|
+
const box = ref(memberEls, hit.id);
|
|
263
|
+
if (box) bloomInside(box);
|
|
264
|
+
}
|
|
265
|
+
async function add(title: string): Promise<void> {
|
|
266
|
+
const taken = (id: string) => onRoster(schema, id) || (map != null && Object.hasOwn(map, id));
|
|
267
|
+
const id = addedId(title, taken);
|
|
268
|
+
commit(commitMember(map, id, memberWrite({}, title)));
|
|
269
|
+
await flush();
|
|
270
|
+
const box = ref(memberEls, id);
|
|
271
|
+
// The item's columns are what the author came to fill; a checklist has none, and
|
|
272
|
+
// the box stays where the next item is typed.
|
|
273
|
+
const first = Object.keys(schema.properties ?? {})[0];
|
|
274
|
+
if (first !== undefined) ref(colEls, id)?.focusPath([first]);
|
|
275
|
+
if (box) bloomInside(box);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Take the caret: the first item's control, else the add box. */
|
|
143
279
|
export function focus(): void {
|
|
144
280
|
const first = members[0];
|
|
145
|
-
if (first) ref(tickEls, first.id)?.focus();
|
|
281
|
+
if (first) return ref(tickEls, first.id)?.focus();
|
|
282
|
+
const item = added[0];
|
|
283
|
+
if (item) return ref(titleEls, item.id)?.focus();
|
|
284
|
+
addEl?.focus();
|
|
146
285
|
}
|
|
147
286
|
/** The box an arrival wash blooms in: the roster, not the label row above it, this
|
|
148
287
|
* component owning the field's label as an array does. */
|
|
@@ -150,29 +289,29 @@
|
|
|
150
289
|
return rosterEl;
|
|
151
290
|
}
|
|
152
291
|
/**
|
|
153
|
-
* Land at `steps`:
|
|
154
|
-
*
|
|
155
|
-
* that column where the
|
|
156
|
-
* where they are not — the address names the
|
|
157
|
-
* the tick that would open it. One rule keyed on
|
|
158
|
-
* document, so one address lands in one place.
|
|
159
|
-
* to {@link focus}. The innermost row the walk
|
|
160
|
-
* box: the
|
|
292
|
+
* Land at `steps`: an item, or a cell under it ({@link FieldControl.focusPath}). A
|
|
293
|
+
* member's own address is its tick, and an added item's its title, as is its `title`
|
|
294
|
+
* cell; a column address focuses that column where the item is held and its columns
|
|
295
|
+
* are drawn, and the item's own control where they are not — the address names the
|
|
296
|
+
* column, and what is drawn for it is the tick that would open it. One rule keyed on
|
|
297
|
+
* the address, never on the document, so one address lands in one place. An item
|
|
298
|
+
* this control does not draw falls back to {@link focus}. The innermost row the walk
|
|
299
|
+
* opened comes back, else the item's box: the item is the row the address named.
|
|
161
300
|
*/
|
|
162
301
|
export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
|
|
163
302
|
const [id, ...rest] = steps;
|
|
164
|
-
if (typeof id !== 'string' || !matrixDeclares(schema, id)) {
|
|
303
|
+
if (typeof id !== 'string' || !matrixDeclares(schema, id) || !drawn(id)) {
|
|
165
304
|
focus();
|
|
166
305
|
return undefined;
|
|
167
306
|
}
|
|
168
307
|
const column = rest[0];
|
|
169
308
|
const cells = ref(colEls, id);
|
|
170
309
|
const member = ref(memberEls, id);
|
|
171
|
-
if (column !== undefined && column !==
|
|
310
|
+
if (column !== undefined && column !== MATRIX_TITLE && cells) {
|
|
172
311
|
const inner = cells.focusPath(rest, pos);
|
|
173
312
|
return inner instanceof Promise ? inner.then((box) => box ?? member) : (inner ?? member);
|
|
174
313
|
}
|
|
175
|
-
ref(tickEls, id)?.focus();
|
|
314
|
+
(ref(tickEls, id) ?? ref(titleEls, id))?.focus();
|
|
176
315
|
return member;
|
|
177
316
|
}
|
|
178
317
|
</script>
|
|
@@ -189,13 +328,18 @@
|
|
|
189
328
|
{:else}
|
|
190
329
|
<span></span>
|
|
191
330
|
{/if}
|
|
192
|
-
<span class="qm-matrix-count">
|
|
331
|
+
<span class="qm-matrix-count">
|
|
332
|
+
{t.strings.matrixHeld(heldCount, members.length + added.length)}
|
|
333
|
+
</span>
|
|
193
334
|
</div>
|
|
194
|
-
<div
|
|
335
|
+
<div
|
|
336
|
+
class={['qm-matrix-roster', { 'qm-tracks': compact, compact }]}
|
|
337
|
+
data-default={defaulted ? '' : undefined}
|
|
338
|
+
bind:this={rosterEl}
|
|
339
|
+
>
|
|
195
340
|
{#each members as m (m.id)}
|
|
196
341
|
{@const on = held(m.id)}
|
|
197
|
-
{
|
|
198
|
-
<div class="qm-member" class:held={on} bind:this={memberEls[m.id]}>
|
|
342
|
+
<div class={['qm-member', { held: on }]} bind:this={memberEls[m.id]}>
|
|
199
343
|
<div class="qm-member-head">
|
|
200
344
|
<!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
|
|
201
345
|
dial reaches, so the input is `appearance: none` and the box and the
|
|
@@ -214,30 +358,77 @@
|
|
|
214
358
|
</span>
|
|
215
359
|
<label class="qm-member-title" id={titleId(m.id)} for={tickId(m.id)}>{m.title}</label>
|
|
216
360
|
</div>
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
361
|
+
{@render columns(m.id, m.title, on)}
|
|
362
|
+
</div>
|
|
363
|
+
{/each}
|
|
364
|
+
{#each added as item, k (item.id)}
|
|
365
|
+
{@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
|
|
366
|
+
<div class="qm-member held added" bind:this={memberEls[item.id]}>
|
|
367
|
+
<div class="qm-member-head">
|
|
368
|
+
<!-- Held by being present: the tick is fixed, and the remove is its untick. -->
|
|
369
|
+
<span class="qm-tick" aria-hidden="true">
|
|
370
|
+
<span class="qm-tick-input qm-tick-fixed"></span>
|
|
371
|
+
<Icon name="check" class="qm-tick-mark" />
|
|
372
|
+
</span>
|
|
373
|
+
<input
|
|
374
|
+
class="qm-input qm-focus-ring qm-member-title-input"
|
|
375
|
+
type="text"
|
|
376
|
+
id={itemTitleId(item.id)}
|
|
377
|
+
aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
|
|
378
|
+
value={item.title}
|
|
379
|
+
bind:this={titleEls[item.id]}
|
|
380
|
+
oninput={(e) => retitle(item.id, e.currentTarget.value)}
|
|
231
381
|
/>
|
|
232
|
-
|
|
233
|
-
<
|
|
234
|
-
|
|
382
|
+
<span class="qm-member-title" id={titleId(item.id)} hidden>{name}</span>
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
class="qm-icon-btn qm-focus-ring qm-member-remove"
|
|
386
|
+
title={t.strings.matrixRemove(name)}
|
|
387
|
+
aria-label={t.strings.matrixRemove(name)}
|
|
388
|
+
onclick={() => removeItem(item.id)}><Icon name="x" /></button
|
|
389
|
+
>
|
|
390
|
+
</div>
|
|
391
|
+
{@render columns(item.id, name, true)}
|
|
235
392
|
</div>
|
|
236
393
|
{/each}
|
|
237
394
|
</div>
|
|
395
|
+
{#if schema.open}
|
|
396
|
+
<MatrixAdd
|
|
397
|
+
bind:this={addEl}
|
|
398
|
+
id={`${base}-add`}
|
|
399
|
+
placeholder={t.strings.matrixAdd(label ?? '')}
|
|
400
|
+
{options}
|
|
401
|
+
onChoose={choose}
|
|
402
|
+
/>
|
|
403
|
+
{/if}
|
|
238
404
|
<DiagnosticList diagnostics={foot} />
|
|
239
405
|
</div>
|
|
240
406
|
|
|
407
|
+
<!-- A held item's columns, one rung in, the way a variant's cells unfold under the
|
|
408
|
+
discriminant; an unheld one's diagnostics under its head. Keyed by item id above, so
|
|
409
|
+
a tick elsewhere leaves this subform mounted and the caret in it. -->
|
|
410
|
+
{#snippet columns(id: string, title: string, on: boolean)}
|
|
411
|
+
{@const deep = routed.below.get(id)}
|
|
412
|
+
{#if on && hasColumns}
|
|
413
|
+
<ObjectField
|
|
414
|
+
bind:this={colEls[id]}
|
|
415
|
+
value={matrixColumns(memberValue(map, id))}
|
|
416
|
+
properties={schema.properties}
|
|
417
|
+
label={`${label ?? ''} ${title}`.trim()}
|
|
418
|
+
idBase={memberBase(id)}
|
|
419
|
+
labelledBy={titleId(id)}
|
|
420
|
+
contentAt={(path) => readAt([id, ...path])}
|
|
421
|
+
onCommit={(cols) => commitColumns(id, cols)}
|
|
422
|
+
diagnostics={deep?.filter((d) => d.steps[0] !== MATRIX_TITLE)}
|
|
423
|
+
/>
|
|
424
|
+
<DiagnosticList
|
|
425
|
+
diagnostics={deep?.filter((d) => d.steps[0] === MATRIX_TITLE).map((d) => d.diagnostic)}
|
|
426
|
+
/>
|
|
427
|
+
{:else}
|
|
428
|
+
<DiagnosticList diagnostics={deep?.map((d) => d.diagnostic)} />
|
|
429
|
+
{/if}
|
|
430
|
+
{/snippet}
|
|
431
|
+
|
|
241
432
|
<style>
|
|
242
433
|
.qm-matrix {
|
|
243
434
|
display: flex;
|
|
@@ -317,10 +508,15 @@
|
|
|
317
508
|
background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
318
509
|
border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
319
510
|
}
|
|
320
|
-
.qm-tick-input:checked
|
|
511
|
+
.qm-tick-input:checked,
|
|
512
|
+
.qm-tick-fixed {
|
|
321
513
|
background: var(--_qm-accent);
|
|
322
514
|
border-color: var(--_qm-accent);
|
|
323
515
|
}
|
|
516
|
+
.qm-tick-fixed {
|
|
517
|
+
box-sizing: border-box;
|
|
518
|
+
cursor: default;
|
|
519
|
+
}
|
|
324
520
|
/* The mark rides over the input and takes no press of its own; it is drawn by the
|
|
325
521
|
input's state, in the ink the surface reads over the accent. */
|
|
326
522
|
.qm-tick :global(.qm-tick-mark) {
|
|
@@ -333,7 +529,7 @@
|
|
|
333
529
|
opacity: 0;
|
|
334
530
|
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
335
531
|
}
|
|
336
|
-
.qm-tick:has(:checked) :global(.qm-tick-mark) {
|
|
532
|
+
.qm-tick:has(:checked, .qm-tick-fixed) :global(.qm-tick-mark) {
|
|
337
533
|
opacity: 1;
|
|
338
534
|
}
|
|
339
535
|
/* Unheld reads at the label ink, held at the value ink: the title is what the
|
|
@@ -350,4 +546,53 @@
|
|
|
350
546
|
.qm-member-title:hover {
|
|
351
547
|
color: var(--_qm-ink);
|
|
352
548
|
}
|
|
549
|
+
/* An added item's title is its value, an input drawn as the roster's titles are: its
|
|
550
|
+
text starts where theirs does, one gap past the tick, and its well comes up under
|
|
551
|
+
the pointer and the focus, flush with the tick, so at rest the row reads as the rows
|
|
552
|
+
above it. */
|
|
553
|
+
.qm-member-title-input {
|
|
554
|
+
flex: 1;
|
|
555
|
+
min-width: 0;
|
|
556
|
+
margin-inline-start: calc(-1 * var(--_qm-space-2));
|
|
557
|
+
padding-inline-start: var(--_qm-space-2);
|
|
558
|
+
background: transparent;
|
|
559
|
+
transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
560
|
+
}
|
|
561
|
+
.qm-member-title-input:hover {
|
|
562
|
+
background: var(--_qm-surface-hover);
|
|
563
|
+
}
|
|
564
|
+
.qm-member-title-input:focus {
|
|
565
|
+
background: var(--_qm-surface-well);
|
|
566
|
+
}
|
|
567
|
+
/* The remove is offered by the row the pointer or the focus is in, as an array row's
|
|
568
|
+
is, and says destructive under the pointer. */
|
|
569
|
+
.qm-member-remove {
|
|
570
|
+
flex-shrink: 0;
|
|
571
|
+
color: var(--_qm-ink-label);
|
|
572
|
+
opacity: var(--_qm-opacity-idle);
|
|
573
|
+
transition:
|
|
574
|
+
opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
575
|
+
background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
|
|
576
|
+
color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
577
|
+
}
|
|
578
|
+
.qm-member.added:hover > .qm-member-head > .qm-member-remove,
|
|
579
|
+
.qm-member.added:focus-within > .qm-member-head > .qm-member-remove {
|
|
580
|
+
opacity: 1;
|
|
581
|
+
}
|
|
582
|
+
.qm-member-remove:hover {
|
|
583
|
+
background: var(--_qm-danger-tint);
|
|
584
|
+
color: var(--_qm-danger);
|
|
585
|
+
}
|
|
586
|
+
.qm-member-remove :global(svg) {
|
|
587
|
+
width: var(--_qm-glyph-control);
|
|
588
|
+
height: var(--_qm-glyph-control);
|
|
589
|
+
}
|
|
590
|
+
/* The default's set is what prints and nothing written: its titles and every value
|
|
591
|
+
its columns draw take the default rung (theme.css), and the first gesture, writing
|
|
592
|
+
it, takes it off. */
|
|
593
|
+
.qm-matrix-roster[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
|
|
594
|
+
.qm-matrix-roster[data-default] :global(.qm-select:not([data-ghosted])),
|
|
595
|
+
.qm-matrix-roster[data-default] .qm-member.held .qm-member-title {
|
|
596
|
+
color: var(--_qm-ink-default);
|
|
597
|
+
}
|
|
353
598
|
</style>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
2
2
|
import { type DeepDiagnostic } from './diagnostics.js';
|
|
3
3
|
import type { LandingBox } from './leaves.js';
|
|
4
|
-
import './controls.css';
|
|
5
4
|
interface Props {
|
|
6
5
|
/** The stored sparse map, or undefined while the field is unset. */
|
|
7
6
|
value: Record<string, unknown> | undefined;
|
|
8
|
-
/** The field's own schema: `members` is the roster, `properties` the columns
|
|
7
|
+
/** The field's own schema: `members` is the roster, `properties` the columns,
|
|
8
|
+
* `open` whether a document may add items. */
|
|
9
9
|
schema: QuillFieldSchema;
|
|
10
10
|
/** Field label, the naming prefix a member's columns compose with. */
|
|
11
11
|
label?: string;
|
|
@@ -16,15 +16,17 @@ interface Props {
|
|
|
16
16
|
/** Where the description parks, for the group's `aria-describedby`. */
|
|
17
17
|
descriptionId?: string;
|
|
18
18
|
/** The field's control id: the base a member's tick and columns derive their own
|
|
19
|
-
*
|
|
19
|
+
* names from, one `-m-<member id>` segment down. */
|
|
20
20
|
idBase?: string;
|
|
21
|
+
/** The field's leaf key, where it is a card's own field: how the add box tells
|
|
22
|
+
* this list from the rest of the document's (`checklists.ts`). */
|
|
23
|
+
listKey?: string;
|
|
21
24
|
/** The boundary's nested content read, rooted at this field: a member's content
|
|
22
|
-
*
|
|
25
|
+
* column reads at `[id, column]` (`reader.getContentAt`). */
|
|
23
26
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
24
27
|
onCommit: (v: Record<string, unknown> | undefined) => void;
|
|
25
28
|
/** The diagnostics routed into this field, each with its steps still to walk: one
|
|
26
|
-
*
|
|
27
|
-
* field's foot. */
|
|
29
|
+
* naming a drawn item draws under it, any other at the field's foot. */
|
|
28
30
|
diagnostics?: readonly DeepDiagnostic[];
|
|
29
31
|
}
|
|
30
32
|
declare const MatrixField: import("svelte").Component<Props, {
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
numbers, and `1.000` as a whole one.
|
|
30
30
|
-->
|
|
31
31
|
<script lang="ts">
|
|
32
|
-
import { syncedLocal } from './synced.svelte.js';
|
|
33
32
|
import './controls.css';
|
|
34
33
|
|
|
35
34
|
interface Props {
|
|
@@ -53,11 +52,8 @@
|
|
|
53
52
|
}
|
|
54
53
|
let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// back in (see `syncedLocal`).
|
|
59
|
-
const local = syncedLocal(() => (value != null ? String(value) : (fallback ?? '')));
|
|
60
|
-
const defaulted = $derived(value == null && !!fallback && local.value === fallback);
|
|
55
|
+
let local = $derived(value != null ? String(value) : (fallback ?? ''));
|
|
56
|
+
const defaulted = $derived(value == null && !!fallback && local === fallback);
|
|
61
57
|
|
|
62
58
|
const partial = $derived(
|
|
63
59
|
integer ? /^\s*[+-]?\d*\s*$/ : /^\s*[+-]?\d*\.?\d*(?:[eE][+-]?\d*)?\s*$/
|
|
@@ -77,7 +73,7 @@
|
|
|
77
73
|
// `insertText` lands on the undo stack and fires `input`; `setRangeText` does neither.
|
|
78
74
|
if (document.execCommand?.('insertText', false, data)) return;
|
|
79
75
|
el.setRangeText(data, from, to, 'end');
|
|
80
|
-
local
|
|
76
|
+
local = el.value;
|
|
81
77
|
}
|
|
82
78
|
|
|
83
79
|
// Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
|
|
@@ -89,7 +85,7 @@
|
|
|
89
85
|
const entry = raw.trim();
|
|
90
86
|
if (entry === '') {
|
|
91
87
|
onCommit(undefined);
|
|
92
|
-
local
|
|
88
|
+
local = el.value = fallback ?? '';
|
|
93
89
|
return;
|
|
94
90
|
}
|
|
95
91
|
// Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
|
|
@@ -102,7 +98,7 @@
|
|
|
102
98
|
class="qm-input qm-focus-ring"
|
|
103
99
|
type="text"
|
|
104
100
|
inputmode={integer ? 'numeric' : 'decimal'}
|
|
105
|
-
value={local
|
|
101
|
+
value={local}
|
|
106
102
|
{id}
|
|
107
103
|
{placeholder}
|
|
108
104
|
data-default={defaulted ? '' : undefined}
|
|
@@ -110,7 +106,7 @@
|
|
|
110
106
|
aria-describedby={describedBy}
|
|
111
107
|
onbeforeinput={filter}
|
|
112
108
|
oninput={(e) => {
|
|
113
|
-
local
|
|
109
|
+
local = (e.currentTarget as HTMLInputElement).value;
|
|
114
110
|
}}
|
|
115
111
|
onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
|
|
116
112
|
/>
|