Files
headlessui/packages/@headlessui-react/src/components/switch/switch.tsx
T
Jordan Pittman 8d20cfb0c6 Deprecate dot notation (#3170)
* Expose new components under dot notation

Most of them already where so only a couple were missing.

* Deprecate dot notation

* Add deprecation to `RadioGroupOption`

* Update deprecations

* Update changelog

* Update changelog
2024-05-03 09:55:02 -04:00

286 lines
8.3 KiB
TypeScript

'use client'
import { useFocusRing } from '@react-aria/focus'
import { useHover } from '@react-aria/interactions'
import React, {
Fragment,
createContext,
useCallback,
useContext,
useMemo,
useRef,
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 { useDisposables } from '../../hooks/use-disposables'
import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
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 {
Description,
useDescribedBy,
useDescriptions,
type _internal_ComponentDescription,
} from '../description/description'
import { Keys } from '../keyboard'
import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label'
interface StateDefinition {
switch: HTMLButtonElement | null
setSwitch(element: HTMLButtonElement): void
}
let GroupContext = createContext<StateDefinition | null>(null)
GroupContext.displayName = 'GroupContext'
// ---
let DEFAULT_GROUP_TAG = Fragment
export type SwitchGroupProps<TTag extends ElementType = typeof DEFAULT_GROUP_TAG> = Props<TTag>
function GroupFn<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
props: SwitchGroupProps<TTag>
) {
let [switchElement, setSwitchElement] = useState<HTMLButtonElement | null>(null)
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let context = useMemo<StateDefinition>(
() => ({ switch: switchElement, setSwitch: setSwitchElement }),
[switchElement, setSwitchElement]
)
let ourProps = {}
let theirProps = props
return (
<DescriptionProvider name="Switch.Description" value={describedby}>
<LabelProvider
name="Switch.Label"
value={labelledby}
props={{
htmlFor: context.switch?.id,
onClick(event: React.MouseEvent<HTMLLabelElement>) {
if (!switchElement) return
if (event.currentTarget instanceof HTMLLabelElement) {
event.preventDefault()
}
switchElement.click()
switchElement.focus({ preventScroll: true })
},
}}
>
<GroupContext.Provider value={context}>
{render({
ourProps,
theirProps,
slot: {},
defaultTag: DEFAULT_GROUP_TAG,
name: 'Switch.Group',
})}
</GroupContext.Provider>
</LabelProvider>
</DescriptionProvider>
)
}
// ---
let DEFAULT_SWITCH_TAG = 'button' as const
type SwitchRenderPropArg = {
checked: boolean
hover: boolean
focus: boolean
active: boolean
autofocus: boolean
changing: boolean
disabled: boolean
}
type SwitchPropsWeControl = 'aria-checked' | 'aria-describedby' | 'aria-labelledby' | 'role'
export type SwitchProps<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG> = Props<
TTag,
SwitchRenderPropArg,
SwitchPropsWeControl,
{
checked?: boolean
defaultChecked?: boolean
onChange?(checked: boolean): void
name?: string
value?: string
form?: string
autoFocus?: boolean
disabled?: boolean
tabIndex?: number
}
>
function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
props: SwitchProps<TTag>,
ref: Ref<HTMLButtonElement>
) {
let internalId = useId()
let providedId = useProvidedId()
let providedDisabled = useDisabled()
let {
id = providedId || `headlessui-switch-${internalId}`,
disabled = providedDisabled || false,
checked: controlledChecked,
defaultChecked = false,
onChange: controlledOnChange,
name,
value,
form,
autoFocus = false,
...theirProps
} = props
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef<HTMLButtonElement | null>(null)
let switchRef = useSyncRefs(
internalSwitchRef,
ref,
groupContext === null ? null : groupContext.setSwitch
)
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
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 labelledBy = useLabelledBy()
let describedBy = useDescribedBy()
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })
let slot = useMemo(() => {
return {
checked,
disabled,
hover,
focus,
active,
autofocus: autoFocus,
changing,
} satisfies SwitchRenderPropArg
}, [checked, hover, focus, active, disabled, changing, autoFocus])
let ourProps = mergeProps(
{
id,
ref: switchRef,
role: 'switch',
type: useResolveButtonType(props, internalSwitchRef),
tabIndex: props.tabIndex === -1 ? 0 : props.tabIndex ?? 0,
'aria-checked': checked,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
disabled: disabled || undefined,
autoFocus,
onClick: handleClick,
onKeyUp: handleKeyUp,
onKeyPress: handleKeyPress,
},
focusProps,
hoverProps,
pressProps
)
let reset = useCallback(() => {
return onChange?.(defaultChecked)
}, [onChange /* Explicitly ignoring `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_SWITCH_TAG, name: 'Switch' })}
</>
)
}
// ---
export interface _internal_ComponentSwitch extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
props: SwitchProps<TTag> & RefProp<typeof SwitchFn>
): JSX.Element
}
export interface _internal_ComponentSwitchGroup extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
props: SwitchGroupProps<TTag> & RefProp<typeof GroupFn>
): JSX.Element
}
export interface _internal_ComponentSwitchLabel extends _internal_ComponentLabel {}
export interface _internal_ComponentSwitchDescription extends _internal_ComponentDescription {}
let SwitchRoot = forwardRefWithAs(SwitchFn) as _internal_ComponentSwitch
export let SwitchGroup = GroupFn as _internal_ComponentSwitchGroup
/** @deprecated use `<Label>` instead of `<SwitchLabel>` */
export let SwitchLabel = Label as _internal_ComponentSwitchLabel
/** @deprecated use `<Description>` instead of `<SwitchDescription>` */
export let SwitchDescription = Description as _internal_ComponentSwitchDescription
export let Switch = Object.assign(SwitchRoot, {
/** @deprecated use `<SwitchGroup>` instead of `<Switch.Group>` */
Group: SwitchGroup,
/** @deprecated use `<Label>` instead of `<Switch.Label>` */
Label: SwitchLabel,
/** @deprecated use `<Description>` instead of `<Switch.Description>` */
Description: SwitchDescription,
})