@weasel-js/labkit 1.7.1 → 1.7.2
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/chrome/index.js +4 -4
- package/dist/{chunk-GAHELLJT.js → chunk-RODIY7HI.js} +7 -7
- package/dist/{chunk-GAHELLJT.js.map → chunk-RODIY7HI.js.map} +1 -1
- package/dist/{chunk-4KSC7NXL.js → chunk-U6UQVGFP.js} +1984 -1892
- package/dist/chunk-U6UQVGFP.js.map +1 -0
- package/dist/chunk-WFALF2X2.js +302 -0
- package/dist/chunk-WFALF2X2.js.map +1 -0
- package/dist/{chunk-JFOJESJF.js → chunk-WWJDPNZZ.js} +22 -22
- package/dist/chunk-WWJDPNZZ.js.map +1 -0
- package/dist/{chunk-CEGFXTBD.js → chunk-XRX5S5NT.js} +4 -4
- package/dist/{chunk-CEGFXTBD.js.map → chunk-XRX5S5NT.js.map} +1 -1
- package/dist/{chunk-FB4MIZOO.js → chunk-XZDCEMEL.js} +6 -6
- package/dist/chunk-XZDCEMEL.js.map +1 -0
- package/dist/controls/index.d.ts +55 -3
- package/dist/controls/index.js +3 -2
- package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +135 -134
- package/dist/index.js.map +1 -1
- package/dist/layers/index.js +1 -1
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.js +3 -3
- package/dist/styles.css +228 -11
- package/package.json +11 -11
- package/src/annotations/capture.browser.test.ts +9 -3
- package/src/canvas/Stage.test.tsx +34 -25
- package/src/chrome/labRegions.test.tsx +37 -26
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -2
- package/src/controls/ControlMatrix.less +245 -0
- package/src/controls/ControlMatrix.stories.less +13 -0
- package/src/controls/ControlMatrix.stories.tsx +135 -0
- package/src/controls/ControlMatrix.test.tsx +206 -0
- package/src/controls/ControlMatrix.tsx +446 -0
- package/src/controls/ControlPanel.tsx +4 -2
- package/src/controls/index.ts +6 -0
- package/src/dragdrop/dragDrop.test.tsx +34 -33
- package/src/index.ts +6 -0
- package/src/lab/Lab.chrome.test.tsx +15 -10
- package/src/lab/Lab.nebula.test.tsx +5 -5
- package/src/lab/Lab.surface.test.tsx +30 -21
- package/src/lab/Lab.test.tsx +75 -74
- package/src/lab/Lab.undock.test.tsx +15 -10
- package/src/lab/LabHeader.test.tsx +15 -14
- package/src/lab/LabPalette.test.tsx +12 -7
- package/src/lab/LabShell.less +1 -2
- package/src/lab/LabZoom.test.tsx +55 -44
- package/src/lab/Workspace.surface.test.tsx +16 -13
- package/src/lab/Workspace.test.tsx +32 -27
- package/src/overview/OverviewMarks.test.tsx +9 -8
- package/src/overview/TrialOverview.test.tsx +18 -17
- package/src/primitives/FpsMeter.less +1 -2
- package/src/primitives/JobProgress.less +1 -2
- package/src/primitives/ScaleIndicator.less +1 -1
- package/src/primitives/StatusBar.less +1 -1
- package/src/specimen/Specimen.less +1 -0
- package/src/styles.less +3 -0
- package/src/trial/Trial.annotations.persist.test.tsx +20 -9
- package/src/trial/Trial.annotations.test.tsx +46 -41
- package/src/trial/Trial.canvas.test.tsx +21 -17
- package/src/trial/Trial.config.test.tsx +34 -27
- package/src/trial/Trial.loupe.test.tsx +27 -18
- package/src/trial/Trial.targets.test.tsx +9 -8
- package/src/trial/Trial.test.tsx +51 -46
- package/src/trial/Trial.trialId.test.tsx +7 -6
- package/dist/chunk-4KSC7NXL.js.map +0 -1
- package/dist/chunk-FB4MIZOO.js.map +0 -1
- package/dist/chunk-JFOJESJF.js.map +0 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { getAlpha01, isBuiltinToolPref, toHex8, withAlpha01 } from '@weasel-js/core';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Focusable,
|
|
5
|
+
isPrefLeaf,
|
|
6
|
+
type PrefLeaf,
|
|
7
|
+
type PropertyControlProps,
|
|
8
|
+
PropertyHelp,
|
|
9
|
+
PropertyList,
|
|
10
|
+
PropertyPanel,
|
|
11
|
+
prefFieldProps,
|
|
12
|
+
type StanceProps,
|
|
13
|
+
Tooltip,
|
|
14
|
+
TooltipTrigger,
|
|
15
|
+
useOverlayPortal,
|
|
16
|
+
} from '@weasel-js/ui';
|
|
17
|
+
import {
|
|
18
|
+
type CSSProperties,
|
|
19
|
+
type DOMAttributes,
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
type ReactElement,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react';
|
|
26
|
+
import { Dialog as RACDialog, Popover as RACPopover } from 'react-aria-components';
|
|
27
|
+
import { auto as autoValue } from '../config/auto';
|
|
28
|
+
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
29
|
+
import type { ResolvedConfig } from '../config/types';
|
|
30
|
+
import { ControlRow } from './ControlPanel';
|
|
31
|
+
import { summarizeValue } from './inDialog';
|
|
32
|
+
|
|
33
|
+
/** One column of a {@link ControlMatrix}: a config group, every one the same shape. */
|
|
34
|
+
export interface ControlMatrixColumn {
|
|
35
|
+
/** Config path prefix of this column's group, e.g. `'looks.window'`. */
|
|
36
|
+
key: string;
|
|
37
|
+
/** Short header shown at narrow width, e.g. `'Win'`. */
|
|
38
|
+
label: string;
|
|
39
|
+
/** Full name, for the header's tooltip and accessible name, e.g. `'Window'`. */
|
|
40
|
+
title?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One row of a {@link ControlMatrix}: a setting every column's group may hold. */
|
|
44
|
+
export interface ControlMatrixRow {
|
|
45
|
+
/** Path of the setting inside each column's group, e.g. `'background'` or `'fill.colorBy'`. */
|
|
46
|
+
key: string;
|
|
47
|
+
/** Row label. Defaults to the leaf's own name from the schema. */
|
|
48
|
+
label?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ControlMatrixProps extends StanceProps {
|
|
52
|
+
/** Heads the matrix. With `title`, `stance` or `tone` given it sits in a
|
|
53
|
+
* `<PropertyPanel>`, as a `ControlPanel` does. */
|
|
54
|
+
title?: ReactNode;
|
|
55
|
+
/** The whole resolved schema. A cell's leaf is looked up at
|
|
56
|
+
* `${column.key}.${row.key}`; a cell with none draws a dash. */
|
|
57
|
+
schema: ResolvedConfig;
|
|
58
|
+
/** The scopes. The first is the fallback the others inherit from: its cells
|
|
59
|
+
* never inherit, and its header is emphasized. */
|
|
60
|
+
columns: readonly ControlMatrixColumn[];
|
|
61
|
+
rows: readonly ControlMatrixRow[];
|
|
62
|
+
/** Values to show, already resolved: an inherited cell's value is the one it
|
|
63
|
+
* inherits. */
|
|
64
|
+
config: Record<string, unknown>;
|
|
65
|
+
/** Full dotted paths whose cells inherit, drawn ghosted. */
|
|
66
|
+
auto: ReadonlySet<string>;
|
|
67
|
+
/** Writes a value, which pins the cell, or labkit's `auto` sentinel, which
|
|
68
|
+
* unpins it. */
|
|
69
|
+
setConfig: (path: string, value: unknown) => void;
|
|
70
|
+
/** Clicking a column header. Without it the headers are plain text. */
|
|
71
|
+
onColumnClick?: (column: ControlMatrixColumn) => void;
|
|
72
|
+
/** What an inherited cell's tooltip says. Defaults to `from <first column>`. */
|
|
73
|
+
inheritHint?: (column: ControlMatrixColumn) => string;
|
|
74
|
+
className?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NO_AUTO: ReadonlySet<string> = new Set();
|
|
78
|
+
const noop = (): void => {};
|
|
79
|
+
|
|
80
|
+
const nameOf = (column: ControlMatrixColumn): string => column.title ?? column.label;
|
|
81
|
+
|
|
82
|
+
/** The open popover: which cell, and the element it hangs off. */
|
|
83
|
+
interface OpenCell {
|
|
84
|
+
path: string;
|
|
85
|
+
leaf: PrefLeaf;
|
|
86
|
+
column: ControlMatrixColumn;
|
|
87
|
+
first: boolean;
|
|
88
|
+
label: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One setting across several config groups of the same shape: settings down
|
|
93
|
+
* the side, scopes across the top, a compact read-only cell at each crossing.
|
|
94
|
+
* A cell inherits from the first column until it is pinned; inherited cells
|
|
95
|
+
* draw ghosted. Click a cell to edit it with the control a `ControlPanel` would
|
|
96
|
+
* draw (a boolean flips and a color opens its picker in place); editing pins,
|
|
97
|
+
* and the popover's Inherit button — or Option/Alt-click on the cell — unpins.
|
|
98
|
+
*/
|
|
99
|
+
export function ControlMatrix({
|
|
100
|
+
title,
|
|
101
|
+
stance,
|
|
102
|
+
tone,
|
|
103
|
+
schema,
|
|
104
|
+
columns,
|
|
105
|
+
rows,
|
|
106
|
+
config,
|
|
107
|
+
auto,
|
|
108
|
+
setConfig,
|
|
109
|
+
onColumnClick,
|
|
110
|
+
inheritHint,
|
|
111
|
+
className,
|
|
112
|
+
}: ControlMatrixProps): ReactElement {
|
|
113
|
+
const fallback = columns[0];
|
|
114
|
+
const hintFor =
|
|
115
|
+
inheritHint ?? ((_: ControlMatrixColumn) => (fallback ? `from ${nameOf(fallback)}` : ''));
|
|
116
|
+
const [open, setOpen] = useState<OpenCell | null>(null);
|
|
117
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
118
|
+
const { anchor, portalProps } = useOverlayPortal();
|
|
119
|
+
|
|
120
|
+
const leafAt = (path: string): PrefLeaf | undefined => {
|
|
121
|
+
const node = schemaNodeAtPath(schema.group, path);
|
|
122
|
+
return node && isPrefLeaf(node) ? node : undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const rowSpecs = rows.map((row) => {
|
|
126
|
+
const leaves = columns.map((column) => leafAt(`${column.key}.${row.key}`));
|
|
127
|
+
const named = leaves.find((leaf) => leaf !== undefined);
|
|
128
|
+
return { row, leaves, label: row.label ?? named?.name ?? row.key, about: named?.description };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const table = (
|
|
132
|
+
<div className={className ? `lk-control-matrix ${className}` : 'lk-control-matrix'}>
|
|
133
|
+
{anchor}
|
|
134
|
+
<table className="lk-control-matrix__table">
|
|
135
|
+
<thead>
|
|
136
|
+
<tr>
|
|
137
|
+
<td className="lk-control-matrix__corner" />
|
|
138
|
+
{columns.map((column, i) => (
|
|
139
|
+
<th
|
|
140
|
+
key={column.key}
|
|
141
|
+
scope="col"
|
|
142
|
+
className={i === 0 ? 'lk-control-matrix__head is-first' : 'lk-control-matrix__head'}
|
|
143
|
+
>
|
|
144
|
+
<ColumnHeader column={column} onClick={onColumnClick} />
|
|
145
|
+
</th>
|
|
146
|
+
))}
|
|
147
|
+
</tr>
|
|
148
|
+
</thead>
|
|
149
|
+
<tbody>
|
|
150
|
+
{rowSpecs.map(({ row, leaves, label, about }) => (
|
|
151
|
+
<tr key={row.key}>
|
|
152
|
+
<th scope="row" className="lk-control-matrix__label">
|
|
153
|
+
<span className="lk-control-matrix__label-text">{label}</span>
|
|
154
|
+
{about ? <PropertyHelp label={label} description={about} /> : null}
|
|
155
|
+
</th>
|
|
156
|
+
{columns.map((column, i) => {
|
|
157
|
+
const path = `${column.key}.${row.key}`;
|
|
158
|
+
const leaf = leaves[i];
|
|
159
|
+
if (!leaf) {
|
|
160
|
+
return (
|
|
161
|
+
<td key={column.key} className="lk-control-matrix__cell is-missing">
|
|
162
|
+
<span role="img" aria-label={`${nameOf(column)} has no ${label}`}>
|
|
163
|
+
—
|
|
164
|
+
</span>
|
|
165
|
+
</td>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const first = i === 0;
|
|
169
|
+
const inherited = !first && auto.has(path);
|
|
170
|
+
return (
|
|
171
|
+
<td
|
|
172
|
+
key={column.key}
|
|
173
|
+
className={
|
|
174
|
+
inherited ? 'lk-control-matrix__cell is-inherited' : 'lk-control-matrix__cell'
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
<Cell
|
|
178
|
+
path={path}
|
|
179
|
+
leaf={leaf}
|
|
180
|
+
value={valueAtPath(config, path)}
|
|
181
|
+
name={`${nameOf(column)} ${label}`}
|
|
182
|
+
hint={inherited ? hintFor(column) : undefined}
|
|
183
|
+
canUnpin={!first && !inherited}
|
|
184
|
+
setConfig={setConfig}
|
|
185
|
+
onOpen={(el) => {
|
|
186
|
+
triggerRef.current = el;
|
|
187
|
+
setOpen({ path, leaf, column, first, label });
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
</td>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</tr>
|
|
194
|
+
))}
|
|
195
|
+
</tbody>
|
|
196
|
+
</table>
|
|
197
|
+
<RACPopover
|
|
198
|
+
triggerRef={triggerRef}
|
|
199
|
+
isOpen={open !== null}
|
|
200
|
+
onOpenChange={(next) => {
|
|
201
|
+
if (!next) setOpen(null);
|
|
202
|
+
}}
|
|
203
|
+
placement="bottom"
|
|
204
|
+
className="lk-control-matrix__popover"
|
|
205
|
+
data-weasel-overlay=""
|
|
206
|
+
{...portalProps}
|
|
207
|
+
>
|
|
208
|
+
{open ? (
|
|
209
|
+
<RACDialog
|
|
210
|
+
className="lk-control-matrix__editor"
|
|
211
|
+
aria-label={`${nameOf(open.column)} ${open.label}`}
|
|
212
|
+
>
|
|
213
|
+
<PropertyList pack="one-up">
|
|
214
|
+
<ControlRow
|
|
215
|
+
path={open.path}
|
|
216
|
+
leaf={open.leaf}
|
|
217
|
+
resolved={schema}
|
|
218
|
+
config={config}
|
|
219
|
+
shown={config}
|
|
220
|
+
setConfig={setConfig}
|
|
221
|
+
pack="one-up"
|
|
222
|
+
auto={NO_AUTO}
|
|
223
|
+
setRowAuto={noop}
|
|
224
|
+
/>
|
|
225
|
+
</PropertyList>
|
|
226
|
+
{clearable(open.leaf) ? (
|
|
227
|
+
<div className="lk-control-matrix__editor-actions">
|
|
228
|
+
<Button
|
|
229
|
+
size="sm"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
|
|
232
|
+
onClick={() => setConfig(open.path, null)}
|
|
233
|
+
>
|
|
234
|
+
Clear
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
237
|
+
) : null}
|
|
238
|
+
{open.first ? null : (
|
|
239
|
+
<div className="lk-control-matrix__editor-actions">
|
|
240
|
+
<Button
|
|
241
|
+
size="sm"
|
|
242
|
+
variant="ghost"
|
|
243
|
+
disabled={auto.has(open.path)}
|
|
244
|
+
tooltip={`Inherit ${hintFor(open.column)}`}
|
|
245
|
+
onClick={() => setConfig(open.path, autoValue)}
|
|
246
|
+
>
|
|
247
|
+
Inherit
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</RACDialog>
|
|
252
|
+
) : null}
|
|
253
|
+
</RACPopover>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (title === undefined && stance === undefined && tone === undefined) return table;
|
|
258
|
+
return (
|
|
259
|
+
<PropertyPanel title={title} stance={stance} tone={tone}>
|
|
260
|
+
{table}
|
|
261
|
+
</PropertyPanel>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function ColumnHeader({
|
|
266
|
+
column,
|
|
267
|
+
onClick,
|
|
268
|
+
}: {
|
|
269
|
+
column: ControlMatrixColumn;
|
|
270
|
+
onClick?: (column: ControlMatrixColumn) => void;
|
|
271
|
+
}) {
|
|
272
|
+
const name = nameOf(column);
|
|
273
|
+
if (!onClick) {
|
|
274
|
+
return column.title && column.title !== column.label ? (
|
|
275
|
+
<abbr title={column.title} className="lk-control-matrix__head-text">
|
|
276
|
+
{column.label}
|
|
277
|
+
</abbr>
|
|
278
|
+
) : (
|
|
279
|
+
<span className="lk-control-matrix__head-text">{column.label}</span>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return (
|
|
283
|
+
<TooltipTrigger>
|
|
284
|
+
<Focusable>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
className="lk-control-matrix__head-button"
|
|
288
|
+
aria-label={name}
|
|
289
|
+
onClick={() => onClick(column)}
|
|
290
|
+
>
|
|
291
|
+
{column.label}
|
|
292
|
+
</button>
|
|
293
|
+
</Focusable>
|
|
294
|
+
<Tooltip>{name}</Tooltip>
|
|
295
|
+
</TooltipTrigger>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A color leaf whose default is empty: unset is a value of its own, not black. */
|
|
300
|
+
const clearable = (leaf: PrefLeaf): boolean => leaf.kind === 'color' && leaf.default === '';
|
|
301
|
+
|
|
302
|
+
/** The number of decimals a step implies: `0.05` shows two, `1` none. */
|
|
303
|
+
function decimalsOf(step: number | undefined): number {
|
|
304
|
+
if (step === undefined || !Number.isFinite(step) || step <= 0) return 0;
|
|
305
|
+
const text = String(step);
|
|
306
|
+
const exp = /e-(\d+)$/.exec(text);
|
|
307
|
+
if (exp) return Number(exp[1]);
|
|
308
|
+
const dot = text.indexOf('.');
|
|
309
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** What a cell shows of a value it opens a popover to edit. */
|
|
313
|
+
function summaryOf(field: PropertyControlProps | null, value: unknown): ReactNode {
|
|
314
|
+
if (field?.kind === 'number') {
|
|
315
|
+
return typeof field.value === 'number' ? field.value.toFixed(decimalsOf(field.step)) : '—';
|
|
316
|
+
}
|
|
317
|
+
if (field?.kind === 'enum') {
|
|
318
|
+
const option = field.options.find((o) => o.value === field.value);
|
|
319
|
+
if (!option) return '—';
|
|
320
|
+
return typeof option.glyph === 'string' ? option.glyph : option.label;
|
|
321
|
+
}
|
|
322
|
+
if (field?.kind === 'string') return field.value || '—';
|
|
323
|
+
return summarizeValue(value);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function Cell({
|
|
327
|
+
path,
|
|
328
|
+
leaf,
|
|
329
|
+
value,
|
|
330
|
+
name,
|
|
331
|
+
hint,
|
|
332
|
+
canUnpin,
|
|
333
|
+
setConfig,
|
|
334
|
+
onOpen,
|
|
335
|
+
}: {
|
|
336
|
+
path: string;
|
|
337
|
+
leaf: PrefLeaf;
|
|
338
|
+
value: unknown;
|
|
339
|
+
/** The cell's accessible name: column and row. */
|
|
340
|
+
name: string;
|
|
341
|
+
/** Present while the cell inherits: where from. */
|
|
342
|
+
hint?: string;
|
|
343
|
+
canUnpin: boolean;
|
|
344
|
+
setConfig: (path: string, value: unknown) => void;
|
|
345
|
+
onOpen: (el: HTMLElement) => void;
|
|
346
|
+
}) {
|
|
347
|
+
const shown = value ?? leaf.default;
|
|
348
|
+
const write = (next: unknown): void => setConfig(path, next);
|
|
349
|
+
const field = isBuiltinToolPref(leaf)
|
|
350
|
+
? prefFieldProps(leaf, { value: shown, setValue: write })
|
|
351
|
+
: null;
|
|
352
|
+
const label = hint ? `${name}, ${hint}` : name;
|
|
353
|
+
|
|
354
|
+
// Option/Alt-click hands a pinned cell back to the fallback, and does nothing
|
|
355
|
+
// else on any cell — it must not also flip or open what it lands on.
|
|
356
|
+
const unpinned = (e: MouseEvent): boolean => {
|
|
357
|
+
if (!e.altKey) return false;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
if (canUnpin) setConfig(path, autoValue);
|
|
360
|
+
return true;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// The shape `Focusable` asks of the element it makes a tooltip trigger.
|
|
364
|
+
let control: ReactElement<DOMAttributes<HTMLElement>, string>;
|
|
365
|
+
if (field?.kind === 'boolean') {
|
|
366
|
+
const on = field.value === true;
|
|
367
|
+
control = (
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
role="switch"
|
|
371
|
+
aria-checked={on}
|
|
372
|
+
aria-label={label}
|
|
373
|
+
className="lk-control-matrix__switch"
|
|
374
|
+
onClick={(e) => {
|
|
375
|
+
if (!unpinned(e)) field.onChange(!on);
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
<span className="lk-control-matrix__track" aria-hidden="true">
|
|
379
|
+
<span className="lk-control-matrix__thumb" />
|
|
380
|
+
</span>
|
|
381
|
+
</button>
|
|
382
|
+
);
|
|
383
|
+
} else if (field?.kind === 'color' && !clearable(leaf)) {
|
|
384
|
+
const stored =
|
|
385
|
+
typeof field.value === 'string' && field.value !== '' ? toHex8(field.value) : '#000000ff';
|
|
386
|
+
control = (
|
|
387
|
+
<input
|
|
388
|
+
type="color"
|
|
389
|
+
aria-label={label}
|
|
390
|
+
className="lk-control-matrix__swatch"
|
|
391
|
+
value={stored.slice(0, 7)}
|
|
392
|
+
onClick={(e) => {
|
|
393
|
+
unpinned(e);
|
|
394
|
+
}}
|
|
395
|
+
onChange={(e) => {
|
|
396
|
+
const rgb = e.currentTarget.value;
|
|
397
|
+
field.onChange(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb);
|
|
398
|
+
}}
|
|
399
|
+
/>
|
|
400
|
+
);
|
|
401
|
+
} else if (field?.kind === 'color') {
|
|
402
|
+
// A color that may be unset opens the popover, which is the only place it can be cleared.
|
|
403
|
+
const set = typeof field.value === 'string' && field.value !== '';
|
|
404
|
+
control = (
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
aria-label={set ? label : `${label}, unset`}
|
|
408
|
+
aria-haspopup="dialog"
|
|
409
|
+
className="lk-control-matrix__value"
|
|
410
|
+
onClick={(e) => {
|
|
411
|
+
if (!unpinned(e)) onOpen(e.currentTarget);
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
<span
|
|
415
|
+
className={set ? 'lk-control-matrix__chip' : 'lk-control-matrix__chip is-unset'}
|
|
416
|
+
style={set ? ({ '--lk-chip': field.value } as CSSProperties) : undefined}
|
|
417
|
+
aria-hidden="true"
|
|
418
|
+
/>
|
|
419
|
+
</button>
|
|
420
|
+
);
|
|
421
|
+
} else {
|
|
422
|
+
const valueClass =
|
|
423
|
+
field?.kind === 'number' ? 'lk-control-matrix__value is-number' : 'lk-control-matrix__value';
|
|
424
|
+
control = (
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
aria-label={label}
|
|
428
|
+
aria-haspopup="dialog"
|
|
429
|
+
className={valueClass}
|
|
430
|
+
onClick={(e) => {
|
|
431
|
+
if (!unpinned(e)) onOpen(e.currentTarget);
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
{summaryOf(field, shown)}
|
|
435
|
+
</button>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!hint) return control;
|
|
440
|
+
return (
|
|
441
|
+
<TooltipTrigger>
|
|
442
|
+
<Focusable>{control}</Focusable>
|
|
443
|
+
<Tooltip>{hint}</Tooltip>
|
|
444
|
+
</TooltipTrigger>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
@@ -399,7 +399,7 @@ function sectionKey(section: SectionSpec): string {
|
|
|
399
399
|
return section.at === '' ? section.label : `${section.at}.${section.label}`;
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
402
|
+
export interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
403
403
|
path: string;
|
|
404
404
|
leaf: PrefLeaf;
|
|
405
405
|
resolved: ResolvedConfig;
|
|
@@ -432,7 +432,9 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
|
|
|
432
432
|
return (leaf as unknown as Record<string, T | undefined>)[key];
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
|
|
435
|
+
/** One leaf's row, as the panel draws it. Shared with `ControlMatrix`, whose
|
|
436
|
+
* popover edits one cell with exactly this row. */
|
|
437
|
+
export function ControlRow<TC extends Record<string, unknown>>({
|
|
436
438
|
path,
|
|
437
439
|
leaf,
|
|
438
440
|
resolved,
|
package/src/controls/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ControlMatrix,
|
|
3
|
+
type ControlMatrixColumn,
|
|
4
|
+
type ControlMatrixProps,
|
|
5
|
+
type ControlMatrixRow,
|
|
6
|
+
} from './ControlMatrix';
|
|
1
7
|
export { ControlPanel } from './ControlPanel';
|
|
2
8
|
export { type InDialogOptions, inDialog, summarizeValue } from './inDialog';
|
|
3
9
|
export type {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { act, fireEvent,
|
|
1
|
+
import { act, fireEvent, screen } from '@testing-library/react';
|
|
2
|
+
import { renderSettled } from '@weasel-js/react/testing/renderSettled';
|
|
2
3
|
import { useContext } from 'react';
|
|
3
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
5
|
import { useStore } from 'zustand/react';
|
|
@@ -51,8 +52,8 @@ const centredInstrument = defineInstrument<DropState, Record<string, never>>({
|
|
|
51
52
|
},
|
|
52
53
|
});
|
|
53
54
|
|
|
54
|
-
function renderLab(probe?: (state: DropState) => void, instrument = testInstrument) {
|
|
55
|
-
return
|
|
55
|
+
async function renderLab(probe?: (state: DropState) => void, instrument = testInstrument) {
|
|
56
|
+
return renderSettled(
|
|
56
57
|
<Lab instruments={[instrument]} defaultInstrument={instrument.name}>
|
|
57
58
|
{probe ? <StateProbe onState={probe} /> : null}
|
|
58
59
|
</Lab>,
|
|
@@ -88,104 +89,104 @@ function pointerDown(el: Element, x: number, y: number) {
|
|
|
88
89
|
|
|
89
90
|
/** The session listens on the document, so that is where the rest of the
|
|
90
91
|
* gesture has to arrive — a dispatch on `window` reaches nothing. */
|
|
91
|
-
function dispatchOnDocument(type: string, init: PointerEventInit) {
|
|
92
|
-
act(() => {
|
|
92
|
+
async function dispatchOnDocument(type: string, init: PointerEventInit) {
|
|
93
|
+
await act(async () => {
|
|
93
94
|
document.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
|
|
94
95
|
});
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function pointerMove(x: number, y: number) {
|
|
98
|
-
dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
|
|
98
|
+
async function pointerMove(x: number, y: number) {
|
|
99
|
+
await dispatchOnDocument('pointermove', { buttons: 1, clientX: x, clientY: y });
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function pointerUp(x: number, y: number) {
|
|
102
|
-
dispatchOnDocument('pointerup', { clientX: x, clientY: y });
|
|
102
|
+
async function pointerUp(x: number, y: number) {
|
|
103
|
+
await dispatchOnDocument('pointerup', { clientX: x, clientY: y });
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
describe('DragDrop integration', () => {
|
|
106
|
-
it('renders palette item in the sidebar', () => {
|
|
107
|
-
renderLab();
|
|
107
|
+
it('renders palette item in the sidebar', async () => {
|
|
108
|
+
await renderLab();
|
|
108
109
|
expect(screen.getByRole('button', { name: 'Item A' })).toBeInTheDocument();
|
|
109
110
|
});
|
|
110
111
|
|
|
111
|
-
it('drops an item onto the canvas and appends it to state', () => {
|
|
112
|
+
it('drops an item onto the canvas and appends it to state', async () => {
|
|
112
113
|
let latest: DropState = { items: [] };
|
|
113
|
-
renderLab((s) => {
|
|
114
|
+
await renderLab((s) => {
|
|
114
115
|
latest = s;
|
|
115
116
|
});
|
|
116
117
|
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
117
118
|
|
|
118
|
-
act(() => {
|
|
119
|
+
await act(async () => {
|
|
119
120
|
pointerDown(palette, 0, 0);
|
|
120
121
|
});
|
|
121
|
-
pointerUp(250, 250);
|
|
122
|
+
await pointerUp(250, 250);
|
|
122
123
|
|
|
123
124
|
expect(latest.items).toHaveLength(1);
|
|
124
125
|
expect(latest.items[0]?.id).toBe('a');
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
it('drop outside the canvas leaves state unchanged', () => {
|
|
128
|
+
it('drop outside the canvas leaves state unchanged', async () => {
|
|
128
129
|
let latest: DropState = { items: [] };
|
|
129
|
-
renderLab((s) => {
|
|
130
|
+
await renderLab((s) => {
|
|
130
131
|
latest = s;
|
|
131
132
|
});
|
|
132
133
|
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
133
134
|
|
|
134
|
-
act(() => {
|
|
135
|
+
await act(async () => {
|
|
135
136
|
pointerDown(palette, 0, 0);
|
|
136
137
|
});
|
|
137
|
-
pointerMove(10, 10);
|
|
138
|
-
pointerUp(10, 10);
|
|
138
|
+
await pointerMove(10, 10);
|
|
139
|
+
await pointerUp(10, 10);
|
|
139
140
|
|
|
140
141
|
expect(latest.items).toHaveLength(0);
|
|
141
142
|
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
142
143
|
});
|
|
143
144
|
|
|
144
|
-
it('shows drag ghost during drag and clears on drop', () => {
|
|
145
|
-
renderLab();
|
|
145
|
+
it('shows drag ghost during drag and clears on drop', async () => {
|
|
146
|
+
await renderLab();
|
|
146
147
|
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
147
148
|
|
|
148
|
-
act(() => {
|
|
149
|
+
await act(async () => {
|
|
149
150
|
pointerDown(palette, 5, 5);
|
|
150
151
|
});
|
|
151
152
|
expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
|
|
152
153
|
|
|
153
|
-
pointerUp(250, 250);
|
|
154
|
+
await pointerUp(250, 250);
|
|
154
155
|
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
155
156
|
});
|
|
156
157
|
|
|
157
|
-
it('a cancelled gesture drops nothing and clears the ghost', () => {
|
|
158
|
+
it('a cancelled gesture drops nothing and clears the ghost', async () => {
|
|
158
159
|
let latest: DropState = { items: [] };
|
|
159
|
-
renderLab((s) => {
|
|
160
|
+
await renderLab((s) => {
|
|
160
161
|
latest = s;
|
|
161
162
|
});
|
|
162
163
|
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
163
164
|
|
|
164
|
-
act(() => {
|
|
165
|
+
await act(async () => {
|
|
165
166
|
pointerDown(palette, 0, 0);
|
|
166
167
|
});
|
|
167
|
-
pointerMove(250, 250);
|
|
168
|
+
await pointerMove(250, 250);
|
|
168
169
|
expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
|
|
169
170
|
|
|
170
|
-
dispatchOnDocument('pointercancel', {});
|
|
171
|
+
await dispatchOnDocument('pointercancel', {});
|
|
171
172
|
|
|
172
173
|
expect(latest.items).toHaveLength(0);
|
|
173
174
|
expect(document.querySelector('.lk-drag-ghost')).toBeNull();
|
|
174
175
|
});
|
|
175
176
|
|
|
176
|
-
it('drops through the instrument world spec, not the canvas top-left', () => {
|
|
177
|
+
it('drops through the instrument world spec, not the canvas top-left', async () => {
|
|
177
178
|
let latest: DropState = { items: [] };
|
|
178
|
-
renderLab((s) => {
|
|
179
|
+
await renderLab((s) => {
|
|
179
180
|
latest = s;
|
|
180
181
|
}, centredInstrument);
|
|
181
182
|
const palette = screen.getByRole('button', { name: 'Item A' });
|
|
182
183
|
|
|
183
|
-
act(() => {
|
|
184
|
+
await act(async () => {
|
|
184
185
|
pointerDown(palette, 0, 0);
|
|
185
186
|
});
|
|
186
187
|
// The host spans (100,100)-(500,500), so its centre is at (300,300).
|
|
187
188
|
// Dropping 50px right of centre and 50px above it, at zoom 1, y up.
|
|
188
|
-
pointerUp(350, 250);
|
|
189
|
+
await pointerUp(350, 250);
|
|
189
190
|
|
|
190
191
|
expect(latest.items[0]).toMatchObject({ x: 50, y: 50 });
|
|
191
192
|
});
|
package/src/index.ts
CHANGED
|
@@ -229,6 +229,12 @@ export type {
|
|
|
229
229
|
export { useConfigSchema } from './config/useConfigSchema';
|
|
230
230
|
export { useResolvedConfig } from './config/useResolvedConfig';
|
|
231
231
|
export { isLeafVisible } from './config/visible';
|
|
232
|
+
export {
|
|
233
|
+
ControlMatrix,
|
|
234
|
+
type ControlMatrixColumn,
|
|
235
|
+
type ControlMatrixProps,
|
|
236
|
+
type ControlMatrixRow,
|
|
237
|
+
} from './controls/ControlMatrix';
|
|
232
238
|
export { ControlPanel } from './controls/ControlPanel';
|
|
233
239
|
export { type InDialogOptions, inDialog, summarizeValue } from './controls/inDialog';
|
|
234
240
|
export type {
|