@quillmark/svelte 0.0.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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One card block: the header (composable cards only: an inline-editable title over
|
|
3
|
+
a full-width rename region, and the reorder/
|
|
4
|
+
delete controls), the grouped field list packed by `ui.group`/`ui.compact`, and
|
|
5
|
+
the body prose leaf. `main` is headerless with no controls. Every prose leaf
|
|
6
|
+
takes a parent-built LIVE address so a card reorder re-targets its commits
|
|
7
|
+
without a remount.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { wording } from './strings.js';
|
|
11
|
+
|
|
12
|
+
// The surface's words, ambient from the editor root; the package's English
|
|
13
|
+
// off-tree, so this component renders standalone too.
|
|
14
|
+
const t = wording();
|
|
15
|
+
import { untrack } from 'svelte';
|
|
16
|
+
import Icon from './icons/Icon.svelte';
|
|
17
|
+
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
18
|
+
import type { EditorErrorHandler } from '../core/errors.js';
|
|
19
|
+
import type { FieldController } from '../core/codec/index.js';
|
|
20
|
+
import type { CardModel, FieldModel } from './structure.js';
|
|
21
|
+
import { placeFields, humanize, initialExpandedGroup } from './structure.js';
|
|
22
|
+
import type { FieldDomIds } from './domid.js';
|
|
23
|
+
import Field from './Field.svelte';
|
|
24
|
+
import ProseField from './ProseField.svelte';
|
|
25
|
+
import CardControls from './CardControls.svelte';
|
|
26
|
+
import DiagnosticList from './DiagnosticList.svelte';
|
|
27
|
+
|
|
28
|
+
/** The per-card operation bundle the VisualEditor builds (all resolve id→index lazily). */
|
|
29
|
+
interface CardOps {
|
|
30
|
+
makeAddr: (field?: string) => Addr;
|
|
31
|
+
leafKey: (field?: string) => string;
|
|
32
|
+
domIds: (field?: string) => FieldDomIds;
|
|
33
|
+
panelId: (group: string) => string;
|
|
34
|
+
commit: (name: string, value: unknown) => void;
|
|
35
|
+
move: (dir: -1 | 1) => void;
|
|
36
|
+
remove: () => void;
|
|
37
|
+
retype: (kind: string) => void;
|
|
38
|
+
rename: (title: string) => void;
|
|
39
|
+
diagFor: (field?: string) => Diagnostic[] | undefined;
|
|
40
|
+
/** Consumer enum-option policy for a field; true when no hook set. */
|
|
41
|
+
enumAllowed: (field: string, value: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Props {
|
|
45
|
+
card: CardModel;
|
|
46
|
+
doc: Document;
|
|
47
|
+
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
48
|
+
quill: Quill;
|
|
49
|
+
index: number;
|
|
50
|
+
isFirst: boolean;
|
|
51
|
+
isLast: boolean;
|
|
52
|
+
kinds: string[];
|
|
53
|
+
ops: CardOps;
|
|
54
|
+
onFocus?: (addr: Addr) => void;
|
|
55
|
+
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
56
|
+
onChange?: (addr: Addr) => void;
|
|
57
|
+
onError?: EditorErrorHandler;
|
|
58
|
+
register?: (key: string, controller: FieldController) => void;
|
|
59
|
+
unregister?: (key: string) => void;
|
|
60
|
+
}
|
|
61
|
+
let {
|
|
62
|
+
card,
|
|
63
|
+
doc,
|
|
64
|
+
quill,
|
|
65
|
+
index,
|
|
66
|
+
isFirst,
|
|
67
|
+
isLast,
|
|
68
|
+
kinds,
|
|
69
|
+
ops,
|
|
70
|
+
onFocus,
|
|
71
|
+
onCaretMove,
|
|
72
|
+
onChange,
|
|
73
|
+
onError,
|
|
74
|
+
register,
|
|
75
|
+
unregister
|
|
76
|
+
}: Props = $props();
|
|
77
|
+
|
|
78
|
+
// Local title reconcile (external change only), like the scalar controls.
|
|
79
|
+
// svelte-ignore state_referenced_locally
|
|
80
|
+
let localTitle = $state(card.titleOverride);
|
|
81
|
+
$effect(() => {
|
|
82
|
+
const incoming = card.titleOverride;
|
|
83
|
+
untrack(() => {
|
|
84
|
+
if (incoming !== localTitle) localTitle = incoming;
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Inline-editable title: select-all on entry so a title reads as
|
|
89
|
+
// text you replace, and Enter/Escape as commit/revert. Rename stays LIVE on
|
|
90
|
+
// input: `titleAtFocus` is the pre-edit value Escape rolls back to.
|
|
91
|
+
let titleAtFocus = '';
|
|
92
|
+
function onTitleFocus(e: FocusEvent): void {
|
|
93
|
+
titleAtFocus = localTitle;
|
|
94
|
+
(e.currentTarget as HTMLInputElement).select();
|
|
95
|
+
}
|
|
96
|
+
// A mouse press on an UNFOCUSED title would place a caret on mouseup, collapsing
|
|
97
|
+
// the focus-time select-all. Take focus manually and suppress that caret so a
|
|
98
|
+
// click-to-enter selects all too; a press while already focused stays normal, so
|
|
99
|
+
// clicking mid-title to place the caret still works.
|
|
100
|
+
function onTitleMousedown(e: MouseEvent): void {
|
|
101
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
102
|
+
if (document.activeElement !== el) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
el.focus();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// The rename region extends the title's hit area to the header's free width.
|
|
108
|
+
// A press on that empty space focuses the input (which selects all, the
|
|
109
|
+
// same click-to-enter the title itself gets) and preventDefault keeps the press
|
|
110
|
+
// from starting a text selection on the wrapper. A press ON the input is left to
|
|
111
|
+
// `onTitleMousedown`, which owns the already-focused caret-placement case.
|
|
112
|
+
function onRenameMousedown(e: MouseEvent): void {
|
|
113
|
+
const input = (e.currentTarget as HTMLElement).querySelector('input');
|
|
114
|
+
if (!input || e.target === input) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
input.focus();
|
|
117
|
+
}
|
|
118
|
+
function onTitleKeydown(e: KeyboardEvent): void {
|
|
119
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
120
|
+
if (e.key === 'Enter') {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
el.blur(); // commit: the live value already persisted on input
|
|
123
|
+
} else if (e.key === 'Escape') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (localTitle !== titleAtFocus) {
|
|
126
|
+
localTitle = titleAtFocus;
|
|
127
|
+
ops.rename(titleAtFocus); // roll back the live edits
|
|
128
|
+
}
|
|
129
|
+
el.blur();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Group accordion. Ungrouped fields (`group == null`) render above,
|
|
134
|
+
// always visible; grouped sections collapse into a one-open-at-a-time accordion.
|
|
135
|
+
const ungrouped = $derived(card.sections.filter((s) => s.group == null));
|
|
136
|
+
const grouped = $derived(card.sections.filter((s) => s.group != null));
|
|
137
|
+
|
|
138
|
+
// Whether there is a metadata block to bracket at all. A schema that
|
|
139
|
+
// declares no fields still gets a body (`bodyEnabled` defaults true), and a bracket
|
|
140
|
+
// around nothing is worse than none: with no content between them the two
|
|
141
|
+
// horizontals land on one y and paint as a stroke of twice the width, which is the
|
|
142
|
+
// one thing a figure claiming one width cannot do.
|
|
143
|
+
const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
|
|
144
|
+
|
|
145
|
+
// Ephemeral session state: the open group's id (`null` = all collapsed). Seeded
|
|
146
|
+
// ONCE from the card's shape (structure.initialExpandedGroup); Card is keyed by
|
|
147
|
+
// stable id, so this survives the VisualEditor re-derive that reassigns `card`
|
|
148
|
+
// and resets only on a remount (reload). Not reconciled to later section changes:
|
|
149
|
+
// a retype is the one reshape, and the open group stays where it still exists.
|
|
150
|
+
// svelte-ignore state_referenced_locally
|
|
151
|
+
let expanded = $state<string | null>(initialExpandedGroup(card.sections));
|
|
152
|
+
// The accordion's two elements per group, by group id. A closing panel goes
|
|
153
|
+
// `inert` with whatever it held still focused, and `inert` does not say where
|
|
154
|
+
// focus should go: the browser drops it on the body, losing the user's place in
|
|
155
|
+
// the card. So the close hands focus to the header, which is what reopens the
|
|
156
|
+
// section.
|
|
157
|
+
let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
|
|
158
|
+
let panels = $state<Record<string, HTMLElement | undefined>>({});
|
|
159
|
+
/**
|
|
160
|
+
* Move to `next` (`null` = all closed), evacuating focus from the group this
|
|
161
|
+
* closes. Every write to `expanded` goes through here: a group also closes when
|
|
162
|
+
* ANOTHER opens, which is not a gesture the user aimed at the closing section.
|
|
163
|
+
*/
|
|
164
|
+
function setExpanded(next: string | null): void {
|
|
165
|
+
const closing = expanded;
|
|
166
|
+
if (closing !== null && closing !== next && panels[closing]?.contains(document.activeElement))
|
|
167
|
+
headers[closing]?.focus();
|
|
168
|
+
expanded = next;
|
|
169
|
+
}
|
|
170
|
+
function toggleGroup(group: string): void {
|
|
171
|
+
setExpanded(expanded === group ? null : group);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Open the group holding leaf `key`, if this card has one. A collapsed panel is
|
|
176
|
+
* clipped to zero height and `inert` rather than unmounted, so a caret placed
|
|
177
|
+
* inside it does not land at all and the arrival wash goes unseen.
|
|
178
|
+
* `VisualEditor.setCaret` calls this on every card and then waits a flush before
|
|
179
|
+
* landing: `inert` clears when the panel renders open, not when `expanded` is
|
|
180
|
+
* assigned. The card keeps owning `expanded`, and a card that does not hold the
|
|
181
|
+
* key does nothing.
|
|
182
|
+
*
|
|
183
|
+
* A call, not a prop: a reveal is an event, and modelling it as state needs a
|
|
184
|
+
* fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
|
|
185
|
+
* re-derive from reopening the accordion under the user.
|
|
186
|
+
*/
|
|
187
|
+
export function revealLeaf(key: string): void {
|
|
188
|
+
const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
|
|
189
|
+
if (section?.group) setExpanded(section.group);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let el = $state<HTMLElement | undefined>(undefined);
|
|
193
|
+
/**
|
|
194
|
+
* Bring this card into view after the structure mutation that placed it:
|
|
195
|
+
* `center` for an insert, `nearest` for a reorder that only needs to stay
|
|
196
|
+
* on screen. `scrollIntoView` walks to its own scroll ancestor, so this is
|
|
197
|
+
* indifferent to whether the package or the consumer owns the scroll container.
|
|
198
|
+
*
|
|
199
|
+
* The reduced-motion guard is JS rather than the CSS the accordion uses: a
|
|
200
|
+
* scroll's behaviour is an argument, not a transition a media query can cancel.
|
|
201
|
+
*/
|
|
202
|
+
export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
|
|
203
|
+
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
204
|
+
el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block });
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<!-- `qm-main` is a structural marker and carries no fill: a tone here would bet on
|
|
209
|
+
what the consumer put behind the column, and structure already says which card
|
|
210
|
+
this is. -->
|
|
211
|
+
<section
|
|
212
|
+
bind:this={el}
|
|
213
|
+
class="qm-card"
|
|
214
|
+
class:qm-main={card.isMain}
|
|
215
|
+
class:qm-unschemable={card.unschemable}
|
|
216
|
+
>
|
|
217
|
+
{#if card.unschemable}
|
|
218
|
+
{@render recoveryShell()}
|
|
219
|
+
{:else}
|
|
220
|
+
{#if !card.isMain}
|
|
221
|
+
<header class="qm-card-header">
|
|
222
|
+
<!-- The rename target is the header's whole free width, not the title's text
|
|
223
|
+
box. The autosize keeps that box exactly as wide as its
|
|
224
|
+
text; this wrapper makes the rest of the row enter the edit too. -->
|
|
225
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
226
|
+
<div class="qm-card-rename" onmousedown={onRenameMousedown}>
|
|
227
|
+
<!-- Autosize: the sizer span's ::after mirrors the text and dictates the grid
|
|
228
|
+
cell width, so the overlaid input grows with content and reads as text,
|
|
229
|
+
not a persistent box. `data-value` falls back to the
|
|
230
|
+
placeholder so an empty title still reserves its resolved-title width. -->
|
|
231
|
+
<span class="qm-card-title-sizer" data-value={localTitle || card.titlePlaceholder}>
|
|
232
|
+
<!-- `qm-focus-ring` is what the box used to do: it was this control's ONLY
|
|
233
|
+
focus indicator, and the ink step that replaced it cannot be one, since
|
|
234
|
+
hover already takes that step. `:focus-visible`, so a press enters the
|
|
235
|
+
edit unringed while a tab into it draws one. -->
|
|
236
|
+
<input
|
|
237
|
+
class="qm-card-title qm-focus-ring"
|
|
238
|
+
value={localTitle}
|
|
239
|
+
placeholder={card.titlePlaceholder}
|
|
240
|
+
aria-label={t.strings.cardTitle}
|
|
241
|
+
size="1"
|
|
242
|
+
onmousedown={onTitleMousedown}
|
|
243
|
+
onfocus={onTitleFocus}
|
|
244
|
+
onkeydown={onTitleKeydown}
|
|
245
|
+
oninput={(e) => {
|
|
246
|
+
localTitle = (e.currentTarget as HTMLInputElement).value;
|
|
247
|
+
ops.rename(localTitle);
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
</span>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="qm-card-header-right">
|
|
253
|
+
<CardControls
|
|
254
|
+
{isFirst}
|
|
255
|
+
{isLast}
|
|
256
|
+
onMoveUp={() => ops.move(-1)}
|
|
257
|
+
onMoveDown={() => ops.move(1)}
|
|
258
|
+
onDelete={() => ops.remove()}
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
</header>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
264
|
+
<div class="qm-card-body">
|
|
265
|
+
<!-- The metadata block, and the thing the bracket brackets: every
|
|
266
|
+
field the card has, ungrouped and grouped alike, inside one element whose
|
|
267
|
+
top and bottom EDGES are the bracket's two horizontals. Anchoring them here
|
|
268
|
+
rather than on the header and on the accordion is what lets an open
|
|
269
|
+
section's vertical actually reach them: a rule drawn in the card body's
|
|
270
|
+
gap instead would stand clear of where that vertical stops, and the corner
|
|
271
|
+
it exists to close would miss. It also survives the shapes the accordion
|
|
272
|
+
does not have: a card with no groups still terminates above its body,
|
|
273
|
+
because the rule belongs to the fields, not to the sections.
|
|
274
|
+
|
|
275
|
+
Rendered only when there ARE fields: the bracket is a statement about a
|
|
276
|
+
block, so with no block there is nothing to say, and an empty wrapper
|
|
277
|
+
between two rules is what collapses them onto each other. -->
|
|
278
|
+
{#if hasMeta}
|
|
279
|
+
<div
|
|
280
|
+
class="qm-card-meta"
|
|
281
|
+
class:qm-meta-top={!card.isMain}
|
|
282
|
+
class:qm-meta-bottom={card.hasBody}
|
|
283
|
+
>
|
|
284
|
+
<!-- Ungrouped fields render above the accordion, always visible:
|
|
285
|
+
a label-less section has no header to toggle, and these read as the card's
|
|
286
|
+
primary fields. -->
|
|
287
|
+
{#each ungrouped as section (section.group ?? '_ungrouped')}
|
|
288
|
+
<div class="qm-section">
|
|
289
|
+
{@render sectionFields(section.fields)}
|
|
290
|
+
</div>
|
|
291
|
+
{/each}
|
|
292
|
+
|
|
293
|
+
<!-- Group accordion: each `ui.group` is a collapsible section,
|
|
294
|
+
one open at a time. The header toggles; the panel slides via a
|
|
295
|
+
0fr↔1fr grid row (the `slow` duration rung). The sections share ONE wrapper
|
|
296
|
+
with no gap of its own: each header's symmetric padding is the whole
|
|
297
|
+
inter-group rhythm. The card body's gap still separates this
|
|
298
|
+
block from the ungrouped fields and the body leaf.
|
|
299
|
+
|
|
300
|
+
Rendered only when a card HAS groups, so an empty accordion is not the
|
|
301
|
+
bracket's last child: the bottom rule's inset belongs to whichever block
|
|
302
|
+
actually abuts it, and a zero-height wrapper standing there would take it
|
|
303
|
+
from the ungrouped fields that do. -->
|
|
304
|
+
{#if grouped.length}
|
|
305
|
+
<div class="qm-groups">
|
|
306
|
+
{#each grouped as section (section.group)}
|
|
307
|
+
{@const group = section.group as string}
|
|
308
|
+
{@const isOpen = expanded === group}
|
|
309
|
+
{@const panelId = ops.panelId(group)}
|
|
310
|
+
<div class="qm-group" class:qm-open={isOpen}>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
class="qm-group-header"
|
|
314
|
+
bind:this={headers[group]}
|
|
315
|
+
aria-expanded={isOpen}
|
|
316
|
+
aria-controls={panelId}
|
|
317
|
+
onclick={() => toggleGroup(group)}
|
|
318
|
+
>
|
|
319
|
+
<Icon name="chevron-right" class="qm-group-chevron" size={14} />{section.label}
|
|
320
|
+
</button>
|
|
321
|
+
<!-- `inert` is what makes closed mean CLOSED: the panel is clipped, not
|
|
322
|
+
unmounted, so without it every field
|
|
323
|
+
in a hidden section keeps its place in the tab order and in the a11y
|
|
324
|
+
tree, under a header announcing `aria-expanded="false"`. Paint is
|
|
325
|
+
untouched, so the 0fr↔1fr slide is the same slide. -->
|
|
326
|
+
<div
|
|
327
|
+
class="qm-group-panel"
|
|
328
|
+
id={panelId}
|
|
329
|
+
inert={!isOpen}
|
|
330
|
+
bind:this={panels[group]}
|
|
331
|
+
>
|
|
332
|
+
<div class="qm-group-panel-inner">
|
|
333
|
+
{@render sectionFields(section.fields)}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
{/each}
|
|
338
|
+
</div>
|
|
339
|
+
{/if}
|
|
340
|
+
</div>
|
|
341
|
+
{/if}
|
|
342
|
+
|
|
343
|
+
<!-- The body: no label and no box. "Body" names the surface the
|
|
344
|
+
card is printed on, so a label restates it; and the accessible name
|
|
345
|
+
survives on the leaf itself, so the region
|
|
346
|
+
is still announced. Unframed is the point rather than a saving: the body is
|
|
347
|
+
the ONLY surface in the card without an edge, which is what makes it read
|
|
348
|
+
as paper instead of as one more field, and the bracket's bottom rule
|
|
349
|
+
already does the separating a box was doing. -->
|
|
350
|
+
{#if card.hasBody}
|
|
351
|
+
<div class="qm-body-leaf">
|
|
352
|
+
<ProseField
|
|
353
|
+
{doc}
|
|
354
|
+
{quill}
|
|
355
|
+
addr={ops.makeAddr(undefined)}
|
|
356
|
+
unframed
|
|
357
|
+
label="Body"
|
|
358
|
+
placeholder={card.bodyGhost}
|
|
359
|
+
leafKey={ops.leafKey(undefined)}
|
|
360
|
+
{onFocus}
|
|
361
|
+
{onCaretMove}
|
|
362
|
+
{onChange}
|
|
363
|
+
{onError}
|
|
364
|
+
{register}
|
|
365
|
+
{unregister}
|
|
366
|
+
/>
|
|
367
|
+
<DiagnosticList diagnostics={ops.diagFor(undefined)} />
|
|
368
|
+
</div>
|
|
369
|
+
{/if}
|
|
370
|
+
</div>
|
|
371
|
+
{/if}
|
|
372
|
+
</section>
|
|
373
|
+
|
|
374
|
+
<!-- A section's fields as ONE grid: shared by the ungrouped block and the accordion
|
|
375
|
+
panels so both render a group's fields identically. Rows are the
|
|
376
|
+
grid's business: fields carry a span and auto-place, so nothing here re-derives
|
|
377
|
+
structure on resize and a prose leaf's key never moves. -->
|
|
378
|
+
{#snippet sectionFields(fields: FieldModel[])}
|
|
379
|
+
<div class="qm-fields">
|
|
380
|
+
{#each placeFields(fields) as { field: f, span } (f.name)}
|
|
381
|
+
<Field
|
|
382
|
+
field={f}
|
|
383
|
+
{span}
|
|
384
|
+
value={card.values[f.name]}
|
|
385
|
+
provenance={card.provenance[f.name]}
|
|
386
|
+
{doc}
|
|
387
|
+
{quill}
|
|
388
|
+
proseAddr={ops.makeAddr(f.name)}
|
|
389
|
+
leafKey={ops.leafKey(f.name)}
|
|
390
|
+
domIds={ops.domIds(f.name)}
|
|
391
|
+
onCommitScalar={(v) => ops.commit(f.name, v)}
|
|
392
|
+
optionAllowed={(v) => ops.enumAllowed(f.name, v)}
|
|
393
|
+
{onFocus}
|
|
394
|
+
{onCaretMove}
|
|
395
|
+
{onChange}
|
|
396
|
+
{onError}
|
|
397
|
+
{register}
|
|
398
|
+
{unregister}
|
|
399
|
+
diagnostics={ops.diagFor(f.name)}
|
|
400
|
+
/>
|
|
401
|
+
{/each}
|
|
402
|
+
</div>
|
|
403
|
+
{/snippet}
|
|
404
|
+
|
|
405
|
+
<!-- Recovery shell for an un-schemable card: a card whose `kind` has no
|
|
406
|
+
schema (foreign kind, or a schema declaring no `card_kinds`). It stays VISIBLE and
|
|
407
|
+
REMOVABLE (its fields/body/`$ext` remain in the Document) so it is never a data
|
|
408
|
+
trap. Retyping to a declared kind projects it against that kind on the next
|
|
409
|
+
derive: `setCardKind` swaps the kind and leaves payload and body untouched, so
|
|
410
|
+
no field is dropped; with no kinds to offer, delete is the only exit. -->
|
|
411
|
+
{#snippet recoveryShell()}
|
|
412
|
+
<header class="qm-card-header">
|
|
413
|
+
<span class="qm-card-title-static">{humanize(card.kind)}</span>
|
|
414
|
+
<div class="qm-card-header-right">
|
|
415
|
+
<CardControls
|
|
416
|
+
{isFirst}
|
|
417
|
+
{isLast}
|
|
418
|
+
onMoveUp={() => ops.move(-1)}
|
|
419
|
+
onMoveDown={() => ops.move(1)}
|
|
420
|
+
onDelete={() => ops.remove()}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
</header>
|
|
424
|
+
<div class="qm-card-recovery">
|
|
425
|
+
<p class="qm-recovery-note">
|
|
426
|
+
{t.strings.unknownKind(card.kind)}
|
|
427
|
+
</p>
|
|
428
|
+
{#if kinds.length}
|
|
429
|
+
<label class="qm-recovery-retype">
|
|
430
|
+
{t.strings.retypeLabel}
|
|
431
|
+
<select
|
|
432
|
+
onchange={(e) => {
|
|
433
|
+
const el = e.currentTarget as HTMLSelectElement;
|
|
434
|
+
if (el.value) ops.retype(el.value);
|
|
435
|
+
}}
|
|
436
|
+
>
|
|
437
|
+
<option value="" disabled selected>{t.strings.retypePlaceholder}</option>
|
|
438
|
+
{@render kindOptions()}
|
|
439
|
+
</select>
|
|
440
|
+
</label>
|
|
441
|
+
{:else}
|
|
442
|
+
<p class="qm-recovery-note qm-recovery-muted">
|
|
443
|
+
{t.strings.noCardKinds}
|
|
444
|
+
</p>
|
|
445
|
+
{/if}
|
|
446
|
+
</div>
|
|
447
|
+
{/snippet}
|
|
448
|
+
|
|
449
|
+
<!-- The declared card kinds as `<option>`s for the recovery shell's retype,
|
|
450
|
+
the one place a card's kind changes after insert. -->
|
|
451
|
+
{#snippet kindOptions()}
|
|
452
|
+
{#each kinds as k (k)}
|
|
453
|
+
<option value={k}>{humanize(k)}</option>
|
|
454
|
+
{/each}
|
|
455
|
+
{/snippet}
|
|
456
|
+
|
|
457
|
+
<style>
|
|
458
|
+
.qm-card {
|
|
459
|
+
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
460
|
+
border-radius: var(--_qm-radius);
|
|
461
|
+
/* Uniform inset on every side: a body-shown and a
|
|
462
|
+
body-hidden card stay symmetric, every left edge on one gutter. The SAME rung
|
|
463
|
+
as the gap between the card's stacked regions below: the card's inset and its
|
|
464
|
+
internal rhythm are one number, so what separates the header from the fields is
|
|
465
|
+
what separates the fields from the card's edge. */
|
|
466
|
+
padding: var(--_qm-space-3);
|
|
467
|
+
background: var(--_qm-surface-raised);
|
|
468
|
+
display: flex;
|
|
469
|
+
flex-direction: column;
|
|
470
|
+
gap: var(--_qm-space-3);
|
|
471
|
+
}
|
|
472
|
+
.qm-card-header {
|
|
473
|
+
display: flex;
|
|
474
|
+
align-items: center;
|
|
475
|
+
justify-content: space-between;
|
|
476
|
+
gap: var(--_qm-space-2);
|
|
477
|
+
}
|
|
478
|
+
/* The header's other end, hung so the row is balanced: the title's ink sits on the
|
|
479
|
+
card's gutter, and this puts the control glyphs on the opposite one. What stands
|
|
480
|
+
between a glyph and the region's edge is the tap target's own centring — half the
|
|
481
|
+
difference between `--_qm-tap-min` and the glyph — which is a target, not a
|
|
482
|
+
rhythm, and so is exactly what a gutter should not be measured from. Both ends
|
|
483
|
+
therefore hang by the chrome they carry and by nothing else; the title's is now
|
|
484
|
+
zero, and this one is the arithmetic of two rungs rather than a rung of its own,
|
|
485
|
+
because the amount is off the space scale (5px at the shipped dials) and a scale
|
|
486
|
+
that could express it would be a scale with a step nothing else uses.
|
|
487
|
+
|
|
488
|
+
It is the fill, not the box, that overhangs the gutter here: an icon button rests
|
|
489
|
+
unboxed and fills only under the pointer, so the
|
|
490
|
+
thing crossing the line is a transient target wash, not an edge the card claims. */
|
|
491
|
+
.qm-card-header-right {
|
|
492
|
+
display: flex;
|
|
493
|
+
align-items: center;
|
|
494
|
+
gap: var(--_qm-space-2);
|
|
495
|
+
margin-right: calc(-1 * (var(--_qm-tap-min) - var(--_qm-glyph-control)) / 2);
|
|
496
|
+
}
|
|
497
|
+
/* Reveal the reorder chevrons while the pointer or the caret is in the card
|
|
498
|
+
(CardControls owns the default hidden state). Focus is read off the CARD, so a
|
|
499
|
+
caret in any leaf, the title, or a chevron itself holds the reveal: hover
|
|
500
|
+
alone would strand the pair on keyboard and touch. This is the card's whole
|
|
501
|
+
active treatment; nothing marks the section itself. */
|
|
502
|
+
.qm-card:hover :global(.qm-card-reorder),
|
|
503
|
+
.qm-card:focus-within :global(.qm-card-reorder) {
|
|
504
|
+
opacity: 1;
|
|
505
|
+
}
|
|
506
|
+
/* The rename hit region: the header's free width, full height, so a
|
|
507
|
+
press anywhere left of the controls enters the title edit. `min-width: 0` lets
|
|
508
|
+
it shrink past the title's intrinsic width; `align-self: stretch` beats the
|
|
509
|
+
header's `align-items: center` so the region is the row's whole height rather
|
|
510
|
+
than the input's.
|
|
511
|
+
|
|
512
|
+
It hangs by NOTHING, which is the whole of what the title carrying no box buys:
|
|
513
|
+
the first character lands on the card's gutter with the field list, the
|
|
514
|
+
bracket's rules and the body's first character, because nothing stands between
|
|
515
|
+
the region's edge and the glyph. The stretch is also what
|
|
516
|
+
keeps the target legal with the padding gone: WCAG 2.5.8's floor is the ROW's,
|
|
517
|
+
which the controls hold at `--_qm-tap-min`, so the input never carried it. */
|
|
518
|
+
.qm-card-rename {
|
|
519
|
+
flex: 1;
|
|
520
|
+
min-width: 0;
|
|
521
|
+
align-self: stretch;
|
|
522
|
+
display: flex;
|
|
523
|
+
align-items: center;
|
|
524
|
+
cursor: text;
|
|
525
|
+
}
|
|
526
|
+
/* Autosize sizer: an inline-grid whose ::after mirrors the text
|
|
527
|
+
into the single cell, so the overlaid input tracks its content width. Bounded
|
|
528
|
+
to the rename region's width (`min-width: 0` + `max-width: 100%`), which the
|
|
529
|
+
header's space-between keeps clear of the controls. The input inherits the type
|
|
530
|
+
tokens (`font: inherit`, leading included) so the mirror and the input measure
|
|
531
|
+
alike; which is why the leading rung is declared here and not on the input. */
|
|
532
|
+
.qm-card-title-sizer {
|
|
533
|
+
display: inline-grid;
|
|
534
|
+
align-items: center;
|
|
535
|
+
min-width: 0;
|
|
536
|
+
max-width: 100%;
|
|
537
|
+
font-size: var(--_qm-text-title);
|
|
538
|
+
font-weight: var(--_qm-weight-label);
|
|
539
|
+
line-height: var(--_qm-leading-tight);
|
|
540
|
+
font-family: inherit;
|
|
541
|
+
}
|
|
542
|
+
/* The mirror has no box model to match, because the input has none: with the
|
|
543
|
+
padding and the hairline gone from both, the two measure alike by carrying the
|
|
544
|
+
same nothing. */
|
|
545
|
+
.qm-card-title-sizer::after {
|
|
546
|
+
content: attr(data-value) ' ';
|
|
547
|
+
grid-area: 1 / 1;
|
|
548
|
+
visibility: hidden;
|
|
549
|
+
white-space: pre;
|
|
550
|
+
min-width: 2ch;
|
|
551
|
+
}
|
|
552
|
+
/* The title draws NO box, in any state. A box is
|
|
553
|
+
what says "type here", and a card title is already the one line of a card that
|
|
554
|
+
reads as its name; the region's `cursor: text` and the caret that lands on a
|
|
555
|
+
press say the rest. A hover-summoned box was also the inverse of the recipe every
|
|
556
|
+
other control follows, where the box rests drawn and hover changes nothing, and
|
|
557
|
+
it filled to `--_qm-surface` — the page rung,
|
|
558
|
+
BELOW the card it sits on, so the one hover on this surface that receded instead
|
|
559
|
+
of lifting.
|
|
560
|
+
|
|
561
|
+
What replaces it is an ink step, the same cue the group header takes for the same
|
|
562
|
+
reason: a wide borderless target drawing no box has no other way to answer the
|
|
563
|
+
pointer. Rest is `--_qm-ink-label`, which is where the recovery shell's static
|
|
564
|
+
title already sits, so the editable title and its unschemable twin finally agree
|
|
565
|
+
on tone as well as on the three type rungs they share. */
|
|
566
|
+
.qm-card-title {
|
|
567
|
+
grid-area: 1 / 1;
|
|
568
|
+
width: 100%;
|
|
569
|
+
min-width: 0;
|
|
570
|
+
font: inherit;
|
|
571
|
+
/* `font: inherit` does not carry colour: an actual form control, unlike this
|
|
572
|
+
rule's neighbours, defaults to the UA's own text colour rather than the
|
|
573
|
+
header's ink without this line. */
|
|
574
|
+
color: var(--_qm-ink-label);
|
|
575
|
+
border: none;
|
|
576
|
+
padding: 0;
|
|
577
|
+
background: transparent;
|
|
578
|
+
transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
579
|
+
}
|
|
580
|
+
.qm-card-title:hover,
|
|
581
|
+
.qm-card-title:focus {
|
|
582
|
+
color: var(--_qm-ink);
|
|
583
|
+
}
|
|
584
|
+
/* One rhythm for the card's stacked regions and for the metadata block inside it:
|
|
585
|
+
the gap between a field list and the body is the gap between two field lists. */
|
|
586
|
+
.qm-card-body,
|
|
587
|
+
.qm-card-meta {
|
|
588
|
+
display: flex;
|
|
589
|
+
flex-direction: column;
|
|
590
|
+
gap: var(--_qm-space-3);
|
|
591
|
+
}
|
|
592
|
+
/* The metadata bracket: the card's chrome brackets its metadata, and
|
|
593
|
+
the body is what falls outside it. Three strokes at ONE rung: the two
|
|
594
|
+
horizontals here and the open section's vertical below; because a bracket whose
|
|
595
|
+
sides disagree on width or tone reads as three unrelated lines rather than as
|
|
596
|
+
one figure. That sameness is the whole effect; it is why the vertical takes
|
|
597
|
+
`--_qm-border` and not `--_qm-accent`, which would be ornament with the chevron
|
|
598
|
+
and the header's ink step already saying open.
|
|
599
|
+
|
|
600
|
+
Both horizontals are conditional, and each condition is what the stroke means: a
|
|
601
|
+
top rule is the line under a card title, so the headerless `main` has none and
|
|
602
|
+
its bracket starts at the card's own top inset; a bottom rule divides fields from
|
|
603
|
+
body, so a card with no body has nothing to divide. The bracket is therefore
|
|
604
|
+
sometimes two strokes and sometimes one: an open figure by construction, which
|
|
605
|
+
is what distinguishes it from a second box inside the card. */
|
|
606
|
+
.qm-meta-top {
|
|
607
|
+
border-top: var(--_qm-border-width) solid var(--_qm-border);
|
|
608
|
+
}
|
|
609
|
+
.qm-meta-bottom {
|
|
610
|
+
border-bottom: var(--_qm-border-width) solid var(--_qm-border);
|
|
611
|
+
}
|
|
612
|
+
/* No inset between a horizontal and the accordion, deliberately: an open section's
|
|
613
|
+
vertical has to REACH the rule to close a corner against it, and any padding here
|
|
614
|
+
is the distance by which it would miss. The accordion needs none anyway: a group
|
|
615
|
+
header's symmetric padding is the whole inter-group rhythm.
|
|
616
|
+
|
|
617
|
+
A block of FIELDS has no padding of its own, so it takes the inset wherever it
|
|
618
|
+
abuts a rule; three places, since three blocks can: an ungrouped block under the
|
|
619
|
+
top rule, an ungrouped block over the bottom rule (only reachable because the
|
|
620
|
+
accordion's `{#if}` keeps an empty wrapper out of last position), and the last
|
|
621
|
+
section's panel when it is open, whose inset is otherwise zero at the bottom and
|
|
622
|
+
would stand its final row of controls directly on the stroke. The panel's padding
|
|
623
|
+
is inside `.qm-group`, so the vertical still runs the full height to the corner.
|
|
624
|
+
Ungrouped fields always render first, so the top selector needs no qualifier. */
|
|
625
|
+
.qm-meta-top > .qm-section {
|
|
626
|
+
padding-top: var(--_qm-space-3);
|
|
627
|
+
}
|
|
628
|
+
.qm-meta-bottom > .qm-section:last-child,
|
|
629
|
+
.qm-meta-bottom .qm-group:last-child.qm-open .qm-group-panel-inner {
|
|
630
|
+
padding-bottom: var(--_qm-space-3);
|
|
631
|
+
}
|
|
632
|
+
/* The two query containers: an ungrouped section, and a group panel's inner. Each
|
|
633
|
+
is its own, so capacity follows the width the fields actually get: a panel's
|
|
634
|
+
inset makes that narrower than the card.
|
|
635
|
+
|
|
636
|
+
The ACTION COLUMN is minted here and spent one level down, on the field
|
|
637
|
+
(`Field.svelte` holds why it is the field's inset and not this container's). It
|
|
638
|
+
is declared on the query container because both of its consumers hang off one:
|
|
639
|
+
the fields inside, and the capacity ramp below, which has to carry the column in
|
|
640
|
+
its own arithmetic now that the query no longer reads a content box the
|
|
641
|
+
reservation already narrowed. */
|
|
642
|
+
.qm-section,
|
|
643
|
+
.qm-group-panel-inner {
|
|
644
|
+
--action-col: calc(var(--_qm-tap-min) + var(--_qm-space-2));
|
|
645
|
+
container-type: inline-size;
|
|
646
|
+
}
|
|
647
|
+
/* One grid per section. Capacity is the container's, not JavaScript's:
|
|
648
|
+
nothing measures, so there is no observer to loop, no pre-measure pass at the
|
|
649
|
+
wrong capacity, and no re-packing to restructure the DOM under a prose leaf.
|
|
650
|
+
Fields auto-place, which is what keeps a trailing orphan at its column width
|
|
651
|
+
instead of growing to fill the line.
|
|
652
|
+
|
|
653
|
+
Capacity steps 1 → 2 → 4, skipping 3: each rung is the width at which a track
|
|
654
|
+
still clears the comfortable field minimum (2 needs 32rem, 4 needs 65rem), and
|
|
655
|
+
an even capacity is what lets `lone`'s half land on a track boundary. Each rung
|
|
656
|
+
pays for a track's own action column as well as the gutters between tracks, since
|
|
657
|
+
the reservation is per field now: a rung is `n * (220px + action) + gaps`.
|
|
658
|
+
|
|
659
|
+
A row-sharing field spans three implicit row tracks (Field.svelte), so `row-gap`
|
|
660
|
+
here is the gutter BETWEEN field rows; the tighter one inside a field is the
|
|
661
|
+
subgrid's own. The grid takes the container's full width; each field insets its
|
|
662
|
+
own action column inside the track it lands in. */
|
|
663
|
+
.qm-fields {
|
|
664
|
+
--cols: 1;
|
|
665
|
+
--cols-half: 1;
|
|
666
|
+
display: grid;
|
|
667
|
+
grid-template-columns: repeat(var(--cols), 1fr);
|
|
668
|
+
column-gap: var(--_qm-space-2);
|
|
669
|
+
row-gap: var(--_qm-space-2);
|
|
670
|
+
}
|
|
671
|
+
@container (min-width: 32rem) {
|
|
672
|
+
.qm-fields {
|
|
673
|
+
--cols: 2;
|
|
674
|
+
--cols-half: 1;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
@container (min-width: 65rem) {
|
|
678
|
+
.qm-fields {
|
|
679
|
+
--cols: 4;
|
|
680
|
+
--cols-half: 2;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/* Group accordion. The header is a toggle
|
|
684
|
+
at the field-label rung; the panel slides via a 0fr↔1fr grid row so the height
|
|
685
|
+
animates without a magic max-height. */
|
|
686
|
+
/* The wrapper carries NO gap: the headers' own padding is the rhythm (see the
|
|
687
|
+
markup note). */
|
|
688
|
+
.qm-groups,
|
|
689
|
+
.qm-group {
|
|
690
|
+
display: flex;
|
|
691
|
+
flex-direction: column;
|
|
692
|
+
}
|
|
693
|
+
/* The bracket's third stroke, on the SECTION rather than on its panel:
|
|
694
|
+
spanning the header and the content is what gives it a corner to close into at
|
|
695
|
+
the block's edge: a rule that starts below the header has nothing to meet there.
|
|
696
|
+
One rung with the two horizontals, held there by `check:style`'s border-width axis.
|
|
697
|
+
|
|
698
|
+
Drawn transparent when closed rather than absent, so the 1px it occupies is the
|
|
699
|
+
same open or shut and a toggle moves no text. */
|
|
700
|
+
.qm-group {
|
|
701
|
+
border-left: var(--_qm-border-width) solid transparent;
|
|
702
|
+
transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
|
|
703
|
+
}
|
|
704
|
+
.qm-group.qm-open {
|
|
705
|
+
border-left-color: var(--_qm-border);
|
|
706
|
+
}
|
|
707
|
+
/* Vertical padding is symmetric and is the TIGHTEST rung that still clears WCAG
|
|
708
|
+
2.5.8's 24×24 floor over the label rung's line box: the header is the whole row,
|
|
709
|
+
so adjacent labels share one rhythm with no dead strip outside the button, and a
|
|
710
|
+
rung above the floor is a rung spent per section on a card whose scarce axis is
|
|
711
|
+
vertical. Horizontal is one rung left and zero right. Left, because the section's
|
|
712
|
+
vertical runs down this row: at zero the chevron stands on the stroke with only
|
|
713
|
+
the icon box's own bearing between them, which is neither a rung nor a constant:
|
|
714
|
+
the glyph's rotation swaps which bearing faces the stroke, so the clearance moves
|
|
715
|
+
as the section opens. Right stays at zero because the row is the target and an
|
|
716
|
+
inset there is target given back; the left one is inside the button and costs
|
|
717
|
+
none.
|
|
718
|
+
|
|
719
|
+
A button by tag and NEITHER button family by recipe: it reads no
|
|
720
|
+
`--_qm-tap-min` (the padding above already clears the floor) and takes no hover
|
|
721
|
+
fill (an ink step, below); so the type a family would have carried is declared
|
|
722
|
+
here. `font: inherit` because a UA button inherits no face and `--qm-font` would
|
|
723
|
+
stop at this row; then the field-label rung, at the tight leading a single line
|
|
724
|
+
takes. Sentence case at that rung, reused rather than a fifth size minted: a
|
|
725
|
+
section name is structurally a heading, and uppercase costs it twice: the word
|
|
726
|
+
shape a column of them is scanned by, and apparent width, so a long label crowds
|
|
727
|
+
sooner. Size, weight and leading all sit after `font`, which is a shorthand that
|
|
728
|
+
carries `line-height`.
|
|
729
|
+
|
|
730
|
+
On the BUTTON rather than on a span inside it: the row's type is one decision,
|
|
731
|
+
and a wrapper that carries nothing else is indirection between the header and
|
|
732
|
+
its own label. The label is a text run beside the chevron, and flex gives it an
|
|
733
|
+
anonymous item either way. */
|
|
734
|
+
.qm-group-header {
|
|
735
|
+
display: flex;
|
|
736
|
+
align-items: center;
|
|
737
|
+
gap: var(--_qm-space);
|
|
738
|
+
width: 100%;
|
|
739
|
+
border: none;
|
|
740
|
+
background: transparent;
|
|
741
|
+
padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-space);
|
|
742
|
+
font: inherit;
|
|
743
|
+
font-size: var(--_qm-text-label);
|
|
744
|
+
font-weight: var(--_qm-weight-soft);
|
|
745
|
+
line-height: var(--_qm-leading-tight);
|
|
746
|
+
cursor: pointer;
|
|
747
|
+
color: var(--_qm-ink-meta);
|
|
748
|
+
text-align: left;
|
|
749
|
+
transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
750
|
+
}
|
|
751
|
+
.qm-group-header :global(.qm-group-chevron) {
|
|
752
|
+
flex-shrink: 0;
|
|
753
|
+
transition: transform var(--_qm-duration-slow) var(--_qm-ease-reverse);
|
|
754
|
+
}
|
|
755
|
+
/* An ink step, not a hue: the three status hues are the only exits from the
|
|
756
|
+
greyscale, and a section standing open is not a status anyway.
|
|
757
|
+
|
|
758
|
+
HOVER'S ALONE, though the step suits open as well. `color` is one transition
|
|
759
|
+
declaration, and open is the accordion's gesture where hover is its own, so the two
|
|
760
|
+
share a rung and one of them is wrong: an ink step at the slow rung lags the
|
|
761
|
+
pointer, and at the fast one it settles 80ms before the panel it belongs to.
|
|
762
|
+
Hover keeps the property because open has another cue in the
|
|
763
|
+
chevron's rotation and hover has none, a big borderless target drawing no box. */
|
|
764
|
+
.qm-group-header:hover {
|
|
765
|
+
color: var(--_qm-ink);
|
|
766
|
+
}
|
|
767
|
+
.qm-group.qm-open .qm-group-header :global(.qm-group-chevron) {
|
|
768
|
+
transform: rotate(90deg);
|
|
769
|
+
}
|
|
770
|
+
/* Sliding panel: the grid track goes 0fr→1fr; the inner clips at min-height 0.
|
|
771
|
+
Clipping is the whole of what CSS does here: what a closed panel is OUT of is
|
|
772
|
+
`inert`'s to say, on the element. The panel
|
|
773
|
+
draws no rule of its own: the vertical is the section's (`.qm-group`), which is
|
|
774
|
+
what spans the header too. */
|
|
775
|
+
.qm-group-panel {
|
|
776
|
+
display: grid;
|
|
777
|
+
grid-template-rows: 0fr;
|
|
778
|
+
transition: grid-template-rows var(--_qm-duration-slow) var(--_qm-ease-reverse);
|
|
779
|
+
}
|
|
780
|
+
.qm-group.qm-open .qm-group-panel {
|
|
781
|
+
grid-template-rows: 1fr;
|
|
782
|
+
}
|
|
783
|
+
/* The grouped sections' query container (declared above): its left inset is
|
|
784
|
+
exactly what makes a panel's usable width differ from the card's. */
|
|
785
|
+
.qm-group-panel-inner {
|
|
786
|
+
display: flex;
|
|
787
|
+
flex-direction: column;
|
|
788
|
+
gap: var(--_qm-space-2);
|
|
789
|
+
min-height: 0;
|
|
790
|
+
overflow: hidden;
|
|
791
|
+
/* Zero when closed, so the insets arrive with the panel: a `0fr` track collapses
|
|
792
|
+
the CONTENT, not the padding, and a top inset declared here stands under a
|
|
793
|
+
closed header as dead space the header's symmetric padding cannot absorb. The
|
|
794
|
+
panel animates its width along with them, and the capacity ramp reads that
|
|
795
|
+
width: a closed panel is `inert` and unmeasured either way, so what a rung
|
|
796
|
+
crosses mid-slide is a hidden grid. */
|
|
797
|
+
padding: 0;
|
|
798
|
+
transition: padding var(--_qm-duration-slow) var(--_qm-ease-reverse);
|
|
799
|
+
}
|
|
800
|
+
.qm-group.qm-open .qm-group-panel-inner {
|
|
801
|
+
padding: var(--_qm-space-2) 0 0 var(--_qm-space-3);
|
|
802
|
+
}
|
|
803
|
+
.qm-body-leaf {
|
|
804
|
+
display: flex;
|
|
805
|
+
flex-direction: column;
|
|
806
|
+
gap: var(--_qm-space);
|
|
807
|
+
}
|
|
808
|
+
/* Recovery shell: dashed edge marks an un-schemable card. The edge
|
|
809
|
+
alone carries that: fill stays the card rung, content behind it intact. */
|
|
810
|
+
.qm-card.qm-unschemable {
|
|
811
|
+
border-style: dashed;
|
|
812
|
+
}
|
|
813
|
+
/* One typographic role with the editable title above, so it reads the same three
|
|
814
|
+
rungs: including the tight leading a card title takes over the root's reading
|
|
815
|
+
rhythm. */
|
|
816
|
+
.qm-card-title-static {
|
|
817
|
+
font-size: var(--_qm-text-title);
|
|
818
|
+
font-weight: var(--_qm-weight-label);
|
|
819
|
+
line-height: var(--_qm-leading-tight);
|
|
820
|
+
color: var(--_qm-ink-label);
|
|
821
|
+
}
|
|
822
|
+
.qm-card-recovery {
|
|
823
|
+
display: flex;
|
|
824
|
+
flex-direction: column;
|
|
825
|
+
gap: var(--_qm-space-2);
|
|
826
|
+
}
|
|
827
|
+
.qm-recovery-note {
|
|
828
|
+
margin: 0;
|
|
829
|
+
font-size: var(--_qm-text-label);
|
|
830
|
+
color: var(--_qm-ink-label);
|
|
831
|
+
}
|
|
832
|
+
.qm-recovery-muted {
|
|
833
|
+
color: var(--_qm-ink-ghost);
|
|
834
|
+
}
|
|
835
|
+
.qm-recovery-retype {
|
|
836
|
+
display: inline-flex;
|
|
837
|
+
align-items: center;
|
|
838
|
+
gap: var(--_qm-space-2);
|
|
839
|
+
font-size: var(--_qm-text-label);
|
|
840
|
+
color: var(--_qm-ink-label);
|
|
841
|
+
}
|
|
842
|
+
.qm-recovery-retype select {
|
|
843
|
+
font: inherit;
|
|
844
|
+
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
845
|
+
border-radius: var(--_qm-radius-inner);
|
|
846
|
+
background: var(--_qm-surface);
|
|
847
|
+
padding: var(--_qm-space-half) var(--_qm-space-2);
|
|
848
|
+
}
|
|
849
|
+
</style>
|