cursedbelt 5.96.1 → 5.97.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 +11 -2
- package/dist/react/components/PaneSplit.d.ts.map +1 -1
- package/dist/react/components/PaneSplit.js +12 -11
- package/dist/react/components/PaneSplit.js.map +1 -1
- package/dist/react/components/PanelGrid.d.ts +14 -8
- package/dist/react/components/PanelGrid.d.ts.map +1 -1
- package/dist/react/components/PanelGrid.js +10 -3
- package/dist/react/components/PanelGrid.js.map +1 -1
- package/dist/react/components/SplitPane.d.ts +3 -9
- package/dist/react/components/SplitPane.d.ts.map +1 -1
- package/dist/react/components/SplitPane.js +0 -3
- package/dist/react/components/SplitPane.js.map +1 -1
- package/dist/react/components/paneSplit.css +8 -0
- package/dist/react/filter-rail/railResize.d.ts +16 -5
- package/dist/react/filter-rail/railResize.d.ts.map +1 -1
- package/dist/react/filter-rail/railResize.js +27 -9
- package/dist/react/filter-rail/railResize.js.map +1 -1
- package/dist/react/filter-rail/railWidth.d.ts +10 -5
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
- package/dist/react/filter-rail/railWidth.js +13 -8
- package/dist/react/filter-rail/railWidth.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/paneReach.d.ts +36 -0
- package/dist/react/lib/paneReach.d.ts.map +1 -0
- package/dist/react/lib/paneReach.js +72 -0
- package/dist/react/lib/paneReach.js.map +1 -0
- package/dist/react/master-detail/MasterDetail.d.ts +6 -3
- package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
- package/dist/react/master-detail/MasterDetail.js +3 -2
- package/dist/react/master-detail/MasterDetail.js.map +1 -1
- package/dist/react/media/videoEnhance.d.ts +1 -1
- package/dist/react/media/videoEnhance.js +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/testing/phoneRules.js +3 -3
- package/dist/testing/phoneRules.js.map +1 -1
- package/package.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +25 -0
- package/scripts/guardrailsEnforce.ts +61 -0
- package/src/react/components/PaneSplit.spec.tsx +26 -3
- package/src/react/components/PaneSplit.tsx +26 -13
- package/src/react/components/PanelGrid.tsx +16 -11
- package/src/react/components/SplitPane.spec.tsx +10 -5
- package/src/react/components/SplitPane.tsx +3 -12
- package/src/react/components/paneSplit.css +8 -0
- package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
- package/src/react/filter-rail/railResize.spec.tsx +27 -5
- package/src/react/filter-rail/railResize.tsx +46 -15
- package/src/react/filter-rail/railWidth.ts +13 -8
- package/src/react/index.ts +1 -0
- package/src/react/lib/paneReach.spec.ts +58 -0
- package/src/react/lib/paneReach.ts +65 -0
- package/src/react/master-detail/MasterDetail.tsx +10 -5
- package/src/react/media/videoEnhance.ts +1 -1
- package/src/testing/phoneRules.ts +2 -2
|
@@ -163,6 +163,11 @@
|
|
|
163
163
|
// <Name,…>`; with no list the rule is inert rather than guessing. Near spellings count
|
|
164
164
|
// too (task 2381): `CheckBox`, `StatusBadge`, `Stat`, `ToastStack`.
|
|
165
165
|
//
|
|
166
|
+
// no-hand-rolled-divider — owner, 2026-10-27: every divider drags as far as the hand takes
|
|
167
|
+
// it, shutting a pane included. An app imports `react-resizable-panels` only through
|
|
168
|
+
// cursedbelt's `SplitPane`/`PanelGrid`, and a seam it draws itself (a `separator` role
|
|
169
|
+
// with a pointer handler) takes its geometry from `reachSize`/`stepSize`. See its block.
|
|
170
|
+
//
|
|
166
171
|
// no-inline-style — DESIGN.md §3/§4 (task 2381): `style={{…}}` may carry only a CSS
|
|
167
172
|
// custom property or COMPUTED geometry; a literal size, color or font is a violation.
|
|
168
173
|
//
|
|
@@ -3101,6 +3106,61 @@ const noArbitraryLength: Rule = {
|
|
|
3101
3106
|
},
|
|
3102
3107
|
};
|
|
3103
3108
|
|
|
3109
|
+
// ── no-hand-rolled-divider (forge task 3839, 2026-10-27) ──────────────────────────
|
|
3110
|
+
// The owner: *"For all dividers … that I can drag to size the panes, let me drag them as far as I
|
|
3111
|
+
// want including shutting a pane completely … I don't want any panes to block me."* station's
|
|
3112
|
+
// Queue capped its task list at 36% (`MasterDetail`'s `max`), and desk's own seams each kept a
|
|
3113
|
+
// min and a max of their own, so every app's divider stopped somewhere different and none could
|
|
3114
|
+
// be shut.
|
|
3115
|
+
//
|
|
3116
|
+
// cursedbelt's dividers obey one rule (`src/react/lib/paneReach.ts`): no maximum, and a minimum
|
|
3117
|
+
// that is a SNAP — below half of it the pane shuts. `SplitPane`/`PanelGrid` cannot be capped (the
|
|
3118
|
+
// props are gone), `PaneSplit` and `useRailResize` call the rule. So in app source:
|
|
3119
|
+
// - importing `react-resizable-panels` directly is a violation — it is how a `maxSize` comes back;
|
|
3120
|
+
// - a seam drawn by hand (`role="separator"` in a file with a pointer or mouse-down handler) is
|
|
3121
|
+
// a violation unless the file imports `reachSize` or `stepSize` from cursedbelt, which is the
|
|
3122
|
+
// whole of what makes a hand-drawn seam obey the rule.
|
|
3123
|
+
// A drag that is not a pane divider (a spreadsheet's column-width grip) opts out on the block
|
|
3124
|
+
// above with `guardrails-ignore no-hand-rolled-divider: <why>`.
|
|
3125
|
+
const RAW_PANELS_IMPORT_RE = /from\s+["']react-resizable-panels["']/g;
|
|
3126
|
+
const SEPARATOR_ROLE_RE = /role\s*[=:]\s*\{?\s*["']separator["']/g;
|
|
3127
|
+
const SEAM_DRAG_RE = /\bon(?:Pointer|Mouse)Down\b|["'](?:pointer|mouse)down["']/;
|
|
3128
|
+
const REACH_IMPORT_RE = /import\s*\{[^}]*\b(?:reachSize|stepSize)\b[^}]*\}\s*from\s*["']cursedbelt\//;
|
|
3129
|
+
const DIVIDER_IGNORE_RE = /guardrails-ignore\s+no-hand-rolled-divider\s*:/;
|
|
3130
|
+
|
|
3131
|
+
const noHandRolledDivider: Rule = {
|
|
3132
|
+
name: "no-hand-rolled-divider",
|
|
3133
|
+
description:
|
|
3134
|
+
"owner, 2026-10-27 (forge task 3839): every divider drags as far as the hand takes it, shutting a pane included. Split panes are cursedbelt's SplitPane / PanelGrid / MasterDetail / PaneSplit / useRailResize — never react-resizable-panels directly (that is how a maxSize comes back) — and a seam an app draws itself takes its geometry from cursedbelt's reachSize/stepSize (cursedbelt/react/pane-split), so no pane has a maximum and any pane can be shut.",
|
|
3135
|
+
check(filePath, source) {
|
|
3136
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
3137
|
+
if (extname(normalized) === ".css" || isTestSource(normalized)) return [];
|
|
3138
|
+
const scannable = blankComments(source);
|
|
3139
|
+
const lines = source.split("\n");
|
|
3140
|
+
const optedOut = (line: number) => {
|
|
3141
|
+
const block: string[] = [];
|
|
3142
|
+
for (let i = line - 2; i >= 0 && block.length < 12; i--) {
|
|
3143
|
+
const raw = lines[i] ?? "";
|
|
3144
|
+
if (raw.trim() === "") break;
|
|
3145
|
+
block.push(raw);
|
|
3146
|
+
}
|
|
3147
|
+
return DIVIDER_IGNORE_RE.test(block.join("\n"));
|
|
3148
|
+
};
|
|
3149
|
+
const violations: Violation[] = [];
|
|
3150
|
+
for (const match of scannable.matchAll(RAW_PANELS_IMPORT_RE)) {
|
|
3151
|
+
const line = lineOf(source, match.index ?? 0);
|
|
3152
|
+
if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
3153
|
+
}
|
|
3154
|
+
if (SEAM_DRAG_RE.test(scannable) && !REACH_IMPORT_RE.test(scannable)) {
|
|
3155
|
+
for (const match of scannable.matchAll(SEPARATOR_ROLE_RE)) {
|
|
3156
|
+
const line = lineOf(source, match.index ?? 0);
|
|
3157
|
+
if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
return violations;
|
|
3161
|
+
},
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3104
3164
|
export const RULES: Record<string, Rule> = {
|
|
3105
3165
|
[noRawActionButton.name]: noRawActionButton,
|
|
3106
3166
|
[rawButtonResetsFace.name]: rawButtonResetsFace,
|
|
@@ -3114,6 +3174,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
3114
3174
|
[noRawDataTable.name]: noRawDataTable,
|
|
3115
3175
|
[noHandRolledPropertyList.name]: noHandRolledPropertyList,
|
|
3116
3176
|
[noRawKbd.name]: noRawKbd,
|
|
3177
|
+
[noHandRolledDivider.name]: noHandRolledDivider,
|
|
3117
3178
|
[noTailwindPalette.name]: noTailwindPalette,
|
|
3118
3179
|
[themeColoursResolve.name]: themeColoursResolve,
|
|
3119
3180
|
[noRawTitleAttr.name]: noRawTitleAttr,
|
|
@@ -48,13 +48,36 @@ describe('PaneSplit', () => {
|
|
|
48
48
|
expect(sizes[1]).toBeCloseTo(0.48);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// The owner, 2026-10-27: no divider is a wall — a pane can be shut, from either side.
|
|
52
|
+
test('Home and End shut the pane on either side of the seam', () => {
|
|
52
53
|
const onResize = draw();
|
|
53
54
|
const [, outer] = screen.getAllByRole('separator');
|
|
55
|
+
expect([outer!.getAttribute('aria-valuemin'), outer!.getAttribute('aria-valuemax')]).toEqual(['0', '100']);
|
|
54
56
|
fireEvent.keyDown(outer!, { key: 'Home' });
|
|
55
57
|
expect(onResize.mock.calls[0]![0]).toEqual([]);
|
|
56
|
-
expect(onResize.mock.calls[0]![1]
|
|
58
|
+
expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
|
|
57
59
|
fireEvent.keyDown(outer!, { key: 'End' });
|
|
58
|
-
expect(onResize.mock.calls[1]![1]
|
|
60
|
+
expect(onResize.mock.calls[1]![1]).toEqual([1, 0]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an arrow from the snap shuts the pane, and the next one opens it at the snap', () => {
|
|
64
|
+
const onResize = mock();
|
|
65
|
+
const at = (share: number): PaneSplitNode<{ id: string }> => ({
|
|
66
|
+
kind: 'split',
|
|
67
|
+
direction: 'row',
|
|
68
|
+
sizes: [share, 1 - share],
|
|
69
|
+
children: [
|
|
70
|
+
{ kind: 'leaf', pane: { id: 'a' } },
|
|
71
|
+
{ kind: 'leaf', pane: { id: 'b' } },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
const { rerender } = render(<PaneSplit node={at(0.12)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
|
|
75
|
+
fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowLeft' });
|
|
76
|
+
expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
|
|
77
|
+
rerender(<PaneSplit node={at(0)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
|
|
78
|
+
// A shut pane draws nothing, but its seam is still there to bring it back.
|
|
79
|
+
expect(screen.getByLabelText('a').parentElement!.hasAttribute('data-shut')).toBe(true);
|
|
80
|
+
fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowRight' });
|
|
81
|
+
expect(onResize.mock.calls[1]![1][0]).toBeCloseTo(0.12);
|
|
59
82
|
});
|
|
60
83
|
});
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* and the arithmetic that turns a pointer into a pair of fractions. Unlike `PanelGrid` it owns no
|
|
12
12
|
* sizes and persists nothing, which is what an app that stores its layout as a setting needs.
|
|
13
13
|
*
|
|
14
|
-
* 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End
|
|
14
|
+
* 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End shut the pane either side.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **No seam is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want including
|
|
17
|
+
* shutting a pane completely"*). `minFraction` is a snap, not a floor: `lib/paneReach.ts` shuts a pane
|
|
18
|
+
* dragged below half of it, and a shut pane's seam stays at the edge to drag it open again.
|
|
15
19
|
*
|
|
16
20
|
* 🔴 **The hit strip lives inside the seam's own track** — a real flex item, never a bar hung over
|
|
17
21
|
* the boundary. An invisible strip centered on the seam sits over the first pixels of the pane beside
|
|
@@ -25,8 +29,12 @@
|
|
|
25
29
|
|
|
26
30
|
import type { CSSProperties, JSX, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react';
|
|
27
31
|
import { useCallback, useRef } from 'react';
|
|
32
|
+
import { reachSize, stepSize } from '../lib/paneReach.js';
|
|
28
33
|
import './paneSplit.css';
|
|
29
34
|
|
|
35
|
+
/** The rule every divider obeys — re-exported for a seam an app draws itself (guardrails' `no-hand-rolled-divider`). */
|
|
36
|
+
export { reachSize, stepSize } from '../lib/paneReach.js';
|
|
37
|
+
|
|
30
38
|
export type PaneSplitDirection = 'row' | 'column';
|
|
31
39
|
|
|
32
40
|
export type PaneSplitNode<P> =
|
|
@@ -43,7 +51,10 @@ export interface PaneSplitProps<P> {
|
|
|
43
51
|
renderPane: (pane: P) => JSX.Element;
|
|
44
52
|
/** A seam moved: the split at `path` now has these fractions. */
|
|
45
53
|
onResize: (path: PaneSplitPath, sizes: number[]) => void;
|
|
46
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The snap: a pane dragged below half of this share shuts, and between half and all of it holds
|
|
56
|
+
* here (`lib/paneReach.ts`). Never a floor — any pane can be shut. Default 0.12.
|
|
57
|
+
*/
|
|
47
58
|
minFraction?: number;
|
|
48
59
|
/** Where `node` sits — only for a tree drawn from below its root. */
|
|
49
60
|
path?: PaneSplitPath;
|
|
@@ -79,16 +90,15 @@ function SplitNode<P>({
|
|
|
79
90
|
|
|
80
91
|
/** Set the share of the pair either side of seam `index`; only those two panes move. */
|
|
81
92
|
const setShare = useCallback(
|
|
82
|
-
(index: number,
|
|
93
|
+
(index: number, bounded: number) => {
|
|
83
94
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
84
95
|
if (pair <= 0) return;
|
|
85
|
-
const bounded = Math.min(Math.max(share, minFraction), 1 - minFraction);
|
|
86
96
|
const sizes = [...node.sizes];
|
|
87
97
|
sizes[index] = pair * bounded;
|
|
88
98
|
sizes[index + 1] = pair * (1 - bounded);
|
|
89
99
|
onResize(path, sizes);
|
|
90
100
|
},
|
|
91
|
-
[node.sizes,
|
|
101
|
+
[node.sizes, onResize, path],
|
|
92
102
|
);
|
|
93
103
|
|
|
94
104
|
const moveSeam = (index: number, position: number) => {
|
|
@@ -100,13 +110,13 @@ function SplitNode<P>({
|
|
|
100
110
|
const before = node.sizes.slice(0, index).reduce((sum, size) => sum + size, 0);
|
|
101
111
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
102
112
|
if (pair <= 0) return;
|
|
103
|
-
setShare(index, (offset - before) / pair);
|
|
113
|
+
setShare(index, reachSize((offset - before) / pair, minFraction, 1));
|
|
104
114
|
};
|
|
105
115
|
|
|
106
116
|
const nudgeSeam = (index: number, delta: number) => {
|
|
107
117
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
108
118
|
if (pair <= 0) return;
|
|
109
|
-
setShare(index, (node.sizes[index] ?? 0) / pair
|
|
119
|
+
setShare(index, stepSize((node.sizes[index] ?? 0) / pair, delta, minFraction, 1));
|
|
110
120
|
};
|
|
111
121
|
|
|
112
122
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
|
|
@@ -114,8 +124,8 @@ function SplitNode<P>({
|
|
|
114
124
|
const forward = node.direction === 'row' ? 'ArrowRight' : 'ArrowDown';
|
|
115
125
|
if (event.key === back) nudgeSeam(index, -0.02);
|
|
116
126
|
else if (event.key === forward) nudgeSeam(index, 0.02);
|
|
117
|
-
else if (event.key === 'Home')
|
|
118
|
-
else if (event.key === 'End')
|
|
127
|
+
else if (event.key === 'Home') setShare(index, 0);
|
|
128
|
+
else if (event.key === 'End') setShare(index, 1);
|
|
119
129
|
else return;
|
|
120
130
|
event.preventDefault();
|
|
121
131
|
};
|
|
@@ -138,7 +148,6 @@ function SplitNode<P>({
|
|
|
138
148
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
139
149
|
};
|
|
140
150
|
|
|
141
|
-
const minPercent = Math.round(minFraction * 100);
|
|
142
151
|
return (
|
|
143
152
|
<div className="cb-panesplit" data-direction={node.direction} ref={frame}>
|
|
144
153
|
{node.children.map((child, index) => (
|
|
@@ -149,8 +158,8 @@ function SplitNode<P>({
|
|
|
149
158
|
seam={{
|
|
150
159
|
direction: node.direction,
|
|
151
160
|
percent: Math.round((node.sizes[index] ?? 0) * 100),
|
|
152
|
-
min:
|
|
153
|
-
max: 100
|
|
161
|
+
min: 0,
|
|
162
|
+
max: 100,
|
|
154
163
|
onKeyDown: (event) => onKeyDown(event, index),
|
|
155
164
|
onPointerDown: (event) => onPointerDown(event, index),
|
|
156
165
|
onPointerMove,
|
|
@@ -178,7 +187,11 @@ interface SeamProps {
|
|
|
178
187
|
function ChildAndSeam({ last, size, seam, children }: { last: boolean; size: number; seam: SeamProps; children: JSX.Element }): JSX.Element {
|
|
179
188
|
return (
|
|
180
189
|
<>
|
|
181
|
-
<div
|
|
190
|
+
<div
|
|
191
|
+
className="cb-panesplit__cell"
|
|
192
|
+
data-shut={size <= 0 ? '' : undefined}
|
|
193
|
+
style={{ '--cb-panesplit-share': size } as CSSProperties}
|
|
194
|
+
>
|
|
182
195
|
{children}
|
|
183
196
|
</div>
|
|
184
197
|
{!last && (
|
|
@@ -18,11 +18,11 @@ export interface PanelGridLeaf {
|
|
|
18
18
|
* this exact confusion collapsed a contact list to ~34px).
|
|
19
19
|
*/
|
|
20
20
|
defaultSize?: number | string;
|
|
21
|
+
/**
|
|
22
|
+
* The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
|
|
23
|
+
* it holds here. Same rule as `lib/paneReach.ts`, applied by the library's collapsible panel.
|
|
24
|
+
*/
|
|
21
25
|
minSize?: number | string;
|
|
22
|
-
maxSize?: number | string;
|
|
23
|
-
/** Panel collapses to `collapsedSize` when dragged below `minSize`. */
|
|
24
|
-
collapsible?: boolean;
|
|
25
|
-
collapsedSize?: number | string;
|
|
26
26
|
className?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -38,10 +38,9 @@ export interface PanelGridSplit {
|
|
|
38
38
|
resizable?: boolean;
|
|
39
39
|
/** Divider look for THIS split's dividers (overrides the grid-level setting). */
|
|
40
40
|
separatorVariant?: PanelSeparatorVariant;
|
|
41
|
-
/** Sizing of this split when it is itself a cell of a parent split. */
|
|
41
|
+
/** Sizing of this split when it is itself a cell of a parent split (`minSize` is a snap, as on a leaf). */
|
|
42
42
|
defaultSize?: number | string;
|
|
43
43
|
minSize?: number | string;
|
|
44
|
-
maxSize?: number | string;
|
|
45
44
|
className?: string;
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -97,7 +96,7 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
|
|
|
97
96
|
* root={{
|
|
98
97
|
* type: 'split', id: 'row', direction: 'horizontal',
|
|
99
98
|
* children: [
|
|
100
|
-
* { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px'
|
|
99
|
+
* { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px' },
|
|
101
100
|
* {
|
|
102
101
|
* type: 'split', id: 'main', direction: 'vertical',
|
|
103
102
|
* children: [
|
|
@@ -110,7 +109,14 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
|
|
|
110
109
|
* />
|
|
111
110
|
* ```
|
|
112
111
|
*
|
|
113
|
-
*
|
|
112
|
+
* 🔴 **No divider is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want
|
|
113
|
+
* including shutting a pane completely … I don't want any panes to block me"*). So there is no
|
|
114
|
+
* `maxSize` and no `collapsible` to turn off: EVERY cell is collapsible to 0, and its `minSize`
|
|
115
|
+
* is the snap `lib/paneReach.ts` describes — below half of it the cell shuts, so the cell beside it
|
|
116
|
+
* can take the whole split. station's Queue capped its task list at 36% and cut off the titles it
|
|
117
|
+
* was opened to read; that is the bug this removes the means to write.
|
|
118
|
+
*
|
|
119
|
+
* Keyboard resizing (Enter shuts/opens a pane), hit-target enlargement, and
|
|
114
120
|
* aria roles come from react-resizable-panels; the divider look comes from
|
|
115
121
|
* `PanelSeparator` (single hairline + invisible wide hotspot — panel content
|
|
116
122
|
* must not draw its own border against a divider).
|
|
@@ -295,9 +301,8 @@ function GridCell({
|
|
|
295
301
|
id={child.id}
|
|
296
302
|
defaultSize={child.defaultSize}
|
|
297
303
|
minSize={child.minSize}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
collapsedSize={isSplit(child) ? undefined : child.collapsedSize}
|
|
304
|
+
collapsible
|
|
305
|
+
collapsedSize={0}
|
|
301
306
|
className={cn('min-h-0 min-w-0', child.className)}
|
|
302
307
|
>
|
|
303
308
|
{isSplit(child) ? (
|
|
@@ -105,15 +105,20 @@ describe('SplitPane', () => {
|
|
|
105
105
|
window.matchMedia = original;
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// The owner, 2026-10-27: no divider is a wall. There is no way to ASK for one — a cap or an
|
|
109
|
+
// un-collapsible pane does not type-check — and the drag reaching both edges is proven in a real
|
|
110
|
+
// browser (`e2e/pane-reach.spec.ts`).
|
|
111
|
+
test('a pane cannot be capped or made un-shuttable', () => {
|
|
109
112
|
const panels: SplitPaneItem[] = [
|
|
110
|
-
{ id: 'main', content: <div>Main</div
|
|
111
|
-
|
|
113
|
+
{ id: 'main', content: <div>Main</div>, minSize: '16rem' },
|
|
114
|
+
// @ts-expect-error — `maxSize` is gone: a pane grows until its neighbor shuts.
|
|
115
|
+
{ id: 'side', content: <div>Side</div>, maxSize: '40%' },
|
|
116
|
+
// @ts-expect-error — `collapsible` is gone: every pane can be shut.
|
|
117
|
+
{ id: 'third', content: <div>Third</div>, collapsible: false },
|
|
112
118
|
];
|
|
113
119
|
const { getByText, container } = render(<SplitPane panels={panels} />);
|
|
114
120
|
expect(getByText('Main')).toBeTruthy();
|
|
115
121
|
expect(getByText('Side')).toBeTruthy();
|
|
116
|
-
|
|
117
|
-
expect(container.querySelectorAll('[data-panel]').length).toBe(2);
|
|
122
|
+
expect(container.querySelectorAll('[data-panel]').length).toBe(3);
|
|
118
123
|
});
|
|
119
124
|
});
|
|
@@ -12,17 +12,11 @@ export interface SplitPaneItem {
|
|
|
12
12
|
* number is NOT treated as a percentage (it silently becomes a pixel size).
|
|
13
13
|
*/
|
|
14
14
|
defaultSize?: number | string;
|
|
15
|
-
/** Minimum size constraint. */
|
|
16
|
-
minSize?: number | string;
|
|
17
|
-
/** Maximum size constraint. */
|
|
18
|
-
maxSize?: number | string;
|
|
19
15
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
|
|
17
|
+
* it holds here (`lib/paneReach.ts`). There is no maximum and no opting out — see `PanelGrid`.
|
|
22
18
|
*/
|
|
23
|
-
|
|
24
|
-
/** Size when collapsed; defaults to 0. */
|
|
25
|
-
collapsedSize?: number | string;
|
|
19
|
+
minSize?: number | string;
|
|
26
20
|
className?: string;
|
|
27
21
|
}
|
|
28
22
|
|
|
@@ -70,9 +64,6 @@ export function SplitPane({
|
|
|
70
64
|
content: p.content,
|
|
71
65
|
defaultSize: p.defaultSize,
|
|
72
66
|
minSize: p.minSize,
|
|
73
|
-
maxSize: p.maxSize,
|
|
74
|
-
collapsible: p.collapsible,
|
|
75
|
-
collapsedSize: p.collapsedSize,
|
|
76
67
|
className: p.className,
|
|
77
68
|
}));
|
|
78
69
|
return (
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
min-height: 0;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/* A shut pane (dragged to nothing — `lib/paneReach.ts`) draws nothing and takes no focus; its
|
|
44
|
+
content would otherwise spill out of a zero-wide track. The seam beside it is a sibling, so it
|
|
45
|
+
stays to drag the pane open again. */
|
|
46
|
+
.cb-panesplit__cell[data-shut] {
|
|
47
|
+
visibility: hidden;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
.cb-panesplit__seam {
|
|
44
52
|
flex: 0 0 auto;
|
|
45
53
|
position: relative;
|
|
@@ -580,10 +580,14 @@ describe('FilterRail — resizeKey', () => {
|
|
|
580
580
|
expect(globalThis.localStorage?.getItem('test:rail')).toBeNull();
|
|
581
581
|
});
|
|
582
582
|
|
|
583
|
-
test('
|
|
584
|
-
globalThis.localStorage?.setItem('test:rail', '
|
|
585
|
-
const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
583
|
+
test('a stored sliver is never drawn — it opens at the snap, or shut below half of it', () => {
|
|
584
|
+
globalThis.localStorage?.setItem('test:rail', '120');
|
|
585
|
+
const { container, unmount } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
586
586
|
expect((container.querySelector('nav') as HTMLElement).style.width).toBe('160px');
|
|
587
|
+
unmount();
|
|
588
|
+
globalThis.localStorage?.setItem('test:rail', '20');
|
|
589
|
+
const shut = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
590
|
+
expect((shut.container.querySelector('nav') as HTMLElement).style.width).toBe('0px');
|
|
587
591
|
});
|
|
588
592
|
|
|
589
593
|
test('no handle while collapsed', () => {
|
|
@@ -49,13 +49,35 @@ describe('useRailResize + RailResizeHandle', () => {
|
|
|
49
49
|
expect(aside.style.width).toBe('250px');
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// The owner, 2026-10-27: "let me drag them as far as I want including shutting a pane completely".
|
|
53
|
+
test('no wall: a drag reaches the whole row one way and shuts the rail the other', () => {
|
|
53
54
|
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
fireEvent.
|
|
55
|
+
const h = handle as HTMLElement;
|
|
56
|
+
// happy-dom lays nothing out, so the row is the window's width.
|
|
57
|
+
const row = globalThis.innerWidth;
|
|
58
|
+
fireEvent.pointerDown(h, { button: 0, clientX: 0, pointerId: 1 });
|
|
59
|
+
fireEvent.pointerMove(h, { clientX: 5000, pointerId: 1 });
|
|
60
|
+
expect(aside.style.width).toBe(`${row}px`);
|
|
61
|
+
expect(aside.style.maxWidth).toBe('100%');
|
|
62
|
+
// Between half the snap and the snap it holds — no sliver of a rail.
|
|
63
|
+
fireEvent.pointerMove(h, { clientX: 100, pointerId: 1 });
|
|
64
|
+
expect(aside.style.width).toBe('160px');
|
|
65
|
+
fireEvent.pointerMove(h, { clientX: 60, pointerId: 1 });
|
|
66
|
+
fireEvent.pointerUp(h, { clientX: 60, pointerId: 1 });
|
|
67
|
+
expect(aside.style.width).toBe('0px');
|
|
68
|
+
expect(aside.style.visibility).toBe('hidden');
|
|
69
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('0');
|
|
70
|
+
// Shut, the handle is still there to drag it back, and an arrow opens it at the snap.
|
|
71
|
+
expect(h.style.visibility).toBe('visible');
|
|
72
|
+
fireEvent.keyDown(h, { key: 'ArrowRight' });
|
|
58
73
|
expect(aside.style.width).toBe('160px');
|
|
74
|
+
expect(aside.style.visibility).toBe('');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a shut rail is remembered shut', () => {
|
|
78
|
+
globalThis.localStorage?.setItem('t:rail', '0');
|
|
79
|
+
const { aside } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
80
|
+
expect(aside.style.width).toBe('0px');
|
|
59
81
|
});
|
|
60
82
|
|
|
61
83
|
test('a right-button press is not a drag', () => {
|
|
@@ -13,15 +13,19 @@
|
|
|
13
13
|
* reach;
|
|
14
14
|
* - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
|
|
15
15
|
* - **double-click resets** to the rail's own default width, and forgets the stored one;
|
|
16
|
-
* - remembered on RELEASE — a drag is one decision, not sixty writes a second
|
|
16
|
+
* - remembered on RELEASE — a drag is one decision, not sixty writes a second;
|
|
17
|
+
* - 🔴 **no wall** (the owner, 2026-10-27): the drag reaches the far side of the row the rail
|
|
18
|
+
* shares, and below half of `MIN_RAIL_PX` the rail SHUTS — `lib/paneReach.ts`, the rule every
|
|
19
|
+
* cursedbelt divider obeys. A shut rail draws nothing but its handle, at the edge, to drag back.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* Storage is `railWidth.ts`, so the width is announced through `preferenceSync` like every other
|
|
22
|
+
* viewer preference.
|
|
20
23
|
*/
|
|
21
24
|
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
|
22
25
|
import { useCallback, useRef, useState } from 'react';
|
|
23
26
|
import { cn } from '../lib/cn.js';
|
|
24
|
-
import {
|
|
27
|
+
import { reachSize, stepSize } from '../lib/paneReach.js';
|
|
28
|
+
import { MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
25
29
|
|
|
26
30
|
export interface UseRailResizeOptions {
|
|
27
31
|
/** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
|
|
@@ -37,9 +41,15 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
|
37
41
|
railRef: RefObject<E | null>;
|
|
38
42
|
/** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
|
|
39
43
|
active: boolean;
|
|
40
|
-
/** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
|
|
44
|
+
/** The dragged width in px (0 = shut), or `null` for "the rail's own default width". Null while inactive. */
|
|
41
45
|
width: number | null;
|
|
42
|
-
/**
|
|
46
|
+
/** Dragged all the way shut. The rail must then draw nothing but the handle — `style` does it. */
|
|
47
|
+
shut: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Spread onto the rail's `style` — `undefined` at the default width. 🔴 Spread ALL of it, not
|
|
50
|
+
* just `width`: a shut rail is also borderless and `visibility: hidden` (its content would
|
|
51
|
+
* otherwise spill out of a zero-wide box), and a wide one is held to its row by `maxWidth`.
|
|
52
|
+
*/
|
|
43
53
|
style: CSSProperties | undefined;
|
|
44
54
|
/** Forget the dragged width (what a double-click on the handle does). */
|
|
45
55
|
reset: () => void;
|
|
@@ -50,6 +60,8 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
|
50
60
|
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
|
51
61
|
/** The rail's current rendered width, for `aria-valuenow` before any drag. */
|
|
52
62
|
measure: () => number;
|
|
63
|
+
/** The width of the row the rail shares — how far it can grow. */
|
|
64
|
+
room: () => number;
|
|
53
65
|
};
|
|
54
66
|
}
|
|
55
67
|
|
|
@@ -62,6 +74,12 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
62
74
|
const [dragged, setDragged] = useState<number | null>(() => (resizeKey ? readRailWidth(resizeKey) : null));
|
|
63
75
|
const active = Boolean(resizeKey) && enabled;
|
|
64
76
|
const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
|
|
77
|
+
// The row the rail shares with the page it navigates. A box with no layout (a test DOM, a hidden
|
|
78
|
+
// tab) measures 0, so the window stands in — never 0, which would make every width "shut".
|
|
79
|
+
const room = useCallback(() => {
|
|
80
|
+
const row = railRef.current?.parentElement?.getBoundingClientRect().width ?? 0;
|
|
81
|
+
return row > 0 ? row : globalThis.innerWidth || MIN_RAIL_PX * 4;
|
|
82
|
+
}, []);
|
|
65
83
|
|
|
66
84
|
const onPointerDown = useCallback(
|
|
67
85
|
(event: ReactPointerEvent<HTMLElement>) => {
|
|
@@ -69,10 +87,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
69
87
|
const handle = event.currentTarget;
|
|
70
88
|
const startX = event.clientX;
|
|
71
89
|
const startWidth = measure();
|
|
90
|
+
const total = room();
|
|
72
91
|
let latest = startWidth;
|
|
73
92
|
handle.setPointerCapture?.(event.pointerId);
|
|
74
93
|
const move = (moveEvent: PointerEvent) => {
|
|
75
|
-
latest =
|
|
94
|
+
latest = Math.round(reachSize(startWidth + moveEvent.clientX - startX, MIN_RAIL_PX, total));
|
|
76
95
|
setDragged(latest);
|
|
77
96
|
};
|
|
78
97
|
const end = () => {
|
|
@@ -87,7 +106,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
87
106
|
handle.addEventListener('pointercancel', end);
|
|
88
107
|
event.preventDefault();
|
|
89
108
|
},
|
|
90
|
-
[resizeKey, measure],
|
|
109
|
+
[resizeKey, measure, room],
|
|
91
110
|
);
|
|
92
111
|
|
|
93
112
|
const onKeyDown = useCallback(
|
|
@@ -97,11 +116,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
97
116
|
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
98
117
|
if (!delta) return;
|
|
99
118
|
event.preventDefault();
|
|
100
|
-
const next =
|
|
119
|
+
const next = Math.round(stepSize(dragged ?? measure(), delta, MIN_RAIL_PX, room()));
|
|
101
120
|
setDragged(next);
|
|
102
121
|
writeRailWidth(resizeKey, next);
|
|
103
122
|
},
|
|
104
|
-
[resizeKey, dragged, measure],
|
|
123
|
+
[resizeKey, dragged, measure, room],
|
|
105
124
|
);
|
|
106
125
|
|
|
107
126
|
const reset = useCallback(() => {
|
|
@@ -110,16 +129,27 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
110
129
|
}, [resizeKey]);
|
|
111
130
|
|
|
112
131
|
const width = active ? dragged : null;
|
|
132
|
+
const shut = width === 0;
|
|
113
133
|
return {
|
|
114
134
|
railRef,
|
|
115
135
|
active,
|
|
116
136
|
width,
|
|
117
|
-
|
|
137
|
+
shut,
|
|
138
|
+
style: width === null ? undefined : shut ? SHUT_STYLE : { width, maxWidth: '100%' },
|
|
118
139
|
reset,
|
|
119
|
-
handle: { label, onPointerDown, onKeyDown, measure },
|
|
140
|
+
handle: { label, onPointerDown, onKeyDown, measure, room },
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
|
|
144
|
+
/** A shut rail: no width, no padding (a padded box is never narrower than its padding), no border,
|
|
145
|
+
* and nothing drawn but the handle, which opts back in. `overflow: visible`, because a rail that
|
|
146
|
+
* scrolls itself (`overflow-y-auto`) would clip its handle out of a zero-wide box — still in the
|
|
147
|
+
* DOM, never under the pointer; its content is hidden by `visibility` instead. */
|
|
148
|
+
const SHUT_STYLE: CSSProperties = { width: 0, minWidth: 0, padding: 0, borderRightWidth: 0, overflow: 'visible', visibility: 'hidden' };
|
|
149
|
+
/** The shut rail's handle: visible again, and wholly on the page side of the edge, so all 12px of
|
|
150
|
+
* it can be grabbed even when the rail sits against the window's edge. */
|
|
151
|
+
const SHUT_HANDLE_STYLE: CSSProperties = { visibility: 'visible', right: -12 };
|
|
152
|
+
|
|
123
153
|
/**
|
|
124
154
|
* The handle itself — a 12px hotspot centered on the rail's right border. The RAIL must be
|
|
125
155
|
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
@@ -130,7 +160,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
|
130
160
|
*/
|
|
131
161
|
export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResize; className?: string }) {
|
|
132
162
|
if (!rail.active) return null;
|
|
133
|
-
const { label, onPointerDown, onKeyDown, measure } = rail.handle;
|
|
163
|
+
const { label, onPointerDown, onKeyDown, measure, room } = rail.handle;
|
|
134
164
|
return (
|
|
135
165
|
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
136
166
|
<div
|
|
@@ -138,9 +168,10 @@ export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResiz
|
|
|
138
168
|
role='separator'
|
|
139
169
|
aria-orientation='vertical'
|
|
140
170
|
aria-label={`Resize ${label}`}
|
|
141
|
-
aria-valuemin={
|
|
142
|
-
aria-valuemax={
|
|
171
|
+
aria-valuemin={0}
|
|
172
|
+
aria-valuemax={Math.round(room())}
|
|
143
173
|
aria-valuenow={Math.round(rail.width ?? measure())}
|
|
174
|
+
{...(rail.shut ? { style: SHUT_HANDLE_STYLE } : {})}
|
|
144
175
|
tabIndex={0}
|
|
145
176
|
data-cb-rail-resize=''
|
|
146
177
|
onPointerDown={onPointerDown}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How wide the person dragged a sidebar — the pure half, so the
|
|
2
|
+
* How wide the person dragged a sidebar — the pure half, so the storage is testable without a
|
|
3
3
|
* pointer.
|
|
4
4
|
*
|
|
5
5
|
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
@@ -13,22 +13,27 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Narrower than this and a row is an icon and an ellipsis — so it is the SNAP, not a floor:
|
|
18
|
+
* dragged below half of it the rail shuts (width 0), between half and all of it it holds here
|
|
19
|
+
* (`lib/paneReach.ts`). There is no maximum: the owner, 2026-10-27, *"let me drag them as far as
|
|
20
|
+
* I want including shutting a pane completely"* — a rail grows until the page beside it is shut.
|
|
21
|
+
*/
|
|
17
22
|
export const MIN_RAIL_PX = 160;
|
|
18
|
-
/** Wider than this and the rail is eating the page it navigates. */
|
|
19
|
-
export const MAX_RAIL_PX = 640;
|
|
20
23
|
|
|
24
|
+
/** A width as stored: whole pixels, and snapped at the bottom like a drag (0 is a shut rail), so a
|
|
25
|
+
* sliver written by an older build or by hand is never drawn. No top — `maxWidth` holds it to its row. */
|
|
21
26
|
export const clampRailWidth = (px: number): number =>
|
|
22
|
-
Math.round(Math.
|
|
27
|
+
px < MIN_RAIL_PX / 2 ? 0 : Math.round(Math.max(MIN_RAIL_PX, px));
|
|
23
28
|
|
|
24
|
-
/** The remembered width, or null for "the rail's default". Wrapped: `localStorage`
|
|
25
|
-
* a sandboxed iframe and in Safari's private mode. */
|
|
29
|
+
/** The remembered width (0 = shut), or null for "the rail's default". Wrapped: `localStorage`
|
|
30
|
+
* throws in a sandboxed iframe and in Safari's private mode. */
|
|
26
31
|
export function readRailWidth(key: string): number | null {
|
|
27
32
|
try {
|
|
28
33
|
const raw = globalThis.localStorage?.getItem(key);
|
|
29
34
|
if (raw == null) return null;
|
|
30
35
|
const stored = Number(raw);
|
|
31
|
-
return Number.isFinite(stored) && stored
|
|
36
|
+
return raw.trim() !== '' && Number.isFinite(stored) && stored >= 0 ? clampRailWidth(stored) : null;
|
|
32
37
|
} catch {
|
|
33
38
|
return null;
|
|
34
39
|
}
|
package/src/react/index.ts
CHANGED
|
@@ -674,6 +674,7 @@ export {
|
|
|
674
674
|
type SplitPaneItem,
|
|
675
675
|
type SplitPaneProps,
|
|
676
676
|
} from './components/SplitPane.js';
|
|
677
|
+
export { reachSize, stepSize } from './lib/paneReach.js';
|
|
677
678
|
export { Sparkline, type SparklineProps } from './components/Sparkline.js';
|
|
678
679
|
export { StatStrip, type StatStripItem, type StatStripProps } from './components/StatStrip.js';
|
|
679
680
|
export {
|