Files
headlessui/packages/@headlessui-react/src/components/checkbox/checkbox.tsx
T
Robin Malfait 8c3499cc8d Only handle form reset when defaultValue is used (#3240)
* add `useDefaultValue` hook

This allows us to have a guaranteed `default value` that never changes
unless the component re-mounts.

Since the hook returns a stable value, we can safely include it in
dependency arrays of certain hooks.

Before this change, including this is in the dependency arrays it would
cause a trigger or change of the hook when the `defaultValue` changes
but we never want that.

* do not handle `reset` when no `defaultValue` or `defaultChecked` was provided

If a `defaultValue` is provided, then the reset will be handled and the
`onChange` will be called with this value.

If no `defaultValue` was provided, we won't handle the `reset`,
otherwise we would call the `onChange` with `undefined` which is
incorrect.

* update changelog
2024-05-25 00:26:51 +02:00

210 lines
5.6 KiB
TypeScript

'use client'
import { useFocusRing } from '@react-aria/focus'
import { useHover } from '@react-aria/interactions'
import React, {
useCallback,
useMemo,
useState,
type ElementType,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from 'react'
import { useActivePress } from '../../hooks/use-active-press'
import { useControllable } from '../../hooks/use-controllable'
import { useDefaultValue } from '../../hooks/use-default-value'
import { useDisposables } from '../../hooks/use-disposables'
import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useDisabled } from '../../internal/disabled'
import { FormFields } from '../../internal/form-fields'
import { useProvidedId } from '../../internal/id'
import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { attemptSubmit } from '../../utils/form'
import {
forwardRefWithAs,
mergeProps,
render,
type HasDisplayName,
type RefProp,
} from '../../utils/render'
import { useDescribedBy } from '../description/description'
import { Keys } from '../keyboard'
import { useLabelledBy } from '../label/label'
let DEFAULT_CHECKBOX_TAG = 'span' as const
type CheckboxRenderPropArg = {
checked: boolean
changing: boolean
focus: boolean
active: boolean
hover: boolean
autofocus: boolean
disabled: boolean
indeterminate: boolean
}
type CheckboxPropsWeControl =
| 'aria-checked'
| 'aria-describedby'
| 'aria-disabled'
| 'aria-labelledby'
| 'role'
| 'tabIndex'
export type CheckboxProps<
TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG,
TType = string,
> = Props<
TTag,
CheckboxRenderPropArg,
CheckboxPropsWeControl,
{
value?: TType
disabled?: boolean
indeterminate?: boolean
checked?: boolean
defaultChecked?: boolean
autoFocus?: boolean
form?: string
name?: string
onChange?: (checked: boolean) => void
}
>
function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TType = any>(
props: CheckboxProps<TTag, TType>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let providedId = useProvidedId()
let providedDisabled = useDisabled()
let {
id = providedId || `headlessui-checkbox-${internalId}`,
disabled = providedDisabled || false,
autoFocus = false,
checked: controlledChecked,
defaultChecked: _defaultChecked,
onChange: controlledOnChange,
name,
value,
form,
indeterminate = false,
...theirProps
} = props
let defaultChecked = useDefaultValue(_defaultChecked)
let [checked, onChange] = useControllable(
controlledChecked,
controlledOnChange,
defaultChecked ?? false
)
let labelledBy = useLabelledBy()
let describedBy = useDescribedBy()
let d = useDisposables()
let [changing, setChanging] = useState(false)
let toggle = useEvent(() => {
setChanging(true)
onChange?.(!checked)
d.nextFrame(() => {
setChanging(false)
})
})
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
toggle()
})
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === Keys.Space) {
event.preventDefault()
toggle()
} else if (event.key === Keys.Enter) {
attemptSubmit(event.currentTarget)
}
})
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
let handleKeyPress = useEvent((event: ReactKeyboardEvent<HTMLElement>) => event.preventDefault())
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })
let ourProps = mergeProps(
{
ref,
id,
role: 'checkbox',
'aria-checked': indeterminate ? 'mixed' : checked ? 'true' : 'false',
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
'aria-disabled': disabled ? true : undefined,
indeterminate: indeterminate ? 'true' : undefined,
tabIndex: 0,
onKeyUp: disabled ? undefined : handleKeyUp,
onKeyPress: disabled ? undefined : handleKeyPress,
onClick: disabled ? undefined : handleClick,
},
focusProps,
hoverProps,
pressProps
)
let slot = useMemo(() => {
return {
checked,
disabled,
hover,
focus,
active,
indeterminate,
changing,
autofocus: autoFocus,
} satisfies CheckboxRenderPropArg
}, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus])
let reset = useCallback(() => {
if (defaultChecked === undefined) return
return onChange?.(defaultChecked)
}, [onChange, defaultChecked])
return (
<>
{name != null && (
<FormFields
disabled={disabled}
data={{ [name]: value || 'on' }}
overrides={{ type: 'checkbox', checked }}
form={form}
onReset={reset}
/>
)}
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_CHECKBOX_TAG,
name: 'Checkbox',
})}
</>
)
}
// ---
export interface _internal_ComponentCheckbox extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TType = string>(
props: CheckboxProps<TTag, TType> & RefProp<typeof CheckboxFn>
): JSX.Element
}
export let Checkbox = forwardRefWithAs(CheckboxFn) as _internal_ComponentCheckbox