Plans a hand-rolled replacement for reka-ui across Button, Checkbox, Switch, RadioGroup, Dialog, and Select to drop the dependency (and its peerDependency burden on consumers) while preserving the current public API and visual design exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.4 KiB
Remove reka-ui dependency
Context
The library recently finished migrating off PrimeVue onto a shadcn-vue-style
component set (ff02f7e chore: remove PrimeVue and the ThemeEngine, migration complete). That migration adopted reka-ui (the Vue port of Radix
primitives) as the headless behavior layer under Button, Checkbox,
Switch, RadioGroup, Dialog, and Select. reka-ui is currently a
peerDependency of @nychthemeron/library, meaning every consumer of the
library has to install it too.
Goal: drop reka-ui entirely — fewer dependencies for the library and its
consumers, no external headless-UI surface to track for breaking changes —
by hand-rolling the behavior each component needs directly in this repo.
This is an internal implementation swap. No component's public props, emits,
slots, or CSS classes change; Storybook stories in packages/playground
should work unmodified against the new implementations.
Scope
Files currently importing from reka-ui (24 total, packages/library/src/components/ui/):
button/Button.vuecheckbox/Checkbox.vueswitch/Switch.vueradio-group/RadioGroup.vue,radio-group/RadioGroupItem.vuedialog/*.vue(10 files: Dialog, DialogTrigger, DialogContent, DialogOverlay, DialogClose, DialogTitle, DialogDescription, DialogFooter, DialogScrollContent, plus the already-presentational DialogHeader which doesn't import reka-ui but lives in the same family)select/*.vue(11 files: Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectItemText, SelectLabel, SelectGroup, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton)
Key decisions
Dialog is built as a fully custom div + Teleport, not on the native
<dialog> element. Native <dialog> would give focus-trap/ESC/top-layer
behavior for free, but its close() removes the element from the render
tree immediately, which fights the fade/zoom exit animation the current
design already has (data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95). A <Transition>-driven custom implementation
lets the leave animation play out naturally before unmount.
Select is a fully custom listbox, not a native <select>. The current
design has a styled popover (rounded corners, shadow, slide/fade animation,
scroll-up/down buttons) that native <select> cannot reproduce — its
dropdown is unstyleable OS chrome in every browser. Given the existing
Storybook stories only exercise flat item lists (no search/multi-select),
a hand-rolled listbox with manual keyboard nav and simple fixed-position
placement is sufficient and preserves the current visual design exactly.
No positioning library. Select's popover placement is computed manually
via getBoundingClientRect() (flip above the trigger when there's
insufficient room below, recalculate on scroll/resize while open) rather
than pulling in @floating-ui/dom or using CSS anchor positioning (the
latter is Chrome/Edge-only today, not viable for full browser support).
Shared internal primitives
New composables/utilities under packages/library/src/lib/ (or similar),
used across the affected components instead of one large headless library:
Slot(asChild helper) — clones the single child VNode and merges the parent's props/listeners onto it. Needed forButton'sasChild,DialogTrigger as-child,DialogClose as-child,SelectIcon as-child. This is the one piece of VNode-cloning plumbing kept in the codebase.- Portal — Vue's built-in
<Teleport to="body">directly; no wrapper component needed. useFocusTrap— on activation, moves focus into a container, cycles Tab/Shift+Tab within its focusable descendants, restores focus to the previously-focused element on deactivation. Used by Dialog and Select.useDismissableLayer— closes on Escape keydown and on click/pointerdown outside a given container. Used by Dialog (overlay click + Escape) and Select (outside click + Escape).useRovingFocus— arrow-key navigation across a set of items with a single tab stop (only the active item is in the tab order). Used byRadioGroup.usePopoverPosition— computesfixedtop/left for a floating element relative to a trigger'sgetBoundingClientRect(), flips above/below based on available viewport space, recalculates on scroll/resize while open. Used by Select's listbox.- Scroll lock — reuse
@vueuse/core'suseScrollLock(already a dependency) rather than writing a new composable. Applied to<body>while Dialog or Select is open.
Per-component plan
-
Button — replace
Primitivewith<component :is="as">(default'button'), using the newSlothelper whenasChildis true. -
Checkbox / Switch — reka's
CheckboxRoot/SwitchRootcurrently render as a<button role="checkbox|switch">witharia-checkedand adata-checked/data-uncheckedattribute that the existing Tailwind classes key off of (e.g.data-checked:bg-primary). Reimplement that directly: a<button type="button">managingv-model, settingaria-checked,data-checked/data-unchecked, forwardingdisabled/aria-invalid, toggling on click (Space/Enter activation is native<button>behavior, no extra handling needed). Markup and classes stay identical — no visual change. -
RadioGroup / RadioGroupItem —
role="radiogroup"wrapper +role="radio"buttons usinguseRovingFocusfor arrow-key movement and single-tab-stop behavior;v-modelon the group for the selected value. -
Dialog family —
Dialog(root) holdsopenstate viaprovide/inject, matching the currentv-model:openAPI.DialogTriggertoggles it (asChildsupported viaSlot).DialogContentteleports tobody, renders overlay + panel, appliesuseFocusTrap+useDismissableLayer+ scroll lock while open, and drives the fade/zoom animation with<Transition>.DialogTitle/DialogDescriptiongenerate and wirearia-labelledby/aria-describedbyback onto the content.DialogOverlay,DialogHeader,DialogFooter,DialogClose,DialogScrollContentare thin/presentational already or need only a trivial context hookup. -
Select family —
Select(root) holdsopen/modelValueviaprovide/inject.SelectTriggeris arole="combobox" aria-expandedbutton.SelectContentteleports, positions itself withusePopoverPosition, traps focus, and owns keyboard handling: Up/Down move a highlighted item, Enter/Space selects, Escape/outside-click closes, typeahead jumps to the first item matching typed characters.SelectItem/SelectItemText/SelectLabel/SelectGroup/SelectSeparatorread highlighted/selected state from the injected context.SelectScrollUpButton/SelectScrollDownButtonbecome simple "is the viewport scrolled away from the top/bottom" indicators driven by a scroll listener, rather than reka's internal viewport machinery.
This family carries the most real accessibility surface (the listbox keyboard pattern), so beyond unit tests it needs a manual pass in Storybook against the existing stories: keyboard nav, focus return, and Escape/outside-click behavior.
Package/build changes
packages/library/package.json: removereka-uifrompeerDependencies.packages/library/vite.config.ts: removereka-uifrom the Rollupexternal/output.globalsconfig.- No new dependencies added.
Testing
- Vitest unit tests (
@vue/test-utilsis an existing but currently-unused devDependency) for the composables with real logic and failure modes:useFocusTrap,useRovingFocus,useDismissableLayer, and Select's keyboard/typeahead handling. - Presentational components remain covered by the existing Storybook stories rather than new component-level tests.
- Manual verification: exercise every existing Storybook story for Button/Checkbox/Switch/RadioGroup/Dialog/Select in a browser (headless Chromium per the existing Storybook screenshot setup), checking keyboard navigation, focus return, and Escape/outside-click behavior by hand, not just visual diffing.
Non-goals
- No visual/CSS changes to any component.
- No new features (multi-select, search/combobox, non-modal dialogs, etc.) beyond what the current reka-ui-backed components already support and the existing stories exercise.
- No changes to consumers outside this repo's
packages/playground; the public component API is preserved exactly.