Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/layout-panel-zero-width.md
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions apps/storybook/stories/Resizable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -752,3 +753,88 @@ export const HoverPillOnOverlayHandle: StoryObj<typeof HoverPillOnOverlay> = {
args: {pillPlacement: 'start', dir: 'ltr'},
render: args => <HoverPillOnOverlay {...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 (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
<>
<LayoutPanel
resizable={sidebar.props}
hasDivider={false}
data-testid="collapsed-start">
<Heading level={4}>Panel</Heading>
<Text>Collapsed with its region.</Text>
</LayoutPanel>
<ResizeHandle resizable={sidebar.props} label="Resize panel" />
</>
}
content={
<LayoutContent data-testid="collapsed-content">
<Text>The content starts at the handle.</Text>
</LayoutContent>
}
end={
<LayoutPanel width={0} hasDivider data-testid="collapsed-end">
<Heading level={4}>Details</Heading>
</LayoutPanel>
}
/>
</div>
);
}

export const CollapsedPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <CollapsedPanels />,
};

/**
* 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 (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
content={
<LayoutContent>
<Button
label={isOpen ? 'Collapse details' : 'Open details'}
onClick={() => setIsOpen(open => !open)}
/>
</LayoutContent>
}
end={
<LayoutPanel
width={isOpen ? 240 : 0}
hasDivider
data-testid="collapsing-end">
{Array.from({length: 40}, (_, index) => (
<Text key={index}>Detail line {index + 1}</Text>
))}
</LayoutPanel>
}
/>
</div>
);
}

export const CollapsingScrollPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <CollapsingScrollPanel />,
};
4 changes: 2 additions & 2 deletions docs/specs/components/Layout/LayoutPanel.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/Layout/LayoutPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -354,6 +367,7 @@ export function LayoutPanel({
padding != null && containerPaddingBlockEndVarStyles[padding],
hasDivider && dividerStyle,
shouldCollapseSpacing && collapseStyle,
isZeroWidth && styles.zeroWidth,
xstyle,
),
className,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
`[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');
});
Loading