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.
This commit is contained in:
Matthew McPeak 2026-07-13 18:13:23 -04:00
parent 8175db2cde
commit 07b44f6de4
3 changed files with 109 additions and 35 deletions

View file

@ -1,25 +1,68 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui" import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes } from "vue" import { computed, provide, ref } from 'vue'
import { reactiveOmit } from "@vueuse/core" import { useVModel } from '@vueuse/core'
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui" import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils" import { useRovingFocus } from '@/lib/use-roving-focus'
import { RadioGroupContextKey } from './context'
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<{
const emits = defineEmits<RadioGroupRootEmits>() modelValue?: string
defaultValue?: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, "class") const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const forwarded = useForwardPropsEmits(delegatedProps, emits) 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> </script>
<template> <template>
<RadioGroupRoot <div
v-slot="slotProps"
data-slot="radio-group" data-slot="radio-group"
role="radiogroup"
:class="cn('grid gap-2 w-full', props.class)" :class="cn('grid gap-2 w-full', props.class)"
v-bind="forwarded" @keydown="onKeydown"
> >
<slot v-bind="slotProps" /> <slot />
</RadioGroupRoot> </div>
</template> </template>

View file

@ -1,41 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import { CircleIcon } from '@lucide/vue'; 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'
import type { RadioGroupItemProps } from "reka-ui" const props = defineProps<{
import type { HTMLAttributes } from "vue" value: string
import { reactiveOmit } from "@vueuse/core" disabled?: boolean
import { class?: HTMLAttributes['class']
RadioGroupIndicator, }>()
RadioGroupItem,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>() const context = inject(RadioGroupContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const delegatedProps = reactiveOmit(props, "class") 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
})
const forwardedProps = useForwardProps(delegatedProps) 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> </script>
<template> <template>
<RadioGroupItem <button
ref="itemRef"
type="button"
role="radio"
data-slot="radio-group-item" data-slot="radio-group-item"
v-bind="forwardedProps" :data-value="value"
:aria-checked="isChecked"
:data-state="isChecked ? 'checked' : 'unchecked'"
:tabindex="isTabbable ? 0 : -1"
:disabled="disabled || context.disabled.value"
:class=" :class="
cn( 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', '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, props.class,
) )
" "
@click="onClick"
> >
<RadioGroupIndicator <span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
data-slot="radio-group-indicator"
class="flex size-4 items-center justify-center"
>
<slot> <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" /> <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> </slot>
</RadioGroupIndicator> </span>
</RadioGroupItem> </button>
</template> </template>

View file

@ -0,0 +1,11 @@
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')