@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.
Files changed (67) hide show
  1. package/dist/chrome/index.js +4 -4
  2. package/dist/{chunk-GAHELLJT.js → chunk-RODIY7HI.js} +7 -7
  3. package/dist/{chunk-GAHELLJT.js.map → chunk-RODIY7HI.js.map} +1 -1
  4. package/dist/{chunk-4KSC7NXL.js → chunk-U6UQVGFP.js} +1984 -1892
  5. package/dist/chunk-U6UQVGFP.js.map +1 -0
  6. package/dist/chunk-WFALF2X2.js +302 -0
  7. package/dist/chunk-WFALF2X2.js.map +1 -0
  8. package/dist/{chunk-JFOJESJF.js → chunk-WWJDPNZZ.js} +22 -22
  9. package/dist/chunk-WWJDPNZZ.js.map +1 -0
  10. package/dist/{chunk-CEGFXTBD.js → chunk-XRX5S5NT.js} +4 -4
  11. package/dist/{chunk-CEGFXTBD.js.map → chunk-XRX5S5NT.js.map} +1 -1
  12. package/dist/{chunk-FB4MIZOO.js → chunk-XZDCEMEL.js} +6 -6
  13. package/dist/chunk-XZDCEMEL.js.map +1 -0
  14. package/dist/controls/index.d.ts +55 -3
  15. package/dist/controls/index.js +3 -2
  16. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +135 -134
  19. package/dist/index.js.map +1 -1
  20. package/dist/layers/index.js +1 -1
  21. package/dist/passthrough/weasel-ui.js +1 -1
  22. package/dist/primitives/index.js +3 -3
  23. package/dist/styles.css +228 -11
  24. package/package.json +11 -11
  25. package/src/annotations/capture.browser.test.ts +9 -3
  26. package/src/canvas/Stage.test.tsx +34 -25
  27. package/src/chrome/labRegions.test.tsx +37 -26
  28. package/src/chrome/regions/PaletteRegion.test.tsx +2 -2
  29. package/src/controls/ControlMatrix.less +245 -0
  30. package/src/controls/ControlMatrix.stories.less +13 -0
  31. package/src/controls/ControlMatrix.stories.tsx +135 -0
  32. package/src/controls/ControlMatrix.test.tsx +206 -0
  33. package/src/controls/ControlMatrix.tsx +446 -0
  34. package/src/controls/ControlPanel.tsx +4 -2
  35. package/src/controls/index.ts +6 -0
  36. package/src/dragdrop/dragDrop.test.tsx +34 -33
  37. package/src/index.ts +6 -0
  38. package/src/lab/Lab.chrome.test.tsx +15 -10
  39. package/src/lab/Lab.nebula.test.tsx +5 -5
  40. package/src/lab/Lab.surface.test.tsx +30 -21
  41. package/src/lab/Lab.test.tsx +75 -74
  42. package/src/lab/Lab.undock.test.tsx +15 -10
  43. package/src/lab/LabHeader.test.tsx +15 -14
  44. package/src/lab/LabPalette.test.tsx +12 -7
  45. package/src/lab/LabShell.less +1 -2
  46. package/src/lab/LabZoom.test.tsx +55 -44
  47. package/src/lab/Workspace.surface.test.tsx +16 -13
  48. package/src/lab/Workspace.test.tsx +32 -27
  49. package/src/overview/OverviewMarks.test.tsx +9 -8
  50. package/src/overview/TrialOverview.test.tsx +18 -17
  51. package/src/primitives/FpsMeter.less +1 -2
  52. package/src/primitives/JobProgress.less +1 -2
  53. package/src/primitives/ScaleIndicator.less +1 -1
  54. package/src/primitives/StatusBar.less +1 -1
  55. package/src/specimen/Specimen.less +1 -0
  56. package/src/styles.less +3 -0
  57. package/src/trial/Trial.annotations.persist.test.tsx +20 -9
  58. package/src/trial/Trial.annotations.test.tsx +46 -41
  59. package/src/trial/Trial.canvas.test.tsx +21 -17
  60. package/src/trial/Trial.config.test.tsx +34 -27
  61. package/src/trial/Trial.loupe.test.tsx +27 -18
  62. package/src/trial/Trial.targets.test.tsx +9 -8
  63. package/src/trial/Trial.test.tsx +51 -46
  64. package/src/trial/Trial.trialId.test.tsx +7 -6
  65. package/dist/chunk-4KSC7NXL.js.map +0 -1
  66. package/dist/chunk-FB4MIZOO.js.map +0 -1
  67. 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
- function ControlRow<TC extends Record<string, unknown>>({
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,
@@ -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, render, screen } from '@testing-library/react';
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 render(
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 {