Skip to content

fix(Carousel): the scroll area is a named group, a tab stop only while it overflows - #8155

Merged
cixzhang merged 5 commits into
meta:mainfrom
vjeux:uikit/c23-carousel-scroll-area
Oct 31, 2026
Merged

cixzhang merged 5 commits into
meta:mainfrom
vjeux:uikit/c23-carousel-scroll-area

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

  • 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

Writing rubric results

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

Scope

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

Testing

Local gates at a2184a1:

Sibling: #7906 and #8153 also edit Carousel.tsx; whichever lands second rebases.

@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 Error Error Oct 31, 2026 2:24am 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 31, 2026, 4:35 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

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview.

Modified Components

Carousel (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 272B 272B 0B
Lines of Code 486 488 -
Complexity Very High Very High (64) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/Carousel 51.9KB 52.0KB +162B 10.4KB 10.4KB 0B
core/TabList 40.8KB 40.9KB +141B 7.3KB 7.3KB 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.

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.6KB 0B

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 | View full report

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:32

@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 one engineering-owner decision and one fix. No current spec chooses the scroll area's accessible name, so an engineering owner needs to accept the carousel's own label, or name the source the scroll area should use, by commenting /approve-spec <full head SHA> on this pull request; the author can record that decision as an engineering owner. Separately, tabIndex={hasOverflow ? 0 : -1} relies on useScrollOverflow, which re-measures only when the scroll area itself scrolls or resizes, so slides added after mount inside an unchanged box (for example a one-slide-per-view carousel fed by async data) leave an overflowing scroll area out of the tab order with both edge buttons hidden: please re-measure when the content changes, keep -1 while it fits for the edge-button hand-off, and add a test that adds slides without a scroll or resize. The accessibility guidance in Carousel.doc.mjs should also stop describing the scroll area as an unconditional tab stop.

[Automated review]

// programmatically, for the edge buttons' hand-off (FR2).
role="group"
aria-label={ariaLabel}
tabIndex={hasOverflow ? 0 : -1}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hasOverflow comes from useScrollOverflow, which re-measures only on this element's own scroll events and own-box resize. Slides appended after mount inside an unchanged box (a one-slide-per-view carousel fed by async data) never flip it: in Chromium, 1280px of content in a 320px area stays at tabindex="-1" with both edge buttons hidden, so Tab skips the carousel and the arrow keys never reach it, where the base kept a reachable stop. spec:AST-025 FR12 requires overflowing content to stay keyboard reachable and leaves geometry to FR5 ("A viewport-only ResizeObserver is insufficient because content may grow while the viewport's own box remains unchanged"). Re-measuring when the slides change (or reading the shared useScrollableArea measurement) while keeping -1 for the fitting case would close it; a test that rerenders with more slides and a wider scrollWidth without firing scroll would pin 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.

Approval waits on one engineering-owner decision and one fix. No current spec chooses the scroll area's accessible name, so an engineering owner needs to accept the carousel's own label, or name the source the scroll area should use, by commenting /approve-spec <full head SHA> on this pull request; the author can record that decision as an engineering owner. Separately, tabIndex={hasOverflow ? 0 : -1} relies on useScrollOverflow, which re-measures only when the scroll area itself scrolls or resizes, so slides added after mount inside an unchanged box (for example a one-slide-per-view carousel fed by async data) leave an overflowing scroll area out of the tab order with both edge buttons hidden: please re-measure when the content changes, keep -1 while it fits for the edge-button hand-off, and add a test that adds slides without a scroll or resize. The accessibility guidance in Carousel.doc.mjs should also stop describing the scroll area as an unconditional tab stop.

[Automated review]

// programmatically, for the edge buttons' hand-off (FR2).
role="group"
aria-label={ariaLabel}
tabIndex={hasOverflow ? 0 : -1}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hasOverflow comes from useScrollOverflow, which re-measures only on this element's own scroll events and own-box resize. Slides appended after mount inside an unchanged box (a one-slide-per-view carousel fed by async data) never flip it: in Chromium, 1280px of content in a 320px area stays at tabindex="-1" with both edge buttons hidden, so Tab skips the carousel and the arrow keys never reach it, where the base kept a reachable stop. spec:AST-025 FR12 requires overflowing content to stay keyboard reachable and leaves geometry to FR5 ("A viewport-only ResizeObserver is insufficient because content may grow while the viewport's own box remains unchanged"). Re-measuring when the slides change (or reading the shared useScrollableArea measurement), while keeping -1 for the fitting case, would close it; a test that rerenders with more slides and a wider scrollWidth without firing scroll would pin 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 a2184a1: useScrollOverflow now also observes each slide's box, slides added or removed, and the scroll area's class and style (spec:AST-025 FR5), so slides appended inside an unchanged box make it a tab stop and show the edge buttons, and it stays -1 while everything fits. The new tests add and grow slides without a scroll or resize, and the doc's guidance is updated. Naming follows FR12 as #9088 states it, with a test for the translated default.

… area's own size

The scroll area is a tab stop only while its content overflows, and its
edge buttons and fades follow the same measure. The content can come to
overflow, or stop, with the scroll area keeping its size. These fail on
`useScrollOverflow` as it stands, which measures only on the container's
own scroll and resize:

- a child added or removed, a child that resizes, and a new class or
  style on the container (a gap) each measure again;
- once the container detaches, nothing observes its content;
- Carousel's tab stop follows slides added or removed and a slide that
  grows, with the scroll area keeping its size.
vjeux added 3 commits October 30, 2026 18:58
…nly the container

`useScrollOverflow` measured on the container's own scroll and resize, so
content that came to overflow, or stopped, with the container keeping its
size left the state stale: a fixed-width Carousel kept or lost its tab
stop wrongly, and a Carousel scrolled to its end kept Scroll right
disabled after a slide was added.

The hook now also observes each child's size (a slide's content growing),
the children added or removed, and the container's class and style (a new
gap or padding), and measures again on each, still only while the
container is rendered. Every observation ends when the container
detaches.
…overflows

The accessibility guidance said the scroll container is a tab stop. It
is one while its content overflows, so keyboard users can pan it with the
arrow keys; while everything fits it stays out of the tab order.
…lt name

A Tab-able scroll area is a group named by the component that owns it:
the caller's label, else that component's translated default. The
caller's label was tested; this pins the default, so a carousel with no
`aria-label` still names its scroll area "Carousel".
@vjeux
vjeux force-pushed the uikit/c23-carousel-scroll-area branch from b85821e to a2184a1 Compare October 31, 2026 02:16
@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 03:49
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec a2184a1

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

This looks ready to merge: the Carousel scroll area is now a named group that joins the tab order only while it overflows, and slides added or grown inside an unchanged box now make it a tab stop and bring back the edge buttons, which the new tests and a Chromium check confirm. The naming follows the current scrollable-container spec (spec:AST-025 FR12), and the Carousel record edit has an engineering owner's approval on this head. No changes are needed.

[Automated review]

@cixzhang
cixzhang merged commit 26784d2 into meta:main Oct 31, 2026
102 of 103 checks passed
@vjeux
vjeux deleted the uikit/c23-carousel-scroll-area branch October 31, 2026 05:06

This branch had an error being deployed

1 failed deployment
Preview — a2184a1e 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants