Skip to content

fix(AppShell): with fill height, keyboard users can scroll an overflowing main region - #8111

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c10-scroll-region-keyboard
Oct 31, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c10-scroll-region-keyboard

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Siblings: #8066, #6314.

Why

With height="fill", a keyboard user can't scroll AppShell's main region when it holds nothing focusable: Tab skips it, so Page Down and the arrow keys do nothing. axe reports it as scrollable-region-focusable, a serious violation.

What led to it

Part of the touch-presentations program's work on AppShell scrolling (spec:AST-094), alongside #8066, which changes which element scrolls. Main was only the skip link's tabIndex={-1} target, which Tab never stops on.

Options and tradeoffs

  • Always make main a tab stop: simple, but adds a stop even when main fits, and an extra stop before its first link.
  • A stop only while main overflows, with Tab entry moving on to its first link or button: what spec:AST-025 FR12 asks; it costs measuring main, which happens only with fill.

Why this choice

The second follows FR12 and AppShell FR4: a viewport joins the tab order only while it overflows, and Shift+Tab skips it. A fitting main, and height="auto", keep tabIndex={-1}. Only Tab focus draws main's ring, inset and following the surface's rounded corner; a click, the skip link or a script's focus draws none.

Main has no inner content box, so an observer internal to AppShell watches main's direct children; the public useScrollableArea is unchanged, and its one-element form is a separate draft that waits on AST-025 OQ1. The AppShell record states the observation (PR1) and the ring (FR4), and drops the gap this closes. Main's stop is named by its main role alone, which the FR12 ruling allows for the single main.

How to check it

In Chromium, on the AppShell Playground story with height="fill" and 40 rows:

  1. Press Tab until focus reaches main: a ring appears inside its edge, and Page Down scrolls it. Before, Tab left the page.
  2. Add a link inside main: Tab lands on the link instead, and Shift+Tab from it skips main.
  3. Click into main, or use the skip link: no ring.
  4. Show fewer rows so main fits: main leaves the tab order.

Evidence

Before (main) and after (this PR):

main this PR

Tab, then Shift+Tab, with a link inside; the ring: Tab, then Shift+Tab Focus ring

Writing rubric results

  • R1 narration: the PR1, FR4 and verification-map edits are present tense; the closed current-state gap is removed, and the glance row counts four gaps.
  • R2 research: none in the record.
  • R3 evidence: the new verification cells name test files, not changes.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: none touched; AST-025 OQ1 stays open with its options.

@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 31, 2026 2:54am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review 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 31, 2026, 3:13 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. 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

AppShell (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 785B 785B 0B
Lines of Code 746 1020 -
Complexity Very High Very High (171) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
charts/Chart N/A 65.1KB N/A N/A 20B N/A
charts/ChartLegend N/A 54.4KB N/A N/A 20B N/A
charts/ChartTooltip N/A 54.4KB N/A N/A 20B N/A
core/AlertDialog 58.1KB 58.2KB +146B 12.6KB 12.6KB 0B
core/AppShell 67.6KB 70.1KB +2.5KB ⚠️ 11.8KB 11.9KB +29B
core/Banner 49.3KB 49.5KB +154B 10.1KB 10.1KB 0B
core/BaseTypeahead 55.9KB 56.1KB +142B 10.1KB 10.1KB 0B
core/BottomSheet 33.5KB 33.5KB +3B 7.4KB 7.4KB 0B
core/BreadcrumbItem 68.6KB 68.7KB +154B 11.3KB 11.3KB 0B
core/Button 44.6KB 44.7KB +152B 9.3KB 9.3KB 0B
core/Calendar 56.4KB 56.6KB +153B 10.2KB 10.2KB 0B
core/Carousel 51.9KB 52.1KB +141B 10.4KB 10.4KB 0B
core/ChatComposer 62.3KB 62.5KB +160B 11.0KB 11.0KB 0B
core/ChatComposerInput 57.1KB 57.3KB +148B 10.2KB 10.2KB 0B
core/ChatComposerTokenElement 57.1KB 57.3KB +147B 10.2KB 10.2KB 0B
core/ChatDictationButton 48.0KB 48.2KB +165B 9.6KB 9.6KB 0B
core/ChatLayout 50.5KB 50.7KB +159B 10.1KB 10.1KB 0B
core/ChatLayoutScrollButton 48.0KB 48.1KB +158B 9.8KB 9.8KB 0B
core/ChatMessageList 29.4KB 29.5KB +148B 7.8KB 7.8KB 0B
core/ChatSendButton 47.3KB 47.4KB +169B 9.3KB 9.3KB 0B
core/ChatToolCalls 32.5KB 32.6KB +146B 9.1KB 9.1KB 0B
core/CheckboxInput 51.5KB 51.6KB +175B 9.5KB 9.5KB 0B
core/CheckboxList 46.8KB 46.9KB +153B 8.4KB 8.4KB 0B
core/CheckboxListItem 55.9KB 56.0KB +144B 10.8KB 10.8KB 0B
core/CodeBlock 61.6KB 61.8KB +160B 10.9KB 10.9KB 0B
core/CommandPalette 53.1KB 53.3KB +137B 12.0KB 12.0KB 0B
core/CommandPaletteInput 32.9KB 33.1KB +144B 8.3KB 8.3KB 0B
core/ComplexSelector 57.4KB 57.6KB +179B 10.8KB 10.8KB 0B
core/ContextMenu 76.2KB 76.4KB +141B 15.1KB 15.1KB 0B
core/DateInput 101.3KB 101.5KB +162B 15.0KB 15.0KB 0B
core/DateRangeInput 71.2KB 71.4KB +159B 11.4KB 11.4KB 0B
core/DateTimeInput 111.4KB 111.6KB +174B 15.4KB 15.4KB 0B
core/DialogHeader 50.4KB 50.6KB +161B 10.0KB 10.0KB 0B
core/DrawerHeader 50.5KB 50.7KB +171B 11.4KB 11.4KB 0B
core/DropdownMenu 74.2KB 74.4KB +158B 15.2KB 15.2KB 0B
core/DropdownMenuSubMenu 59.9KB 60.0KB +137B 9.8KB 9.8KB 0B
core/Field 44.9KB 45.1KB +156B 8.1KB 8.1KB 0B
core/FileInput 55.7KB 55.8KB +162B 10.5KB 10.5KB 0B
core/Heading 7.7KB 7.9KB +151B 5.9KB 5.9KB 0B
core/IconButton 44.6KB 44.8KB +164B 9.3KB 9.3KB 0B
core/InputGroup 45.5KB 45.7KB +157B 8.2KB 8.2KB 0B
core/LayerProvider 65.0KB 65.1KB +131B 10.3KB 10.3KB 0B
core/Lightbox 52.8KB 52.9KB +131B 10.0KB 10.0KB 0B
core/Link 45.1KB 45.2KB +149B 7.8KB 7.8KB 0B
core/ListItem 55.3KB 55.5KB +164B 10.8KB 10.8KB 0B
core/Markdown 146.4KB 146.6KB +172B 18.0KB 18.0KB 0B
core/MobileNav 52.6KB 52.7KB +140B 10.2KB 10.2KB 0B
core/MobileNavToggle 47.8KB 47.9KB +139B 9.6KB 9.6KB 0B
core/MoreMenu 74.5KB 74.6KB +146B 15.2KB 15.2KB 0B
core/MultiSelector 74.3KB 74.4KB +152B 14.8KB 14.8KB 0B
core/NumberInput 57.3KB 57.5KB +157B 10.9KB 10.9KB 0B
core/Pagination 86.6KB 86.8KB +161B 15.6KB 15.6KB 0B
core/Popover 51.1KB 51.2KB +143B 12.2KB 12.2KB 0B
core/PowerSearch 168.2KB 168.3KB +150B 20.1KB 20.1KB 0B
core/RadioList 46.3KB 46.4KB +151B 8.2KB 8.2KB 0B
core/RadioListItem 53.9KB 54.1KB +155B 9.9KB 9.9KB 0B
core/ScrollableArea 10.7KB 10.7KB +4B 4.3KB 4.3KB 0B
core/Selector 78.3KB 78.4KB +171B 15.3KB 15.3KB 0B
core/SideNav 65.6KB 65.7KB +148B 10.7KB 10.7KB 0B
core/SideNavCollapseButton 48.2KB 48.4KB +156B 9.6KB 9.6KB 0B
core/SideNavHeading 60.1KB 60.2KB +154B 10.4KB 10.4KB 0B
core/SideNavItem 56.2KB 56.3KB +134B 10.3KB 10.3KB 0B
core/Slider 49.2KB 49.4KB +156B 8.7KB 8.7KB 0B
core/Spinner 25.3KB 25.5KB +155B 6.7KB 6.7KB 0B
core/Stepper 49.0KB 49.2KB +143B 9.8KB 9.8KB 0B
core/Switch 49.8KB 50.0KB +152B 9.5KB 9.5KB 0B
core/TabMenu 56.8KB 57.0KB +149B 10.3KB 10.3KB 0B
core/Table 85.9KB 86.1KB +170B 15.8KB 15.8KB 0B
core/TableCell 77.5KB 77.6KB +141B 15.5KB 15.5KB 0B
core/TableHeaderCell 77.1KB 77.2KB +132B 15.3KB 15.3KB 0B
core/TableSelectionToolbar 53.7KB 53.9KB +143B 11.3KB 11.3KB 0B
core/Text 7.2KB 7.4KB +145B 5.9KB 5.9KB 0B
core/TextArea 50.1KB 50.2KB +142B 9.6KB 9.6KB 0B
core/TextInput 55.1KB 55.3KB +170B 10.6KB 10.6KB 0B
core/Thumbnail 52.2KB 52.4KB +143B 11.2KB 11.2KB 0B
core/TimeInput 88.8KB 89.0KB +160B 13.9KB 13.9KB 0B
core/Timer 9.3KB 9.4KB +152B 6.8KB 6.8KB 0B
core/Timestamp 27.9KB 28.1KB +165B 9.7KB 9.7KB 0B
core/Toast 61.4KB 61.6KB +149B 9.9KB 9.9KB 0B
core/ToggleButton 45.4KB 45.5KB +165B 9.4KB 9.4KB 0B
core/Tokenizer 70.1KB 70.2KB +138B 16.7KB 16.7KB 0B
core/TopNav 53.5KB 53.6KB +143B 10.5KB 10.5KB 0B
core/TopNavHeading 59.5KB 59.6KB +141B 10.2KB 10.2KB 0B
core/TopNavMegaMenu 55.9KB 56.1KB +164B 11.0KB 11.0KB 0B
core/TopNavMenu 59.2KB 59.3KB +149B 10.3KB 10.3KB 0B
core/Typeahead 63.1KB 63.2KB +150B 11.5KB 11.5KB 0B
lab/ChatEmojiPicker N/A 67.1KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 67.6KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 95.9KB N/A N/A 1.9KB N/A
lab/CommentThread N/A 218.5KB N/A N/A 2.4KB N/A
lab/Comments N/A 223.0KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.5KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 65.1KB N/A N/A 722B N/A
lab/ListInput N/A 76.9KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 155.0KB N/A N/A 1.2KB N/A
lab/MobileTokenizer N/A 94.3KB N/A N/A 880B N/A
lab/Schedule N/A 61.8KB N/A N/A 3.9KB N/A
lab/TourStep N/A 56.6KB N/A N/A 590B N/A
lab/TransferList N/A 69.0KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 74.4KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.7KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 214.6KB N/A N/A 454B N/A
richtext/RichTextView N/A 252.1KB N/A N/A 2.3KB 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.

⚠️ 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/AppShell +2.5KB ⚠️ +29B

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 35.6KB 35.7KB +18B

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


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.

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it in Chromium, WebKit and Firefox, while a main that fits stays out of the tab order. Approval waits on an engineering owner settling two spec points: whether main may be a Tab stop named only by its main landmark role, with the inset focus ring (AST-025 FR12 asks for an accessible name, so either FR12 is updated or main gets one), and whether useScrollableArea may publicly accept one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Separately, please fix:

  • The changeset and the AppShell children doc say pages with interactive content keep their tab order, but a main that starts with a form field or a menu button gains one stop.
  • AppShell.tsx keeps main's DOM node in React state, which react-component-runtime INV1 rules out; a ref is enough.
  • At height="auto" main is still observed although it can never become a stop, so attach the ref only for fill.

[Automated review]

'@astryxdesign/core': patch
---

[fix] With `height="fill"`, AppShell's main region can be scrolled from the keyboard when its content overflows and has nothing focusable (WCAG 2.1.1 Keyboard). Before, main was only the skip link's target (`tabIndex={-1}`): Tab passed it by, so long text, a report or a read-only page could not be scrolled without a pointer. Now, while main scrolls overflowing content, it is a stop in the tab order and keyboard focus on it draws the standard focus ring, inset. Tab entry moves on to its first link or button when there is one, so pages with interactive content keep their tab order. A main region that fits, and `height="auto"` (where the page scrolls), are unchanged. `useScrollableArea` also accepts one element as both viewport and content box, and then observes that element's direct children.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tab entry moves on only when the first focusable element inside main is a plain link or button (AST-025 FR12); with a form field or a menu button first, main is one extra stop before it, so "pages with interactive content keep their tab order" overstates it. Suggest: "Tab entry moves on when the first focusable element inside main is a plain link or button; otherwise main is one stop before 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 cbe2a00: the changeset now says Tab entry moves on when the first focusable element inside main is a plain link or button, and that otherwise main is one stop before it.

required: true,
description:
'children, rendered in the main landmark. Scrolls internally when height is fill, and with the page when it is auto.',
'children, rendered in the main landmark. Scrolls internally when height is fill, and with the page when it is auto. While it scrolls content that overflows, it is a stop in the tab order, and Tab entry moves on to its first link or button when it has one.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same wording issue as the changeset: delegation happens only when the first focusable element in main is a plain link or button, not whenever main contains one.

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 cbe2a00: the children doc now says the same: delegation happens only when main's first focusable element is a plain link or button, and otherwise main is one stop before it.

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
keyboardAccess: {owner: 'content'},
stickyContainment: 'always',
});
const [mainElement, setMainElement] = useState<HTMLDivElement | null>(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.

This keeps main's DOM node in React state, which architecture:react-component-runtime INV1 rules out ("DOM nodes and whole DOM collections MUST NOT be stored in React state"). A ref that re-attaches the delegation when the node is replaced (INV6), or letting the hook attach it, avoids the state.

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 cbe2a00: main's node now lives in a callback ref, not React state. The keyboard delegation is attached while main is a scroll region and released when the node is detached or replaced, before a new node takes over.

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated

const mainInner = (
<LayoutContent
ref={mainRef}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At height="auto" this ref still starts the hook's observers on main (a resize observer per direct child, a subtree mutation observer, a measurement after each mutation frame) although isMainScrollRegion can never be true there; passing the ref only when isFill avoids that (react-component-runtime INV5).

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 cbe2a00: only height="fill" passes the scroll hook's ref, so at "auto" nothing observes main. A unit test counts the observed targets inside main: none at auto, some with fill. It fails on the previous head.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region with nothing focusable in it and Page Down scrolls it in Chromium and WebKit, while a main that fits keeps its old behavior. Approval waits on an engineering-owner decision on two spec points: whether main may be a Tab stop named only by its main landmark role, with the inset focus ring (AST-025 FR12 asks for an accessible name, so either FR12 is updated or main gets one), and whether useScrollableArea may publicly accept one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently, please:

  • Reword the changeset and the AppShell children doc, because a main whose first focusable element is a form field or a menu button now takes one extra Tab stop.
  • Own main's DOM node through a ref instead of React state in AppShell.tsx, as react-component-runtime INV1 requires.
  • Attach the hook's ref only for height="fill", since at auto main is observed although it can never become a stop.
  • Rebase onto main, because pr-a11y currently stops before the new AppShell step on a spec this branch predates.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region with nothing focusable in it and Page Down scrolls it in Chromium, Firefox and WebKit, while a main that fits keeps its old tab order. Approval waits on an engineering-owner decision on three spec points: whether main may be a Tab stop named only by its main landmark role (AST-025 FR12 asks for an accessible name), whether the inset focus ring and the new rounding of main's top-start corner, which also cuts content on fitting and auto mains, are the intended treatment, and whether useScrollableArea may publicly accept one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently, please:

  • Reword the changeset and the AppShell children doc, because a main whose first focusable element is a form field now takes one extra Tab stop, and fitting and auto mains change at their top-start corner.
  • Own main's DOM node through a ref instead of React state in AppShell.tsx, as react-component-runtime INV1 requires.
  • Attach the hook's ref only for height="fill", since at auto main is observed although it can never become a stop.

[Automated review]

'@astryxdesign/core': patch
---

[fix] With `height="fill"`, AppShell's main region can be scrolled from the keyboard when its content overflows and has nothing focusable (WCAG 2.1.1 Keyboard). Before, main was only the skip link's target (`tabIndex={-1}`): Tab passed it by, so long text, a report or a read-only page could not be scrolled without a pointer. Now, while main scrolls overflowing content, it is a stop in the tab order and keyboard focus on it draws the standard focus ring, inset and following the rounded corner of the elevated surface. Tab entry moves on to its first link or button when there is one, so pages with interactive content keep their tab order. A main region that fits, and `height="auto"` (where the page scrolls), are unchanged. `useScrollableArea` also accepts one element as both viewport and content box, and then observes that element's direct children.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two parts of this note don't match the code. Tab entry moves on only when the first focusable element inside main is a plain link or button (AST-025 FR12); with a form field or a menu button first, main is one extra stop before it. And since the corner commit, a fitting main and height="auto" in an elevated shell with a top nav and an inline side nav round off content at their top-start corner, so they are not unchanged. Suggest: "Tab entry moves on when the first focusable element inside main is a plain link or button; otherwise main is one stop before it", plus a line for the corner if it stays.

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 cbe2a00: the Tab-entry wording is as suggested. The corner now applies only while main is a keyboard stop, so fitting and auto mains are unchanged, and the changeset's corner line says that. The ring treatment stays part of the owner decision.

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
xstyle={[contentAreaStyle, styles.mainFocusTarget]}>
xstyle={[
contentAreaStyle,
shouldElevateWithCorner && styles.mainElevatedCorner,

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 rounding applies whenever the shell elevates with a corner, at both heights and whether or not main overflows, so content touching main's top-start corner (a full-bleed header or image) is now cut round on pages where main is never a stop and no ring is drawn. If the goal is only the ring's shape, limiting it to isMainScrollRegion keeps fitting and auto mains as they were; either way it is part of the owner decision on the ring.

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 cbe2a00: the rounding now applies only while main is a scroll region (a keyboard stop), where it shapes the focus ring. Fitting and auto mains keep their corner, and the fitting-main browser test now asserts a square corner; it fails on the previous build with 28px.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux
vjeux force-pushed the uikit/c10-scroll-region-keyboard branch from 457d6f8 to cbe2a00 Compare October 24, 2026 22:29
@vjeux
vjeux force-pushed the uikit/c10-scroll-region-keyboard branch from cbe2a00 to 8910dfe Compare October 24, 2026 22:47
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Observers. Use the shared observers (utils/sharedResizeObserver.ts) per react-component-runtime INV7.

Agreed, the observers belong to the shared owner. In b7aa07f, useScrollableArea observes mutations through utils/sharedMutationObserver.ts: the new observeMutations(element, options, callback) keeps one observer per set of options for the whole page (observeContent is now its text-and-children form), so the hook no longer creates a MutationObserver of its own. Its resize observation already goes through observeResize. A test renders three scroll areas and counts at most two observers.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it, while a main that fits, the skip link and height="auto" behave as before. Approval waits on an engineering-owner decision on four spec points: updating AST-025 FR12 for a Tab stop named only by its main landmark role, updating AppShell PR1 for observing main in fill, and providing or identifying the governing spec for the inset focus ring with its rounded corner and for useScrollableArea accepting one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. One change is needed either way:

  • The [fix] changeset also announces useScrollableArea's new one-element shape, a compatible capability that AST-017 FR7 declares as [feat], so move that sentence into its own [feat] changeset if the shape stays, or drop it if the shape goes.

[Automated review]

'@astryxdesign/core': patch
---

[fix] With `height="fill"`, AppShell's main region can be scrolled from the keyboard when its content overflows and has nothing focusable (WCAG 2.1.1 Keyboard). Before, main was only the skip link's target (`tabIndex={-1}`): Tab passed it by, so long text, a report or a read-only page could not be scrolled without a pointer. Now, while main scrolls overflowing content, it is a stop in the tab order and keyboard focus on it draws the standard focus ring, inset; in an elevated shell with a top nav and an inline side nav, main then takes the surface's rounded top-start corner so the ring follows it. Tab entry moves on when the first focusable element inside main is a plain link or button; otherwise main is one stop before it. A main region that fits, and `height="auto"` (where the page scrolls), are unchanged. `useScrollableArea` also accepts one element as both viewport and content box, and then observes that element's direct children.

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 [fix] note also announces a new public capability: useScrollableArea accepting one element as both viewport and content box. AST-017 FR7 declares compatible capabilities as [feat] (still a patch bump on 0.x), as main's CodeBlock endContent changeset does. If the one-element shape stays after the owner decision on AST-025 OQ1, move this sentence into its own [feat] changeset; if it goes, drop the sentence.

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.

Moved in d7317a2: the [fix] note no longer mentions useScrollableArea's one-element shape, which now has its own [feat] changeset, scrollable-area-single-element.md (AST-017 FR7). If the owner decision on AST-025 OQ1 drops the shape, that changeset goes with it.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it in Chromium, Firefox and WebKit, while a main that fits, the skip link and height="auto" behave as before, and no further code change is needed. Approval waits on an engineering-owner decision on four spec points: updating AST-025 FR12 for a Tab stop named only by its main landmark role, updating AppShell PR1 for observing main in fill, and providing or identifying the governing spec for the inset focus ring with its rounded corner and for useScrollableArea accepting one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; a point an owner declines instead needs its matching code change, such as a catalog-backed name for main or an internal content box.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…names

Byte-identical copies of each clip and still, named by pull request only.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:31

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it, while a main that fits, the skip link and height="auto" behave as before, and no further code change is needed. Approval waits on an engineering-owner decision on four spec points: updating AST-025 FR12 for a Tab stop named only by its main landmark role, updating AppShell PR1 for observing main in fill, and providing or identifying the governing spec for the inset focus ring with its rounded corner and for useScrollableArea accepting one element as its own content box (the option AST-025 OQ1 leaves open). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; a point an owner declines instead needs its matching code change, such as a catalog-backed name for main or an internal content box.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it to the end in Chromium and WebKit, and no code fix is outstanding. Approval waits on an engineering-owner decision on four points the current specs do not settle: main's stop is named only by its main landmark role where AST-025 FR12 asks for an accessible name, AppShell now observes main in fill where AppShell PR1 says it observes nothing, no spec covers the inset focus ring with its rounded corner, and AST-025 OQ1 still leaves the public one-element useScrollableArea shape open. Any engineering owner, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or say which point needs a code change instead (a catalog-backed name, another indicator, or an internal content box), with the records on main updated to match.

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

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it to the end in Chromium and WebKit, and its role-only name is now covered by AST-025 FR12. Approval waits on an engineering-owner decision on the two AppShell record amendments this pull request makes: PR1 now lets AppShell observe main, its direct children and its subtree in fill, and FR4 now adds an inset Tab focus ring with a rounded corner that no current spec covers. Any engineering owner, the author included, can record that decision by commenting /approve-spec <full head SHA> on this pull request, or say which point needs a code change instead. Separately, the FR6/PR1 verification row in AppShell.spec.md still says measuring in fill fails; it should say that measuring the header there, or not measuring main, fails.

[Automated review]

| FR1–FR4, AR1 | `AppShell.test.tsx` slot, landmark, banner, and ref suites; `AppShellMainKeyboard.a11y.browser.spec.ts` for main as a tab stop and its focus ring | Content only; each slot; mobile top bar with banner; Tab, Shift+Tab, click and skip link onto an overflowing main | A missing or duplicate landmark, or a nested banner, fails role queries. | `audit:AppShell/anatomy` |
| FR5, AR2 | `AppShell.test.tsx` skip-link and keyboard suites | First Tab; Enter; click; translated text | Losing the href, the focus move, or tab order fails. | `audit:AppShell/accessibility` |
| FR6, PR1 | `AppShell.test.tsx` height-mode suites | `fill`, `auto`, section header in `auto` | Dropping the header's sticky placement or measuring in `fill` fails. | `audit:AppShell/behavior` |
| FR6, PR1 | `AppShell.test.tsx` height-mode and main keyboard access suites; `observeMainOverflow.test.ts` | `fill`, `auto`, section header in `auto`; main fitting and overflowing in `fill` | Dropping the header's sticky placement or measuring in `fill` fails. | `audit:AppShell/behavior` |

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 row now lists the main-overflow tests, but its failure expectation still reads "measuring in fill fails", while the rewritten PR1 above requires measuring main in fill. Suggest: "Dropping the header's sticky placement, measuring the header in fill, or not measuring main there fails."

…wing main region

With `height="fill"`, main becomes a sequential tab stop while its content
overflows (spec:AST-025 FR12), and Tab focus on it draws the standard ring,
inset (component:AppShell FR4). AppShell measures main through an internal
observer that watches main's direct children, since main has no inner
content box; the public useScrollableArea is unchanged. The AppShell record
states the observation in PR1 and the ring in FR4.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 399ea1a

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 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.

The keyboard fix works: with height="fill", Tab now reaches an overflowing main region and Page Down scrolls it to the end in Chromium and WebKit, while the skip link and a click stay ringless. The AppShell record changes (PR1 and FR4) carry an engineering owner's spec approval at this head, and the verification row now matches the rewritten PR1. No further changes are needed from this review; CI's build and test jobs still have to run once the pull request leaves draft.

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 04:06
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…e it overflows (#8155)

## User impact

A keyboard user who tabs onto a Carousel's scroll area lands on an element with no role and no name, so a screen reader announces nothing. The same unnamed stop appears even when everything fits. The edge buttons hand focus here when their direction runs out (component:Carousel FR2).

## Evidence

Chromium, keys captioned: Tab rings the › button, a second Tab focuses the scroll area, → → → ← scroll it. Its ring is the browser's default outline.

![Chromium, keyboard only: Tab reaches the scroll area, the arrows scroll it](https://raw.githubusercontent.com/vjeux/astryx/136eab094dbb72fc2b8cdbeed5e447170c553ee6/carousel-scroll-area-keyboard.gif)

- Before: the binding's `scroller-keyboard` state failed `carousel.scroller.role-and-name`, recorded as a known failure. After: it passes 8/8 with no known failures, and axe reports 0 violations on every Carousel story.
- New unit tests fail without the change: the named group, the tab stop following overflow, and the tab stop following slides added, removed or grown with the box unchanged. Another pins the translated default name.
- In Chromium and WebKit, before the re-measure, a fixed-width carousel's tab stop went stale as its content changed, and Scroll right stayed disabled after a slide was added at the end; both follow now.

## Problem and solution fit

- The scroll area is `role="group"`, named like the carousel (the caller's `aria-label`, else the translated "Carousel"), so landing on it says where the user is.
- It is a tab stop (`tabIndex={0}`) only while its content overflows; while it fits it is `tabIndex={-1}`, which keeps the edge buttons' programmatic hand-off (FR2) working.
- The overflow is measured again when the content changes inside an unchanged box: `useScrollOverflow` now observes each child's box, children added or removed, and the container's class and style (`spec:AST-025` FR5). The edge buttons and fades, and TabList's fades, follow the same measure.

## Expected behavior and authority

`spec:AST-025` FR12: a viewport that takes keyboard scrolling joins the tab order only while it overflows, with a role and an accessible name; as #9088 states it, a `group` named by the component that owns it, the caller's label, else its translated default. `component:Carousel` FR2 names it the hand-off target, out of the tab order when the content fits.

## Smallest restoration

- Three attributes on the scroll area; `useScrollOverflow` also observes the content, with its API unchanged.
- `Carousel.doc.mjs` and the Carousel record's review table describe the new behavior; the binding's known failure is removed.
- Tab entry does not delegate to a first link or button (FR12's optional path); that needs #8111's shared delegation.

## Writing rubric results

R1 narration: a projection row called the gap pending; it now states the behavior. R2–R6: none.

## Scope

- [x] One defect is restored.
- [x] Separable contradictory or unsettled tagalongs were removed or split.
- [x] No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

Local gates at a2184a1:

- `pnpm exec vitest run packages/core/src/Carousel packages/core/src/hooks/useScrollOverflow.test.tsx packages/core/src/TabList`: 5 files, 158 tests passed.
- `pnpm exec playwright test packages/core/src/Carousel/__tests__/Carousel.a11y.chromium.spec.ts`: 8 passed.
- `pnpm a11y:audit -- --components core/Carousel`: 0 violations, gate passed.
- `pnpm test:ui`: 16,462 passed. The node project: 14,563 passed; its only failures are `packages/cli/foundation/release/cache.test.mjs` (fixed by #8520) and `apps/storybook/.storybook/story-tree.test.ts` (#8509), unrelated to this change.
- Root build and typecheck, `CI=true pnpm lint`, `pnpm check:knowledge`, `pnpm check:changesets` and its coverage, the Storybook build, the interaction config (136 passed), `pnpm check:repo`, Prettier: clean.
- Size: 10 files, 321 lines added, 43 removed.

Sibling: #7906 and #8153 also edit Carousel.tsx; whichever lands second rebases.
@cixzhang
cixzhang merged commit 00507ba into meta:main Oct 31, 2026
100 checks passed
@vjeux
vjeux deleted the uikit/c10-scroll-region-keyboard branch October 31, 2026 05:06

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

3 participants