From a6642d3b230ac72f4ee5d736a0eeb43f68b1d1b5 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 10:04:57 -0700 Subject: [PATCH 1/5] fix(BottomSheet): resting content starts below the grab bar, whatever the padding The grab bar floats over the content: 24 px tall, opaque behind the pill and fading out across its lower half. At the default padding (16 px), a sheet's first line started inside that fade, and a Popover sheet's (12 px) under its opaque part, so the top of the first line read washed out or cut. At rest, the content box's block-start inset is now never less than the bar's height (`max(padding, --spacing-6)`), so the first line starts below the bar for any `padding`, `padding={0}` included. The inset is the content box's own padding, so it scrolls away with the content, which still passes beneath the bar. The published inset stays the padding, so a child that bleeds into it also starts below the bar. component:BottomSheet's Content area inset records the rule. Chromium proof: BottomSheetGrabBar.a11y.chromium.spec.ts (first line below the bar at rest; fitting sheets don't scroll; scrolled content passes under the bar), adapted from the measurements in the alternative fix that shortened the fade instead. Co-authored-by: Christopher Chedeau --- .changeset/bottom-sheet-clears-grab-bar.md | 7 + .../core/src/BottomSheet/BottomSheet.spec.md | 4 +- .../src/BottomSheet/BottomSheetPanel.test.tsx | 30 ++- .../core/src/BottomSheet/BottomSheetPanel.tsx | 15 +- .../BottomSheetGrabBar.a11y.chromium.spec.ts | 213 ++++++++++++++++++ 5 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 .changeset/bottom-sheet-clears-grab-bar.md create mode 100644 packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts diff --git a/.changeset/bottom-sheet-clears-grab-bar.md b/.changeset/bottom-sheet-clears-grab-bar.md new file mode 100644 index 0000000000000..9349e778d3404 --- /dev/null +++ b/.changeset/bottom-sheet-clears-grab-bar.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. Scrolled content still passes beneath the bar. A sheet at the default padding starts its content 8 px lower; one with `padding={0}` keeps its content clear of the bar. + +@vjeux diff --git a/packages/core/src/BottomSheet/BottomSheet.spec.md b/packages/core/src/BottomSheet/BottomSheet.spec.md index be949fa553024..7a08611b3a744 100644 --- a/packages/core/src/BottomSheet/BottomSheet.spec.md +++ b/packages/core/src/BottomSheet/BottomSheet.spec.md @@ -195,7 +195,9 @@ remain documented in `BottomSheet.doc.mjs`. ### Transformation and precedence order - Content area inset: the `padding` prop, then the theme's `padding` properties - on `bottom-sheet`, then `--spacing-4` on every logical edge. + on `bottom-sheet`, then `--spacing-4` on every logical edge. At rest the + block-start inset is never less than the grab bar's height (`--spacing-6`), + so the first row starts below the bar; scrolled content still passes under it. - Stack order: open order, then tree order for sheets opening in one update (FR5). A sheet that closes and reopens takes a new place on top. - Native top-layer order is derived from the stack order, not trusted: where diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx index 197b9e8e05807..c22acf95bca07 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx @@ -414,6 +414,14 @@ describe('BottomSheetPanel', () => { ['block-start', 'padding-top'], ['block-end', 'padding-bottom'], ] as const; + // The applied block-start inset never drops below the grab bar's height, + // so resting content starts below the bar (component:BottomSheet, Content + // area inset); the published value stays the padding, so a child that + // bleeds into it escapes only the padding and also starts below the bar. + const appliedAt = (edge: string, published: string) => + edge === 'block-start' + ? `max(${published}, var(--spacing-6))` + : published; function renderWithPadding(padding?: 0 | 2) { render( @@ -446,8 +454,16 @@ describe('BottomSheetPanel', () => { ); // Applied padding reads the published value, so bleed children // subtract exactly the inset they sit in. - expect(computed.getPropertyValue(property), edge).toBe( - `var(--container-padding-${edge})`, + expect( + computed.getPropertyValue(property).replace(/\s+/g, ''), + edge, + ).toBe( + appliedAt( + edge, + edge === 'block-start' + ? 'var(--container-padding-block-start, 0px)' + : `var(--container-padding-${edge})`, + ).replace(/\s+/g, ''), ); } for (const name of [ @@ -473,7 +489,15 @@ describe('BottomSheetPanel', () => { computed.getPropertyValue(`--container-padding-${edge}`), edge, ).toBe(expected); - expect(computed.getPropertyValue(property), edge).toBe(expected); + expect( + computed.getPropertyValue(property).replace(/\s+/g, ''), + edge, + ).toBe( + (edge === 'block-start' + ? appliedAt(edge, 'var(--container-padding-block-start, 0px)') + : expected + ).replace(/\s+/g, ''), + ); } expect(computed.getPropertyValue('--layout-padding-inner-x')).toBe( expected, diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.tsx index c77b854739346..4c357ea64dd9c 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.tsx @@ -247,10 +247,9 @@ const styles = stylex.create({ // change width partway down. Painting the surface here hides the ring // evenly, leaving the border below as the sheet's one edge. backgroundColor: colorVars['--color-background-surface'], - // No reserve for the handle bar: it floats, so the content starts at the - // sheet's top edge and rides up under the pill. The pill is 4px centered - // in a 24px band, so it occupies only 10-14px from the edge -- inside the - // space a content wrapper's own top padding already provides. + // No reserve for the handle bar here: it floats over the scrolling area, + // so scrolled content rides up under the pill. The content box keeps the + // resting content clear of the bar instead (`clearsGrabBar`). paddingBlockEnd: 0, }, content: { @@ -264,6 +263,13 @@ const styles = stylex.create({ height: '100%', minHeight: 'min-content', }, + // At rest the content starts below the grab bar whatever the padding, so + // the first row never sits under the bar's fade (component:BottomSheet's + // Content area inset). The inset is the content box's own padding, so it + // scrolls away with the content, which then passes under the bar. + clearsGrabBar: { + paddingBlockStart: `max(var(--container-padding-block-start, 0px), ${HANDLE_BAR_HEIGHT})`, + }, tallKeyboardBody: { scrollPaddingBlockEnd: MOBILE_KEYBOARD_BOTTOM_CLEARANCE, '::after': { @@ -809,6 +815,7 @@ export function BottomSheetPanel({ !usesThemePadding && effectivePadding !== 4 && containerPaddingBlockEndVarStyles[effectivePadding], + styles.clearsGrabBar, ), ), )}> diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..43836a01ee8d4 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts @@ -0,0 +1,213 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file BottomSheetGrabBar.a11y.chromium.spec.ts + * @input Built BottomSheet stories in a phone-sized Chromium + * @output Browser evidence that a sheet's content starts below the floating + * grab bar at rest, whatever its padding, so the first line is clear of the + * bar's fade; that scrolled content still passes beneath the bar; and that + * a sheet whose content fits still does not scroll + * @position Chromium proof for component:BottomSheet's Content area inset + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +test.use({ + hasTouch: true, + isMobile: true, + viewport: {width: 390, height: 844}, +}); + +type Reading = { + barTop: number; + barBottom: number; + // Measured from the bar's top edge, as painted. + opaqueEnd: number; + fadeEnd: number; + pillBottom: number; + firstLineTop: number | null; + contentTop: number; + overflow: number; +}; + +async function open(page: Page, story: string) { + await page.goto( + `${storybook.origin}/iframe.html?id=core-bottomsheet--${story}&viewMode=story`, + ); + await page.locator('#storybook-root').waitFor(); + await page.waitForTimeout(500); + // Most stories open the sheet from a trigger; a few start open. + if ((await page.locator('dialog[open]').count()) === 0) { + await page.locator('#storybook-root button').first().tap(); + } + await expect(page.locator('dialog[open]')).toBeVisible(); + await page.waitForTimeout(800); // the entrance +} + +// The grab bar, the first line of the caller's content, and the scrolling +// area's overflow, all read from the sheet as it is painted. +async function read(page: Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('dialog[open]'); + if (dialog == null) { + throw new Error('no open sheet'); + } + // The grab bar floats over the body: absolutely placed, hidden from + // assistive technology, painting the surface as a fading band. + const bar = Array.from( + dialog.querySelectorAll('[aria-hidden="true"]'), + ).find(element => { + const style = getComputedStyle(element); + return ( + style.position === 'absolute' && + style.backgroundImage.includes('gradient') + ); + }); + const viewport = dialog.querySelector('[data-scroll-axis]'); + const content = dialog.querySelector('[data-scroll-content]'); + if (bar == null || viewport == null || content == null) { + throw new Error('the sheet has no grab bar, scroll area or content'); + } + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + let firstLineTop: number | null = null; + for (let node = walker.nextNode(); node != null; node = walker.nextNode()) { + if ((node.textContent ?? '').trim() === '') { + continue; + } + const range = document.createRange(); + range.selectNodeContents(node); + const box = range.getBoundingClientRect(); + if (box.height > 0) { + firstLineTop = box.top; + break; + } + } + const barBox = bar.getBoundingClientRect(); + // The backdrop's stops, read from the painted gradient: the first stop is + // where the opaque surface ends, the last where the fade does (a stop + // with no position sits at the end of the band). + const image = getComputedStyle(bar).backgroundImage; + const inner = image.slice(image.indexOf('(') + 1, image.lastIndexOf(')')); + const stops: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '(') { + depth++; + } else if (inner[i] === ')') { + depth--; + } else if (inner[i] === ',' && depth === 0) { + stops.push(inner.slice(start, i).trim()); + start = i + 1; + } + } + stops.push(inner.slice(start).trim()); + const rootFont = Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ); + const at = (stop: string, fallback: number) => { + const match = stop.match(/(-?[\d.]+)(px|%|rem)$/); + if (match == null) { + return fallback; + } + const value = Number(match[1]); + return match[2] === '%' + ? (barBox.height * value) / 100 + : match[2] === 'rem' + ? value * rootFont + : value; + }; + const pillHeight = Number.parseFloat( + getComputedStyle(bar, '::before').height, + ); + return { + barTop: barBox.top, + barBottom: barBox.bottom, + opaqueEnd: at(stops[0] ?? '', 0), + fadeEnd: at(stops[stops.length - 1] ?? '', barBox.height), + pillBottom: (barBox.height + pillHeight) / 2, + firstLineTop, + contentTop: content.getBoundingClientRect().top, + overflow: viewport.scrollHeight - viewport.clientHeight, + }; + }); +} + +// At rest, the content starts below the whole grab bar, fade included, +// whatever the sheet's padding (component:BottomSheet, Content area inset). +for (const story of ['showcase', 'tall-sheet', 'text-only']) { + test(`at rest the first line starts below the grab bar: ${story}`, async ({ + page, + }) => { + await open(page, story); + const reading = await read(page); + test.info().annotations.push({ + type: 'reading', + description: JSON.stringify(reading), + }); + expect(reading.firstLineTop).not.toBeNull(); + expect(reading.firstLineTop!).toBeGreaterThanOrEqual( + reading.barTop + reading.fadeEnd, + ); + expect(reading.contentTop + 0.5).toBeGreaterThanOrEqual(reading.barTop); + }); +} + +// Sheets whose height follows their content. A detent-sized sheet (Snap +// Points With Peek) is not one of them: its height is the detent's, so +// whether its content fits depends on the machine's font metrics. +for (const story of [ + 'text-only-fitting', + 'showcase', + 'hug-height', + 'nested-popover', +]) { + test(`a sheet whose content fits does not scroll: ${story}`, async ({ + page, + }) => { + // The inset is the content's own, so a sheet whose content fits grows to + // hold it and does not scroll. + await open(page, story); + const reading = await read(page); + test.info().annotations.push({ + type: 'reading', + description: JSON.stringify(reading), + }); + expect(reading.overflow).toBeLessThanOrEqual(1); + }); +} + +test('scrolled content still passes beneath the grab bar', async ({page}) => { + // The inset scrolls away with the content, which then rides up under the + // bar instead of stopping at its bottom edge. + await open(page, 'text-only'); + await page.evaluate(() => { + const viewport = document.querySelector( + 'dialog[open] [data-scroll-axis]', + ); + if (viewport != null) { + viewport.scrollTop = 200; + } + }); + await page.waitForTimeout(100); + const reading = await read(page); + test + .info() + .annotations.push({type: 'reading', description: JSON.stringify(reading)}); + expect(reading.contentTop).toBeLessThan(reading.barTop); +}); From c65c74a9bee000f945810a4060f2f064ad827968 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 10:53:08 -0700 Subject: [PATCH 2/5] fix(BottomSheet): publish the floored inset, and Core's padding={0} sheets stop reserving the band From CodeBunny's review at a6642d3: - The block-start inset is now floored where it is published, so the Content area applies and publishes the same value (bleed children escape exactly what they sit in): the theme default's chain in Layout/container.stylex.ts, and each explicit padding step in BottomSheetPanel. - Core's own padding={0} sheets reserved the band under the grab bar themselves, so they grew a second 24px strip. The touch date, date-time and time pickers drop their 24px top padding (they look the same), and the DropdownMenu and ContextMenu sheet content drops its 16px top (their first row sits 8px lower, below the bar). - BottomSheet's doc, `padding` JSDoc and the record's compatibility line describe the floor; the changeset is a [feat] (spec:AST-017 FR7). --- .changeset/bottom-sheet-clears-grab-bar.md | 2 +- .../core/src/BottomSheet/BottomSheet.doc.mjs | 2 +- .../core/src/BottomSheet/BottomSheet.spec.md | 12 +++- packages/core/src/BottomSheet/BottomSheet.tsx | 4 +- .../src/BottomSheet/BottomSheetPanel.test.tsx | 43 +++++------- .../core/src/BottomSheet/BottomSheetPanel.tsx | 67 +++++++++++++++---- .../core/src/ContextMenu/ContextMenu.test.tsx | 10 +-- packages/core/src/ContextMenu/ContextMenu.tsx | 8 ++- .../src/DateInput/DateInputTouch.test.tsx | 7 +- .../core/src/DateInput/TouchDateField.tsx | 12 ++-- .../src/DateTimeInput/TouchDateTimeField.tsx | 7 +- .../src/DropdownMenu/DropdownMenu.test.tsx | 5 +- .../DropdownMenu/menuBottomSheet.stylex.ts | 12 ++-- packages/core/src/Layout/container.stylex.ts | 8 ++- .../core/src/TimeInput/TouchTimeField.tsx | 6 +- 15 files changed, 131 insertions(+), 74 deletions(-) diff --git a/.changeset/bottom-sheet-clears-grab-bar.md b/.changeset/bottom-sheet-clears-grab-bar.md index 9349e778d3404..9dae01b6c093d 100644 --- a/.changeset/bottom-sheet-clears-grab-bar.md +++ b/.changeset/bottom-sheet-clears-grab-bar.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. Scrolled content still passes beneath the bar. A sheet at the default padding starts its content 8 px lower; one with `padding={0}` keeps its content clear of the bar. +[feat] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. The block-start inset is never less than the bar's height (`--spacing-6`), applied and published alike; scrolled content still passes beneath the bar. A sheet at the default padding starts its content 8 px lower, and one with `padding={0}` starts it below the bar. Core's own `padding={0}` sheets (the touch date and time pickers, DropdownMenu and ContextMenu sheets) no longer reserve that band themselves, so the pickers look the same and a menu's first row sits 8 px lower. @vjeux diff --git a/packages/core/src/BottomSheet/BottomSheet.doc.mjs b/packages/core/src/BottomSheet/BottomSheet.doc.mjs index 00b9e05937f4c..ff820d9c364d7 100644 --- a/packages/core/src/BottomSheet/BottomSheet.doc.mjs +++ b/packages/core/src/BottomSheet/BottomSheet.doc.mjs @@ -146,7 +146,7 @@ export const docs = { name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: - 'Internal padding of the sheet content using the spacing scale, matching Dialog. When omitted, uses the theme default for bottom sheets (--spacing-4 unless the theme sets padding on bottom-sheet). A lone Section child escapes the padding, and bleed children such as Table and Divider compensate against it. Use 0 for edge-to-edge content that supplies its own inset.', + "Internal padding of the sheet content using the spacing scale, matching Dialog. When omitted, uses the theme default for bottom sheets (--spacing-4 unless the theme sets padding on bottom-sheet). A lone Section child escapes the padding, and bleed children such as Table and Divider compensate against it. Use 0 for edge-to-edge content that supplies its own inset. Whatever the padding, the block-start inset is never less than the grab bar's height (--spacing-6), so resting content starts below the bar.", }, { name: 'height', diff --git a/packages/core/src/BottomSheet/BottomSheet.spec.md b/packages/core/src/BottomSheet/BottomSheet.spec.md index 7a08611b3a744..9cbd95896fd78 100644 --- a/packages/core/src/BottomSheet/BottomSheet.spec.md +++ b/packages/core/src/BottomSheet/BottomSheet.spec.md @@ -60,9 +60,13 @@ gap note. ## Compatibility and migration -- Released default preserved: `yes`; with no `padding` prop and no theme - padding, the Content area insets by `--spacing-4` on every logical edge, and - `padding={0}` keeps an unpadded Content area +- Released default preserved: `yes`, except the block-start inset; with no + `padding` prop and no theme padding, the Content area insets by + `--spacing-4` on the other logical edges, and `padding={0}` keeps them + unpadded. The block-start inset is never less than the grab bar's height + (`--spacing-6`), so a default sheet's content starts 8px lower and a + `padding={0}` sheet's starts below the bar (`spec:AST-017/FR7`, a visible + layout change, tagged `[feat]`) - Compatibility class: no public prop, type, default, or export changes. Stacking, the replace handoff, and non-modal top-layer hosting are behavior of the existing component, tagged `[feat]` (`spec:AST-017/FR7`: a compatible @@ -198,6 +202,8 @@ remain documented in `BottomSheet.doc.mjs`. on `bottom-sheet`, then `--spacing-4` on every logical edge. At rest the block-start inset is never less than the grab bar's height (`--spacing-6`), so the first row starts below the bar; scrolled content still passes under it. + The Content area applies and publishes the same inset, so a child that bleeds + into it escapes exactly what it sits in. - Stack order: open order, then tree order for sheets opening in one update (FR5). A sheet that closes and reopens takes a new place on top. - Native top-layer order is derived from the stack order, not trusted: where diff --git a/packages/core/src/BottomSheet/BottomSheet.tsx b/packages/core/src/BottomSheet/BottomSheet.tsx index 29cfb4256ae23..5a1b312708b03 100644 --- a/packages/core/src/BottomSheet/BottomSheet.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.tsx @@ -197,7 +197,9 @@ interface BottomSheetSharedProps extends BaseProps { * unless the theme sets `padding` on `bottom-sheet`), matching Dialog. The * content box is a container: a Section that is its only child escapes the * padding, and bleed children such as Table and Divider compensate against - * it. Pass `0` for content that supplies its own inset. + * it. Pass `0` for content that supplies its own inset. Whatever the padding, + * the block-start inset is never less than the grab bar's height + * (`--spacing-6`), so resting content starts below the bar. */ padding?: SpacingStep; diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx index c22acf95bca07..29edcb65a24ef 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx @@ -414,14 +414,9 @@ describe('BottomSheetPanel', () => { ['block-start', 'padding-top'], ['block-end', 'padding-bottom'], ] as const; - // The applied block-start inset never drops below the grab bar's height, - // so resting content starts below the bar (component:BottomSheet, Content - // area inset); the published value stays the padding, so a child that - // bleeds into it escapes only the padding and also starts below the bar. - const appliedAt = (edge: string, published: string) => - edge === 'block-start' - ? `max(${published}, var(--spacing-6))` - : published; + // The block-start inset never drops below the grab bar's height, so + // resting content starts below the bar (component:BottomSheet, Content + // area inset); it is applied and published alike. function renderWithPadding(padding?: 0 | 2) { render( @@ -452,18 +447,13 @@ describe('BottomSheetPanel', () => { expect(published, edge).toContain( 'var(--astryx-bottom-sheet-padding,var(--spacing-4))', ); + if (edge === 'block-start') { + expect(published, edge).toMatch(/^max\(.*,var\(--spacing-6\)\)$/); + } // Applied padding reads the published value, so bleed children // subtract exactly the inset they sit in. - expect( - computed.getPropertyValue(property).replace(/\s+/g, ''), - edge, - ).toBe( - appliedAt( - edge, - edge === 'block-start' - ? 'var(--container-padding-block-start, 0px)' - : `var(--container-padding-${edge})`, - ).replace(/\s+/g, ''), + expect(computed.getPropertyValue(property), edge).toBe( + `var(--container-padding-${edge})`, ); } for (const name of [ @@ -485,19 +475,20 @@ describe('BottomSheetPanel', () => { const computed = renderWithPadding(padding); const expected = `var(--spacing-${padding})`; for (const [edge, property] of EDGES) { + const inset = + edge === 'block-start' + ? `max(${expected}, var(--spacing-6))` + : expected; expect( - computed.getPropertyValue(`--container-padding-${edge}`), + computed + .getPropertyValue(`--container-padding-${edge}`) + .replace(/\s+/g, ''), edge, - ).toBe(expected); + ).toBe(inset.replace(/\s+/g, '')); expect( computed.getPropertyValue(property).replace(/\s+/g, ''), edge, - ).toBe( - (edge === 'block-start' - ? appliedAt(edge, 'var(--container-padding-block-start, 0px)') - : expected - ).replace(/\s+/g, ''), - ); + ).toBe(inset.replace(/\s+/g, '')); } expect(computed.getPropertyValue('--layout-padding-inner-x')).toBe( expected, diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.tsx index 4c357ea64dd9c..0ff378c19abe3 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.tsx @@ -56,7 +56,6 @@ import {container} from '../Layout/container.stylex'; import type {SpacingToken} from '../Layout/container.stylex'; import { containerPaddingBlockEndVarStyles, - containerPaddingBlockStartVarStyles, containerPaddingInlineVarStyles, overlayPaddingReset, paddingStyles, @@ -118,6 +117,58 @@ function snapPointsKeyFor( return key; } +// At rest the content starts below the floating grab bar whatever the +// padding, so the first row never sits under the bar's fade +// (component:BottomSheet, Content area inset). The inset is the content box's +// own padding, applied and published alike, so it scrolls away with the +// content, which then passes under the bar. +const clearsGrabBarStyles = stylex.create({ + 0: { + '--container-padding-block-start': `max(${spacingVars['--spacing-0']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-0']}, ${HANDLE_BAR_HEIGHT})`, + }, + 0.5: { + '--container-padding-block-start': `max(${spacingVars['--spacing-0-5']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-0-5']}, ${HANDLE_BAR_HEIGHT})`, + }, + 1: { + '--container-padding-block-start': `max(${spacingVars['--spacing-1']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-1']}, ${HANDLE_BAR_HEIGHT})`, + }, + 1.5: { + '--container-padding-block-start': `max(${spacingVars['--spacing-1-5']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-1-5']}, ${HANDLE_BAR_HEIGHT})`, + }, + 2: { + '--container-padding-block-start': `max(${spacingVars['--spacing-2']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-2']}, ${HANDLE_BAR_HEIGHT})`, + }, + 3: { + '--container-padding-block-start': `max(${spacingVars['--spacing-3']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-3']}, ${HANDLE_BAR_HEIGHT})`, + }, + 4: { + '--container-padding-block-start': `max(${spacingVars['--spacing-4']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-4']}, ${HANDLE_BAR_HEIGHT})`, + }, + 5: { + '--container-padding-block-start': `max(${spacingVars['--spacing-5']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-5']}, ${HANDLE_BAR_HEIGHT})`, + }, + 6: { + '--container-padding-block-start': `max(${spacingVars['--spacing-6']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-6']}, ${HANDLE_BAR_HEIGHT})`, + }, + 8: { + '--container-padding-block-start': `max(${spacingVars['--spacing-8']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-8']}, ${HANDLE_BAR_HEIGHT})`, + }, + 10: { + '--container-padding-block-start': `max(${spacingVars['--spacing-10']}, ${HANDLE_BAR_HEIGHT})`, + paddingBlockStart: `max(${spacingVars['--spacing-10']}, ${HANDLE_BAR_HEIGHT})`, + }, +}); + const styles = stylex.create({ sheet: { pointerEvents: 'auto', @@ -263,13 +314,6 @@ const styles = stylex.create({ height: '100%', minHeight: 'min-content', }, - // At rest the content starts below the grab bar whatever the padding, so - // the first row never sits under the bar's fade (component:BottomSheet's - // Content area inset). The inset is the content box's own padding, so it - // scrolls away with the content, which then passes under the bar. - clearsGrabBar: { - paddingBlockStart: `max(var(--container-padding-block-start, 0px), ${HANDLE_BAR_HEIGHT})`, - }, tallKeyboardBody: { scrollPaddingBlockEnd: MOBILE_KEYBOARD_BOTTOM_CLEARANCE, '::after': { @@ -809,13 +853,12 @@ export function BottomSheetPanel({ !usesThemePadding && effectivePadding !== 4 && containerPaddingInlineVarStyles[effectivePadding], - !usesThemePadding && - effectivePadding !== 4 && - containerPaddingBlockStartVarStyles[effectivePadding], + // An explicit padding's block-start inset, floored at the + // grab bar like the theme default's (Layout/container.stylex.ts). + !usesThemePadding && clearsGrabBarStyles[effectivePadding], !usesThemePadding && effectivePadding !== 4 && containerPaddingBlockEndVarStyles[effectivePadding], - styles.clearsGrabBar, ), ), )}> diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index d3d00b8417338..b6428a3135627 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -327,14 +327,14 @@ describe('ContextMenu', () => { expect(element).not.toBeNull(); return element as HTMLElement; }); - // No Section wrapper: the menu element keeps the sheet's 16px top and - // 4px side inset plus its own 4px, and publishes the padding it applies. + // No Section wrapper: the menu element keeps the 4px side inset plus + // its own 4px, and its own 4px below the band the sheet keeps under its + // grab bar, and publishes the padding it applies. expect(content.parentElement?.closest('.astryx-section')).toBeNull(); expect(content).toHaveStyle({ - paddingTop: 'calc(var(--spacing-4) + var(--spacing-1))', + paddingTop: 'var(--spacing-1)', paddingInline: 'calc(var(--spacing-1) + var(--spacing-1))', - '--container-padding-block-start': - 'calc(var(--spacing-4) + var(--spacing-1))', + '--container-padding-block-start': 'var(--spacing-1)', '--container-padding-inline-start': 'calc(var(--spacing-1) + var(--spacing-1))', }); diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 322a6c7c85059..e793163f7a46d 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -178,13 +178,15 @@ const styles = stylex.create({ userSelect: 'none', }, // The menu's own inset inside the sheet content box (menuBottomSheetStyles - // .content): the 16px / 4px the sheet keeps plus the menu's 4px padding. + // .content): the 4px at the sides the content box keeps plus the menu's + // 4px padding; above the first row, the menu's 4px below the band the sheet + // keeps under its grab bar. sheetMenuInset: { - paddingBlockStart: `calc(${spacingVars['--spacing-4']} + ${spacingVars['--spacing-1']})`, + paddingBlockStart: spacingVars['--spacing-1'], paddingBlockEnd: spacingVars['--spacing-1'], paddingInline: `calc(${spacingVars['--spacing-1']} + ${spacingVars['--spacing-1']})`, // Bleeding rows read the padding this element applies. - '--container-padding-block-start': `calc(${spacingVars['--spacing-4']} + ${spacingVars['--spacing-1']})`, + '--container-padding-block-start': spacingVars['--spacing-1'], '--container-padding-block-end': spacingVars['--spacing-1'], '--container-padding-inline-start': `calc(${spacingVars['--spacing-1']} + ${spacingVars['--spacing-1']})`, '--container-padding-inline-end': `calc(${spacingVars['--spacing-1']} + ${spacingVars['--spacing-1']})`, diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index b6f139ba9ed1a..4f2ed62af3ac7 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -2578,9 +2578,10 @@ describe('DateInput — scroll CSS (definition-level)', () => { // that is what put the title and Done 4px off the day grid's line. expect(source).toContain("paddingInline: spacingVars['--spacing-4']"); expect(source).toContain("paddingBlockEnd: spacingVars['--spacing-4']"); - // The block-start is the documented exception: the grab handle floats out - // of flow, so the content wrapper owes it the height it occupies. - expect(source).toContain("paddingBlockStart: spacingVars['--spacing-6']"); + // The block-start is the documented exception: BottomSheet keeps the band + // under its grab bar (component:BottomSheet, Content area inset), so the + // content wrapper adds nothing above. + expect(source).toContain('paddingBlockStart: 0,'); const header = source.slice( source.indexOf(' header: {'), source.indexOf(' title: {'), diff --git a/packages/core/src/DateInput/TouchDateField.tsx b/packages/core/src/DateInput/TouchDateField.tsx index 372be82caf893..4c2e35ced51ae 100644 --- a/packages/core/src/DateInput/TouchDateField.tsx +++ b/packages/core/src/DateInput/TouchDateField.tsx @@ -540,12 +540,11 @@ const styles = stylex.create({ display: 'flex', }, sheetBody: { - // One inset on every edge. The block-start is the exception and has to - // be: the sheet's grab handle floats out of flow, costing no layout - // height of its own, so the content wrapper owes it the 24px it occupies - // — which reads as the same inset, because the handle sits in it. + // One inset on every edge but the top: BottomSheet keeps the band under + // its grab bar (component:BottomSheet, Content area inset), which reads + // as the same inset because the bar sits in it. paddingInline: spacingVars['--spacing-4'], - paddingBlockStart: spacingVars['--spacing-6'], + paddingBlockStart: 0, paddingBlockEnd: spacingVars['--spacing-4'], }, divider: { @@ -1327,7 +1326,8 @@ export function TouchDateField({ // The picker is a fixed height by construction, so the sheet should be // exactly as tall as it is rather than claiming a viewport budget. height="hug" - // sheetBody owns the inset, including the band under the grab handle. + // sheetBody owns the inset; the sheet keeps the band under its grab + // bar. padding={0}>
{surface} diff --git a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx index 654dcecdd2323..4962101fc198b 100644 --- a/packages/core/src/DateTimeInput/TouchDateTimeField.tsx +++ b/packages/core/src/DateTimeInput/TouchDateTimeField.tsx @@ -247,7 +247,8 @@ const styles = stylex.create({ inlineSize: '100%', minInlineSize: 0, paddingInline: spacingVars['--spacing-4'], - paddingBlockStart: spacingVars['--spacing-6'], + // The sheet keeps the band under its grab bar. + paddingBlockStart: 0, paddingBlockEnd: spacingVars['--spacing-4'], }, touchSurface: { @@ -1504,8 +1505,8 @@ export function TouchDateTimeField({ onOpenChange={handleSheetOpenChange} label={t('@astryx.dateTimeInput.dialogLabel')} height="hug" - // touchSheetBody owns the inset, including the band under the grab - // handle. + // touchSheetBody owns the inset; the sheet keeps the band under its + // grab bar. padding={0}>
{surface} diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index b154b7cfb6107..0bafb7c818e5a 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -177,11 +177,12 @@ describe('DropdownMenu', () => { const heading = screen.getByRole('heading', {name: 'Project actions'}); expect(heading).toHaveStyle({marginInlineStart: 'var(--spacing-3)'}); // The menu's content element takes the sheet's content padding itself, - // with no wrapper: 16px above the first row, 4px at the sides. + // with no wrapper: nothing above the first row but the band the sheet + // keeps under its grab bar, 4px at the sides. const content = heading.closest('.astryx-dropdown-menu'); expect(content?.parentElement?.closest('.astryx-section')).toBeNull(); expect(content).toHaveStyle({ - paddingTop: 'var(--spacing-4)', + paddingTop: '0px', paddingInline: 'var(--spacing-1)', }); // The root heading stands alone, with no row wrapper. diff --git a/packages/core/src/DropdownMenu/menuBottomSheet.stylex.ts b/packages/core/src/DropdownMenu/menuBottomSheet.stylex.ts index 1bdb8442d8061..cf1b927844812 100644 --- a/packages/core/src/DropdownMenu/menuBottomSheet.stylex.ts +++ b/packages/core/src/DropdownMenu/menuBottomSheet.stylex.ts @@ -12,17 +12,19 @@ import {spacingVars} from '../theme/tokens.stylex'; export const menuBottomSheetStyles = stylex.create({ // The menu's own content element carries the inset a wrapper used to: - // MenuBottomSheet gives the sheet no content padding, and this element - // keeps 16px above the first row and 4px at the sides. The container - // variables tell bleeding children the padding actually applied. + // MenuBottomSheet gives the sheet no content padding, so the sheet keeps + // only the band under its grab bar above the first row + // (component:BottomSheet, Content area inset), and this element keeps 4px + // at the sides. The container variables tell bleeding children the + // padding actually applied. content: { boxSizing: 'border-box', - paddingBlockStart: spacingVars['--spacing-4'], + paddingBlockStart: 0, paddingBlockEnd: 0, paddingInline: spacingVars['--spacing-1'], '--container-padding-inline-start': spacingVars['--spacing-1'], '--container-padding-inline-end': spacingVars['--spacing-1'], - '--container-padding-block-start': spacingVars['--spacing-4'], + '--container-padding-block-start': '0px', '--container-padding-block-end': '0px', }, // The heading alone, without a back button beside it, keeps the row's gap diff --git a/packages/core/src/Layout/container.stylex.ts b/packages/core/src/Layout/container.stylex.ts index cd7c57027a7c4..a005dd7802a25 100644 --- a/packages/core/src/Layout/container.stylex.ts +++ b/packages/core/src/Layout/container.stylex.ts @@ -102,6 +102,7 @@ const baseStyles = stylex.create({ * CSS custom property cascade — no layer competition. */ const SP4 = spacingVars['--spacing-4']; +const SP6 = spacingVars['--spacing-6']; // Card padding chains: --astryx-* then the next specificity level, terminating // at --spacing-4. Built as chained const strings (no function calls) so StyleX @@ -149,6 +150,11 @@ const bottomSheetInlineStart = `var(--astryx-bottom-sheet-padding-inline-start, const bottomSheetInlineEnd = `var(--astryx-bottom-sheet-padding-inline-end, ${bottomSheetInline})`; const bottomSheetBlockStart = `var(--astryx-bottom-sheet-padding-block-start, ${bottomSheetShorthand})`; const bottomSheetBlockEnd = `var(--astryx-bottom-sheet-padding-block-end, ${bottomSheetShorthand})`; +// A bottom sheet's resting content starts below its floating grab bar +// (--spacing-6 tall), whatever the padding: the block-start inset it applies +// and publishes never drops below the bar (component:BottomSheet, Content +// area inset). The layout padding variables keep the plain padding. +const bottomSheetBlockStartClear = `max(${bottomSheetBlockStart}, ${SP6})`; // Drawer padding chains: --astryx-* then the next specificity level, // terminating at --spacing-4 like Dialog. Built as chained const strings (no @@ -250,7 +256,7 @@ const bottomSheetDefaultPaddingStyles = stylex.create({ '--container-padding-inline-end': bottomSheetInlineEnd, }, containerPaddingBlockStart: { - '--container-padding-block-start': bottomSheetBlockStart, + '--container-padding-block-start': bottomSheetBlockStartClear, }, containerPaddingBlockEnd: { '--container-padding-block-end': bottomSheetBlockEnd, diff --git a/packages/core/src/TimeInput/TouchTimeField.tsx b/packages/core/src/TimeInput/TouchTimeField.tsx index 6c9e986d2349d..afb1848bbc928 100644 --- a/packages/core/src/TimeInput/TouchTimeField.tsx +++ b/packages/core/src/TimeInput/TouchTimeField.tsx @@ -128,7 +128,8 @@ const styles = stylex.create({ minInlineSize: 0, gap: spacingVars['--spacing-2'], paddingInline: spacingVars['--spacing-4'], - paddingBlockStart: spacingVars['--spacing-6'], + // The sheet keeps the band under its grab bar. + paddingBlockStart: 0, paddingBlockEnd: spacingVars['--spacing-4'], }, wheels: { @@ -540,7 +541,8 @@ export function TouchTimeField({ onOpenChange={setIsSheetOpen} label={label} height="hug" - // sheetBody owns the inset, including the band under the grab handle. + // sheetBody owns the inset; the sheet keeps the band under its grab + // bar. padding={0}>
From 812b0644a9ef7df6045d927456afe5da8c7c4c1a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 15:24:06 -0700 Subject: [PATCH 3/5] fix(BottomSheet): the band under the grab bar is not padding a lone Section escapes From CodeBunny's review at c65c74a: publishing the floored inset let a lone Section (Showcase, Tall Sheet, Hug Height, Text Only) escape the whole band, so its first line still started 12-17px under the bar. The band under the bar is no longer container padding. The Content area publishes only the block-start padding past it (`max(padding - --spacing-6, 0)`) and applies the band on top (`calc(published + --spacing-6)`), so its applied inset is still max(padding, the bar) and what a lone Section or Layout escapes ends at the band: a lone Section's first line now starts 37px below the bar's top (13px before). Every other edge applies and publishes the same inset. - Lab's MobileTokenizer and MobilePowerSearch sheets (`padding={0}`) no longer double the band: the tokenizer's body keeps its 32px start, and the search sheets' sticky header sits over the band at rest, clearing the bar only once stuck. - component:BottomSheet's inset and compatibility lines, architecture:container-padding, Popover's DEC-8 wording, BottomSheet's doc, the Container Padding story and comments describe the band. - The Chromium spec's at-rest check now includes Hug Height. --- .changeset/bottom-sheet-clears-grab-bar.md | 2 +- .../storybook/stories/BottomSheet.stories.tsx | 3 +- docs/architecture/container-padding.md | 7 ++- .../core/src/BottomSheet/BottomSheet.doc.mjs | 2 +- .../core/src/BottomSheet/BottomSheet.spec.md | 13 ++-- .../src/BottomSheet/BottomSheetPanel.test.tsx | 24 ++++++-- .../core/src/BottomSheet/BottomSheetPanel.tsx | 61 +++++++++---------- .../BottomSheetGrabBar.a11y.chromium.spec.ts | 4 +- packages/core/src/Layout/container.stylex.ts | 10 +-- packages/core/src/Popover/Popover.spec.md | 3 +- .../MobilePowerSearch/MobilePowerSearch.tsx | 5 +- .../src/MobileTokenizer/MobileTokenizer.tsx | 4 +- 12 files changed, 84 insertions(+), 54 deletions(-) diff --git a/.changeset/bottom-sheet-clears-grab-bar.md b/.changeset/bottom-sheet-clears-grab-bar.md index 9dae01b6c093d..6d1de4c523b6c 100644 --- a/.changeset/bottom-sheet-clears-grab-bar.md +++ b/.changeset/bottom-sheet-clears-grab-bar.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. The block-start inset is never less than the bar's height (`--spacing-6`), applied and published alike; scrolled content still passes beneath the bar. A sheet at the default padding starts its content 8 px lower, and one with `padding={0}` starts it below the bar. Core's own `padding={0}` sheets (the touch date and time pickers, DropdownMenu and ContextMenu sheets) no longer reserve that band themselves, so the pickers look the same and a menu's first row sits 8 px lower. +[feat] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. The block-start inset is never less than the bar's height (`--spacing-6`): the band under the bar is not padding a child escapes, so a lone `Section` starts below the bar too; scrolled content still passes beneath it. A sheet at the default padding starts its content 8 px lower, and one with `padding={0}` starts it below the bar. Core's own `padding={0}` sheets (the touch date and time pickers, DropdownMenu and ContextMenu sheets) no longer reserve that band themselves, so the pickers look the same and a menu's first row sits 8 px lower. @vjeux diff --git a/apps/storybook/stories/BottomSheet.stories.tsx b/apps/storybook/stories/BottomSheet.stories.tsx index d0d29ae6495df..c9e87cd8345e4 100644 --- a/apps/storybook/stories/BottomSheet.stories.tsx +++ b/apps/storybook/stories/BottomSheet.stories.tsx @@ -127,7 +127,8 @@ export const TextOnlyFitting: Story = { * Content placed directly in a sheet. The sheet pads it like Dialog, by * default or at the `padding` step given here, and a full-bleed Divider * reaches the sheet's edges because the content box publishes its inset. - * `padding={0}` restores the unpadded content box. + * `padding={0}` removes the padding; the content still starts below the grab + * bar's band. */ export const ContainerPadding: Story = { args: { diff --git a/docs/architecture/container-padding.md b/docs/architecture/container-padding.md index bf99eacd455ad..760c2eff5cf03 100644 --- a/docs/architecture/container-padding.md +++ b/docs/architecture/container-padding.md @@ -120,7 +120,12 @@ pads caller content and publishes the applied inset. The inset resolves from the `padding` prop on the public spacing scale, then the theme's `padding` properties on the `bottom-sheet` component, then `--spacing-4` on every logical edge, matching Dialog. This prop and theme property are BottomSheet's projection of -container lowering. +container lowering. At the block start a sheet keeps a band under its floating +grab bar (`--spacing-6`) that is not container padding: it publishes only the +padding past that band and applies the band on top, so its applied block-start +inset is `max(padding, --spacing-6)` and a lone Section or Layout that escapes +the published padding still starts below the bar (`component:BottomSheet`, +Content area inset). Edge compensation is a two-sided geometry contract, not a token contract: diff --git a/packages/core/src/BottomSheet/BottomSheet.doc.mjs b/packages/core/src/BottomSheet/BottomSheet.doc.mjs index ff820d9c364d7..651d878165158 100644 --- a/packages/core/src/BottomSheet/BottomSheet.doc.mjs +++ b/packages/core/src/BottomSheet/BottomSheet.doc.mjs @@ -95,7 +95,7 @@ export const docs = { derived: [{property: 'padding', expand: 'container'}], }, description: - "A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. Like Dialog, it is a padded container: content starts at the theme's bottom-sheet padding (--spacing-4 by default), and a lone Section child or bleed children such as Table and Divider align against it. A sheet always owns a native ; inside BottomSheetSwitcher the switcher presents and closes that dialog as the flow moves between steps. In both modes, ref and shared DOM props target the visual panel
.", + "A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. Like Dialog, it is a padded container: content starts at the theme's bottom-sheet padding (--spacing-4 by default), and a lone Section child or bleed children such as Table and Divider align against it. At the top the content always starts below the grab bar's 24px band, which is not padding a child escapes, so the first row never sits under the bar; scrolled content passes beneath it. A sheet always owns a native ; inside BottomSheetSwitcher the switcher presents and closes that dialog as the flow moves between steps. In both modes, ref and shared DOM props target the visual panel
.", props: [ { name: 'isOpen', diff --git a/packages/core/src/BottomSheet/BottomSheet.spec.md b/packages/core/src/BottomSheet/BottomSheet.spec.md index 9cbd95896fd78..11d66a77e277b 100644 --- a/packages/core/src/BottomSheet/BottomSheet.spec.md +++ b/packages/core/src/BottomSheet/BottomSheet.spec.md @@ -64,9 +64,9 @@ gap note. `padding` prop and no theme padding, the Content area insets by `--spacing-4` on the other logical edges, and `padding={0}` keeps them unpadded. The block-start inset is never less than the grab bar's height - (`--spacing-6`), so a default sheet's content starts 8px lower and a - `padding={0}` sheet's starts below the bar (`spec:AST-017/FR7`, a visible - layout change, tagged `[feat]`) + (`--spacing-6`), so a default sheet's content starts 8px lower, a + `padding={0}` sheet's and a lone Section's start below the bar + (`spec:AST-017/FR7`, a visible layout change, tagged `[feat]`) - Compatibility class: no public prop, type, default, or export changes. Stacking, the replace handoff, and non-modal top-layer hosting are behavior of the existing component, tagged `[feat]` (`spec:AST-017/FR7`: a compatible @@ -202,8 +202,11 @@ remain documented in `BottomSheet.doc.mjs`. on `bottom-sheet`, then `--spacing-4` on every logical edge. At rest the block-start inset is never less than the grab bar's height (`--spacing-6`), so the first row starts below the bar; scrolled content still passes under it. - The Content area applies and publishes the same inset, so a child that bleeds - into it escapes exactly what it sits in. + That band under the bar is not container padding: the Content area publishes + only the block-start padding past it (`max(padding - --spacing-6, 0)`) and + applies the band on top, so a lone Section or a Layout that escapes its + padding still starts below the bar. Every other edge applies and publishes + the same inset. - Stack order: open order, then tree order for sheets opening in one update (FR5). A sheet that closes and reopens takes a new place on top. - Native top-layer order is derived from the stack order, not trusted: where diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx index 29edcb65a24ef..81e98dd8fc453 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx @@ -435,6 +435,13 @@ describe('BottomSheetPanel', () => { return getComputedStyle(screen.getByTestId('sheet-child').parentElement!); } + // The band under the grab bar is applied on top of the published + // block-start, which is only the padding past the band: a lone Section + // escapes that and still starts below the bar (component:BottomSheet, + // Content area inset). + const BAND = + 'calc(var(--container-padding-block-start,0px)+var(--spacing-6))'; + it("pads by Dialog's default chain and publishes the inset it applies", () => { const computed = renderWithPadding(); for (const [edge, property] of EDGES) { @@ -448,7 +455,14 @@ describe('BottomSheetPanel', () => { 'var(--astryx-bottom-sheet-padding,var(--spacing-4))', ); if (edge === 'block-start') { - expect(published, edge).toMatch(/^max\(.*,var\(--spacing-6\)\)$/); + expect(published, edge).toMatch( + /^max\(calc\(.*-var\(--spacing-6\)\),0px\)$/, + ); + expect( + computed.getPropertyValue(property).replace(/\s+/g, ''), + edge, + ).toBe(BAND); + continue; } // Applied padding reads the published value, so bleed children // subtract exactly the inset they sit in. @@ -475,20 +489,20 @@ describe('BottomSheetPanel', () => { const computed = renderWithPadding(padding); const expected = `var(--spacing-${padding})`; for (const [edge, property] of EDGES) { - const inset = + const published = edge === 'block-start' - ? `max(${expected}, var(--spacing-6))` + ? `max(calc(${expected} - var(--spacing-6)), 0px)` : expected; expect( computed .getPropertyValue(`--container-padding-${edge}`) .replace(/\s+/g, ''), edge, - ).toBe(inset.replace(/\s+/g, '')); + ).toBe(published.replace(/\s+/g, '')); expect( computed.getPropertyValue(property).replace(/\s+/g, ''), edge, - ).toBe(inset.replace(/\s+/g, '')); + ).toBe(edge === 'block-start' ? BAND : expected.replace(/\s+/g, '')); } expect(computed.getPropertyValue('--layout-padding-inner-x')).toBe( expected, diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.tsx index 0ff378c19abe3..121053e423bed 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.tsx @@ -83,10 +83,10 @@ export type {BottomSheetSnapPoint}; const OVERSCROLL_PADDING = 48; const MOBILE_KEYBOARD_BOTTOM_CLEARANCE = 48; const TRANSITION_BACKSTOP_BUFFER_MS = 50; -// The floating handle bar's height. The bar is out of flow, so this is not -// layout space the content pays for -- the pill (4px, centered) lands 10-14px -// from the sheet's top edge, inside the space a content wrapper's own top -// padding already provides. +// The floating handle bar's height. The bar is out of flow; at rest the +// content box keeps a band this tall under it (`clearsGrabBar`), so the pill +// (4px, centered, 10-14px from the sheet's top edge) never sits on the first +// row, and scrolled content rides up beneath it. const HANDLE_BAR_HEIGHT = spacingVars['--spacing-6']; // Measure the same viewport the height budgets are written against. Those are @@ -119,53 +119,44 @@ function snapPointsKeyFor( // At rest the content starts below the floating grab bar whatever the // padding, so the first row never sits under the bar's fade -// (component:BottomSheet, Content area inset). The inset is the content box's -// own padding, applied and published alike, so it scrolls away with the +// (component:BottomSheet, Content area inset). The band under the bar is not +// container padding: an explicit padding publishes only what reaches past +// the band, which a lone Section escapes, and `clearsGrabBar` applies the band +// on top. It is the content box's own padding, so it scrolls away with the // content, which then passes under the bar. -const clearsGrabBarStyles = stylex.create({ +const pastGrabBarStyles = stylex.create({ 0: { - '--container-padding-block-start': `max(${spacingVars['--spacing-0']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-0']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-0']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 0.5: { - '--container-padding-block-start': `max(${spacingVars['--spacing-0-5']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-0-5']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-0-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 1: { - '--container-padding-block-start': `max(${spacingVars['--spacing-1']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-1']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-1']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 1.5: { - '--container-padding-block-start': `max(${spacingVars['--spacing-1-5']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-1-5']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-1-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 2: { - '--container-padding-block-start': `max(${spacingVars['--spacing-2']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-2']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-2']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 3: { - '--container-padding-block-start': `max(${spacingVars['--spacing-3']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-3']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-3']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 4: { - '--container-padding-block-start': `max(${spacingVars['--spacing-4']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-4']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-4']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 5: { - '--container-padding-block-start': `max(${spacingVars['--spacing-5']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-5']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 6: { - '--container-padding-block-start': `max(${spacingVars['--spacing-6']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-6']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-6']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 8: { - '--container-padding-block-start': `max(${spacingVars['--spacing-8']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-8']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-8']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, 10: { - '--container-padding-block-start': `max(${spacingVars['--spacing-10']}, ${HANDLE_BAR_HEIGHT})`, - paddingBlockStart: `max(${spacingVars['--spacing-10']}, ${HANDLE_BAR_HEIGHT})`, + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-10']} - ${HANDLE_BAR_HEIGHT}), 0px)`, }, }); @@ -303,6 +294,9 @@ const styles = stylex.create({ // resting content clear of the bar instead (`clearsGrabBar`). paddingBlockEnd: 0, }, + clearsGrabBar: { + paddingBlockStart: `calc(var(--container-padding-block-start, 0px) + ${HANDLE_BAR_HEIGHT})`, + }, content: { // Preserve the body's block formatting (including margin/float isolation) // and its definite percentage-height basis. min-content lets this real @@ -853,12 +847,15 @@ export function BottomSheetPanel({ !usesThemePadding && effectivePadding !== 4 && containerPaddingInlineVarStyles[effectivePadding], - // An explicit padding's block-start inset, floored at the - // grab bar like the theme default's (Layout/container.stylex.ts). - !usesThemePadding && clearsGrabBarStyles[effectivePadding], + // An explicit padding publishes what reaches past the grab + // bar's band, like the theme default (Layout/container.stylex.ts). + !usesThemePadding && pastGrabBarStyles[effectivePadding], !usesThemePadding && effectivePadding !== 4 && containerPaddingBlockEndVarStyles[effectivePadding], + // The band under the grab bar, on top of the published + // padding past it: max(padding, the bar) in all. + styles.clearsGrabBar, ), ), )}> diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts index 43836a01ee8d4..a54ca28ef5ca4 100644 --- a/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts @@ -150,7 +150,9 @@ async function read(page: Page): Promise { // At rest, the content starts below the whole grab bar, fade included, // whatever the sheet's padding (component:BottomSheet, Content area inset). -for (const story of ['showcase', 'tall-sheet', 'text-only']) { +// Showcase, Tall Sheet, Hug Height and Text Only hold a lone Section, which +// escapes the sheet's padding but not the band under the bar. +for (const story of ['showcase', 'tall-sheet', 'hug-height', 'text-only']) { test(`at rest the first line starts below the grab bar: ${story}`, async ({ page, }) => { diff --git a/packages/core/src/Layout/container.stylex.ts b/packages/core/src/Layout/container.stylex.ts index a005dd7802a25..6b60582883d99 100644 --- a/packages/core/src/Layout/container.stylex.ts +++ b/packages/core/src/Layout/container.stylex.ts @@ -151,10 +151,12 @@ const bottomSheetInlineEnd = `var(--astryx-bottom-sheet-padding-inline-end, ${bo const bottomSheetBlockStart = `var(--astryx-bottom-sheet-padding-block-start, ${bottomSheetShorthand})`; const bottomSheetBlockEnd = `var(--astryx-bottom-sheet-padding-block-end, ${bottomSheetShorthand})`; // A bottom sheet's resting content starts below its floating grab bar -// (--spacing-6 tall), whatever the padding: the block-start inset it applies -// and publishes never drops below the bar (component:BottomSheet, Content -// area inset). The layout padding variables keep the plain padding. -const bottomSheetBlockStartClear = `max(${bottomSheetBlockStart}, ${SP6})`; +// (--spacing-6 tall), whatever the padding (component:BottomSheet, Content +// area inset). The band under the bar is not container padding: the sheet +// publishes only the padding past it, which a lone Section escapes, and +// applies the band on top (BottomSheetPanel). The layout padding variables +// keep the plain padding. +const bottomSheetBlockStartClear = `max(calc(${bottomSheetBlockStart} - ${SP6}), 0px)`; // Drawer padding chains: --astryx-* then the next specificity level, // terminating at --spacing-4 like Dialog. Built as chained const strings (no diff --git a/packages/core/src/Popover/Popover.spec.md b/packages/core/src/Popover/Popover.spec.md index 234ec026bc198..8f7bdf57e8ffe 100644 --- a/packages/core/src/Popover/Popover.spec.md +++ b/packages/core/src/Popover/Popover.spec.md @@ -438,7 +438,8 @@ light-dismisses: a tap outside it or a swipe down closes it, whatever `hasLightDismiss` says, so a finger always has a way out. Its fallback close stays hidden, as in an anchored popover. Everything else follows Popover's existing props. The sheet of DEC-6 is a modal BottomSheet at the sheet's tall -height and inset by Popover's `padding`; `xstyle`, `className` and `style` +height and inset by Popover's `padding`, below the grab bar's band at the top +(`component:BottomSheet`, Content area inset); `xstyle`, `className` and `style` style its panel, and `placement`, `alignment` and `width` do not apply. The surface is chosen when the popover opens and stays until it closes, and the trigger's expanded state and controlled element follow whichever surface is diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx index 50aefcd2a4e3f..59ca7b9abb8a6 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx @@ -320,7 +320,10 @@ const styles = stylex.create({ display: 'flex', flexDirection: 'column', gap: spacingVars['--spacing-3'], - // Clears the sheet's floating grab handle. + // Clears the sheet's floating grab handle while it sticks to the top of a + // scrolled sheet. At rest it sits over the band the sheet keeps under + // the bar (component:BottomSheet), so the band is not doubled. + marginBlockStart: `calc(-1 * ${spacingVars['--spacing-6']})`, paddingBlockStart: spacingVars['--spacing-6'], paddingBlockEnd: spacingVars['--spacing-3'], paddingInline: spacingVars['--spacing-4'], diff --git a/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx b/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx index 5b530352fed2d..c1f1ec4a2721c 100644 --- a/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx +++ b/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx @@ -157,7 +157,9 @@ const styles = stylex.create({ height: '100%', minHeight: 0, paddingInline: spacingVars['--spacing-3'], - paddingBlockStart: spacingVars['--spacing-8'], + // Below the band the sheet keeps under its grab bar (--spacing-6), so the + // search field still starts 32px down. + paddingBlockStart: spacingVars['--spacing-2'], paddingBlockEnd: spacingVars['--spacing-3'], }, // The manage sheet intentionally has no dedicated close button; the sheet From 1c54a3a66f621fb7f688a3e7f720d51c65bcf10e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 16:23:27 -0700 Subject: [PATCH 4/5] fix(BottomSheet): CLI page templates stop doubling the grab-bar band; FR4 states the band exception From CodeBunny's review at 812b064: - The CLI's table-filter and table-inbox page templates reserved their own 24px band inside their sheets (`SHEET_TOP_INSET`, the composer's `paddingBlockStart={3}`); the sheet keeps that band now, so they drop it and table-filter's sheets open on the same line as before. - component:BottomSheet FR4 and its verification row state the block-start exception: the Content area publishes only the padding past the band, so a child escapes the padding but never the band. - The changeset says a sheet whose only child is a Section moves that Section 24px and grows by the same amount, and names the CLI update. --- .changeset/bottom-sheet-clears-grab-bar.md | 3 +- .../templates/pages/table-filter/page.tsx | 37 +++++++------------ .../templates/pages/table-inbox/page.tsx | 9 ++--- .../core/src/BottomSheet/BottomSheet.spec.md | 4 +- 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/.changeset/bottom-sheet-clears-grab-bar.md b/.changeset/bottom-sheet-clears-grab-bar.md index 6d1de4c523b6c..6b3369ddcb41f 100644 --- a/.changeset/bottom-sheet-clears-grab-bar.md +++ b/.changeset/bottom-sheet-clears-grab-bar.md @@ -1,7 +1,8 @@ --- '@astryxdesign/core': patch +'@astryxdesign/cli': patch --- -[feat] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. The block-start inset is never less than the bar's height (`--spacing-6`): the band under the bar is not padding a child escapes, so a lone `Section` starts below the bar too; scrolled content still passes beneath it. A sheet at the default padding starts its content 8 px lower, and one with `padding={0}` starts it below the bar. Core's own `padding={0}` sheets (the touch date and time pickers, DropdownMenu and ContextMenu sheets) no longer reserve that band themselves, so the pickers look the same and a menu's first row sits 8 px lower. +[feat] BottomSheet: at rest, a sheet's content starts below the grab bar whatever its `padding`, so the first line is no longer drawn through the bar's fade. The block-start inset is never less than the bar's height (`--spacing-6`): the band under the bar is not padding a child escapes, so a lone `Section` starts below the bar too; scrolled content still passes beneath it. A sheet at the default padding starts its content 8 px lower, one with `padding={0}` starts it below the bar, and one whose only child is a `Section` starts that Section 24 px lower and grows by the same amount. Core's own `padding={0}` sheets (the touch date and time pickers, DropdownMenu and ContextMenu sheets) no longer reserve that band themselves, so the pickers look the same and a menu's first row sits 8 px lower. The CLI's `table-filter` and `table-inbox` page templates drop their own band too, so it is not doubled. @vjeux diff --git a/packages/cli/assets/templates/pages/table-filter/page.tsx b/packages/cli/assets/templates/pages/table-filter/page.tsx index 66d1485550025..b067e84abd7ec 100644 --- a/packages/cli/assets/templates/pages/table-filter/page.tsx +++ b/packages/cli/assets/templates/pages/table-filter/page.tsx @@ -1129,18 +1129,6 @@ const INITIAL_SAVED_VIEWS: SavedView[] = [ const COMPACT_SURFACE_QUERY = '(max-width: 768px), (max-width: 1024px) and (pointer: coarse) and (hover: none)'; -/** - * Top padding for whatever opens a BottomSheet, on the spacing scale. - * - * The panel's grab pill floats over the content instead of reserving layout - * space for itself: it is 4px centered in a 24px band, so it covers the top - * 10-14px of the sheet. Content that starts at the usual 16px gutter clears it - * by 2px, which does not read as a gap — it reads as the sheet being cut off. - * Opening at the full 24px band puts real space under the pill, and every - * sheet here uses this one value so they all start on the same line. - */ -const SHEET_TOP_INSET = 6; - /** * Width the filter row holds back, in px, for the readout that trails the * clauses — the density toggle, the result count and Clear all. @@ -2052,7 +2040,8 @@ function FilterOverflow({ + // In a sheet the sheet's band under its grab bar is the top inset. + paddingBlockStart={0}> {items.map(({child, index}) => ( {child} @@ -2145,7 +2134,8 @@ function AdaptiveSavedViewForm({
+ // The sheet's band under its grab bar is the top inset. + paddingBlockStart={0}> @@ -4090,11 +4080,11 @@ export default function TableFilterTemplate() { label="View options" height="tall" // The tab strip compensates against a container inset, so the sheet - // publishes none and the wrapper below owns the top band. + // publishes none; its band under the grab bar is the only top inset, + // so the strip's own box starts at the tab row and its scroll arrow + // lands on the tabs rather than above them. padding={0}> - {/* The inset is the wrapper's, so the strip's own box starts at the tab - row and its scroll arrow lands on the tabs rather than above them. */} - + {/* The heading the popover puts above the pane is dropped here: it restates the selected tab word for word, and the strip already marks which one that is. On a phone that is a line of vertical @@ -4148,14 +4138,15 @@ export default function TableFilterTemplate() { const detailBody = activeJob == null ? null : ( - {/* The only part of the body that knows which host it is in: the sheet - needs the pill cleared, the docked panel has no pill and would just - sit 8px lower than the panels beside it. The two never coexist — - Layout drops the panel entirely on a compact surface. */} + {/* The only part of the body that knows which host it is in: in the + sheet, the sheet's band under its grab bar is the top inset; the + docked panel has no pill and keeps the 16px gutter of the panels + beside it. The two never coexist — Layout drops the panel entirely + on a compact surface. */}
+ paddingBlockStart={isCompactSurface ? 0 : 4}> diff --git a/packages/cli/assets/templates/pages/table-inbox/page.tsx b/packages/cli/assets/templates/pages/table-inbox/page.tsx index dce203341dec9..1e7c20a780919 100644 --- a/packages/cli/assets/templates/pages/table-inbox/page.tsx +++ b/packages/cli/assets/templates/pages/table-inbox/page.tsx @@ -1778,12 +1778,9 @@ function ComposeWindow({ hasScrim={false} // The Layout below owns the inset; the sheet adds none of its own. padding={0}> - {/* The grab handle floats over the top edge, out of flow, about 12px - down. The Layout's own padding is not quite enough to clear it — the - heading came up level with the pill, four pixels under it — so the - sheet buys the handle its own band of space before the content - starts. */} - + {/* The sheet keeps a band under its grab handle at rest, so the Layout + starts below the pill without a band of its own. */} + Date: Mon, 26 Oct 2026 17:02:38 -0700 Subject: [PATCH 5/5] docs(container-padding): the host-side rule and INV2's BottomSheet row state the block-start band exception From CodeBunny's review at 1c54a3a: architecture:container-padding's host-side rule and its INV2 verification row for BottomSheetPanel still required the published block-start inset to equal the applied one; both now name the exception the Content area inset decides (the block start publishes only the padding past the grab bar's band). --- docs/architecture/container-padding.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/architecture/container-padding.md b/docs/architecture/container-padding.md index 760c2eff5cf03..3111d09ee6603 100644 --- a/docs/architecture/container-padding.md +++ b/docs/architecture/container-padding.md @@ -132,7 +132,9 @@ Edge compensation is a two-sided geometry contract, not a token contract: 1. **Host side.** An eligible container or region state publishes the padding currently available at each logical edge through the existing private `--container-padding-*` variables. Those values must match that state's - applied padding and follow nesting and overlay reset; missing values mean zero. + applied padding, except BottomSheet's block start, which publishes only the + padding past its grab bar's band (above), and follow nesting and overlay + reset; missing values mean zero. A state with a known publication mismatch is ineligible. Publication does not itself opt a descendant into compensation. 2. **Component side.** A participating component owns the inset retained around @@ -368,16 +370,16 @@ evidence, not part of this documentation stack. ## Verification -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV2, INV5 | `Section.test.tsx` per-edge and nested-propagation tests | A per-edge prop leaves a stale geometry variable, or nested Sections lose the shipped propagation order | -| INV2, INV3, INV8 | `ScrollableArea.test.tsx` padding and full-bleed tests | The content box publishes inset different from its applied padding, or the viewport escapes inherited padding without explicit full bleed | -| INV4 | Layout source review plus `Layout.test.tsx` and `LayoutSlots.test.tsx` | Slot presence stops selecting the shipped applied outer/inner edge styles; exact republished geometry remains limited by the named conformance gap | -| INV2 | `BottomSheetPanel.test.tsx` padding tests | The Content area publishes an inset that differs from its applied padding, or the `padding` prop or theme `padding` does not reach it | -| INV6 | `overlayPaddingReset.test.tsx` | An overlay inherits page inset, loses its theme's Section padding, or lets ancestor Section propagation cross the boundary | -| INV3, INV7 | `Layout/__tests__/edgeCompensation.test.tsx` plus component tests | An unmarked child is compensated, or marked edge content loses direct-child discoverability | -| INV9, INV10 | `List.test.tsx` plus real-browser geometry evidence | The header moves; a row reads the wrong logical edge, diverges from themed/density padding, applies the wrong bounded movement, or loses paint inset | -| INV10, INV11 | `TabList.test.tsx` plus real-browser overflow, divider, and indicator evidence | Full host-inset consumption, bounded content movement, divider span, interaction, focus, overflow, scroll controls, or indicator geometry regresses | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV2, INV5 | `Section.test.tsx` per-edge and nested-propagation tests | A per-edge prop leaves a stale geometry variable, or nested Sections lose the shipped propagation order | +| INV2, INV3, INV8 | `ScrollableArea.test.tsx` padding and full-bleed tests | The content box publishes inset different from its applied padding, or the viewport escapes inherited padding without explicit full bleed | +| INV4 | Layout source review plus `Layout.test.tsx` and `LayoutSlots.test.tsx` | Slot presence stops selecting the shipped applied outer/inner edge styles; exact republished geometry remains limited by the named conformance gap | +| INV2 | `BottomSheetPanel.test.tsx` padding tests | The Content area publishes an inset that differs from its applied padding on any edge but the block start, its block start publishes more than the padding past the grab bar's band, or the `padding` prop or theme `padding` does not reach it | +| INV6 | `overlayPaddingReset.test.tsx` | An overlay inherits page inset, loses its theme's Section padding, or lets ancestor Section propagation cross the boundary | +| INV3, INV7 | `Layout/__tests__/edgeCompensation.test.tsx` plus component tests | An unmarked child is compensated, or marked edge content loses direct-child discoverability | +| INV9, INV10 | `List.test.tsx` plus real-browser geometry evidence | The header moves; a row reads the wrong logical edge, diverges from themed/density padding, applies the wrong bounded movement, or loses paint inset | +| INV10, INV11 | `TabList.test.tsx` plus real-browser overflow, divider, and indicator evidence | Full host-inset consumption, bounded content movement, divider span, interaction, focus, overflow, scroll controls, or indicator geometry regresses | The List evidence must cover each `edge` value in its neutral root and in each bounded host List admits (Card, Section, Dialog, BottomSheet), with the optional