Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(radio-group): new component #299

Merged
merged 21 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"overrides": [
{
"files": [
"**/stories/*.vue"
"**/stories/*.{vue,js,jsx,ts,tsx}"
],
"rules": {
"no-console": "off"
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
needs: [lint, typecheck]

permissions:
# Required to checkout the code
contents: read
# Required to put a comment into the pull-request
pull-requests: write

strategy:
matrix:
Expand All @@ -54,8 +60,8 @@ jobs:
- uses: ./.github/actions/setup-and-cache
- uses: ./.github/actions/cache-build

- name: 🧪 Test
run: pnpm test
# - name: 🧪 Test
# run: pnpm test

- name: 🧪 Test with coverage
run: pnpm coverage
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@oku-ui/primitive": "workspace:^",
"@oku-ui/progress": "workspace:^",
"@oku-ui/provide": "workspace:^",
"@oku-ui/radio-group": "workspace:^",
"@oku-ui/roving-focus": "workspace:^",
"@oku-ui/separator": "workspace:^",
"@oku-ui/slot": "workspace:^",
Expand Down
2 changes: 1 addition & 1 deletion packages/components/collection/src/stories/LogItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCollection } from './utils'

export function LogItems({ name = 'items' }: { name?: string }) {
const getItems = useCollection(undefined)
// eslint-disable-next-line no-console

console.log(name, getItems.value)
return null
}
8 changes: 3 additions & 5 deletions packages/components/presence/src/presence.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineComponent, h, toRefs } from 'vue'
import { cloneVNode, defineComponent, toRefs } from 'vue'
import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable'
import { usePresence } from './usePresence'

Expand All @@ -20,7 +20,6 @@ const presence = defineComponent({
},
setup(props, { slots }) {
const { present } = toRefs(props)

const forwardedRef = useForwardRef()
const { isPresent, ref: presenceRef } = usePresence(present)
const composedRefs = useComposedRefs(presenceRef, forwardedRef)
Expand All @@ -30,11 +29,10 @@ const presence = defineComponent({
isPresent,
})
const [child] = slot ?? []

return isPresent.value
? h(child, {
? cloneVNode(child, {
ref: composedRefs,
})
}, true)
: null
}
},
Expand Down
12 changes: 12 additions & 0 deletions packages/components/radio-group/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# `@oku-ui/radio-group`

<span><a href="https://www.npmjs.com/package/@oku-ui/radio-group "><img src="https://img.shields.io/npm/v/@oku-ui/radio-group?style=flat&colorA=18181B&colorB=28CF8D" alt="Version"></a> </span> | <span> <a href="https://www.npmjs.com/package/@oku-ui/radio-group"> <img src="https://img.shields.io/npm/dm/@oku-ui/radio-group?style=flat&colorA=18181B&colorB=28CF8D" alt="Downloads"> </a> </span> | <span> <a href="https://oku-ui.com/primitives/components/radio-group"><img src="https://img.shields.io/badge/Open%20Documentation-18181B" alt="Website"></a> </span>

## Installation

```sh
$ pnpm add @oku-ui/radio-group
```

## Usages
...
12 changes: 12 additions & 0 deletions packages/components/radio-group/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
entries: [
{
builder: 'mkdist',
input: './src/',
pattern: ['**/!(*.test|*.stories).ts'],
},
],
declaration: true,
})
47 changes: 47 additions & 0 deletions packages/components/radio-group/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@oku-ui/radio-group",
"type": "module",
"version": "0.1.0",
"license": "MIT",
"source": "src/index.ts",
"funding": "https://github.com/sponsors/productdevbook",
"homepage": "https://oku-ui.com/primitives",
"repository": {
"type": "git",
"url": "git+https://github.com/oku-ui/primitives.git",
"directory": "packages/components/radio-group"
},
"bugs": {
"url": "https://github.com/oku-ui/primitives/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
}
},
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"peerDependencies": {
"vue": "^3.3.4"
},
"dependencies": {
"@oku-ui/direction": "latest",
"@oku-ui/presence": "latest",
"@oku-ui/primitive": "latest",
"@oku-ui/provide": "latest",
"@oku-ui/roving-focus": "latest",
"@oku-ui/use-composable": "latest",
"@oku-ui/utils": "latest"
},
"devDependencies": {
"tsconfig": "workspace:^"
}
}
81 changes: 81 additions & 0 deletions packages/components/radio-group/src/BubbleInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ElementType } from '@oku-ui/primitive'
import { usePrevious, useSize } from '@oku-ui/use-composable'
import { computed, defineComponent, h, ref, toRefs, watchEffect } from 'vue'
import type { PropType } from 'vue'

const BUBBLE_INPUT_NAME = 'BubbleInput'

export type BubbleInputIntrinsicElement = ElementType<'button'>
export type BubbleInputElement = Omit<HTMLButtonElement, 'checked'>

interface BubbleInputProps {
checked: boolean
control: HTMLElement | null
bubbles: boolean
}

const bubbleInputPropsObject = {
checked: {
type: Boolean as PropType<boolean>,
required: true,
},
control: {
type: HTMLElement as PropType<HTMLElement | null>,
default: null,
},
bubbles: {
type: Boolean as PropType<boolean | undefined>,
default: false,
},
}

const BubbleInput = defineComponent({
name: BUBBLE_INPUT_NAME,
inheritAttrs: false,
props: bubbleInputPropsObject,
setup(props, { attrs }) {
const { control, checked } = toRefs(props)
const bubbles = computed(() => props.bubbles ?? true)
const inputRef = ref<HTMLInputElement | null>(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,
...attrs,
'tabindex': -1,
'ref': inputRef,
'style': {
...attrs.style as any,
...controlSize.value,
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
margin: 0,
},
})
},
})

const OkuBubbleInput = BubbleInput as typeof BubbleInput &
(new () => {
$props: Partial<BubbleInputElement>
})

export { OkuBubbleInput }

export type { BubbleInputProps }
155 changes: 155 additions & 0 deletions packages/components/radio-group/src/Radio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Primitive, primitiveProps } from '@oku-ui/primitive'
import type { ElementType } from '@oku-ui/primitive'
import { createProvideScope } from '@oku-ui/provide'
import { useComposedRefs, useForwardRef } from '@oku-ui/use-composable'
import { computed, defineComponent, h, mergeProps, ref, toRefs } from 'vue'
import type { PropType, Ref } from 'vue'
import { composeEventHandlers } from '@oku-ui/utils'
import type { ScopeRadio } from './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<boolean>
disabled?: Ref<boolean | undefined>
}

export const useRadioScope = createRadioScope()

export const [radioProvider, useRadioInject] = createRadioProvide<RadioProvideValue>(RADIO_NAME)

export type RadioIntrinsicIntrinsicElement = ElementType<'button'>
export type RadioElement = HTMLButtonElement

interface RadioProps {
checked?: boolean
required?: boolean
disabled?: boolean
value?: string
name?: string
onCheck?(): void
onClick?(): (event: MouseEvent) => void
}

export const radioPropsObject = {
checked: {
type: Boolean as PropType<boolean>,
default: false,
},
required: {
type: Boolean as PropType<boolean | undefined>,
default: undefined,
},
disabled: {
type: Boolean as PropType<boolean | undefined>,
default: undefined,
},
name: {
type: String as PropType<string | undefined>,
default: undefined,
},
value: {
type: String as PropType<string>,
default: 'on',
},
}

const Radio = defineComponent({
name: RADIO_NAME,
inheritAttrs: false,
props: {
...radioPropsObject,
...scopeRadioProps,
...primitiveProps,
},
emits: {
check: () => true,
click: (event: MouseEvent) => true,
},
setup(props, { attrs, slots, emit }) {
const {
checked,
required,
disabled,
value,
name,
scopeOkuRadio,
asChild,
...radioProps
} = toRefs(props)

const { ...radioAttrs } = attrs as RadioIntrinsicIntrinsicElement

const hasConsumerStoppedPropagationRef = ref(false)
const buttonRef = ref<HTMLButtonElement | null>(null)
const forwardedRef = useForwardRef()
const composedRefs = useComposedRefs(buttonRef, forwardedRef)

const isFormControl = computed(() => buttonRef.value ? Boolean(buttonRef.value.closest('form')) : false)

radioProvider({
checked,
disabled,
scope: props.scopeOkuRadio,
})

return () => [
h(Primitive.button, {
'type': 'button',
'role': 'radio',
'aria-checked': checked.value,
'data-state': getState(checked.value || false),
'data-disabled': disabled.value ? '' : undefined,
'disabled': disabled.value,
'value': value.value,
...mergeProps(radioAttrs, radioProps),
'ref': composedRefs,
'onClick': composeEventHandlers((e: MouseEvent) => {
emit('click', e)
},
(event: MouseEvent) => {
// radios cannot be unchecked so we only communicate a checked state
if (!checked.value)
emit('check')
if (isFormControl.value) {
// TODO: check `isPropagationStopped`
// 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.
if (!hasConsumerStoppedPropagationRef.value)
event.stopPropagation()
}
}),
}, {
default: () => slots.default?.(),
}),
isFormControl.value && h(OkuBubbleInput, {
control: buttonRef.value,
bubbles: !hasConsumerStoppedPropagationRef.value,
name: name.value,
value: value.value,
checked: checked.value || false,
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%)',
} as CSSStyleDeclaration,
}),
]
},
})

export const OkuRadio = Radio as typeof Radio &
(new () => {
$props: ScopeRadio<Partial<RadioElement>>
})

export type { RadioProps }
Loading
Loading