Skip to content

Commit fa2411e

Browse files
feat: [RadioGroup] implements
1 parent d2d7c1f commit fa2411e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1091
-34
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Enter the component you want most in the components, leave the emojis and follow
4848
| NavigationMenu | 🚧 |
4949
| Popover | 🚧 |
5050
| [Progress](https://vue-primitives.netlify.app/?path=/story/components-progress--styled) ||
51-
| RadioGroup | 🚧 |
51+
| [RadioGroup](https://vue-primitives.netlify.app/?path=/story/components-radiogroup--styled) | |
5252
| ScrollArea | 🚧 |
5353
| Select | 🚧 |
5454
| [Separator](https://vue-primitives.netlify.app/?path=/story/components-separator--styled) ||

packages/vue-primitives/src/accordion/Accordion.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const handleKeydown = composeEventHandlers<KeyboardEvent>((event) => {
4040
if (!ACCORDION_KEYS.includes(event.key))
4141
return
4242
const target = event.target as HTMLElement
43-
const triggerCollection = getItems().filter(item => !item.ref?.disabled)
43+
const triggerCollection = getItems().filter(item => !item.ref.disabled)
4444
const triggerIndex = triggerCollection.findIndex(item => item.ref === target)
4545
const triggerCount = triggerCollection.length
4646
@@ -108,7 +108,7 @@ const handleKeydown = composeEventHandlers<KeyboardEvent>((event) => {
108108
}
109109
110110
const clampedIndex = nextIndex % triggerCount
111-
triggerCollection[clampedIndex]?.ref?.focus()
111+
triggerCollection[clampedIndex]?.ref.focus()
112112
})
113113
114114
provideAccordionContext({

packages/vue-primitives/src/accordion/stories/Accordion.stories.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,4 @@ export function Horizontal() {
4040
export function Chromatic() {
4141
return CChromatic
4242
}
43-
4443
Chromatic.parameters = { chromatic: { disable: false } }

packages/vue-primitives/src/primitive/Primitive.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ defineExpose({
2727
:is="asChild ? Slot : as"
2828
:ref="(el: any) => {
2929
const node = (el?.$el ?? el)
30-
elRef = node && node.nodeType === ELEMENT_NODE ? node : undefined
30+
const elNode = node && node.nodeType === ELEMENT_NODE ? node : undefined
31+
if (elNode === elRef) return
32+
elRef = elNode
3133
}"
3234
>
3335
<slot />

packages/vue-primitives/src/progress/stories/Progress.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export function Styled() {
1212
export function Chromatic() {
1313
return CChromatic
1414
}
15+
Chromatic.parameters = { chromatic: { disable: false } }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface BubbleInputProps {
2+
checked: boolean
3+
control: HTMLElement | undefined
4+
bubbles: boolean
5+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watch } from 'vue'
3+
import { useSize } from '../hooks/useSize.ts'
4+
import type { BubbleInputProps } from './BubbleInput.ts'
5+
6+
defineOptions({
7+
name: 'BubbleInput',
8+
})
9+
10+
const props = withDefaults(defineProps<BubbleInputProps>(), {
11+
checked: undefined,
12+
control: undefined,
13+
bubbles: true,
14+
})
15+
const elRef = shallowRef<HTMLInputElement>()
16+
17+
const controlSize = useSize(() => props.control)
18+
// TODO: Check if this is the correct way to create a change event
19+
// const initChecked = isIndeterminate(props.checked) ? false : props.checked
20+
21+
// Bubble checked change to parents (e.g form change event)
22+
watch(() => props.checked, (checked, prevChecked) => {
23+
const input = elRef.value
24+
if (!input)
25+
return
26+
27+
const inputProto = window.HTMLInputElement.prototype
28+
const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'checked') as PropertyDescriptor
29+
const setChecked = descriptor.set
30+
31+
if (prevChecked !== checked && setChecked) {
32+
// TODO: Check if this is the correct way to create a change event
33+
const event = new Event('change', { bubbles: props.bubbles })
34+
setChecked.call(input, checked)
35+
input.dispatchEvent(event)
36+
}
37+
})
38+
</script>
39+
40+
<template>
41+
<input
42+
ref="elRef"
43+
type="radio"
44+
aria-hidden
45+
tabindex="-1"
46+
:checked="checked"
47+
:style="{
48+
width: `${controlSize?.width || 0}px`,
49+
height: `${controlSize?.width || 0}px`,
50+
position: 'absolute',
51+
pointerEvents: 'none',
52+
opacity: 0,
53+
margin: 0,
54+
}"
55+
>
56+
</template>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createContext } from '../hooks/createContext.ts'
2+
import type { PrimitiveProps } from '../primitive/index.ts'
3+
4+
export interface RadioProps extends PrimitiveProps {
5+
name?: string
6+
value?: string
7+
checked?: boolean
8+
required?: boolean
9+
disabled?: boolean
10+
}
11+
12+
// eslint-disable-next-line ts/consistent-type-definitions
13+
export type RadioEmits = {
14+
'update:checked': [checked: boolean]
15+
}
16+
17+
export interface RadioContextValue {
18+
checked: () => boolean
19+
disabled: () => boolean
20+
}
21+
export const [provideRadioContext, useRadioContext] = createContext<RadioContextValue>('Radio')
22+
23+
export function getState(checked: boolean) {
24+
return checked ? 'checked' : 'unchecked'
25+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script setup lang="ts">
2+
import { computed, shallowRef, useAttrs } from 'vue'
3+
import { Primitive } from '../primitive/index.ts'
4+
import { composeEventHandlers } from '../utils/composeEventHandlers.ts'
5+
import { isFunction } from '../utils/is.ts'
6+
import { type RadioEmits, type RadioProps, getState, provideRadioContext } from './Radio.ts'
7+
import BubbleInput from './BubbleInput.vue'
8+
9+
defineOptions({
10+
name: 'Radio',
11+
inheritAttrs: false,
12+
})
13+
14+
const props = withDefaults(defineProps<RadioProps>(), {
15+
as: 'button',
16+
value: 'on',
17+
checked: false,
18+
})
19+
const emit = defineEmits<RadioEmits>()
20+
const attrs = useAttrs()
21+
const elRef = shallowRef<HTMLButtonElement>()
22+
23+
const hasConsumerStoppedPropagation = shallowRef(false)
24+
// We set this to true by default so that events bubble to forms without JS (SSR)
25+
const isFormControl = computed(() => elRef.value ? Boolean(elRef.value.closest('form')) : true)
26+
27+
type CliclEvent = Event & { _stopPropagation: Event['stopPropagation'], _isPropagationStopped: boolean, isPropagationStopped: () => boolean }
28+
const onClick = composeEventHandlers<CliclEvent>((event) => {
29+
event._stopPropagation = event.stopPropagation
30+
event._isPropagationStopped = false
31+
event.stopPropagation = function stopPropagation() {
32+
this._isPropagationStopped = true
33+
event._stopPropagation()
34+
}
35+
event.isPropagationStopped = function isPropagationStopped() {
36+
return this._isPropagationStopped
37+
}
38+
39+
isFunction(attrs.onClick) && attrs.onClick(event)
40+
}, (event) => {
41+
// radios cannot be unchecked so we only communicate a checked state
42+
if (!props.checked)
43+
emit('update:checked', true)
44+
if (isFormControl.value) {
45+
hasConsumerStoppedPropagation.value = event.isPropagationStopped()
46+
// if radio is in a form, stop propagation from the button so that we only propagate
47+
// one click event (from the input). We propagate changes from an input so that native
48+
// form validation works and form events reflect radio updates.
49+
if (!hasConsumerStoppedPropagation.value)
50+
event.stopPropagation()
51+
}
52+
})
53+
54+
provideRadioContext({
55+
checked() {
56+
return props.checked
57+
},
58+
disabled() {
59+
return props.disabled
60+
},
61+
})
62+
63+
defineExpose({
64+
$el: elRef,
65+
})
66+
</script>
67+
68+
<template>
69+
<Primitive
70+
:ref="(el: any) => elRef = el?.$el"
71+
:as="as"
72+
:as-child="asChild"
73+
type="button"
74+
role="radio"
75+
:aria-checked="checked"
76+
:data-state="getState(checked)"
77+
:data-disabled="disabled ? '' : undefined"
78+
:disabled="disabled"
79+
:value="value"
80+
v-bind="{
81+
...attrs,
82+
onClick,
83+
}"
84+
>
85+
<slot />
86+
</Primitive>
87+
88+
<BubbleInput
89+
v-if="isFormControl"
90+
:control="elRef"
91+
:bubbles="!hasConsumerStoppedPropagation"
92+
:name="name"
93+
:value="value"
94+
:checked="checked"
95+
:required="required"
96+
:disabled="disabled"
97+
:style="{
98+
transform: 'translateX(-100%)',
99+
}"
100+
/>
101+
</template>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Ref } from 'vue'
2+
import { createContext } from '../hooks/createContext.ts'
3+
import type { PrimitiveProps } from '../primitive/index.ts'
4+
import type { RovingFocusGroupProps } from '../roving-focus/index.ts'
5+
6+
export interface RadioGroupProps extends PrimitiveProps {
7+
name?: string
8+
required?: boolean
9+
disabled?: boolean
10+
dir?: RovingFocusGroupProps['dir']
11+
orientation?: RovingFocusGroupProps['orientation']
12+
loop?: RovingFocusGroupProps['loop']
13+
defaultValue?: string
14+
value?: string
15+
}
16+
17+
// eslint-disable-next-line ts/consistent-type-definitions
18+
export type RadioGroupEmits = {
19+
'update:value': [value: string]
20+
}
21+
22+
export interface RadioGroupContextValue {
23+
name: () => string | undefined
24+
required: () => boolean
25+
disabled: () => boolean
26+
value: Ref<string>
27+
onValueChange: (value: string) => void
28+
}
29+
30+
export const [provideRadioGroupContext, useRadioGroupContext] = createContext<RadioGroupContextValue>('RadioGroup')

0 commit comments

Comments
 (0)