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>
83 KiB
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
bunfor every package-manager command (nevernpm/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.tsmust 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.tsetc. all live flat intests/, none colocated insrc/). - Run commands from the repo root unless a step says otherwise. Root scripts (
package.json):bun run test:unit(runs Vitest once inpackages/library),bun run type-check(vue-tsc --build),bun run build,bun run dev(starts Storybook, which resolves@nychthemeron/librarystraight tosrc/index.tsin 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-signfor 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 rendersprops.asnormally, or clones its single child VNode (merging in all non-prop attrs) whenasChildis true. Used by Task 6 (Button), Task 9 (DialogTrigger/DialogClose). -
Step 1: Write the failing test
// 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
// 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>))
}
},
})
// 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
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. Whileactive.valueis true: Tab/Shift+Tab cycle withincontainerRef's focusable descendants; on activation, focus moves tooptions.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
// 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
// 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
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. Whileactive.valueis true: Escape keydown callsonDismiss(); apointerdownoutsidecontainerRefcallsoptions.onPointerDownOutside?.(event)first, and if that handler doesn't callevent.preventDefault(), then callsonDismiss(). Used by Task 10 (DialogContent, DialogScrollContent) and Task 13 (SelectContent). -
Step 1: Write the failing test
// 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
// 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
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 }.focusIndexclamps (or wraps, ifloopis true, default true) the index into range and calls.focus()on that item.handleKeydownmoves 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, viafocusIndexonly) and Task 13 (SelectContent, viahandleKeydown). -
Step 1: Write the failing test
// 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
// 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
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' }(areactiveobject). Recomputes wheneveropenbecomes true (afternextTick) and on scroll/resize while open. Used by Task 13 (SelectContent). -
Step 1: Write the failing test
// 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
// 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
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:
Primitivefrom@/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
<!-- 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
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:
useVModelfrom@vueuse/core(already a dependency, established pattern — seepackages/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) }, emitsupdate: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
<!-- 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
<!-- 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
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,focusIndexonly — RadioGroup needs all four arrow keys regardless of layout orientation, per the ARIA radiogroup pattern, so it drivesfocusIndexdirectly rather thanhandleKeydown). -
Produces:
RadioGroupContextKey: InjectionKey<RadioGroupContext>(internal, not exported from the package). No change toRadioGroup/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
// 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
<!-- 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
<!-- 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
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>whereDialogContext = { 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
// 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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>
<!-- 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:
- 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
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),useScrollLockfrom@vueuse/core. -
Produces: no change to public props/slots (
{ class?, showCloseButton? }onDialogContent;{ class? }onDialogScrollContent). -
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
<!-- 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
<!-- 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
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: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
// 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
<!-- 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
<!-- 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
<!-- 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>
<!-- 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>
<!-- 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:
<!-- 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 12–13) — confirm no errors in the six files touched by this task.
- Step 7: Commit
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? }onSelectTrigger;{ value: string, disabled?, class? }onSelectItem, slotindicator-icon). -
Step 1: Rewrite SelectTrigger.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
<!-- 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
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),useScrollLockfrom@vueuse/core. -
Produces: no change to public props/slots on
SelectContent;SelectScrollUpButton/SelectScrollDownButtongain aviewport: HTMLElement | nullprop passed internally bySelectContent(not part of the family's public API — these two components aren't meant to be used standalone). -
Step 1: Rewrite SelectScrollUpButton.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
<!-- 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
<!-- 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 1–5).
Run: bun run type-check
Expected: No errors anywhere under packages/library/src.
- Step 6: Commit
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-uiimport). -
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:
"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:
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
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).