Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/bottom-sheet-clears-grab-bar.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion apps/storybook/stories/BottomSheet.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
31 changes: 19 additions & 12 deletions docs/architecture/container-padding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +123 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This paragraph now says the block start publishes only the padding past the band, but the INV2 verification row further down (line 376) still fails BottomSheet when "The Content area publishes an inset that differs from its applied padding". Could it get the same band exception FR4's row now has, e.g. "…on an edge other than the block start, its block start publishes more than the padding past the grab bar's band, or…"?


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With this exception the host-side rule treats BottomSheet's block start as an intended split, but INV11 (line 289) still says a host state "whose published inset does not match its applied padding is ineligible until that conformance gap is fixed". Nothing compensates at a sheet's block start today, so behavior is unaffected; naming the exception in INV11 too, or saying the block start stays ineligible by design, would keep the record consistent for whoever approves it.

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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 14 additions & 23 deletions packages/cli/assets/templates/pages/table-filter/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2052,7 +2040,8 @@ function FilterOverflow({
<VStack
gap={2}
padding={isCompactSurface ? 4 : 0}
paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 0}>
// In a sheet the sheet's band under its grab bar is the top inset.
paddingBlockStart={0}>
{items.map(({child, index}) => (
<StackItem key={index} xstyle={styles.overflowItem}>
{child}
Expand Down Expand Up @@ -2145,7 +2134,8 @@ function AdaptiveSavedViewForm({
<Section
variant="transparent"
padding={4}
paddingBlockStart={SHEET_TOP_INSET}>
// The sheet's band under its grab bar is the top inset.
paddingBlockStart={0}>
<VStack gap={4}>
<HStack gap={2}>
<StackItem size="fill">
Expand Down Expand Up @@ -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. */}
<VStack gap={0} paddingBlockStart={SHEET_TOP_INSET}>
<VStack gap={0}>
{/* 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
Expand Down Expand Up @@ -4148,14 +4138,15 @@ export default function TableFilterTemplate() {
const detailBody =
activeJob == null ? null : (
<VStack gap={0} xstyle={styles.detailPanel}>
{/* 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. */}
<Section
variant="transparent"
padding={4}
paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 4}>
paddingBlockStart={isCompactSurface ? 0 : 4}>
<VStack gap={4}>
<HStack gap={2}>
<StackItem size="fill">
Expand Down
9 changes: 3 additions & 6 deletions packages/cli/assets/templates/pages/table-inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<VStack paddingBlockStart={3}>
{/* The sheet keeps a band under its grab handle at rest, so the Layout
starts below the pill without a band of its own. */}
<VStack>
<Layout
// auto, not fill: the sheet is sized by this Layout now rather than
// the other way round. `fill` would stretch it to a height that
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/BottomSheet/BottomSheet.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog>; 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 <div>. 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 <dialog>; 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 <div>. 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',
Expand Down Expand Up @@ -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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The component description at line 98 still says 'content starts at the theme's bottom-sheet padding (--spacing-4 by default)', and the Container Padding story still says 'padding={0} restores the unpadded content box' (apps/storybook/stories/BottomSheet.stories.tsx:130); both need the floor too.

},
{
name: 'height',
Expand Down
Loading
Loading