# 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).