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

83 KiB
Raw Blame History

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

// 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. 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

// 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. 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

// 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 }. 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

// 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' } (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

// 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: 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
<!-- 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: 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
<!-- 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, 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
// 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> 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
// 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), 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
<!-- 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 1213) — 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? } on SelectTrigger; { value: string, disabled?, class? } on SelectItem, slot indicator-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), 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

<!-- 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 15).

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 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:

   "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 24 checks out, this plan is done — no commit needed for this task (it's verification only).