cursedbelt 5.58.0 → 5.59.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/react/components/PaneSplit.d.ts +52 -0
- package/dist/react/components/PaneSplit.d.ts.map +1 -0
- package/dist/react/components/PaneSplit.js +103 -0
- package/dist/react/components/PaneSplit.js.map +1 -0
- package/dist/react/components/PointerDrag.d.ts +88 -0
- package/dist/react/components/PointerDrag.d.ts.map +1 -0
- package/dist/react/components/PointerDrag.js +156 -0
- package/dist/react/components/PointerDrag.js.map +1 -0
- package/dist/react/components/paneSplit.css +93 -0
- package/dist/react/components/pointerDrag.css +39 -0
- package/package.json +15 -1
- package/scripts/styleAreas.ts +3 -0
- package/src/componentCssReadsAreDeclared.spec.ts +62 -0
- package/src/react/components/PaneSplit.spec.tsx +60 -0
- package/src/react/components/PaneSplit.tsx +206 -0
- package/src/react/components/PointerDrag.spec.tsx +108 -0
- package/src/react/components/PointerDrag.tsx +261 -0
- package/src/react/components/paneSplit.css +93 -0
- package/src/react/components/pointerDrag.css +39 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every bare `var(--x)` a component's plain stylesheet reads must be a property the belt declares.
|
|
3
|
+
*
|
|
4
|
+
* ── The measurement this exists because of (2026-10-05, forge task desk-split-pane-tab-dragging) ─
|
|
5
|
+
* `pointerDrag.css` (5.59.0, first cut) read `z-index: var(--cb-dragghost-z)`, a name only
|
|
6
|
+
* `DragGhost`'s inline style set. The belt's gate was green, e2e included. desk's
|
|
7
|
+
* `test/build/belt-tokens.test.ts` went red on it after the belt had landed: an app's built
|
|
8
|
+
* stylesheet must define whatever it reads with nothing behind it, and a name set only from a
|
|
9
|
+
* `style` prop is defined nowhere a stylesheet can see. Every app that folds the belt's CSS and
|
|
10
|
+
* runs that check would meet it the same way, one app at a time.
|
|
11
|
+
*
|
|
12
|
+
* ── The rule ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* In `src/react/**\/*.css`, a `var()` WITHOUT a fallback must name a property some belt stylesheet
|
|
14
|
+
* declares (`theme.css`'s tokens, or the component's own rule). A value the component sets inline
|
|
15
|
+
* gets a fallback (`var(--cb-panesplit-share, 1)`) or a theme token (`var(--z-popover, 1100)`).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { Glob } from 'bun';
|
|
20
|
+
|
|
21
|
+
const ROOT = new URL('..', import.meta.url).pathname;
|
|
22
|
+
|
|
23
|
+
const withoutComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
+
|
|
25
|
+
/** Every custom property a stylesheet declares. */
|
|
26
|
+
function declaredIn(css: string): Set<string> {
|
|
27
|
+
return new Set([...withoutComments(css).matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1] as string));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The bare `var()` reads in `css` whose name `declared` does not hold. */
|
|
31
|
+
function undeclaredBareReads(css: string, declared: Set<string>): string[] {
|
|
32
|
+
const names = [...withoutComments(css).matchAll(/var\(\s*(--[a-zA-Z0-9-]+)\s*\)/g)].map((m) => m[1] as string);
|
|
33
|
+
return [...new Set(names.filter((name) => !declared.has(name)))].sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('undeclaredBareReads', () => {
|
|
37
|
+
test('a bare read of a name only an inline style sets is caught — the 5.59.0 ghost', () => {
|
|
38
|
+
expect(undeclaredBareReads('.g { z-index: var(--cb-dragghost-z); }', new Set(['--z-popover']))).toEqual(['--cb-dragghost-z']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a fallback, a declared token, or a commented-out read is not a dependency', () => {
|
|
42
|
+
const css = '/* var(--gone) */ .g { --own: 1; z-index: var(--z-popover); flex: var(--share, 1) 1 0; order: var(--own); }';
|
|
43
|
+
expect(undeclaredBareReads(css, new Set([...declaredIn(css), '--z-popover']))).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("the belt's component stylesheets", () => {
|
|
48
|
+
const all = [...new Glob('src/**/*.css').scanSync(ROOT)];
|
|
49
|
+
const declared = new Set(all.flatMap((file) => [...declaredIn(readFileSync(`${ROOT}${file}`, 'utf8'))]));
|
|
50
|
+
const components = all.filter((file) => file.startsWith('src/react/'));
|
|
51
|
+
|
|
52
|
+
test('the sweep measured something', () => {
|
|
53
|
+
expect(components.length).toBeGreaterThan(10);
|
|
54
|
+
expect(declared.has('--z-popover')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const file of components) {
|
|
58
|
+
test(`${file} reads nothing bare that no belt stylesheet declares`, () => {
|
|
59
|
+
expect(undeclaredBareReads(readFileSync(`${ROOT}${file}`, 'utf8'), declared)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { PaneSplit, type PaneSplitNode } from './PaneSplit.js';
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
// desk-split-pane-tab-dragging: desk's WorkspaceSplit, graduated. The drawn picture (square panes,
|
|
8
|
+
// one hairline) is measured in desk's real browser; the tree and the seam arithmetic are here.
|
|
9
|
+
const tree: PaneSplitNode<{ id: string }> = {
|
|
10
|
+
kind: 'split',
|
|
11
|
+
direction: 'column',
|
|
12
|
+
sizes: [0.5, 0.5],
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
kind: 'split',
|
|
16
|
+
direction: 'row',
|
|
17
|
+
sizes: [0.5, 0.5],
|
|
18
|
+
children: [
|
|
19
|
+
{ kind: 'leaf', pane: { id: 'p1' } },
|
|
20
|
+
{ kind: 'leaf', pane: { id: 'p2' } },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{ kind: 'leaf', pane: { id: 'p3' } },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function draw(onResize = mock()) {
|
|
28
|
+
render(<PaneSplit node={tree} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
|
|
29
|
+
return onResize;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('PaneSplit', () => {
|
|
33
|
+
test('draws every leaf, with one seam between each pair of siblings', () => {
|
|
34
|
+
draw();
|
|
35
|
+
expect(['p1', 'p2', 'p3'].map((id) => screen.getByLabelText(id).tagName)).toEqual(['SECTION', 'SECTION', 'SECTION']);
|
|
36
|
+
const seams = screen.getAllByRole('separator');
|
|
37
|
+
expect(seams.map((seam) => seam.getAttribute('aria-orientation'))).toEqual(['vertical', 'horizontal']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an arrow key moves only its own seam, and reports the split at its path', () => {
|
|
41
|
+
const onResize = draw();
|
|
42
|
+
const [inner] = screen.getAllByRole('separator');
|
|
43
|
+
fireEvent.keyDown(inner!, { key: 'ArrowRight' });
|
|
44
|
+
expect(onResize).toHaveBeenCalledTimes(1);
|
|
45
|
+
const [path, sizes] = onResize.mock.calls[0]!;
|
|
46
|
+
expect(path).toEqual([0]);
|
|
47
|
+
expect(sizes[0]).toBeCloseTo(0.52);
|
|
48
|
+
expect(sizes[1]).toBeCloseTo(0.48);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('Home and End stop at the smallest share a pane may have', () => {
|
|
52
|
+
const onResize = draw();
|
|
53
|
+
const [, outer] = screen.getAllByRole('separator');
|
|
54
|
+
fireEvent.keyDown(outer!, { key: 'Home' });
|
|
55
|
+
expect(onResize.mock.calls[0]![0]).toEqual([]);
|
|
56
|
+
expect(onResize.mock.calls[0]![1][0]).toBeCloseTo(0.12);
|
|
57
|
+
fireEvent.keyDown(outer!, { key: 'End' });
|
|
58
|
+
expect(onResize.mock.calls[1]![1][0]).toBeCloseTo(0.88);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PaneSplit` — an editor's pane tree, drawn VS Code's way: panes that meet edge to edge, square,
|
|
3
|
+
* with one hairline between them.
|
|
4
|
+
*
|
|
5
|
+
* Graduated from desk's `WorkspaceSplit` on the owner's word: *"Panels and panes should be separated
|
|
6
|
+
* by sharp corners and thin border lines just like they are in vscode"* — and *"since these changes
|
|
7
|
+
* apply to layouts and cards they should be changed in cb and should impact desk through cb"*.
|
|
8
|
+
*
|
|
9
|
+
* The model is the app's — a tree of leaves and splits, each split carrying the fractions of its
|
|
10
|
+
* children. This component is only geometry: flex tracks, one seam between each pair of children,
|
|
11
|
+
* and the arithmetic that turns a pointer into a pair of fractions. Unlike `PanelGrid` it owns no
|
|
12
|
+
* sizes and persists nothing, which is what an app that stores its layout as a setting needs.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End take it to the bounds.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **The hit strip lives inside the seam's own track** — a real flex item, never a bar hung over
|
|
17
|
+
* the boundary. An invisible strip centred on the seam sits over the first pixels of the pane beside
|
|
18
|
+
* it and swallows clicks nobody aimed at it (desk's file `21`, where a 44px hit area ate a fill
|
|
19
|
+
* handle). So the track is a few pixels wide for the hand, painted the panes' own ground
|
|
20
|
+
* (`--cb-panesplit-ground`) with the hairline drawn down its middle: to the eye, two panes and one
|
|
21
|
+
* line, which is VS Code's picture; to the pointer, a target that touches no pane.
|
|
22
|
+
*
|
|
23
|
+
* Plain CSS (`paneSplit.css`), prefix `cb-panesplit`, so it draws in an app with no Tailwind pass.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { CSSProperties, JSX, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react';
|
|
27
|
+
import { useCallback, useRef } from 'react';
|
|
28
|
+
import './paneSplit.css';
|
|
29
|
+
|
|
30
|
+
export type PaneSplitDirection = 'row' | 'column';
|
|
31
|
+
|
|
32
|
+
export type PaneSplitNode<P> =
|
|
33
|
+
| { kind: 'leaf'; pane: P }
|
|
34
|
+
| { kind: 'split'; direction: PaneSplitDirection; sizes: readonly number[]; children: readonly PaneSplitNode<P>[] };
|
|
35
|
+
|
|
36
|
+
/** Where a node sits in the tree: the child index at each level, from the root. */
|
|
37
|
+
export type PaneSplitPath = readonly number[];
|
|
38
|
+
|
|
39
|
+
export interface PaneSplitProps<P> {
|
|
40
|
+
node: PaneSplitNode<P>;
|
|
41
|
+
/** A key unique across the whole tree — what keeps a pane's subtree when its siblings close. */
|
|
42
|
+
paneKey: (pane: P) => string;
|
|
43
|
+
renderPane: (pane: P) => JSX.Element;
|
|
44
|
+
/** A seam moved: the split at `path` now has these fractions. */
|
|
45
|
+
onResize: (path: PaneSplitPath, sizes: number[]) => void;
|
|
46
|
+
/** The smallest share of an axis a pane can be dragged down to. Default 0.12. */
|
|
47
|
+
minFraction?: number;
|
|
48
|
+
/** Where `node` sits — only for a tree drawn from below its root. */
|
|
49
|
+
path?: PaneSplitPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MIN_FRACTION = 0.12;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A key for a child that follows its content rather than its position: closing a pane in the middle
|
|
56
|
+
* of a row shifts every later child's index, and `key={index}` would hand React the wrong subtree.
|
|
57
|
+
*/
|
|
58
|
+
function childKey<P>(node: PaneSplitNode<P>, paneKey: (pane: P) => string): string {
|
|
59
|
+
if (node.kind === 'leaf') return paneKey(node.pane);
|
|
60
|
+
return node.children.map((child) => childKey(child, paneKey)).join('|');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function PaneSplit<P>(props: PaneSplitProps<P>): JSX.Element {
|
|
64
|
+
const { node } = props;
|
|
65
|
+
if (node.kind === 'leaf') return props.renderPane(node.pane);
|
|
66
|
+
return <SplitNode {...props} node={node} path={props.path ?? []} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function SplitNode<P>({
|
|
70
|
+
node,
|
|
71
|
+
path,
|
|
72
|
+
paneKey,
|
|
73
|
+
renderPane,
|
|
74
|
+
onResize,
|
|
75
|
+
minFraction = DEFAULT_MIN_FRACTION,
|
|
76
|
+
}: PaneSplitProps<P> & { node: Extract<PaneSplitNode<P>, { kind: 'split' }>; path: PaneSplitPath }): JSX.Element {
|
|
77
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
78
|
+
const dragging = useRef<number | null>(null);
|
|
79
|
+
|
|
80
|
+
/** Set the share of the pair either side of seam `index`; only those two panes move. */
|
|
81
|
+
const setShare = useCallback(
|
|
82
|
+
(index: number, share: number) => {
|
|
83
|
+
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
84
|
+
if (pair <= 0) return;
|
|
85
|
+
const bounded = Math.min(Math.max(share, minFraction), 1 - minFraction);
|
|
86
|
+
const sizes = [...node.sizes];
|
|
87
|
+
sizes[index] = pair * bounded;
|
|
88
|
+
sizes[index + 1] = pair * (1 - bounded);
|
|
89
|
+
onResize(path, sizes);
|
|
90
|
+
},
|
|
91
|
+
[node.sizes, minFraction, onResize, path],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const moveSeam = (index: number, position: number) => {
|
|
95
|
+
const box = frame.current?.getBoundingClientRect();
|
|
96
|
+
if (!box) return;
|
|
97
|
+
const total = node.direction === 'row' ? box.width : box.height;
|
|
98
|
+
if (total <= 0) return;
|
|
99
|
+
const offset = (node.direction === 'row' ? position - box.left : position - box.top) / total;
|
|
100
|
+
const before = node.sizes.slice(0, index).reduce((sum, size) => sum + size, 0);
|
|
101
|
+
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
102
|
+
if (pair <= 0) return;
|
|
103
|
+
setShare(index, (offset - before) / pair);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const nudgeSeam = (index: number, delta: number) => {
|
|
107
|
+
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
108
|
+
if (pair <= 0) return;
|
|
109
|
+
setShare(index, (node.sizes[index] ?? 0) / pair + delta);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
|
|
113
|
+
const back = node.direction === 'row' ? 'ArrowLeft' : 'ArrowUp';
|
|
114
|
+
const forward = node.direction === 'row' ? 'ArrowRight' : 'ArrowDown';
|
|
115
|
+
if (event.key === back) nudgeSeam(index, -0.02);
|
|
116
|
+
else if (event.key === forward) nudgeSeam(index, 0.02);
|
|
117
|
+
else if (event.key === 'Home') nudgeSeam(index, -1);
|
|
118
|
+
else if (event.key === 'End') nudgeSeam(index, 1);
|
|
119
|
+
else return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>, index: number) => {
|
|
124
|
+
if (event.button !== 0) return;
|
|
125
|
+
dragging.current = index;
|
|
126
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
131
|
+
if (dragging.current === null) return;
|
|
132
|
+
moveSeam(dragging.current, node.direction === 'row' ? event.clientX : event.clientY);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
136
|
+
if (dragging.current === null) return;
|
|
137
|
+
dragging.current = null;
|
|
138
|
+
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const minPercent = Math.round(minFraction * 100);
|
|
142
|
+
return (
|
|
143
|
+
<div className="cb-panesplit" data-direction={node.direction} ref={frame}>
|
|
144
|
+
{node.children.map((child, index) => (
|
|
145
|
+
<ChildAndSeam
|
|
146
|
+
key={childKey(child, paneKey)}
|
|
147
|
+
last={index === node.children.length - 1}
|
|
148
|
+
size={node.sizes[index] ?? 1 / node.children.length}
|
|
149
|
+
seam={{
|
|
150
|
+
direction: node.direction,
|
|
151
|
+
percent: Math.round((node.sizes[index] ?? 0) * 100),
|
|
152
|
+
min: minPercent,
|
|
153
|
+
max: 100 - minPercent,
|
|
154
|
+
onKeyDown: (event) => onKeyDown(event, index),
|
|
155
|
+
onPointerDown: (event) => onPointerDown(event, index),
|
|
156
|
+
onPointerMove,
|
|
157
|
+
endDrag,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<PaneSplit node={child} path={[...path, index]} paneKey={paneKey} renderPane={renderPane} onResize={onResize} minFraction={minFraction} />
|
|
161
|
+
</ChildAndSeam>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface SeamProps {
|
|
168
|
+
direction: PaneSplitDirection;
|
|
169
|
+
percent: number;
|
|
170
|
+
min: number;
|
|
171
|
+
max: number;
|
|
172
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
|
|
173
|
+
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
174
|
+
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
175
|
+
endDrag: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ChildAndSeam({ last, size, seam, children }: { last: boolean; size: number; seam: SeamProps; children: JSX.Element }): JSX.Element {
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
<div className="cb-panesplit__cell" style={{ '--cb-panesplit-share': size } as CSSProperties}>
|
|
182
|
+
{children}
|
|
183
|
+
</div>
|
|
184
|
+
{!last && (
|
|
185
|
+
// A draggable, keyboard-adjustable divider — an <hr> is a static, non-focusable thematic
|
|
186
|
+
// break and cannot carry the pointer and keyboard handlers or the aria-valuenow it needs.
|
|
187
|
+
// biome-ignore lint/a11y/useSemanticElements: see the comment above
|
|
188
|
+
<div
|
|
189
|
+
className="cb-panesplit__seam"
|
|
190
|
+
role="separator"
|
|
191
|
+
tabIndex={0}
|
|
192
|
+
aria-orientation={seam.direction === 'row' ? 'vertical' : 'horizontal'}
|
|
193
|
+
aria-label={seam.direction === 'row' ? 'Width of the pane on the left' : 'Height of the pane above'}
|
|
194
|
+
aria-valuenow={seam.percent}
|
|
195
|
+
aria-valuemin={seam.min}
|
|
196
|
+
aria-valuemax={seam.max}
|
|
197
|
+
onKeyDown={seam.onKeyDown}
|
|
198
|
+
onPointerDown={seam.onPointerDown}
|
|
199
|
+
onPointerMove={seam.onPointerMove}
|
|
200
|
+
onPointerUp={seam.endDrag}
|
|
201
|
+
onPointerCancel={seam.endDrag}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { DragGhost, type PointerDragState, type UsePointerDragOptions, usePointerDrag } from './PointerDrag.js';
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
// desk-split-pane-tab-dragging: the gesture's decisions are proved here; that the ghost really
|
|
8
|
+
// follows the pointer across panes is proved in desk's real browser (test/browser/workspace.test.ts).
|
|
9
|
+
function Harness(props: Omit<UsePointerDragOptions<string>, 'onDrop'> & { onDrop?: (state: PointerDragState<string>) => void }) {
|
|
10
|
+
const { drag, bind } = usePointerDrag<string>({ ...props, onDrop: props.onDrop ?? (() => {}) });
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<button
|
|
14
|
+
// guardrails-ignore no-raw-action-button: a bare element, so what is under test is the
|
|
15
|
+
// hook's bindings and nothing a Button adds.
|
|
16
|
+
type="button"
|
|
17
|
+
{...bind('tab-a')}
|
|
18
|
+
>
|
|
19
|
+
A
|
|
20
|
+
</button>
|
|
21
|
+
<DragGhost drag={drag}>ghost of {drag?.item}</DragGhost>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const press = (pointerType: string, x: number, y: number) => ({ pointerId: 1, pointerType, button: 0, clientX: x, clientY: y });
|
|
27
|
+
|
|
28
|
+
describe('usePointerDrag', () => {
|
|
29
|
+
test('a mouse press that never travels past the slop is a tap, and draws no ghost', () => {
|
|
30
|
+
const onTap = mock();
|
|
31
|
+
const onDrop = mock();
|
|
32
|
+
render(<Harness onTap={onTap} onDrop={onDrop} />);
|
|
33
|
+
const tab = screen.getByRole('button', { name: 'A' });
|
|
34
|
+
fireEvent.pointerDown(tab, press('mouse', 10, 10));
|
|
35
|
+
fireEvent.pointerMove(tab, press('mouse', 12, 11));
|
|
36
|
+
expect(screen.queryByTestId('drag-ghost')).toBeNull();
|
|
37
|
+
fireEvent.pointerUp(tab, press('mouse', 12, 11));
|
|
38
|
+
expect(onTap).toHaveBeenCalledWith('tab-a');
|
|
39
|
+
expect(onDrop).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('past the slop the ghost follows the pointer, and the release is a drop', () => {
|
|
43
|
+
const onTap = mock();
|
|
44
|
+
const onDrop = mock();
|
|
45
|
+
const onMove = mock();
|
|
46
|
+
render(<Harness onTap={onTap} onDrop={onDrop} onMove={onMove} />);
|
|
47
|
+
const tab = screen.getByRole('button', { name: 'A' });
|
|
48
|
+
fireEvent.pointerDown(tab, press('mouse', 10, 10));
|
|
49
|
+
fireEvent.pointerMove(tab, press('mouse', 40, 30));
|
|
50
|
+
fireEvent.pointerMove(tab, press('mouse', 90, 60));
|
|
51
|
+
const ghost = screen.getByTestId('drag-ghost');
|
|
52
|
+
expect(ghost.textContent).toBe('ghost of tab-a');
|
|
53
|
+
// Grabbed at (10,10) of a box at (0,0): the ghost's corner sits that far up-left of the pointer.
|
|
54
|
+
const grab = (onMove.mock.calls[0]![0] as PointerDragState<string>).grab;
|
|
55
|
+
expect([ghost.style.getPropertyValue('--cb-dragghost-x'), ghost.style.getPropertyValue('--cb-dragghost-y')]).toEqual([`${90 - grab.x}px`, `${60 - grab.y}px`]);
|
|
56
|
+
fireEvent.pointerUp(tab, press('mouse', 90, 60));
|
|
57
|
+
expect(onDrop).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect((onDrop.mock.calls[0]![0] as PointerDragState<string>).at).toEqual({ x: 90, y: 60 });
|
|
59
|
+
expect(onTap).not.toHaveBeenCalled();
|
|
60
|
+
expect(screen.queryByTestId('drag-ghost')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a finger that moves before the hold is a scroll — no drag, no tap', () => {
|
|
64
|
+
const onTap = mock();
|
|
65
|
+
const onDrop = mock();
|
|
66
|
+
render(<Harness onTap={onTap} onDrop={onDrop} holdMs={1000} />);
|
|
67
|
+
const tab = screen.getByRole('button', { name: 'A' });
|
|
68
|
+
fireEvent.pointerDown(tab, press('touch', 10, 10));
|
|
69
|
+
fireEvent.pointerMove(tab, press('touch', 60, 10));
|
|
70
|
+
fireEvent.pointerUp(tab, press('touch', 60, 10));
|
|
71
|
+
expect(screen.queryByTestId('drag-ghost')).toBeNull();
|
|
72
|
+
expect(onTap).not.toHaveBeenCalled();
|
|
73
|
+
expect(onDrop).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a finger that rests through the hold drags, and holds the page still while it does', async () => {
|
|
77
|
+
const onDrop = mock();
|
|
78
|
+
render(<Harness onDrop={onDrop} holdMs={5} />);
|
|
79
|
+
const tab = screen.getByRole('button', { name: 'A' });
|
|
80
|
+
fireEvent.pointerDown(tab, press('touch', 10, 10));
|
|
81
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 30)));
|
|
82
|
+
expect(screen.getByTestId('drag-ghost')).toBeTruthy();
|
|
83
|
+
const scroll = new Event('touchmove', { cancelable: true });
|
|
84
|
+
window.dispatchEvent(scroll);
|
|
85
|
+
expect(scroll.defaultPrevented).toBe(true);
|
|
86
|
+
fireEvent.pointerMove(tab, press('touch', 80, 40));
|
|
87
|
+
fireEvent.pointerUp(tab, press('touch', 80, 40));
|
|
88
|
+
expect(onDrop).toHaveBeenCalledTimes(1);
|
|
89
|
+
// Released: the page scrolls again.
|
|
90
|
+
const after = new Event('touchmove', { cancelable: true });
|
|
91
|
+
window.dispatchEvent(after);
|
|
92
|
+
expect(after.defaultPrevented).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('Escape abandons a drag — cancelled, never dropped', () => {
|
|
96
|
+
const onDrop = mock();
|
|
97
|
+
const onCancel = mock();
|
|
98
|
+
render(<Harness onDrop={onDrop} onCancel={onCancel} />);
|
|
99
|
+
const tab = screen.getByRole('button', { name: 'A' });
|
|
100
|
+
fireEvent.pointerDown(tab, press('mouse', 10, 10));
|
|
101
|
+
fireEvent.pointerMove(tab, press('mouse', 60, 10));
|
|
102
|
+
fireEvent.keyDown(window, { key: 'Escape' });
|
|
103
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(screen.queryByTestId('drag-ghost')).toBeNull();
|
|
105
|
+
fireEvent.pointerUp(tab, press('mouse', 60, 10));
|
|
106
|
+
expect(onDrop).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|