@microbit/ui 0.6.0 → 0.6.1
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/package.json +1 -1
- package/src/SplitView.recipe.ts +115 -0
- package/src/SplitView.tsx +578 -0
- package/src/base-preset.ts +2 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@microbit/ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { defineSlotRecipe } from "@pandacss/dev";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SplitView slot recipe — two panes either side of a draggable splitter.
|
|
10
|
+
* Consumed by SplitView.tsx.
|
|
11
|
+
*
|
|
12
|
+
* Orientation (the splitter's, as for `aria-orientation`: `vertical` sits
|
|
13
|
+
* between side-by-side panes) and drag state come from data attributes the
|
|
14
|
+
* component sets (`data-orientation`, `data-dragging`) rather than
|
|
15
|
+
* variants, so there is nothing runtime-selected and no `staticCss` entry is
|
|
16
|
+
* needed.
|
|
17
|
+
*
|
|
18
|
+
* The splitter's look is meant to be restyled per app via its `css` prop:
|
|
19
|
+
* the grip inherits `color`, so one override tints both. An override of `bg`
|
|
20
|
+
* also replaces the hover and pressed fills, so restate `_hover` and
|
|
21
|
+
* `_active` alongside it.
|
|
22
|
+
*
|
|
23
|
+
* Registered in the base preset (base-preset.ts).
|
|
24
|
+
*/
|
|
25
|
+
export const splitView = defineSlotRecipe({
|
|
26
|
+
className: "splitView",
|
|
27
|
+
slots: ["root", "pane", "splitter", "grip"],
|
|
28
|
+
base: {
|
|
29
|
+
root: {
|
|
30
|
+
display: "flex",
|
|
31
|
+
"&[data-orientation=horizontal]": {
|
|
32
|
+
flexDirection: "column",
|
|
33
|
+
},
|
|
34
|
+
// useMove cancels the pointerdown, but on desktop only marks the
|
|
35
|
+
// splitter itself unselectable; this stops a drag over either pane
|
|
36
|
+
// selecting its text.
|
|
37
|
+
"&[data-dragging]": {
|
|
38
|
+
userSelect: "none",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
pane: {
|
|
42
|
+
// The primary pane's size is an inline style; the secondary takes the
|
|
43
|
+
// rest. Zero minimums let the secondary shrink below its content's
|
|
44
|
+
// intrinsic size, which flex items otherwise refuse to do.
|
|
45
|
+
"&[data-primary]": {
|
|
46
|
+
flex: "none",
|
|
47
|
+
},
|
|
48
|
+
"&:not([data-primary])": {
|
|
49
|
+
flex: "1 1 0",
|
|
50
|
+
minWidth: 0,
|
|
51
|
+
minHeight: 0,
|
|
52
|
+
},
|
|
53
|
+
"&[data-collapsed]": {
|
|
54
|
+
visibility: "hidden",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
splitter: {
|
|
58
|
+
position: "relative",
|
|
59
|
+
// Paint the focus ring over the following pane.
|
|
60
|
+
zIndex: 1,
|
|
61
|
+
flex: "none",
|
|
62
|
+
display: "flex",
|
|
63
|
+
alignItems: "center",
|
|
64
|
+
justifyContent: "center",
|
|
65
|
+
bg: "gray.100",
|
|
66
|
+
color: "blackAlpha.800",
|
|
67
|
+
touchAction: "none",
|
|
68
|
+
outline: "none",
|
|
69
|
+
transitionProperty: "background",
|
|
70
|
+
transitionDuration: "ultra-fast",
|
|
71
|
+
transitionTimingFunction: "ease-in",
|
|
72
|
+
// The neutral button's ladder.
|
|
73
|
+
_hover: { bg: "gray.300" },
|
|
74
|
+
_active: { bg: "gray.350" },
|
|
75
|
+
"&[data-orientation=vertical]": {
|
|
76
|
+
width: "2.5",
|
|
77
|
+
cursor: "col-resize",
|
|
78
|
+
},
|
|
79
|
+
"&[data-orientation=horizontal]": {
|
|
80
|
+
height: "2.5",
|
|
81
|
+
cursor: "row-resize",
|
|
82
|
+
},
|
|
83
|
+
_focusVisible: {
|
|
84
|
+
focusRing: "outline",
|
|
85
|
+
},
|
|
86
|
+
// A 10px bar is a small touch target; widen the hit area to 24px
|
|
87
|
+
// without changing the drawn width. Coarse pointers only, so a mouse
|
|
88
|
+
// keeps reaching the scrollbars at the panes' edges.
|
|
89
|
+
"@media (pointer: coarse)": {
|
|
90
|
+
"&::before": {
|
|
91
|
+
content: '""',
|
|
92
|
+
position: "absolute",
|
|
93
|
+
},
|
|
94
|
+
"&[data-orientation=vertical]::before": {
|
|
95
|
+
insetBlock: 0,
|
|
96
|
+
insetInline: "-7px",
|
|
97
|
+
},
|
|
98
|
+
"&[data-orientation=horizontal]::before": {
|
|
99
|
+
insetInline: 0,
|
|
100
|
+
insetBlock: "-7px",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
grip: {
|
|
105
|
+
width: "6",
|
|
106
|
+
height: "6",
|
|
107
|
+
flex: "none",
|
|
108
|
+
color: "inherit",
|
|
109
|
+
pointerEvents: "none",
|
|
110
|
+
"[data-orientation=horizontal] > &": {
|
|
111
|
+
transform: "rotate(90deg)",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
CSSProperties,
|
|
9
|
+
forwardRef,
|
|
10
|
+
HTMLAttributes,
|
|
11
|
+
KeyboardEvent,
|
|
12
|
+
MutableRefObject,
|
|
13
|
+
PointerEvent,
|
|
14
|
+
ReactNode,
|
|
15
|
+
useCallback,
|
|
16
|
+
useContext,
|
|
17
|
+
useLayoutEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from "react";
|
|
22
|
+
import {
|
|
23
|
+
mergeProps,
|
|
24
|
+
useFocusRing,
|
|
25
|
+
useId,
|
|
26
|
+
useLocale,
|
|
27
|
+
useMove,
|
|
28
|
+
useObjectRef,
|
|
29
|
+
} from "react-aria";
|
|
30
|
+
import { RiMore2Fill } from "react-icons/ri";
|
|
31
|
+
import { css, cx } from "styled-system/css";
|
|
32
|
+
import { splitView } from "styled-system/recipes";
|
|
33
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* - `open`: the primary pane is resizable and the splitter is shown.
|
|
37
|
+
* - `compact`: the primary pane is fixed at `compactSize`, with no splitter
|
|
38
|
+
* (e.g. a sidebar reduced to an icon rail).
|
|
39
|
+
* - `collapsed`: the primary pane is hidden (kept mounted, but out of the
|
|
40
|
+
* tab order and accessibility tree), with no splitter.
|
|
41
|
+
*
|
|
42
|
+
* Focus is the app's to manage when the mode changes. Leaving `open`
|
|
43
|
+
* unmounts the splitter, so if it had focus (say a keyboard shortcut changed
|
|
44
|
+
* the mode) focus drops to the document body; collapsing leaves focus inside
|
|
45
|
+
* the primary pane on a hidden element. Move it to the control that changed
|
|
46
|
+
* the mode, or into the secondary pane.
|
|
47
|
+
*/
|
|
48
|
+
export type SplitViewMode = "open" | "compact" | "collapsed";
|
|
49
|
+
|
|
50
|
+
export type SplitViewOrientation = "horizontal" | "vertical";
|
|
51
|
+
|
|
52
|
+
const keyboardStep = 10;
|
|
53
|
+
|
|
54
|
+
interface SplitViewContextValue {
|
|
55
|
+
orientation: SplitViewOrientation;
|
|
56
|
+
mode: SplitViewMode;
|
|
57
|
+
compactSize: number;
|
|
58
|
+
primaryId: string;
|
|
59
|
+
primaryRef: MutableRefObject<HTMLDivElement | null>;
|
|
60
|
+
primarySize: number;
|
|
61
|
+
primaryMinSize: number;
|
|
62
|
+
primaryMaxSize: number;
|
|
63
|
+
startMove: () => void;
|
|
64
|
+
setSize: (size: number) => void;
|
|
65
|
+
commitSize: () => void;
|
|
66
|
+
setDragging: (dragging: boolean) => void;
|
|
67
|
+
setPrimaryPaneId: (id: string | undefined) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SplitViewContext = createContext<SplitViewContextValue | null>(null);
|
|
71
|
+
|
|
72
|
+
const useSplitViewContext = (component: string): SplitViewContextValue => {
|
|
73
|
+
const value = useContext(SplitViewContext);
|
|
74
|
+
if (!value) {
|
|
75
|
+
throw new Error(`${component} must be inside a SplitView`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
81
|
+
Math.min(Math.max(value, min), max);
|
|
82
|
+
|
|
83
|
+
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
|
|
84
|
+
/**
|
|
85
|
+
* The splitter's orientation, as for `aria-orientation`: `vertical` sits
|
|
86
|
+
* between side-by-side panes and moves left and right; `horizontal` sits
|
|
87
|
+
* between stacked panes and moves up and down.
|
|
88
|
+
*/
|
|
89
|
+
orientation?: SplitViewOrientation;
|
|
90
|
+
mode?: SplitViewMode;
|
|
91
|
+
/** The primary pane's size in `compact` mode, in px. */
|
|
92
|
+
compactSize?: number;
|
|
93
|
+
/** The primary pane's size in px, controlled. */
|
|
94
|
+
size?: number;
|
|
95
|
+
/** The primary pane's initial size in px, uncontrolled. */
|
|
96
|
+
defaultSize?: number;
|
|
97
|
+
/** Called as the size changes during a drag or key press. */
|
|
98
|
+
onSizeChange?: (size: number) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Called once a drag or key press finishes, with the final size. The place
|
|
101
|
+
* to persist it.
|
|
102
|
+
*/
|
|
103
|
+
onSizeChangeEnd?: (size: number) => void;
|
|
104
|
+
/** The primary pane's minimum size in px. */
|
|
105
|
+
primaryMinSize?: number;
|
|
106
|
+
/** The primary pane's maximum size in px. */
|
|
107
|
+
primaryMaxSize?: number;
|
|
108
|
+
/**
|
|
109
|
+
* The secondary pane's minimum size in px. When the container is too small
|
|
110
|
+
* for both minimums, the primary pane keeps its minimum and the secondary
|
|
111
|
+
* gives way.
|
|
112
|
+
*/
|
|
113
|
+
secondaryMinSize?: number;
|
|
114
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
115
|
+
css?: SystemStyleObject;
|
|
116
|
+
/**
|
|
117
|
+
* Two `SplitViewPane`s, one marked `primary`, with a `SplitViewSplitter`
|
|
118
|
+
* between them. Either pane may be the primary.
|
|
119
|
+
*/
|
|
120
|
+
children: ReactNode;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* SplitView — two panes with a splitter that resizes one of them: the
|
|
125
|
+
* primary pane has an explicit size, and the secondary takes the rest. A
|
|
126
|
+
* WAI-ARIA window splitter: the splitter is focusable, resizes with the
|
|
127
|
+
* arrow keys (Home/End for the primary pane's minimum/maximum), and exposes
|
|
128
|
+
* its position to assistive tech as a percentage of the available range.
|
|
129
|
+
*
|
|
130
|
+
* Sizes are px. The requested size is kept when the container shrinks below
|
|
131
|
+
* it and restored when it grows back, so a window resize doesn't lose the
|
|
132
|
+
* user's layout. Persisting the size is the app's call: control it with
|
|
133
|
+
* `size`/`onSizeChangeEnd`.
|
|
134
|
+
*
|
|
135
|
+
* The root sizes like any flex container, so give it its extent from the
|
|
136
|
+
* layout (e.g. `flex: 1` with a zero minimum), and stacked panes a height.
|
|
137
|
+
*
|
|
138
|
+
* Collapsing the primary pane is app state (`mode`), driven by the app's own
|
|
139
|
+
* controls, which also serve as the non-drag alternative WCAG 2.5.7 asks for.
|
|
140
|
+
*/
|
|
141
|
+
export const SplitView = forwardRef<HTMLDivElement, SplitViewProps>(
|
|
142
|
+
function SplitView(
|
|
143
|
+
{
|
|
144
|
+
orientation = "vertical",
|
|
145
|
+
mode = "open",
|
|
146
|
+
compactSize = 0,
|
|
147
|
+
size,
|
|
148
|
+
defaultSize,
|
|
149
|
+
onSizeChange,
|
|
150
|
+
onSizeChangeEnd,
|
|
151
|
+
primaryMinSize = 0,
|
|
152
|
+
primaryMaxSize = Infinity,
|
|
153
|
+
secondaryMinSize = 0,
|
|
154
|
+
css: cssProp,
|
|
155
|
+
className,
|
|
156
|
+
children,
|
|
157
|
+
...rest
|
|
158
|
+
},
|
|
159
|
+
ref,
|
|
160
|
+
) {
|
|
161
|
+
const slots = splitView();
|
|
162
|
+
const rootRef = useObjectRef(ref);
|
|
163
|
+
const primaryRef = useRef<HTMLDivElement>(null);
|
|
164
|
+
const generatedId = useId();
|
|
165
|
+
const [primaryPaneId, setPrimaryPaneId] = useState<string>();
|
|
166
|
+
const primaryId = primaryPaneId ?? generatedId;
|
|
167
|
+
const [uncontrolledSize, setUncontrolledSize] = useState(
|
|
168
|
+
defaultSize ?? primaryMinSize,
|
|
169
|
+
);
|
|
170
|
+
const requestedSize = size ?? uncontrolledSize;
|
|
171
|
+
// Container content size less the splitter and gaps. Undefined until
|
|
172
|
+
// measured, and while the container has no size (e.g. inside a hidden
|
|
173
|
+
// tab), so the primary pane isn't clamped to nothing.
|
|
174
|
+
const [available, setAvailable] = useState<number>();
|
|
175
|
+
const [dragging, setDragging] = useState(false);
|
|
176
|
+
|
|
177
|
+
useLayoutEffect(() => {
|
|
178
|
+
const root = rootRef.current;
|
|
179
|
+
if (!root) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Layout sizes rather than bounding rects, which a transformed ancestor
|
|
183
|
+
// would scale.
|
|
184
|
+
const measure = () => {
|
|
185
|
+
const sideBySide = orientation === "vertical";
|
|
186
|
+
const style = getComputedStyle(root);
|
|
187
|
+
const px = (value: string) => parseFloat(value) || 0;
|
|
188
|
+
const content = sideBySide
|
|
189
|
+
? root.clientWidth - px(style.paddingLeft) - px(style.paddingRight)
|
|
190
|
+
: root.clientHeight - px(style.paddingTop) - px(style.paddingBottom);
|
|
191
|
+
const gaps =
|
|
192
|
+
Math.max(root.children.length - 1, 0) *
|
|
193
|
+
px(sideBySide ? style.columnGap : style.rowGap);
|
|
194
|
+
const splitter = root.querySelector<HTMLElement>(
|
|
195
|
+
":scope > [data-splitter]",
|
|
196
|
+
);
|
|
197
|
+
const splitterSize = splitter
|
|
198
|
+
? sideBySide
|
|
199
|
+
? splitter.offsetWidth
|
|
200
|
+
: splitter.offsetHeight
|
|
201
|
+
: 0;
|
|
202
|
+
setAvailable(content > 0 ? content - gaps - splitterSize : undefined);
|
|
203
|
+
};
|
|
204
|
+
measure();
|
|
205
|
+
if (typeof ResizeObserver === "undefined") {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const observer = new ResizeObserver(measure);
|
|
209
|
+
observer.observe(root);
|
|
210
|
+
return () => observer.disconnect();
|
|
211
|
+
// `mode` because the splitter only renders when open.
|
|
212
|
+
}, [rootRef, orientation, mode]);
|
|
213
|
+
|
|
214
|
+
const max =
|
|
215
|
+
available === undefined
|
|
216
|
+
? Math.max(primaryMinSize, primaryMaxSize)
|
|
217
|
+
: Math.max(
|
|
218
|
+
primaryMinSize,
|
|
219
|
+
Math.min(primaryMaxSize, available - secondaryMinSize),
|
|
220
|
+
);
|
|
221
|
+
const primarySize = clamp(requestedSize, primaryMinSize, max);
|
|
222
|
+
|
|
223
|
+
// The size last passed to onSizeChange. A controlled parent may reject it
|
|
224
|
+
// without re-rendering, so each move starts again from the rendered size.
|
|
225
|
+
const latestSize = useRef(primarySize);
|
|
226
|
+
const startMove = useCallback(() => {
|
|
227
|
+
latestSize.current = primarySize;
|
|
228
|
+
}, [primarySize]);
|
|
229
|
+
const callbacks = useRef({ onSizeChange, onSizeChangeEnd });
|
|
230
|
+
useLayoutEffect(() => {
|
|
231
|
+
callbacks.current = { onSizeChange, onSizeChangeEnd };
|
|
232
|
+
});
|
|
233
|
+
const isControlled = size !== undefined;
|
|
234
|
+
// A key press that doesn't move the splitter (an arrow across the split
|
|
235
|
+
// axis, Home at the minimum) still ends a move, but has nothing to persist.
|
|
236
|
+
const uncommitted = useRef(false);
|
|
237
|
+
|
|
238
|
+
const setSize = useCallback(
|
|
239
|
+
(next: number) => {
|
|
240
|
+
if (next === latestSize.current) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
latestSize.current = next;
|
|
244
|
+
uncommitted.current = true;
|
|
245
|
+
if (!isControlled) {
|
|
246
|
+
setUncontrolledSize(next);
|
|
247
|
+
}
|
|
248
|
+
callbacks.current.onSizeChange?.(next);
|
|
249
|
+
},
|
|
250
|
+
[isControlled],
|
|
251
|
+
);
|
|
252
|
+
const commitSize = useCallback(() => {
|
|
253
|
+
if (!uncommitted.current) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
uncommitted.current = false;
|
|
257
|
+
callbacks.current.onSizeChangeEnd?.(latestSize.current);
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const context = useMemo<SplitViewContextValue>(
|
|
261
|
+
() => ({
|
|
262
|
+
orientation,
|
|
263
|
+
mode,
|
|
264
|
+
compactSize,
|
|
265
|
+
primaryId,
|
|
266
|
+
primaryRef,
|
|
267
|
+
primarySize,
|
|
268
|
+
primaryMinSize,
|
|
269
|
+
primaryMaxSize: max,
|
|
270
|
+
startMove,
|
|
271
|
+
setSize,
|
|
272
|
+
commitSize,
|
|
273
|
+
setDragging,
|
|
274
|
+
setPrimaryPaneId,
|
|
275
|
+
}),
|
|
276
|
+
[
|
|
277
|
+
orientation,
|
|
278
|
+
mode,
|
|
279
|
+
compactSize,
|
|
280
|
+
primaryId,
|
|
281
|
+
primarySize,
|
|
282
|
+
primaryMinSize,
|
|
283
|
+
max,
|
|
284
|
+
startMove,
|
|
285
|
+
setSize,
|
|
286
|
+
commitSize,
|
|
287
|
+
],
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<SplitViewContext.Provider value={context}>
|
|
292
|
+
<div
|
|
293
|
+
ref={rootRef}
|
|
294
|
+
data-orientation={orientation}
|
|
295
|
+
data-dragging={dragging || undefined}
|
|
296
|
+
className={cx(
|
|
297
|
+
slots.root,
|
|
298
|
+
cssProp ? css(cssProp) : undefined,
|
|
299
|
+
className,
|
|
300
|
+
)}
|
|
301
|
+
{...rest}
|
|
302
|
+
>
|
|
303
|
+
{children}
|
|
304
|
+
</div>
|
|
305
|
+
</SplitViewContext.Provider>
|
|
306
|
+
);
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
export interface SplitViewPaneProps extends HTMLAttributes<HTMLDivElement> {
|
|
311
|
+
/**
|
|
312
|
+
* The pane the splitter sizes; exactly one pane is primary. The splitter's
|
|
313
|
+
* `aria-controls` names its `id`, generated unless given.
|
|
314
|
+
*/
|
|
315
|
+
primary?: boolean;
|
|
316
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
317
|
+
css?: SystemStyleObject;
|
|
318
|
+
children?: ReactNode;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const SplitViewPane = forwardRef<HTMLDivElement, SplitViewPaneProps>(
|
|
322
|
+
function SplitViewPane(
|
|
323
|
+
{ primary = false, css: cssProp, className, style, children, ...rest },
|
|
324
|
+
ref,
|
|
325
|
+
) {
|
|
326
|
+
const slots = splitView();
|
|
327
|
+
const {
|
|
328
|
+
orientation,
|
|
329
|
+
mode,
|
|
330
|
+
compactSize,
|
|
331
|
+
primaryId,
|
|
332
|
+
primaryRef,
|
|
333
|
+
primarySize,
|
|
334
|
+
setPrimaryPaneId,
|
|
335
|
+
} = useSplitViewContext("SplitViewPane");
|
|
336
|
+
const paneRef = useObjectRef(ref);
|
|
337
|
+
useLayoutEffect(() => {
|
|
338
|
+
if (primary) {
|
|
339
|
+
primaryRef.current = paneRef.current;
|
|
340
|
+
return () => {
|
|
341
|
+
primaryRef.current = null;
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}, [primary, primaryRef, paneRef]);
|
|
345
|
+
const id = rest.id;
|
|
346
|
+
useLayoutEffect(() => {
|
|
347
|
+
if (primary && id) {
|
|
348
|
+
setPrimaryPaneId(id);
|
|
349
|
+
return () => setPrimaryPaneId(undefined);
|
|
350
|
+
}
|
|
351
|
+
}, [primary, id, setPrimaryPaneId]);
|
|
352
|
+
let sizeStyle: CSSProperties | undefined;
|
|
353
|
+
if (primary) {
|
|
354
|
+
const extent =
|
|
355
|
+
mode === "open" ? primarySize : mode === "compact" ? compactSize : 0;
|
|
356
|
+
sizeStyle =
|
|
357
|
+
orientation === "vertical" ? { width: extent } : { height: extent };
|
|
358
|
+
if (mode === "collapsed") {
|
|
359
|
+
// Inline, as a recipe rule would lose to padding or a border from the
|
|
360
|
+
// pane's `css`, leaving a gap where the pane was.
|
|
361
|
+
Object.assign(sizeStyle, {
|
|
362
|
+
padding: 0,
|
|
363
|
+
borderWidth: 0,
|
|
364
|
+
minWidth: 0,
|
|
365
|
+
minHeight: 0,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return (
|
|
370
|
+
<div
|
|
371
|
+
{...rest}
|
|
372
|
+
ref={paneRef}
|
|
373
|
+
id={id ?? (primary ? primaryId : undefined)}
|
|
374
|
+
data-primary={primary || undefined}
|
|
375
|
+
data-collapsed={(primary && mode === "collapsed") || undefined}
|
|
376
|
+
className={cx(
|
|
377
|
+
slots.pane,
|
|
378
|
+
cssProp ? css(cssProp) : undefined,
|
|
379
|
+
className,
|
|
380
|
+
)}
|
|
381
|
+
style={sizeStyle ? { ...style, ...sizeStyle } : style}
|
|
382
|
+
>
|
|
383
|
+
{children}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
type SplitterLabelling =
|
|
390
|
+
| { "aria-label": string; "aria-labelledby"?: string }
|
|
391
|
+
| { "aria-label"?: string; "aria-labelledby": string };
|
|
392
|
+
|
|
393
|
+
export type SplitViewSplitterProps = Omit<
|
|
394
|
+
HTMLAttributes<HTMLDivElement>,
|
|
395
|
+
| "role"
|
|
396
|
+
| "children"
|
|
397
|
+
| "aria-label"
|
|
398
|
+
| "aria-labelledby"
|
|
399
|
+
| "aria-orientation"
|
|
400
|
+
| "aria-controls"
|
|
401
|
+
| "aria-valuemin"
|
|
402
|
+
| "aria-valuemax"
|
|
403
|
+
| "aria-valuenow"
|
|
404
|
+
| "aria-valuetext"
|
|
405
|
+
> &
|
|
406
|
+
SplitterLabelling & {
|
|
407
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
408
|
+
css?: SystemStyleObject;
|
|
409
|
+
/** Replaces the default grip icon. */
|
|
410
|
+
children?: ReactNode;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The splitter between the panes. Renders only in `open` mode; leaving it
|
|
415
|
+
* ends any drag in progress. Needs an accessible name: label it by what it
|
|
416
|
+
* resizes ("Resize sidebar"), or point `aria-labelledby` at the primary
|
|
417
|
+
* pane's visible heading.
|
|
418
|
+
*
|
|
419
|
+
* Event handlers chain with the splitter's own, so an app can add the
|
|
420
|
+
* pattern's optional Enter to collapse and restore, or a double-click reset.
|
|
421
|
+
*/
|
|
422
|
+
export const SplitViewSplitter = forwardRef<
|
|
423
|
+
HTMLDivElement,
|
|
424
|
+
SplitViewSplitterProps
|
|
425
|
+
>(function SplitViewSplitter(props, ref) {
|
|
426
|
+
const { mode } = useSplitViewContext("SplitViewSplitter");
|
|
427
|
+
return mode === "open" ? <OpenSplitter {...props} ref={ref} /> : null;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const OpenSplitter = forwardRef<HTMLDivElement, SplitViewSplitterProps>(
|
|
431
|
+
function OpenSplitter(
|
|
432
|
+
{ css: cssProp, className, children, ...rest },
|
|
433
|
+
forwardedRef,
|
|
434
|
+
) {
|
|
435
|
+
const slots = splitView();
|
|
436
|
+
const {
|
|
437
|
+
orientation,
|
|
438
|
+
primaryId,
|
|
439
|
+
primaryRef,
|
|
440
|
+
primarySize,
|
|
441
|
+
primaryMinSize,
|
|
442
|
+
primaryMaxSize,
|
|
443
|
+
startMove,
|
|
444
|
+
setSize,
|
|
445
|
+
commitSize,
|
|
446
|
+
setDragging,
|
|
447
|
+
} = useSplitViewContext("SplitViewSplitter");
|
|
448
|
+
const { locale } = useLocale();
|
|
449
|
+
const ref = useObjectRef(forwardedRef);
|
|
450
|
+
const position = useRef(0);
|
|
451
|
+
// +1 when moving the splitter right/down grows the primary pane.
|
|
452
|
+
const growth = useRef(1);
|
|
453
|
+
const sideBySide = orientation === "vertical";
|
|
454
|
+
const { isFocusVisible, focusProps } = useFocusRing();
|
|
455
|
+
// Set on press rather than from useMove, which only starts a move once the
|
|
456
|
+
// pointer moves.
|
|
457
|
+
const [pressed, setPressed] = useState(false);
|
|
458
|
+
const pointerMoving = useRef(false);
|
|
459
|
+
|
|
460
|
+
// useMove doesn't end a move when it unmounts, as it does when the mode
|
|
461
|
+
// leaves `open` mid-drag (say an app collapsing a pane dragged below a
|
|
462
|
+
// threshold).
|
|
463
|
+
useLayoutEffect(
|
|
464
|
+
() => () => {
|
|
465
|
+
if (pointerMoving.current) {
|
|
466
|
+
pointerMoving.current = false;
|
|
467
|
+
setDragging(false);
|
|
468
|
+
commitSize();
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
[setDragging, commitSize],
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const { moveProps } = useMove({
|
|
475
|
+
onMoveStart(e) {
|
|
476
|
+
startMove();
|
|
477
|
+
position.current = primarySize;
|
|
478
|
+
// Measured rather than derived from DOM order and text direction:
|
|
479
|
+
// the layout already accounts for both.
|
|
480
|
+
const primary = primaryRef.current?.getBoundingClientRect();
|
|
481
|
+
const splitter = ref.current?.getBoundingClientRect();
|
|
482
|
+
if (primary && splitter) {
|
|
483
|
+
const centre = (r: DOMRect) =>
|
|
484
|
+
sideBySide ? r.left + r.width / 2 : r.top + r.height / 2;
|
|
485
|
+
growth.current = centre(primary) < centre(splitter) ? 1 : -1;
|
|
486
|
+
}
|
|
487
|
+
if (e.pointerType !== "keyboard") {
|
|
488
|
+
pointerMoving.current = true;
|
|
489
|
+
setDragging(true);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
onMove(e) {
|
|
493
|
+
let delta = sideBySide ? e.deltaX : e.deltaY;
|
|
494
|
+
if (e.pointerType === "keyboard") {
|
|
495
|
+
// Arrows across the split axis do nothing (APG window splitter).
|
|
496
|
+
delta *= keyboardStep;
|
|
497
|
+
}
|
|
498
|
+
if (delta === 0) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Track the unclamped pointer position so that dragging past a limit
|
|
502
|
+
// and back doesn't shift the splitter relative to the pointer.
|
|
503
|
+
position.current += delta * growth.current;
|
|
504
|
+
setSize(clamp(position.current, primaryMinSize, primaryMaxSize));
|
|
505
|
+
},
|
|
506
|
+
onMoveEnd() {
|
|
507
|
+
pointerMoving.current = false;
|
|
508
|
+
setDragging(false);
|
|
509
|
+
commitSize();
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Capture routes the drag's events, and its cursor, to the splitter
|
|
514
|
+
// wherever the pointer goes; an iframe would otherwise swallow them. Its
|
|
515
|
+
// loss marks the end of the press, moved or not. jsdom lacks it.
|
|
516
|
+
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
517
|
+
if (e.button === 0) {
|
|
518
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
519
|
+
setPressed(true);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const onLostPointerCapture = () => setPressed(false);
|
|
523
|
+
|
|
524
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
525
|
+
if (e.key === "Home") {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
startMove();
|
|
528
|
+
setSize(primaryMinSize);
|
|
529
|
+
commitSize();
|
|
530
|
+
} else if (e.key === "End" && Number.isFinite(primaryMaxSize)) {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
startMove();
|
|
533
|
+
setSize(primaryMaxSize);
|
|
534
|
+
commitSize();
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const range = primaryMaxSize - primaryMinSize;
|
|
539
|
+
const percent =
|
|
540
|
+
Number.isFinite(range) && range > 0
|
|
541
|
+
? Math.round(((primarySize - primaryMinSize) / range) * 100)
|
|
542
|
+
: 0;
|
|
543
|
+
// Known gap: TalkBack doesn't send swipe adjustments to a role="separator". A
|
|
544
|
+
// visually hidden range input fixes that (react-aria's column resizer
|
|
545
|
+
// does it), at the cost of desktop screen readers announcing a slider.
|
|
546
|
+
return (
|
|
547
|
+
<div
|
|
548
|
+
{...mergeProps(rest, moveProps, focusProps, {
|
|
549
|
+
onKeyDown,
|
|
550
|
+
onPointerDown,
|
|
551
|
+
onLostPointerCapture,
|
|
552
|
+
})}
|
|
553
|
+
ref={ref}
|
|
554
|
+
role="separator"
|
|
555
|
+
tabIndex={0}
|
|
556
|
+
aria-orientation={orientation}
|
|
557
|
+
aria-controls={primaryId}
|
|
558
|
+
aria-valuemin={0}
|
|
559
|
+
aria-valuemax={100}
|
|
560
|
+
aria-valuenow={percent}
|
|
561
|
+
aria-valuetext={new Intl.NumberFormat(locale, {
|
|
562
|
+
style: "percent",
|
|
563
|
+
}).format(percent / 100)}
|
|
564
|
+
data-splitter=""
|
|
565
|
+
data-orientation={orientation}
|
|
566
|
+
data-focus-visible={isFocusVisible || undefined}
|
|
567
|
+
data-pressed={pressed || undefined}
|
|
568
|
+
className={cx(
|
|
569
|
+
slots.splitter,
|
|
570
|
+
cssProp ? css(cssProp) : undefined,
|
|
571
|
+
className,
|
|
572
|
+
)}
|
|
573
|
+
>
|
|
574
|
+
{children ?? <RiMore2Fill aria-hidden className={slots.grip} />}
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
},
|
|
578
|
+
);
|
package/src/base-preset.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { numberField } from "./NumberField.recipe";
|
|
|
39
39
|
import { menu } from "./Menu.recipe";
|
|
40
40
|
import { select } from "./Select.recipe";
|
|
41
41
|
import { slider } from "./Slider.recipe";
|
|
42
|
+
import { splitView } from "./SplitView.recipe";
|
|
42
43
|
import { switchRecipe } from "./Switch.recipe";
|
|
43
44
|
import { dialog } from "./Modal.recipe";
|
|
44
45
|
import { text } from "./Text.recipe";
|
|
@@ -344,6 +345,7 @@ export const basePreset = definePreset({
|
|
|
344
345
|
radio,
|
|
345
346
|
select,
|
|
346
347
|
slider,
|
|
348
|
+
splitView,
|
|
347
349
|
switchRecipe,
|
|
348
350
|
toast,
|
|
349
351
|
},
|
package/src/index.ts
CHANGED