Skip to content

Instantly share code, notes, and snippets.

@brookback
Created December 15, 2023 15:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brookback/3d4079a20f7b7a9d85de24c63710c2d2 to your computer and use it in GitHub Desktop.
Save brookback/3d4079a20f7b7a9d85de24c63710c2d2 to your computer and use it in GitHub Desktop.
Native popovers in Preact.
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