Skip to content

feat(MultiSelector): footer — caller content under the list - #7273

Closed
vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:ms-panel-footer
Closed

vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:ms-panel-footer

Conversation

@vjeux

@vjeux vjeux commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: the branch sits on main, so this diff is already its own.

User need

What led to it: this comes from moving every menu and picker in our app onto Astryx, which began with a press, slide and lift bug in its phone menus. Our label picker needs a "Manage labels" button at the bottom of its panel, the way pickers usually end with a link to where the items are managed, but MultiSelector's panel ends at its list.

Why: without a place under the list, an app has to put that button outside the picker, away from the labels it manages.

Problem and solution fit

A new optional footer renders under the list, behind a divider and outside its scroll area, in both the popover and the bottom sheet. With a footer, the popover becomes a modal dialog (the owner's ruling): focus moves in on open, Tab cycles through the search field and the footer controls and wraps, and Escape or a press outside closes and returns focus. Closing the panel when a footer control acts is out of scope.

Current specification

component:MultiSelector (draft): DEC-4 records the footer and the owner's dialog ruling; FR12 states the contract; AR1 names the APG Dialog (Modal) parts (spec:AST-020/FR15); FR10 notes the dialog popup. Other proposals: #7272 FR11, #7274 FR13.

Public delta and caller burden

  • New optional footer; without it nothing changes.
  • useFocusTrap: a trap nested inside another now owns Tab while focus is in it, so a popover opened from a footer button keeps its own Tab order.
<MultiSelector
  {...pickerProps}
  hasSearch
  footer={<Button label="Manage labels" variant="ghost" onClick={openSettings} />}
/>

Design and alternatives

  • A footer row inside the list. Easy, but pressing it would toggle it like an option.
  • An option action. Works for one row, not for the panel.
  • Reuse emptyText. Shows only when nothing matches.
  • A footer prop, as on CommandPalette and SideNav (chosen). It keeps the button out of the list. The cost: with a footer the panel is a dialog that traps Tab, as DropdownMenu's header and footer already do.

Evidence

Thirteen cases in MultiSelector.test.tsx cover the dialog wiring, the Tab cycle, the sheet, Escape (also from a footer menu) and the unchanged no-footer path; they fail on main. A Chromium test checks the dialog semantics, Tab wrapping and Escape in three stories.

Writing rubric results

R1 narration: none in FR12, AR1 or DEC-4.
R2 research: none.
R3 evidence: FR12 cites DEC-4, spec:AST-020 FR15 and tests.
R4 rejected alternatives: four, in DEC-4.
R5 change references: none.
R6 open questions: none.

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.

Testing

In Storybook, open Core/MultiSelector → Panel Footer and open the picker.

  1. A "Manage labels" button sits under the list.
  2. Press Tab: focus moves from the search field to the button; press Tab again and it wraps back to the search field.
  3. Press Escape: the panel closes and focus returns to the trigger.

@vercel

vercel Bot commented Oct 9, 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 27, 2026 6:43pm UTC

Request Review

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

astracat-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 1:44 AM UTC

@github-actions

github-actions Bot commented Oct 10, 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

MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 273B 273B 0B
Lines of Code 2642 2883 -
Complexity Very High Very High (386) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/BaseTypeahead 54.5KB 54.6KB +89B 9.9KB 9.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.5KB 66.6KB +84B 11.0KB 11.0KB 0B
core/ChatComposer 60.8KB 60.9KB +85B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.9KB 56.0KB +79B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.9KB 56.0KB +79B 10.1KB 10.1KB 0B
core/ComplexSelector 56.0KB 56.0KB +69B 10.6KB 10.6KB 0B
core/ContextMenu 74.7KB 74.7KB 0B 14.6KB 14.6KB 0B
core/DateInput 99.7KB 99.8KB +96B 14.8KB 14.8KB 0B
core/DateRangeInput 69.5KB 69.6KB +76B 11.2KB 11.2KB 0B
core/DateTimeInput 109.8KB 109.9KB +96B 15.2KB 15.2KB 0B
core/Drawer 31.6KB 31.6KB +70B 8.0KB 8.0KB 0B
core/DropdownMenu 72.8KB 72.9KB +95B 14.7KB 14.7KB 0B
core/DropdownMenuSubMenu 58.2KB 58.2KB 0B 9.5KB 9.5KB 0B
core/Markdown 144.5KB 144.5KB +1B 17.4KB 17.4KB 0B
core/MoreMenu 73.2KB 73.3KB +84B 14.7KB 14.7KB 0B
core/MultiSelector 73.1KB 73.6KB +531B 14.6KB 14.6KB 0B
core/Pagination 84.9KB 85.0KB +69B 15.3KB 15.3KB 0B
core/Popover 50.0KB 50.1KB +91B 12.1KB 12.1KB 0B
core/PowerSearch 158.2KB 158.3KB +90B 19.9KB 19.9KB 0B
core/Selector 76.5KB 76.5KB +88B 15.0KB 15.0KB 0B
core/SideNavHeading 54.9KB 54.9KB +76B 10.1KB 10.1KB 0B
core/SideNavItem 54.6KB 54.7KB +88B 10.1KB 10.1KB 0B
core/TabMenu 51.8KB 51.8KB +79B 10.0KB 10.0KB 0B
core/Table 83.9KB 83.9KB -3B 15.1KB 15.1KB 0B
core/TableCell 75.8KB 75.8KB 0B 14.8KB 14.8KB 0B
core/TableHeaderCell 75.5KB 75.5KB 0B 14.8KB 14.8KB 0B
core/Tokenizer 68.8KB 68.8KB +85B 16.5KB 16.5KB 0B
core/TopNavHeading 54.3KB 54.4KB +75B 9.9KB 9.9KB 0B
core/TopNavMegaMenu 54.3KB 54.4KB +87B 10.8KB 10.8KB 0B
core/TopNavMenu 53.7KB 53.8KB +75B 10.1KB 10.1KB 0B
core/Typeahead 61.6KB 61.7KB +98B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.3KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 65.9KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 94.3KB N/A N/A 1.9KB N/A
lab/CommentThread N/A 216.1KB N/A N/A 2.4KB N/A
lab/Comments N/A 220.7KB N/A N/A 2.5KB N/A
lab/MobilePowerSearch N/A 158.3KB N/A N/A 1.4KB N/A
lab/TourStep N/A 55.5KB N/A N/A 590B N/A
lab/TransferListSelector N/A 72.5KB N/A N/A 1.9KB N/A
richtext/RichTextEditorToolbar N/A 212.7KB N/A N/A 454B N/A

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.

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 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 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 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
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 35 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.

Thanks — this is a focused, well-evidenced addition, and no code changes are being requested here.

Approval is waiting on a maintainer spec decision. MultiSelector's component record is currently a draft, and this change adds the footer as proposed FR11/DEC-3 pending owner review. A maintainer needs to adopt that footer contract as the governing current spec — caller content under the list and outside the listbox/grid in both presentations, Tab continuing into its controls with popover Tab-past-last and Escape closing, and the untargeted Panel footer — or identify another governing current record for it.

For context, the behavior itself checks out: the new footer unit tests, the full MultiSelector and consumer-doc suites, and real-browser walks of Tab, Shift+Tab, Escape, Tab-past-last, and clicking the footer control all pass, the no-footer default is unchanged, and no concrete defect was found. The remaining step is that maintainer decision.

[Automated review]

@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 is waiting on a maintainer. MultiSelector's component record is still a draft, so a maintainer needs to adopt the proposed footer contract (FR11/DEC-3, the untargeted Panel footer, and FR10's narrowing to "no control within grid rows is a tab stop") as current, or point to another governing spec. They also need to confirm whether AST-058's "no Tab stop inside the popup" wording covers only option actions. Separately, two keyboard issues need fixes and regression tests:

  • One Escape pressed inside a menu opened from a footer control closes both the menu and the panel, because the footer's own Escape handling runs before the shared dismissal stack, which already closes the panel from footer focus on its own.
  • Without hasSearch, Shift+Tab from the footer's first control stays in the footer instead of returning to the trigger, because the popover's focus trap wraps it, so keyboard users must close the panel to get back to the options.

[Automated review]

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Owner ruling, cixzhang, 2026-10-13: with a footer, the panel holds a control that is not an option, so it has to be a dialog, and Tab has to wrap. The footer's placement outside the listbox is right; the popup around it still claims to be a plain listbox.

Today the trigger says aria-haspopup="listbox" (or grid) and its aria-controls names the listbox. A screen-reader user is promised a list of options, then Tab lands on "Manage labels". The same rule landed for DropdownMenu in component:DropdownMenu DEC-10 and AR5.

What the spec and code should say, in component:MultiSelector FR11 and DEC-3:

  • Dialog with a footer: the pointer popover is a role="dialog" with aria-modal="true", named by the component's label, holding the search row, the listbox or grid, and the footer. The listbox keeps only options.
  • Trigger: aria-haspopup="dialog", and aria-controls names the popover that holds the dialog. With search, aria-controls on the search field keeps naming the listbox.
  • Tab wraps: Tab and Shift+Tab cycle through the dialog's stops (search field, its clear button, the listbox when it is a stop, and the footer's controls) and wrap at either end. Do not close the panel on Tab past the last footer control. Escape and a press outside close the panel and return focus to the trigger. Hidden controls are not stops.
  • No search plus footer: the trigger is a select-only combobox using aria-activedescendant. A dialog means focus moves into the popup, so with a footer the panel switches to that model: focus moves into the dialog on open (to the listbox, which takes a Tab stop), and the trigger stops owning the arrow keys while the panel is open.
  • No footer: nothing changes. The bottom sheet is already a dialog; the footer goes inside it, with no second dialog.

Please also record the APG Dialog (Modal) parts it adopts in the accessibility contract, under spec:AST-020/FR15, as DropdownMenu AR5 does.

@github-actions
github-actions Bot requested a review from cixzhang October 14, 2026 07:20
@vjeux

vjeux commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Applied in d67f1de, on top of a rebase onto main (80e8be2).

  • Dialog with a footer: the pointer popover is role="dialog" with aria-modal="true", named by label. It holds the search row, the listbox or grid, and the footer, and the listbox keeps only options.
  • Trigger: it says aria-haspopup="dialog", and its aria-controls names the popover that holds the dialog. This applies to the caller's control from renderTrigger too. With search, the search field's aria-controls still names the listbox.
  • Tab wraps: Tab and Shift+Tab cycle through the search field, its clear button while it shows, the listbox when it is a stop, and the footer's controls, and wrap at both ends. Hidden controls are not stops, including ones under a hidden wrapper, and Tab never closes the panel. Escape and a press outside close it and return focus to the trigger; Chromium checks the focus return.
  • No search plus footer: focus moves into the dialog on open, to the listbox. The listbox takes a Tab stop and the arrow, Enter, Escape and typeahead keys, and the trigger drops aria-activedescendant. The trigger keeps its combobox role, as it already does when it opens the bottom sheet's dialog. Say if you'd rather it become a plain button here.
  • No footer: nothing changes, and a test pins it. In the bottom sheet the footer stays inside the sheet's dialog, with no second dialog. I also made Tab wrap in the sheet when it has a footer, because Shift+Tab from the top of such a sheet used to close it. Say if you'd rather leave the sheet's Tab as it was.
  • Spec: FR11 is rewritten. AR1 records the APG Dialog (Modal) parts under spec:AST-020/FR15, as DropdownMenu AR5 does in fix(DropdownMenu): a popover with a header or footer is a modal dialog, and Tab wraps #7515. DEC-3 carries your ruling, with you as its decider. FR10 notes the dialog popup.

Tests: I rewrote the footer suite, and 5 of its 9 cases fail on the previous head. The Chromium spec now walks the dialog with a second story for the no-search case, and 3 of its 4 tests fail on a build of the previous head. 1276 related unit tests, the MultiSelector Chromium specs, typecheck, lint:strict and check:knowledge pass.

@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.

Thanks for applying the dialog ruling. Approval now waits on a maintainer to record the footer contract as current (or point to another current spec that governs it), because MultiSelector's spec record is still a draft and a public addition is accepted only against current authority; that decision should also settle the two points you left open (Tab wrapping inside the bottom sheet, and the no-search trigger keeping its combobox role). Separately, two issues need fixes:

  • One Escape inside a menu opened from a footer control closes both the menu and the panel, because the footer's own Escape handler runs before the shared dismissal stack; removing that branch fixes it, and Escape from a plain footer control still closes the panel.
  • Without search, the listbox that now receives focus is announced by the trigger's visible text (such as "Select…") instead of the field's label; naming it with label, as the renderTrigger path already does, fixes it.

[Automated review]

@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 is waiting on a maintainer: component:MultiSelector is still a draft record, so the footer contract this PR writes into it (FR11, AR1, DEC-3 and the narrowed FR10) needs to be recorded as current, or another current spec named that governs it, including whether Tab wraps inside the bottom sheet and whether the no-search trigger keeps its combobox role. Separately, two keyboard and screen-reader issues need fixes, each with a regression test:

  • One Escape inside a menu opened from a footer control closes both the menu and the panel, because the footer's own Escape branch in handleFooterKeyDown acts on a key the menu already handled; deleting it leaves Escape to the shared dismissal stack, and Escape from a plain footer button still closes the panel.
  • Without search, the listbox that receives focus is named by the trigger's text ("Select…") instead of the label; naming it with aria-label={label} whenever it owns the keyboard, as the renderTrigger path already does, fixes it.

[Automated review]

Comment on lines +1904 to +1907
if (event.key === 'Escape') {
onKeyDown(event);
return;
}

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 branch also receives every Escape that bubbles from a layer a footer control opens (React events bubble through portals), so one Escape inside a footer DropdownMenu closes the menu and the panel; overlay-dismissal FR2 says one request affects one surface. With these four lines removed, the first Escape closes only the menu, Escape from a plain footer button still closes the panel through the shared stack, and the footer suite still passes. Consider deleting the branch and adding a regression with a DropdownMenu in the footer.

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.

Removed in 0f9d691: Escape is left to the shared dismissal stack, which still closes the panel from a plain footer control. New case "an Escape inside a menu a footer control opens closes only that menu" (a DropdownMenu in the footer) fails at the previous head.

@@ -2345,13 +2470,30 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
: listboxLabelProps)}

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 a footer and no search, this listbox now takes focus (listboxOwnsKeyboard), but here it keeps aria-labelledby on the trigger, and Chromium names it from the trigger’s text: “Select…”, then “1 selected”, while the dialog is “Labels”. Naming it with aria-label={label} whenever listboxOwnsKeyboard is true, as the renderTrigger path and the no-search sheet do, and asserting getByRole('listbox', {name: 'Labels'}) in PanelFooter.a11y.chromium.spec.ts 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 0f9d691: whenever the listbox takes focus itself, it carries aria-label={label}. The no-search unit case and PanelFooter.a11y.chromium.spec.ts assert the name "Labels", before and after a pick changes the trigger's text.

@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 is waiting on a maintainer: component:MultiSelector is still a draft record, so the footer contract this PR writes into it (FR11, AR1, DEC-3 and the narrowed FR10) needs to be recorded as current, or another current spec named that governs it, including whether Tab wraps inside the bottom sheet and whether the no-search trigger keeps its combobox role. Separately, one keyboard issue needs a fix and a regression test: the footer's Tab handler also takes Tab presses from a layer a footer control opens, so Tab from the last control of a Popover opened from the footer moves focus to the picker's search field and leaves that popover open; returning early when the key comes from inside a popover or dialog nested in the footer, as the Escape fix did for menus, would resolve it.

[Automated review]

| FR9 | With `hasSearch` and `hasCreate`, once options have loaded, a `Create "<query>"` row leads the list when the trimmed query equals no option label under the search's own case-insensitive matching. Picking it, or Enter with nothing highlighted, calls `onChange` with the query appended to the value and a `{type: 'create', query}` descriptor, clears the search, and announces the creation; every other change passes no descriptor. The row has no checkbox, is never selected, is excluded from select-all and from `renderOption`, and is announced instead of the empty message when it is the only result. While `isLoading`, no row is offered and Enter commits nothing. `hasCreate` without `hasSearch` warns in development and offers nothing. | `spec:AST-056` FR5 / DEC-3, `component:Tokenizer` `hasCreate`, docs, and tests | Proposed; awaiting owner approval |
| option action | `action` absent, present | The option's secondary control as one node; the popup is a grid of rows pairing option and action | Pointer popup and Touch sheet | absent | Caller | additive | `null` is none; a control without a name warns in development |
| FR10 | Once any declared option — at any depth of sections, before any query filters it — carries an `action`, the popup is a `role="grid"` and stays one while mounted; the trigger advertises `aria-haspopup="grid"` (the bottom sheet, and a popover with a footer (FR11), keep `dialog`). Each option, the select-all row included, is a `role="row"` carrying the option's id, `aria-selected` and `aria-disabled`, with exactly two `gridcell` children: the option's content, then its action or an empty cell. A section is a `rowgroup` named by its title. Pressing the option cell toggles; pressing the action fires the caller's control and nothing else. Up/Down move rows onto the option cell; the inline-end arrow moves to the action cell and the inline-start arrow back, following visual direction under RTL; Enter on the action cell activates the control. `aria-activedescendant` names the row or the action cell. No control within grid rows is a tab stop. | `spec:AST-058` FR1–FR8, AR1–AR4 | Proposed; awaiting owner approval |
| FR11 | With `footer`, the shared panel content ends with a divider and the footer, outside the listbox or grid and outside the list's scroll container, in both presentations. In the Pointer popup the popover is then a `role="dialog"` with `aria-modal="true"`, named by `label`, holding the search row, the listbox or grid, and the footer; the listbox or grid holds only options. The trigger, the caller's control included, says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog; with search, the search field's `aria-controls` still names the listbox. Focus moves into the panel as it opens: to the search field with search; otherwise to the listbox, which takes a Tab stop and owns the arrow, Enter, Escape, and typeahead keys, while the trigger keeps its combobox role but no longer carries `aria-activedescendant`. In both presentations Tab and Shift+Tab cycle through the panel's stops (the search field, its clear button while it shows, the listbox when it takes a stop, then the footer's controls) and wrap at either end; Tab never closes the panel, a control that is hidden or under a hidden ancestor within the panel is not a stop, and with no other stop focus stays where it is. Escape from anywhere in the panel and a press outside close it, and focus returns to the trigger. A press inside the footer is not a light dismiss. The bottom sheet, already a dialog, holds the footer with no second dialog. Without `footer`, nothing changes: no dialog, a `listbox` or `grid` popup, and Tab closes the panel. | DEC-3; `spec:AST-020` FR15; docs and tests | Proposed; awaiting owner approval |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR11 gives the list that takes focus without search a Tab stop and the keys, but never says what names it. The code names it with aria-label={label} and both test suites assert "Labels"; saying so here keeps a later change from falling back to the trigger's text ("Select…").

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.

Done in 9811324: FR11 says the listbox that takes focus without search is "named by label".

@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 is waiting on a maintainer spec decision: component:MultiSelector is still a draft record, so a maintainer needs to record the footer contract this PR writes into it (FR12, AR1, DEC-4 and the FR10 edit) as current, or point to another current spec that governs it. That decision should also settle whether Tab wraps inside the bottom sheet, whether the no-search trigger stays a combobox, and whether spec:AST-058 FR2 and FR5 need a footer exception for pickers whose options carry actions. No code changes are requested: the footer behaves as described in real Chromium, the touched test suites and CI pass, and cixzhang's changes-requested review stays open until they review again.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
Three open pull requests propose rules in this record: meta#7272 (FR11, DEC-3), meta#7273 (FR12, AR1, DEC-4) and meta#7274 (FR13, DEC-5). Each takes its own number, so none changes when another merges first. Record wording only.

@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 is waiting on a spec decision: the footer contract exists only as proposed FR12, AR1, DEC-4 and an FR10 edit in the draft component:MultiSelector record, so an engineering owner needs to approve it by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) or record it as current, including whether Tab wraps inside the bottom sheet and whether the no-search trigger stays a combobox. For pickers whose options carry actions, a footer also departs from spec:AST-058 FR2 (a grid popup) and FR5 (Tab keeps its meaning), so that record needs a matching amendment for a picker with a footer. No code changes are requested: the footer behaves as described in real Chromium and the touched suites pass, and cixzhang's changes-requested review stays open until they review again.

[Automated review]

| FR8 | With `renderTrigger`, the caller-rendered control receives `ref`, `id`, `onClick`, `onKeyDown`, `onFocus`, `aria-haspopup`, `aria-expanded`, `aria-controls`, and `aria-busy`; the panel is anchored to it, focus returns to it on close, the listbox is named by `label`, and the listbox takes focus and owns the keyboard on open. No Field, status, clear action, spinner, or indicator renders. `handleRef` exposes `open`, `close`, `toggle`, `isOpen`; `onOpenChange` fires on every open and close. | DEC-1, docs, and tests | Proposed; awaiting owner approval |
| FR9 | With `hasSearch` and `hasCreate`, once options have loaded, a `Create "<query>"` row leads the list when the trimmed query equals no option label under the search's own case-insensitive matching. Picking it, or Enter with nothing highlighted, calls `onChange` with the query appended to the value and a `{type: 'create', query}` descriptor, clears the search, and announces the creation; every other change passes no descriptor. The row has no checkbox, is never selected, is excluded from select-all and from `renderOption`, and is announced instead of the empty message when it is the only result. While `isLoading`, no row is offered and Enter commits nothing. `hasCreate` without `hasSearch` warns in development and offers nothing. | `spec:AST-056` FR5 / DEC-3, `component:Tokenizer` `hasCreate`, docs, and tests | Proposed; awaiting owner approval |
| option action | `action` absent, present | The option's secondary control as one node; the popup is a grid of rows pairing option and action | Pointer popup and Touch sheet | absent | Caller | additive | `null` is none; a control without a name warns in development |
| FR10 | Once any declared option — at any depth of sections, before any query filters it — carries an `action`, the popup is a `role="grid"` and stays one while mounted; the trigger advertises `aria-haspopup="grid"` (the bottom sheet, and a popover with a footer (FR12), keep `dialog`). Each option, the select-all row included, is a `role="row"` carrying the option's id, `aria-selected` and `aria-disabled`, with exactly two `gridcell` children: the option's content, then its action or an empty cell. A section is a `rowgroup` named by its title. Pressing the option cell toggles; pressing the action fires the caller's control and nothing else. Up/Down move rows onto the option cell; the inline-end arrow moves to the action cell and the inline-start arrow back, following visual direction under RTL; Enter on the action cell activates the control. `aria-activedescendant` names the row or the action cell. No control within grid rows is a tab stop. | `spec:AST-058` FR1–FR8, AR1–AR4 | Proposed; awaiting owner approval |

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 a footer this row makes a grid picker's trigger say aria-haspopup="dialog", and FR12 adds Tab stops inside the panel. Current spec:AST-058 FR2 says the trigger advertises grid (only the bottom sheet keeps dialog) and FR5 keeps Tab's current meaning, with a verification row that fails "a sequential tab stop inside the panel". That record needs a matching amendment for a picker with a footer, decided with this contract.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
Three open pull requests propose rules in this record: meta#7272 (FR11, DEC-3), meta#7273 (FR12, AR1, DEC-4) and meta#7274 (FR13, DEC-5). Each takes its own number, so none changes when another merges first. Record wording only.

@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 is waiting on a spec decision: the footer contract exists only as proposed FR12, AR1, DEC-4 and an FR10 edit in the draft component:MultiSelector record, so an engineering owner needs to approve that text by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), including whether Tab wraps inside the bottom sheet and whether the no-search trigger stays a combobox. For pickers whose options carry actions, a footer also departs from spec:AST-058 FR2 (a grid popup) and FR5 (Tab keeps its meaning), so that record needs a matching amendment. Separately, one fix is needed: moveDialogTab reads the page's document.activeElement (MultiSelector.tsx:1928), so in a picker rendered into an iframe or a Picture-in-Picture window forward Tab never leaves the search field; reading panel.ownerDocument.activeElement, with a second-document regression test, fixes it. cixzhang's changes-requested review stays open until they review again.

[Automated review]

if (stops.length === 0) {
return;
}
const active = document.activeElement;

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 reads the page's document, so when the picker renders in a second document (a same-origin iframe or a Picture-in-Picture window, which usePopover and useFocusTrap now support) active is the <iframe> element and index is always -1: Tab from Search lands on Search again and Shift+Tab always jumps to the last stop. panel.ownerDocument.activeElement keeps the cycle in the panel's own document; a regression case beside Popover's second-document tests would pin it.

vjeux added 10 commits October 26, 2026 08:44
The panel was the search row, a divider and the list, so a picker could
not end with a control of its own, such as a door to where its options
are managed. footer renders caller content after the list behind a
divider, outside the listbox or grid and the list's scroll container, in
both presentations. Tab from the panel's keyboard focus moves into the
footer's controls and keeps the panel open; in the popover, Tab past the
last control and Escape inside it close the panel. Proposed FR11 and
DEC-3 of component:MultiSelector record it; FR10's tab-stop sentence now
names the grid's rows.
…wraps

Owner ruling on this pull request: a footer puts a control that is not an
option in the panel, so the popover is a role="dialog" with
aria-modal="true", named by `label`, holding the search row, the listbox or
grid, and the footer. The trigger (the caller's control included) says
aria-haspopup="dialog" and its aria-controls names the popover; the search
field keeps naming the listbox. Without search, focus moves into the
listbox on open, which takes a Tab stop and the keys, and the trigger drops
aria-activedescendant. Tab and Shift+Tab cycle through the panel's stops
(search field, its clear button, the listbox when it is a stop, the
footer's controls), wrap at both ends, skip hidden controls and never
close; Escape and a press outside close and return focus to the trigger.
The bottom sheet, already a dialog, holds the footer with no second one and
wraps Tab the same way. Without a footer nothing changes.

Spec: FR11 rewritten, AR1 records the APG Dialog (Modal) parts under
spec:AST-020/FR15, DEC-3 carries the ruling, FR10 names the dialog popup.
Tests: the footer suite rewritten (5 of 9 fail on the previous head), the
Chromium spec walks the dialog (3 of 4 fail on the previous head), and a
PanelFooterWithoutSearch story.
…that menu; a focused listbox is named by the label

The footer's own Escape branch also received every Escape that React bubbles through the portal of a layer a footer control opens, so one Escape closed that menu and the panel. Escape is now left to the shared dismissal stack, which still closes the panel from a plain footer control. Without search, the listbox that takes focus is named by aria-label from the label, as the bottom sheet's is, instead of the trigger's text.
… with that layer

The footer's Tab handler also took Tab presses from a popover a footer control opened (inside the footer's DOM, or bubbled through a portal), moving focus to the search field and leaving the popover open. It now returns early for a key from such a layer. FR11 says the no-search list is named by label.
…s inside it

The outer trap's document listener was installed first and ran first, so Tab from the last control of a popover opened from a footer control (also the panel's last stop) wrapped to the panel's search field and left the popover open. A trap now leaves Tab to an active trap nested in its container that holds focus. Unit cases cover both directions and the outer trap once the inner deactivates; a Chromium case walks past the footer popover's last stop on a new story.
…ption actions

Two regression cases for combinations the footer must keep: a renderTrigger picker (the caller's control says aria-haspopup="dialog" and names the popover, focus moves into the listbox, Tab wraps through the footer, Escape returns focus to the control), and options with actions (the grid sits inside the dialog, its action controls take no Tab stop, and the arrows still reach and activate them).
…mes the popover that holds the dialog

FR11, the code and the owner's ruling already say the trigger's aria-controls names the popover that holds the dialog; AR1, DEC-3 and the renderTrigger paragraph said it names the dialog. Record wording only.
In the bottom sheet the trigger's aria-controls names the listbox and it keeps aria-activedescendant, since both key on the footer dialog, which the sheet is not; FR11 now scopes those two clauses to the Pointer popup. isHiddenWithin moves above the matcher's comment, so that comment sits on normalizeLabel again.
Three open pull requests propose rules in this record: meta#7272 (FR11, DEC-3), meta#7273 (FR12, AR1, DEC-4) and meta#7274 (FR13, DEC-5). Each takes its own number, so none changes when another merges first. Record wording only.
…e Pointer popup

The bottom sheet is a dialog with or without a footer, so "Without footer, nothing changes: no dialog" held for the popover only; FR12 now says the Pointer popup is unchanged. Record wording only; the cell keeps its width.

@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 is waiting on a spec decision: the footer contract exists only as proposed FR12, AR1, DEC-4 and an FR10 edit in the draft component:MultiSelector record, so an engineering owner needs to approve that text by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), including whether Tab wraps inside the bottom sheet and whether the no-search trigger stays a combobox. For pickers whose options carry actions, a footer also departs from spec:AST-058 FR2 (a grid popup) and FR5 (Tab keeps its meaning), so that record needs a matching amendment. Separately, one fix is still needed: moveDialogTab reads the page's document.activeElement (MultiSelector.tsx:1932), so in a picker rendered into an iframe or a Picture-in-Picture window forward Tab never leaves the search field; reading panel.ownerDocument.activeElement, with a second-document regression test, fixes it. cixzhang's changes-requested review stays open until they review again.

[Automated review]

Main's meta#8611 (component:Popover DEC-9) made useFocusTrap leave a Tab that starts outside its container to the page. This branch's nested-trap check runs after it: a Tab inside a trap nested in this one's container is still inside this container, so the outer trap still leaves it to the inner one. The doc keeps both sentences.

@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 is waiting on a spec decision: the footer contract exists only as proposed FR12, AR1, DEC-4 and an FR10 edit in the draft component:MultiSelector record, so an engineering owner needs to approve that text by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), including whether Tab wraps inside the bottom sheet and whether the no-search trigger stays a combobox. For pickers whose options carry actions, a footer also departs from spec:AST-058 FR2 (a grid popup) and FR5 (Tab keeps its meaning), so that record needs a matching amendment. Separately, one fix is still needed: in a picker rendered into an iframe or a Picture-in-Picture window, forward Tab never leaves the search field, because moveDialogTab reads the page's document.activeElement (MultiSelector.tsx:1932) and the footer's Tab handler checks instanceof Element against the page (:1967); reading panel.ownerDocument.activeElement and checking the node type instead, with a second-document regression test beside Popover's, fixes it. cixzhang's changes-requested review stays open until they review again.

[Automated review]

return;
}
const footer = footerRef.current;
const target = event.target instanceof Element ? event.target : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In a picker rendered into an iframe or a Picture-in-Picture window, event.target is an Element of that document's realm, so this instanceof Element is false for every footer control and the footer's Tab is never handled; together with document.activeElement at line 1932, forward Tab from Search never leaves the field there. Checking the node type here (nodeType === 1) and reading panel.ownerDocument.activeElement at line 1932 makes a jsdom iframe probe walk Search, Manage labels, Help, Search, as in the page.

Main's meta#8711 appended a 'MultiSelector caller aria-labelledby' suite to MultiSelector.test.tsx where this branch appends its own; the file keeps main's suite as is, then this branch's cases.

@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 is waiting on a spec decision: the footer contract exists only as proposed FR12, AR1, DEC-4 and an FR10 edit in the draft component:MultiSelector record, so an engineering owner needs to approve that text by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), including whether Tab wraps inside the bottom sheet and whether the no-search trigger stays a combobox. For pickers whose options carry actions, a footer also departs from spec:AST-058 FR2 (a grid popup) and FR5 (Tab keeps its meaning), so that record needs a matching amendment. Separately, one fix is still needed: in a picker rendered into an iframe or a Picture-in-Picture window, forward Tab never leaves the search field, because moveDialogTab reads the page's document.activeElement (MultiSelector.tsx:1941) and the footer's Tab handler checks instanceof Element against the page (:1976); reading the panel's ownerDocument.activeElement and checking the node type instead, with a second-document regression test beside Popover's, fixes it. cixzhang's changes-requested review stays open until they review again.

[Automated review]

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

I'm pausing this work for now to reduce contention on the repo. Thank you for the reviews and the spec guidance.

@vjeux vjeux closed this Oct 30, 2026

This branch was successfully deployed

1 active deployment
Preview — b7606a97 Deployed Oct 27, 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