General/random internal cleanup (part 1) (#1484)

* sort React imports

* improve type signature of the `useEvent` hook

* use more correct `useIsoMorphicEffect` check in `useEvent`

* refactor `useCallback` to cleaner `useEvent`

* convert `const` to `let`

Just for consistency..

* cleanup `Tabs` code

Created explicit functions that can be called from child components
instead of calling `dispatch` directly. Introduced a `useData` and
`useActions` hook to make child components easier.

The seperation of `useData` allows us to pass down props directly
instead of going via the `useReducer` hook and dispatching actions to
make values up to date.

* cleanup `Combobox` code

* cleanup `RadioGroup` code
This commit is contained in:
Robin Malfait
2022-05-23 11:26:22 +02:00
committed by GitHub
parent d200be5f6f
commit e819c0a7b2
18 changed files with 1063 additions and 1218 deletions
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
@@ -16,6 +15,7 @@ import { useId } from '../../hooks/use-id'
import { forwardRefWithAs, render } from '../../utils/render'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useEvent } from '../../hooks/use-event'
// ---
@@ -58,7 +58,7 @@ export function useDescriptions(): [
// The provider component
useMemo(() => {
return function DescriptionProvider(props: DescriptionProviderProps) {
let register = useCallback((value: string) => {
let register = useEvent((value: string) => {
setDescriptionIds((existing) => [...existing, value])
return () =>
@@ -68,7 +68,7 @@ export function useDescriptions(): [
if (idx !== -1) clone.splice(idx, 1)
return clone
})
}, [])
})
let contextBag = useMemo(
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
@@ -1,7 +1,7 @@
// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal
import React, {
createContext,
useCallback,
createRef,
useContext,
useEffect,
useMemo,
@@ -15,7 +15,6 @@ import React, {
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
createRef,
} from 'react'
import { Props } from '../../types'
@@ -38,6 +37,7 @@ import { getOwnerDocument } from '../../utils/owner'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useEvent } from '../../hooks/use-event'
enum DialogStates {
Open,
@@ -184,12 +184,9 @@ let DialogRoot = forwardRefWithAs(function Dialog<
panelRef: createRef(),
} as StateDefinition)
let close = useCallback(() => onClose(false), [onClose])
let close = useEvent(() => onClose(false))
let setTitleId = useCallback(
(id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }),
[dispatch]
)
let setTitleId = useEvent((id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }))
let ready = useServerHandoffComplete()
let enabled = ready ? (__demoMode ? false : dialogState === DialogStates.Open) : false
@@ -323,7 +320,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
<StackProvider
type="Dialog"
element={internalDialogRef}
onUpdate={useCallback((message, type, element) => {
onUpdate={useEvent((message, type, element) => {
if (type !== 'Dialog') return
match(message, {
@@ -336,7 +333,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
setNestedDialogCount((count) => count - 1)
},
})
}, [])}
})}
>
<ForcePortalRoot force={true}>
<Portal>
@@ -393,16 +390,13 @@ let Overlay = forwardRefWithAs(function Overlay<
let id = `headlessui-dialog-overlay-${useId()}`
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (event.target !== event.currentTarget) return
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
event.stopPropagation()
close()
},
[close]
)
let handleClick = useEvent((event: ReactMouseEvent) => {
if (event.target !== event.currentTarget) return
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
event.stopPropagation()
close()
})
let slot = useMemo<OverlayRenderPropArg>(
() => ({ open: dialogState === DialogStates.Open }),
@@ -2,7 +2,6 @@
import React, {
Fragment,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
@@ -10,13 +9,13 @@ import React, {
useRef,
// Types
ContextType,
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
Ref,
MutableRefObject,
ContextType,
Ref,
} from 'react'
import { Props } from '../../types'
@@ -29,6 +28,7 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
enum DisclosureStates {
Open,
@@ -188,24 +188,21 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure<
useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
let close = useCallback(
(focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
dispatch({ type: ActionTypes.CloseDisclosure })
let ownerDocument = getOwnerDocument(internalDisclosureRef)
if (!ownerDocument) return
let close = useEvent((focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
dispatch({ type: ActionTypes.CloseDisclosure })
let ownerDocument = getOwnerDocument(internalDisclosureRef)
if (!ownerDocument) return
let restoreElement = (() => {
if (!focusableElement) return ownerDocument.getElementById(buttonId)
if (focusableElement instanceof HTMLElement) return focusableElement
if (focusableElement.current instanceof HTMLElement) return focusableElement.current
let restoreElement = (() => {
if (!focusableElement) return ownerDocument.getElementById(buttonId)
if (focusableElement instanceof HTMLElement) return focusableElement
if (focusableElement.current instanceof HTMLElement) return focusableElement.current
return ownerDocument.getElementById(buttonId)
})()
return ownerDocument.getElementById(buttonId)
})()
restoreElement?.focus()
},
[dispatch, buttonId]
)
restoreElement?.focus()
})
let api = useMemo<ContextType<typeof DisclosureAPIContext>>(() => ({ close }), [close])
@@ -265,35 +262,32 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let buttonRef = useSyncRefs(internalButtonRef, ref, !isWithinPanel ? state.buttonRef : null)
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (isWithinPanel) {
if (state.disclosureState === DisclosureStates.Closed) return
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (isWithinPanel) {
if (state.disclosureState === DisclosureStates.Closed) return
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
state.buttonRef.current?.focus()
break
}
} else {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
break
}
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
state.buttonRef.current?.focus()
break
}
},
[dispatch, isWithinPanel, state.disclosureState, state.buttonRef]
)
} else {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
break
}
}
})
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
@@ -302,22 +296,19 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
event.preventDefault()
break
}
}, [])
})
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
if (isWithinPanel) {
dispatch({ type: ActionTypes.ToggleDisclosure })
state.buttonRef.current?.focus()
} else {
dispatch({ type: ActionTypes.ToggleDisclosure })
}
},
[dispatch, props.disabled, state.buttonRef, isWithinPanel]
)
if (isWithinPanel) {
dispatch({ type: ActionTypes.ToggleDisclosure })
state.buttonRef.current?.focus()
} else {
dispatch({ type: ActionTypes.ToggleDisclosure })
}
})
let slot = useMemo<ButtonRenderPropArg>(
() => ({ open: state.disclosureState === DisclosureStates.Open }),
@@ -1,6 +1,6 @@
import React, {
useRef,
useEffect,
useRef,
// Types
ElementType,
@@ -1,6 +1,5 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
@@ -16,6 +15,7 @@ import { useId } from '../../hooks/use-id'
import { forwardRefWithAs, render } from '../../utils/render'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useEvent } from '../../hooks/use-event'
// ---
@@ -53,7 +53,7 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) =>
// The provider component
useMemo(() => {
return function LabelProvider(props: LabelProviderProps) {
let register = useCallback((value: string) => {
let register = useEvent((value: string) => {
setLabelIds((existing) => [...existing, value])
return () =>
@@ -63,7 +63,7 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) =>
if (idx !== -1) clone.splice(idx, 1)
return clone
})
}, [])
})
let contextBag = useMemo(
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
@@ -2,8 +2,8 @@ import React, {
Fragment,
createContext,
createRef,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
@@ -15,7 +15,6 @@ import React, {
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
useEffect,
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
@@ -473,36 +472,33 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let id = `headlessui-listbox-button-${useId()}`
let d = useDisposables()
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
})
break
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
})
break
case Keys.ArrowUp:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
})
break
}
},
[dispatch, state, d]
)
case Keys.ArrowUp:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
})
break
}
})
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
@@ -511,21 +507,18 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
event.preventDefault()
break
}
}, [])
})
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (state.listboxState === ListboxStates.Open) {
dispatch({ type: ActionTypes.CloseListbox })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
}
},
[dispatch, d, state]
)
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (state.listboxState === ListboxStates.Open) {
dispatch({ type: ActionTypes.CloseListbox })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
}
})
let labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined
@@ -577,10 +570,7 @@ let Label = forwardRefWithAs(function Label<TTag extends ElementType = typeof DE
let id = `headlessui-listbox-label-${useId()}`
let labelRef = useSyncRefs(state.labelRef, ref)
let handleClick = useCallback(
() => state.buttonRef.current?.focus({ preventScroll: true }),
[state.buttonRef]
)
let handleClick = useEvent(() => state.buttonRef.current?.focus({ preventScroll: true }))
let slot = useMemo<LabelRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
@@ -647,78 +637,75 @@ let Options = forwardRefWithAs(function Options<
container.focus({ preventScroll: true })
}, [state.listboxState, state.optionsRef])
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLUListElement>) => {
searchDisposables.dispose()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLUListElement>) => {
searchDisposables.dispose()
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
if (state.activeOptionIndex !== null) {
let { dataRef } = state.options[state.activeOptionIndex]
state.propsRef.current.onChange(dataRef.current.value)
}
if (state.propsRef.current.mode === ValueMode.Single) {
dispatch({ type: ActionTypes.CloseListbox })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
}
break
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
if (state.activeOptionIndex !== null) {
let { dataRef } = state.options[state.activeOptionIndex]
state.propsRef.current.onChange(dataRef.current.value)
}
if (state.propsRef.current.mode === ValueMode.Single) {
dispatch({ type: ActionTypes.CloseListbox })
return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
}
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
default:
if (event.key.length === 1) {
dispatch({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
}
break
}
},
[d, dispatch, searchDisposables, state]
)
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseListbox })
return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
default:
if (event.key.length === 1) {
dispatch({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
}
break
}
})
let labelledby = useComputed(
() => state.labelRef.current?.id ?? state.buttonRef.current?.id,
@@ -826,31 +813,28 @@ let Option = forwardRefWithAs(function Option<
bag.current.textValue = internalOptionRef.current?.textContent?.toLowerCase()
}, [bag, internalOptionRef])
let select = useCallback(() => state.propsRef.current.onChange(value), [state.propsRef, value])
let select = useEvent(() => state.propsRef.current.onChange(value))
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
}, [bag, id])
let handleClick = useCallback(
(event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
select()
if (state.propsRef.current.mode === ValueMode.Single) {
dispatch({ type: ActionTypes.CloseListbox })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
}
},
[dispatch, state.buttonRef, disabled, select]
)
let handleClick = useEvent((event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
select()
if (state.propsRef.current.mode === ValueMode.Single) {
dispatch({ type: ActionTypes.CloseListbox })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
}
})
let handleFocus = useCallback(() => {
let handleFocus = useEvent(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, id, dispatch])
})
let handleMove = useCallback(() => {
let handleMove = useEvent(() => {
if (disabled) return
if (active) return
dispatch({
@@ -859,13 +843,13 @@ let Option = forwardRefWithAs(function Option<
id,
trigger: ActivationTrigger.Pointer,
})
}, [disabled, active, id, dispatch])
})
let handleLeave = useCallback(() => {
let handleLeave = useEvent(() => {
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [disabled, active, dispatch])
})
let slot = useMemo<OptionRenderPropArg>(
() => ({ active, selected, disabled }),
@@ -3,7 +3,6 @@ import React, {
Fragment,
createContext,
createRef,
useCallback,
useContext,
useEffect,
useMemo,
@@ -36,6 +35,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useEvent } from '../../hooks/use-event'
enum MenuStates {
Open,
@@ -303,32 +303,29 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let id = `headlessui-menu-button-${useId()}`
let d = useDisposables()
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }))
break
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }))
break
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }))
break
}
},
[dispatch, d]
)
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }))
break
}
})
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
@@ -337,23 +334,20 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
event.preventDefault()
break
}
}, [])
})
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (props.disabled) return
if (state.menuState === MenuStates.Open) {
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
}
},
[dispatch, d, state, props.disabled]
)
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (props.disabled) return
if (state.menuState === MenuStates.Open) {
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.OpenMenu })
}
})
let slot = useMemo<ButtonRenderPropArg>(
() => ({ open: state.menuState === MenuStates.Open }),
@@ -440,78 +434,75 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
},
})
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
searchDisposables.dispose()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLDivElement>) => {
searchDisposables.dispose()
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseMenu })
if (state.activeItemIndex !== null) {
let { dataRef } = state.items[state.activeItemIndex]
dataRef.current?.domRef.current?.click()
}
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
return dispatch({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseMenu })
if (state.activeItemIndex !== null) {
let { dataRef } = state.items[state.activeItemIndex]
dataRef.current?.domRef.current?.click()
}
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next })
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next })
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous })
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseMenu })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseMenu })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
default:
if (event.key.length === 1) {
dispatch({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
}
break
}
},
[dispatch, searchDisposables, state, ownerDocument]
)
default:
if (event.key.length === 1) {
dispatch({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
}
break
}
})
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
@@ -520,7 +511,7 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
event.preventDefault()
break
}
}, [])
})
let slot = useMemo<ItemsRenderPropArg>(
() => ({ open: state.menuState === MenuStates.Open }),
@@ -607,21 +598,18 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
return () => dispatch({ type: ActionTypes.UnregisterItem, id })
}, [bag, id])
let handleClick = useCallback(
(event: MouseEvent) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
},
[dispatch, state.buttonRef, disabled]
)
let handleClick = useEvent((event: MouseEvent) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
})
let handleFocus = useCallback(() => {
let handleFocus = useEvent(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
}, [disabled, id, dispatch])
})
let handleMove = useCallback(() => {
let handleMove = useEvent(() => {
if (disabled) return
if (active) return
dispatch({
@@ -630,13 +618,13 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
id,
trigger: ActivationTrigger.Pointer,
})
}, [disabled, active, id, dispatch])
})
let handleLeave = useCallback(() => {
let handleLeave = useEvent(() => {
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
}, [disabled, active, dispatch])
})
let slot = useMemo<ItemRenderPropArg>(() => ({ active, disabled }), [active, disabled])
let ourProps = {
@@ -12,9 +12,9 @@ import React, {
ContextType,
Dispatch,
ElementType,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
FocusEvent as ReactFocusEvent,
MutableRefObject,
Ref,
} from 'react'
@@ -3,13 +3,13 @@ import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
// Types
ElementType,
MutableRefObject,
Ref,
useRef,
} from 'react'
import { createPortal } from 'react-dom'
@@ -1,16 +1,15 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
// Types
ElementType,
MutableRefObject,
KeyboardEvent as ReactKeyboardEvent,
ContextType,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MutableRefObject,
Ref,
} from 'react'
@@ -31,14 +30,14 @@ import { attemptSubmit, objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
interface Option {
interface Option<T = unknown> {
id: string
element: MutableRefObject<HTMLElement | null>
propsRef: MutableRefObject<{ value: unknown; disabled: boolean }>
propsRef: MutableRefObject<{ value: T; disabled: boolean }>
}
interface StateDefinition {
options: Option[]
interface StateDefinition<T = unknown> {
options: Option<T>[]
}
enum ActionTypes {
@@ -97,7 +96,7 @@ function useRadioGroupContext(component: string) {
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
function stateReducer<T>(state: StateDefinition<T>, action: Actions) {
return match(action.type, reducers, state, action)
}
@@ -133,9 +132,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
}
: by
)
let [{ options }, dispatch] = useReducer(stateReducer, {
options: [],
} as StateDefinition)
let [state, dispatch] = useReducer(stateReducer, { options: [] } as StateDefinition<TType>)
let options = state.options as unknown as Option<TType>[]
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let id = `headlessui-radiogroup-${useId()}`
@@ -155,20 +153,17 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
[options, value]
)
let triggerChange = useCallback(
(nextValue) => {
if (disabled) return false
if (compare(nextValue, value)) return false
let nextOption = options.find((option) =>
compare(option.propsRef.current.value as TType, nextValue)
)?.propsRef.current
if (nextOption?.disabled) return false
let triggerChange = useEvent((nextValue: TType) => {
if (disabled) return false
if (compare(nextValue, value)) return false
let nextOption = options.find((option) =>
compare(option.propsRef.current.value as TType, nextValue)
)?.propsRef.current
if (nextOption?.disabled) return false
onChange(nextValue)
return true
},
[onChange, value, disabled, options]
)
onChange(nextValue)
return true
})
useTreeWalker({
container: internalRadioGroupRef.current,
@@ -182,78 +177,72 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
},
})
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
let container = internalRadioGroupRef.current
if (!container) return
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
let container = internalRadioGroupRef.current
if (!container) return
let ownerDocument = getOwnerDocument(container)
let ownerDocument = getOwnerDocument(container)
let all = options
.filter((option) => option.propsRef.current.disabled === false)
.map((radio) => radio.element.current) as HTMLElement[]
let all = options
.filter((option) => option.propsRef.current.disabled === false)
.map((radio) => radio.element.current) as HTMLElement[]
switch (event.key) {
case Keys.Enter:
attemptSubmit(event.currentTarget)
break
case Keys.ArrowLeft:
case Keys.ArrowUp:
{
event.preventDefault()
event.stopPropagation()
switch (event.key) {
case Keys.Enter:
attemptSubmit(event.currentTarget)
break
case Keys.ArrowLeft:
case Keys.ArrowUp:
{
event.preventDefault()
event.stopPropagation()
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
(option) => option.element.current === ownerDocument?.activeElement
)
if (activeOption) triggerChange(activeOption.propsRef.current.value)
}
}
break
case Keys.ArrowRight:
case Keys.ArrowDown:
{
event.preventDefault()
event.stopPropagation()
let result = focusIn(all, Focus.Next | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
(option) => option.element.current === ownerDocument?.activeElement
)
if (activeOption) triggerChange(activeOption.propsRef.current.value)
}
}
break
case Keys.Space:
{
event.preventDefault()
event.stopPropagation()
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
(option) => option.element.current === ownerDocument?.activeElement
)
if (activeOption) triggerChange(activeOption.propsRef.current.value)
}
break
}
},
[internalRadioGroupRef, options, triggerChange]
)
}
break
let registerOption = useCallback(
(option: Option) => {
dispatch({ type: ActionTypes.RegisterOption, ...option })
return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id })
},
[dispatch]
)
case Keys.ArrowRight:
case Keys.ArrowDown:
{
event.preventDefault()
event.stopPropagation()
let result = focusIn(all, Focus.Next | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
(option) => option.element.current === ownerDocument?.activeElement
)
if (activeOption) triggerChange(activeOption.propsRef.current.value)
}
}
break
case Keys.Space:
{
event.preventDefault()
event.stopPropagation()
let activeOption = options.find(
(option) => option.element.current === ownerDocument?.activeElement
)
if (activeOption) triggerChange(activeOption.propsRef.current.value)
}
break
}
})
let registerOption = useEvent((option: Option) => {
dispatch({ type: ActionTypes.RegisterOption, ...option })
return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id })
})
let api = useMemo<ContextType<typeof RadioGroupContext>>(
() => ({
@@ -378,15 +367,15 @@ let Option = forwardRefWithAs(function Option<
[id, registerOption, internalOptionRef, props]
)
let handleClick = useCallback(() => {
let handleClick = useEvent(() => {
if (!change(value)) return
addFlag(OptionState.Active)
internalOptionRef.current?.focus()
}, [addFlag, change, value])
})
let handleFocus = useCallback(() => addFlag(OptionState.Active), [addFlag])
let handleBlur = useCallback(() => removeFlag(OptionState.Active), [removeFlag])
let handleFocus = useEvent(() => addFlag(OptionState.Active))
let handleBlur = useEvent(() => removeFlag(OptionState.Active))
let isFirstOption = firstOption?.id === id
let isDisabled = radioGroupDisabled || disabled
@@ -1,11 +1,10 @@
import React, {
Fragment,
createContext,
useCallback,
useContext,
useMemo,
useState,
useRef,
useState,
// Types
ElementType,
@@ -25,6 +24,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
import { useEvent } from '../../hooks/use-event'
interface StateDefinition {
switch: HTMLButtonElement | null
@@ -121,32 +121,23 @@ let SwitchRoot = forwardRefWithAs(function Switch<
groupContext === null ? null : groupContext.setSwitch
)
let toggle = useCallback(() => onChange(!checked), [onChange, checked])
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
let toggle = useEvent(() => onChange(!checked))
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()
},
[toggle]
)
let handleKeyUp = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === Keys.Space) {
event.preventDefault()
toggle()
} else if (event.key === Keys.Enter) {
attemptSubmit(event.currentTarget)
}
},
[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 = useCallback(
(event: ReactKeyboardEvent<HTMLElement>) => event.preventDefault(),
[]
)
let handleKeyPress = useEvent((event: ReactKeyboardEvent<HTMLElement>) => event.preventDefault())
let slot = useMemo<SwitchRenderPropArg>(() => ({ checked }), [checked])
let ourProps = {
@@ -1,20 +1,17 @@
import React, {
Fragment,
createContext,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
useEffect,
// Types
ElementType,
MutableRefObject,
MouseEvent as ReactMouseEvent,
KeyboardEvent as ReactKeyboardEvent,
Dispatch,
ContextType,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
} from 'react'
@@ -29,21 +26,17 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useLatestValue } from '../../hooks/use-latest-value'
import { FocusSentinel } from '../../internal/focus-sentinel'
import { useEvent } from '../../hooks/use-event'
interface StateDefinition {
selectedIndex: number
orientation: 'horizontal' | 'vertical'
activation: 'auto' | 'manual'
tabs: MutableRefObject<HTMLElement | null>[]
panels: MutableRefObject<HTMLElement | null>[]
}
enum ActionTypes {
SetSelectedIndex,
SetOrientation,
SetActivation,
RegisterTab,
UnregisterTab,
@@ -56,8 +49,6 @@ enum ActionTypes {
type Actions =
| { type: ActionTypes.SetSelectedIndex; index: number }
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.SetActivation; activation: StateDefinition['activation'] }
| { type: ActionTypes.RegisterTab; tab: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.UnregisterTab; tab: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.RegisterPanel; panel: MutableRefObject<HTMLElement | null> }
@@ -95,14 +86,6 @@ let reducers: {
return { ...state, selectedIndex: state.tabs.indexOf(next) }
},
[ActionTypes.SetOrientation](state, action) {
if (state.orientation === action.orientation) return state
return { ...state, orientation: action.orientation }
},
[ActionTypes.SetActivation](state, action) {
if (state.activation === action.activation) return state
return { ...state, activation: action.activation }
},
[ActionTypes.RegisterTab](state, action) {
if (state.tabs.includes(action.tab)) return state
return { ...state, tabs: sortByDomNode([...state.tabs, action.tab], (tab) => tab.current) }
@@ -128,11 +111,6 @@ let reducers: {
},
}
let TabsContext = createContext<
[StateDefinition, { change(index: number): void; dispatch: Dispatch<Actions> }] | null
>(null)
TabsContext.displayName = 'TabsContext'
let TabsSSRContext = createContext<MutableRefObject<{ tabs: string[]; panels: string[] }> | null>(
null
)
@@ -148,11 +126,38 @@ function useSSRTabsCounter(component: string) {
return context
}
function useTabsContext(component: string) {
let context = useContext(TabsContext)
let TabsDataContext = createContext<
| ({
orientation: 'horizontal' | 'vertical'
activation: 'auto' | 'manual'
} & StateDefinition)
| null
>(null)
TabsDataContext.displayName = 'TabsDataContext'
function useData(component: string) {
let context = useContext(TabsDataContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Tab.Group /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext)
if (Error.captureStackTrace) Error.captureStackTrace(err, useData)
throw err
}
return context
}
let TabsActionsContext = createContext<{
registerTab(tab: MutableRefObject<HTMLElement | null>): () => void
registerPanel(panel: MutableRefObject<HTMLElement | null>): () => void
change(index: number): void
forceRerender(): void
} | null>(null)
TabsActionsContext.displayName = 'TabsActionsContext'
function useActions(component: string) {
let context = useContext(TabsActionsContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Tab.Group /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useActions)
throw err
}
return context
@@ -195,79 +200,72 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
selectedIndex: selectedIndex ?? defaultIndex,
tabs: [],
panels: [],
orientation,
activation,
} as StateDefinition)
})
let slot = useMemo(() => ({ selectedIndex: state.selectedIndex }), [state.selectedIndex])
let onChangeRef = useLatestValue(onChange || (() => {}))
let stableTabsRef = useLatestValue(state.tabs)
useEffect(() => {
dispatch({ type: ActionTypes.SetOrientation, orientation })
}, [orientation])
useEffect(() => {
dispatch({ type: ActionTypes.SetActivation, activation })
}, [activation])
useIsoMorphicEffect(() => {
let indexToSet = selectedIndex ?? defaultIndex
dispatch({ type: ActionTypes.SetSelectedIndex, index: indexToSet })
}, [selectedIndex /* Deliberately skipping defaultIndex */])
let lastChangedIndex = useRef(state.selectedIndex)
useEffect(() => {
lastChangedIndex.current = state.selectedIndex
}, [state.selectedIndex])
let providerBag = useMemo<ContextType<typeof TabsContext>>(
() => [
state,
{
dispatch,
change(index: number) {
if (lastChangedIndex.current !== index) onChangeRef.current(index)
lastChangedIndex.current = index
dispatch({ type: ActionTypes.SetSelectedIndex, index })
},
},
],
[state, dispatch]
let tabsData = useMemo<ContextType<typeof TabsDataContext>>(
() => ({ orientation, activation, ...state }),
[orientation, activation, state]
)
let SSRCounter = useRef({
tabs: [],
panels: [],
})
let lastChangedIndex = useLatestValue(state.selectedIndex)
let tabsActions: ContextType<typeof TabsActionsContext> = useMemo(
() => ({
registerTab(tab) {
dispatch({ type: ActionTypes.RegisterTab, tab })
return () => dispatch({ type: ActionTypes.UnregisterTab, tab })
},
registerPanel(panel) {
dispatch({ type: ActionTypes.RegisterPanel, panel })
return () => dispatch({ type: ActionTypes.UnregisterPanel, panel })
},
forceRerender() {
dispatch({ type: ActionTypes.ForceRerender })
},
change(index: number) {
if (lastChangedIndex.current !== index) onChangeRef.current(index)
lastChangedIndex.current = index
let ourProps = {
ref: tabsRef,
}
dispatch({ type: ActionTypes.SetSelectedIndex, index })
},
}),
[dispatch]
)
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex })
}, [selectedIndex /* Deliberately skipping defaultIndex */])
let SSRCounter = useRef({ tabs: [], panels: [] })
let ourProps = { ref: tabsRef }
return (
<TabsSSRContext.Provider value={SSRCounter}>
<TabsContext.Provider value={providerBag}>
<FocusSentinel
onFocus={() => {
for (let tab of stableTabsRef.current) {
if (tab.current?.tabIndex === 0) {
tab.current?.focus()
return true
<TabsActionsContext.Provider value={tabsActions}>
<TabsDataContext.Provider value={tabsData}>
<FocusSentinel
onFocus={() => {
for (let tab of stableTabsRef.current) {
if (tab.current?.tabIndex === 0) {
tab.current?.focus()
return true
}
}
}
return false
}}
/>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_TABS_TAG,
name: 'Tabs',
})}
</TabsContext.Provider>
return false
}}
/>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_TABS_TAG,
name: 'Tabs',
})}
</TabsDataContext.Provider>
</TabsActionsContext.Provider>
</TabsSSRContext.Provider>
)
})
@@ -284,7 +282,7 @@ let List = forwardRefWithAs(function List<TTag extends ElementType = typeof DEFA
props: Props<TTag, ListRenderPropArg, ListPropsWeControl> & {},
ref: Ref<HTMLElement>
) {
let [{ selectedIndex, orientation }] = useTabsContext('Tab.List')
let { orientation, selectedIndex } = useData('Tab.List')
let listRef = useSyncRefs(ref)
let slot = { selectedIndex }
@@ -319,20 +317,17 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE
) {
let id = `headlessui-tabs-tab-${useId()}`
let [{ selectedIndex, tabs, panels, orientation, activation }, { dispatch, change }] =
useTabsContext('Tab')
let { orientation, activation, selectedIndex, tabs, panels } = useData('Tab')
let actions = useActions('Tab')
let SSRContext = useSSRTabsCounter('Tab')
let internalTabRef = useRef<HTMLElement>(null)
let internalTabRef = useRef<HTMLElement | null>(null)
let tabRef = useSyncRefs(internalTabRef, ref, (element) => {
if (!element) return
dispatch({ type: ActionTypes.ForceRerender })
actions.forceRerender()
})
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterTab, tab: internalTabRef })
return () => dispatch({ type: ActionTypes.UnregisterTab, tab: internalTabRef })
}, [dispatch, internalTabRef])
useIsoMorphicEffect(() => actions.registerTab(internalTabRef), [actions, internalTabRef])
let mySSRIndex = SSRContext.current.tabs.indexOf(id)
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.tabs.push(id) - 1
@@ -341,65 +336,62 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE
if (myIndex === -1) myIndex = mySSRIndex
let selected = myIndex === selectedIndex
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLElement>) => {
let list = tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[]
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
let list = tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[]
if (event.key === Keys.Space || event.key === Keys.Enter) {
if (event.key === Keys.Space || event.key === Keys.Enter) {
event.preventDefault()
event.stopPropagation()
actions.change(myIndex)
return
}
switch (event.key) {
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
change(myIndex)
return focusIn(list, Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.Last)
}
return match(orientation, {
vertical() {
if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround)
return
}
},
horizontal() {
if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
})
})
switch (event.key) {
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return focusIn(list, Focus.Last)
}
return match(orientation, {
vertical() {
if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
horizontal() {
if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround)
if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround)
return
},
})
},
[tabs, orientation, myIndex, change]
)
let handleFocus = useCallback(() => {
let handleFocus = useEvent(() => {
internalTabRef.current?.focus()
}, [internalTabRef])
})
let handleSelection = useCallback(() => {
let handleSelection = useEvent(() => {
internalTabRef.current?.focus()
change(myIndex)
}, [change, myIndex, internalTabRef])
actions.change(myIndex)
})
// This is important because we want to only focus the tab when it gets focus
// OR it finished the click event (mouseup). However, if you perform a `click`,
// then you will first get the `focus` and then get the `click` event.
let handleMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault()
}, [])
})
let slot = useMemo(() => ({ selected }), [selected])
@@ -438,7 +430,7 @@ let Panels = forwardRefWithAs(function Panels<TTag extends ElementType = typeof
props: Props<TTag, PanelsRenderPropArg>,
ref: Ref<HTMLElement>
) {
let [{ selectedIndex }] = useTabsContext('Tab.Panels')
let { selectedIndex } = useData('Tab.Panels')
let panelsRef = useSyncRefs(ref)
let slot = useMemo(() => ({ selectedIndex }), [selectedIndex])
@@ -469,20 +461,18 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
PropsForFeatures<typeof PanelRenderFeatures>,
ref: Ref<HTMLElement>
) {
let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext('Tab.Panel')
let { selectedIndex, tabs, panels } = useData('Tab.Panel')
let actions = useActions('Tab.Panel')
let SSRContext = useSSRTabsCounter('Tab.Panel')
let id = `headlessui-tabs-panel-${useId()}`
let internalPanelRef = useRef<HTMLElement>(null)
let panelRef = useSyncRefs(internalPanelRef, ref, (element) => {
if (!element) return
dispatch({ type: ActionTypes.ForceRerender })
actions.forceRerender()
})
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterPanel, panel: internalPanelRef })
return () => dispatch({ type: ActionTypes.UnregisterPanel, panel: internalPanelRef })
}, [dispatch, internalPanelRef])
useIsoMorphicEffect(() => actions.registerPanel(internalPanelRef), [actions, internalPanelRef])
let mySSRIndex = SSRContext.current.panels.indexOf(id)
if (mySSRIndex === -1) mySSRIndex = SSRContext.current.panels.push(id) - 1
@@ -1,9 +1,13 @@
import React from 'react'
import { useLatestValue } from './use-latest-value'
export let useEvent =
// TODO: Add React.useEvent ?? once the useEvent hook is available
function useEvent<T, R>(cb: (...args: T[]) => R) {
let cache = React.useRef(cb)
cache.current = cb
return React.useCallback((...args: T[]) => cache.current(...args), [cache])
function useEvent<
F extends (...args: any[]) => any,
P extends any[] = Parameters<F>,
R = ReturnType<F>
>(cb: (...args: P) => R) {
let cache = useLatestValue(cb)
return React.useCallback((...args: P) => cache.current(...args), [cache])
}
@@ -1,12 +1,13 @@
import { useState, useCallback } from 'react'
import { useState } from 'react'
import { useEvent } from './use-event'
export function useFlags(initialFlags = 0) {
let [flags, setFlags] = useState(initialFlags)
let addFlag = useCallback((flag: number) => setFlags((flags) => flags | flag), [setFlags])
let hasFlag = useCallback((flag: number) => Boolean(flags & flag), [flags])
let removeFlag = useCallback((flag: number) => setFlags((flags) => flags & ~flag), [setFlags])
let toggleFlag = useCallback((flag: number) => setFlags((flags) => flags ^ flag), [setFlags])
let addFlag = useEvent((flag: number) => setFlags((flags) => flags | flag))
let hasFlag = useEvent((flag: number) => Boolean(flags & flag))
let removeFlag = useEvent((flag: number) => setFlags((flags) => flags & ~flag))
let toggleFlag = useEvent((flag: number) => setFlags((flags) => flags ^ flag))
return { addFlag, hasFlag, removeFlag, toggleFlag }
}
@@ -1,3 +1,3 @@
import { useLayoutEffect, useEffect } from 'react'
export const useIsoMorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
export let useIsoMorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
@@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback } from 'react'
import { useRef, useEffect } from 'react'
import { useEvent } from './use-event'
let Optional = Symbol()
@@ -15,16 +16,13 @@ export function useSyncRefs<TType>(
cache.current = refs
}, [refs])
let syncRefs = useCallback(
(value: TType) => {
for (let ref of cache.current) {
if (ref == null) continue
if (typeof ref === 'function') ref(value)
else ref.current = value
}
},
[cache]
)
let syncRefs = useEvent((value: TType) => {
for (let ref of cache.current) {
if (ref == null) continue
if (typeof ref === 'function') ref(value)
else ref.current = value
}
})
return refs.every(
(ref) =>
@@ -1,6 +1,5 @@
import React, {
createContext,
useCallback,
useContext,
// Types
@@ -8,6 +7,7 @@ import React, {
ReactNode,
} from 'react'
import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect'
import { useEvent } from '../hooks/use-event'
type OnUpdate = (
message: StackMessage,
@@ -40,16 +40,13 @@ export function StackProvider({
}) {
let parentUpdate = useStackContext()
let notify = useCallback(
(...args: Parameters<OnUpdate>) => {
// Notify our layer
onUpdate?.(...args)
let notify = useEvent((...args: Parameters<OnUpdate>) => {
// Notify our layer
onUpdate?.(...args)
// Notify the parent
parentUpdate(...args)
},
[parentUpdate, onUpdate]
)
// Notify the parent
parentUpdate(...args)
})
useIsoMorphicEffect(() => {
notify(StackMessage.Add, type, element)