Nychthemeron/docs/superpowers/specs/2026-07-13-remove-reka-ui-design.md
2026-07-15 19:23:25 -04:00

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.