diff --git a/packages/components/radio-group/README.md b/packages/components/radio-group/README.md index e6e3e3bbb..4e6dcd5ec 100644 --- a/packages/components/radio-group/README.md +++ b/packages/components/radio-group/README.md @@ -3,7 +3,7 @@ A set of checkable buttons—known as radio buttons—where no more than one of ![@oku-ui/radio-group](./../../../.github/assets/og/oku-radio-group.jpg) -Version | Downloads | Website +[![Version](https://img.shields.io/npm/v/@oku-ui/radio-group?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/@oku-ui/radio-group) [![Downloads](https://img.shields.io/npm/dm/@oku-ui/radio-group?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/@oku-ui/radio-group) ## Installation @@ -11,4 +11,4 @@ A set of checkable buttons—known as radio buttons—where no more than one of $ pnpm add @oku-ui/radio-group ``` -[Documentation](https://oku-ui.com/primitives/components/radio-group) +[Documentation](https://oku-ui.com/primitives/components/radio-group) \ No newline at end of file diff --git a/packages/components/radio-group/src/BubbleInput.ts b/packages/components/radio-group/src/BubbleInput.ts deleted file mode 100644 index ba77cfd08..000000000 --- a/packages/components/radio-group/src/BubbleInput.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { OkuElement } from '@oku-ui/primitive' -import { reactiveOmit, usePrevious, useSize } from '@oku-ui/use-composable' -import { defineComponent, h, mergeProps, reactive, ref, toRefs, watchEffect } from 'vue' -import type { PropType } from 'vue' - -const BUBBLE_INPUT_NAME = 'OkuBubbleInput' - -export type BubbleInputNaviteElement = OkuElement<'button'> -export type BubbleInputElement = Omit - -export interface BubbleInputProps { - checked: boolean - control: HTMLElement | null - bubbles: boolean -} - -const bubbleInputPropsObject = { - props: { - checked: { - type: Boolean as PropType, - required: true, - }, - control: { - type: Object as PropType, - default: null, - }, - bubbles: { - type: Boolean as PropType, - default: true, - }, - }, -} - -const bubbleInput = defineComponent({ - name: BUBBLE_INPUT_NAME, - inheritAttrs: false, - props: { - ...bubbleInputPropsObject.props, - }, - setup(props, { attrs }) { - const { control, checked, bubbles, ...inputProps } = toRefs(props) - const _reactive = reactive(inputProps) - const reactiveInputProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - - const inputRef = ref(null) - const prevChecked = usePrevious(checked) - const controlSize = useSize(control) - - watchEffect(() => { - const input = inputRef.value! - const inputProto = window.HTMLInputElement.prototype - const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'checked') - const setChecked = descriptor?.set - if (prevChecked.value !== checked.value && setChecked) { - const event = new Event('input', { bubbles: bubbles.value }) - setChecked.call(input, checked.value) - input.dispatchEvent(event) - } - }) - - return () => h('input', { - 'type': 'radio', - 'aria-hidden': true, - 'defaultChecked': checked.value, - ...mergeProps(attrs, reactiveInputProps), - 'tabindex': -1, - 'ref': inputRef, - 'style': { - ...attrs.style as any, - ...controlSize.value, - position: 'absolute', - pointerEvents: 'none', - opacity: 0, - margin: '0px', - }, - }) - }, -}) - -export const OkuBubbleInput = bubbleInput as typeof bubbleInput & - (new () => { - $props: BubbleInputNaviteElement - }) diff --git a/packages/components/radio-group/src/Radio.ts b/packages/components/radio-group/src/Radio.ts index 6d139016a..9cf883adb 100644 --- a/packages/components/radio-group/src/Radio.ts +++ b/packages/components/radio-group/src/Radio.ts @@ -1,75 +1,17 @@ -import { Primitive, primitiveProps } from '@oku-ui/primitive' -import type { OkuElement } from '@oku-ui/primitive' -import { createProvideScope } from '@oku-ui/provide' -import { reactiveOmit, useComposedRefs, useForwardRef } from '@oku-ui/use-composable' import { computed, defineComponent, h, mergeProps, reactive, ref, toRefs } from 'vue' -import type { PropType, Ref } from 'vue' +import { reactiveOmit, useComposedRefs, useForwardRef, useListeners } from '@oku-ui/use-composable' import { composeEventHandlers } from '@oku-ui/utils' -import { getState, scopeRadioProps } from './utils' -import { OkuBubbleInput } from './BubbleInput' - -const RADIO_NAME = 'OkuRadio' - -export const [createRadioProvide, createRadioScope] = createProvideScope(RADIO_NAME) - -type RadioProvideValue = { - checked: Ref - disabled?: Ref -} - -export const useRadioScope = createRadioScope() - -export const [radioProvider, useRadioInject] = createRadioProvide(RADIO_NAME) - -export type RadioIntrinsicNaviteElement = OkuElement<'button'> -export type RadioElement = HTMLButtonElement - -export interface RadioProps { - checked?: boolean - required?: boolean - disabled?: boolean - value?: string - name?: string -} - -export type RadioEmits = { - check: [] - click: (event: MouseEvent) => void -} - -export const radioProps = { - props: { - checked: { - type: Boolean as PropType, - default: undefined, - }, - required: { - type: Boolean as PropType, - default: undefined, - }, - disabled: { - type: Boolean as PropType, - default: undefined, - }, - name: { - type: String as PropType, - default: undefined, - }, - value: { - type: String as PropType, - default: 'on', - }, - ...primitiveProps, - }, - emits: { - check: () => true, - // eslint-disable-next-line unused-imports/no-unused-vars - click: (event: MouseEvent) => true, - }, -} +import { Primitive } from '@oku-ui/primitive' +import { OkuBubbleInput } from './bubble-input' +import { getState } from './utils' +import { RADIO_NAME, radioProps, radioProvider, scopeRadioProps } from './props' +import type { RadioEmits, RadioNativeElement } from './props' const radio = defineComponent({ name: RADIO_NAME, + components: { + OkuBubbleInput, + }, inheritAttrs: false, props: { ...radioProps.props, @@ -80,27 +22,32 @@ const radio = defineComponent({ const { scopeOkuRadio, name, + // checked, checked: checkedProp, required, disabled, value, - ...groupProps + ...radioProps } = toRefs(props) - const checked = computed(() => checkedProp.value || false) - const _reactive = reactive(groupProps) - const reactiveGroupProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - const hasConsumerStoppedPropagationRef = ref(false) - const buttonRef = ref(null) + const _reactive = reactive(radioProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + const forwardedRef = useForwardRef() - const composedRefs = useComposedRefs(buttonRef, forwardedRef) + const emits = useListeners() + const checked = computed(() => checkedProp.value || false) + + const buttonRef = ref(null) + const composedRefs = useComposedRefs(forwardedRef, buttonRef) + const hasConsumerStoppedPropagationRef = ref(false) + // We set this to true by default so that events bubble to forms without JS (SSR) const isFormControl = computed(() => buttonRef.value ? Boolean(buttonRef.value.closest('form')) : false) radioProvider({ + scope: scopeOkuRadio.value, checked, disabled, - scope: scopeOkuRadio.value, }) return () => [ @@ -108,21 +55,22 @@ const radio = defineComponent({ 'type': 'button', 'role': 'radio', 'aria-checked': checked.value, - 'data-state': getState(checked.value || false), + 'data-state': computed(() => getState(checked.value)).value, 'data-disabled': disabled.value ? '' : undefined, 'disabled': disabled.value, 'value': value.value, - ...mergeProps(attrs, reactiveGroupProps), + ...mergeProps(attrs, otherProps, emits), 'ref': composedRefs, - 'onClick': composeEventHandlers((e: MouseEvent) => { - emit('click', e) - }, (event: MouseEvent) => { + 'onClick': composeEventHandlers((event) => { + emit('click', event) + }, (event) => { // radios cannot be unchecked so we only communicate a checked state if (!checked.value) emit('check') if (isFormControl.value) { - // TODO: check `isPropagationStopped` + // TODO: isPropagationStopped() is not supported in vue // hasConsumerStoppedPropagationRef.value = event.isPropagationStopped() + // 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. @@ -130,29 +78,23 @@ const radio = defineComponent({ event.stopPropagation() } }), - }, { - default: () => slots.default?.(), - }), + }, () => slots.default?.()), + isFormControl.value && h(OkuBubbleInput, { control: buttonRef.value, - bubbles: !hasConsumerStoppedPropagationRef.value, + bubbles: computed(() => !hasConsumerStoppedPropagationRef.value).value, name: name.value, value: value.value, - checked: checked.value || false, + checked: checked.value, required: required.value, disabled: disabled.value, // We transform because the input is absolutely positioned but we have // rendered it **after** the button. This pulls it back to sit on top // of the button. - style: { - transform: 'translateX(-100%)', - }, + style: { transform: 'translateX(-100%)' }, }), ] }, }) -export const OkuRadio = radio as typeof radio & - (new () => { - $props: RadioIntrinsicNaviteElement - }) +export const OkuRadio = radio as typeof radio & (new () => { $props: RadioNativeElement }) diff --git a/packages/components/radio-group/src/RadioGroup.ts b/packages/components/radio-group/src/RadioGroup.ts deleted file mode 100644 index d6874378c..000000000 --- a/packages/components/radio-group/src/RadioGroup.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { OkuElement, PrimitiveProps } from '@oku-ui/primitive' -import { Primitive, primitiveProps } from '@oku-ui/primitive' -import { computed, defineComponent, h, mergeProps, reactive, toRefs, useModel } from 'vue' -import type { PropType, Ref } from 'vue' -import { createProvideScope } from '@oku-ui/provide' -import { OkuRovingFocusGroup, createRovingFocusGroupScope } from '@oku-ui/roving-focus' -import { reactiveOmit, useControllable, useForwardRef } from '@oku-ui/use-composable' -import { useDirection } from '@oku-ui/direction' -import type { RovingFocusGroupProps } from '@oku-ui/roving-focus' - -import { type RadioProps, createRadioScope } from './Radio' -import { scopeRadioGroupProps } from './utils' - -const RADIO_GROUP_NAME = 'OkuRadioGroup' - -export const [createRadioGroupProvider, createRadioGroupScope] = createProvideScope(RADIO_GROUP_NAME, [ - createRovingFocusGroupScope, - createRadioScope, -]) - -export const [RadioGroupProvider, useRadioGroupInject] - = createRadioGroupProvider(RADIO_GROUP_NAME) - -export const useRovingFocusGroupScope = createRovingFocusGroupScope() - -export type RadioGroupNaviteElement = OkuElement<'div'> -export type RadioElement = HTMLDivElement - -export interface RadioGroupProvideValue { - name?: Ref - required: Ref - disabled: Ref - value?: Ref - onValueChange: (value: string) => void -} - -export type RadioGroupEmits = { - 'update:modelValue': [value: string | undefined] - valueChange: [value: string | undefined] -} - -export interface RadioGroupProps extends PrimitiveProps { - modelValue?: string | undefined - name?: RadioGroupProvideValue['name'] - required?: RadioProps['required'] - disabled?: RadioProps['disabled'] - dir?: RovingFocusGroupProps['dir'] - orientation?: RovingFocusGroupProps['orientation'] - loop?: RovingFocusGroupProps['loop'] - defaultValue?: string - value?: RadioGroupProvideValue['value'] -} - -export const radioGroupProps = { - props: { - modelValue: { - type: [String] as PropType< - string | undefined - >, - default: undefined, - }, - name: { - type: String as PropType, - default: undefined, - }, - required: { - type: Boolean as PropType, - default: false, - }, - disabled: { - type: Boolean as PropType, - default: false, - }, - dir: { - type: String as PropType, - default: undefined, - }, - orientation: { - type: String as PropType, - default: undefined, - }, - loop: { - type: Boolean as PropType, - default: true, - }, - defaultValue: { - type: String as PropType, - default: undefined, - }, - value: { - type: String as PropType, - default: undefined, - }, - ...primitiveProps, - }, - emits: { - // eslint-disable-next-line unused-imports/no-unused-vars - 'update:modelValue': (value: string) => true, - // eslint-disable-next-line unused-imports/no-unused-vars - 'valueChange': (value: string) => true, - }, -} - -const radioGroup = defineComponent({ - name: RADIO_GROUP_NAME, - inheritAttrs: false, - props: { - ...radioGroupProps.props, - ...scopeRadioGroupProps, - }, - emits: radioGroupProps.emits, - setup(props, { slots, emit, attrs }) { - const { - scopeOkuRadioGroup, - name, - defaultValue, - value: valueProp, - required, - disabled, - orientation, - dir, - loop, - ...groupProps - } = toRefs(props) - const _reactive = reactive(groupProps) - const reactiveGroupProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - - const rovingFocusGroupScope = useRovingFocusGroupScope(scopeOkuRadioGroup.value) - const direction = useDirection(dir) - - const forwardedRef = useForwardRef() - const modelValue = useModel(props, 'modelValue') - const proxyValue = computed(() => { - if (modelValue.value !== undefined) - return modelValue.value - if (valueProp.value !== undefined) - return valueProp.value - return undefined - }) - - const { state, updateValue } = useControllable({ - prop: computed(() => proxyValue.value), - defaultProp: computed(() => defaultValue.value), - onChange: (result) => { - emit('valueChange', result) - modelValue.value = result - }, - }) - - RadioGroupProvider({ - scope: props.scopeOkuRadioGroup, - name, - required, - disabled, - value: state, - onValueChange(value: string) { - updateValue(value) - }, - }) - - return () => - h(OkuRovingFocusGroup, { - asChild: true, - ...rovingFocusGroupScope, - orientation: orientation.value, - dir: direction.value, - loop: loop.value, - }, { - default: () => h(Primitive.div, { - 'role': 'radiogroup', - 'aria-required': required.value, - 'aria-oriented': orientation.value, - 'data-disabled': disabled.value ? '' : undefined, - 'dir': direction.value, - ...mergeProps(attrs, reactiveGroupProps), - 'ref': forwardedRef, - }, { - default: () => slots.default?.(), - }), - }) - }, -}) - -export const OkuRadioGroup = radioGroup as typeof radioGroup & - (new () => { - $props: RadioGroupNaviteElement - }) diff --git a/packages/components/radio-group/src/RadioGroupIndicator.ts b/packages/components/radio-group/src/RadioGroupIndicator.ts deleted file mode 100644 index 00bd9f7ba..000000000 --- a/packages/components/radio-group/src/RadioGroupIndicator.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { defineComponent, h, mergeProps, reactive, toRefs } from 'vue' -import { reactiveOmit, useForwardRef } from '@oku-ui/use-composable' -import type { RadioElement } from './Radio' -import { useRadioScope } from './Radio' -import { OkuRadioIndicator } from './RadioIndicator' -import type { RadioIndicatorNaviteElement, RadioIndicatorProps } from './RadioIndicator' -import { scopeRadioGroupProps } from './utils' -import { radioGroupProps } from './RadioGroup' - -const INDICATOR_NAME = 'OkuRadioGroupIndicator' - -export type RadioGroupIndicatorNaviteElement = RadioIndicatorNaviteElement -export type RadioGroupIndicatorElement = RadioElement - -export interface RadioGroupIndicatorProps extends RadioIndicatorProps { } - -export const radioGroupIndicatorProps = { - props: { - ...radioGroupProps.props, - }, -} - -const RadioGroupIndicator = defineComponent({ - name: INDICATOR_NAME, - inheritAttrs: false, - props: { - ...radioGroupIndicatorProps.props, - ...scopeRadioGroupProps, - }, - setup(props, { attrs }) { - const { scopeOkuRadioGroup, ...indicatorProps } = toRefs(props) - const _reactive = reactive(indicatorProps) - const reactiveIndicatorProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - const radioScope = useRadioScope(scopeOkuRadioGroup.value) - const forwardedRef = useForwardRef() - - return () => h(OkuRadioIndicator, { - ...radioScope, - ...mergeProps(attrs, reactiveIndicatorProps), - ref: forwardedRef, - }) - }, -}) - -export const OkuRadioGroupIndicator = RadioGroupIndicator as typeof RadioGroupIndicator & - (new () => { - $props: RadioGroupIndicatorNaviteElement - }) diff --git a/packages/components/radio-group/src/RadioGroupItem.ts b/packages/components/radio-group/src/RadioGroupItem.ts deleted file mode 100644 index 9e88defe5..000000000 --- a/packages/components/radio-group/src/RadioGroupItem.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { computed, defineComponent, h, mergeProps, onBeforeUnmount, onMounted, reactive, ref, toRefs } from 'vue' -import { reactiveOmit, useComposedRefs, useForwardRef } from '@oku-ui/use-composable' - -import { OkuRovingFocusGroupItem } from '@oku-ui/roving-focus' -import { composeEventHandlers } from '@oku-ui/utils' -import { propsOmit } from '@oku-ui/primitive' -import { useRadioGroupInject, useRovingFocusGroupScope } from './RadioGroup' -import type { RadioGroupNaviteElement } from './RadioGroup' -import type { RadioElement, RadioEmits, RadioProps } from './Radio' -import { OkuRadio, radioProps, useRadioScope } from './Radio' -import { ARROW_KEYS, scopeRadioGroupProps } from './utils' - -const ITEM_NAME = 'OkuRadioGroupItem' - -export type RadioGroupItemNaviteElement = RadioGroupNaviteElement -export type RadioGroupItemElement = HTMLDivElement - -export interface RadioGroupItemProps extends Omit { - value: string -} - -export type RadioGroupItemEmits = Omit & { - focus: [event: FocusEvent] -} - -export const radioGroupItemProps = { - props: { - ...propsOmit(radioProps.props, ['name']), - }, - emits: { - ...propsOmit(radioProps.emits, ['check']), - // eslint-disable-next-line unused-imports/no-unused-vars - focus: (event: FocusEvent) => true, - }, -} - -const RadioGroupItem = defineComponent({ - name: ITEM_NAME, - inheritAttrs: false, - props: { - ...radioGroupItemProps.props, - ...scopeRadioGroupProps, - }, - emits: radioGroupItemProps.emits, - setup(props, { slots, emit, attrs }) { - const { - scopeOkuRadioGroup, - disabled, - ...itemProps - } = toRefs(props) - const _reactive = reactive(itemProps) - const reactiveItemProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - - const inject = useRadioGroupInject(ITEM_NAME, scopeOkuRadioGroup.value) - - const isDisabled = computed(() => inject.disabled.value || disabled.value) - const rovingFocusGroupScope = useRovingFocusGroupScope(scopeOkuRadioGroup.value) - const radioScope = useRadioScope(scopeOkuRadioGroup.value) - - const rootRef = ref(null) - const forwardedRef = useForwardRef() - const composedRefs = useComposedRefs(rootRef, forwardedRef) - - const checked = computed(() => inject.value?.value === reactiveItemProps.value) - const isArrowKeyPressedRef = ref(false) - - const handleKeyDown = (event: KeyboardEvent) => { - if (ARROW_KEYS.includes(event.key)) - isArrowKeyPressedRef.value = true - } - const handleKeyUp = () => { - isArrowKeyPressedRef.value = false - } - - onMounted(() => { - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('keyup', handleKeyUp) - }) - - onBeforeUnmount(() => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('keyup', handleKeyUp) - }) - - return () => h(OkuRovingFocusGroupItem, { - asChild: true, - ...rovingFocusGroupScope, - focusable: !isDisabled.value, - active: checked.value, - }, { - default: () => h(OkuRadio, { - disabled: isDisabled.value, - required: inject.required.value || reactiveItemProps.required, - checked: checked.value, - ...radioScope, - ...mergeProps(attrs, reactiveItemProps), - ref: composedRefs, - onCheck: () => { - return inject.onValueChange(props.value) - }, - onKeydown: composeEventHandlers((event: any) => { - // According to WAI ARIA, radio groups don't activate items on enter keypress - if (event.key === 'Enter') - event.preventDefault() - }), - onFocus: composeEventHandlers((e) => { - emit('focus', e) - }, () => { - /** - * 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. - */ - - setTimeout(() => { - if (isArrowKeyPressedRef.value) - rootRef.value?.click() - }, 0) - }), - - }, { - default: () => slots.default?.(), - }), - }) - }, -}) - -export const OkuRadioGroupItem = RadioGroupItem as typeof RadioGroupItem & - (new () => { - $props: RadioGroupItemNaviteElement - }) diff --git a/packages/components/radio-group/src/RadioIndicator.ts b/packages/components/radio-group/src/RadioIndicator.ts deleted file mode 100644 index 48d0570c2..000000000 --- a/packages/components/radio-group/src/RadioIndicator.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Primitive, primitiveProps } from '@oku-ui/primitive' -import type { OkuElement, PrimitiveProps } from '@oku-ui/primitive' -import { computed, defineComponent, h, mergeProps, reactive, toRefs } from 'vue' -import type { PropType } from 'vue' -import { OkuPresence } from '@oku-ui/presence' -import { reactiveOmit, useForwardRef } from '@oku-ui/use-composable' -import { useRadioInject } from './Radio' -import { getState, scopeRadioProps } from './utils' - -const INDICATOR_NAME = 'OkuRadioIndicator' - -export type RadioIndicatorNaviteElement = OkuElement<'span'> -export type RadioIndicatorElement = HTMLSpanElement - -export interface RadioIndicatorProps extends PrimitiveProps { - /** - * Used to force mounting when more control is needed. Useful when - * controlling animation with React animation libraries. - */ - forceMount?: true -} - -export const radioIndicatorProps = { - props: { - forceMount: { - type: Boolean as PropType, - default: undefined, - }, - ...primitiveProps, - }, -} - -const RadioIndicator = defineComponent({ - name: INDICATOR_NAME, - inheritAttrs: false, - props: { - ...radioIndicatorProps.props, - ...scopeRadioProps, - }, - setup(props, { attrs }) { - const { forceMount, scopeOkuRadio, ...indicatorProps } = toRefs(props) - const _reactive = reactive(indicatorProps) - const reactiveIndicatorProps = reactiveOmit(_reactive, (key, _value) => key === undefined) - const inject = useRadioInject(INDICATOR_NAME, scopeOkuRadio.value) - const forwardedRef = useForwardRef() - - return () => { - return h(OkuPresence, { - present: computed(() => forceMount.value || inject.checked.value).value, - }, { - default: () => - h(Primitive.span, { - 'data-state': getState(inject.checked.value), - 'data-disabled': inject.disabled?.value ? '' : undefined, - ...mergeProps(attrs, reactiveIndicatorProps), - 'ref': forwardedRef, - }), - }) - } - }, -}) - -export const OkuRadioIndicator = RadioIndicator as typeof RadioIndicator & - (new () => { - $props: RadioIndicatorNaviteElement - }) diff --git a/packages/components/radio-group/src/bubble-input.ts b/packages/components/radio-group/src/bubble-input.ts new file mode 100644 index 000000000..ef0b08d98 --- /dev/null +++ b/packages/components/radio-group/src/bubble-input.ts @@ -0,0 +1,61 @@ +import { computed, defineComponent, h, mergeProps, reactive, ref, toRefs, watchEffect } from 'vue' +import { reactiveOmit, usePrevious, useSize } from '@oku-ui/use-composable' +import { RADIO_BUBBLE_INPUT_NAME, bubbleInputProps } from './props' +import type { BubbleInputNativeElement } from './props' + +const bubbleInput = defineComponent({ + name: RADIO_BUBBLE_INPUT_NAME, + inheritAttrs: false, + props: { + ...bubbleInputProps.props, + }, + emits: bubbleInputProps.emits, + setup(props, { attrs }) { + const { + checked, + bubbles, + control, + ...inputProps + } = toRefs(props) + + const _reactive = reactive(inputProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + + const inputRef = ref(null) + const prevChecked = usePrevious(checked) + const controlSize = computed(() => useSize(control)) + + // Bubble checked change to parents (e.g form change event) + watchEffect(() => { + const input = inputRef.value! + const inputProto = window.HTMLInputElement.prototype + const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'checked') as PropertyDescriptor + const setChecked = descriptor.set + + if (prevChecked.value !== checked.value && setChecked) { + const event = new Event('click', { bubbles: bubbles.value }) + setChecked.call(input, checked.value) + input.dispatchEvent(event) + } + }) + + return () => h('input', { + 'type': 'radio', + 'aria-hidden': true, + 'defaultChecked': checked.value, + ...mergeProps(attrs, otherProps), + 'tabIndex': -1, + 'ref': inputRef, + 'style': { + ...attrs.style as any, + ...controlSize.value, + position: 'absolute', + pointerEvents: 'none', + opacity: 0, + margin: '0px', + }, + }) + }, +}) + +export const OkuBubbleInput = bubbleInput as typeof bubbleInput & (new () => { $props: BubbleInputNativeElement }) diff --git a/packages/components/radio-group/src/index.ts b/packages/components/radio-group/src/index.ts index 524fe119f..582a52f63 100644 --- a/packages/components/radio-group/src/index.ts +++ b/packages/components/radio-group/src/index.ts @@ -1,44 +1,23 @@ -export { - OkuRadioGroup, - createRadioGroupScope, - radioGroupProps, -} from './RadioGroup' +export { OkuRadioGroup } from './radio-group' +export { OkuRadioGroupItem } from './radio-group-item' +export { OkuRadioGroupIndicator } from './radio-group-indicator' export type { - RadioElement, - RadioGroupNaviteElement, RadioGroupProps, RadioGroupEmits, -} from './RadioGroup' - -export { - OkuRadioGroupItem, - radioGroupItemProps, -} from './RadioGroupItem' + RadioGroupElement, + RadioGroupNativeElement, +} from './props' export type { RadioGroupItemProps, - RadioGroupItemElement, RadioGroupItemEmits, -} from './RadioGroupItem' - -export { - OkuRadioGroupIndicator, - radioGroupIndicatorProps, -} from './RadioGroupIndicator' + RadioGroupItemElement, + RadioGroupItemNativeElement, +} from './props' export type { - RadioGroupIndicatorElement, RadioGroupIndicatorProps, - RadioGroupIndicatorNaviteElement, -} from './RadioGroupIndicator' - -export { - scopeRadioGroupProps, - scopeRadioProps, -} from './utils' - -export type { - ScopeRadio, - ScopeRadioGroup, -} from './utils' + RadioGroupIndicatorElement, + RadioGroupIndicatorNativeElement, +} from './props' diff --git a/packages/components/radio-group/src/props.ts b/packages/components/radio-group/src/props.ts new file mode 100644 index 000000000..a842336d3 --- /dev/null +++ b/packages/components/radio-group/src/props.ts @@ -0,0 +1,284 @@ +import type { PropType, Ref, UnwrapRef } from 'vue' +import { ScopePropObject, createProvideScope } from '@oku-ui/provide' +import type { Scope } from '@oku-ui/provide' +import { primitiveProps, propsOmit } from '@oku-ui/primitive' +import type { OkuElement, PrimitiveProps } from '@oku-ui/primitive' +import { createRovingFocusGroupScope } from '@oku-ui/roving-focus' +import type { RovingFocusGroupProps } from '@oku-ui/roving-focus' + +export type ScopeRadio = T & { scopeOkuRadio?: Scope } + +export const scopeRadioProps = { + scopeOkuRadio: { + ...ScopePropObject, + }, +} + +export const RADIO_NAME = 'OkuRadio' +export const RADIO_INDICATOR_NAME = 'OkuRadioIndicator' +export const RADIO_BUBBLE_INPUT_NAME = 'OkuBubbleInput' + +/* ------------------------------------------------------------------------------------------------- + * Radio - radio.ts + * ----------------------------------------------------------------------------------------------- */ + +export type RadioNativeElement = OkuElement<'button'> +export type RadioElement = HTMLButtonElement + +export const [createRadioProvide, createRadioScope] = createProvideScope(RADIO_NAME) + +type RadioInjectValue = { + checked: Ref + disabled?: Ref +} + +export const [radioProvider, useRadioInject] = createRadioProvide(RADIO_NAME) + +export interface RadioProps extends PrimitiveProps { + checked: boolean + required?: boolean + name?: string + disabled?: boolean + value: string +} + +export type RadioEmits = { + check: [] + click: [event: MouseEvent] +} + +export const radioProps = { + props: { + checked: { + type: Boolean as PropType, + // default: false, + default: undefined, + }, + required: { + type: Boolean as PropType, + }, + name: { + type: String as PropType, + }, + disabled: { + type: Boolean as PropType, + }, + value: { + type: String as PropType, + default: 'on', + }, + ...primitiveProps, + }, + emits: { + check: () => true, + // eslint-disable-next-line unused-imports/no-unused-vars + click: (event: RadioEmits['click'][0]) => true, + }, +} + +/* ------------------------------------------------------------------------------------------------- + * RadioIndicator - radio-indicator.ts + * ----------------------------------------------------------------------------------------------- */ + +export type RadioIndicatorNativeElement = OkuElement<'span'> +export type RadioIndicatorElement = HTMLSpanElement + +export interface RadioIndicatorProps extends PrimitiveProps { + /** + * Used to force mounting when more control is needed. Useful when + * controlling animation with React animation libraries. + */ + forceMount?: true +} + +export const radioIndicatorProps = { + props: { + forceMount: { + type: Boolean as PropType, + default: undefined, + }, + ...primitiveProps, + }, + emits: { }, +} + +/* ------------------------------------------------------------------------------------------------- + * BubbleInput - bubble-input.ts + * ----------------------------------------------------------------------------------------------- */ + +export type BubbleInputNativeElement = Omit, 'checked'> +export type BubbleInputElement = HTMLInputElement + +export interface BubbleInputProps { + checked: boolean + control: HTMLElement | null + bubbles: boolean +} + +export const bubbleInputProps = { + props: { + checked: { + type: Boolean as PropType, + required: true, + }, + control: { + type: [HTMLElement, null] as PropType, + default: null, + required: true, + }, + bubbles: { + type: Boolean as PropType, + default: true, + required: true, + }, + }, + emits: { }, +} + +/* ---------------------------------------------------------------------------------------------- */ + +export type ScopeRadioGroup = T & { scopeOkuRadioGroup?: Scope } + +export const scopeRadioGroupProps = { + scopeOkuRadioGroup: { + ...ScopePropObject, + }, +} + +export const RADIO_GROUP_NAME = 'OkuRadioGroup' +export const RADIO_GROUP_ITEM_NAME = 'OkuRadioGroupItem' +export const RADIO_GROUP_INDICATOR_NAME = 'OkuRadioGroupIndicator' + +/* ------------------------------------------------------------------------------------------------- + * RadioGroup - radio-group.ts + * ----------------------------------------------------------------------------------------------- */ + +export type RadioGroupNativeElement = OkuElement<'div'> +export type RadioGroupElement = HTMLDivElement + +export const [createRadioGroupProvider, createRadioGroupScope] = createProvideScope(RADIO_GROUP_NAME, [ + createRovingFocusGroupScope, + createRadioScope, +]) + +export const useRovingFocusGroupScope = createRovingFocusGroupScope() +export const useRadioScope = createRadioScope() + +interface RadioGroupInjectValue { + name?: Ref + required: Ref + disabled: Ref + value?: Ref + onValueChange(value: string): void +} + +export const [radioGroupProvider, useRadioGroupInject] + = createRadioGroupProvider(RADIO_GROUP_NAME) + +export interface RadioGroupProps extends PrimitiveProps { + // name?: string + name?: UnwrapRef + required?: RadioProps['required'] + disabled?: RadioProps['disabled'] + dir?: RovingFocusGroupProps['dir'] + orientation?: RovingFocusGroupProps['orientation'] + loop?: RovingFocusGroupProps['loop'] + defaultValue?: string + value?: UnwrapRef + modelValue?: string +} + +export type RadioGroupEmits = { + valueChange: [value: string] + 'update:modelValue': [value: string] +} + +export const radioGroupProps = { + props: { + name: { + type: String as PropType, + }, + required: { + type: Boolean as PropType, + default: false, + }, + disabled: { + type: Boolean as PropType, + default: false, + }, + dir: { + type: String as PropType, + }, + orientation: { + type: String as PropType, + }, + loop: { + type: Boolean as PropType, + default: true, + }, + defaultValue: { + type: String as PropType, + default: undefined, + }, + value: { + type: String as PropType, + default: undefined, + }, + modelValue: { + type: String as PropType, + default: undefined, + }, + ...primitiveProps, + }, + emits: { + // eslint-disable-next-line unused-imports/no-unused-vars + 'valueChange': (value: RadioGroupEmits['valueChange'][0]) => true, + // eslint-disable-next-line unused-imports/no-unused-vars + 'update:modelValue': (value: RadioGroupEmits['update:modelValue'][0]) => true, + }, +} + +/* ------------------------------------------------------------------------------------------------- + * RadioGroupItem - radio-group-item.ts + * ----------------------------------------------------------------------------------------------- */ + +export type RadioGroupItemNativeElement = RadioNativeElement +export type RadioGroupItemElement = RadioElement + +export interface RadioGroupItemProps extends Omit { + value: string +} + +export type RadioGroupItemEmits = Omit & { + focus: [event: FocusEvent] + keydown: [event: KeyboardEvent] +} + +export const radioGroupItemProps = { + props: { + ...propsOmit(radioProps.props, ['name']), + }, + emits: { + ...propsOmit(radioProps.emits, ['check']), + // eslint-disable-next-line unused-imports/no-unused-vars + focus: (event: RadioGroupItemEmits['focus'][0]) => true, + // eslint-disable-next-line unused-imports/no-unused-vars + keydown: (event: RadioGroupItemEmits['keydown'][0]) => true, + }, +} + +/* ------------------------------------------------------------------------------------------------- + * RadioGroupIndicator - radio-group-indicator.ts + * ----------------------------------------------------------------------------------------------- */ + +export type RadioGroupIndicatorNativeElement = RadioIndicatorNativeElement +export type RadioGroupIndicatorElement = RadioIndicatorElement + +export interface RadioGroupIndicatorProps extends RadioIndicatorProps { } + +export const radioGroupIndicatorProps = { + props: { + ...radioIndicatorProps.props, + }, + emits: { }, +} diff --git a/packages/components/radio-group/src/radio-group-indicator.ts b/packages/components/radio-group/src/radio-group-indicator.ts new file mode 100644 index 000000000..b4b23e042 --- /dev/null +++ b/packages/components/radio-group/src/radio-group-indicator.ts @@ -0,0 +1,37 @@ +import { defineComponent, h, mergeProps, reactive, toRefs } from 'vue' +import { reactiveOmit, useForwardRef } from '@oku-ui/use-composable' +import { OkuRadioIndicator } from './radio-indicator' +import { RADIO_GROUP_INDICATOR_NAME, radioGroupIndicatorProps, scopeRadioGroupProps, useRadioScope } from './props' +import type { RadioGroupIndicatorNativeElement } from './props' + +const radioGroupIndicator = defineComponent({ + name: RADIO_GROUP_INDICATOR_NAME, + components: { + OkuRadioIndicator, + }, + inheritAttrs: false, + props: { + ...radioGroupIndicatorProps.props, + ...scopeRadioGroupProps, + }, + emits: radioGroupIndicatorProps.emits, + setup(props, { attrs }) { + const { scopeOkuRadioGroup, ...indicatorProps } = toRefs(props) + + const _reactive = reactive(indicatorProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + + const forwardedRef = useForwardRef() + + const radioScope = useRadioScope(scopeOkuRadioGroup.value) + + return () => h(OkuRadioIndicator, { + ...radioScope, + ...mergeProps(attrs, otherProps), + ref: forwardedRef, + }) + }, +}) + +// TODO: https://github.com/vuejs/core/pull/7444 after delete +export const OkuRadioGroupIndicator = radioGroupIndicator as typeof radioGroupIndicator & (new () => { $props: RadioGroupIndicatorNativeElement }) diff --git a/packages/components/radio-group/src/radio-group-item.ts b/packages/components/radio-group/src/radio-group-item.ts new file mode 100644 index 000000000..a82f5dffd --- /dev/null +++ b/packages/components/radio-group/src/radio-group-item.ts @@ -0,0 +1,98 @@ +import { computed, defineComponent, h, mergeProps, onBeforeUnmount, onMounted, reactive, ref, toRefs } from 'vue' +import { reactiveOmit, useComposedRefs, useForwardRef, useListeners } from '@oku-ui/use-composable' +import { OkuRovingFocusGroupItem } from '@oku-ui/roving-focus' +import { composeEventHandlers } from '@oku-ui/utils' +import { ARROW_KEYS } from './utils' +import { OkuRadio } from './Radio' +import { RADIO_GROUP_ITEM_NAME, radioGroupItemProps, scopeRadioGroupProps, useRadioGroupInject, useRadioScope, useRovingFocusGroupScope } from './props' +import type { RadioElement, RadioGroupItemEmits, RadioGroupItemNativeElement } from './props' + +const radioGroupItem = defineComponent({ + name: RADIO_GROUP_ITEM_NAME, + components: { + OkuRovingFocusGroupItem, + OkuRadio, + }, + inheritAttrs: false, + props: { + ...radioGroupItemProps.props, + ...scopeRadioGroupProps, + }, + emits: radioGroupItemProps.emits, + setup(props, { slots, emit, attrs }) { + const { + scopeOkuRadioGroup, + disabled, + ...itemProps + } = toRefs(props) + + const _reactive = reactive(itemProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + + const forwardedRef = useForwardRef() + const emits = useListeners() + + const inject = useRadioGroupInject(RADIO_GROUP_ITEM_NAME, scopeOkuRadioGroup.value) + + const isDisabled = computed(() => inject.disabled.value || disabled.value) + const rovingFocusGroupScope = useRovingFocusGroupScope(scopeOkuRadioGroup.value) + const radioScope = useRadioScope(scopeOkuRadioGroup.value) + + const radioRef = ref(null) + const composedRefs = useComposedRefs(forwardedRef, radioRef) + + const checked = computed(() => inject.value?.value === itemProps.value.value) + const isArrowKeyPressedRef = ref(false) + + const handleKeyDown = (event: KeyboardEvent) => { + if (ARROW_KEYS.includes(event.key)) + isArrowKeyPressedRef.value = true + } + const handleKeyUp = () => isArrowKeyPressedRef.value = false + + onMounted(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) + }) + + onBeforeUnmount(() => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) + }) + + return () => h(OkuRovingFocusGroupItem, { + asChild: true, + ...rovingFocusGroupScope, + focusable: !isDisabled.value, + active: checked.value, + }, () => h(OkuRadio, { + disabled: isDisabled.value, + required: inject.required.value, + checked: checked.value, + ...radioScope, + ...mergeProps(attrs, otherProps, emits), + ref: composedRefs, + onCheck: () => inject.onValueChange(itemProps.value.value), + onKeydown: composeEventHandlers((event) => { + emit('keydown', event) + }, (event) => { + // According to WAI ARIA, radio groups don't activate items on enter keypress + if (event.key === 'Enter') + event.preventDefault() + }), + onFocus: composeEventHandlers((event) => { + emit('focus', event) + }, () => { + /** + * 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 (isArrowKeyPressedRef.value) + radioRef.value?.click() + }), + }, () => slots.default?.())) + }, +}) + +export const OkuRadioGroupItem = radioGroupItem as typeof radioGroupItem & (new () => { $props: RadioGroupItemNativeElement }) diff --git a/packages/components/radio-group/src/radio-group.ts b/packages/components/radio-group/src/radio-group.ts new file mode 100644 index 000000000..24129396c --- /dev/null +++ b/packages/components/radio-group/src/radio-group.ts @@ -0,0 +1,87 @@ +import { computed, defineComponent, h, mergeProps, reactive, toRefs, useModel } from 'vue' +import { reactiveOmit, useControllable, useForwardRef, useListeners } from '@oku-ui/use-composable' +import { useDirection } from '@oku-ui/direction' +import { Primitive } from '@oku-ui/primitive' +import { OkuRovingFocusGroup } from '@oku-ui/roving-focus' +import { RADIO_GROUP_NAME, radioGroupProps, radioGroupProvider, scopeRadioGroupProps, useRovingFocusGroupScope } from './props' +import type { RadioGroupNativeElement } from './props' + +const radioGroup = defineComponent({ + name: RADIO_GROUP_NAME, + components: { + OkuRovingFocusGroup, + }, + inheritAttrs: false, + props: { + ...radioGroupProps.props, + ...scopeRadioGroupProps, + }, + emits: radioGroupProps.emits, + setup(props, { slots, emit, attrs }) { + const { + scopeOkuRadioGroup, + name, + defaultValue, + value: valueProp, + required, + disabled, + orientation, + dir, + loop, + ...groupProps + } = toRefs(props) + + const _reactive = reactive(groupProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + + const forwardedRef = useForwardRef() + const emits = useListeners() + + const rovingFocusGroupScope = useRovingFocusGroupScope(scopeOkuRadioGroup.value) + const direction = useDirection(dir) + + const modelValue = useModel(props, 'modelValue') + const proxyValue = computed({ + get: () => modelValue.value !== undefined ? modelValue.value : valueProp.value !== undefined ? valueProp.value : undefined, + set: () => { }, + }) + + const { state, updateValue } = useControllable({ + prop: computed(() => proxyValue.value), + defaultProp: computed(() => defaultValue.value), + onChange: (result) => { + modelValue.value = result + emit('valueChange', result) + emit('update:modelValue', result) + }, + }) + + radioGroupProvider({ + scope: scopeOkuRadioGroup.value, + name, + required, + disabled, + value: state, + onValueChange: _value => updateValue(_value), + }) + + return () => h(OkuRovingFocusGroup, { + asChild: true, + ...rovingFocusGroupScope, + orientation: orientation.value, + dir: direction.value, + loop: loop.value, + }, () => h(Primitive.div, { + 'role': 'radiogroup', + 'aria-required': required.value, + 'aria-oriented': orientation.value, + 'data-disabled': disabled.value ? '' : undefined, + 'dir': direction.value, + ...mergeProps(attrs, otherProps, emits), + 'ref': forwardedRef, + }, () => slots.default?.())) + }, +}) + +// TODO: https://github.com/vuejs/core/pull/7444 after delete +export const OkuRadioGroup = radioGroup as typeof radioGroup & (new () => { $props: RadioGroupNativeElement }) diff --git a/packages/components/radio-group/src/radio-indicator.ts b/packages/components/radio-group/src/radio-indicator.ts new file mode 100644 index 000000000..125d492ac --- /dev/null +++ b/packages/components/radio-group/src/radio-indicator.ts @@ -0,0 +1,41 @@ +import { computed, defineComponent, h, mergeProps, reactive, toRefs } from 'vue' +import { reactiveOmit, useForwardRef } from '@oku-ui/use-composable' +import { Primitive } from '@oku-ui/primitive' +import { OkuPresence } from '@oku-ui/presence' +import { getState } from './utils' +import { RADIO_INDICATOR_NAME, radioIndicatorProps, scopeRadioProps, useRadioInject } from './props' +import type { RadioIndicatorNativeElement } from './props' + +const radioIndicator = defineComponent({ + name: RADIO_INDICATOR_NAME, + components: { + OkuPresence, + }, + inheritAttrs: false, + props: { + ...radioIndicatorProps.props, + ...scopeRadioProps, + }, + emits: radioIndicatorProps.emits, + setup(props, { attrs }) { + const { scopeOkuRadio, forceMount, ...indicatorProps } = toRefs(props) + + const _reactive = reactive(indicatorProps) + const otherProps = reactiveOmit(_reactive, (key, _value) => key === undefined) + + const forwardedRef = useForwardRef() + + const inject = useRadioInject(RADIO_INDICATOR_NAME, scopeOkuRadio.value) + + return () => h(OkuPresence, { + present: computed(() => forceMount.value || inject.checked.value).value, + }, () => h(Primitive.span, { + 'data-state': getState(inject.checked.value), + 'data-disabled': inject.disabled?.value ? '' : undefined, + ...mergeProps(attrs, otherProps), + 'ref': forwardedRef, + })) + }, +}) + +export const OkuRadioIndicator = radioIndicator as typeof radioIndicator & (new () => { $props: RadioIndicatorNativeElement }) diff --git a/packages/components/radio-group/src/stories/Animated.vue b/packages/components/radio-group/src/stories/Animated.vue new file mode 100644 index 000000000..017fbba2e --- /dev/null +++ b/packages/components/radio-group/src/stories/Animated.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/components/radio-group/src/stories/Chromatic.vue b/packages/components/radio-group/src/stories/Chromatic.vue new file mode 100644 index 000000000..62922913e --- /dev/null +++ b/packages/components/radio-group/src/stories/Chromatic.vue @@ -0,0 +1,228 @@ + + + diff --git a/packages/components/radio-group/src/stories/Controlled.vue b/packages/components/radio-group/src/stories/Controlled.vue new file mode 100644 index 000000000..c48b57a74 --- /dev/null +++ b/packages/components/radio-group/src/stories/Controlled.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/components/radio-group/src/stories/Label.vue b/packages/components/radio-group/src/stories/Label.vue new file mode 100644 index 000000000..010b25603 --- /dev/null +++ b/packages/components/radio-group/src/stories/Label.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/components/radio-group/src/stories/RadioGroup.stories.ts b/packages/components/radio-group/src/stories/RadioGroup.stories.ts new file mode 100644 index 000000000..f56e57a83 --- /dev/null +++ b/packages/components/radio-group/src/stories/RadioGroup.stories.ts @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import type { IRadioGroupProps } from './RadioGroupDemo.vue' +import OkuRadioGroup from './RadioGroupDemo.vue' + +interface StoryProps extends IRadioGroupProps { } + +const meta = { + title: 'Components/RadioGroup', + component: OkuRadioGroup, + args: { + template: 'Styled', + }, +} satisfies Meta & { + args: StoryProps +} + +export default meta +type Story = StoryObj & { + args: StoryProps +} + +export const Styled: Story = { + args: { + template: 'Styled', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Controlled: Story = { + args: { + template: 'Controlled', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Unset: Story = { + args: { + template: 'Unset', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const WithinForm: Story = { + args: { + template: 'WithinForm', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Animated: Story = { + args: { + template: 'Animated', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), +} + +export const Chromatic: Story = { + args: { + template: 'Chromatic', + }, + render: (args: any) => ({ + components: { OkuRadioGroup }, + setup() { + return { args } + }, + template: ` + + `, + }), + parameters: { + chromatic: { disable: false }, + }, +} diff --git a/packages/components/radio-group/src/stories/RadioGroupDemo.vue b/packages/components/radio-group/src/stories/RadioGroupDemo.vue index 37e2fd8ec..b634c47c8 100644 --- a/packages/components/radio-group/src/stories/RadioGroupDemo.vue +++ b/packages/components/radio-group/src/stories/RadioGroupDemo.vue @@ -1,149 +1,114 @@ diff --git a/packages/components/radio-group/src/stories/Styled.vue b/packages/components/radio-group/src/stories/Styled.vue new file mode 100644 index 000000000..e9b50777e --- /dev/null +++ b/packages/components/radio-group/src/stories/Styled.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/components/radio-group/src/stories/Unset.vue b/packages/components/radio-group/src/stories/Unset.vue new file mode 100644 index 000000000..05d154a46 --- /dev/null +++ b/packages/components/radio-group/src/stories/Unset.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/components/radio-group/src/stories/WithinForm.vue b/packages/components/radio-group/src/stories/WithinForm.vue new file mode 100644 index 000000000..bd1a7e97c --- /dev/null +++ b/packages/components/radio-group/src/stories/WithinForm.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/components/radio-group/src/stories/tabs.stories.ts b/packages/components/radio-group/src/stories/tabs.stories.ts deleted file mode 100644 index 0e07cded6..000000000 --- a/packages/components/radio-group/src/stories/tabs.stories.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3' - -import type { ITabsProps } from './RadioGroupDemo.vue' -import OkuTabsComponent from './RadioGroupDemo.vue' - -interface StoryProps extends ITabsProps { } - -const meta = { - title: 'Components/RadioGroup', - component: OkuTabsComponent, - args: { - template: '#1', - }, - -} satisfies Meta & { - args: StoryProps -} - -export default meta -type Story = StoryObj - -export const Styled: Story = { - args: { - template: '#1', - // allshow: true, - }, - render: (args: any) => ({ - components: { OkuTabsComponent }, - setup() { - return { args } - }, - template: ` - - `, - }), -} diff --git a/packages/components/radio-group/src/utils.ts b/packages/components/radio-group/src/utils.ts index ac1f023cd..36f108f97 100644 --- a/packages/components/radio-group/src/utils.ts +++ b/packages/components/radio-group/src/utils.ts @@ -1,24 +1,7 @@ -import type { Scope } from '@oku-ui/provide' -import { ScopePropObject } from '@oku-ui/provide' +const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] -export type ScopeRadioGroup = T & { scopeOkuRadioGroup?: Scope } - -export const scopeRadioGroupProps = { - scopeOkuRadioGroup: { - ...ScopePropObject, - }, -} - -export type ScopeRadio = T & { scopeOkuRadio?: Scope } - -export const scopeRadioProps = { - scopeOkuRadio: { - ...ScopePropObject, - }, -} - -export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] - -export function getState(checked: boolean) { +function getState(checked: boolean) { return checked ? 'checked' : 'unchecked' } + +export { ARROW_KEYS, getState } diff --git a/packages/components/radio-group/tests/__snapshots__/radio-group.test.ts.snap b/packages/components/radio-group/tests/__snapshots__/radio-group.test.ts.snap new file mode 100644 index 000000000..a4070e59e --- /dev/null +++ b/packages/components/radio-group/tests/__snapshots__/radio-group.test.ts.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`okuRadioGroup > should render OkuRadioGroup correctly 1`] = ` +"
+
+ + +
+
" +`; + +exports[`okuRadioGroup > should render OkuRadioGroup correctly 2`] = `""`; + +exports[`okuRadioGroup > should render OkuRadioGroupIndicator correctly 1`] = `"\`OkuRadioIndicator\` must be used within \`OkuRadio\`"`; + +exports[`okuRadioGroup > should render OkuRadioGroupItem correctly 1`] = `"\`OkuRadioGroupItem\` must be used within \`OkuRadioGroup\`"`; + +exports[`okuRadioGroup Stories > animated > should render correctly 1`] = `""`; + +exports[`okuRadioGroup Stories > chromatic > should render correctly 1`] = ` +"

Uncontrolled

+

Unset

+ +

Set

+ +

Controlled

+

Unset

+ +

Set

+ +

Disabled item

+ +

Disabled root

+ +

All items disabled

+ +

Manual focus into group

+ +

Force mounted indicator

+ +

Direction

+

Prop

+ +

Inherited

+ +

State attributes

+

Default

+ +

Disabled item

+ + +

Disabled root

+ +

All items disabled

+" +`; + +exports[`okuRadioGroup Stories > controlled > should render correctly 1`] = `""`; + +exports[`okuRadioGroup Stories > styled > should render correctly 1`] = `""`; + +exports[`okuRadioGroup Stories > unset > should render correctly 1`] = `""`; + +exports[`okuRadioGroup Stories > withinForm > should render correctly 1`] = ` +"
+
+ optional value: + +


+
+ required value: + +


+
+ stop propagation value: + +


+
" +`; diff --git a/packages/components/radio-group/tests/__snapshots__/radio.test.ts.snap b/packages/components/radio-group/tests/__snapshots__/radio.test.ts.snap index a6fd69898..6116e9ecb 100644 --- a/packages/components/radio-group/tests/__snapshots__/radio.test.ts.snap +++ b/packages/components/radio-group/tests/__snapshots__/radio.test.ts.snap @@ -1,299 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`OkuRadioGroup > can be checked 1`] = ` -
- -
- - - - - - - - - - - - -
- -
-`; - -exports[`OkuRadioGroup > can be checked 2`] = ` -
- -
- - - - - - - - - - - - -
- -
-`; - -exports[`OkuRadioGroup > can be checked 3`] = ` -
- -
- - - - - - - - - - - - -
- -
-`; - -exports[`OkuRadioGroup > can be checked 4`] = ` -
- -
- - - - - - - - - - - - -
- -
-`; - -exports[`OkuRadioGroup > renders the component correctly with a label 1`] = ` -
- -
- Label -
- -
-`; - exports[`okuRadioGroup > can be checked 1`] = `
can be checked 1`] = ` @@ -405,9 +108,6 @@ exports[`okuRadioGroup > can be checked 2`] = ` @@ -489,9 +189,6 @@ exports[`okuRadioGroup > can be checked 3`] = ` @@ -528,9 +225,6 @@ exports[`okuRadioGroup > can be checked 4`] = ` diff --git a/packages/components/radio-group/tests/radio-group.test.ts b/packages/components/radio-group/tests/radio-group.test.ts new file mode 100644 index 000000000..fdc26e710 --- /dev/null +++ b/packages/components/radio-group/tests/radio-group.test.ts @@ -0,0 +1,368 @@ +import { defineComponent, ref, watchEffect } from 'vue' +import { enableAutoUnmount, mount, shallowMount } from '@vue/test-utils' +import type { DOMWrapper, VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { axe } from 'vitest-axe' + +import { OkuRadioGroup, OkuRadioGroupIndicator, OkuRadioGroupItem } from '../src' + +import Styled from '../src/stories/Styled.vue' +import Controlled from '../src/stories/Controlled.vue' +import Unset from '../src/stories/Unset.vue' +import WithinForm from '../src/stories/WithinForm.vue' +import Animated from '../src/stories/Animated.vue' +import Chromatic from '../src/stories/Chromatic.vue' + +enableAutoUnmount(afterEach) + +const INDICATOR_TEST_ID_1 = 'radiogroup-indicator-1' +const INDICATOR_TEST_ID_2 = 'radiogroup-indicator-2' + +const onValueChange = vi.fn() + +const RadioGroupTest = defineComponent({ + components: { + OkuRadioGroup, + OkuRadioGroupItem, + OkuRadioGroupIndicator, + }, + props: { + defaultValue: String, + value: String, + disabled: Boolean, + }, + setup() { + const containerRef = ref(null) + watchEffect(() => { + // We use the `hidden` attribute to hide the nested input from both sighted users and the + // accessibility tree. This is perfectly valid so long as users don't override the display of + // `hidden` in CSS. Unfortunately axe doesn't recognize this, so we get a violation because the + // input doesn't have a label. This adds an additional `aria-hidden` attribute to the input to + // get around that. + // https://developer.paciellogroup.com/blog/2012/05/html5-accessibility-chops-hidden-and-aria-hidden/ + containerRef.value?.querySelector('input')?.setAttribute('aria-hidden', 'true') + }) + + return { + containerRef, + INDICATOR_TEST_ID_1, + INDICATOR_TEST_ID_2, + onValueChange, + } + }, + template: ` +
+ + + + + + + + +
+ `, +}) + +globalThis.ResizeObserver = class ResizeObserver { + cb: any + constructor(cb: any) { + this.cb = cb + } + + observe() { + this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]) + } + + unobserve() { } + disconnect() { } +} + +describe('okuRadioGroup', () => { + let wrapper: VueWrapper + let radio_1: DOMWrapper + let radio_2: DOMWrapper + // let indicator_2: DOMWrapper + + beforeEach(() => { + wrapper = mount(RadioGroupTest, { + attachTo: document.body, + }) + + radio_1 = wrapper.find('[value="1"]') + radio_2 = wrapper.find('[value="2"]') + + // indicator_2 = wrapper.find(`[data-testid="${INDICATOR_TEST_ID_2}"]`) + }) + + it('should render OkuRadioGroup correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + + expect(shallowMount(OkuRadioGroup).html()).toMatchSnapshot() + }) + + it('should render OkuRadioGroupItem correctly', () => { + const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => { }) + const wrapper = () => mount(OkuRadioGroupItem) + + expect(() => wrapper()).toThrowErrorMatchingSnapshot() + + expect(spy).toHaveBeenCalled() + + expect(spy.mock.calls[0][0]).toContain('[Vue warn]: injection "Symbol(OkuRadioGroup)" not found.') + }) + + it('should render OkuRadioGroupIndicator correctly', () => { + const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => { }) + const wrapper = () => mount(OkuRadioGroupIndicator) + + expect(() => wrapper()).toThrowErrorMatchingSnapshot() + + expect(spy).toHaveBeenCalled() + + expect(spy.mock.calls[0][0]).toContain('[Vue warn]: injection "Symbol(OkuRadio)" not found.') + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + describe('when clicking the radio 2', () => { + beforeEach(async () => { + radio_2.trigger('click') + // indicator_2 = wrapper.find(`[data-testid="${INDICATOR_TEST_ID_2}"]`) + }) + + it('should render a visible indicator', () => { + expect(wrapper.find(`[data-testid="${INDICATOR_TEST_ID_2}"]`).isVisible()).toBe(true) + }) + + describe('and clicking the radio 1', () => { + beforeEach(async () => { + radio_1.trigger('click') + }) + + it('should remove the indicator', () => { + expect(wrapper.find(`[data-testid="${INDICATOR_TEST_ID_2}"]`).exists()).toBe(false) + }) + }) + }) + + describe('given a disabled Radio', () => { + let wrapper: VueWrapper + + beforeEach(() => { + wrapper = mount(RadioGroupTest, { + props: { + disabled: false, + }, + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + }) + + describe('given an uncontrolled `checked` RadioGroup', () => { + let wrapper: VueWrapper + let radio_1: DOMWrapper + let indicator_2: DOMWrapper + + beforeEach(() => { + wrapper = mount(RadioGroupTest, { + props: { + defaultValue: '1', + }, + attachTo: document.body, + }) + + radio_1 = wrapper.find('[value="1"]') + radio_2 = wrapper.find('[value="2"]') + + indicator_2 = wrapper.find(`[data-testid="${INDICATOR_TEST_ID_2}"]`) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + describe('when clicking the radio', () => { + beforeEach(async () => { + radio_1.trigger('click') + }) + + it('should remove the indicator', () => { + expect(indicator_2.exists()).toBe(false) + }) + + it('should call `onValueChange` event', () => { + expect(onValueChange).toHaveBeenCalled() + }) + }) + + describe('given a controlled `checked` RadioGroup', () => { + let wrapper: VueWrapper + let radio_1: DOMWrapper + + beforeEach(() => { + wrapper = mount(RadioGroupTest, { + props: { + value: '1', + }, + attachTo: document.body, + }) + + radio_1 = wrapper.find('[value="1"]') + }) + + describe('when clicking the radio 1', () => { + beforeEach(() => { + radio_1.trigger('click') + }) + + it('should call `onValueChange` event', () => { + expect(onValueChange).toHaveBeenCalled() + }) + }) + }) + }) +}) + +describe('okuRadioGroup Stories', () => { + describe('styled', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(Styled, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + describe('controlled', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(Controlled, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + describe('unset', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(Unset, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + describe('withinForm', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(WithinForm, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + describe('animated', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(Animated, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) + + describe('chromatic', () => { + let wrapper: VueWrapper> + + beforeEach(async () => { + wrapper = shallowMount(Chromatic, { + attachTo: document.body, + }) + }) + + /** + * @vitest-environment jsdom + */ + it('should have no accessibility violations', async () => { + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render correctly', () => { + expect(wrapper.html()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/components/radio-group/tests/radio.test.ts b/packages/components/radio-group/tests/radio.test.ts deleted file mode 100644 index ddd4f0503..000000000 --- a/packages/components/radio-group/tests/radio.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' -import type { Component } from 'vue' -import { defineComponent, h } from 'vue' -import { OkuRadioGroup, OkuRadioGroupIndicator, OkuRadioGroupItem } from '../src/' - -const testComponent = defineComponent({ - components: { - OkuRadioGroup, - OkuRadioGroupItem, - OkuRadioGroupIndicator, - }, - template: ` - - - - - - - - - - - - - - - - `, -}) - -const component = { - setup(props, { attrs, slots }) { - return () => h(OkuRadioGroup, { ...attrs }, slots) - }, -} as Component - -describe('okuRadioGroup', () => { - it('renders the component correctly', () => { - const wrapper = mount(component) - expect(wrapper.exists()).toBe(true) - }) - - it('renders the component correctly with a label', () => { - const wrapper = mount(component, { - slots: { - default: 'Label', - }, - }) - expect(wrapper.element).toMatchSnapshot() - }) - - it('can be checked', async () => { - const wrapper = mount(testComponent) - expect(wrapper.element).toMatchSnapshot() - - const value2 = wrapper.find('[value="2"]') - await value2.trigger('click') - expect(wrapper.element).toMatchSnapshot() - - const value3 = wrapper.find('[value="3"]') - await value3.trigger('click') - expect(wrapper.element).toMatchSnapshot() - expect(value3.attributes('aria-checked')).toBe('true') - expect(value2.attributes('aria-checked')).toBe('false') - - const span = value3.find('span') - expect(span.classes()).toContain('indicator-class') - expect(span.attributes('data-state')).toBe('checked') - - expect(value2.find('span').exists()).toBe(false) - - const value1 = wrapper.find('[value="1"]') - await value1.trigger('click') - expect(wrapper.element).toMatchSnapshot() - - expect(value1.attributes('aria-checked')).toBe('true') - expect(value2.attributes('aria-checked')).toBe('false') - expect(value3.attributes('aria-checked')).toBe('false') - - expect(value1.find('span').exists()).toBe(true) - expect(value2.find('span').exists()).toBe(false) - expect(value3.find('span').exists()).toBe(false) - }) -})