diff --git a/.changeset/bottom-sheet-clears-grab-bar.md b/.changeset/bottom-sheet-clears-grab-bar.md new file mode 100644 index 0000000000000..6b3369ddcb41f --- /dev/null +++ b/.changeset/bottom-sheet-clears-grab-bar.md @@ -0,0 +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, 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/apps/storybook/stories/BottomSheet.stories.tsx b/apps/storybook/stories/BottomSheet.stories.tsx index 49d03d93b3695..0fca1bd42461d 100644 --- a/apps/storybook/stories/BottomSheet.stories.tsx +++ b/apps/storybook/stories/BottomSheet.stories.tsx @@ -129,7 +129,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..3111d09ee6603 100644 --- a/docs/architecture/container-padding.md +++ b/docs/architecture/container-padding.md @@ -120,14 +120,21 @@ 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: 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 @@ -363,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 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. */} + ; inside the deprecated 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
. Sheets stack: a sheet opened while another is open sits on top of it, the covered sheet stays mounted and recedes, and one scrim covers the page. A sheet that closes as another opens in the same update is replaced by it, with the step-to-step handoff a multi-step flow needs.", + "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 the deprecated 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
. Sheets stack: a sheet opened while another is open sits on top of it, the covered sheet stays mounted and recedes, and one scrim covers the page. A sheet that closes as another opens in the same update is replaced by it, with the step-to-step handoff a multi-step flow needs.", props: [ { name: 'isOpen', @@ -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 382c1a90c74e6..f80a3dd5605cf 100644 --- a/packages/core/src/BottomSheet/BottomSheet.spec.md +++ b/packages/core/src/BottomSheet/BottomSheet.spec.md @@ -63,9 +63,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, 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 @@ -150,7 +154,7 @@ remain documented in `BottomSheet.doc.mjs`. | FR1 | The current presented sheet contains one Sheet panel, one scrolling Content area, and one decorative Handle; a Scrim is present only in scrim-backed presentation. | Current source, docs, and tests | | FR2 | The Sheet panel carries `bottom-sheet`; Content area, Handle, and Scrim carry no BottomSheet public target. | Current source, docs, and tests | | FR3 | Content renders in one real observed content box. Effective overflow, containment, and keyboard access come from `useScrollableArea`; the named overflowing body delegates forward Tab entry to a safe first sequential native link/button. | `spec:AST-025`; tests | -| FR4 | The Content area pads caller content by the sheet's container inset and publishes the applied inset to descendants as a container publisher. | `architecture:container-padding` | +| FR4 | `BottomSheetPanel.test.tsx` container padding tests; `derivedVarRegistry.test.ts` | default, `padding` prop, theme `padding`, lone padded Section child | The published inset differs from the applied padding on an edge other than the block start, the block start publishes more than the padding past the grab bar's band, or a lone padded Section escapes into the band. | `audit:BottomSheet/anatomy` | | FR5 | Modal sheets presented in one document form one stack ordered by open order: the sheet opened most recently is on top; sheets opening in one update are ordered by tree order (a sheet before the sheets rendered in its content, siblings in source order), and the first painted frame already shows that order. Only sheets of one document stack. | DEC-2 | | FR6 | A covered modal sheet stays mounted with its state, recedes behind the top sheet, and is blocked: the browser's modal rules block it, and when it contains no presented sheet it is also `inert` and `aria-hidden`. Exactly one backdrop is visible for a stack — the bottom open sheet's — and a change of scrim owner lands with no transition, so the page dim is constant through a push, a pop, and a handoff. | DEC-2 | | FR7 | Escape, a scrim tap, a swipe, and a platform close request reach only the top sheet of a stack, through that sheet's own `onOpenChange` and `purpose`; a covered sheet, and a replaced sheet during its handoff, report themselves absent to the shared `family:overlay-dismissal` stack while that lasts, so the order among sheets is the sheet stack's and the shared stack stays one registry. | DEC-2; `family:overlay-dismissal/FR2`, `/FR3` | @@ -198,7 +202,14 @@ 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. + 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 @@ -373,7 +384,7 @@ a decision that they must remain unthemeable. | FR2 | Panel target assertion, source inspection, and theming target inventories | Sheet panel, Content area, Handle, and Scrim | Removing the panel target or documenting an unshipped child target fails evidence or inventory. | `audit:BottomSheet/theming` | | FR3, AR1–AR2 | `BottomSheetKeyboard.test.tsx`; `BottomSheetKeyboard.a11y.browser.spec.ts`; open Storybook stories | fitting/overflow, plain text, usable/unusable/dynamic descendants, focus | Missing/duplicate keyboard access, stale ownership, focus movement, or broken content-box layout fails. | `audit:BottomSheet/accessibility` | | Layout evidence | `BottomSheetPanel.test.tsx` | Floating Handle and scrolling Content area | Reordering or merging the stable parts fails existing panel structure and style assertions. | `audit:BottomSheet/anatomy` | -| FR4 | `BottomSheetPanel.test.tsx` container padding tests; `derivedVarRegistry.test.ts` | default, `padding` prop, theme `padding`, lone padded Section child | The published inset differs from the applied padding, or a lone padded Section changes geometry. | `audit:BottomSheet/anatomy` | +| FR4 | `BottomSheetPanel.test.tsx` container padding tests; `derivedVarRegistry.test.ts` | default, `padding` prop, theme `padding`, lone padded Section child | The published inset differs from the applied padding on an edge other than the block start, the block start publishes more than the padding past the grab bar's band, or a lone padded Section escapes into the band. | `audit:BottomSheet/anatomy` | | Element jobs | Open-sheet element count test against the named job list | modal and non-modal sheet, scrim and no scrim | An element in the open sheet's frame with no named job, or a named job with no element | | | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and current target inventory | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:BottomSheet/theming` | | FR5–FR9, FR12–FR15, AR3–AR5 | Stacking unit suite (jsdom, native dialog methods stubbed); real-browser per-frame sampling of transforms, backdrop opacity, hit-testing, and focus on the stacking stories in Chromium, WebKit, and real Safari; a real-browser accessibility-tree check (`spec:AST-020`, `spec:AST-009` FR3) that a lone sheet, a stack of two and three, and a replace handoff expose exactly one `dialog`, the top or entering sheet, with its name, while covered and replaced sheets are hidden, that the stack renders no live region of its own, and that focus enters the entering step's `data-autofocus` field or panel on a replace; a real-browser run with reduced motion emulated on a fine and on a coarse pointer, sampling per frame that no transform animates, that the covered sheet is in its receded place from the frame the push lands, that on a coarse pointer the entering and leaving sheets change opacity only within 200 ms and on a fine pointer at once, that a swipe dismissal fades out in place on a fine pointer, that the dim is constant and that the handoff still completes | siblings, separate components, nested in content, inside a switcher step, a switcher flow as a level, out-of-tree-order opens, same-update deep link, depth 1–3, Escape/scrim/swipe/platform close, covered close, close of the whole stack, focus after each pop, two package copies, a second document | A covered or replaced sheet is exposed in the accessibility tree, the top sheet is exposed without role `dialog` or its name, a covered sheet stays interactive, a second backdrop paints, the dim dips or doubles on a handoff, a dismissal reaches a covered sheet, a nested sheet's state is written or it stays open invisibly, or focus lands on `body` after a pop or a close of the whole stack. | `audit:BottomSheet/behavior` | diff --git a/packages/core/src/BottomSheet/BottomSheet.tsx b/packages/core/src/BottomSheet/BottomSheet.tsx index d7c2911a8840c..9ad37148fb237 100644 --- a/packages/core/src/BottomSheet/BottomSheet.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.tsx @@ -311,7 +311,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 900f34051e377..4ad0b6bdb56dd 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.test.tsx @@ -425,6 +425,9 @@ describe('BottomSheetPanel', () => { ['block-start', 'padding-top'], ['block-end', 'padding-bottom'], ] as const; + // 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( @@ -443,6 +446,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) { @@ -455,6 +465,16 @@ describe('BottomSheetPanel', () => { expect(published, edge).toContain( 'var(--astryx-bottom-sheet-padding,var(--spacing-4))', ); + if (edge === 'block-start') { + 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. expect(computed.getPropertyValue(property), edge).toBe( @@ -480,11 +500,20 @@ describe('BottomSheetPanel', () => { const computed = renderWithPadding(padding); const expected = `var(--spacing-${padding})`; for (const [edge, property] of EDGES) { + const published = + edge === 'block-start' + ? `max(calc(${expected} - var(--spacing-6)), 0px)` + : expected; + expect( + computed + .getPropertyValue(`--container-padding-${edge}`) + .replace(/\s+/g, ''), + edge, + ).toBe(published.replace(/\s+/g, '')); expect( - computed.getPropertyValue(`--container-padding-${edge}`), + computed.getPropertyValue(property).replace(/\s+/g, ''), edge, - ).toBe(expected); - expect(computed.getPropertyValue(property), edge).toBe(expected); + ).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 7af3b3273dc25..c5beb92f144f2 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, @@ -84,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 @@ -118,6 +117,49 @@ 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 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 pastGrabBarStyles = stylex.create({ + 0: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-0']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 0.5: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-0-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 1: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-1']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 1.5: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-1-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 2: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-2']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 3: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-3']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 4: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-4']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 5: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-5']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 6: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-6']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 8: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-8']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, + 10: { + '--container-padding-block-start': `max(calc(${spacingVars['--spacing-10']} - ${HANDLE_BAR_HEIGHT}), 0px)`, + }, +}); + /** * The queries `design:reduced-motion` names: reduced motion with a coarse * primary pointer cross-fades (DR2); reduced motion otherwise changes at once @@ -294,12 +336,14 @@ 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, }, + 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 @@ -880,12 +924,15 @@ export function BottomSheetPanel({ !usesThemePadding && effectivePadding !== 4 && containerPaddingInlineVarStyles[effectivePadding], - !usesThemePadding && - effectivePadding !== 4 && - containerPaddingBlockStartVarStyles[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 new file mode 100644 index 0000000000000..a54ca28ef5ca4 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetGrabBar.a11y.chromium.spec.ts @@ -0,0 +1,215 @@ +// 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). +// 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, + }) => { + 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); +}); 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 a241025106e11..72e7ddb653f4b 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..6b60582883d99 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,13 @@ 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 (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 @@ -250,7 +258,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/Popover/Popover.spec.md b/packages/core/src/Popover/Popover.spec.md index 6fcdf88849bfe..13a482e8e54e8 100644 --- a/packages/core/src/Popover/Popover.spec.md +++ b/packages/core/src/Popover/Popover.spec.md @@ -444,7 +444,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/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}>
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 ae1ad01fb4d4e..d1278f6642d00 100644 --- a/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx +++ b/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx @@ -155,7 +155,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