Skip to content

Add @microbit/ui-carousel package - #118

Merged
microbit-robert merged 29 commits into
mainfrom
carousel
Sep 3, 2026
Merged

Add @microbit/ui-carousel package#118
microbit-robert merged 29 commits into
mainfrom
carousel

Conversation

@microbit-robert

@microbit-robert microbit-robert commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Extract common carousel component which will be used across apps that support multiple projects.

Currently exports three components, although only CarouselRow would be used as things stand.

Exports

  • CarouselRow - heading with optional actions and carousel below
  • Carousel - the common carousel row of 260px wide cards with responsive slides-per-page based on the window width
  • SwiperCarousel - a thin Swiper wrapper with list semantics, translated ARIA annotations and edge-pinned
    prev/next buttons. Swiper props pass through.

Deliberate changes from the app copies

  • containerLabel (pre-translated string) replaces containerMessageId

  • title/actions are ReactNodes

  • The three internal aria strings ship in this package's catalogs under ui-carousel. ids (seeded from the apps' existing translations; ar/cy/it are English backfill until Crowdin).

  • RTL follows useLocale() instead of being hardcoded to false and the carousel direction updates dynamically

  • isNativePlatform() prop removed, apps can pass navigation={false} instead

  • The _shortHeight padding override moves to the CreateAI call site

  • Ensure that slidesPerGroup is never calculated as 0

  • Disable Swiper's scrollOnFocus and use our own focus handling which ensures that cards aren't hanging off the page

  • Fix stale slidesPerGroup by running swiper.updateSlides() when this is changed in recalculateBreakpoints

  • CarouselRow's heading names the carousel via aria-labelledby and titleSuffix renders additional elements (e.g. tooltips) outside the accessible name. Naming is enforced by typecheck.

    Accessibility fixes

  • Fix invalid HTML and remove presentation roles. We no longer use ul and li and have gone back to divs / groups. This is because we focus the card buttons or links, never the listitems themselves, so we don't get the list semantics for free - therefore, keep the slide of total slides labels.

  • Prev/next buttons are named and exposed to assistive tech (voice control can activate them) but stay out of the tab order. The keyboard path is tabbing through the cards themselves. The prev/next buttons now managed by the component rather than Swiper's navigation module, which was fighting our tab-index attribute.

  • prefers-reduced-motion makes all carousel movement instant

  • Autoplay module removed. It was unused and there was no pause control

  • Decorative chevrons are aria-hidden and darkened (gray.500 → gray.800) to improve contrast

Post-merge actions

  • Upload packages/ui-carousel/lang/ui.en.json to Crowdin as new/packages/ui-carousel/ before the next translation sync

New source-shipped workspace for the carousel currently duplicated in
CreateAI and the Python Editor homepage (on branch), modelled on ui-patterns.
Peers: @microbit/ui, swiper (^12–^14; the apps sit at 12 and 14),
react, react-intl. No components yet.
Ported from CreateAI's Panda implementation with the Python Editor's two
fixes folded in (touchStartPreventDefault for overlay dismissal; the
slidesPerGroup guard follows with Carousel). Deliberate API deltas: a
pre-translated containerLabel replaces the app catalog id, RTL follows
useLocale() instead of a hardcoded false, and the native-platform check
is gone — apps pass navigation={false} instead.
This is unused and has never been used in apps.
The release workflow routes a tag prefix to both the package directory and
the npm name, so directory = npm suffix is a repo invariant. Adds the
ui-carousel-vX.Y.Z tag case it enables.
Swiper refuses to start drags on its focusableElements list; with cards
fully covered by an overlay button, a card-face drag set isMoved without
capturing the translate baseline, and released as slideTo(0)
(swiper#5524). Exclude button/label from the list, and ignore
pointer-caused focus in the slide-focus handler so mouse-down can't
slide mid-gesture.
Exclude button/label from Swiper's focusableElements: it refuses drags
from them, and with cards fully covered by an overlay button a card-face
drag poisoned the gesture and snapped to slide 0 on release (swiper#5524,
live in CreateAI today). Drop touchStartPreventDefault={false}, a
Chakra-era workaround: react-aria overlays dismiss via pointer events,
and Swiper's default preventDefault suppresses native link/image drags
and mouse-focus side effects. Reset scrollLeft in the slide focus
handler: the browser scrolls the overflow-hidden container to reveal
focused elements, desyncing the transform-positioned view when tabbing
forward to a clipped card's trailing controls.
Comment on lines +10 to +15
export { default as Carousel, type CarouselProps } from "./Carousel";
export { default as CarouselRow, type CarouselRowProps } from "./CarouselRow";
export {
default as SwiperCarousel,
type SwiperCarouselProps,
} from "./SwiperCarousel";

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.

Or just CarouselRow?

@microbit-robert

Copy link
Copy Markdown
Contributor Author

Moving to draft as this will need some accessibility fixes.

@microbit-robert
microbit-robert marked this pull request as draft September 2, 2026 15:51
tag="ul" produced invalid list structure (li under a div wrapper,
announced as an empty list), and list semantics are wrong here anyway:
focus only ever lands on the cards' overlay controls, and screen
readers don't reliably announce ancestor list position on Tab. Swiper's
default group-per-slide markup with our translated "8 of 12" labels
announces on focus entering each slide; the container becomes a
labelled region. slideRole="presentation" conflicted with those labels.
The deleted "& ul" margin reset was also suppressing Swiper's own
margin-inline auto on .swiper; auto margins disable grid-item stretch,
so the carousel sized to its slides, measured itself as fitting, and
locked — no drag, no buttons. Reapply the reset by class.
aria-hidden buttons excluded voice-control users, and Swiper's a11y
handling kept re-adding tabindex="0" to the wired navigation elements —
a focusable aria-hidden element whenever an edge state changed. The
buttons now manage their own edge visibility from Swiper's events, carry
translated labels, and stay out of the tab order (the cards are the
keyboard path: every card contains a tab stop).
Forced through Swiper's speed param rather than CSS: a zeroed
transition-duration never fires the transitionend Swiper waits on.
Storybook indexes every named export of a stories file as a
story, so exampleCards rendered as a broken phantom entry.
SwiperCarousel no longer forces slide width; Carousel sizes the slide
directly, so items no longer need a single div root to be sized (a div
root still opts into equal-height stretching).
Swiper's own resize handler runs before ours (registered with priority),
so the grid was built with the previous group size — one discrete resize
(maximise, devtools dock) left paging stale until the next event.
handleSlideFocus owns focus-follows-slide (it also slides partially
visible cards into view); with both active they race from opposite
sides of a requestAnimationFrame.
Drop the caller-less padding and swiperWrapperClassName props, the
Pagination module and the navigation/pagination stylesheets (nothing
renders their elements since the buttons detached from Swiper), unused
--swiper-* vars and an inert alignItems style. ReactElement[] replaces
JSX.Element[] ahead of React 19 types; carouselMessage is re-exported
for parity with ui-patterns.
CarouselRow's heading now labels the carousel via aria-labelledby;
titleSuffix replaces titleElement for adornments, which stay outside the
accessible name. A union type requires exactly one naming (title,
containerLabel or ariaLabelledBy) at compile time, so an unnamed or
double-named carousel no longer typechecks.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: c5195c3
Status: ✅  Deploy successful!
Preview URL: https://63dd45b3.ui-2wg.pages.dev
Branch Preview URL: https://carousel.ui-2wg.pages.dev

View logs

The icons now pass contrast checks when the buttons are not
focused and have a semi-transparent background.
@microbit-robert
microbit-robert marked this pull request as ready for review September 3, 2026 15:28
@microbit-robert
microbit-robert merged commit fee04ce into main Sep 3, 2026
3 checks passed
@microbit-robert
microbit-robert deleted the carousel branch September 3, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants