Skip to content

feat(sve-ui): add Carousel and Resizable - #83

Merged
rodriabregu merged 3 commits into
mainfrom
feat/tier2-carousel-resizable
Sep 1, 2026
Merged

rodriabregu merged 3 commits into
mainfrom
feat/tier2-carousel-resizable

Conversation

@rodriabregu

Copy link
Copy Markdown
Owner

What & why

The Tier 2 gaps from the same catalog diff that produced Kbd, ButtonGroup, Empty and InputGroup. Both hand-built — Bits UI ships neither — and neither adds a dependency; the package still has exactly one.

These are harder than Tier 1 because both have real behaviour and a real accessibility contract, not just a styling problem.

Review path

Start with the two index.ts headers: they carry the reasoning, and in both components the refusals are the design.

Carousel/index.ts — CSS scroll-snap rather than transforms, which is what buys touch swiping, trackpad flicks, momentum and the browser's own arrow-key scrolling for free. Then CarouselRoot.svelte, and specifically why position is read from the scroll container on every scroll instead of remembered: a swipe, a flick and the arrow keys are all drivers the component cannot intercept, and a stored index would disagree with the screen after any of them — making the "3 of 5" slide label a lie.

Resizable/index.ts then ResizableHandle.svelte — the APG window-splitter is mostly a keyboard contract, and that is the half every hand-rolled divider skips.

Two deliberate refusals in Carousel

It does not loop. An infinite track makes "slide 3 of 5" meaningless — there is no third of five on a ring — and removes the only honest position affordance a carousel has: a Previous or Next that goes flat at the boundary. A sighted user loses a scrollbar; everyone else loses the ability to know they reached the end.

It does not auto-rotate. That is only acceptable with a visible pause control, and stopping on hover, and stopping on focus, and honouring prefers-reduced-motion. That chain is exactly what gets half-built, and content that moves on its own is one of the most documented accessibility failures on the web. useCarousel() exposes next, prev and goTo for anyone who takes on the obligation that comes with it.

One detail worth checking: Previous and Next are disabled from the measured scroll extent, not the slide index. With three slides visible the last index is reachable while there is still track to scroll, and a control disabled then would be lying.

The Resizable clamp

A drag moves one boundary and touches exactly two panes, clamped against both of their limits before anything is applied.

  • Clamping only the pane being grown lets its neighbour shrink past its own minimum.
  • Distributing a drag across the whole group is what makes resizing a sidebar quietly reflow the far side of a layout.

Both have a test that names them.

Three svelte-check warnings I acted on rather than silenced

  1. Pane captured min/max at init — a real bug. A consumer whose limits depend on state would change them and the group would keep clamping against the old values. They register as getters now, so registration stays one-time (the handle needs an index and id on first render) while the limits stay live.
  2. The a11y linter flags tabindex on a separator. It is right about a plain separator and wrong here: ARIA makes a separator a widget the moment it is focusable and carries aria-valuenow — that is the window-splitter pattern. Ignored with the reason written in the file, because removing the tab stop would delete the keyboard contract this component exists to honour. axe agrees — it passes the fixture.
  3. An even-split line had two unused callback args and read badly. It now recomputes from the new total, so registering a third pane redistributes the first two instead of leaving them at 50 each.

Mutation-verified

clamp only the pane being grown              → FAIL ✓
aria-orientation follows direction of travel → FAIL ✓
tab stop on the carousel viewport            → FAIL ✓
restored                                     → 18 passed

Changes

Area Change
components/Carousel/ 7 parts + context (Root, Viewport, Slide, Previous, Next, Indicators, Indicator)
components/Resizable/ Group, Pane, Handle + context
src/tests/ 18 new tests + 2 fixtures; both added to A11yFixture, which ssr.test.ts reuses — so each gets axe and server-render coverage
apps/docs/src/routes/components/{carousel,resizable} Two docs pages, including sections arguing the refusals
registry.ts, README.md, agent skill Carousel → Display, Resizable → Layout; table → 66
apps/example/src/routes/browse A preview shelf (several slides visible — the case that catches index-based disabling) and a list/detail split on the existing selected
render-baseline.json +2 pages, +4 elements per page with the sidebar (2 components × <a> + new tag)

Test plan

  • turbo run test --force — 683 tests across 78 files (was 665/76)
  • Mutation-verified (above)
  • pnpm lint && pnpm check green, 0 warnings; pnpm format:check clean
  • Package guards: check-dts 199 types, check-css-coverage 197 components, check-docs-coverage 66/66/66, check-readme-coverage 66, publint clean
  • check-example-coverage: all 68 exports rendered
  • check:render — 73 pages, baseline updated in this commit; the uniform +4 verified as 2 new nav entries × 2 elements before accepting it
  • Screenshot baselines for the two new pages — Linux-only, and a missing baseline is a failure. Triggering visual-baselines on this branch next.

Checklist

  • Tests added/updated and passing (pnpm test)
  • pnpm lint && pnpm check && pnpm build all green
  • Changeset included — minor
  • Docs updated — two pages, registry, README, agent skill
  • No Tailwind / config required in consumer projects; no new runtime dependency

The Tier 2 gaps from the catalog diff. Both hand-built, both without a new
dependency, and both with real behaviour rather than only a styling problem.

Carousel is CSS scroll-snap, not transforms: touch swipe, momentum and arrow-key
scrolling come from the platform. Position is read from the scroll container on
every scroll rather than remembered, because a swipe, a flick and the arrow keys
are drivers the component cannot intercept — a stored index would make the
"3 of 5" slide label lie. It does not loop and does not auto-rotate; both are
decisions, both are argued in its index.ts and on its docs page.

Resizable implements the APG window-splitter, which is mostly a keyboard
contract: a focusable separator with live aria-valuenow, arrow keys, Home and
End. A drag moves one boundary and touches two panes, clamped against BOTH of
their limits — clamping only the pane being grown lets its neighbour shrink past
its own minimum.

Three warnings from svelte-check were worth acting on rather than silencing:

- Pane captured `min`/`max` at init, so a consumer whose limits depend on state
  would change them and the group would keep clamping against the old values.
  They register as getters now.
- The a11y linter flags `tabindex` on a `separator`. It is right about a plain
  separator and wrong here: ARIA makes a separator a widget once it is focusable
  and carries aria-valuenow. Ignored with the reason written down, because
  removing the tab stop would delete the keyboard contract.
- An even-split line had two unused callback args and read badly; it now
  recomputes from the new total, so registering a third pane redistributes the
  first two instead of leaving them at 50 each.

Mutation-verified: clamping only the growing pane, reporting aria-orientation as
the direction of travel, and putting a tab stop on the carousel viewport each
fail a test that names them.
@vercel

vercel Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
sve-ui Ready Ready Preview Sep 1, 2026 5:27pm UTC

baselines for the two new component pages: carousel, resizable

322 baselines. Generated by the visual-baselines workflow, which is
the only sanctioned path: they are byte-compared and macOS font
rasterisation does not match this runner.
Written after the last format pass, so it never went through prettier. CI's
format:check is the only thing that runs it — a green turbo run is not a green
CI run, which is exactly the standing rule in ROADMAP.md.
@rodriabregu
rodriabregu merged commit 0b741d2 into main Sep 1, 2026
3 checks passed
@rodriabregu
rodriabregu deleted the feat/tier2-carousel-resizable branch September 1, 2026 17:32

This branch was successfully deployed

1 active deployment
Preview — 114b9465 Deployed Sep 1, 2026 by vercel[bot]
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.

1 participant