diff --git a/.changeset/layout-panel-zero-width.md b/.changeset/layout-panel-zero-width.md new file mode 100644 index 0000000000000..27aa42c51d8a9 --- /dev/null +++ b/.changeset/layout-panel-zero-width.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A `LayoutPanel` whose width is the number 0, such as one given a collapsed `useResizable` region's `resizable` props or `width={0}`, now takes no space and shows none of its children. Before, its padding kept it about 32px wide and its children showed squeezed into that strip, one letter per line. This is visual only: keyboard and assistive-technology hiding for a collapsed panel is separate. + +@vjeux diff --git a/apps/storybook/stories/Resizable.stories.tsx b/apps/storybook/stories/Resizable.stories.tsx index 81c0def763be9..2055b28de8ad3 100644 --- a/apps/storybook/stories/Resizable.stories.tsx +++ b/apps/storybook/stories/Resizable.stories.tsx @@ -14,6 +14,7 @@ import { } from '@astryxdesign/core/Layout'; import {SideNav, SideNavItem} from '@astryxdesign/core/SideNav'; import {Divider} from '@astryxdesign/core/Divider'; +import {Button} from '@astryxdesign/core/Button'; const ps = stylex.create({ shell: { @@ -752,3 +753,88 @@ export const HoverPillOnOverlayHandle: StoryObj = { args: {pillPlacement: 'start', dir: 'ltr'}, render: args => , }; + +/** + * Browser fixture for LayoutPanel at width 0. The start panel takes a + * collapsed region's props and has no divider. The end panel has + * `width={0}` and a divider. Neither takes space or shows its children. + */ +function CollapsedPanels() { + const sidebar = useResizable({ + defaultSize: 200, + collapsible: true, + defaultIsCollapsed: true, + }); + return ( +
+ + + Panel + Collapsed with its region. + + + + } + content={ + + The content starts at the handle. + + } + end={ + + Details + + } + /> +
+ ); +} + +export const CollapsedPanelFixture: Story = { + tags: ['!autodocs', 'no-visual'], + render: () => , +}; + +/** + * Browser fixture for a scrolling LayoutPanel that collapses to width 0. Its + * content overflows while it is open, and a button collapses and opens it. + */ +function CollapsingScrollPanel() { + const [isOpen, setIsOpen] = useState(true); + return ( +
+ +
+ ); +} + +export const CollapsingScrollPanelFixture: Story = { + tags: ['!autodocs', 'no-visual'], + render: () => , +}; diff --git a/docs/specs/components/Layout/LayoutPanel.spec.md b/docs/specs/components/Layout/LayoutPanel.spec.md index b305fcb7b2511..6116b2af4a48c 100644 --- a/docs/specs/components/Layout/LayoutPanel.spec.md +++ b/docs/specs/components/Layout/LayoutPanel.spec.md @@ -205,14 +205,14 @@ of its own, so no anatomy-theming block is declared here. | Contract | Verification | Representative states | Mutation or failure expectation | Audit section | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, ORD1 | `LayoutSlots.test.tsx` class, width, and resizable tests | Numeric and string width; resizable | A lost `layout-panel` class, a missing width, or `width` beating `resizable` fails. Gap: the non-shrinking root, scrolling, and `isScrollable={false}` have no check. No requirement or check covers how a keyboard user reaches a scrolling panel's overflow when the panel holds no focusable content. | `audit:LayoutPanel/layout` | +| FR1, ORD1 | `LayoutSlots.test.tsx` class, width, and resizable tests; `collapsedPanel.interaction.browser.spec.ts` for width 0 | Numeric, string and zero width; resizable; a collapsed region | A lost `layout-panel` class, a missing width, or `width` beating `resizable` fails. Gap: the non-shrinking root, scrolling, and `isScrollable={false}` have no check. No requirement or check covers how a keyboard user reaches a scrolling panel's overflow when the panel holds no focusable content. | `audit:LayoutPanel/layout` | | FR2 | `LayoutSlots.test.tsx` start- and end-slot tests; `Layout.a11y.chromium.spec.ts` `start-end-navigation-rtl` state | Start and end panels; right-to-left | A panel that fails to render in either slot fails. Gap: no check asserts which edge carries the divider or the shared gap. | `audit:LayoutPanel/layout` | | FR3, ORD2 | Source review | Automatic, explicit, and zero padding | Gap: no test covers the automatic per-edge inset, the `padding` replacement, full bleed, or the published values. | `audit:LayoutPanel/layout` | | FR4 | Source review | `hasDivider`; no divider | Gap: no test covers the divider line, the shared gap, or a panel divider beside a ResizeHandle. | `audit:LayoutPanel/layout` | | FR5, AR1 | `LayoutSlots.test.tsx` landmark test; `Layout.a11y.test.tsx` and `Layout.a11y.chromium.spec.ts` `start-end-navigation-rtl` and `nested-panel-region` states | Caller `navigation` and `region` roles and labels | A lost role or name fails, including at the accessibility tree. Gap: no test asserts that a panel without `role` exposes none. | `audit:LayoutPanel/accessibility` | | FR6 | `LayoutSlots.test.tsx` ref and className tests | Ref; caller class | A ref on another element or a dropped class fails. Gap: no test covers `style`, DOM, data, or event props, or the `xstyle` merge order. | `audit:LayoutPanel/api` | | FR7, AR2 | Real-browser frame capture of a start and an end panel collapsing and opening, with focus inside, with the hook's `props` and with a hand-built object (pending) | Start and end; toggle, Enter and a drag past the threshold; configuration-object form; Reduce Motion | A jump instead of a slide, a gap beside the moving edge, a slide under the configuration-object form, focus left in a collapsed panel, a collapsed panel reachable by Tab or exposed to assistive technology, or a new prop fails. | `audit:LayoutPanel/behavior` | -| AR3 | `useScrollRegionProps.test.tsx` LayoutPanel tests; `scrollRegionName.a11y.chromium.spec.ts` `panel-scrolls` | Scrolling panel; caller `navigation` role and label | A group role or name missing while the panel overflows fails, at Chromium's accessibility tree too; a caller's role or label lost fails. | `audit:LayoutPanel/accessibility` | +| AR3 | `useScrollRegionProps.test.tsx` LayoutPanel tests; `scrollRegionName.a11y.chromium.spec.ts` `panel-scrolls`; `collapsedPanel.interaction.browser.spec.ts` | Scrolling panel; caller `navigation` role and label; a scrolling panel collapsed to width 0 | A group role or name missing while the panel overflows fails, at Chromium's accessibility tree too; a caller's role or label lost fails. A group role kept at width 0 fails. | `audit:LayoutPanel/accessibility` | | PR1 | Source review | Every state | Gap: no automated check fails if LayoutPanel adds a listener, timer, or measurement. | `audit:LayoutPanel/behavior` | | RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Content width with start panel` story | Start panel beside the content; LTR and RTL | A start panel that is not at the inline start of the content in both directions fails D2. | `audit:LayoutPanel/i18n-rtl` | | FR8 | `resizableSlot.test.tsx` region and `props` cases | The region; its `props` | A panel that ignores the region's size, or a collapse that leaves it reachable, fails. | `audit:LayoutPanel/api` | diff --git a/packages/core/src/Layout/LayoutPanel.tsx b/packages/core/src/Layout/LayoutPanel.tsx index f0949447715f2..a2d02c57275ea 100644 --- a/packages/core/src/Layout/LayoutPanel.tsx +++ b/packages/core/src/Layout/LayoutPanel.tsx @@ -92,6 +92,12 @@ const styles = stylex.create({ scrollable: { overflow: 'auto', }, + // At width 0 the padding box is 0 too, so the clip hides every child. + zeroWidth: { + paddingInlineStart: 0, + paddingInlineEnd: 0, + overflow: 'clip', + }, // For start panel: divider on end edge dividerEnd: { borderInlineEndWidth: 1, @@ -307,11 +313,18 @@ export function LayoutPanel({ const isEndPanel = area === 'end'; const isZeroPadding = padding === 0; + // A collapsed region sizes its panel to 0. + const isZeroWidth = effectiveWidth === 0; // When no divider, collapse spacing for seamless visual flow const shouldCollapseSpacing = - !hasDivider && !isZeroPadding && padding == null; - const region = useScrollRegionProps({isScrollable, role, label}); + !hasDivider && !isZeroPadding && padding == null && !isZeroWidth; + // At width 0 the panel clips its content, so it does not scroll. + const region = useScrollRegionProps({ + isScrollable: isScrollable && !isZeroWidth, + role, + label, + }); const mergedRef = useMergedRefs(ref, region.ref, elementRef, lendRef); // Select divider style based on position @@ -354,6 +367,7 @@ export function LayoutPanel({ padding != null && containerPaddingBlockEndVarStyles[padding], hasDivider && dividerStyle, shouldCollapseSpacing && collapseStyle, + isZeroWidth && styles.zeroWidth, xstyle, ), className, diff --git a/packages/core/src/Layout/__tests__/collapsedPanel.interaction.browser.spec.ts b/packages/core/src/Layout/__tests__/collapsedPanel.interaction.browser.spec.ts new file mode 100644 index 0000000000000..2d30ccdad5d92 --- /dev/null +++ b/packages/core/src/Layout/__tests__/collapsedPanel.interaction.browser.spec.ts @@ -0,0 +1,124 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file collapsedPanel.interaction.browser.spec.ts + * @input The a11y-spec Playwright entry and the interaction harness: + * Resizable's collapsed-panel fixture story + * @output Proves a LayoutPanel at width 0 takes no space, shows none of its + * children, and is no scrolling group, in Chromium and WebKit + * @position Interaction proof for LayoutPanel.tsx + */ + +import {expect, test} from '@astryxdesign/a11y-spec/playwright'; +import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +const STORY = 'core-resizable--collapsed-panel-fixture'; + +interface PanelReading { + /** The panel's border box, which a divider widens by its line. */ + width: number; + paddingInline: number; + /** The children's boxes and text runs that have an area. */ + laidOutChildRects: number; + /** Of those, the ones inside the panel's padding box, where its clip shows them. */ + shownChildRects: number; +} + +test('a LayoutPanel at width 0 takes no space and shows none of its children', async ({ + page, +}) => { + await openStory(page, storybook(), STORY); + const readings: [string, PanelReading][] = []; + for (const testId of ['collapsed-start', 'collapsed-end']) { + const reading: PanelReading = await page.evaluate(id => { + const panel = document.querySelector( + `[data-testid="${id}"]`, + ); + if (panel == null) { + throw new Error(`the fixture did not render ${id}`); + } + const box = panel.getBoundingClientRect(); + const style = getComputedStyle(panel); + // The clip shows a child only inside the padding box. The handle's grab + // zone can cover that box, so the check reads geometry, not hit tests. + const clip = { + left: box.left + parseFloat(style.borderLeftWidth), + right: box.right - parseFloat(style.borderRightWidth), + top: box.top + parseFloat(style.borderTopWidth), + bottom: box.bottom - parseFloat(style.borderBottomWidth), + }; + const rects: DOMRect[] = []; + for (const element of panel.querySelectorAll('*')) { + rects.push(...element.getClientRects()); + } + const walker = document.createTreeWalker(panel, NodeFilter.SHOW_TEXT); + for ( + let node = walker.nextNode(); + node != null; + node = walker.nextNode() + ) { + const range = document.createRange(); + range.selectNodeContents(node); + rects.push(...range.getClientRects()); + } + const laidOut = rects.filter(rect => rect.width > 0 && rect.height > 0); + const shown = laidOut.filter( + rect => + Math.min(rect.right, clip.right) - Math.max(rect.left, clip.left) > + 0 && + Math.min(rect.bottom, clip.bottom) - Math.max(rect.top, clip.top) > 0, + ); + return { + width: box.width, + paddingInline: + parseFloat(style.paddingInlineStart) + + parseFloat(style.paddingInlineEnd), + laidOutChildRects: laidOut.length, + shownChildRects: shown.length, + }; + }, testId); + readings.push([testId, reading]); + } + // The children are laid out, so their absence on screen is the clip. + for (const [testId, reading] of readings) { + expect(reading.laidOutChildRects, testId).toBeGreaterThan(0); + } + expect( + readings.map(([testId, reading]) => [ + testId, + reading.width, + reading.paddingInline, + reading.shownChildRects, + ]), + ).toEqual([ + ['collapsed-start', 0, 0, 0], + ['collapsed-end', 1, 0, 0], + ]); +}); + +test('a scrolling LayoutPanel collapsed to width 0 is no longer a named group', async ({ + page, +}) => { + await openStory( + page, + storybook(), + 'core-resizable--collapsing-scroll-panel-fixture', + ); + const panel = page.getByTestId('collapsing-end'); + // While it is open, its content overflows, so it is the named group. + await expect(panel).toHaveAttribute('role', 'group'); + await expect(panel).toHaveAttribute('aria-label', /.+/); + await page.getByRole('button', {name: 'Collapse details'}).click(); + await expect + .poll(async () => + panel.evaluate(element => element.getBoundingClientRect().width), + ) + .toBeLessThanOrEqual(1); + // At width 0 the panel clips its content, so it cannot scroll. + await expect(panel).not.toHaveAttribute('role', 'group'); + await expect(panel).not.toHaveAttribute('aria-label'); + await page.getByRole('button', {name: 'Open details'}).click(); + await expect(panel).toHaveAttribute('role', 'group'); +});