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