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:
parent
8175db2cde
commit
07b44f6de4
3 changed files with 109 additions and 35 deletions
|
|
@ -1,25 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
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<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<RadioGroupRootEmits>()
|
||||
const props = defineProps<{
|
||||
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>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
v-slot="slotProps"
|
||||
<div
|
||||
data-slot="radio-group"
|
||||
role="radiogroup"
|
||||
:class="cn('grid gap-2 w-full', props.class)"
|
||||
v-bind="forwarded"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</RadioGroupRoot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,61 @@
|
|||
<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"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
<button
|
||||
ref="itemRef"
|
||||
type="button"
|
||||
role="radio"
|
||||
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="
|
||||
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"
|
||||
>
|
||||
<RadioGroupIndicator
|
||||
data-slot="radio-group-indicator"
|
||||
class="flex size-4 items-center justify-center"
|
||||
>
|
||||
<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>
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
11
packages/library/src/components/ui/radio-group/context.ts
Normal file
11
packages/library/src/components/ui/radio-group/context.ts
Normal 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')
|
||||
Loading…
Reference in a new issue