Files
headlessui/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
T
2021-04-08 13:04:03 +02:00

282 lines
7.5 KiB
TypeScript

// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#disclosure
import React, {
Fragment,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
// Types
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
Ref,
} from 'react'
import { Props } from '../../types'
import { match } from '../../utils/match'
import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
enum DisclosureStates {
Open,
Closed,
}
interface StateDefinition {
disclosureState: DisclosureStates
linkedPanel: boolean
buttonId: string
panelId: string
}
enum ActionTypes {
ToggleDisclosure,
SetButtonId,
SetPanelId,
LinkPanel,
UnlinkPanel,
}
type Actions =
| { type: ActionTypes.ToggleDisclosure }
| { type: ActionTypes.SetButtonId; buttonId: string }
| { type: ActionTypes.SetPanelId; panelId: string }
| { type: ActionTypes.LinkPanel }
| { type: ActionTypes.UnlinkPanel }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.ToggleDisclosure]: state => ({
...state,
disclosureState: match(state.disclosureState, {
[DisclosureStates.Open]: DisclosureStates.Closed,
[DisclosureStates.Closed]: DisclosureStates.Open,
}),
}),
[ActionTypes.LinkPanel](state) {
if (state.linkedPanel === true) return state
return { ...state, linkedPanel: true }
},
[ActionTypes.UnlinkPanel](state) {
if (state.linkedPanel === false) return state
return { ...state, linkedPanel: false }
},
[ActionTypes.SetButtonId](state, action) {
if (state.buttonId === action.buttonId) return state
return { ...state, buttonId: action.buttonId }
},
[ActionTypes.SetPanelId](state, action) {
if (state.panelId === action.panelId) return state
return { ...state, panelId: action.panelId }
},
}
let DisclosureContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
DisclosureContext.displayName = 'DisclosureContext'
function useDisclosureContext(component: string) {
let context = useContext(DisclosureContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureContext)
throw err
}
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_DISCLOSURE_TAG = Fragment
interface DisclosureRenderPropArg {
open: boolean
}
export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
props: Props<TTag, DisclosureRenderPropArg>
) {
let buttonId = `headlessui-disclosure-button-${useId()}`
let panelId = `headlessui-disclosure-panel-${useId()}`
let reducerBag = useReducer(stateReducer, {
disclosureState: DisclosureStates.Closed,
linkedPanel: false,
buttonId,
panelId,
} as StateDefinition)
let [{ disclosureState }, dispatch] = reducerBag
useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
let slot = useMemo<DisclosureRenderPropArg>(
() => ({ open: disclosureState === DisclosureStates.Open }),
[disclosureState]
)
return (
<DisclosureContext.Provider value={reducerBag}>
{render({
props,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
})}
</DisclosureContext.Provider>
)
}
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
interface ButtonRenderPropArg {
open: boolean
}
type ButtonPropsWeControl =
| 'id'
| 'type'
| 'aria-expanded'
| 'aria-controls'
| 'onKeyDown'
| 'onClick'
let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: Ref<HTMLButtonElement>
) {
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
let buttonRef = useSyncRefs(ref)
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
break
}
},
[dispatch]
)
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
}, [])
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
dispatch({ type: ActionTypes.ToggleDisclosure })
},
[dispatch, props.disabled]
)
let slot = useMemo<ButtonRenderPropArg>(
() => ({ open: state.disclosureState === DisclosureStates.Open }),
[state]
)
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
id: state.buttonId,
type: 'button',
'aria-expanded': state.disclosureState === DisclosureStates.Open ? true : undefined,
'aria-controls': state.linkedPanel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Disclosure.Button',
})
})
// ---
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
}
type PanelPropsWeControl = 'id'
let PanelRenderFeatures = Features.RenderStrategy | Features.Static
let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg, PanelPropsWeControl> &
PropsForFeatures<typeof PanelRenderFeatures>,
ref: Ref<HTMLDivElement>
) {
let [state, dispatch] = useDisclosureContext([Disclosure.name, Panel.name].join('.'))
let panelRef = useSyncRefs(ref, () => {
if (state.linkedPanel) return
dispatch({ type: ActionTypes.LinkPanel })
})
// Unlink on "unmount" myself
useEffect(() => () => dispatch({ type: ActionTypes.UnlinkPanel }), [dispatch])
// Unlink on "unmount" children
useEffect(() => {
if (state.disclosureState === DisclosureStates.Closed && (props.unmount ?? true)) {
dispatch({ type: ActionTypes.UnlinkPanel })
}
}, [state.disclosureState, props.unmount, dispatch])
let slot = useMemo<PanelRenderPropArg>(
() => ({ open: state.disclosureState === DisclosureStates.Open }),
[state]
)
let propsWeControl = {
ref: panelRef,
id: state.panelId,
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible: state.disclosureState === DisclosureStates.Open,
name: 'Disclosure.Panel',
})
})
// ---
Disclosure.Button = Button
Disclosure.Panel = Panel