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
42 changes: 42 additions & 0 deletions pages/container-query-scrollbar.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Comment thread
avinashbot marked this conversation as resolved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';

import Grid from '~components/grid';

const ARTICLE =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut ' +
'labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' +
'laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in ' +
'voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat ' +
'non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis ' +
'unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, ' +
'eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt ' +
'explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia ' +
'consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, ' +
'qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius ' +
'modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.';

export default function GridScrollbarOscillationSimplePage() {
return (
<>
<h1>Grid scrollbar oscillation (AWSUI-62065)</h1>
<p style={{ maxInlineSize: 720 }}>
Slowly resize the window width around <code>1120px</code>. The window should be short enough that vertical
scrollbars just start to appear on the wider breakpoint. Side by side, the text column is squeezed and wraps
tall enough to show a scrollbar; stacked, it gets the full width, becomes shorter, and the scrollbar disappears
— flipping the layout back and forth.
</p>

<Grid
gridDefinition={[
{ colspan: { default: 12, m: 6 } }, // long text column
{ colspan: { default: 12, m: 6 } }, // short side column
]}
>
<div style={{ border: '1px solid black', padding: 16 }}>{ARTICLE}</div>
<div style={{ border: '1px solid black', padding: 16 }}>Summary — a short side column</div>
</Grid>
</>
);
}
52 changes: 52 additions & 0 deletions src/internal/__tests__/breakpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,58 @@ describe('getMatchingBreakpoint', () => {
it('returns "default" if the filter is an empty array', () => {
expect(getMatchingBreakpoint(1000, [])).toBe('default');
});

describe('previousBreakpoint', () => {
// xs boundary is 688px. The dead-band is +/- 20px around it: [668, 708].
it('does not switch down when the width shrinks slightly below the boundary', () => {
// Width drops to 680 (e.g. a 17px scrollbar appeared) but we were already at "xs".
expect(getMatchingBreakpoint(680, undefined, 'xs')).toBe('xs');
});

it('does not switch up when the width grows slightly above the boundary', () => {
// Width rises to 695 but we were at "xxs"; still within the dead-band.
expect(getMatchingBreakpoint(695, undefined, 'xxs')).toBe('xxs');
});

it('switches down once the width clears the boundary by more than the margin', () => {
expect(getMatchingBreakpoint(665, undefined, 'xs')).toBe('xxs');
});

it('switches up once the width clears the boundary by more than the margin', () => {
expect(getMatchingBreakpoint(710, undefined, 'xxs')).toBe('xs');
});

it('uses nominal thresholds when no previous breakpoint is provided', () => {
expect(getMatchingBreakpoint(680)).toBe('xxs');
expect(getMatchingBreakpoint(695)).toBe('xs');
expect(getMatchingBreakpoint(680, undefined, null)).toBe('xxs');
});

it('prevents oscillation across a scrollbar-width toggle near a boundary', () => {
const scrollbar = 17;
// Start just above the xs boundary with no scrollbar.
const wideWidth = 695;
const narrowWidth = wideWidth - scrollbar; // 678, scrollbar present

// First (initial) resolution uses nominal thresholds -> "xs".
let breakpoint = getMatchingBreakpoint(wideWidth);
expect(breakpoint).toBe('xs');

// Scrollbar appears, width shrinks: stickiness keeps us at "xs".
breakpoint = getMatchingBreakpoint(narrowWidth, undefined, breakpoint);
expect(breakpoint).toBe('xs');

// Scrollbar disappears, width grows back: still "xs". Stable, no flip-flop.
breakpoint = getMatchingBreakpoint(wideWidth, undefined, breakpoint);
expect(breakpoint).toBe('xs');
});

it('applies stickiness together with a breakpoint filter', () => {
// "xs" filtered out -> nominal would fall back to "xxs"; with previous "xs" stays sticky is
// not possible (filtered), so it resolves within the allowed set.
expect(getMatchingBreakpoint(680, ['default', 'xxs', 's'], 'xxs')).toBe('xxs');
});
});
});

describe('matchBreakpointMapping', () => {
Expand Down
35 changes: 32 additions & 3 deletions src/internal/breakpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,44 @@ export function matchBreakpointMapping<T>(subset: Partial<Record<Breakpoint, T>>
return null;
}

/**
* The width (in px) by which a measured container can switch into a breakpoint boundary without
* actually triggering that breakpoint switch. This makes each breakpoint edge "sticky", making
* you have to travel a little further into the breakpoint to "lose" the previous one.
*
* When a JS-resolved breakpoint sits within a scrollbar-width of a boundary, switching the layout
* can grow/shrink the page enough to toggle the viewport scrollbar, which in turn changes the
* measured width and flips the breakpoint back — an infinite layout loop (see AWSUI-62065).
*/
const BREAKPOINT_SWITCH_OFFSET = 20;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is probably fine, I am wondering if we could/should calculate the width of the scrollbar. This way, the approach would also work for cases where the scrollbar is bigger than 20px. Note: As of now, all default scrollbar sizes are <= 17px (https://codepen.io/sambible/post/browser-scrollbar-widths) - but could theoretically be changed by browser extensions (or even ourselves through CSS).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could go either way on that, I was conflicted about this.

We actually already have a utility for it (browser-scrollbar-size.ts) but the only reliable way of doing is to modify the DOM and measure the elements, which is a bit of a worrying performance trap (not something I noticed on my Macbook on a simple dev page, but breakpoint measurements are pretty foundational and used in a lot of places). Plus, having a fixed number makes it more predictable between test and real environments (for example, we don't have any testing for overlay scrollbars, even browser/screenshot tests).


/**
* Get the named breakpoint for a provided width, optionally filtering to a subset of breakpoints.
*/
export function getMatchingBreakpoint<T extends readonly Breakpoint[]>(
width: number,
breakpointFilter?: T
breakpointFilter?: T,
previousBreakpoint?: Breakpoint | null
): T[number] | 'default' {
for (const [breakpoint, breakpointWidth] of BREAKPOINT_MAPPING) {
if (width > breakpointWidth && (!breakpointFilter || breakpointFilter.indexOf(breakpoint) !== -1)) {
const previousBreakpointIndex =
previousBreakpoint === undefined || previousBreakpoint === null
? -1
: BREAKPOINTS_DESCENDING.indexOf(previousBreakpoint);

for (let i = 0; i < BREAKPOINT_MAPPING.length; i++) {
const [breakpoint, breakpointWidth] = BREAKPOINT_MAPPING[i];
if (breakpointFilter && breakpointFilter.indexOf(breakpoint) === -1) {
continue;
}
// Based on BREAKPOINT_SWITCH_OFFSET, we either shrink or grow the breakpoint value we match against
// depending on whether the previous breakpoint was above or below the matched one. This enables the
// "sticky" behavior that makes the user have to resize the element further into a breakpoint boundary
// to actually switch the breakpoint.
let stickyBreakpointWidth = breakpointWidth;
if (previousBreakpointIndex !== -1) {
stickyBreakpointWidth += previousBreakpointIndex <= i ? -BREAKPOINT_SWITCH_OFFSET : BREAKPOINT_SWITCH_OFFSET;
}
if (width > stickyBreakpointWidth) {
return breakpoint;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { Breakpoint, getMatchingBreakpoint } from '../../breakpoints';
export function useContainerBreakpoints<T extends readonly Breakpoint[], E extends Element = any>(triggers?: T) {
// triggers.join() gives us a stable value to use for the dependencies argument
const triggersDep = triggers?.join();
// eslint-disable-next-line react-hooks/exhaustive-deps
return useContainerQuery(rect => getMatchingBreakpoint(rect.contentBoxWidth, triggers), [triggersDep]) as [
'default' | T[number] | null,
React.Ref<E>,
];
return useContainerQuery(
(rect, previousBreakpoint) => getMatchingBreakpoint(rect.contentBoxWidth, triggers, previousBreakpoint),
// eslint-disable-next-line react-hooks/exhaustive-deps
[triggersDep]
) as ['default' | T[number] | null, React.Ref<E>];
}
Loading