From 344f2725d3509685fa73e1ade2f645a0e95ac7e2 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 08:58:27 -0800 Subject: [PATCH 1/8] test(LayoutPanel): focus leaves a collapsing panel, and the collapsed panel is inert Cases for a LayoutPanel beside its ResizeHandle. When the region collapses with focus inside the panel, focus moves to the handle, with the ring the last input calls for, and the panel is inert and hidden from assistive technology until it opens. Opening moves no focus, and a collapse started from the handle or from outside the panel leaves focus where it is. This holds for the hook's own props, a hand-built object, a reversed end panel, and a panel at the hook's size beside a handle given the props, from the first commit when the region starts collapsed. A panel with no resize controller is never inert. These cases fail until the next commit. --- .../core/src/Resizable/paneFocus.test.tsx | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/core/src/Resizable/paneFocus.test.tsx diff --git a/packages/core/src/Resizable/paneFocus.test.tsx b/packages/core/src/Resizable/paneFocus.test.tsx new file mode 100644 index 0000000000000..1a81f7f637623 --- /dev/null +++ b/packages/core/src/Resizable/paneFocus.test.tsx @@ -0,0 +1,257 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paneFocus.test.tsx + * @input vitest (jsdom), Testing Library, Layout with a LayoutPanel beside a + * ResizeHandle, and useResizable's configuration-object form + * @output Checks that focus inside a collapsing LayoutPanel moves to its + * ResizeHandle, and that the collapsed panel is inert + * @position Integration tests for resizablePane.ts, LayoutPanel.tsx and + * ResizeHandle.tsx + */ + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {__resetInteractionModalityForTest} from '../utils/interactionModality'; +import {Layout} from '../Layout/Layout'; +import {LayoutContent} from '../Layout/LayoutContent'; +import {LayoutPanel} from '../Layout/LayoutPanel'; +import {ResizeHandle} from './ResizeHandle'; +import { + useResizable, + type ResizableRegion, + type UseResizableSingleConfig, +} from './useResizable'; + +let propsRegion: ResizableRegion | undefined; + +const NO_CONFIG: UseResizableSingleConfig = {}; + +/** + * A Layout whose start panel, or end panel when reversed, takes the + * configuration-object hook's own `props`. Or it takes a hand-built copy: the + * same fields in an object `useResizable` did not return. Or, with + * `isWidthPanel`, it renders at the hook's size beside a handle given the + * props. + */ +function PropsSplit({ + config = NO_CONFIG, + isReversed = false, + isHandBuilt = false, + isWidthPanel = false, + panelKey = 'panel', +}: { + config?: UseResizableSingleConfig; + isReversed?: boolean; + isHandBuilt?: boolean; + isWidthPanel?: boolean; + panelKey?: string; +}) { + const value = useResizable({defaultSize: 200, collapsible: true, ...config}); + propsRegion = value; + const panelProps = isHandBuilt ? {...value.props} : value.props; + const panel = isWidthPanel ? ( + + + + ) : ( + + + + ); + const handle = ( + + ); + return ( + <> + + + {panel} + {handle} + + ) + } + content={Main} + end={ + isReversed ? ( + <> + {handle} + {panel} + + ) : undefined + } + /> + + ); +} + +const pane = () => screen.getByTestId('pane'); +const separator = () => screen.getByRole('separator', {name: 'Resize pane'}); + +beforeEach(() => { + __resetInteractionModalityForTest(); + propsRegion = undefined; + localStorage.clear(); +}); + +afterEach(() => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; +}); + +describe('focus leaves a collapsing pane, and the collapsed pane is inert (component:LayoutPanel AR2)', () => { + it('moves focus inside a collapsing pane to its ResizeHandle and makes the pane inert and hidden until it opens', () => { + render(); + screen.getByRole('button', {name: 'Inside'}).focus(); + act(() => { + propsRegion?.collapse(); + }); + expect(document.activeElement).toBe(separator()); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + act(() => { + propsRegion?.expand(); + }); + // Opening moves no focus. + expect(document.activeElement).toBe(separator()); + expect(pane()).not.toHaveAttribute('inert'); + expect(pane()).not.toHaveAttribute('aria-hidden'); + }); + + it('leaves focus where it is for a collapse started from the handle or from outside the pane', () => { + render(); + separator().focus(); + fireEvent.keyDown(separator(), {key: 'Enter'}); + expect(document.activeElement).toBe(separator()); + fireEvent.keyDown(separator(), {key: 'Enter'}); + const outside = screen.getByRole('button', {name: 'Outside'}); + outside.focus(); + act(() => { + propsRegion?.collapse(); + }); + expect(document.activeElement).toBe(outside); + expect(pane()).toHaveAttribute('inert'); + }); + + it('keeps a pane that starts collapsed inert from its first commit', () => { + render(); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + }); + + it("moves focus to the adjacent handle and then makes the pane inert for the hook's props and for a hand-built object", () => { + for (const isHandBuilt of [false, true]) { + for (const isReversed of [false, true]) { + const view = render( + , + ); + screen.getByRole('button', {name: 'Inside'}).focus(); + act(() => { + propsRegion?.collapse(); + }); + expect(document.activeElement).toBe(separator()); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + act(() => { + propsRegion?.expand(); + }); + expect(document.activeElement).toBe(separator()); + expect(pane()).not.toHaveAttribute('inert'); + expect(pane()).not.toHaveAttribute('aria-hidden'); + view.unmount(); + } + } + }); + + it("keeps a pane that takes the hook's props, or a hand-built object, inert from its first commit when the region starts collapsed", () => { + for (const isHandBuilt of [false, true]) { + const view = render( + , + ); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + view.unmount(); + } + }); + + it("moves focus to the handle and makes the pane inert for a pane at the hook's size beside a handle given its props", () => { + for (const isReversed of [false, true]) { + const view = render(); + screen.getByRole('button', {name: 'Inside'}).focus(); + act(() => { + propsRegion?.collapse(); + }); + expect(document.activeElement).toBe(separator()); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + act(() => { + propsRegion?.expand(); + }); + expect(pane()).not.toHaveAttribute('inert'); + expect(pane()).not.toHaveAttribute('aria-hidden'); + view.unmount(); + } + }); + + it("keeps a pane at the hook's size, beside a handle given its props, inert from its first commit when the region starts collapsed", () => { + render(); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + }); + + it('keeps an end pane, reversed by its ResizeHandle, inert from its first commit when the region starts collapsed', () => { + render(); + expect(pane()).toHaveAttribute('inert'); + expect(pane()).toHaveAttribute('aria-hidden', 'true'); + act(() => { + propsRegion?.expand(); + }); + expect(pane()).not.toHaveAttribute('inert'); + }); + + for (const input of ['pointer', 'keyboard'] as const) { + it(`moves focus to the handle with the ring the last input calls for (${input}; architecture:interaction-modality INV1)`, () => { + render(); + const inside = screen.getByRole('button', {name: 'Inside'}); + inside.focus(); + if (input === 'pointer') { + fireEvent.pointerDown(inside); + } else { + fireEvent.keyDown(inside, {key: 'Enter'}); + } + const focus = vi.spyOn(separator(), 'focus'); + act(() => { + propsRegion?.collapse(); + }); + expect(document.activeElement).toBe(separator()); + expect(focus.mock.calls).toEqual( + input === 'pointer' ? [[{focusVisible: false}]] : [[]], + ); + }); + } + + it('never makes a LayoutPanel without a resize controller inert', () => { + render( + + + + } + content={Main} + />, + ); + expect(pane()).not.toHaveAttribute('inert'); + expect(pane()).not.toHaveAttribute('aria-hidden'); + }); +}); From 1a2a619f1553bdd61079c0817d983722f69868b4 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 08:58:27 -0800 Subject: [PATCH 2/8] fix(LayoutPanel): focus leaves a collapsing panel, and the collapsed panel is inert A collapsing LayoutPanel moves focus inside it to its ResizeHandle, through the focus-leaving sequence that disclosures share, so the ring follows the last input. A commit later the panel goes inert and `aria-hidden` until it opens, so inert never drops that focus to the page. The panel reads the collapse state from its `resizable` props, or, with no `resizable`, from the ResizeHandle beside it: a new internal module, resizablePane.ts, lets a panel at the region's size lend its element to that handle, which holds it collapsed with the region. ResizeHandle holds the shared modality tracker and registers its element, through a callback ref, for the panel to find. No prop, option, export or type changes. --- packages/core/src/Layout/LayoutPanel.tsx | 78 +++++++- packages/core/src/Resizable/ResizeHandle.tsx | 62 +++++- packages/core/src/Resizable/resizablePane.ts | 188 +++++++++++++++++++ 3 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/Resizable/resizablePane.ts diff --git a/packages/core/src/Layout/LayoutPanel.tsx b/packages/core/src/Layout/LayoutPanel.tsx index 89373b620175f..3fbef14500ff2 100644 --- a/packages/core/src/Layout/LayoutPanel.tsx +++ b/packages/core/src/Layout/LayoutPanel.tsx @@ -5,7 +5,7 @@ /** * @file LayoutPanel.tsx * @input Uses React StyleX, LayoutAreaContext, LayoutSlotsContext, - * useScrollRegionProps + * useScrollRegionProps, resizablePane.ts * @output Exports LayoutPanel component and LayoutPanelProps * @position Sidebar panel for Layout start/end slots. Use for navigation panels, * settings sidebars, detail panels, or any fixed-width side content. @@ -18,7 +18,7 @@ import type {AriaRole, ReactNode} from 'react'; import type {BaseProps} from '../BaseProps'; -import {use} from 'react'; +import {use, useCallback, useRef, useState, useSyncExternalStore} from 'react'; import * as stylex from '@stylexjs/stylex'; import {colorVars, spacingVars} from '../theme/tokens.stylex'; import {LayoutAreaContext} from './LayoutAreaContext'; @@ -28,6 +28,14 @@ import {useScrollRegionProps} from '../hooks/useScrollRegionProps'; import {mergeProps} from '../utils'; import type {SizeValue, SpacingStep} from '../utils/types'; import type {ResizableProps} from '../Resizable/useResizable'; +import { + createResizablePaneLink, + resizeHandleBeside, +} from '../Resizable/resizablePane'; +import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {moveFocusLeftBehind, noteFocusWithin} from '../utils/focusReturn'; +import {useFocusLeavingChange} from '../utils/useFocusLeavingChange'; import {themeProps} from '../utils/themeProps'; import { paddingStyles, @@ -103,6 +111,8 @@ const styles = stylex.create({ }, }); +const returnFalse = () => false; + // Dynamic styles for sizing props const dynamicStyles = stylex.create({ sizing: (width: SizeValue | null) => ({ @@ -231,6 +241,61 @@ export function LayoutPanel({ // When resizable props are provided, use the hook-driven size const effectiveWidth = resizable ? resizable._size : width; + // With no `resizable`, a ResizeHandle beside the panel that has a region's + // props finds the panel and holds it collapsed with the region. The panel + // lends its element in its callback ref for that. + const [paneLink] = useState(createResizablePaneLink); + const isHeldCollapsed = useSyncExternalStore( + paneLink.subscribe, + paneLink.isCollapsed, + returnFalse, + ); + const isLentBeside = resizable == null; + const lendRef = useCallback( + (element: HTMLDivElement | null) => { + paneLink.attach(isLentBeside ? element : null); + }, + [paneLink, isLentBeside], + ); + + // A collapsing panel moves focus inside it to its ResizeHandle, through the + // focus-leaving sequence that disclosures share. It goes inert and hidden + // from assistive technology a commit later, so inert never drops that focus + // to the page. Opening drops both at once. The ResizeHandle beside it holds + // the region's collapse state, or else the `resizable` props hold it. + const elementRef = useRef(null); + const isRegionCollapsed = + resizable == null ? isHeldCollapsed : resizable._isCollapsed; + const noteFocusBeforeCollapse = useFocusLeavingChange( + isRegionCollapsed, + (wasCollapsed: boolean) => + wasCollapsed ? null : noteFocusWithin(elementRef.current), + (noted, isCollapsed) => { + const panel = elementRef.current; + if (isCollapsed && panel != null) { + moveFocusLeftBehind( + noted, + paneLink.focusTarget() ?? resizeHandleBeside(panel, area), + panel, + ); + } + }, + ); + const [isSettledCollapsed, setIsSettledCollapsed] = + useState(isRegionCollapsed); + if (!isRegionCollapsed && isSettledCollapsed) { + setIsSettledCollapsed(false); + } + // Syncs `inert` with the browser's focus: the panel goes inert only after + // this commit's focus move. + useIsomorphicLayoutEffect(() => { + if (isRegionCollapsed && !isSettledCollapsed) { + // eslint-disable-next-line @eslint-react/set-state-in-effect -- the panel goes inert only after the focus-leaving move above ran in this commit; inert in the collapsing commit would drop that focus to the page first + setIsSettledCollapsed(true); + } + }, [isRegionCollapsed, isSettledCollapsed]); + const isConcealed = isRegionCollapsed && isSettledCollapsed; + // Determine panel position const isStartPanel = area === 'start'; const isEndPanel = area === 'end'; @@ -241,7 +306,7 @@ export function LayoutPanel({ const shouldCollapseSpacing = !hasDivider && !isZeroPadding && padding == null; const region = useScrollRegionProps({isScrollable, role, label}); - const mergedRef = useMergedRefs(ref, region.ref); + const mergedRef = useMergedRefs(ref, region.ref, elementRef, lendRef); // Select divider style based on position const dividerStyle = isStartPanel @@ -288,7 +353,12 @@ export function LayoutPanel({ className, style, )} - {...props}> + {...props} + {...(isConcealed ? {inert: true, 'aria-hidden': true} : {})}> + {children} ); diff --git a/packages/core/src/Resizable/ResizeHandle.tsx b/packages/core/src/Resizable/ResizeHandle.tsx index f73da1158c6ed..52eccba194703 100644 --- a/packages/core/src/Resizable/ResizeHandle.tsx +++ b/packages/core/src/Resizable/ResizeHandle.tsx @@ -12,7 +12,7 @@ * embedded frame still arrive. */ -import {useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useReducer, useRef, useState} from 'react'; import type {HTMLAttributes, ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '../BaseProps'; @@ -35,11 +35,20 @@ import { type ResizeGesture, type ResizeGestureModel, } from './resizeGesture'; +import { + handOverPane, + registerResizeHandle, + resizablePaneFor, + watchResizablePanes, + type ResizablePane, +} from './resizablePane'; import {separatorAttributes} from './separatorAttributes'; import {themeProps} from '../utils/themeProps'; import {useDevWarning} from '../hooks/useDevWarning'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {useMergedRefs} from '../hooks/useMergedRefs'; +import {useInteractionModalityTracking} from '../utils/interactionModality'; import { registerEnglishFallback, useComponentTranslator, @@ -529,6 +538,55 @@ export function ResizeHandle({ }; }, [hasRegion, regionGesture]); + // --- The LayoutPanel beside a handle given `resizable` props --- + + // A collapsing panel moves focus here, and the ring follows the last input. + const {ref: modalityRef} = useInteractionModalityTracking(); + // A LayoutPanel at the region's size, with no `resizable` prop, has no + // controller to find it. This handle holds it collapsed with the region, so + // focus inside it moves here and it goes inert. A panel lent later is found + // at once. + const [, rerender] = useReducer((count: number) => count + 1, 0); + const heldPaneRef = useRef(null); + // The element goes in the handle registry, for a collapsing panel beside it + // to hand focus to. When the element goes, a held panel opens. + const registryRef = useCallback((element: HTMLDivElement | null) => { + if (element == null) { + return; + } + const unregister = registerResizeHandle(element); + return () => { + unregister(); + heldPaneRef.current = handOverPane( + heldPaneRef.current, + null, + false, + null, + ); + }; + }, []); + const isRegionCollapsed = resizable?._isCollapsed ?? false; + // Syncs with the lent-panel registry. A panel lent or taken back re-renders + // this handle, so it looks for its pane again. + useEffect( + () => (hasRegion ? watchResizablePanes(rerender) : undefined), + [hasRegion], + ); + // Syncs the held panel with the region's collapse state after each commit. + useIsomorphicLayoutEffect(() => { + const element = handleRef.current; + const pane = + hasRegion && element != null + ? resizablePaneFor(element, isReversed) + : null; + heldPaneRef.current = handOverPane( + heldPaneRef.current, + pane, + isRegionCollapsed, + element, + ); + }); + // --- ARIA --- const sizeNow = resizable?._size; const minNow = resizable?._minSizePx; @@ -587,7 +645,7 @@ export function ResizeHandle({ return (