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>
165 lines
8.4 KiB
Markdown
165 lines
8.4 KiB
Markdown
# 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.vue`
|
|
- `checkbox/Checkbox.vue`
|
|
- `switch/Switch.vue`
|
|
- `radio-group/RadioGroup.vue`, `radio-group/RadioGroupItem.vue`
|
|
- `dialog/*.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 for `Button`'s `asChild`,
|
|
`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 by
|
|
`RadioGroup`.
|
|
- **`usePopoverPosition`** — computes `fixed` top/left for a floating
|
|
element relative to a trigger's `getBoundingClientRect()`, flips
|
|
above/below based on available viewport space, recalculates on
|
|
scroll/resize while open. Used by Select's listbox.
|
|
- **Scroll lock** — reuse `@vueuse/core`'s `useScrollLock` (already a
|
|
dependency) rather than writing a new composable. Applied to `<body>`
|
|
while Dialog or Select is open.
|
|
|
|
## Per-component plan
|
|
|
|
- **Button** — replace `Primitive` with `<component :is="as">` (default
|
|
`'button'`), using the new `Slot` helper when `asChild` is true.
|
|
|
|
- **Checkbox / Switch** — reka's `CheckboxRoot`/`SwitchRoot` currently
|
|
render as a `<button role="checkbox|switch">` with `aria-checked` and a
|
|
`data-checked`/`data-unchecked` attribute that the existing Tailwind
|
|
classes key off of (e.g. `data-checked:bg-primary`). Reimplement that
|
|
directly: a `<button type="button">` managing `v-model`, setting
|
|
`aria-checked`, `data-checked`/`data-unchecked`, forwarding
|
|
`disabled`/`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 using `useRovingFocus` for arrow-key movement and
|
|
single-tab-stop behavior; `v-model` on the group for the selected value.
|
|
|
|
- **Dialog family** — `Dialog` (root) holds `open` state via
|
|
`provide`/`inject`, matching the current `v-model:open` API.
|
|
`DialogTrigger` toggles it (`asChild` supported via `Slot`).
|
|
`DialogContent` teleports to `body`, renders overlay + panel, applies
|
|
`useFocusTrap` + `useDismissableLayer` + scroll lock while open, and
|
|
drives the fade/zoom animation with `<Transition>`. `DialogTitle` /
|
|
`DialogDescription` generate and wire `aria-labelledby` /
|
|
`aria-describedby` back onto the content. `DialogOverlay`,
|
|
`DialogHeader`, `DialogFooter`, `DialogClose`, `DialogScrollContent`
|
|
are thin/presentational already or need only a trivial context hookup.
|
|
|
|
- **Select family** — `Select` (root) holds `open`/`modelValue` via
|
|
`provide`/`inject`. `SelectTrigger` is a `role="combobox"
|
|
aria-expanded` button. `SelectContent` teleports, positions itself with
|
|
`usePopoverPosition`, 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` /
|
|
`SelectSeparator` read highlighted/selected state from the injected
|
|
context. `SelectScrollUpButton` / `SelectScrollDownButton` become 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`: remove `reka-ui` from
|
|
`peerDependencies`.
|
|
- `packages/library/vite.config.ts`: remove `reka-ui` from the Rollup
|
|
`external`/`output.globals` config.
|
|
- No new dependencies added.
|
|
|
|
## Testing
|
|
|
|
- Vitest unit tests (`@vue/test-utils` is 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.
|