Skip to content

feat(Popover): presentation — a BottomSheet on a phone when asked, as iOS presents a popover - #8229

Merged
cixzhang merged 27 commits into
meta:mainfrom
vjeux:uikit/c26-popover-adaptive-sheet
Oct 26, 2026
Merged

cixzhang merged 27 commits into
meta:mainfrom
vjeux:uikit/c26-popover-adaptive-sheet

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User need

On a phone, a Popover is a small box beside its trigger; iOS presents a popover as a sheet on an iPhone. A product wanting that composes BottomSheet itself (the Adaptive Popover Recipe story), wiring trigger, open state and label twice; the menus already take presentation.

Evidence

iOS 26.2 Safari, Adaptive Presentation story, a tap on Filter:

Adaptive presentation on an iPhone

  • iOS: the sheet, no anchored popover; aria-expanded true, aria-controls in the sheet.

A drag down (the grey dot is the finger) closes it and collapses the trigger:

Closing the sheet

  • Chromium: the sheet in a phone profile; the anchored popover with a mouse.

Problem and solution fit

Popover takes presentation with the same values: 'popover' (default, unchanged), 'bottom-sheet', and 'adaptive' (the sheet on a compact touch screen, the popover elsewhere). One trigger, open state, onOpenChange and content instance; the trigger's aria-expanded and aria-controls follow the open surface, chosen when it opens and kept until it closes.

Current specification

component:Popover DEC-6 and DEC-8 (cixzhang, 2026-10-25) decide presentation and the sheet it presents; this implements both and adds a Touch sheet anatomy row delegating to BottomSheet's bottom-sheet target (architecture:component-theming-surface INV3). Sibling #8182 edits the same files.

Public delta and caller burden

  • New optional prop presentation?: 'popover' | 'bottom-sheet' | 'adaptive', default 'popover': existing callers see no change.
  • The sheet follows DEC-8: named by label, else by the trigger's text; tall, inset by padding, styled by xstyle, className and style. It always light-dismisses (a tap outside, a swipe down), Escape follows hasEscapeDismiss, and the fallback close stays hidden until reached. role, isModal and hasAutoFocus do not apply.
  • The BottomSheet loads on demand, as DropdownMenu's does.

Design and alternatives

  • spec:AST-017 FR60 (DEC-29) and architecture:component-style-authoring INV7: BottomSheet's frame drops three elements and gains no part or target, so the open sheet on a phone renders as many elements as the anchored popover with a mouse (178 against 178, with the default dismissals and with both off).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

  • R1 narration: none (present tense). R2 research: none.
  • R3 evidence: the presentation row (DEC-6, DEC-8) names the test file. R4 rejected alternatives, R5 change references: none.
  • R6 open questions: none; DEC-6 settles the default.

Testing

At e561e4f:

  • pnpm vitest run packages/core/src/Popover packages/core/src/BottomSheet packages/core/src/theme packages/core/src/Button packages/core/src/DropdownMenu packages/core/src/Selector packages/core/src/ContextMenu packages/core/src/Tokenizer: 2726 pass, including PopoverPresentation.test.tsx (24: a closed sheet observing nothing; content state across openings; a role="none" trigger advertising dialog; a sheet light-dismissing by a tap outside and a swipe down whatever hasLightDismiss says; Escape following hasEscapeDismiss; the hidden fallback close kept in the sheet; the trigger's text naming a sheet without a label; a sheet with both dismissals off rendering no more elements than the anchored popover) and BottomSheetEdgeTint.test.tsx (the tint on the panel, with no element of its own, and the declarations WebKit samples).
  • Element counts, Chromium, document.body.querySelectorAll('*').length (rest / open / after close), desktop 1280 px mouse vs phone 390 px coarse pointer: Adaptive Presentation 178 / 178 / 178 vs 178 / 178 / 178; with both dismissals off 178 vs 178. In jsdom: 16 vs 16 with the default dismissals, without light dismiss, and with neither. Every other Popover story is unchanged.
  • iOS 26.2 simulator, Safari with the compact and the bottom tab bar, a modal and a non-modal sheet over a red page with its own fixed green footer: the bottom toolbar takes the sheet's surface colour with the panel's ::after, as it did with the element. A control with the tint hidden reads the same in this simulator, so the unit test pins the sampled declarations.
  • pnpm exec vitest run --project node packages/cli/test: 309 pass. pnpm build, then pnpm typecheck (every package), eslint, prettier, pnpm check:knowledge, pnpm check:changesets and node scripts/check-sync.js pass.
  • BottomSheet and Popover browser specs (playwright.config.ts): Chromium 61, WebKit 34 (in the Playwright container), all pass.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 26, 2026 8:13am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 8:34 AM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Popover (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 412B 412B 0B
Lines of Code 686 973 -
Complexity Very High Very High (145) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AppShell 55.2KB 55.2KB +26B 11.4KB 11.4KB 0B
core/Banner 47.2KB 47.2KB 0B 9.5KB 9.5KB 0B
core/BaseTypeahead 53.4KB 53.4KB 0B 9.7KB 9.7KB 0B
core/BottomSheet 25.9KB 25.8KB -80B 7.5KB 7.6KB +70B
core/BottomSheetSwitcher 9.1KB 9.1KB 0B 3.2KB 3.2KB 0B
core/BreadcrumbItem 65.4KB 65.4KB 0B 10.8KB 10.8KB 0B
core/Calendar 54.4KB 54.4KB 0B 9.8KB 9.8KB 0B
core/Carousel 48.4KB 49.7KB +1.3KB 10.0KB 10.0KB 0B
core/ChatComposer 59.9KB 59.9KB 0B 10.5KB 10.5KB 0B
core/ChatDictationButton 46.0KB 46.0KB 0B 9.2KB 9.2KB 0B
core/ChatLayout 48.4KB 48.4KB 0B 9.7KB 9.7KB 0B
core/ChatLayoutScrollButton 45.9KB 45.9KB 0B 9.3KB 9.3KB 0B
core/ChatMessageMetadata 25.7KB 25.7KB 0B 3.7KB 3.7KB 0B
core/ChatSendButton 45.4KB 45.4KB 0B 9.0KB 9.0KB 0B
core/ChatToolCalls 31.7KB 31.7KB 0B 8.7KB 8.7KB 0B
core/CheckIndicator 8.9KB 8.9KB 0B 3.4KB 3.4KB 0B
core/CheckboxInput 49.1KB 49.1KB 0B 9.1KB 9.1KB 0B
core/CheckboxList 45.6KB 45.6KB 0B 7.9KB 7.9KB 0B
core/CheckboxListItem 53.2KB 53.2KB 0B 10.2KB 10.2KB 0B
core/Citation 25.8KB 25.8KB -2B 4.0KB 4.0KB 0B
core/CodeBlock 58.3KB 58.3KB 0B 10.4KB 10.4KB 0B
core/Collapsible 11.6KB 11.6KB 0B 4.1KB 4.1KB 0B
core/CommandPalette 49.1KB 49.1KB 0B 11.7KB 11.7KB 0B
core/CommandPaletteInput 31.7KB 31.7KB 0B 8.0KB 8.0KB 0B
core/ComplexSelector 54.8KB 54.8KB 0B 10.3KB 10.3KB 0B
core/ContextMenu 77.1KB 77.1KB +30B 14.2KB 14.2KB +70B
core/ContextMenuItem 16.4KB 16.4KB 0B 5.0KB 5.0KB 0B
core/DateInput 92.3KB 91.9KB -448B 14.3KB 14.3KB +58B
core/DateRangeInput 63.9KB 68.4KB +4.5KB ⚠️ 10.9KB 10.9KB +32B
core/DateTimeInput 101.6KB 102.1KB +471B 14.7KB 14.8KB +56B
core/DialogHeader 48.3KB 48.3KB 0B 9.6KB 9.6KB 0B
core/DrawerHeader 48.4KB 48.4KB 0B 11.0KB 11.0KB 0B
core/DropdownMenu 72.1KB 72.1KB +29B 14.3KB 14.4KB +70B
core/DropdownMenuCheckboxItem 16.0KB 16.0KB 0B 5.4KB 5.4KB 0B
core/DropdownMenuItem 15.3KB 15.3KB 0B 4.9KB 4.9KB 0B
core/DropdownMenuRadioItem 16.0KB 16.1KB +1B 5.4KB 5.4KB 0B
core/DropdownMenuSubMenu 57.2KB 57.2KB 0B 9.3KB 9.3KB 0B
core/Field 43.8KB 43.8KB 0B 7.7KB 7.7KB 0B
core/FieldLabel 39.5KB 39.5KB 0B 6.4KB 6.4KB 0B
core/FieldStatus 10.2KB 10.2KB 0B 4.0KB 4.0KB 0B
core/FileInput 53.2KB 53.2KB 0B 10.1KB 10.1KB 0B
core/Icon 8.7KB 8.7KB 0B 3.4KB 3.4KB 0B
core/InputGroup 44.4KB 44.4KB +1B 7.8KB 7.8KB 0B
core/LayerProvider 57.1KB 57.1KB 0B 9.9KB 9.9KB 0B
core/Lightbox 49.1KB 49.1KB 0B 9.6KB 9.6KB 0B
core/Link 43.2KB 43.2KB 0B 7.4KB 7.4KB 0B
core/ListItem 52.7KB 52.7KB 0B 10.2KB 10.2KB 0B
core/Markdown 136.5KB 136.5KB +27B 16.9KB 16.9KB +79B
core/MobileNav 48.4KB 48.4KB 0B 9.8KB 9.8KB 0B
core/MobileNavToggle 45.8KB 45.8KB 0B 9.2KB 9.2KB 0B
core/MoreMenu 72.3KB 72.3KB +28B 14.3KB 14.4KB +70B
core/MultiSelector 75.1KB 75.1KB +30B 14.1KB 14.2KB +86B
core/NumberInput 55.1KB 55.1KB 0B 10.4KB 10.4KB 0B
core/Overlay 14.2KB 14.2KB 0B 3.7KB 3.7KB 0B
core/Pagination 86.8KB 86.8KB +28B 14.8KB 14.9KB +73B
core/Popover 46.9KB 49.4KB +2.5KB ⚠️ 9.2KB 11.7KB +2.5KB ⚠️
core/PowerSearch 147.2KB 147.2KB -62B 18.1KB 18.2KB +65B
core/RadioList 45.2KB 45.2KB 0B 7.8KB 7.8KB 0B
core/RadioListItem 51.8KB 51.8KB 0B 9.5KB 9.5KB 0B
core/Selector 78.2KB 78.2KB +26B 14.5KB 14.6KB +75B
core/SelectorOption 14.4KB 14.4KB 0B 4.8KB 4.8KB 0B
core/SideNav 55.4KB 55.4KB 0B 10.3KB 10.3KB 0B
core/SideNavCollapseButton 46.2KB 46.2KB 0B 9.2KB 9.2KB 0B
core/SideNavHeading 54.3KB 54.3KB 0B 9.9KB 9.9KB 0B
core/SideNavItem 53.9KB 53.9KB 0B 9.9KB 9.9KB 0B
core/Slider 48.1KB 48.1KB 0B 8.3KB 8.3KB 0B
core/Step 32.2KB 32.2KB 0B 5.1KB 5.1KB 0B
core/Stepper 47.0KB 47.0KB 0B 9.3KB 9.3KB 0B
core/Switch 48.0KB 48.0KB 0B 9.1KB 9.1KB 0B
core/TabList 39.9KB 39.9KB 0B 7.0KB 7.0KB 0B
core/TabMenu 51.2KB 51.2KB 0B 9.8KB 9.8KB 0B
core/Table 83.2KB 83.3KB +30B 14.6KB 14.6KB +72B
core/TableCell 78.1KB 78.2KB +27B 14.3KB 14.4KB +70B
core/TableHeaderCell 77.9KB 77.9KB +29B 14.3KB 14.4KB +70B
core/TextArea 48.6KB 48.6KB 0B 9.1KB 9.1KB 0B
core/TextInput 52.6KB 52.6KB 0B 10.2KB 10.2KB 0B
core/Thumbnail 49.6KB 49.6KB 0B 10.8KB 10.8KB 0B
core/TimeInput 78.8KB 78.7KB -72B 13.2KB 13.3KB +70B
core/Timestamp 27.4KB 27.4KB 0B 9.3KB 9.3KB 0B
core/Toast 53.8KB 53.8KB 0B 9.5KB 9.5KB 0B
core/Token 29.5KB 29.5KB 0B 4.3KB 4.3KB 0B
core/Tokenizer 63.8KB 63.8KB 0B 11.4KB 11.4KB 0B
core/TopNav 49.2KB 49.2KB 0B 10.1KB 10.1KB 0B
core/TopNavHeading 53.7KB 53.7KB 0B 9.7KB 9.7KB 0B
core/TopNavMegaMenu 53.7KB 53.7KB 0B 10.5KB 10.5KB 0B
core/TopNavMenu 53.1KB 53.1KB 0B 9.9KB 9.9KB 0B
core/TreeList 32.4KB 32.4KB 0B 4.5KB 4.5KB 0B
core/Typeahead 60.5KB 60.5KB 0B 11.0KB 11.0KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/DateRangeInput +4.5KB ⚠️ +32B
core/Popover +2.5KB ⚠️ +2.5KB ⚠️

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 33.8KB 33.9KB +95B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

48 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 556,900
RichTextEditor Markdown parity: side by side probe light 538,816
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 28 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: presentation is a new Popover concept that component:Popover does not yet cover, so an engineering owner needs to approve DEC-6 (with its Presentation row and OQ1) or say what it must amend, recorded by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. That decision should also settle how the sheet relates to FR1 (one show, hide and toggle lifecycle), FR2 (content-first focus) and DEC-2 (the popover theme target), which DEC-6 leaves unamended. Independently of that decision, please fix:

  • The content is mounted twice whenever the sheet can be the surface (in the hidden anchored layer and in the sheet), so callers get two instances with separate state, duplicated ids and two data-testid elements.
  • With hasLightDismiss={false} and hasEscapeDismiss={false} the sheet becomes a required alertdialog without the fallback close control, so read-only content cannot be closed.
  • A controlled adaptive popover swaps from the sheet to the anchored popover if the viewport crosses 768 px while it is open, and fires onOpenChange(true) from an effect.
  • xstyle, className and style (and five other props) never reach the sheet, while the docs list only placement, alignment and width as not applying.

[Automated review]

Comment thread packages/core/src/Popover/Popover.tsx Outdated
hasLightDismiss ? 'info' : hasEscapeDismiss ? 'form' : 'required'
}
data-testid={testId}>
{content}

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 anchored layer (popover.render(...), line 799) also mounts content and keeps it mounted while closed, so whenever this sheet renders the caller's content exists twice: two instances with separate state and effects, duplicated explicit ids, and data-testid on two elements (with the sheet open on a Pixel 7 profile the page holds 8 checkboxes and 2 Apply buttons). Mounting one surface at a time, as DropdownMenu does, avoids it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4713e34: the content now exists once. The sheet holds it while the sheet is the surface (open, or the one an open would choose, through its exit), and the anchored layer holds it otherwise; the test id follows it. The new case "holds one instance of the content" failed before, with two Apply buttons.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
height="tall"
padding={padding}
purpose={
hasLightDismiss ? 'info' : hasEscapeDismiss ? 'form' : 'required'

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 both dismissals off this becomes required (no swipe, scrim click or Escape), and hasCloseButton is not passed through, so the fallback "Close popover" control the anchored surface keeps is gone: read-only content leaves phone users in an alertdialog they cannot close. Rendering the fallback close control in the sheet, or keeping Escape here, gives them a way out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4713e34: a sheet with neither light nor Escape dismissal keeps the anchored surface's hidden fallback close control (hasCloseButton, closeButtonLabel). The new case failed before.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
return;
}
if (isOpen && !popover.isOpen) {
if (isOpen && !popover.isOpen && resolvedPresentation !== 'bottom-sheet') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a controlled popover isSheetOpen follows the live query (line 451), so when a phone crosses 768 px while the sheet is open (rotating a Pixel 7) the sheet unmounts and this Effect opens the anchored popover, firing onOpenChange(true) with no user action (architecture:react-component-runtime INV2, INV3). Latching the surface chosen at open, as the uncontrolled path does, keeps the sheet until it closes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4713e34: a controlled popover latches the surface chosen as it opens and keeps it until it closes. A phone turned past 768 px keeps its sheet, and no onOpenChange(true) fires. The new case failed before.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
* The surface that holds the content. `'popover'` anchors it to the
* trigger. `'bottom-sheet'` presents it in a modal BottomSheet, labeled
* by `label`. `'adaptive'` presents the BottomSheet on a compact touch
* screen (a phone, in either orientation) and the anchored popover

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 query is (max-width: 768px) and (pointer: coarse), so a phone wider than 768 px in landscape (a Pixel 7 at 863 px) gets the anchored popover; "in either orientation" overstates it. The list below also omits xstyle, className, style, role, isModal, hasCloseButton, closeButtonLabel and hasAutoFocus, which the sheet drops.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4713e34: the docs now state the rule exactly (a coarse pointer and a viewport at most 768 px wide: a phone in portrait, and in landscape only while it stays that narrow) and list every prop a sheet does not use.

cixzhang added a commit that referenced this pull request Oct 24, 2026
…presentation (#8273)

## Exact decision needed

Whether callers can keep a stable component's existing surface when the component gains touch behavior, and through what API.

## Existing authority searched

- `architecture:public-component-api` INV4 (keep derivable decisions internal), INV9 (released behavior changes under `spec:AST-017`), INV3 (one prop, one concept).
- `spec:AST-002` FR2–FR3: derivable differences, thresholds, and tuning stay internal.
- `spec:AST-017` FR3 and DEC-17: a functional default change names the prop that restores the old default; FR21 and FR27: an omitted experimental opt-in leaves stable behavior unchanged, which covers experimental surfaces only.
- No current record requires a caller opt-out for touch behavior on a stable component.
- Search terms: presentation, opt-out, touch default, restoring prop, adaptive, touch mode. Overlapping open pull requests: #8229 (Popover `presentation`), #8195 (DropdownMenu touch tracking), #8052 (AST-094 resolver), #8062 (shared touch resolver), #7977 and #8001 (touch sheets behind `experimental.presentation`).

## Decision

- `architecture:public-component-api` INV16: a stable component that, under a coarse pointer, presents a different surface or switches how an open surface follows input takes one `presentation` prop, and a value such as `presentation="popover"` keeps its fine-pointer surface on every device. Touch behavior is the default where it serves touch users; the caller's explicit choice always wins. Every such touch mode is reachable by callers; thresholds and tuning stay internal. Touch styling and touch-only behavior other records define (press paint, the iOS text-size floor, a row swipe, a tooltip's touch trigger) stay with those records.
- `spec:AST-017` FR3 and DEC-23: for a touch default, that `presentation` value is the restoring input the Changeset names.

## Rejected alternatives

- Touch behavior that callers cannot turn off or pin.

## Claim scope and non-goals

Caller control over touch presentations and modes on stable components. Which components adopt touch defaults, the touch behavior itself, and whether a given touch default is breaking stay with their owners.

## Evidence and implementation relationship

Selector, MultiSelector, DropdownMenu, MoreMenu, ContextMenu, and the date and time inputs already take `presentation`. Related open work, not edited here: #8229 adds `presentation` to Popover (its `'popover'` default conforms); #8195 adds a touch-tracking mode to DropdownMenu, which INV16 requires callers to be able to reach; the `spec:AST-094` revision in #8052 carries the touch presentation resolver. No implementation is included.

## Writing rubric results

- R1 narration: none.
- R2 research in the record: none.
- R3 evidence naming a change: none; verification names test states.
- R4 rejected alternatives over one line or outside a decision: one, inside DEC-23, one line.
- R5 pull request or issue references remaining: none.
- R6 ruled open questions still listing options: none.

## Scope

- [x] This PR contains specification records only.
- [x] It records observable behavior and preserves equivalent internal implementations.
- [x] Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
- [x] Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
- [x] It records one intentional durable decision, not a rescue for a separable tagalong.
- [x] The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
- [x] Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
- [x] Public text and artifacts contain no internal Meta context.

## Validation

- `pnpm check:knowledge` (CI lint)
- Prettier
- `git diff --check`
… iOS presents a popover

Popover takes `presentation` with the values DropdownMenu, ContextMenu,
MoreMenu, Selector and MultiSelector use. 'popover', the default, keeps the
released anchored surface. 'bottom-sheet' presents the content in a modal
BottomSheet labeled by `label`, at the sheet's tall height, inset by
`padding`. 'adaptive' presents that sheet on a compact touch screen and the
anchored popover elsewhere, which is how iOS presents a popover on an iPhone
unless the app keeps it anchored.

In a sheet, placement, alignment and width do not apply; light dismiss maps
to the sheet's purpose (info, form without light dismiss, required with
neither). The surface is chosen when the popover opens and stays until it
closes; the trigger's expanded state and controlled element follow it. The
sheet loads on demand (lazyPopoverBottomSheet), as DropdownMenu's does.

component:Popover gains the Presentation concept and DEC-6, pending owner
review, and OQ1 asks whether 'adaptive' becomes the default.

The content exists once: the sheet holds it while it is the surface (open,
or the one an open would choose, through its exit) and the anchored layer
otherwise, so callers no longer get two instances, duplicated ids and two
test ids. A sheet with neither light nor Escape dismissal keeps the anchored
surface's hidden fallback close control. A controlled popover keeps the
surface chosen as it opened until it closes, so a phone turned past the
compact width keeps its sheet and onOpenChange(true) no longer fires from an
effect. The docs list every prop a sheet does not use and state the compact
rule exactly.
@vjeux
vjeux force-pushed the uikit/c26-popover-adaptive-sheet branch from 2582a33 to 4713e34 Compare October 24, 2026 22:01
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision: component:Popover DEC-6, with its Presentation row, verification row and OQ1, is a new proposal pending owner review, recorded by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), and that decision should settle how the sheet relates to FR1 (one show, hide and toggle lifecycle), FR2 (content-first focus), DEC-2 (the popover theme target) and the hook-owned fallback close control. The earlier findings on duplicated content, the required sheet with no way out and the controlled sheet swap are fixed, each with a test that fails without the fix. Independently of the owner decision, please fix:

  • Forward the caller's xstyle, className and style to the sheet, which BottomSheet passes to its panel, and list the same props that do not apply in DEC-6, its row and the changeset as in the docs.
  • Describe adaptive without the 768 px numbers, since spec:AST-012 FR9 on main defines compact as width below md or height below 500.
  • Set the controlled surface latch during render rather than in the layout Effect, as architecture:react-component-runtime INV1 and INV3 require.
  • Subscribe to the compact-touch media query only for presentation="adaptive", so default popovers hold no viewport listener.

[Automated review]

// surface's fallback close control, so it can always be closed.
const sheet =
isSheetOpen || resolvedPresentation === 'bottom-sheet' ? (
<PopoverBottomSheet

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 caller's xstyle, className and style never reach the sheet here, so one <Popover className="…"> is styled on a desktop and silently unstyled on a phone. architecture:public-component-api INV5 asks for supported style and class inputs to reach the element that owns the contract, and BottomSheet already forwards all three to its panel; passing the caller's three (not Popover's own viewport-fit styles) would keep them.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
* The surface that holds the content. `'popover'` anchors it to the
* trigger. `'bottom-sheet'` presents it in a modal BottomSheet, labeled
* by `label`. `'adaptive'` presents the BottomSheet on a compact touch
* screen (a coarse pointer and a viewport at most 768 px wide: a phone in

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR9 on main now defines compact as a viewport width below the md point or a height below 500, and says no resolver keeps its own numbers; a phone in landscape under 500 tall will count as compact once the shared resolver adopts it. Describing adaptive as "a compact touch screen" without the 768 px numbers (here and in the three doc rows) keeps the docs right through that change.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
const surface =
controlledSurface ?? (popover.isOpen ? 'popover' : resolvedPresentation);
if (surface !== controlledSurface) {
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the surface is chosen once, as the controlled popover opens

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 latch is derived from props, so the render where isOpen turns true commits isSheetOpen === false and this Effect repairs it before paint (architecture:react-component-runtime INV1), and the disable reasons name no external system (INV3). Setting controlledSurface during render when isOpen changes, and keeping this Effect for popover.show()/hide() only, would avoid both.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
const isControlled = isOpen !== undefined;
// The bottom-sheet presentation: the surface it resolves to now, and
// whether the sheet is the surface that is open.
const resolvedPresentation = useAdaptivePresentation(presentation);

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 runs for every Popover, so each default and each closed instance now holds a matchMedia listener and re-renders when a phone crosses 768 px, though only adaptive reads it. Skipping the subscription unless presentation === 'adaptive' keeps closed and default popovers free of viewport observation, as PR1 intends.

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 4713e34

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026
…pact definition, and only adaptive popovers listen

Review fixes on the presentation change:
- The caller's xstyle, className and style reach the sheet, which passes
  them to its panel, as they reach the anchored surface (they were
  dropped). The docs and changeset list what does not apply in a sheet:
  placement, alignment, width, role, isModal and hasAutoFocus.
- `adaptive` reads compact from the compact definition (AST-012 FR9:
  narrower than the theme's md point or shorter than 500, with a coarse
  pointer), from the nearest Theme provider's width points, instead of
  the 768px query; the docs describe it without the old numbers.
- Only an adaptive popover reads the theme and subscribes to that query;
  a default or bottom-sheet popover holds no viewport or document
  listener.
- The controlled surface latch is set during render as `isOpen` turns on
  or off; the layout Effect only shows or hides the anchored popover.

Tests: the compact query is read only for adaptive; a short landscape
phone (height below 500) gets the sheet; caller className and style land
on the sheet's panel. All fail on the previous head. Merges main for the
compact definition. The Popover record is unchanged.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec fe78c6b

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 25, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An engineering owner has approved the presentation spec (DEC-6) for this head and the default popover path is unchanged, but one accessibility fix is needed before this can be approved. When an adaptive sheet is open and the screen stops being compact (for example an iPad mini turned to landscape), closing it unmounts the sheet before its exit finishes, so focus lands on the page body instead of returning to the trigger; please keep the sheet mounted through its exit once it has been the open surface, and add a test that closes it after the turn.

  • Without a Theme provider, adaptive measures compact against the default md point rather than the registered root theme's, which AppShell follows for the same width points.

[Automated review]

Comment thread packages/core/src/Popover/Popover.tsx Outdated
// it is the open one. A sheet with no dismissal keeps the anchored
// surface's fallback close control, so it can always be closed.
const sheet =
isSheetOpen || resolvedPresentation === 'bottom-sheet' ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When an adaptive sheet is open and the screen stops being compact (an iPad mini turned from 744 × 1133 to 1133 × 744), closing it makes this condition false, so the sheet unmounts before BottomSheet's exit runs: in Chromium focus then lands on body instead of returning to the trigger (component:Popover AR4), and the exit motion is cut. Keeping the sheet, and the content in it (line 830), mounted until its exit finishes once it has been the open surface would fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0a53fa1. A sheet that has been the open surface now stays mounted after it closes, through its exit: its dialog closes while still in the document, BottomSheet hands focus back to the trigger, and the exit motion plays. The anchored popover opening releases it. The new test opens the adaptive sheet on a compact screen, turns the screen wide, closes it with Escape and ends the exit; the dialog closes while connected, focus is on the trigger, and the next open is the anchored popover with the content. It fails on fe78c6b.

Comment thread packages/core/src/Popover/Popover.tsx Outdated
const isAdaptive = presentation === 'adaptive';
// `use` may be called conditionally, unlike the other hooks.
const points = isAdaptive
? use(ThemeContext)?.theme.__adaptations?.widthBreakpoints

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Without a Theme provider this falls back to the default md, but architecture:theme-application has content with no provider follow the registered root theme, the path useThemeDefinition() gives AppShell for the same width points. In a separate React root under a root theme that overrides md, an adaptive Popover would measure compact (spec:AST-012 FR9) against a different point than AppShell.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0a53fa1. Without a Theme provider, adaptive now reads the registered root theme's width points through useThemeDefinition, the path AppShell uses. The hook takes an enabled flag, so a default or bottom-sheet popover still reads and subscribes to nothing. The test registers a root theme with md: 900 and checks that the compact query follows it.

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 131c988

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one small fix: the presentation design is owner-approved for this head, popovers without it are unchanged, and BottomSheet's slimmer frame renders the same pixels, but PopoverPresentation, the type of the new prop, is not exported from @astryxdesign/core/Popover (architecture:public-component-api INV1). Please add it to the type export in packages/core/src/Popover/index.ts, as DropdownMenu and Selector export their presentation types. Separately, a default sheet's first row starts 12 px down under BottomSheet's 24 px grab-bar fade, which is worth a follow-up. The fixing commit needs a fresh /approve-spec <full head SHA> from an engineering owner; the author may record it as one.

[Automated review]

* a BottomSheet, or adaptive (a BottomSheet on a compact touch screen, the
* popover elsewhere).
*/
export type PopoverPresentation = AdaptivePresentation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PopoverPresentation types the new public prop, but packages/core/src/Popover/index.ts exports only PopoverProps and PopoverTriggerRenderProps, so callers cannot import it from @astryxdesign/core/Popover or the root, while DropdownMenu and Selector export their presentation types. Please add it to the index's type export (architecture:public-component-api INV1).

onOpenChange={setSheetOpen}
label={label ?? triggerText}
height="tall"
padding={padding}

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 the default padding (step 3, 12 px) the first row starts under BottomSheet's grab bar, which floats over the top 24 px with the surface colour opaque to 60%; in Chromium at 390 px the top edge of a first checkbox fades. Not blocking, since DEC-8 decides the inset, but worth a follow-up so a sheet's top inset clears the bar.

From CodeBunny's review at 131c988: the new `presentation` prop's type is
exported from @astryxdesign/core/Popover beside PopoverProps
(public-component-api INV1).
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec e57d3b6

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one architecture fix: the presentation design is owner-approved for this head, popovers without it are unchanged, and BottomSheet's slimmer frame keeps its pixels, but Popover.tsx builds its own presentation hold (the controlled-surface latch and hasSheetBeenOpen), which architecture:layer-runtime INV14 on main assigns to the layer runtime. Please either move that hold into the layer runtime and use it from Popover, or, if an engineering owner prefers to keep it in Popover for now, record it beside the components INV14's gap paragraph already names. Separately, a default sheet's first row starts under BottomSheet's 24 px grab-bar fade, which is worth a follow-up. The fixing commit needs a fresh /approve-spec <full head SHA> from an engineering owner; the author may record it as one.

[Automated review]

// A controlled popover opens on the surface its presentation resolves to
// as it opens, and keeps that surface until it closes: a phone rotated past
// the compact width while the sheet is open keeps its sheet.
const [controlledSurface, setControlledSurface] = useState<

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 latch, with hasSheetBeenOpen below (:586-595, :662-675), is a Popover-owned presentation hold: it keeps the surface chosen at opening until the close settles. architecture:layer-runtime INV14 on main assigns that hold to the layer runtime ("A component that needs one of these uses the runtime's rather than building its own"), and its gap paragraph does not name Popover. Please move the hold into the layer runtime and use it here, or have an engineering owner record Popover's hold among INV14's current gaps.

onOpenChange={setSheetOpen}
label={label ?? triggerText}
height="tall"
padding={padding}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: with the default padding (step 3, 12 px) the first row starts inside BottomSheet's grab bar, which floats over the top 24 px of the body, opaque to 60% and then fading, so the top of the first line reads washed out. DEC-8 decides the inset, so this is a follow-up: a sheet top inset that clears the bar.

…14 gap

From CodeBunny's review at e57d3b6: Popover keeps the surface it chose from
an opening until the close settles (its presentation hold) inside Popover,
which INV14 assigns to the layer runtime. No shared hold exists yet, so the
record's gap paragraph names Popover beside the components that already
build their own layer mechanisms.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026
No conflicts. Brings architecture:layer-runtime INV14 and its gap paragraph.
…14 gap

From CodeBunny's review at e57d3b6: Popover keeps the surface it chose from
an opening until the close settles (its presentation hold) inside Popover,
which INV14 assigns to the layer runtime. No shared hold exists yet, so the
record's gap paragraph names Popover beside the components that already
build their own layer mechanisms.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 268fa9b

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: presentation follows component:Popover DEC-6, the sheet's details (DEC-8), BottomSheet's slimmer frame and the layer-runtime line recording Popover's own presentation hold are approved by an engineering owner for this revision, and in Chromium default popovers and BottomSheet's pixels are unchanged. Two optional follow-ups: give the sheet a top inset that clears the grab bar (the first row starts 14 px below the top, inside the bar's 24 px fade), and align DEC-8 and AR2 with the docs on role, isModal, hasAutoFocus and the trigger-text name. Any further commit needs a fresh /approve-spec <full head SHA> from an engineering owner, which the author may record as one.

[Automated review]

onOpenChange={setSheetOpen}
label={label ?? triggerText}
height="tall"
padding={padding}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: with the default padding (step 3, 12 px) the sheet's first row starts 14 px below the panel's top edge, inside BottomSheet's 24 px grab bar, which is opaque surface colour to 60 % and then fades (BottomSheetPanel.tsx:198-215). In Chromium at 390 px the heading's top reads washed out. A sheet top inset that clears the bar would fix it; DEC-8 decides the inset, so it is the owner's call.

text when it has none, so the dialog always has a name. The sheet always
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: "Everything else follows Popover's existing props" reads as if role, isModal and hasAutoFocus still apply in a sheet, while the prop docs, JSDoc and changeset say they do not, and the modal sheet always takes focus. Naming them beside placement, alignment and width would align DEC-8 with the shipped behaviour; AR2's "does not synthesize a label" could likewise be scoped to the anchored surface.

expect(trigger()).toHaveAttribute('aria-haspopup', 'dialog');
});

it('a sheet with neither dismissal renders no more elements than the anchored popover', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the name says the sheet renders no more elements than the anchored popover, but the assertion at line 895 expects exactly one more (the edge tint, within the open-layer allowance). Renaming the test to match would keep it from misleading a later reader.

@cixzhang
cixzhang merged commit 2af4d30 into meta:main Oct 26, 2026
64 checks passed
@vjeux
vjeux deleted the uikit/c26-popover-adaptive-sheet branch October 26, 2026 09:07
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…keyboard notification export

uikit/uikit.test.ts: main's leavesOf (a one-line export is a row, sourced
or not) keeps this branch's check of values bound from an exported group
(const {curve: move} = group). uikit/timing.ts merged cleanly.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8176's local head merges the maintainer's af953bd (meta#8076's bd9246d:
main 2af4d30 with meta#8229's Popover presentation, her BottomSheetPanel.tsx
resolution). No conflicts; `timing.ts` is meta#8176's.
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Popover loads its bottom sheet with import('../BottomSheet/BottomSheet') (lazyPopoverBottomSheet.ts), so a phone's first presentation waits on a second request before the sheet can appear. Your 17:05Z rule (#8464 (comment)) has components import the shared modules directly, with one copy per page, and as vjeux, an engineering owner, has put it: "Adding an extra load on mobile is extremely costly."

The cost of loading it with Popover, measured with the size report's builder (component-size.js, import {Popover}): 49.8 KB gzip today, plus the sheet's own chunk at a phone's first presentation; 66.6 KB with the sheet in the import (+16.8 KB, the part of the sheet Popover does not already share). In exchange, the sheet appears in the render that opens it.

This branch makes it static: vjeux:uikit/x1-popover-sheet-static, on main (its change: vjeux/astryx@8a13f3d...dbbe4ef).

  • A test-first commit: a phone mounts the closed sheet in the render that chooses it, and a tap presents it in the render that opens it; both fail while the sheet is lazy.
  • Then the direct import: lazyPopoverBottomSheet.ts and its preload go, the sheet still renders nothing on the server or while hydrating (AST-013 FR9), and DEC-8's loading sentence says what ships.
  • Popover and BottomSheet jsdom tests: 369 pass. BottomSheet's keyboard specs, which nest a Popover in the sheet: 27/27 in Chromium and in WebKit. Root typecheck and lint pass.

Whether to take it is your call.

This branch was successfully deployed

1 active deployment
Preview — 268fa9b7 Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants