Repository navigation
feat(sve-ui): add Carousel and Resizable - #83
Merged
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The Tier 2 gaps from the same catalog diff that produced
Kbd,ButtonGroup,EmptyandInputGroup. 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.tsheaders: 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. ThenCarouselRoot.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.tsthenResizableHandle.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()exposesnext,prevandgoTofor 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.
Both have a test that names them.
Three svelte-check warnings I acted on rather than silenced
Panecapturedmin/maxat 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.tabindexon aseparator. It is right about a plain separator and wrong here: ARIA makes a separator a widget the moment it is focusable and carriesaria-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.Mutation-verified
Changes
components/Carousel/components/Resizable/src/tests/A11yFixture, whichssr.test.tsreuses — so each gets axe and server-render coverageapps/docs/src/routes/components/{carousel,resizable}registry.ts,README.md, agent skillapps/example/src/routes/browseselectedrender-baseline.json<a>+newtag)Test plan
turbo run test --force— 683 tests across 78 files (was 665/76)pnpm lint && pnpm checkgreen, 0 warnings;pnpm format:checkcleancheck-dts199 types,check-css-coverage197 components,check-docs-coverage66/66/66,check-readme-coverage66,publintcleancheck-example-coverage: all 68 exports renderedcheck:render— 73 pages, baseline updated in this commit; the uniform+4verified as 2 new nav entries × 2 elements before accepting itvisual-baselineson this branch next.Checklist
pnpm test)pnpm lint && pnpm check && pnpm buildall greenminor