Skip to content
Open
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
35 changes: 35 additions & 0 deletions packages/react-aria-components/stories/ListBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1146,3 +1146,38 @@ export const DropOntoRoot = () => (
<DroppableListBox />
</div>
);

export const FractionalWidth: StoryFn = () => {
let items = Array.from({length: 50}, (_, i) => ({id: i, name: `Item ${i + 1}`}));
return (
<div
style={{
display: 'flex',
width: 501,
border: '1px solid gray'
}}>
<div style={{width: '50%'}}>
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: 32}}>
<ListBox
aria-label="Fractional width list 1"
className={styles.menu}
style={{height: 300}}
items={items}>
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
</ListBox>
</Virtualizer>
</div>
<div style={{width: '50%'}}>
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: 32}}>
<ListBox
aria-label="Fractional width list 2"
className={styles.menu}
style={{height: 300}}
items={items}>
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
</ListBox>
</Virtualizer>
</div>
</div>
);
};
68 changes: 68 additions & 0 deletions packages/react-aria-components/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,74 @@ describe('ListBox', () => {
]);
});

it('should not cause horizontal overflow with fractional container width', () => {
let items = [
{id: 1, name: 'Item 1'},
{id: 2, name: 'Item 2'}
];

jest.restoreAllMocks();
jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 250.5,
height: 500,
top: 0,
left: 0,
bottom: 500,
right: 250.5,
x: 0,
y: 0,
toJSON: () => {}
}));
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 251);
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 500);

let {getByRole} = render(
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: 25}}>
<ListBox aria-label="Test" items={items}>
{item => <ListBoxItem>{item.name}</ListBoxItem>}
</ListBox>
</Virtualizer>
);

let listbox = getByRole('listbox');
let contentWrapper = listbox.firstElementChild;
expect(parseFloat(contentWrapper.style.width)).toBeLessThanOrEqual(250.5);
});

it('should not cause vertical overflow with fractional container height', () => {
let items = [
{id: 1, name: 'Item 1'},
{id: 2, name: 'Item 2'}
];

jest.restoreAllMocks();
jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 500,
height: 250.5,
top: 0,
left: 0,
bottom: 250.5,
right: 500,
x: 0,
y: 0,
toJSON: () => {}
}));
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 500);
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 251);

let {getByRole} = render(
<Virtualizer layout={ListLayout} layoutOptions={{orientation: 'horizontal', rowHeight: 25}}>
<ListBox orientation="horizontal" aria-label="Test" items={items}>
{item => <ListBoxItem>{item.name}</ListBoxItem>}
</ListBox>
</Virtualizer>
);

let listbox = getByRole('listbox');
let contentWrapper = listbox.firstElementChild;
expect(parseFloat(contentWrapper.style.height)).toBeLessThanOrEqual(250.5);
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => {
let {getByRole} = renderListbox({selectionMode: 'multiple', escapeKeyBehavior: 'none'});

Expand Down
33 changes: 25 additions & 8 deletions packages/react-aria/src/virtualizer/ScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ interface ScrollViewAria {
contentProps: HTMLAttributes<HTMLElement>;
}

function getClientSize(dom: HTMLElement) {
let clientWidth = dom.clientWidth;
let clientHeight = dom.clientHeight;
let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON;

let rect = dom.getBoundingClientRect?.();
if (rect && rect.width > 0 && rect.height > 0) {
if (!isTestEnv || rect.width % 1 !== 0 || rect.height % 1 !== 0) {
clientWidth = rect.width - Math.max(0, dom.offsetWidth - dom.clientWidth);
clientHeight = rect.height - Math.max(0, dom.offsetHeight - dom.clientHeight);
}
}
return {clientWidth, clientHeight};
}

export function useScrollView(
props: ScrollViewProps,
ref: RefObject<HTMLElement | null>
Expand Down Expand Up @@ -260,8 +275,7 @@ export function useScrollView(
let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes(
'clientHeight'
);
let clientWidth = dom.clientWidth;
let clientHeight = dom.clientHeight;
let {clientWidth, clientHeight} = getClientSize(dom);
let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth;
let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight;

Expand All @@ -286,12 +300,15 @@ export function useScrollView(
// adjusted space. In very specific cases this might result in the scrollbars disappearing
// again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
// an infinite loop. This matches how browsers behavior with native CSS grid layout.
if ((!isTestEnv && clientWidth !== dom.clientWidth) || clientHeight !== dom.clientHeight) {
state.size = new Size(dom.clientWidth, dom.clientHeight);
flush(() => {
updateVisibleRect();
onSizeChange?.(state.size);
});
if (!isTestEnv) {
let nextSize = getClientSize(dom);
if (clientWidth !== nextSize.clientWidth || clientHeight !== nextSize.clientHeight) {
state.size = new Size(nextSize.clientWidth, nextSize.clientHeight);
flush(() => {
updateVisibleRect();
onSizeChange?.(state.size);
});
}
}
}

Expand Down