8000 Implement `Popover` using a separate state machine (#3725) · tailwindlabs/headlessui@afc04bc · GitHub
[go: up one dir, main page]

Skip to content

Commit afc04bc

Browse files
authored
Implement Popover using a separate state machine (#3725)
This PR is an internal refactor, similar to what we did recently for the `Menu`, `Listbox` and `Combobox` components by using a dedicated state machine. This is basically a one-to-one translation without any further optimizations ## Test plan 1. All tests are passing 1. Verified that the Popover still works as expected: https://headlessui-react-git-chore-popover-using-st-7fa062-tailwindlabs.vercel.app/popover/popover
1 parent 7ff4b5b commit afc04bc

File tree

5 files changed

+351
-315
lines changed
< 8000 span class="prc-TooltipV2-Tooltip-cYMVY" data-direction="s" aria-hidden="true" id=":R3t5dab:">Filter options

5 files changed

+351
-315
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createContext, useContext, useMemo } from 'react'
2+
import { PopoverMachine } from './popover-machine'
3+
4+
export const PopoverContext = createContext<PopoverMachine | null>(null)
5+
export function usePopoverMachineContext(component: string) {
6+
let context = useContext(PopoverContext)
7+
if (context === null) {
8+
let err = new Error(`<${component} /> is missing a parent <Popover /> component.`)
9+
if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverMachineContext)
10+
throw err
11+
}
12+
return context
13+
}
14+
15+
export function usePopoverMachine({ __demoMode = false } = {}) {
16+
return useMemo(() => PopoverMachine.new({ __demoMode }), [])
17+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { type MouseEventHandler } from 'react'
2+
import { Machine } from '../../machine'
3+
import * as DOM from '../../utils/dom'
4+
import { getFocusableElements } from '../../utils/focus-management'
5+
import { match } from '../../utils/match'
6+
7+
type MouseEvent<T> = Parameters<MouseEventHandler<T>>[0]
8+
9+
export enum PopoverStates {
10+
Open,
11+
Closed,
12+
}
13+
14+
interface State {
15+
popoverState: PopoverStates
16+
17+
buttons: { current: Symbol[] }
18+
19+
button: HTMLElement | null
20+
buttonId: string | null
21+
panel: HTMLElement | null
22+
panelId: string | null
23+
24+
beforePanelSentinel: { current: HTMLButtonElement | null }
25+
afterPanelSentinel: { current: HTMLButtonElement | null }
26+
afterButtonSentinel: { current: HTMLButtonElement | null }
27+
28+
__demoMode: boolean
29+
}
30+
31+
export enum ActionTypes {
32+
OpenPopover,
33+
ClosePopover,
34+
35+
SetButton,
36+
SetButtonId,
37+
SetPanel,
38+
SetPanelId,
39+
}
40+
41+
export type Actions =
42+
| { type: ActionTypes.OpenPopover }
43+
| { type: ActionTypes.ClosePopover }
44+
| { type: ActionTypes.SetButton; button: HTMLElement | null }
45+
| { type: ActionTypes.SetButtonId; buttonId: string | null }
46+
| { type: ActionTypes.SetPanel; panel: HTMLElement | null }
47+
| { type: ActionTypes.SetPanelId; panelId: string | null }
48+
49+
let reducers: {
50+
[P in ActionTypes]: (state: State, action: Extract<Actions, { type: P }>) => State
51+
} = {
52+
[ActionTypes.OpenPopover]: (state) => {
53+
if (state.popoverState === PopoverStates.Open) return state
54+
return { ...state, popoverState: PopoverStates.Open, __demoMode: false }
55+
},
56+
[ActionTypes.ClosePopover](state) {
57+
if (state.popoverState === PopoverStates.Closed) return state
58+
return { ...state, popoverState: PopoverStates.Closed, __demoMode: false }
59+
},
60+
[ActionTypes.SetButton](state, action) {
61+
if (state.button === action.button) return state
62+
return { ...state, button: action.button }
63+
},
64+
[ActionTypes.SetButtonId](state, action) {
65+
if (state.buttonId === action.buttonId) return state
66+
return { ...state, buttonId: action.buttonId }
67+
},
68+
[ActionTypes.SetPanel](state, action) {
69+
if (state.panel === action.panel) return state
70+
return { ...state, panel: action.panel }
71+
},
72+
[ActionTypes.SetPanelId](state, action) {
73+
if (state.panelId === action.panelId) return state
74+
return { ...state, panelId: action.panelId }
75+
},
76+
}
77+
78+
export class PopoverMachine extends Machine<State, Actions> {
79+
static new({ __demoMode = false } = {}) {
80+
return new PopoverMachine({
81+
__demoMode,
82+
popoverState: __demoMode ? PopoverStates.Open : PopoverStates.Closed,
83+
buttons: { current: [] },
84+
button: null,
85+
buttonId: null,
86+
panel: null,
87+
panelId: null,
88+
beforePanelSentinel: { current: null },
89+
afterPanelSentinel: { current: null },
90+
afterButtonSentinel: { current: null },
91+
})
92+
}
93+
94+
reduce(state: Readonly<State>, action: Actions): State {
95+
return match(action.type, reducers, state, action)
96+
}
97+
98+
actions = {
99+
close: () => this.send({ type: ActionTypes.ClosePopover }),
100+
refocusableClose: (
101+
focusableElement?: HTMLElement | { current: HTMLElement | null } | MouseEvent<HTMLElement>
102+
) => {
103+
this.actions.close()
104+
105+
let restoreElement = (() => {
106+
if (!focusableElement) return this.state.button
107+
if (DOM.isHTMLElement(focusableElement)) return focusableElement
108+
if ('current' in focusableElement && DOM.isHTMLElement(focusableElement.current)) {
109+
return focusableElement.current
110+
}
111+
112+
return this.state.button
113+
})()
114+
115+
restoreElement?.focus()
116+
},
117+
open: () => this.send({ type: ActionTypes.OpenPopover }),
118+
setButtonId: (id: string | null) => this.send({ type: ActionTypes.SetButtonId, buttonId: id }),
119+
setButton: (button: HTMLElement | null) => this.send({ type: ActionTypes.SetButton, button }),
120+
setPanelId: (id: string | null) => this.send({ type: ActionTypes.SetPanelId, panelId: id }),
121+
setPanel: (panel: HTMLElement | null) => this.send({ type: ActionTypes.SetPanel, panel }),
122+
}
123+
124+
selectors = {
125+
isPortalled: (state: State) => {
126+
if (!state.button) return false
127+
if (!state.panel) return false
128+
129+
// We are part of a different "root" tree, so therefore we can consider it portalled. This is a
130+
// heuristic because 3rd party tools could use some form of portal, typically rendered at the
131+
// end of the body but we don't have an actual reference to that.
132+
for (let root of document.querySelectorAll('body > *')) {
133+
if (Number(root?.contains(state.button)) ^ Number(root?.contains(state.panel))) {
134+
return true
135+
}
136+
}
137+
138+
// Use another heuristic to try and calculate whether or not the focusable
139+
// elements are near each other (aka, following the default focus/tab order
140+
// from the browser). If they are then it doesn't really matter if they are
141+
// portalled or not because we can follow the default tab order. But if they
142+
// are not, then we can consider it being portalled so that we can ensure
143+
// that tab and shift+tab (hopefully) go to the correct spot.
144+
let elements = getFocusableElements()
145+
let buttonIdx = elements.indexOf(state.button)
146+
147+
let beforeIdx = (buttonIdx + elements.length - 1) % elements.length
148+
let afterIdx = (buttonIdx + 1) % elements.length
149+
150+
let beforeElement = elements[beforeIdx]
151+
let afterElement = elements[afterIdx]
152+
153+
if (!state.panel.contains(beforeElement) && !state.panel.contains(afterElement)) {
154+
return true
155+
}
156+
157+
// It may or may not be portalled, but we don't really know.
158+
return false
159+
},
160+
}
161+
}

0 commit comments

Comments
 (0)
0