From d56ccce2febf20e37c6038a9d2f2a5ed6a6687db Mon Sep 17 00:00:00 2001 From: Matthew L McPeak Date: Mon, 13 Jul 2026 17:26:30 -0400 Subject: [PATCH] docs: add implementation plan for removing reka-ui 15 tasks: 5 shared composables (Primitive/Slot, focus-trap, dismissable-layer, roving-focus, popover-position) with unit tests, then component-by-component rewrites (Button, Checkbox, Switch, RadioGroup, Dialog, Select) verified against the existing regression suite in packages/library/tests/, then dropping the dependency itself. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-13-remove-reka-ui.md | 2488 +++++++++++++++++ 1 file changed, 2488 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-remove-reka-ui.md diff --git a/docs/superpowers/plans/2026-07-13-remove-reka-ui.md b/docs/superpowers/plans/2026-07-13-remove-reka-ui.md new file mode 100644 index 0000000..8fc60cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-remove-reka-ui.md @@ -0,0 +1,2488 @@ +# Remove reka-ui Dependency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every `reka-ui` import in `@nychthemeron/library` with a small, hand-rolled implementation, then drop `reka-ui` from the library's dependencies entirely, with zero change to any component's public props/emits/slots/CSS classes. + +**Architecture:** Five small shared primitives (`Primitive`/`Slot` for polymorphic/`asChild` rendering, `useFocusTrap`, `useDismissableLayer`, `useRovingFocus`, `usePopoverPosition`) replace reka-ui's headless behavior layer. Each affected component is rewritten in place to use these primitives plus Vue's built-in ``/`` and VueUse's `useVModel`/`useScrollLock` (already dependencies). No new dependencies are added. + +**Tech Stack:** Vue 3.5 (`useId()`, ``, ``), `@vueuse/core` (`useVModel`, `useScrollLock`, already a dependency), Vitest + `@vue/test-utils` (already devDependencies, already wired to `bun run test:unit`), Tailwind v4 with `shadcn-vue/tailwind.css`'s `@custom-variant` definitions (`data-open`, `data-closed`, `data-checked`, `data-unchecked`, `data-disabled` — these match either `data-state="x"` or a bare `data-x` attribute, confirmed by reading `node_modules/.../shadcn-vue/dist/tailwind.css`). + +## Global Constraints + +- Use `bun` for every package-manager command (never `npm`/`yarn`). +- No visual/CSS class changes to any component — copy existing Tailwind class strings verbatim. +- No new npm dependencies. Reuse `@vueuse/core` (`useVModel`, `useScrollLock`) which is already a dependency. +- Every existing test in `packages/library/tests/*.spec.ts` must keep passing unmodified — they are the regression suite for this migration and encode the real public contract (e.g. `data-state="checked"/"unchecked"` attributes, `role="combobox"` on the Select trigger, Dialog content only existing in the DOM while open). +- New composable tests go in `packages/library/tests/`, named after their source file (kebab-case), matching the existing convention (`tests/RadioGroup.spec.ts` etc. all live flat in `tests/`, none colocated in `src/`). +- Run commands from the repo root unless a step says otherwise. Root scripts (`package.json`): `bun run test:unit` (runs Vitest once in `packages/library`), `bun run type-check` (`vue-tsc --build`), `bun run build`, `bun run dev` (starts Storybook, which resolves `@nychthemeron/library` straight to `src/index.ts` in dev mode — no library build needed to see changes there). +- GPG commit signing is broken in this sandbox (confirmed earlier in this session) — commit with `git commit --no-gpg-sign` for every commit in this plan. + +--- + +## Task 1: `Primitive`/`Slot` polymorphic-rendering utility + +**Files:** +- Create: `packages/library/src/lib/slot.ts` +- Create: `packages/library/src/lib/primitive.ts` +- Test: `packages/library/tests/primitive.spec.ts` + +**Interfaces:** +- Produces: `Primitive` (named export from `@/lib/primitive`), a Vue component with props `{ as?: string | Component (default 'div'), asChild?: boolean (default false) }` that renders `props.as` normally, or clones its single child VNode (merging in all non-prop attrs) when `asChild` is true. Used by Task 6 (Button), Task 9 (DialogTrigger/DialogClose). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/library/tests/primitive.spec.ts +import { describe, it, expect } from 'vitest' +import { h } from 'vue' +import { mount } from '@vue/test-utils' +import { Primitive } from '../src/lib/primitive' + +describe('Primitive', () => { + it('renders the "as" tag with forwarded attrs when asChild is false', () => { + const wrapper = mount(Primitive, { + props: { as: 'a' }, + attrs: { href: '/somewhere', class: 'link' }, + slots: { default: () => 'Go' }, + }) + const el = wrapper.get('a') + expect(el.attributes('href')).toBe('/somewhere') + expect(el.classes()).toContain('link') + expect(el.text()).toBe('Go') + }) + + it('clones the single child and merges attrs onto it when asChild is true', async () => { + let clicked = false + const wrapper = mount(Primitive, { + props: { asChild: true }, + attrs: { class: 'from-parent', 'data-slot': 'button', onClick: () => { clicked = true } }, + slots: { + default: () => h('button', { class: 'from-child', type: 'button' }, 'Click'), + }, + }) + const el = wrapper.get('button') + expect(el.attributes('data-slot')).toBe('button') + expect(el.attributes('type')).toBe('button') + expect(el.classes()).toContain('from-parent') + expect(el.classes()).toContain('from-child') + await el.trigger('click') + expect(clicked).toBe(true) + }) + + it('throws if asChild is true with zero or multiple children', () => { + expect(() => + mount(Primitive, { + props: { asChild: true }, + slots: { default: () => [h('span', 'a'), h('span', 'b')] }, + }), + ).toThrow() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/library && bunx vitest run tests/primitive.spec.ts` +Expected: FAIL — `Failed to resolve import "../src/lib/primitive"` (files don't exist yet). + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/library/src/lib/slot.ts +import type { VNode } from 'vue' +import { cloneVNode, defineComponent, mergeProps } from 'vue' + +export default defineComponent({ + name: 'Slot', + inheritAttrs: false, + setup(_, { slots, attrs }) { + return () => { + const children = slots.default?.() ?? [] + if (children.length !== 1) { + throw new Error('Slot requires exactly one child element') + } + const child = children[0] as VNode + return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record)) + } + }, +}) +``` + +```ts +// packages/library/src/lib/primitive.ts +import type { Component } from 'vue' +import { defineComponent, h } from 'vue' +import Slot from './slot' + +export const Primitive = defineComponent({ + name: 'Primitive', + inheritAttrs: false, + props: { + as: { + type: [String, Object, Function] as unknown as () => string | Component, + default: 'div', + }, + asChild: { type: Boolean, default: false }, + }, + setup(props, { slots, attrs }) { + return () => { + const Tag = props.asChild ? Slot : props.as + return h(Tag, attrs, slots) + } + }, +}) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/primitive.spec.ts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/lib/slot.ts packages/library/src/lib/primitive.ts packages/library/tests/primitive.spec.ts +git commit --no-gpg-sign -m "$(cat <<'EOF' +feat: add Primitive/Slot polymorphic-rendering utility + +First piece of the reka-ui removal: a small asChild/Slot implementation +to replace reka-ui's Primitive component. +EOF +)" +``` + +--- + +## Task 2: `useFocusTrap` composable + +**Files:** +- Create: `packages/library/src/lib/use-focus-trap.ts` +- Test: `packages/library/tests/use-focus-trap.spec.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `useFocusTrap(containerRef: Ref, active: Ref, options?: { initialFocus?: () => HTMLElement | null }): void`. While `active.value` is true: Tab/Shift+Tab cycle within `containerRef`'s focusable descendants; on activation, focus moves to `options.initialFocus()` if provided, else the container's first focusable descendant, else the container itself; on deactivation, focus returns to whatever was focused immediately before activation. Used by Task 10 (DialogContent, DialogScrollContent) and Task 13 (SelectContent). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/library/tests/use-focus-trap.spec.ts +import { describe, it, expect, afterEach } from 'vitest' +import { nextTick, ref } from 'vue' +import { useFocusTrap } from '../src/lib/use-focus-trap' + +function appendContainer() { + const container = document.createElement('div') + const first = document.createElement('button') + first.textContent = 'first' + const last = document.createElement('button') + last.textContent = 'last' + container.append(first, last) + document.body.append(container) + return { container, first, last } +} + +describe('useFocusTrap', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('moves focus into the container on activation', async () => { + const { container, first } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active) + + active.value = true + await nextTick() + await nextTick() + + expect(document.activeElement).toBe(first) + }) + + it('uses the initialFocus override when provided', async () => { + const { container, last } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active, { initialFocus: () => last }) + + active.value = true + await nextTick() + await nextTick() + + expect(document.activeElement).toBe(last) + }) + + it('wraps Tab from the last focusable back to the first', async () => { + const { container, first, last } = appendContainer() + const containerRef = ref(container) + const active = ref(true) + useFocusTrap(containerRef, active) + last.focus() + + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + document.dispatchEvent(event) + + expect(document.activeElement).toBe(first) + }) + + it('wraps Shift+Tab from the first focusable back to the last', async () => { + const { container, first, last } = appendContainer() + const containerRef = ref(container) + const active = ref(true) + useFocusTrap(containerRef, active) + first.focus() + + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }) + document.dispatchEvent(event) + + expect(document.activeElement).toBe(last) + }) + + it('restores focus to the previously-focused element on deactivation', async () => { + const opener = document.createElement('button') + opener.textContent = 'opener' + document.body.append(opener) + opener.focus() + + const { container } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active) + + active.value = true + await nextTick() + await nextTick() + active.value = false + + expect(document.activeElement).toBe(opener) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/library && bunx vitest run tests/use-focus-trap.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/library/src/lib/use-focus-trap.ts +import type { Ref } from 'vue' +import { nextTick, onUnmounted, watch } from 'vue' + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) +} + +export interface FocusTrapOptions { + initialFocus?: () => HTMLElement | null +} + +export function useFocusTrap( + containerRef: Ref, + active: Ref, + options: FocusTrapOptions = {}, +) { + let previouslyFocused: HTMLElement | null = null + + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'Tab') return + const container = containerRef.value + if (!container) return + + const focusable = getFocusable(container) + if (focusable.length === 0) { + event.preventDefault() + return + } + + const first = focusable[0]! + const last = focusable[focusable.length - 1]! + const current = document.activeElement + + if (event.shiftKey) { + if (current === first || !container.contains(current)) { + event.preventDefault() + last.focus() + } + } else { + if (current === last || !container.contains(current)) { + event.preventDefault() + first.focus() + } + } + } + + watch( + active, + (isActive) => { + if (isActive) { + previouslyFocused = document.activeElement as HTMLElement | null + document.addEventListener('keydown', handleKeydown) + nextTick(() => { + const container = containerRef.value + if (!container) return + if (container.contains(document.activeElement)) return + const target = options.initialFocus?.() ?? getFocusable(container)[0] ?? container + target?.focus() + }) + } else { + document.removeEventListener('keydown', handleKeydown) + previouslyFocused?.focus?.() + previouslyFocused = null + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown) + }) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/use-focus-trap.spec.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/lib/use-focus-trap.ts packages/library/tests/use-focus-trap.spec.ts +git commit --no-gpg-sign -m "$(cat <<'EOF' +feat: add useFocusTrap composable + +Replaces reka-ui's internal focus-trap behavior for Dialog and Select. +EOF +)" +``` + +--- + +## Task 3: `useDismissableLayer` composable + +**Files:** +- Create: `packages/library/src/lib/use-dismissable-layer.ts` +- Test: `packages/library/tests/use-dismissable-layer.spec.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `useDismissableLayer(containerRef: Ref, active: Ref, options: { onDismiss: () => void, onPointerDownOutside?: (event: PointerEvent) => void }): void`. While `active.value` is true: Escape keydown calls `onDismiss()`; a `pointerdown` outside `containerRef` calls `options.onPointerDownOutside?.(event)` first, and if that handler doesn't call `event.preventDefault()`, then calls `onDismiss()`. Used by Task 10 (DialogContent, DialogScrollContent) and Task 13 (SelectContent). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/library/tests/use-dismissable-layer.spec.ts +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ref } from 'vue' +import { useDismissableLayer } from '../src/lib/use-dismissable-layer' + +function firePointerDown(target: EventTarget) { + const event = new PointerEvent('pointerdown', { bubbles: true, cancelable: true }) + target.dispatchEvent(event) +} + +describe('useDismissableLayer', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('calls onDismiss on Escape keydown while active', () => { + const containerRef = ref(document.createElement('div')) + const active = ref(true) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { onDismiss }) + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + + expect(onDismiss).toHaveBeenCalledOnce() + }) + + it('does not call onDismiss on Escape when inactive', () => { + const containerRef = ref(document.createElement('div')) + const active = ref(false) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { onDismiss }) + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('calls onDismiss on pointerdown outside the container', () => { + const container = document.createElement('div') + const outside = document.createElement('div') + document.body.append(container, outside) + const containerRef = ref(container) + const active = ref(true) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { onDismiss }) + + firePointerDown(outside) + + expect(onDismiss).toHaveBeenCalledOnce() + }) + + it('does not call onDismiss on pointerdown inside the container', () => { + const container = document.createElement('div') + const inside = document.createElement('span') + container.append(inside) + document.body.append(container) + const containerRef = ref(container) + const active = ref(true) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { onDismiss }) + + firePointerDown(inside) + + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('skips onDismiss when onPointerDownOutside calls preventDefault', () => { + const container = document.createElement('div') + const outside = document.createElement('div') + document.body.append(container, outside) + const containerRef = ref(container) + const active = ref(true) + const onDismiss = vi.fn() + useDismissableLayer(containerRef, active, { + onDismiss, + onPointerDownOutside: (event) => event.preventDefault(), + }) + + firePointerDown(outside) + + expect(onDismiss).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/library && bunx vitest run tests/use-dismissable-layer.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/library/src/lib/use-dismissable-layer.ts +import type { Ref } from 'vue' +import { onUnmounted, watch } from 'vue' + +export interface DismissableLayerOptions { + onDismiss: () => void + onPointerDownOutside?: (event: PointerEvent) => void +} + +export function useDismissableLayer( + containerRef: Ref, + active: Ref, + options: DismissableLayerOptions, +) { + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'Escape') return + event.preventDefault() + options.onDismiss() + } + + function handlePointerDown(event: PointerEvent) { + const container = containerRef.value + if (!container) return + if (container.contains(event.target as Node)) return + + options.onPointerDownOutside?.(event) + if (event.defaultPrevented) return + options.onDismiss() + } + + function attach() { + document.addEventListener('keydown', handleKeydown) + document.addEventListener('pointerdown', handlePointerDown) + } + + function detach() { + document.removeEventListener('keydown', handleKeydown) + document.removeEventListener('pointerdown', handlePointerDown) + } + + watch( + active, + (isActive) => { + if (isActive) attach() + else detach() + }, + { immediate: true }, + ) + + onUnmounted(detach) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/use-dismissable-layer.spec.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/lib/use-dismissable-layer.ts packages/library/tests/use-dismissable-layer.spec.ts +git commit --no-gpg-sign -m "$(cat <<'EOF' +feat: add useDismissableLayer composable + +Replaces reka-ui's Escape/click-outside dismissal behavior for Dialog +and Select. +EOF +)" +``` + +--- + +## Task 4: `useRovingFocus` composable + +**Files:** +- Create: `packages/library/src/lib/use-roving-focus.ts` +- Test: `packages/library/tests/use-roving-focus.spec.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `useRovingFocus(itemsRef: Ref, options?: { orientation?: 'vertical' | 'horizontal', loop?: boolean }): { handleKeydown: (event: KeyboardEvent) => void, focusIndex: (index: number) => void }`. `focusIndex` clamps (or wraps, if `loop` is true, default true) the index into range and calls `.focus()` on that item. `handleKeydown` moves focus by one item on ArrowDown/ArrowUp (vertical, default) or ArrowRight/ArrowLeft (horizontal), and jumps to the first/last item on Home/End. Used by Task 8 (RadioGroup, via `focusIndex` only) and Task 13 (SelectContent, via `handleKeydown`). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/library/tests/use-roving-focus.spec.ts +import { describe, it, expect, afterEach } from 'vitest' +import { ref } from 'vue' +import { useRovingFocus } from '../src/lib/use-roving-focus' + +function makeItems(count: number) { + const items = Array.from({ length: count }, (_, i) => { + const el = document.createElement('button') + el.textContent = `item-${i}` + document.body.append(el) + return el + }) + return items +} + +function arrowDown() { + return new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }) +} +function arrowUp() { + return new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true }) +} + +describe('useRovingFocus', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('focusIndex focuses the item at that index', () => { + const items = makeItems(3) + const { focusIndex } = useRovingFocus(ref(items)) + focusIndex(1) + expect(document.activeElement).toBe(items[1]) + }) + + it('focusIndex wraps by default when loop is not set', () => { + const items = makeItems(3) + const { focusIndex } = useRovingFocus(ref(items)) + focusIndex(3) + expect(document.activeElement).toBe(items[0]) + focusIndex(-1) + expect(document.activeElement).toBe(items[2]) + }) + + it('focusIndex clamps instead of wrapping when loop is false', () => { + const items = makeItems(3) + const { focusIndex } = useRovingFocus(ref(items), { loop: false }) + focusIndex(5) + expect(document.activeElement).toBe(items[2]) + focusIndex(-5) + expect(document.activeElement).toBe(items[0]) + }) + + it('handleKeydown moves focus forward/backward on ArrowDown/ArrowUp for vertical orientation', () => { + const items = makeItems(3) + const { handleKeydown } = useRovingFocus(ref(items)) + items[0]!.focus() + handleKeydown(arrowDown()) + expect(document.activeElement).toBe(items[1]) + handleKeydown(arrowUp()) + expect(document.activeElement).toBe(items[0]) + }) + + it('handleKeydown jumps to first/last on Home/End', () => { + const items = makeItems(3) + const { handleKeydown } = useRovingFocus(ref(items)) + items[1]!.focus() + handleKeydown(new KeyboardEvent('keydown', { key: 'End', cancelable: true })) + expect(document.activeElement).toBe(items[2]) + handleKeydown(new KeyboardEvent('keydown', { key: 'Home', cancelable: true })) + expect(document.activeElement).toBe(items[0]) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/library && bunx vitest run tests/use-roving-focus.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/library/src/lib/use-roving-focus.ts +import type { Ref } from 'vue' + +export interface RovingFocusOptions { + orientation?: 'vertical' | 'horizontal' + loop?: boolean +} + +export function useRovingFocus(itemsRef: Ref, options: RovingFocusOptions = {}) { + const orientation = options.orientation ?? 'vertical' + const loop = options.loop ?? true + + function focusIndex(index: number) { + const items = itemsRef.value + if (items.length === 0) return + const clamped = loop + ? ((index % items.length) + items.length) % items.length + : Math.max(0, Math.min(index, items.length - 1)) + items[clamped]?.focus() + } + + function handleKeydown(event: KeyboardEvent) { + const items = itemsRef.value + if (items.length === 0) return + const currentIndex = items.indexOf(document.activeElement as HTMLElement) + const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight' + const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft' + + if (event.key === nextKey) { + event.preventDefault() + focusIndex(currentIndex + 1) + } else if (event.key === prevKey) { + event.preventDefault() + focusIndex(currentIndex - 1) + } else if (event.key === 'Home') { + event.preventDefault() + focusIndex(0) + } else if (event.key === 'End') { + event.preventDefault() + focusIndex(items.length - 1) + } + } + + return { handleKeydown, focusIndex } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/use-roving-focus.spec.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/lib/use-roving-focus.ts packages/library/tests/use-roving-focus.spec.ts +git commit --no-gpg-sign -m "$(cat <<'EOF' +feat: add useRovingFocus composable + +Replaces reka-ui's roving-tabindex/arrow-key navigation for RadioGroup +and Select. +EOF +)" +``` + +--- + +## Task 5: `usePopoverPosition` composable + +**Files:** +- Create: `packages/library/src/lib/use-popover-position.ts` +- Test: `packages/library/tests/use-popover-position.spec.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `usePopoverPosition(triggerRef: Ref, contentRef: Ref, open: Ref): { top: number, left: number, minWidth: number, maxHeight: number, side: 'top' | 'bottom' }` (a `reactive` object). Recomputes whenever `open` becomes true (after `nextTick`) and on scroll/resize while open. Used by Task 13 (SelectContent). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/library/tests/use-popover-position.spec.ts +import { describe, it, expect, vi, afterEach } from 'vitest' +import { nextTick, ref } from 'vue' +import { usePopoverPosition } from '../src/lib/use-popover-position' + +function mockRect(el: HTMLElement, rect: Partial) { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, + toJSON: () => {}, + ...rect, + } as DOMRect) +} + +describe('usePopoverPosition', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('places the content below the trigger when there is enough room', async () => { + const trigger = document.createElement('button') + const content = document.createElement('div') + document.body.append(trigger, content) + mockRect(trigger, { top: 100, bottom: 130, left: 20, width: 80 }) + Object.defineProperty(content, 'offsetHeight', { value: 40, configurable: true }) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000) + + const open = ref(false) + const position = usePopoverPosition(ref(trigger), ref(content), open) + open.value = true + await nextTick() + await nextTick() + + expect(position.side).toBe('bottom') + expect(position.top).toBe(134) + expect(position.minWidth).toBe(80) + }) + + it('flips above the trigger when there is not enough room below', async () => { + const trigger = document.createElement('button') + const content = document.createElement('div') + document.body.append(trigger, content) + mockRect(trigger, { top: 700, bottom: 730, left: 20, width: 80 }) + Object.defineProperty(content, 'offsetHeight', { value: 200, configurable: true }) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000) + + const open = ref(false) + const position = usePopoverPosition(ref(trigger), ref(content), open) + open.value = true + await nextTick() + await nextTick() + + expect(position.side).toBe('top') + expect(position.top).toBeLessThan(700) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/library && bunx vitest run tests/use-popover-position.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/library/src/lib/use-popover-position.ts +import type { Ref } from 'vue' +import { nextTick, onUnmounted, reactive, watch } from 'vue' + +export interface PopoverPosition { + top: number + left: number + minWidth: number + maxHeight: number + side: 'top' | 'bottom' +} + +const GAP = 4 +const VIEWPORT_MARGIN = 8 + +export function usePopoverPosition( + triggerRef: Ref, + contentRef: Ref, + open: Ref, +) { + const position = reactive({ top: 0, left: 0, minWidth: 0, maxHeight: 0, side: 'bottom' }) + + function update() { + const trigger = triggerRef.value + const content = contentRef.value + if (!trigger || !content) return + + const triggerRect = trigger.getBoundingClientRect() + const contentHeight = content.offsetHeight + const spaceBelow = window.innerHeight - triggerRect.bottom - VIEWPORT_MARGIN + const spaceAbove = triggerRect.top - VIEWPORT_MARGIN + const placeAbove = spaceBelow < contentHeight && spaceAbove > spaceBelow + + position.side = placeAbove ? 'top' : 'bottom' + position.left = Math.min( + Math.max(triggerRect.left, VIEWPORT_MARGIN), + window.innerWidth - triggerRect.width - VIEWPORT_MARGIN, + ) + position.minWidth = triggerRect.width + position.maxHeight = Math.max(placeAbove ? spaceAbove : spaceBelow, 0) + position.top = placeAbove + ? triggerRect.top - GAP - Math.min(contentHeight, position.maxHeight) + : triggerRect.bottom + GAP + } + + function handleReposition() { + if (open.value) update() + } + + watch( + open, + (isOpen) => { + if (isOpen) { + nextTick(update) + window.addEventListener('scroll', handleReposition, true) + window.addEventListener('resize', handleReposition) + } else { + window.removeEventListener('scroll', handleReposition, true) + window.removeEventListener('resize', handleReposition) + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + window.removeEventListener('scroll', handleReposition, true) + window.removeEventListener('resize', handleReposition) + }) + + return position +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/use-popover-position.spec.ts` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/lib/use-popover-position.ts packages/library/tests/use-popover-position.spec.ts +git commit --no-gpg-sign -m "$(cat <<'EOF' +feat: add usePopoverPosition composable + +Replaces reka-ui's floating-position logic for the Select listbox with +a simple flip-above/below fixed-position calculation. +EOF +)" +``` + +--- + +## Task 6: Rewrite `Button.vue` + +**Files:** +- Modify: `packages/library/src/components/ui/button/Button.vue` +- Test (existing, must still pass): `packages/library/tests/Button.spec.ts` + +**Interfaces:** +- Consumes: `Primitive` from `@/lib/primitive` (Task 1). +- Produces: no change to `Button`'s public props/slots — `{ as?: string, asChild?: boolean, variant?, size?, class?, loading?: boolean, disabled?: boolean }`. + +- [ ] **Step 1: Confirm the existing test currently passes (baseline, still on reka-ui)** + +Run: `cd packages/library && bunx vitest run tests/Button.spec.ts` +Expected: PASS (5 tests) — this is the pre-change baseline. + +- [ ] **Step 2: Rewrite the component** + +```vue + + + + +``` + +- [ ] **Step 3: Run the existing test to verify it still passes** + +Run: `cd packages/library && bunx vitest run tests/Button.spec.ts` +Expected: PASS (5 tests), unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add packages/library/src/components/ui/button/Button.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite Button on the local Primitive utility + +Drops the reka-ui Primitive import; behavior and classes are unchanged. +EOF +)" +``` + +--- + +## Task 7: Rewrite `Checkbox.vue` and `Switch.vue` + +**Files:** +- Modify: `packages/library/src/components/ui/checkbox/Checkbox.vue` +- Modify: `packages/library/src/components/ui/switch/Switch.vue` +- Test (existing, must still pass): `packages/library/tests/Checkbox.spec.ts`, `packages/library/tests/Switch.spec.ts` + +**Interfaces:** +- Consumes: `useVModel` from `@vueuse/core` (already a dependency, established pattern — see `packages/library/src/components/ui/input/Input.vue`). +- Produces: no change to public props/emits — `{ modelValue?: boolean, defaultValue?: boolean, disabled?: boolean, required?: boolean, class?, size?: 'sm'|'default' (Switch only) }`, emits `update:modelValue`. + +- [ ] **Step 1: Confirm the existing tests currently pass (baseline, still on reka-ui)** + +Run: `cd packages/library && bunx vitest run tests/Checkbox.spec.ts tests/Switch.spec.ts` +Expected: PASS (6 tests total). + +- [ ] **Step 2: Rewrite Checkbox.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Rewrite Switch.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Run the existing tests to verify they still pass** + +Run: `cd packages/library && bunx vitest run tests/Checkbox.spec.ts tests/Switch.spec.ts` +Expected: PASS (6 tests total), unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add packages/library/src/components/ui/checkbox/Checkbox.vue packages/library/src/components/ui/switch/Switch.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite Checkbox and Switch without reka-ui + +Both become plain role="checkbox"/role="switch" buttons driven by +useVModel, matching the existing Input.vue controlled-value pattern. +Classes and data-state/aria-checked contract are unchanged. +EOF +)" +``` + +--- + +## Task 8: Rewrite `RadioGroup.vue` and `RadioGroupItem.vue` + +**Files:** +- Create: `packages/library/src/components/ui/radio-group/context.ts` +- Modify: `packages/library/src/components/ui/radio-group/RadioGroup.vue` +- Modify: `packages/library/src/components/ui/radio-group/RadioGroupItem.vue` +- Test (existing, must still pass): `packages/library/tests/RadioGroup.spec.ts` + +**Interfaces:** +- Consumes: `useRovingFocus` (Task 4, `focusIndex` only — RadioGroup needs all four arrow keys regardless of layout orientation, per the ARIA radiogroup pattern, so it drives `focusIndex` directly rather than `handleKeydown`). +- Produces: `RadioGroupContextKey: InjectionKey` (internal, not exported from the package). No change to `RadioGroup`/`RadioGroupItem`'s public props/emits. + +- [ ] **Step 1: Confirm the existing test currently passes (baseline, still on reka-ui)** + +Run: `cd packages/library && bunx vitest run tests/RadioGroup.spec.ts` +Expected: PASS (2 tests). + +- [ ] **Step 2: Write the context** + +```ts +// packages/library/src/components/ui/radio-group/context.ts +import type { InjectionKey, Ref } from 'vue' + +export interface RadioGroupContext { + modelValue: Ref + disabled: Ref + items: Ref + register: (el: HTMLElement) => void + unregister: (el: HTMLElement) => void +} + +export const RadioGroupContextKey: InjectionKey = Symbol('RadioGroupContext') +``` + +- [ ] **Step 3: Rewrite RadioGroup.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Rewrite RadioGroupItem.vue** + +```vue + + + + +``` + +- [ ] **Step 5: Run the existing test to verify it still passes** + +Run: `cd packages/library && bunx vitest run tests/RadioGroup.spec.ts` +Expected: PASS (2 tests), unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add packages/library/src/components/ui/radio-group/ +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite RadioGroup without reka-ui + +Uses a small provide/inject context plus useRovingFocus for arrow-key +selection. data-state/aria-checked contract is unchanged. +EOF +)" +``` + +--- + +## Task 9: Rewrite the Dialog root family + +**Files:** +- Create: `packages/library/src/components/ui/dialog/context.ts` +- Modify: `packages/library/src/components/ui/dialog/Dialog.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogTrigger.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogOverlay.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogClose.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogTitle.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogDescription.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogFooter.vue` + +**Interfaces:** +- Consumes: `Primitive` (Task 1). +- Produces: `DialogContextKey: InjectionKey` where `DialogContext = { open: Ref, titleId: string, descriptionId: string }` (internal, not exported). Used by Task 10 (DialogContent, DialogScrollContent). +- No change to public props/emits/slots on any Dialog component. + +This task covers everything except `DialogContent.vue`/`DialogScrollContent.vue` (Task 10) — those need the focus-trap/dismissable-layer/scroll-lock composables and are bigger, so they're a separate task even though they're the same component family. + +- [ ] **Step 1: Write the context** + +```ts +// packages/library/src/components/ui/dialog/context.ts +import type { InjectionKey, Ref } from 'vue' + +export interface DialogContext { + open: Ref + titleId: string + descriptionId: string +} + +export const DialogContextKey: InjectionKey = Symbol('DialogContext') +``` + +- [ ] **Step 2: Rewrite Dialog.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Rewrite DialogTrigger.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Rewrite DialogOverlay.vue** + +```vue + + + + +``` + +- [ ] **Step 5: Rewrite DialogClose.vue** + +```vue + + + + +``` + +- [ ] **Step 6: Rewrite DialogTitle.vue and DialogDescription.vue** + +```vue + + + + +``` + +```vue + + + + +``` + +- [ ] **Step 7: Fix DialogFooter.vue's import** + +`DialogFooter.vue` currently imports `DialogClose` straight from `reka-ui` instead of the local component. Change: + +```diff +- import { DialogClose } from "reka-ui" ++ import DialogClose from "./DialogClose.vue" +``` + +Leave the rest of `DialogFooter.vue` unchanged (it's otherwise presentational). + +- [ ] **Step 8: Run type-check to catch any wiring mistakes** + +Run: `bun run type-check` +Expected: This will still report errors from `DialogContent.vue`/`DialogScrollContent.vue` (not rewritten until Task 10) and from the not-yet-updated Select family — that's expected at this point. Confirm there are **no** errors pointing at the seven files touched in this task. + +- [ ] **Step 9: Commit** + +```bash +git add packages/library/src/components/ui/dialog/context.ts \ + packages/library/src/components/ui/dialog/Dialog.vue \ + packages/library/src/components/ui/dialog/DialogTrigger.vue \ + packages/library/src/components/ui/dialog/DialogOverlay.vue \ + packages/library/src/components/ui/dialog/DialogClose.vue \ + packages/library/src/components/ui/dialog/DialogTitle.vue \ + packages/library/src/components/ui/dialog/DialogDescription.vue \ + packages/library/src/components/ui/dialog/DialogFooter.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite the Dialog root family without reka-ui + +Root/Trigger/Overlay/Close/Title/Description now share a small +provide/inject context instead of reka-ui's Dialog primitives. +DialogContent/DialogScrollContent follow in the next commit since they +need the focus-trap and dismissable-layer composables. +EOF +)" +``` + +--- + +## Task 10: Rewrite `DialogContent.vue` and `DialogScrollContent.vue` + +**Files:** +- Modify: `packages/library/src/components/ui/dialog/DialogContent.vue` +- Modify: `packages/library/src/components/ui/dialog/DialogScrollContent.vue` +- Test (existing, must still pass): `packages/library/tests/Dialog.spec.ts` + +**Interfaces:** +- Consumes: `DialogContextKey` (Task 9), `useFocusTrap` (Task 2), `useDismissableLayer` (Task 3), `useScrollLock` from `@vueuse/core`. +- Produces: no change to public props/slots (`{ class?, showCloseButton? }` on `DialogContent`; `{ class? }` on `DialogScrollContent`). + +- [ ] **Step 1: Confirm the existing test currently passes** + +Run: `cd packages/library && bunx vitest run tests/Dialog.spec.ts` +Expected: At this point `Dialog.vue` is already rewritten (Task 9) but `DialogContent.vue` still imports `reka-ui` — this step is a checkpoint, not a strict requirement; if it fails because `DialogContent.vue` hasn't been updated yet to use the new `DialogContextKey`, proceed directly to Step 2 (this is expected: Task 9 changed the context Dialog provides, so DialogContent must be updated in the same logical unit of work before the suite is green again). + +- [ ] **Step 2: Rewrite DialogContent.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Rewrite DialogScrollContent.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Run the existing test to verify it passes** + +Run: `cd packages/library && bunx vitest run tests/Dialog.spec.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Run type-check across the whole Dialog family** + +Run: `bun run type-check` +Expected: No errors referencing any file under `packages/library/src/components/ui/dialog/`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/library/src/components/ui/dialog/DialogContent.vue packages/library/src/components/ui/dialog/DialogScrollContent.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite DialogContent/DialogScrollContent without reka-ui + +Uses Teleport + Transition + useFocusTrap + useDismissableLayer + +useScrollLock. The Dialog family no longer imports reka-ui at all. +EOF +)" +``` + +--- + +## Task 11: Rewrite the Select root family (Select, SelectValue, SelectLabel, SelectGroup, SelectSeparator, SelectItemText) + +**Files:** +- Create: `packages/library/src/components/ui/select/context.ts` +- Modify: `packages/library/src/components/ui/select/Select.vue` +- Modify: `packages/library/src/components/ui/select/SelectValue.vue` +- Modify: `packages/library/src/components/ui/select/SelectLabel.vue` +- Modify: `packages/library/src/components/ui/select/SelectGroup.vue` +- Modify: `packages/library/src/components/ui/select/SelectSeparator.vue` +- Modify: `packages/library/src/components/ui/select/SelectItemText.vue` + +**Interfaces:** +- Produces: `SelectContextKey: InjectionKey` (internal, not exported) where: + ```ts + interface SelectContext { + open: Ref + modelValue: Ref + disabled: Ref + triggerRef: Ref + contentId: string + itemLabels: Map + registerLabel: (value: string, label: string) => void + unregisterLabel: (value: string) => void + } + ``` + Used by Task 12 (SelectTrigger, SelectItem) and Task 13 (SelectContent). +- No change to public props/emits/slots on any Select component. + +- [ ] **Step 1: Write the context** + +```ts +// packages/library/src/components/ui/select/context.ts +import type { InjectionKey, Ref } from 'vue' + +export interface SelectContext { + open: Ref + modelValue: Ref + disabled: Ref + triggerRef: Ref + contentId: string + itemLabels: Map + registerLabel: (value: string, label: string) => void + unregisterLabel: (value: string) => void +} + +export const SelectContextKey: InjectionKey = Symbol('SelectContext') +``` + +- [ ] **Step 2: Rewrite Select.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Rewrite SelectValue.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Rewrite SelectLabel.vue, SelectGroup.vue, SelectSeparator.vue** + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +- [ ] **Step 5: Simplify SelectItemText.vue** + +Label capture moves to `SelectItem.vue` in Task 12 (it reads its own root element's `textContent`, which already includes `SelectItemText`'s slot content), so `SelectItemText` becomes purely presentational: + +```vue + + +``` + +- [ ] **Step 6: Run type-check** + +Run: `bun run type-check` +Expected: Errors remaining only in `SelectTrigger.vue`, `SelectItem.vue`, `SelectContent.vue`, `SelectScrollUpButton.vue`, `SelectScrollDownButton.vue` (not rewritten until Tasks 12–13) — confirm no errors in the six files touched by this task. + +- [ ] **Step 7: Commit** + +```bash +git add packages/library/src/components/ui/select/context.ts \ + packages/library/src/components/ui/select/Select.vue \ + packages/library/src/components/ui/select/SelectValue.vue \ + packages/library/src/components/ui/select/SelectLabel.vue \ + packages/library/src/components/ui/select/SelectGroup.vue \ + packages/library/src/components/ui/select/SelectSeparator.vue \ + packages/library/src/components/ui/select/SelectItemText.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite the Select root family without reka-ui + +Root/Value/Label/Group/Separator/ItemText now share a small +provide/inject context. SelectValue's label lookup is backed by an +itemLabels map that SelectItem populates on mount (Task 12) — matching +the existing behavior where the trigger's displayed label only +resolves once the dropdown has opened at least once. +EOF +)" +``` + +--- + +## Task 12: Rewrite `SelectTrigger.vue` and `SelectItem.vue` + +**Files:** +- Modify: `packages/library/src/components/ui/select/SelectTrigger.vue` +- Modify: `packages/library/src/components/ui/select/SelectItem.vue` +- Test (existing, must still pass): `packages/library/tests/Select.spec.ts` + +**Interfaces:** +- Consumes: `SelectContextKey` (Task 11). +- Produces: no change to public props/slots (`{ class?, size?, disabled? }` on `SelectTrigger`; `{ value: string, disabled?, class? }` on `SelectItem`, slot `indicator-icon`). + +- [ ] **Step 1: Rewrite SelectTrigger.vue** + +```vue + + + + +``` + +- [ ] **Step 2: Rewrite SelectItem.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Run the existing Select test** + +Run: `cd packages/library && bunx vitest run tests/Select.spec.ts` +Expected: This will still fail (`SelectContent` isn't rewritten until Task 13, so `Select.vue`'s new context and `SelectContent`'s old reka-ui import are incompatible right now). This is expected mid-family state — proceed to Task 13, then re-run. + +- [ ] **Step 4: Commit** + +```bash +git add packages/library/src/components/ui/select/SelectTrigger.vue packages/library/src/components/ui/select/SelectItem.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite SelectTrigger/SelectItem without reka-ui + +role="combobox" trigger and role="option" items driven by the shared +Select context. SelectContent (Task 13) is needed before the Select +family works end-to-end again. +EOF +)" +``` + +--- + +## Task 13: Rewrite `SelectContent.vue`, `SelectScrollUpButton.vue`, `SelectScrollDownButton.vue` + +**Files:** +- Modify: `packages/library/src/components/ui/select/SelectContent.vue` +- Modify: `packages/library/src/components/ui/select/SelectScrollUpButton.vue` +- Modify: `packages/library/src/components/ui/select/SelectScrollDownButton.vue` +- Test (existing, must still pass): `packages/library/tests/Select.spec.ts` + +**Interfaces:** +- Consumes: `SelectContextKey` (Task 11), `useFocusTrap` (Task 2), `useDismissableLayer` (Task 3), `useRovingFocus` (Task 4), `usePopoverPosition` (Task 5), `useScrollLock` from `@vueuse/core`. +- Produces: no change to public props/slots on `SelectContent`; `SelectScrollUpButton`/`SelectScrollDownButton` gain a `viewport: HTMLElement | null` prop passed internally by `SelectContent` (not part of the family's public API — these two components aren't meant to be used standalone). + +- [ ] **Step 1: Rewrite SelectScrollUpButton.vue** + +```vue + + + + +``` + +- [ ] **Step 2: Rewrite SelectScrollDownButton.vue** + +```vue + + + + +``` + +- [ ] **Step 3: Rewrite SelectContent.vue** + +```vue + + + + +``` + +- [ ] **Step 4: Run the existing Select test — this is the first point since Task 11 where the whole family is wired together again** + +Run: `cd packages/library && bunx vitest run tests/Select.spec.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Run the full unit test suite and type-check** + +Run: `bun run test:unit` +Expected: All spec files pass (every `tests/*.spec.ts`, plus the 5 new composable specs and the `Primitive` spec from Tasks 1–5). + +Run: `bun run type-check` +Expected: No errors anywhere under `packages/library/src`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/library/src/components/ui/select/SelectContent.vue \ + packages/library/src/components/ui/select/SelectScrollUpButton.vue \ + packages/library/src/components/ui/select/SelectScrollDownButton.vue +git commit --no-gpg-sign -m "$(cat <<'EOF' +refactor: rewrite SelectContent and scroll buttons without reka-ui + +Positioning via usePopoverPosition, focus/dismiss via useFocusTrap + +useDismissableLayer, arrow/Home/End navigation via useRovingFocus, plus +inline typeahead. This is the last reka-ui import in the library. +EOF +)" +``` + +--- + +## Task 14: Drop the `reka-ui` dependency + +**Files:** +- Modify: `packages/library/package.json` +- Modify: `packages/library/vite.config.ts` + +**Interfaces:** +- Consumes: nothing (this task just removes now-unused dependency wiring; Tasks 1–13 already removed every `reka-ui` import). + +- [ ] **Step 1: Confirm no source file still imports reka-ui** + +Run: `grep -rl "reka-ui" packages/library/src` +Expected: no output (empty). If anything prints, stop and go fix that file before continuing — it means an earlier task was missed. + +- [ ] **Step 2: Remove reka-ui from package.json** + +In `packages/library/package.json`, remove the `reka-ui` line from `peerDependencies`: + +```diff + "peerDependencies": { + "vue": "^3.5.0", +- "reka-ui": "^2.10.0" + }, +``` + +- [ ] **Step 3: Remove reka-ui from the Vite build config** + +In `packages/library/vite.config.ts`: + +```diff + rollupOptions: { +- external: ['vue', 'reka-ui'], ++ external: ['vue'], + output: { + globals: { + vue: 'Vue', +- 'reka-ui': 'RekaUI', + }, + }, + }, +``` + +- [ ] **Step 4: Reinstall to update the lockfile** + +Run: `bun install` +Expected: `reka-ui` is removed from `bun.lock`. (It may still appear as a transitive dependency of `shadcn-vue` if that package lists it — check the diff; if so, that's fine, it's no longer *our* dependency, just something shadcn-vue's CLI tooling pulls in for its own use, not something consumers of `@nychthemeron/library` need to install.) + +- [ ] **Step 5: Run the full test suite and build** + +Run: `bun run test:unit` +Expected: All tests pass. + +Run: `bun run build` +Expected: Library and Storybook both build successfully with no reference to `reka-ui`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/library/package.json packages/library/vite.config.ts bun.lock +git commit --no-gpg-sign -m "$(cat <<'EOF' +chore: drop the reka-ui dependency + +Every component that used to depend on it has been rewritten on small +local primitives (Tasks 1-13). reka-ui is no longer a peerDependency of +@nychthemeron/library, so consumers no longer need to install it. +EOF +)" +``` + +--- + +## Task 15: Manual verification pass in Storybook + +**Files:** none (verification only). + +- [ ] **Step 1: Start Storybook** + +Run (background, per the project's browser-verification-setup memory): `bun run dev` + +This resolves `@nychthemeron/library` to `src/index.ts` directly in dev mode, so no library build is needed to see the changes. + +- [ ] **Step 2: Checkbox/Switch/RadioGroup — click and keyboard** + +Open the Checkbox, Switch, and RadioGroup stories. For each: click to toggle/select, confirm the visual state (fill color, thumb position) matches the pre-change screenshots. For RadioGroup specifically: Tab to the group, then use ArrowDown/ArrowUp to move between items and confirm the selection follows focus. + +- [ ] **Step 3: Dialog — open, close, focus, keyboard** + +Open the Dialog story. Click the trigger: confirm the dialog opens with a fade/zoom-in animation, focus lands inside the dialog (on the close button or first focusable element), and Tab/Shift+Tab cycle within the dialog without escaping to the page behind it. Press Escape: confirm it closes with a fade/zoom-out animation (not an instant disappearance) and focus returns to the trigger button. Click the overlay (outside the dialog panel): confirm it also closes. + +- [ ] **Step 4: Select — open, keyboard, typeahead, click** + +Open the Select story. Click the trigger: confirm the listbox opens below (or above, if you resize the viewport so there isn't room below) the trigger, positioned and sized correctly. Use ArrowDown/ArrowUp to move the highlighted item (confirm the highlighted item visibly changes and receives real focus). Press Home/End: confirm it jumps to the first/last item. Type a letter matching an item's first letter: confirm typeahead jumps to it. Press Enter: confirm it selects and closes, and the trigger now shows the selected label. Reopen and click an item directly with the mouse: confirm the same selection behavior. Press Escape while open: confirm it closes without changing the selection. + +- [ ] **Step 5: Report and fix** + +If any of the above deviates from the pre-migration behavior (compare against `main`/the commit before Task 1 if needed), fix the relevant component before considering this plan complete. Once everything in Steps 2–4 checks out, this plan is done — no commit needed for this task (it's verification only).