Created
December 15, 2023 15:09
-
-
Save brookback/3d4079a20f7b7a9d85de24c63710c2d2 to your computer and use it in GitHub Desktop.
Native popovers in Preact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as preact from 'preact'; | |
import { DOMAttrs, DOMEvent, JSXElement, CSS, Ref, RefObject } from './types'; | |
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'preact/hooks'; | |
import { classNames } from '@lookback/shared'; | |
import { forwardRef, memo } from 'preact/compat'; | |
interface Props { | |
button: JSXElement | ((isExpanded: boolean) => JSXElement); | |
style?: CSS; | |
} | |
interface Context { | |
id: string; | |
isExpanded: boolean; | |
buttonRef: Ref<HTMLButtonElement>; | |
} | |
const PopoverContext = preact.createContext<Context | null>(null); | |
export const Popover = memo( | |
forwardRef<HTMLDivElement, Props & Omit<DOMAttrs<HTMLDivElement>, 'id' | 'style'>>( | |
({ button, onToggle, class: className, style, ...props }, ref) => { | |
const generatedId = useId(); | |
const [isExpanded, setExpanded] = useState<boolean>(false); | |
const [position, setPosition] = useState<CSS>({}); | |
const buttonRef = useRef<HTMLButtonElement>(null); | |
const id = `popover-${generatedId}`; | |
const ctx: Context = { id, isExpanded, buttonRef }; | |
const renderedButton = useMemo( | |
() => (typeof button == 'function' ? button(isExpanded) : button), | |
[isExpanded, button], | |
); | |
const onPopoverToggle = (evt: ToggleEvent<HTMLDivElement>) => { | |
setExpanded(evt.newState == 'open'); | |
// The popover is in the DOM — rendered — but until it's actually shown, we can't measure it. We need | |
// do wait for this event for it to happen. | |
if (evt.newState == 'open') { | |
setPosition(positionOf(buttonRef)); | |
} | |
onToggle?.call(evt.target, evt); | |
}; | |
// Track position of trigger button and calculate popover's position accordingly. | |
useEffect(() => { | |
if (!isExpanded || !buttonRef.current || !ref || typeof ref == 'function') return; | |
const observer = observeRect(buttonRef.current, (rect) => { | |
if (!ref.current) return; | |
setPosition(positionCenter(rect, ref.current)); | |
}); | |
observer.observe(); | |
return () => observer.unobserve(); | |
}, [isExpanded]); | |
return ( | |
<PopoverContext.Provider value={ctx}> | |
{renderedButton} | |
<div | |
{...props} | |
ref={ref} | |
id={id} | |
popover="auto" | |
class={classNames('Popover', className)} | |
onToggle={onPopoverToggle} | |
style={{ ...style, ...position }} | |
/> | |
</PopoverContext.Provider> | |
); | |
}, | |
), | |
); | |
const usePopover = (): Context => { | |
const ctx = useContext<Context | null>(PopoverContext); | |
if (!ctx) { | |
throw new Error('Must call usePopover within a <Popover />!'); | |
} | |
return ctx; | |
}; | |
export const PopoverButton = (props: Omit<DOMAttrs<HTMLButtonElement>, 'id' | 'ref'>) => { | |
const { id, isExpanded, buttonRef } = usePopover(); | |
return <button {...props} popovertarget={id} aria-expanded={isExpanded} ref={buttonRef} />; | |
}; | |
const positionOf = (triggerRef: RefObject<HTMLButtonElement | null>) => { | |
const trigger = triggerRef.current; | |
if (!trigger) return {}; | |
const target = trigger.popoverTargetElement; | |
if (!target) { | |
throw new Error('No popoverTargetElement on button trigger!'); | |
} | |
return positionCenter(trigger.getBoundingClientRect(), target); | |
}; | |
const positionCenter = (triggerRect: DOMRect, target: HTMLElement) => { | |
const targetRect = target.getBoundingClientRect(); | |
let left = triggerRect.right + window.pageXOffset - targetRect.width / 2 - triggerRect.width / 2; | |
const overflowRight = window.outerWidth - (left + targetRect.width); | |
// Prevent right edge overflowing window | |
if (overflowRight < 0) { | |
left -= Math.abs(overflowRight); | |
} | |
return { | |
position: 'absolute', | |
left: Math.max(0, left) + 'px', | |
...getTopPosition(triggerRect, targetRect), | |
}; | |
}; | |
const getCollisions = (triggerRect: DOMRect, tooltipRect: DOMRect, offsetBottom = 0) => { | |
const collisions = { | |
top: triggerRect.top - tooltipRect.height < 0, | |
right: window.innerWidth < triggerRect.left + tooltipRect.width, | |
bottom: window.innerHeight < triggerRect.bottom + tooltipRect.height + offsetBottom, | |
left: triggerRect.left - tooltipRect.width < 0, | |
}; | |
const directionRight = collisions.right && !collisions.left; | |
const directionLeft = collisions.left && !collisions.right; | |
const directionUp = collisions.bottom && !collisions.top; | |
const directionDown = collisions.top && !collisions.bottom; | |
return { directionRight, directionLeft, directionUp, directionDown }; | |
}; | |
const getTopPosition = (targetRect: DOMRect, popoverRect: DOMRect) => { | |
const { directionUp } = getCollisions(targetRect, popoverRect); | |
return { | |
top: directionUp | |
? `${targetRect.top - popoverRect.height + window.pageYOffset}px` | |
: `${targetRect.top + targetRect.height + window.pageYOffset}px`, | |
}; | |
}; | |
// OBSERVE DOMRECT | |
const observableProps: (keyof DOMRect)[] = ['bottom', 'height', 'left', 'right', 'top', 'width']; | |
type RectProps = { | |
rect: DOMRect | undefined; | |
hasChanged: boolean; | |
callbacks: Function[]; | |
}; | |
let rafId: number; | |
let observedNodes = new Map<HTMLElement, RectProps>(); | |
const changed = (a: DOMRect, b: DOMRect) => observableProps.some((p) => a[p] !== b[p]); | |
const doObserve = () => { | |
for (const [node, state] of observedNodes) { | |
const rect = node.getBoundingClientRect(); | |
if (!state.rect || changed(rect, state.rect)) { | |
state.callbacks.forEach((cb) => cb(rect)); | |
} | |
} | |
rafId = window.requestAnimationFrame(doObserve); | |
}; | |
/** Poll based observing of the bounding rect on `target`. */ | |
const observeRect = (target: HTMLElement, onChange: (rect: DOMRect) => void) => { | |
return { | |
observe: () => { | |
const wasEmpty = observedNodes.size == 0; | |
if (observedNodes.has(target)) { | |
observedNodes.get(target)!.callbacks.push(onChange); | |
} else { | |
observedNodes.set(target, { | |
callbacks: [onChange], | |
hasChanged: false, | |
rect: undefined, | |
}); | |
} | |
if (wasEmpty) doObserve(); | |
}, | |
unobserve: () => { | |
const state = observedNodes.get(target); | |
if (state) { | |
const index = state.callbacks.indexOf(onChange); | |
if (index >= 0) state.callbacks.splice(index, 1); | |
if (!state.callbacks.length) observedNodes.delete(target); | |
if (!observedNodes.size) cancelAnimationFrame(rafId); | |
} | |
}, | |
}; | |
}; | |
// TYPE POLYFILLS | |
declare module 'preact' { | |
namespace JSX { | |
interface HTMLAttributes<RefType extends EventTarget = EventTarget> { | |
popover?: 'auto' | 'manual'; | |
popovertarget?: string; | |
onBeforeToggle?: (evt: ToggleEvent<RefType>) => void; | |
} | |
} | |
} | |
declare global { | |
interface HTMLElement { | |
popoverTargetElement: HTMLElement | null; | |
showPopover: () => void; | |
} | |
} | |
interface ToggleEvent<T extends EventTarget> extends DOMEvent<T> { | |
newState: 'open' | 'closed'; | |
oldState: 'open' | 'closed'; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment