@weasel-js/labkit 1.3.0 → 1.4.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- package/dist/chunk-WTI26YTM.js.map +0 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { type RefObject, StrictMode, useRef } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { resolveLoupe } from './types';
|
|
5
|
+
import { type LoupeState, useLoupe } from './useLoupe';
|
|
6
|
+
|
|
7
|
+
interface HarnessProps {
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
peekKey?: string | null;
|
|
10
|
+
sample?: (p: { x: number; y: number }) => string | null;
|
|
11
|
+
minFactor?: number;
|
|
12
|
+
maxFactor?: number;
|
|
13
|
+
onColorChange?: (hex: string) => void;
|
|
14
|
+
seen?: (loupe: LoupeState) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reports the loupe's state as text, which is every assertion jsdom can make
|
|
18
|
+
* about a magnifier — that the lens shows the right region is a screenshot. */
|
|
19
|
+
function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
|
|
20
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
21
|
+
const loupe = useLoupe({
|
|
22
|
+
hostRef: hostRef as RefObject<HTMLElement | null>,
|
|
23
|
+
enabled,
|
|
24
|
+
sample,
|
|
25
|
+
capability: resolveLoupe(rest),
|
|
26
|
+
});
|
|
27
|
+
seen?.(loupe);
|
|
28
|
+
return (
|
|
29
|
+
<div ref={hostRef} data-testid="host">
|
|
30
|
+
<span data-testid="visible">{String(loupe.visible)}</span>
|
|
31
|
+
<span data-testid="aim">{`${loupe.aim.x},${loupe.aim.y}`}</span>
|
|
32
|
+
<span data-testid="factor">{loupe.factor}</span>
|
|
33
|
+
<span data-testid="mode">{loupe.mode}</span>
|
|
34
|
+
<span data-testid="color">{String(loupe.color)}</span>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const read = (id: string): string => screen.getByTestId(id).textContent ?? '';
|
|
40
|
+
|
|
41
|
+
/** jsdom lays nothing out, so every rect is 0×0 at the origin and a client
|
|
42
|
+
* coordinate is already host-relative. */
|
|
43
|
+
function move(x: number, y: number): void {
|
|
44
|
+
fireEvent.pointerMove(screen.getByTestId('host'), { clientX: x, clientY: y });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('useLoupe', () => {
|
|
48
|
+
it('stays hidden until the pointer is over the host', () => {
|
|
49
|
+
render(<Harness />);
|
|
50
|
+
expect(read('visible')).toBe('false');
|
|
51
|
+
move(40, 25);
|
|
52
|
+
expect(read('visible')).toBe('true');
|
|
53
|
+
expect(read('aim')).toBe('40,25');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('hides again when the pointer leaves', () => {
|
|
57
|
+
render(<Harness />);
|
|
58
|
+
move(40, 25);
|
|
59
|
+
fireEvent.pointerLeave(screen.getByTestId('host'));
|
|
60
|
+
expect(read('visible')).toBe('false');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores the pointer while it is turned off', () => {
|
|
64
|
+
render(<Harness enabled={false} />);
|
|
65
|
+
move(40, 25);
|
|
66
|
+
expect(read('visible')).toBe('false');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('peeks while the peek key is held, and stops on release', () => {
|
|
70
|
+
render(<Harness enabled={false} />);
|
|
71
|
+
move(40, 25);
|
|
72
|
+
act(() => {
|
|
73
|
+
fireEvent.keyDown(window, { key: 'Alt' });
|
|
74
|
+
});
|
|
75
|
+
expect(read('visible')).toBe('true');
|
|
76
|
+
act(() => {
|
|
77
|
+
fireEvent.keyUp(window, { key: 'Alt' });
|
|
78
|
+
});
|
|
79
|
+
expect(read('visible')).toBe('false');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not peek when the capability turned the key off', () => {
|
|
83
|
+
render(<Harness enabled={false} peekKey={null} />);
|
|
84
|
+
move(40, 25);
|
|
85
|
+
act(() => {
|
|
86
|
+
fireEvent.keyDown(window, { key: 'Alt' });
|
|
87
|
+
});
|
|
88
|
+
expect(read('visible')).toBe('false');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('drops the peek when the window loses focus, so a held key cannot stick', () => {
|
|
92
|
+
render(<Harness enabled={false} />);
|
|
93
|
+
move(40, 25);
|
|
94
|
+
act(() => {
|
|
95
|
+
fireEvent.keyDown(window, { key: 'Alt' });
|
|
96
|
+
});
|
|
97
|
+
act(() => {
|
|
98
|
+
fireEvent.blur(window);
|
|
99
|
+
});
|
|
100
|
+
expect(read('visible')).toBe('false');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('opens at the declared factor and zooms on the wheel', () => {
|
|
104
|
+
render(<Harness />);
|
|
105
|
+
move(40, 25);
|
|
106
|
+
expect(read('factor')).toBe('6');
|
|
107
|
+
act(() => {
|
|
108
|
+
fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
|
|
109
|
+
});
|
|
110
|
+
expect(Number(read('factor'))).toBeGreaterThan(6);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('clamps the wheel to the declared bounds', () => {
|
|
114
|
+
render(<Harness minFactor={4} maxFactor={8} />);
|
|
115
|
+
move(40, 25);
|
|
116
|
+
for (let i = 0; i < 40; i++) {
|
|
117
|
+
act(() => {
|
|
118
|
+
fireEvent.wheel(screen.getByTestId('host'), { deltaY: -100 });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
expect(Number(read('factor'))).toBe(8);
|
|
122
|
+
for (let i = 0; i < 80; i++) {
|
|
123
|
+
act(() => {
|
|
124
|
+
fireEvent.wheel(screen.getByTestId('host'), { deltaY: 100 });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
expect(Number(read('factor'))).toBe(4);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('leaves the wheel to pan-zoom while the lens is not shown', () => {
|
|
131
|
+
render(<Harness enabled={false} />);
|
|
132
|
+
const host = screen.getByTestId('host');
|
|
133
|
+
const wheel = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true });
|
|
134
|
+
act(() => {
|
|
135
|
+
host.dispatchEvent(wheel);
|
|
136
|
+
});
|
|
137
|
+
expect(wheel.defaultPrevented).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('takes the wheel from pan-zoom while the lens is shown', () => {
|
|
141
|
+
render(<Harness />);
|
|
142
|
+
move(40, 25);
|
|
143
|
+
const host = screen.getByTestId('host');
|
|
144
|
+
const wheel = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true });
|
|
145
|
+
act(() => {
|
|
146
|
+
host.dispatchEvent(wheel);
|
|
147
|
+
});
|
|
148
|
+
expect(wheel.defaultPrevented).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports the colour under the aim', () => {
|
|
152
|
+
const onColorChange = vi.fn();
|
|
153
|
+
render(
|
|
154
|
+
<Harness sample={(p) => (p.x > 10 ? '#ff0000' : '#00ff00')} onColorChange={onColorChange} />,
|
|
155
|
+
);
|
|
156
|
+
move(40, 25);
|
|
157
|
+
expect(read('color')).toBe('#ff0000');
|
|
158
|
+
expect(onColorChange).toHaveBeenCalledWith('#ff0000');
|
|
159
|
+
move(4, 25);
|
|
160
|
+
expect(read('color')).toBe('#00ff00');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('picks what the lens shows at a point inside it', () => {
|
|
164
|
+
const seen: LoupeState[] = [];
|
|
165
|
+
render(<Harness sample={(p) => `#${Math.round(p.x)}`} seen={(l) => seen.push(l)} />);
|
|
166
|
+
move(100, 100);
|
|
167
|
+
const loupe = seen[seen.length - 1];
|
|
168
|
+
// Half the default 200px lens right of centre, at 6x, is 100/6 world px
|
|
169
|
+
// right of the aim.
|
|
170
|
+
const hex = loupe.pick({ x: 100 + 100, y: 100 });
|
|
171
|
+
expect(hex).toBe(`#${Math.round(100 + 100 / 6)}`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('still aims after a mount / unmount / remount', () => {
|
|
175
|
+
// What StrictMode does to every effect. The model is created once and
|
|
176
|
+
// `dispose` is one-way, so tearing it down on the first unmount left a
|
|
177
|
+
// magnifier that drew but never moved.
|
|
178
|
+
render(
|
|
179
|
+
<StrictMode>
|
|
180
|
+
<Harness />
|
|
181
|
+
</StrictMode>,
|
|
182
|
+
);
|
|
183
|
+
move(40, 25);
|
|
184
|
+
expect(read('visible')).toBe('true');
|
|
185
|
+
expect(read('aim')).toBe('40,25');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('switches mode', () => {
|
|
189
|
+
const seen: LoupeState[] = [];
|
|
190
|
+
render(<Harness seen={(l) => seen.push(l)} />);
|
|
191
|
+
move(40, 25);
|
|
192
|
+
act(() => {
|
|
193
|
+
seen[seen.length - 1].setMode('pixel');
|
|
194
|
+
});
|
|
195
|
+
expect(read('mode')).toBe('pixel');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createLoupeModel,
|
|
3
|
+
type LoupeMode,
|
|
4
|
+
type LoupeModel,
|
|
5
|
+
type LoupePoint,
|
|
6
|
+
} from '@weasel-js/loupe';
|
|
7
|
+
import { type RefObject, useCallback, useEffect, useReducer, useRef } from 'react';
|
|
8
|
+
import type { ResolvedLoupe } from './types';
|
|
9
|
+
|
|
10
|
+
/** Options for {@link useLoupe}. */
|
|
11
|
+
export interface UseLoupeOptions {
|
|
12
|
+
capability: ResolvedLoupe;
|
|
13
|
+
/** The element the lens tracks the pointer across. */
|
|
14
|
+
hostRef: RefObject<HTMLElement | null>;
|
|
15
|
+
/** Whether the loupe is turned on. Hold-to-peek shows it regardless. */
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/** Hex colour at a host point. Omitted, the loupe reports no colour — which
|
|
18
|
+
* is the honest answer for a surface with no pixels to read. */
|
|
19
|
+
sample?: (p: LoupePoint) => string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A loupe as a React view reads it. */
|
|
23
|
+
export interface LoupeState {
|
|
24
|
+
/** Whether the lens should be drawn. */
|
|
25
|
+
visible: boolean;
|
|
26
|
+
/** Where it is aimed, in the host's own pixels. */
|
|
27
|
+
aim: LoupePoint;
|
|
28
|
+
factor: number;
|
|
29
|
+
mode: LoupeMode;
|
|
30
|
+
color: string | null;
|
|
31
|
+
setMode: (mode: LoupeMode) => void;
|
|
32
|
+
setFactor: (factor: number) => void;
|
|
33
|
+
/** Sample what the lens shows at a point inside it. */
|
|
34
|
+
pick: (p?: LoupePoint) => string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How fast the wheel walks the magnification. Gentler than the pan-zoom
|
|
38
|
+
* wheel: the lens' whole range is one order of magnitude. */
|
|
39
|
+
const WHEEL_RATE = 0.002;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Binds `@weasel-js/loupe`'s model to a host element and drives it from the
|
|
43
|
+
* pointer, the peek key and the wheel.
|
|
44
|
+
*
|
|
45
|
+
* These are plain listeners rather than gesture bindings because labkit trials
|
|
46
|
+
* do not route input through the dispatcher — see the loupe entry under
|
|
47
|
+
* "Selection, actions & UI panels" in `docs/TODO.md`. They are all in this one
|
|
48
|
+
* hook so that when trial input does go through it, this is the only file to
|
|
49
|
+
* rewrite.
|
|
50
|
+
*/
|
|
51
|
+
export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
|
|
52
|
+
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
53
|
+
|
|
54
|
+
const overRef = useRef(false);
|
|
55
|
+
const peekingRef = useRef(false);
|
|
56
|
+
const enabledRef = useRef(enabled);
|
|
57
|
+
enabledRef.current = enabled;
|
|
58
|
+
const sampleRef = useRef(sample);
|
|
59
|
+
sampleRef.current = sample;
|
|
60
|
+
const onColorChangeRef = useRef(capability.onColorChange);
|
|
61
|
+
onColorChangeRef.current = capability.onColorChange;
|
|
62
|
+
|
|
63
|
+
// Reads nothing but refs, so it is stable and an effect may depend on it.
|
|
64
|
+
const shown = useCallback(
|
|
65
|
+
(): boolean => overRef.current && (enabledRef.current || peekingRef.current),
|
|
66
|
+
[],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const modelRef = useRef<LoupeModel | null>(null);
|
|
70
|
+
if (modelRef.current === null) {
|
|
71
|
+
modelRef.current = createLoupeModel({
|
|
72
|
+
mode: capability.mode,
|
|
73
|
+
factor: capability.factor,
|
|
74
|
+
minFactor: capability.minFactor,
|
|
75
|
+
maxFactor: capability.maxFactor,
|
|
76
|
+
onColorChange: (hex) => onColorChangeRef.current?.(hex),
|
|
77
|
+
surface: {
|
|
78
|
+
lens: () => {
|
|
79
|
+
const d = diameterRef.current;
|
|
80
|
+
const { x, y } = modelRef.current?.aim ?? { x: 0, y: 0 };
|
|
81
|
+
return { x: x - d / 2, y: y - d / 2, w: d, h: d };
|
|
82
|
+
},
|
|
83
|
+
// The lens follows the pointer and is painted over the host rather than
|
|
84
|
+
// into it, so it is never part of the picture being magnified.
|
|
85
|
+
covers: () => false,
|
|
86
|
+
sample: (p) => sampleRef.current?.(p) ?? null,
|
|
87
|
+
hidden: () => !shown(),
|
|
88
|
+
gone: () => goneRef.current,
|
|
89
|
+
changed: bump,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const model = modelRef.current;
|
|
94
|
+
|
|
95
|
+
const diameterRef = useRef(capability.diameter);
|
|
96
|
+
diameterRef.current = capability.diameter;
|
|
97
|
+
const goneRef = useRef(false);
|
|
98
|
+
|
|
99
|
+
// The model holds no resources, and `dispose` is one-way — so unmounting only
|
|
100
|
+
// reports the lens gone. Disposing here instead leaves React's mount / unmount
|
|
101
|
+
// / remount in StrictMode with a permanently dead model that silently ignores
|
|
102
|
+
// every aim.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
goneRef.current = false;
|
|
105
|
+
return () => {
|
|
106
|
+
goneRef.current = true;
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// Turning the loupe off with the pointer still inside must put the lens away,
|
|
111
|
+
// and the model only reconsiders on an aim.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!enabled && !peekingRef.current) bump();
|
|
114
|
+
}, [enabled]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const host = hostRef.current;
|
|
118
|
+
if (!host) return;
|
|
119
|
+
|
|
120
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
121
|
+
overRef.current = true;
|
|
122
|
+
const rect = host.getBoundingClientRect();
|
|
123
|
+
model.aimAt({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
124
|
+
if (!shown()) return;
|
|
125
|
+
bump();
|
|
126
|
+
};
|
|
127
|
+
const onPointerLeave = (): void => {
|
|
128
|
+
overRef.current = false;
|
|
129
|
+
bump();
|
|
130
|
+
};
|
|
131
|
+
const onWheel = (e: WheelEvent): void => {
|
|
132
|
+
if (!shown()) return;
|
|
133
|
+
// Ahead of the stack's own pan-zoom, which is a React handler on this same
|
|
134
|
+
// element: capture runs first, and stopping propagation keeps the event
|
|
135
|
+
// from reaching React's root listener at all.
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
model.setFactor(model.factor * Math.exp(-e.deltaY * WHEEL_RATE));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
142
|
+
host.addEventListener('pointerleave', onPointerLeave);
|
|
143
|
+
host.addEventListener('wheel', onWheel, { capture: true, passive: false });
|
|
144
|
+
return () => {
|
|
145
|
+
host.removeEventListener('pointermove', onPointerMove);
|
|
146
|
+
host.removeEventListener('pointerleave', onPointerLeave);
|
|
147
|
+
host.removeEventListener('wheel', onWheel, { capture: true });
|
|
148
|
+
};
|
|
149
|
+
}, [hostRef, model, shown]);
|
|
150
|
+
|
|
151
|
+
const peekKey = capability.peekKey;
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (peekKey == null) return;
|
|
154
|
+
const setPeeking = (next: boolean): void => {
|
|
155
|
+
if (peekingRef.current === next) return;
|
|
156
|
+
peekingRef.current = next;
|
|
157
|
+
bump();
|
|
158
|
+
};
|
|
159
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
160
|
+
if (e.key === peekKey) setPeeking(true);
|
|
161
|
+
};
|
|
162
|
+
const onKeyUp = (e: KeyboardEvent): void => {
|
|
163
|
+
if (e.key === peekKey) setPeeking(false);
|
|
164
|
+
};
|
|
165
|
+
// A key held while the window loses focus never sends its keyup, so the
|
|
166
|
+
// peek would stay on until the key was pressed and released again.
|
|
167
|
+
const onBlur = (): void => setPeeking(false);
|
|
168
|
+
|
|
169
|
+
window.addEventListener('keydown', onKeyDown);
|
|
170
|
+
window.addEventListener('keyup', onKeyUp);
|
|
171
|
+
window.addEventListener('blur', onBlur);
|
|
172
|
+
return () => {
|
|
173
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
174
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
175
|
+
window.removeEventListener('blur', onBlur);
|
|
176
|
+
};
|
|
177
|
+
}, [peekKey]);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
visible: shown(),
|
|
181
|
+
aim: model.aim,
|
|
182
|
+
factor: model.factor,
|
|
183
|
+
mode: model.mode,
|
|
184
|
+
color: model.color,
|
|
185
|
+
setMode: model.setMode,
|
|
186
|
+
setFactor: model.setFactor,
|
|
187
|
+
pick: model.pick,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -117,14 +117,25 @@ describe('FloatingPanel', () => {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
describe('FloatingPanel dragging', () => {
|
|
120
|
-
it('marks itself dragging
|
|
120
|
+
it('marks itself dragging once the pointer moves, not on the press', () => {
|
|
121
121
|
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
122
122
|
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
123
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
124
|
+
fireEvent.pointerMove(panel, { clientX: 140, clientY: 100 });
|
|
123
125
|
expect(panel.dataset.dragging).toBe('true');
|
|
124
126
|
fireEvent.pointerUp(panel);
|
|
125
127
|
expect(panel.dataset.dragging).toBeUndefined();
|
|
126
128
|
});
|
|
127
129
|
|
|
130
|
+
it('a press that never moves is not a drag', () => {
|
|
131
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
132
|
+
const before = panel.style.left;
|
|
133
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
134
|
+
fireEvent.pointerUp(panel, { clientX: 100, clientY: 100 });
|
|
135
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
136
|
+
expect(panel.style.left).toBe(before);
|
|
137
|
+
});
|
|
138
|
+
|
|
128
139
|
it('moves with the pointer, accumulating across moves', () => {
|
|
129
140
|
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
130
141
|
expect(panel.style.left).toBe('12px');
|
|
@@ -238,3 +249,67 @@ describe('FloatingPanel persistence', () => {
|
|
|
238
249
|
expect(() => renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>)).not.toThrow();
|
|
239
250
|
});
|
|
240
251
|
});
|
|
252
|
+
|
|
253
|
+
describe('a child that is not a native control', () => {
|
|
254
|
+
// The real symptom — capture retargets mouseup, so the browser synthesizes no
|
|
255
|
+
// `click` on the child — cannot be reproduced in jsdom, which fires the click
|
|
256
|
+
// either way. Capture-on-press is the observable proxy for it.
|
|
257
|
+
it('does not capture the pointer until the pointer has actually moved', () => {
|
|
258
|
+
const captured: number[] = [];
|
|
259
|
+
Element.prototype.setPointerCapture = (id: number) => {
|
|
260
|
+
captured.push(id);
|
|
261
|
+
};
|
|
262
|
+
render(
|
|
263
|
+
<FloatingPanel>
|
|
264
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a non-native control is the subject */}
|
|
265
|
+
<span data-testid="row" role="button" tabIndex={0}>
|
|
266
|
+
row
|
|
267
|
+
</span>
|
|
268
|
+
</FloatingPanel>,
|
|
269
|
+
);
|
|
270
|
+
const row = screen.getByTestId('row');
|
|
271
|
+
fireEvent.pointerDown(row, { pointerId: 1, button: 0, clientX: 40, clientY: 40 });
|
|
272
|
+
expect(captured).toEqual([]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('a clickable row nested in a list, the shape a floating panel usually holds', () => {
|
|
277
|
+
it('does not capture the pointer on a press two ancestors below the panel', () => {
|
|
278
|
+
const captured: number[] = [];
|
|
279
|
+
Element.prototype.setPointerCapture = (id: number) => {
|
|
280
|
+
captured.push(id);
|
|
281
|
+
};
|
|
282
|
+
render(
|
|
283
|
+
<FloatingPanel>
|
|
284
|
+
<ul>
|
|
285
|
+
<li>
|
|
286
|
+
{/* biome-ignore lint/a11y/useSemanticElements: a non-native control is the subject */}
|
|
287
|
+
<span data-testid="row" role="button" tabIndex={0}>
|
|
288
|
+
row
|
|
289
|
+
</span>
|
|
290
|
+
</li>
|
|
291
|
+
</ul>
|
|
292
|
+
</FloatingPanel>,
|
|
293
|
+
);
|
|
294
|
+
fireEvent.pointerDown(screen.getByTestId('row'), {
|
|
295
|
+
pointerId: 1,
|
|
296
|
+
button: 0,
|
|
297
|
+
clientX: 40,
|
|
298
|
+
clientY: 40,
|
|
299
|
+
});
|
|
300
|
+
expect(captured).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('still drags when the press that started in the list actually moves', () => {
|
|
304
|
+
const { panel } = renderPanel(
|
|
305
|
+
<FloatingPanel anchor="top-left">
|
|
306
|
+
<ul>
|
|
307
|
+
<li>row</li>
|
|
308
|
+
</ul>
|
|
309
|
+
</FloatingPanel>,
|
|
310
|
+
);
|
|
311
|
+
fireEvent.pointerDown(screen.getByText('row'), { pointerId: 1, clientX: 100, clientY: 100 });
|
|
312
|
+
fireEvent.pointerMove(panel, { pointerId: 1, clientX: 200, clientY: 100 });
|
|
313
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -37,6 +37,9 @@ function borderBoxOf(entry: ResizeObserverEntry): { w: number; h: number } {
|
|
|
37
37
|
return { w: entry.contentRect.width, h: entry.contentRect.height };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** How far the pointer must travel before a press becomes a drag. */
|
|
41
|
+
const DRAG_THRESHOLD = 3;
|
|
42
|
+
|
|
40
43
|
/** A pointerdown on one of these is the child's, not a drag. */
|
|
41
44
|
function isInteractive(target: EventTarget | null): boolean {
|
|
42
45
|
if (!(target instanceof Element)) return false;
|
|
@@ -127,18 +130,30 @@ export function FloatingPanel({
|
|
|
127
130
|
.placements.get(ITEM_ID);
|
|
128
131
|
|
|
129
132
|
const dragging = useRef<{ x: number; y: number } | null>(null);
|
|
133
|
+
/** Pressed but not yet moved far enough to be a drag. */
|
|
134
|
+
const pending = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
|
130
135
|
const [isDragging, setDragging] = useState(false);
|
|
131
136
|
|
|
132
137
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
133
138
|
if (isInteractive(e.target)) return;
|
|
134
139
|
// The canvas stack underneath owns pan/zoom on the same pointer events.
|
|
135
140
|
e.stopPropagation();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
// Armed, not capturing. Capture retargets mouseup to this element, and the
|
|
142
|
+
// browser then synthesizes no `click` on the child under the cursor — so
|
|
143
|
+
// capturing on press makes every non-native control in the panel dead to a
|
|
144
|
+
// real mouse, silently. `isInteractive` only exempts the names it lists; a
|
|
145
|
+
// `role="button"` span, a react-aria control or a canvas is not among them.
|
|
146
|
+
pending.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
|
139
147
|
};
|
|
140
148
|
|
|
141
149
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
150
|
+
const armed = pending.current;
|
|
151
|
+
if (armed && armed.pointerId === e.pointerId && !dragging.current) {
|
|
152
|
+
if (Math.hypot(e.clientX - armed.x, e.clientY - armed.y) < DRAG_THRESHOLD) return;
|
|
153
|
+
dragging.current = { x: armed.x, y: armed.y };
|
|
154
|
+
setDragging(true);
|
|
155
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
156
|
+
}
|
|
142
157
|
const from = dragging.current;
|
|
143
158
|
if (!from) return;
|
|
144
159
|
const dx = e.clientX - from.x;
|
|
@@ -160,6 +175,7 @@ export function FloatingPanel({
|
|
|
160
175
|
};
|
|
161
176
|
|
|
162
177
|
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
178
|
+
pending.current = null;
|
|
163
179
|
if (!dragging.current) return;
|
|
164
180
|
dragging.current = null;
|
|
165
181
|
setDragging(false);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
3
|
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
4
|
+
import { DEFAULT_FRAME } from '../canvas/worldSpec';
|
|
4
5
|
import { ScaleIndicator } from './ScaleIndicator';
|
|
5
6
|
|
|
6
7
|
describe('ScaleIndicator', () => {
|
|
@@ -27,7 +28,9 @@ describe('ScaleIndicator', () => {
|
|
|
27
28
|
|
|
28
29
|
test('reads zoom from CanvasStackContext when prop omitted', () => {
|
|
29
30
|
render(
|
|
30
|
-
<CanvasStackContext.Provider
|
|
31
|
+
<CanvasStackContext.Provider
|
|
32
|
+
value={{ view: { zoom: 2.5, pan: { x: 0, y: 0 } }, frame: DEFAULT_FRAME }}
|
|
33
|
+
>
|
|
31
34
|
<ScaleIndicator pixelsPerUnit={50} unit="ft" />
|
|
32
35
|
</CanvasStackContext.Provider>,
|
|
33
36
|
);
|
|
@@ -37,7 +40,9 @@ describe('ScaleIndicator', () => {
|
|
|
37
40
|
|
|
38
41
|
test('explicit zoom prop overrides context', () => {
|
|
39
42
|
render(
|
|
40
|
-
<CanvasStackContext.Provider
|
|
43
|
+
<CanvasStackContext.Provider
|
|
44
|
+
value={{ view: { zoom: 100, pan: { x: 0, y: 0 } }, frame: DEFAULT_FRAME }}
|
|
45
|
+
>
|
|
41
46
|
<ScaleIndicator zoom={1} pixelsPerUnit={50} unit="ft" />
|
|
42
47
|
</CanvasStackContext.Provider>,
|
|
43
48
|
);
|
|
@@ -62,7 +62,25 @@
|
|
|
62
62
|
border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
.lk-sidebar-section__bar {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.lk-sidebar-section__undock {
|
|
71
|
+
padding: 0 var(--wzl-space-xs);
|
|
72
|
+
border: 0;
|
|
73
|
+
background: transparent;
|
|
74
|
+
color: var(--wzl-fg-subtle);
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
|
|
77
|
+
&:hover {
|
|
78
|
+
color: var(--wzl-fg);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
.lk-sidebar-section__head {
|
|
83
|
+
flex: 1;
|
|
66
84
|
width: 100%;
|
|
67
85
|
padding: var(--wzl-space-xs);
|
|
68
86
|
border: 0;
|
|
@@ -73,6 +73,14 @@
|
|
|
73
73
|
border-color: var(--wzl-border);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// A toggle that is on stays on when the pointer leaves, so it reads as held
|
|
77
|
+
// rather than hovered — the hover rule alone would be indistinguishable.
|
|
78
|
+
&--pressed {
|
|
79
|
+
background: var(--wzl-accent-soft);
|
|
80
|
+
border-color: var(--wzl-accent);
|
|
81
|
+
color: var(--wzl-fg);
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
&--danger:hover:not(:disabled) {
|
|
77
85
|
color: var(--wzl-danger);
|
|
78
86
|
border-color: var(--wzl-danger);
|
|
@@ -70,6 +70,8 @@ export interface ToolbarButtonProps {
|
|
|
70
70
|
variant?: 'default' | 'danger';
|
|
71
71
|
/** Square, for a button whose whole content is one glyph. */
|
|
72
72
|
iconOnly?: boolean;
|
|
73
|
+
/** Makes this a toggle: `aria-pressed`, and held down while true. */
|
|
74
|
+
pressed?: boolean;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
function Button({
|
|
@@ -80,11 +82,13 @@ function Button({
|
|
|
80
82
|
'aria-label': ariaLabel,
|
|
81
83
|
variant = 'default',
|
|
82
84
|
iconOnly,
|
|
85
|
+
pressed,
|
|
83
86
|
}: ToolbarButtonProps) {
|
|
84
87
|
const cls = [
|
|
85
88
|
'lk-toolbar-button',
|
|
86
89
|
variant === 'danger' && 'lk-toolbar-button--danger',
|
|
87
90
|
iconOnly && 'lk-toolbar-button--icon',
|
|
91
|
+
pressed && 'lk-toolbar-button--pressed',
|
|
88
92
|
]
|
|
89
93
|
.filter(Boolean)
|
|
90
94
|
.join(' ');
|
|
@@ -96,6 +100,7 @@ function Button({
|
|
|
96
100
|
disabled={disabled}
|
|
97
101
|
title={title}
|
|
98
102
|
aria-label={ariaLabel ?? title}
|
|
103
|
+
aria-pressed={pressed}
|
|
99
104
|
>
|
|
100
105
|
{children}
|
|
101
106
|
</button>
|
|
@@ -25,3 +25,24 @@ describe('nextIndex', () => {
|
|
|
25
25
|
expect(nextIndex(0, 'ArrowRight', 0)).toBeNull();
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
describe('nextIndex — vertical', () => {
|
|
30
|
+
it('walks the down/up arrows', () => {
|
|
31
|
+
expect(nextIndex(0, 'ArrowDown', 3, 'vertical')).toBe(1);
|
|
32
|
+
expect(nextIndex(2, 'ArrowDown', 3, 'vertical')).toBe(0);
|
|
33
|
+
expect(nextIndex(1, 'ArrowUp', 3, 'vertical')).toBe(0);
|
|
34
|
+
expect(nextIndex(0, 'ArrowUp', 3, 'vertical')).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('leaves the cross-axis arrows to the page', () => {
|
|
38
|
+
expect(nextIndex(0, 'ArrowRight', 3, 'vertical')).toBeNull();
|
|
39
|
+
expect(nextIndex(0, 'ArrowLeft', 3, 'vertical')).toBeNull();
|
|
40
|
+
expect(nextIndex(0, 'ArrowDown', 3)).toBeNull();
|
|
41
|
+
expect(nextIndex(0, 'ArrowUp', 3)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('still jumps to the ends', () => {
|
|
45
|
+
expect(nextIndex(1, 'Home', 3, 'vertical')).toBe(0);
|
|
46
|
+
expect(nextIndex(1, 'End', 3, 'vertical')).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
});
|