Skip to content

Commit

Permalink
feat: Radio Group 3/3 (#323)
Browse files Browse the repository at this point in the history
* feat: radio group accessibility

* fix: typo
  • Loading branch information
zernonia authored Aug 21, 2023
1 parent 7921020 commit e35072c
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 120 deletions.
72 changes: 72 additions & 0 deletions packages/radix-vue/src/RadioGroup/Radio.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts">
export interface RadioProps extends PrimitiveProps {
value?: string
disabled?: boolean
required?: boolean
checked?: boolean
}
</script>

<script setup lang="ts">
import {
computed,
inject,
ref,
toRefs,
} from 'vue'
import { RADIO_GROUP_INJECTION_KEY } from './RadioGroupRoot.vue'
import {
Primitive,
type PrimitiveProps,
usePrimitiveElement,
} from '@/Primitive'
const props = withDefaults(defineProps<RadioProps>(), {
disabled: false,
as: 'button',
})
const { value, checked } = toRefs(props)
const { primitiveElement, currentElement: triggerElement } = usePrimitiveElement()
const context = inject(RADIO_GROUP_INJECTION_KEY)
// We set this to true by default so that events bubble to forms without JS (SSR)
const isFormControl = computed(() =>
triggerElement.value ? Boolean(triggerElement.value.closest('form')) : true,
)
const hasConsumerStoppedPropagationRef = ref(false)
function handleClick(event: MouseEvent) {
context?.changeModelValue(value?.value)
if (isFormControl.value && 'isPropagationStopped' in event) {
// hasConsumerStoppedPropagationRef.value = event.isPropagationStopped() as boolean
// if radio is in a form, stop propagation from the button so that we only propagate
// one click event (from the input). We propagate changes from an input so that native
// form validation works and form events reflect radio updates.
if (!hasConsumerStoppedPropagationRef.value)
event.stopPropagation()
}
}
</script>

<template>
<Primitive
ref="primitiveElement"
v-bind="$attrs"
role="radio"
:type="as === 'button' ? 'button' : undefined"
:as="as"
:aria-checked="checked"
:as-child="asChild"
:disabled="disabled ? true : undefined"
:data-state="checked ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:value="value"
:required="required"
:name="context?.name"
@click="handleClick"
>
<slot />
</Primitive>
</template>
5 changes: 3 additions & 2 deletions packages/radix-vue/src/RadioGroup/RadioGroup.story.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ const radioStateSingle = ref('default')
</script>

<template>
<Story title="Radio Group" :layout="{ type: 'single', iframe: true }">
<Story title="Radio Group" :layout="{ type: 'single', iframe: false }">
<Variant title="default">
<RadioGroupRoot
v-model="radioStateSingle"
:loop="false"
class="flex flex-col gap-2.5"
default-value="default"
aria-label="View density"
orientation="vertical"
>
<div class="flex items-center">
<RadioGroupItem
id="r1"
class="bg-white w-[25px] h-[25px] rounded-full shadow-[0_2px_10px] shadow-blackA7 hover:bg-violet3 focus:shadow-[0_0_0_2px] focus:shadow-black outline-none cursor-default"
class="bg-white w-[25px] h-[25px] rounded-full shadow-[0_2px_10px] shadow-blackA7 hover:bg-violet3 focus:shadow-[0_0_0_2px] focus:shadow-black outline-none cursor-default data-[disabled]:bg-red-500"
value="default"
>
<RadioGroupIndicator
Expand Down
142 changes: 58 additions & 84 deletions packages/radix-vue/src/RadioGroup/RadioGroupItem.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
<script lang="ts">
export interface RadioGroupItemProps extends PrimitiveProps {
value?: string
disabled?: boolean
required?: boolean
}
export interface RadioGroupItemProps extends Omit<RadioProps, 'checked'> {}
interface RadioItemProvideValue {
disabled: ComputedRef<boolean>
Expand All @@ -12,6 +8,10 @@ interface RadioItemProvideValue {
export const RADIO_GROUP_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<RadioItemProvideValue>
export default {
inheritAttrs: false,
}
</script>

<script setup lang="ts">
Expand All @@ -21,112 +21,86 @@ import {
computed,
inject,
provide,
ref,
toRefs,
} from 'vue'
import { RADIO_GROUP_INJECTION_KEY } from './RadioGroupRoot.vue'
import {
Primitive,
type PrimitiveProps,
usePrimitiveElement,
} from '@/Primitive'
import { useArrowNavigation } from '@/shared'
import { RovingFocusItem } from '@/RovingFocus'
import Radio, { type RadioProps } from './Radio.vue'
import { useEventListener } from '@vueuse/core'
const props = withDefaults(defineProps<RadioGroupItemProps>(), {
disabled: false,
as: 'button',
})
const { value } = toRefs(props)
const { primitiveElement, currentElement } = usePrimitiveElement()
const context = inject(RADIO_GROUP_INJECTION_KEY)
const disabled = computed(() => {
return context?.disabled.value || props.disabled
})
const required = computed(() => {
return context?.required.value || props.required
})
const checked = computed(() => {
return context?.modelValue?.value === props.value
})
const disabled = computed(() => context?.disabled.value || props.disabled)
const required = computed(() => context?.required.value || props.required)
const checked = computed(() => context?.modelValue?.value === props.value)
provide(RADIO_GROUP_ITEM_INJECTION_KEY, { disabled, checked })
function changeOption(value: string) {
if (disabled.value)
return
context?.changeModelValue(value)
}
const isArrowKeyPressed = ref(false)
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
const { primitiveElement, currentElement } = usePrimitiveElement()
function handleKeydown(e: KeyboardEvent) {
if (disabled.value)
return
const newSelectedElement = useArrowNavigation(
e,
currentElement.value!,
context?.parentElement.value,
{
arrowKeyOptions: context?.orientation.value,
loop: context?.loop.value,
},
)
useEventListener('keydown', (event) => {
if (ARROW_KEYS.includes(event.key))
isArrowKeyPressed.value = true
})
useEventListener('keyup', () => {
isArrowKeyPressed.value = false
})
if (newSelectedElement) {
changeOption(newSelectedElement?.getAttribute('value')!)
context!.currentFocusedElement!.value = newSelectedElement
newSelectedElement.focus()
}
function handleFocus() {
setTimeout(() => {
/**
* Our `RovingFocusGroup` will focus the radio when navigating with arrow keys
* and we need to 'check' it in that case. We click it to 'check' it (instead
* of updating `context.value`) so that the radio change event fires.
*/
if (isArrowKeyPressed.value)
currentElement.value?.click()
}, 0)
}
const getTabIndex = computed(() => {
if (!context?.currentFocusedElement?.value) {
return checked.value ? '0' : '-1'
}
else {
return context?.currentFocusedElement?.value === currentElement.value
? '0'
: '-1'
}
})
</script>

<template>
<Primitive
ref="primitiveElement"
:type="as === 'button' ? 'button' : undefined"
:as="as"
role="radio"
data-radix-vue-collection-item
v-bind="$attrs"
:as-child="props.asChild"
:disabled="disabled ? true : undefined"
:data-state="checked ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:tabindex="getTabIndex"
:value="props.value"
:name="context?.name"
@click="changeOption(props.value!)"
@keydown="handleKeydown"
>
<slot />
</Primitive>
<RovingFocusItem :checked="checked" :disabled="disabled" as-child :focusable="!disabled" :active="checked">
<Radio
ref="primitiveElement" v-bind="{ ...$attrs, ...props }"
:checked="checked"
@keydown.enter.prevent
@focus="handleFocus"
>
<slot />
</Radio>
</RovingFocusItem>

<input
v-model="value"
type="radio"
aria-hidden="true"
tabindex="-1"
:value="props.value"
:default-value="checked"
:required="required"
:checked="checked"
:disabled="disabled"
style="
transform: translateX(-100%);
position: absolute;
pointer-events: none;
opacity: 0;
margin: 0px;
width: 25px;
height: 25px;
:style=" {
transform: 'translateX(-100%)',
position: 'absolute',
pointerEvents: 'none',
opacity: '0',
margin: '0px',
width: '25px',
height: '25px',
}
"
:checked="checked"
>
</template>
60 changes: 26 additions & 34 deletions packages/radix-vue/src/RadioGroup/RadioGroupRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@
import {
Primitive,
type PrimitiveProps,
usePrimitiveElement,
} from '@/Primitive'
import type { DataOrientation, Direction } from '@/shared/types'
import { useVModel } from '@vueuse/core'
import type { InjectionKey, Ref } from 'vue'
export interface RadioGroupRootProps extends PrimitiveProps {
modelValue?: string | string[]
onValueChange?: (value: string) => void
modelValue?: string
defaultValue?: string
value?: string
disabled?: boolean
name?: string
required?: boolean
Expand All @@ -21,14 +18,12 @@ export interface RadioGroupRootProps extends PrimitiveProps {
loop?: boolean
}
export interface RadioGroupRootEmits {
(e: 'update:modelValue', payload: string | string[]): void
(e: 'update:modelValue', payload: string): void
}
interface RadioGroupProvideValue {
modelValue?: Readonly<Ref<string | string[] | undefined>>
modelValue?: Readonly<Ref<string | undefined>>
changeModelValue: (value?: string) => void
parentElement: Ref<HTMLElement | undefined>
currentFocusedElement?: Ref<HTMLElement | undefined>
disabled: Ref<boolean>
loop: Ref<boolean>
orientation: Ref<DataOrientation | undefined>
Expand All @@ -41,7 +36,8 @@ export const RADIO_GROUP_INJECTION_KEY
</script>

<script setup lang="ts">
import { provide, ref } from 'vue'
import { provide, toRefs } from 'vue'
import { RovingFocusGroup } from '@/RovingFocus'
const props = withDefaults(defineProps<RadioGroupRootProps>(), {
disabled: false,
Expand All @@ -53,43 +49,39 @@ const props = withDefaults(defineProps<RadioGroupRootProps>(), {
const emits = defineEmits<RadioGroupRootEmits>()
const { primitiveElement, currentElement: parentElement }
= usePrimitiveElement()
const modelValue = useVModel(props, 'modelValue', emits, {
defaultValue: props.defaultValue,
passive: true,
})
const { disabled, loop, orientation, name, required } = toRefs(props)
provide<RadioGroupProvideValue>(RADIO_GROUP_INJECTION_KEY, {
modelValue,
changeModelValue: (value?: string) => {
modelValue.value = value
if (value && props.onValueChange)
props.onValueChange(value)
},
parentElement,
currentFocusedElement: ref(),
disabled: ref(props.disabled),
loop: ref(props.loop),
orientation: ref(props.orientation),
name: props.name,
required: ref(props.required),
disabled,
loop,
orientation,
name: name?.value,
required,
})
</script>

<template>
<Primitive
ref="primitiveElement"
role="radiogroup"
:data-disabled="props.disabled ? '' : undefined"
:as-child="props.asChild"
:as="as"
:required="props.required"
:aria-required="props.required"
:dir="props.dir"
:name="props.name"
>
<slot />
</Primitive>
<RovingFocusGroup as-child :orientation="orientation" :dir="dir" :loop="loop">
<Primitive
role="radiogroup"
:data-disabled="disabled ? '' : undefined"
:as-child="asChild"
:as="as"
:required="required"
:aria-orientation="orientation"
:aria-required="required"
:dir="dir"
:name="name"
>
<slot />
</Primitive>
</RovingFocusGroup>
</template>

0 comments on commit e35072c

Please sign in to comment.