Nychthemeron/docs/superpowers/plans/2026-07-13-remove-reka-ui.md
Matthew L McPeak d56ccce2fe 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 <noreply@anthropic.com>
2026-07-13 17:26:30 -04:00

2488 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<Teleport>`/`<Transition>` and VueUse's `useVModel`/`useScrollLock` (already dependencies). No new dependencies are added.
**Tech Stack:** Vue 3.5 (`useId()`, `<Teleport>`, `<Transition>`), `@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<string, unknown>))
}
},
})
```
```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<HTMLElement | null>, active: Ref<boolean>, 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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement>(FOCUSABLE_SELECTOR))
}
export interface FocusTrapOptions {
initialFocus?: () => HTMLElement | null
}
export function useFocusTrap(
containerRef: Ref<HTMLElement | null>,
active: Ref<boolean>,
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<HTMLElement | null>, active: Ref<boolean>, 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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>(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<HTMLElement | null>,
active: Ref<boolean>,
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<HTMLElement[]>, 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<HTMLElement[]>, 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<HTMLElement | null>, contentRef: Ref<HTMLElement | null>, open: Ref<boolean>): { 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<DOMRect>) {
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<HTMLElement | null>,
contentRef: Ref<HTMLElement | null>,
open: Ref<boolean>,
) {
const position = reactive<PopoverPosition>({ 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
<!-- packages/library/src/components/ui/button/Button.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { computed } from 'vue'
import { Primitive } from '@/lib/primitive'
import { cn } from '@/lib/utils'
import NychLoadingIcon from '@/components/NychLoadingIcon.vue'
import { buttonVariants } from '.'
interface Props {
as?: string
asChild?: boolean
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
variant: 'primary',
})
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:disabled="isDisabled"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<NychLoadingIcon v-if="loading" class="size-4" />
<slot />
</Primitive>
</template>
```
- [ ] **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
<!-- packages/library/src/components/ui/checkbox/Checkbox.vue -->
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: boolean
defaultValue?: boolean
disabled?: boolean
required?: boolean
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const checked = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue ?? false,
})
function toggle() {
if (props.disabled) return
checked.value = !checked.value
}
</script>
<template>
<button
type="button"
role="checkbox"
data-slot="checkbox"
:aria-checked="checked"
:aria-required="required"
:data-state="checked ? 'checked' : 'unchecked'"
:disabled="disabled"
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
@click="toggle"
>
<span
v-if="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<slot>
<CheckIcon />
</slot>
</span>
</button>
</template>
```
- [ ] **Step 3: Rewrite Switch.vue**
```vue
<!-- packages/library/src/components/ui/switch/Switch.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
modelValue?: boolean
defaultValue?: boolean
disabled?: boolean
required?: boolean
class?: HTMLAttributes['class']
size?: 'sm' | 'default'
}>(),
{ size: 'default' },
)
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const checked = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue ?? false,
})
function toggle() {
if (props.disabled) return
checked.value = !checked.value
}
</script>
<template>
<button
type="button"
role="switch"
data-slot="switch"
:data-size="size"
:aria-checked="checked"
:aria-required="required"
:data-state="checked ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled"
:class="cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
props.class,
)"
@click="toggle"
>
<span
data-slot="switch-thumb"
:data-state="checked ? 'checked' : 'unchecked'"
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
>
<slot name="thumb" />
</span>
</button>
</template>
```
- [ ] **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<RadioGroupContext>` (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<string | undefined>
disabled: Ref<boolean>
items: Ref<HTMLElement[]>
register: (el: HTMLElement) => void
unregister: (el: HTMLElement) => void
}
export const RadioGroupContextKey: InjectionKey<RadioGroupContext> = Symbol('RadioGroupContext')
```
- [ ] **Step 3: Rewrite RadioGroup.vue**
```vue
<!-- packages/library/src/components/ui/radio-group/RadioGroup.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, provide, ref } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useRovingFocus } from '@/lib/use-roving-focus'
import { RadioGroupContextKey } from './context'
const props = defineProps<{
modelValue?: string
defaultValue?: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue,
})
const disabled = computed(() => props.disabled ?? false)
const items = ref<HTMLElement[]>([])
function register(el: HTMLElement) {
items.value.push(el)
}
function unregister(el: HTMLElement) {
items.value = items.value.filter((item) => item !== el)
}
provide(RadioGroupContextKey, { modelValue, disabled, items, register, unregister })
const { focusIndex } = useRovingFocus(items, { orientation: 'vertical', loop: true })
function onKeydown(event: KeyboardEvent) {
const currentIndex = items.value.indexOf(document.activeElement as HTMLElement)
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
focusIndex(currentIndex + 1)
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
focusIndex(currentIndex - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusIndex(0)
} else if (event.key === 'End') {
event.preventDefault()
focusIndex(items.value.length - 1)
} else {
return
}
const value = (document.activeElement as HTMLElement | null)?.dataset.value
if (value !== undefined) modelValue.value = value
}
</script>
<template>
<div
data-slot="radio-group"
role="radiogroup"
:class="cn('grid gap-2 w-full', props.class)"
@keydown="onKeydown"
>
<slot />
</div>
</template>
```
- [ ] **Step 4: Rewrite RadioGroupItem.vue**
```vue
<!-- packages/library/src/components/ui/radio-group/RadioGroupItem.vue -->
<script setup lang="ts">
import { CircleIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { RadioGroupContextKey } from './context'
const props = defineProps<{
value: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const context = inject(RadioGroupContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const isChecked = computed(() => context.modelValue.value === props.value)
const isTabbable = computed(() => {
const current = context.modelValue.value ?? context.items.value[0]?.dataset.value
return props.value === current
})
function onClick() {
if (props.disabled || context.disabled.value) return
context.modelValue.value = props.value
}
onMounted(() => {
if (itemRef.value) context.register(itemRef.value)
})
onBeforeUnmount(() => {
if (itemRef.value) context.unregister(itemRef.value)
})
</script>
<template>
<button
ref="itemRef"
type="button"
role="radio"
data-slot="radio-group-item"
:data-value="value"
:aria-checked="isChecked"
:data-state="isChecked ? 'checked' : 'unchecked'"
:tabindex="isTabbable ? 0 : -1"
:disabled="disabled || context.disabled.value"
:class="
cn(
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@click="onClick"
>
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
<slot>
<CircleIcon class="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
</slot>
</span>
</button>
</template>
```
- [ ] **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<DialogContext>` where `DialogContext = { open: Ref<boolean>, 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<boolean>
titleId: string
descriptionId: string
}
export const DialogContextKey: InjectionKey<DialogContext> = Symbol('DialogContext')
```
- [ ] **Step 2: Rewrite Dialog.vue**
```vue
<!-- packages/library/src/components/ui/dialog/Dialog.vue -->
<script setup lang="ts">
import { provide, useId } from 'vue'
import { useVModel } from '@vueuse/core'
import { DialogContextKey } from './context'
const props = defineProps<{ open?: boolean, defaultOpen?: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
provide(DialogContextKey, {
open,
titleId: `dialog-title-${useId()}`,
descriptionId: `dialog-description-${useId()}`,
})
</script>
<template>
<slot />
</template>
```
- [ ] **Step 3: Rewrite DialogTrigger.vue**
```vue
<!-- packages/library/src/components/ui/dialog/DialogTrigger.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-trigger"
aria-haspopup="dialog"
:aria-expanded="context.open.value"
@click="context.open.value = true"
>
<slot />
</Primitive>
</template>
```
- [ ] **Step 4: Rewrite DialogOverlay.vue**
```vue
<!-- packages/library/src/components/ui/dialog/DialogOverlay.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Transition
enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 duration-100"
>
<div
v-if="context.open.value"
data-slot="dialog-overlay"
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
/>
</Transition>
</template>
```
- [ ] **Step 5: Rewrite DialogClose.vue**
```vue
<!-- packages/library/src/components/ui/dialog/DialogClose.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-close"
@click="context.open.value = false"
>
<slot />
</Primitive>
</template>
```
- [ ] **Step 6: Rewrite DialogTitle.vue and DialogDescription.vue**
```vue
<!-- packages/library/src/components/ui/dialog/DialogTitle.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<h2
:id="context.titleId"
data-slot="dialog-title"
:class="cn('text-base leading-none font-medium font-serif', props.class)"
>
<slot />
</h2>
</template>
```
```vue
<!-- packages/library/src/components/ui/dialog/DialogDescription.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<p
:id="context.descriptionId"
data-slot="dialog-description"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</p>
</template>
```
- [ ] **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
<!-- packages/library/src/components/ui/dialog/DialogContent.vue -->
<script setup lang="ts">
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogOverlay from './DialogOverlay.vue'
import DialogClose from './DialogClose.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], showCloseButton?: boolean }>(),
{ showCloseButton: true },
)
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<DialogOverlay />
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
>
<div
v-if="context.open.value"
ref="contentRef"
data-slot="dialog-content"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn('bg-popover text-popover-foreground border border-border grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm shadow-lg sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
<XIcon class="size-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</div>
</Transition>
</Teleport>
</template>
```
- [ ] **Step 3: Rewrite DialogScrollContent.vue**
```vue
<!-- packages/library/src/components/ui/dialog/DialogScrollContent.vue -->
<script setup lang="ts">
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogClose from './DialogClose.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
onPointerDownOutside: (event) => {
const target = event.target as HTMLElement
if (event.offsetX > target.clientWidth || event.offsetY > target.clientHeight) {
event.preventDefault()
}
},
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 duration-100"
>
<div
v-if="context.open.value"
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80"
>
<div
ref="contentRef"
data-slot="dialog-content"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
props.class,
)"
>
<slot />
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</div>
</div>
</Transition>
</Teleport>
</template>
```
- [ ] **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<SelectContext>` (internal, not exported) where:
```ts
interface SelectContext {
open: Ref<boolean>
modelValue: Ref<string | undefined>
disabled: Ref<boolean>
triggerRef: Ref<HTMLElement | null>
contentId: string
itemLabels: Map<string, string>
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<boolean>
modelValue: Ref<string | undefined>
disabled: Ref<boolean>
triggerRef: Ref<HTMLElement | null>
contentId: string
itemLabels: Map<string, string>
registerLabel: (value: string, label: string) => void
unregisterLabel: (value: string) => void
}
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')
```
- [ ] **Step 2: Rewrite Select.vue**
```vue
<!-- packages/library/src/components/ui/select/Select.vue -->
<script setup lang="ts">
import { computed, provide, reactive, ref, useId } from 'vue'
import { useVModel } from '@vueuse/core'
import { SelectContextKey } from './context'
const props = defineProps<{
modelValue?: string
defaultValue?: string
disabled?: boolean
open?: boolean
defaultOpen?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue,
})
const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
const disabled = computed(() => props.disabled ?? false)
const triggerRef = ref<HTMLElement | null>(null)
const itemLabels = reactive(new Map<string, string>())
provide(SelectContextKey, {
open,
modelValue,
disabled,
triggerRef,
contentId: `select-content-${useId()}`,
itemLabels,
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
unregisterLabel: (value: string) => { itemLabels.delete(value) },
})
</script>
<template>
<slot />
</template>
```
- [ ] **Step 3: Rewrite SelectValue.vue**
```vue
<!-- packages/library/src/components/ui/select/SelectValue.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { SelectContextKey } from './context'
const props = defineProps<{ placeholder?: string }>()
const context = inject(SelectContextKey)!
</script>
<template>
<span
data-slot="select-value"
:data-placeholder="context.modelValue.value ? undefined : placeholder"
>
<slot>{{ context.modelValue.value !== undefined ? context.itemLabels.get(context.modelValue.value) : placeholder }}</slot>
</span>
</template>
```
- [ ] **Step 4: Rewrite SelectLabel.vue, SelectGroup.vue, SelectSeparator.vue**
```vue
<!-- packages/library/src/components/ui/select/SelectLabel.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-label" :class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)">
<slot />
</div>
</template>
```
```vue
<!-- packages/library/src/components/ui/select/SelectGroup.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-group" role="group" :class="cn('scroll-my-1 p-1', props.class)">
<slot />
</div>
</template>
```
```vue
<!-- packages/library/src/components/ui/select/SelectSeparator.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-separator" role="separator" :class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)" />
</template>
```
- [ ] **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
<!-- packages/library/src/components/ui/select/SelectItemText.vue -->
<template>
<span data-slot="select-item-text"><slot /></span>
</template>
```
- [ ] **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 1213) — 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
<!-- packages/library/src/components/ui/select/SelectTrigger.vue -->
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
{ size: 'default' },
)
const context = inject(SelectContextKey)!
function onClick() {
if (props.disabled || context.disabled.value) return
context.open.value = !context.open.value
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
context.open.value = true
}
}
</script>
<template>
<button
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
type="button"
data-slot="select-trigger"
:data-size="size"
role="combobox"
:aria-controls="context.contentId"
:aria-expanded="context.open.value"
aria-autocomplete="none"
:disabled="disabled || context.disabled.value"
:class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
@click="onClick"
@keydown="onKeydown"
>
<slot />
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</button>
</template>
```
- [ ] **Step 2: Rewrite SelectItem.vue**
```vue
<!-- packages/library/src/components/ui/select/SelectItem.vue -->
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
import SelectItemText from './SelectItemText.vue'
const props = defineProps<{
value: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const context = inject(SelectContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const isSelected = computed(() => context.modelValue.value === props.value)
function select() {
if (props.disabled) return
context.modelValue.value = props.value
context.open.value = false
}
function onPointerMove() {
if (props.disabled) return
itemRef.value?.focus()
}
onMounted(() => {
context.registerLabel(props.value, itemRef.value?.textContent?.trim() ?? '')
})
onBeforeUnmount(() => {
context.unregisterLabel(props.value)
})
</script>
<template>
<div
ref="itemRef"
data-slot="select-item"
role="option"
:data-value="value"
:aria-selected="isSelected"
:data-disabled="disabled ? '' : undefined"
tabindex="-1"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)
"
@click="select"
@pointermove="onPointerMove"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<slot v-if="isSelected" name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</div>
</template>
```
- [ ] **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
<!-- packages/library/src/components/ui/select/SelectScrollUpButton.vue -->
<script setup lang="ts">
import { ChevronUpIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
function updateVisibility() {
visible.value = !!props.viewport && props.viewport.scrollTop > 0
}
watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop -= 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script>
<template>
<div
v-if="visible"
data-slot="select-scroll-up-button"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
>
<slot>
<ChevronUpIcon />
</slot>
</div>
</template>
```
- [ ] **Step 2: Rewrite SelectScrollDownButton.vue**
```vue
<!-- packages/library/src/components/ui/select/SelectScrollDownButton.vue -->
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
function updateVisibility() {
const viewport = props.viewport
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
}
watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop += 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script>
<template>
<div
v-if="visible"
data-slot="select-scroll-down-button"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
>
<slot>
<ChevronDownIcon />
</slot>
</div>
</template>
```
- [ ] **Step 3: Rewrite SelectContent.vue**
```vue
<!-- packages/library/src/components/ui/select/SelectContent.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject, nextTick, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { useRovingFocus } from '@/lib/use-roving-focus'
import { usePopoverPosition } from '@/lib/use-popover-position'
import { SelectContextKey } from './context'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(SelectContextKey)!
const contentRef = ref<HTMLElement | null>(null)
const viewportRef = ref<HTMLElement | null>(null)
const items = ref<HTMLElement[]>([])
const position = usePopoverPosition(context.triggerRef, contentRef, context.open)
function collectItems() {
const viewport = viewportRef.value
items.value = viewport
? Array.from(viewport.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
: []
}
function initialFocusTarget() {
collectItems()
return items.value.find((item) => item.dataset.value === context.modelValue.value) ?? items.value[0] ?? null
}
useFocusTrap(contentRef, context.open, { initialFocus: initialFocusTarget })
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
})
const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
let typeaheadBuffer = ''
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
function handleTypeahead(char: string) {
typeaheadBuffer += char.toLowerCase()
clearTimeout(typeaheadTimeout)
typeaheadTimeout = setTimeout(() => { typeaheadBuffer = '' }, 500)
const match = items.value.find((item) => (item.textContent ?? '').trim().toLowerCase().startsWith(typeaheadBuffer))
match?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
handleRovingKeydown(event)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
const value = (document.activeElement as HTMLElement | null)?.dataset.value
if (value !== undefined) {
context.modelValue.value = value
context.open.value = false
}
return
}
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
handleTypeahead(event.key)
}
}
watch(context.open, async (isOpen) => {
if (!isOpen) return
await nextTick()
collectItems()
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
>
<div
v-if="context.open.value"
:id="context.contentId"
ref="contentRef"
data-slot="select-content"
role="listbox"
:data-side="position.side"
:style="{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
minWidth: `${position.minWidth}px`,
maxHeight: `${position.maxHeight}px`,
}"
tabindex="-1"
v-bind="$attrs"
:class="cn(
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 border border-border min-w-36 rounded-lg shadow-md overflow-x-hidden overflow-y-auto',
props.class,
)"
@keydown="onKeydown"
>
<SelectScrollUpButton :viewport="viewportRef" />
<div ref="viewportRef" class="overflow-y-auto">
<slot />
</div>
<SelectScrollDownButton :viewport="viewportRef" />
</div>
</Transition>
</Teleport>
</template>
```
- [ ] **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 15).
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 113 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 24 checks out, this plan is done — no commit needed for this task (it's verification only).