-
Notifications
You must be signed in to change notification settings - Fork 232
fix: Fix container-query driven layout oscillation caused by scrollbars #4615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| // 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> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.