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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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