@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.6.0",
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
+ );
@@ -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
@@ -33,6 +33,7 @@ export * from "./RadioGroup";
33
33
  export * from "./Skeleton";
34
34
  export * from "./Slide";
35
35
  export * from "./Slider";
36
+ export * from "./SplitView";
36
37
  export * from "./Select";
37
38
  export * from "./Spinner";
38
39
  export * from "./Svg";