cursedbelt 5.8.0 → 5.9.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/filter-rail/FilterRail.d.ts.map +1 -1
- package/dist/react/filter-rail/FilterRail.js +5 -52
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- package/dist/react/filter-rail/index.d.ts +1 -0
- package/dist/react/filter-rail/index.d.ts.map +1 -1
- package/dist/react/filter-rail/index.js +1 -0
- package/dist/react/filter-rail/index.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +63 -0
- package/dist/react/filter-rail/railResize.d.ts.map +1 -0
- package/dist/react/filter-rail/railResize.js +79 -0
- package/dist/react/filter-rail/railResize.js.map +1 -0
- package/package.json +1 -1
- package/src/react/filter-rail/FilterRail.tsx +10 -78
- package/src/react/filter-rail/index.ts +1 -0
- package/src/react/filter-rail/railResize.spec.tsx +92 -0
- package/src/react/filter-rail/railResize.tsx +156 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FilterRail.d.ts","sourceRoot":"","sources":["../../../src/react/filter-rail/FilterRail.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"FilterRail.d.ts","sourceRoot":"","sources":["../../../src/react/filter-rail/FilterRail.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
|
|
3
|
-
import { useCallback, useRef, useState } from 'react';
|
|
4
3
|
import { ScrollArea } from '../components/ScrollArea.js';
|
|
5
4
|
import { cn } from '../lib/cn.js';
|
|
6
5
|
import { Tooltip } from '../components/Tooltip.js';
|
|
7
6
|
import { TAP_TARGET, TAP_TARGET_ALWAYS, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
8
|
-
import {
|
|
7
|
+
import { RailResizeHandle, useRailResize } from './railResize.js';
|
|
9
8
|
/**
|
|
10
9
|
* The rail's body — one long scroller, or a flex column that hands its leftover height to a
|
|
11
10
|
* section that asked for it.
|
|
@@ -65,56 +64,10 @@ function RailBody({ filling, children }) {
|
|
|
65
64
|
* {@link FilterRailSection.selected}.
|
|
66
65
|
*/
|
|
67
66
|
export function FilterRail({ sections, selected, onSelect, label, collapsed = false, onCollapsedChange, header, footer, width = 'w-56', resizeKey, className, }) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
if (!resizeKey || event.button !== 0)
|
|
73
|
-
return;
|
|
74
|
-
const handle = event.currentTarget;
|
|
75
|
-
const startX = event.clientX;
|
|
76
|
-
const startWidth = navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX;
|
|
77
|
-
let latest = startWidth;
|
|
78
|
-
handle.setPointerCapture?.(event.pointerId);
|
|
79
|
-
const move = (moveEvent) => {
|
|
80
|
-
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
81
|
-
setDragged(latest);
|
|
82
|
-
};
|
|
83
|
-
const end = () => {
|
|
84
|
-
handle.releasePointerCapture?.(event.pointerId);
|
|
85
|
-
handle.removeEventListener('pointermove', move);
|
|
86
|
-
handle.removeEventListener('pointerup', end);
|
|
87
|
-
handle.removeEventListener('pointercancel', end);
|
|
88
|
-
// Remembered on RELEASE — a drag is one decision, not sixty a second.
|
|
89
|
-
writeRailWidth(resizeKey, latest);
|
|
90
|
-
};
|
|
91
|
-
handle.addEventListener('pointermove', move);
|
|
92
|
-
handle.addEventListener('pointerup', end);
|
|
93
|
-
handle.addEventListener('pointercancel', end);
|
|
94
|
-
event.preventDefault();
|
|
95
|
-
}, [resizeKey]);
|
|
96
|
-
const onResizeKey = useCallback((event) => {
|
|
97
|
-
if (!resizeKey)
|
|
98
|
-
return;
|
|
99
|
-
const step = event.shiftKey ? 64 : 16;
|
|
100
|
-
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
101
|
-
if (!delta)
|
|
102
|
-
return;
|
|
103
|
-
event.preventDefault();
|
|
104
|
-
const next = clampRailWidth((dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX) + delta);
|
|
105
|
-
setDragged(next);
|
|
106
|
-
writeRailWidth(resizeKey, next);
|
|
107
|
-
}, [resizeKey, dragged]);
|
|
108
|
-
return (_jsxs("nav", { ref: navRef, "aria-label": label, "data-cb-filter-rail": collapsed ? 'collapsed' : 'expanded', className: cn('flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface', collapsed ? 'w-14' : dragged !== null && resizing ? undefined : width, resizing && 'relative', className), ...(resizing && dragged !== null ? { style: { width: dragged } } : {}), children: [resizing ? (_jsx("div", { role: 'separator', "aria-orientation": 'vertical', "aria-label": `Resize ${label}`, "aria-valuemin": MIN_RAIL_PX, "aria-valuemax": MAX_RAIL_PX, "aria-valuenow": Math.round(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX), tabIndex: 0, "data-cb-rail-resize": '', onPointerDown: onResizePointerDown, onKeyDown: onResizeKey, onDoubleClick: () => {
|
|
109
|
-
setDragged(null);
|
|
110
|
-
if (resizeKey)
|
|
111
|
-
writeRailWidth(resizeKey, null);
|
|
112
|
-
},
|
|
113
|
-
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the
|
|
114
|
-
// border, and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
115
|
-
// Only utilities the stylesheet already ships, so the handle adds no CSS to any app
|
|
116
|
-
// (`fixtureAppCss.spec.ts` budgets that).
|
|
117
|
-
className: 'absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' })) : null, onCollapsedChange ? (_jsxs("div", { className: cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center'), children: [!collapsed && _jsx("span", { className: 'flex-1' }), _jsx(Tooltip, { content: collapsed ? `Expand ${label}` : `Collapse ${label}`, children: _jsx("button", { type: 'button',
|
|
67
|
+
// The drag, the keys, the clamp, the storage and the reset are `railResize.tsx`, shared with
|
|
68
|
+
// every sidebar that is not a FilterRail (music's LibraryRail).
|
|
69
|
+
const rail = useRailResize({ resizeKey, label, enabled: !collapsed });
|
|
70
|
+
return (_jsxs("nav", { ref: rail.railRef, "aria-label": label, "data-cb-filter-rail": collapsed ? 'collapsed' : 'expanded', className: cn('flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface', collapsed ? 'w-14' : rail.width !== null ? undefined : width, rail.active && 'relative', className), ...(rail.style ? { style: rail.style } : {}), children: [_jsx(RailResizeHandle, { rail: rail }), onCollapsedChange ? (_jsxs("div", { className: cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center'), children: [!collapsed && _jsx("span", { className: 'flex-1' }), _jsx(Tooltip, { content: collapsed ? `Expand ${label}` : `Collapse ${label}`, children: _jsx("button", { type: 'button',
|
|
118
71
|
// guardrails-ignore no-raw-action-button: the rail's own chrome control — an
|
|
119
72
|
// IconButton here would pull the Button/Tooltip graph into every rail consumer.
|
|
120
73
|
onClick: () => onCollapsedChange(!collapsed), "aria-label": collapsed ? `Expand ${label}` : `Collapse ${label}`, "aria-expanded": !collapsed, className: `grid size-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card-raised hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET} [&_svg]:size-4`, children: collapsed ? _jsx(ChevronsRight, {}) : _jsx(ChevronsLeft, {}) }) })] })) : null, header && !collapsed ? _jsx("div", { className: 'flex-none px-2 pt-2', children: header }) : null, _jsx(RailBody, { filling: sections.some((section) => section.fill === true), children: sections.map((section) => (_jsxs("div", { className: cn('mb-3 last:mb-0',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FilterRail.js","sourceRoot":"","sources":["../../../src/react/filter-rail/FilterRail.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE3D,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"FilterRail.js","sourceRoot":"","sources":["../../../src/react/filter-rail/FilterRail.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/filter-rail/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/filter-rail/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,KAAK,oBAAoB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/filter-rail/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,GAIX,MAAM,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/filter-rail/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,GAIX,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,gBAAgB,EAA8C,aAAa,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag a sidebar's right edge to resize it, like VS Code's side bar — the gesture half, for
|
|
3
|
+
* any rail, not only `FilterRail`.
|
|
4
|
+
*
|
|
5
|
+
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
6
|
+
* dragging to resize them like in vscode."* 5.8.0 shipped the handle INSIDE `FilterRail`; the
|
|
7
|
+
* sidebars in music (`LibraryRail`) and others are their own columns around a `FileTree`, so
|
|
8
|
+
* the handle came out here once rather than being copied into each app. `FilterRail` uses
|
|
9
|
+
* exactly this, so there is one gesture to get right:
|
|
10
|
+
*
|
|
11
|
+
* - **pointer capture**, or the drag ends the first time the pointer outruns the strip;
|
|
12
|
+
* - **←/→** (Shift for bigger steps), because a drag-only control is one some people cannot
|
|
13
|
+
* reach;
|
|
14
|
+
* - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* Clamp and storage are `railWidth.ts`, so the width is announced through `preferenceSync`
|
|
19
|
+
* like every other viewer preference.
|
|
20
|
+
*/
|
|
21
|
+
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
|
22
|
+
export interface UseRailResizeOptions {
|
|
23
|
+
/** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
|
|
24
|
+
resizeKey: string | undefined;
|
|
25
|
+
/** The rail's accessible name; the handle announces as "Resize <label>". */
|
|
26
|
+
label: string;
|
|
27
|
+
/** False hides the handle and drops the dragged width (a collapsed rail). Default true. */
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
31
|
+
/** Put on the rail element — the handle measures it to start a drag. */
|
|
32
|
+
railRef: RefObject<E | null>;
|
|
33
|
+
/** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
|
|
34
|
+
active: boolean;
|
|
35
|
+
/** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
|
|
36
|
+
width: number | null;
|
|
37
|
+
/** `{ width }` while there is a dragged width, else `undefined` — spread onto the rail's `style`. */
|
|
38
|
+
style: CSSProperties | undefined;
|
|
39
|
+
/** Forget the dragged width (what a double-click on the handle does). */
|
|
40
|
+
reset: () => void;
|
|
41
|
+
/** @internal — read by `RailResizeHandle`. */
|
|
42
|
+
readonly handle: {
|
|
43
|
+
label: string;
|
|
44
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
45
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
|
46
|
+
/** The rail's current rendered width, for `aria-valuenow` before any drag. */
|
|
47
|
+
measure: () => number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export declare function useRailResize<E extends HTMLElement = HTMLElement>({ resizeKey, label, enabled, }: UseRailResizeOptions): RailResize<E>;
|
|
51
|
+
/**
|
|
52
|
+
* The handle itself — a 12px hotspot centred on the rail's right border. The RAIL must be
|
|
53
|
+
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
54
|
+
* the resize is inactive.
|
|
55
|
+
*
|
|
56
|
+
* Only utilities the stylesheet already ships, so it adds no CSS to any app that imports the
|
|
57
|
+
* `filter-rail` area (`fixtureAppCss.spec.ts` budgets that).
|
|
58
|
+
*/
|
|
59
|
+
export declare function RailResizeHandle({ rail, className }: {
|
|
60
|
+
rail: RailResize;
|
|
61
|
+
className?: string;
|
|
62
|
+
}): import("react").JSX.Element | null;
|
|
63
|
+
//# sourceMappingURL=railResize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"railResize.d.ts","sourceRoot":"","sources":["../../../src/react/filter-rail/railResize.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,IAAI,kBAAkB,EAAE,YAAY,IAAI,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAK9H,MAAM,WAAW,oBAAoB;IACnC,yFAAyF;IACzF,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,2FAA2F;IAC3F,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IAC7D,wEAAwE;IACxE,OAAO,EAAE,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC7B,gFAAgF;IAChF,MAAM,EAAE,OAAO,CAAC;IAChB,kGAAkG;IAClG,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qGAAqG;IACrG,KAAK,EAAE,aAAa,GAAG,SAAS,CAAC;IACjC,yEAAyE;IACzE,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,MAAM,EAAE;QACf,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAC/D,SAAS,EAAE,CAAC,KAAK,EAAE,kBAAkB,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;QAC5D,8EAA8E;QAC9E,OAAO,EAAE,MAAM,MAAM,CAAC;KACvB,CAAC;CACH;AAED,wBAAgB,aAAa,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,EACjE,SAAS,EACT,KAAK,EACL,OAAc,GACf,EAAE,oBAAoB,GAAG,UAAU,CAAC,CAAC,CAAC,CA6DtC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,sCAyB7F"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useRef, useState } from 'react';
|
|
3
|
+
import { cn } from '../lib/cn.js';
|
|
4
|
+
import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
5
|
+
export function useRailResize({ resizeKey, label, enabled = true, }) {
|
|
6
|
+
const railRef = useRef(null);
|
|
7
|
+
const [dragged, setDragged] = useState(() => (resizeKey ? readRailWidth(resizeKey) : null));
|
|
8
|
+
const active = Boolean(resizeKey) && enabled;
|
|
9
|
+
const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
|
|
10
|
+
const onPointerDown = useCallback((event) => {
|
|
11
|
+
if (!resizeKey || event.button !== 0)
|
|
12
|
+
return;
|
|
13
|
+
const handle = event.currentTarget;
|
|
14
|
+
const startX = event.clientX;
|
|
15
|
+
const startWidth = measure();
|
|
16
|
+
let latest = startWidth;
|
|
17
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
18
|
+
const move = (moveEvent) => {
|
|
19
|
+
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
20
|
+
setDragged(latest);
|
|
21
|
+
};
|
|
22
|
+
const end = () => {
|
|
23
|
+
if (handle.hasPointerCapture?.(event.pointerId))
|
|
24
|
+
handle.releasePointerCapture(event.pointerId);
|
|
25
|
+
handle.removeEventListener('pointermove', move);
|
|
26
|
+
handle.removeEventListener('pointerup', end);
|
|
27
|
+
handle.removeEventListener('pointercancel', end);
|
|
28
|
+
writeRailWidth(resizeKey, latest);
|
|
29
|
+
};
|
|
30
|
+
handle.addEventListener('pointermove', move);
|
|
31
|
+
handle.addEventListener('pointerup', end);
|
|
32
|
+
handle.addEventListener('pointercancel', end);
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
}, [resizeKey, measure]);
|
|
35
|
+
const onKeyDown = useCallback((event) => {
|
|
36
|
+
if (!resizeKey)
|
|
37
|
+
return;
|
|
38
|
+
const step = event.shiftKey ? 64 : 16;
|
|
39
|
+
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
40
|
+
if (!delta)
|
|
41
|
+
return;
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
const next = clampRailWidth((dragged ?? measure()) + delta);
|
|
44
|
+
setDragged(next);
|
|
45
|
+
writeRailWidth(resizeKey, next);
|
|
46
|
+
}, [resizeKey, dragged, measure]);
|
|
47
|
+
const reset = useCallback(() => {
|
|
48
|
+
setDragged(null);
|
|
49
|
+
if (resizeKey)
|
|
50
|
+
writeRailWidth(resizeKey, null);
|
|
51
|
+
}, [resizeKey]);
|
|
52
|
+
const width = active ? dragged : null;
|
|
53
|
+
return {
|
|
54
|
+
railRef,
|
|
55
|
+
active,
|
|
56
|
+
width,
|
|
57
|
+
style: width === null ? undefined : { width },
|
|
58
|
+
reset,
|
|
59
|
+
handle: { label, onPointerDown, onKeyDown, measure },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The handle itself — a 12px hotspot centred on the rail's right border. The RAIL must be
|
|
64
|
+
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
65
|
+
* the resize is inactive.
|
|
66
|
+
*
|
|
67
|
+
* Only utilities the stylesheet already ships, so it adds no CSS to any app that imports the
|
|
68
|
+
* `filter-rail` area (`fixtureAppCss.spec.ts` budgets that).
|
|
69
|
+
*/
|
|
70
|
+
export function RailResizeHandle({ rail, className }) {
|
|
71
|
+
if (!rail.active)
|
|
72
|
+
return null;
|
|
73
|
+
const { label, onPointerDown, onKeyDown, measure } = rail.handle;
|
|
74
|
+
return (_jsx("div", { role: 'separator', "aria-orientation": 'vertical', "aria-label": `Resize ${label}`, "aria-valuemin": MIN_RAIL_PX, "aria-valuemax": MAX_RAIL_PX, "aria-valuenow": Math.round(rail.width ?? measure()), tabIndex: 0, "data-cb-rail-resize": '', onPointerDown: onPointerDown, onKeyDown: onKeyDown, onDoubleClick: rail.reset,
|
|
75
|
+
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the border,
|
|
76
|
+
// and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
77
|
+
className: cn('absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className) }));
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=railResize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"railResize.js","sourceRoot":"","sources":["../../../src/react/filter-rail/railResize.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
|
|
2
|
-
import type { DragEvent,
|
|
3
|
-
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { DragEvent, ReactNode } from 'react';
|
|
4
3
|
import { ScrollArea } from '../components/ScrollArea.js';
|
|
5
4
|
import { cn } from '../lib/cn.js';
|
|
6
5
|
import { Tooltip } from '../components/Tooltip.js';
|
|
7
6
|
import { TAP_TARGET, TAP_TARGET_ALWAYS, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
8
|
-
import {
|
|
7
|
+
import { RailResizeHandle, useRailResize } from './railResize.js';
|
|
9
8
|
|
|
10
9
|
export interface FilterRailItem {
|
|
11
10
|
/** Stable key, and the value handed to `onSelect`. */
|
|
@@ -249,90 +248,23 @@ export function FilterRail({
|
|
|
249
248
|
resizeKey,
|
|
250
249
|
className,
|
|
251
250
|
}: FilterRailProps) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
);
|
|
256
|
-
const resizing = Boolean(resizeKey) && !collapsed;
|
|
257
|
-
const onResizePointerDown = useCallback(
|
|
258
|
-
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
259
|
-
if (!resizeKey || event.button !== 0) return;
|
|
260
|
-
const handle = event.currentTarget;
|
|
261
|
-
const startX = event.clientX;
|
|
262
|
-
const startWidth = navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX;
|
|
263
|
-
let latest = startWidth;
|
|
264
|
-
handle.setPointerCapture?.(event.pointerId);
|
|
265
|
-
const move = (moveEvent: PointerEvent) => {
|
|
266
|
-
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
267
|
-
setDragged(latest);
|
|
268
|
-
};
|
|
269
|
-
const end = () => {
|
|
270
|
-
handle.releasePointerCapture?.(event.pointerId);
|
|
271
|
-
handle.removeEventListener('pointermove', move);
|
|
272
|
-
handle.removeEventListener('pointerup', end);
|
|
273
|
-
handle.removeEventListener('pointercancel', end);
|
|
274
|
-
// Remembered on RELEASE — a drag is one decision, not sixty a second.
|
|
275
|
-
writeRailWidth(resizeKey, latest);
|
|
276
|
-
};
|
|
277
|
-
handle.addEventListener('pointermove', move);
|
|
278
|
-
handle.addEventListener('pointerup', end);
|
|
279
|
-
handle.addEventListener('pointercancel', end);
|
|
280
|
-
event.preventDefault();
|
|
281
|
-
},
|
|
282
|
-
[resizeKey],
|
|
283
|
-
);
|
|
284
|
-
const onResizeKey = useCallback(
|
|
285
|
-
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
286
|
-
if (!resizeKey) return;
|
|
287
|
-
const step = event.shiftKey ? 64 : 16;
|
|
288
|
-
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
289
|
-
if (!delta) return;
|
|
290
|
-
event.preventDefault();
|
|
291
|
-
const next = clampRailWidth(
|
|
292
|
-
(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX) + delta,
|
|
293
|
-
);
|
|
294
|
-
setDragged(next);
|
|
295
|
-
writeRailWidth(resizeKey, next);
|
|
296
|
-
},
|
|
297
|
-
[resizeKey, dragged],
|
|
298
|
-
);
|
|
251
|
+
// The drag, the keys, the clamp, the storage and the reset are `railResize.tsx`, shared with
|
|
252
|
+
// every sidebar that is not a FilterRail (music's LibraryRail).
|
|
253
|
+
const rail = useRailResize({ resizeKey, label, enabled: !collapsed });
|
|
299
254
|
return (
|
|
300
255
|
<nav
|
|
301
|
-
ref={
|
|
256
|
+
ref={rail.railRef}
|
|
302
257
|
aria-label={label}
|
|
303
258
|
data-cb-filter-rail={collapsed ? 'collapsed' : 'expanded'}
|
|
304
259
|
className={cn(
|
|
305
260
|
'flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface',
|
|
306
|
-
collapsed ? 'w-14' :
|
|
307
|
-
|
|
261
|
+
collapsed ? 'w-14' : rail.width !== null ? undefined : width,
|
|
262
|
+
rail.active && 'relative',
|
|
308
263
|
className,
|
|
309
264
|
)}
|
|
310
|
-
{...(
|
|
265
|
+
{...(rail.style ? { style: rail.style } : {})}
|
|
311
266
|
>
|
|
312
|
-
{
|
|
313
|
-
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
314
|
-
<div
|
|
315
|
-
role='separator'
|
|
316
|
-
aria-orientation='vertical'
|
|
317
|
-
aria-label={`Resize ${label}`}
|
|
318
|
-
aria-valuemin={MIN_RAIL_PX}
|
|
319
|
-
aria-valuemax={MAX_RAIL_PX}
|
|
320
|
-
aria-valuenow={Math.round(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX)}
|
|
321
|
-
tabIndex={0}
|
|
322
|
-
data-cb-rail-resize=''
|
|
323
|
-
onPointerDown={onResizePointerDown}
|
|
324
|
-
onKeyDown={onResizeKey}
|
|
325
|
-
onDoubleClick={() => {
|
|
326
|
-
setDragged(null);
|
|
327
|
-
if (resizeKey) writeRailWidth(resizeKey, null);
|
|
328
|
-
}}
|
|
329
|
-
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the
|
|
330
|
-
// border, and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
331
|
-
// Only utilities the stylesheet already ships, so the handle adds no CSS to any app
|
|
332
|
-
// (`fixtureAppCss.spec.ts` budgets that).
|
|
333
|
-
className='absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
334
|
-
/>
|
|
335
|
-
) : null}
|
|
267
|
+
<RailResizeHandle rail={rail} />
|
|
336
268
|
{onCollapsedChange ? (
|
|
337
269
|
<div className={cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center')}>
|
|
338
270
|
{!collapsed && <span className='flex-1' />}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { RailResizeHandle, useRailResize } from './railResize.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
cleanup();
|
|
7
|
+
globalThis.localStorage?.clear();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A sidebar that is NOT a FilterRail — music's `LibraryRail` shape: its own element, its own
|
|
12
|
+
* default width in CSS, the shared handle inside. This is the seam every such rail passes
|
|
13
|
+
* through, so it is where the gesture is proved.
|
|
14
|
+
*/
|
|
15
|
+
function Sidebar({ resizeKey, enabled }: { resizeKey?: string; enabled?: boolean }) {
|
|
16
|
+
const rail = useRailResize<HTMLElement>({ resizeKey, label: 'Library', enabled });
|
|
17
|
+
return (
|
|
18
|
+
<aside ref={rail.railRef} data-width={rail.width ?? 'default'} style={rail.style} className='relative'>
|
|
19
|
+
<RailResizeHandle rail={rail} />
|
|
20
|
+
</aside>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parts = (container: HTMLElement) => ({
|
|
25
|
+
aside: container.querySelector('aside') as HTMLElement,
|
|
26
|
+
handle: container.querySelector('[data-cb-rail-resize]') as HTMLElement | null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('useRailResize + RailResizeHandle', () => {
|
|
30
|
+
test('no key — no handle, no width', () => {
|
|
31
|
+
const { aside, handle } = parts(render(<Sidebar />).container);
|
|
32
|
+
expect(handle).toBeNull();
|
|
33
|
+
expect(aside.style.width).toBe('');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a pointer drag widens by the distance moved and is remembered on release, not before', () => {
|
|
37
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
38
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
39
|
+
expect(aside.style.width).toBe('300px');
|
|
40
|
+
// happy-dom lays nothing out, so the drag starts from a measured 0 — the delta is the point.
|
|
41
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 0, clientX: 100, pointerId: 1 });
|
|
42
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 350, pointerId: 1 });
|
|
43
|
+
expect(aside.style.width).toBe('250px');
|
|
44
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('300');
|
|
45
|
+
fireEvent.pointerUp(handle as HTMLElement, { clientX: 350, pointerId: 1 });
|
|
46
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('250');
|
|
47
|
+
// Released: further moves do nothing.
|
|
48
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 600, pointerId: 1 });
|
|
49
|
+
expect(aside.style.width).toBe('250px');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a drag past either bound stops at it', () => {
|
|
53
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
54
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 0, clientX: 0, pointerId: 1 });
|
|
55
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 5000, pointerId: 1 });
|
|
56
|
+
expect(aside.style.width).toBe('640px');
|
|
57
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 10, pointerId: 1 });
|
|
58
|
+
expect(aside.style.width).toBe('160px');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a right-button press is not a drag', () => {
|
|
62
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
63
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 2, clientX: 0, pointerId: 1 });
|
|
64
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 400, pointerId: 1 });
|
|
65
|
+
expect(aside.style.width).toBe('');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('←/→ step it (Shift for more), announce it, and a double-click forgets it', () => {
|
|
69
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
70
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
71
|
+
const h = handle as HTMLElement;
|
|
72
|
+
expect(h.getAttribute('role')).toBe('separator');
|
|
73
|
+
expect(h.getAttribute('aria-label')).toBe('Resize Library');
|
|
74
|
+
fireEvent.keyDown(h, { key: 'ArrowLeft' });
|
|
75
|
+
expect(aside.style.width).toBe('284px');
|
|
76
|
+
fireEvent.keyDown(h, { key: 'ArrowRight', shiftKey: true });
|
|
77
|
+
expect(aside.style.width).toBe('348px');
|
|
78
|
+
expect(h.getAttribute('aria-valuenow')).toBe('348');
|
|
79
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('348');
|
|
80
|
+
fireEvent.doubleClick(h);
|
|
81
|
+
expect(aside.style.width).toBe('');
|
|
82
|
+
expect(aside.dataset.width).toBe('default');
|
|
83
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('disabled (a collapsed rail) — no handle, and the stored width is not applied', () => {
|
|
87
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
88
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' enabled={false} />).container);
|
|
89
|
+
expect(handle).toBeNull();
|
|
90
|
+
expect(aside.style.width).toBe('');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag a sidebar's right edge to resize it, like VS Code's side bar — the gesture half, for
|
|
3
|
+
* any rail, not only `FilterRail`.
|
|
4
|
+
*
|
|
5
|
+
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
6
|
+
* dragging to resize them like in vscode."* 5.8.0 shipped the handle INSIDE `FilterRail`; the
|
|
7
|
+
* sidebars in music (`LibraryRail`) and others are their own columns around a `FileTree`, so
|
|
8
|
+
* the handle came out here once rather than being copied into each app. `FilterRail` uses
|
|
9
|
+
* exactly this, so there is one gesture to get right:
|
|
10
|
+
*
|
|
11
|
+
* - **pointer capture**, or the drag ends the first time the pointer outruns the strip;
|
|
12
|
+
* - **←/→** (Shift for bigger steps), because a drag-only control is one some people cannot
|
|
13
|
+
* reach;
|
|
14
|
+
* - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* Clamp and storage are `railWidth.ts`, so the width is announced through `preferenceSync`
|
|
19
|
+
* like every other viewer preference.
|
|
20
|
+
*/
|
|
21
|
+
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
|
22
|
+
import { useCallback, useRef, useState } from 'react';
|
|
23
|
+
import { cn } from '../lib/cn.js';
|
|
24
|
+
import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
25
|
+
|
|
26
|
+
export interface UseRailResizeOptions {
|
|
27
|
+
/** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
|
|
28
|
+
resizeKey: string | undefined;
|
|
29
|
+
/** The rail's accessible name; the handle announces as "Resize <label>". */
|
|
30
|
+
label: string;
|
|
31
|
+
/** False hides the handle and drops the dragged width (a collapsed rail). Default true. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
36
|
+
/** Put on the rail element — the handle measures it to start a drag. */
|
|
37
|
+
railRef: RefObject<E | null>;
|
|
38
|
+
/** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
|
|
39
|
+
active: boolean;
|
|
40
|
+
/** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
|
|
41
|
+
width: number | null;
|
|
42
|
+
/** `{ width }` while there is a dragged width, else `undefined` — spread onto the rail's `style`. */
|
|
43
|
+
style: CSSProperties | undefined;
|
|
44
|
+
/** Forget the dragged width (what a double-click on the handle does). */
|
|
45
|
+
reset: () => void;
|
|
46
|
+
/** @internal — read by `RailResizeHandle`. */
|
|
47
|
+
readonly handle: {
|
|
48
|
+
label: string;
|
|
49
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
50
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
|
51
|
+
/** The rail's current rendered width, for `aria-valuenow` before any drag. */
|
|
52
|
+
measure: () => number;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
57
|
+
resizeKey,
|
|
58
|
+
label,
|
|
59
|
+
enabled = true,
|
|
60
|
+
}: UseRailResizeOptions): RailResize<E> {
|
|
61
|
+
const railRef = useRef<E | null>(null);
|
|
62
|
+
const [dragged, setDragged] = useState<number | null>(() => (resizeKey ? readRailWidth(resizeKey) : null));
|
|
63
|
+
const active = Boolean(resizeKey) && enabled;
|
|
64
|
+
const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
|
|
65
|
+
|
|
66
|
+
const onPointerDown = useCallback(
|
|
67
|
+
(event: ReactPointerEvent<HTMLElement>) => {
|
|
68
|
+
if (!resizeKey || event.button !== 0) return;
|
|
69
|
+
const handle = event.currentTarget;
|
|
70
|
+
const startX = event.clientX;
|
|
71
|
+
const startWidth = measure();
|
|
72
|
+
let latest = startWidth;
|
|
73
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
74
|
+
const move = (moveEvent: PointerEvent) => {
|
|
75
|
+
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
76
|
+
setDragged(latest);
|
|
77
|
+
};
|
|
78
|
+
const end = () => {
|
|
79
|
+
if (handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
|
80
|
+
handle.removeEventListener('pointermove', move);
|
|
81
|
+
handle.removeEventListener('pointerup', end);
|
|
82
|
+
handle.removeEventListener('pointercancel', end);
|
|
83
|
+
writeRailWidth(resizeKey, latest);
|
|
84
|
+
};
|
|
85
|
+
handle.addEventListener('pointermove', move);
|
|
86
|
+
handle.addEventListener('pointerup', end);
|
|
87
|
+
handle.addEventListener('pointercancel', end);
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
},
|
|
90
|
+
[resizeKey, measure],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const onKeyDown = useCallback(
|
|
94
|
+
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
95
|
+
if (!resizeKey) return;
|
|
96
|
+
const step = event.shiftKey ? 64 : 16;
|
|
97
|
+
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
98
|
+
if (!delta) return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
const next = clampRailWidth((dragged ?? measure()) + delta);
|
|
101
|
+
setDragged(next);
|
|
102
|
+
writeRailWidth(resizeKey, next);
|
|
103
|
+
},
|
|
104
|
+
[resizeKey, dragged, measure],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const reset = useCallback(() => {
|
|
108
|
+
setDragged(null);
|
|
109
|
+
if (resizeKey) writeRailWidth(resizeKey, null);
|
|
110
|
+
}, [resizeKey]);
|
|
111
|
+
|
|
112
|
+
const width = active ? dragged : null;
|
|
113
|
+
return {
|
|
114
|
+
railRef,
|
|
115
|
+
active,
|
|
116
|
+
width,
|
|
117
|
+
style: width === null ? undefined : { width },
|
|
118
|
+
reset,
|
|
119
|
+
handle: { label, onPointerDown, onKeyDown, measure },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The handle itself — a 12px hotspot centred on the rail's right border. The RAIL must be
|
|
125
|
+
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
126
|
+
* the resize is inactive.
|
|
127
|
+
*
|
|
128
|
+
* Only utilities the stylesheet already ships, so it adds no CSS to any app that imports the
|
|
129
|
+
* `filter-rail` area (`fixtureAppCss.spec.ts` budgets that).
|
|
130
|
+
*/
|
|
131
|
+
export function RailResizeHandle({ rail, className }: { rail: RailResize; className?: string }) {
|
|
132
|
+
if (!rail.active) return null;
|
|
133
|
+
const { label, onPointerDown, onKeyDown, measure } = rail.handle;
|
|
134
|
+
return (
|
|
135
|
+
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
136
|
+
<div
|
|
137
|
+
role='separator'
|
|
138
|
+
aria-orientation='vertical'
|
|
139
|
+
aria-label={`Resize ${label}`}
|
|
140
|
+
aria-valuemin={MIN_RAIL_PX}
|
|
141
|
+
aria-valuemax={MAX_RAIL_PX}
|
|
142
|
+
aria-valuenow={Math.round(rail.width ?? measure())}
|
|
143
|
+
tabIndex={0}
|
|
144
|
+
data-cb-rail-resize=''
|
|
145
|
+
onPointerDown={onPointerDown}
|
|
146
|
+
onKeyDown={onKeyDown}
|
|
147
|
+
onDoubleClick={rail.reset}
|
|
148
|
+
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the border,
|
|
149
|
+
// and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
150
|
+
className={cn(
|
|
151
|
+
'absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
152
|
+
className,
|
|
153
|
+
)}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|