reblend-ui
Version:
Utilities for creating robust overlay components
228 lines (199 loc) • 6.23 kB
text/typescript
import * as Popper from '@popperjs/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'reblendjs';
import { dequal } from 'dequal';
import { createPopper } from './popper';
const disabledApplyStylesModifier = {
name: 'applyStyles',
enabled: false,
phase: 'afterWrite',
fn: () => undefined,
};
// until docjs supports type exports...
export type Modifier<Name, Options extends Popper.Obj> = Popper.Modifier<
Name,
Options
>;
export type Options = Popper.Options;
export type Instance = Popper.Instance;
export type Placement = Popper.Placement;
export type VirtualElement = Popper.VirtualElement;
export type State = Popper.State;
export type OffsetValue = [
number | null | undefined,
number | null | undefined,
];
export type OffsetFunction = (details: {
popper: Popper.Rect;
reference: Popper.Rect;
placement: Placement;
}) => OffsetValue;
export type Offset = OffsetFunction | OffsetValue;
export type ModifierMap = Record<string, Partial<Modifier<any, any>>>;
export type Modifiers =
| Popper.Options['modifiers']
| Record<string, Partial<Modifier<any, any>>>;
export type UsePopperOptions = Omit<
Options,
'modifiers' | 'placement' | 'strategy'
> & {
enabled?: boolean;
placement?: Options['placement'];
strategy?: Options['strategy'];
modifiers?: Options['modifiers'];
};
export interface UsePopperState {
placement: Placement;
update: () => void;
forceUpdate: () => void;
attributes: Record<string, Record<string, any>>;
styles: Record<string, Partial<CSSStyleDeclaration>>;
state?: State;
}
const ariaDescribedByModifier: Modifier<
'ariaDescribedBy',
Record<string, never>
> = {
name: 'ariaDescribedBy',
enabled: true,
phase: 'afterWrite',
effect:
({ state }) =>
() => {
const { reference, popper } = state.elements;
if ('removeAttribute' in reference) {
const ids = (reference.getAttribute('aria-describedby') || '')
.split(',')
.filter((id) => id.trim() !== popper.id);
if (!ids.length) reference.removeAttribute('aria-describedby');
else reference.setAttribute('aria-describedby', ids.join(','));
}
},
fn: ({ state }) => {
const { popper, reference } = state.elements;
const role = popper.getAttribute('role')?.toLowerCase();
if (popper.id && role === 'tooltip' && 'setAttribute' in reference) {
const ids = reference.getAttribute('aria-describedby');
if (ids && ids.split(',').indexOf(popper.id) !== -1) {
return;
}
reference.setAttribute(
'aria-describedby',
ids ? `${ids},${popper.id}` : popper.id,
);
}
},
};
const EMPTY_MODIFIERS = [] as any;
/**
* Position an element relative some reference element using Popper.js
*
* @param referenceElement
* @param popperElement
* @param {object} options
* @param {object=} options.modifiers Popper.js modifiers
* @param {boolean=} options.enabled toggle the popper functionality on/off
* @param {string=} options.placement The popper element placement relative to the reference element
* @param {string=} options.strategy the positioning strategy
* @param {function=} options.onCreate called when the popper is created
* @param {function=} options.onUpdate called when the popper is updated
*
* @returns {UsePopperState} The popper state
*/
function usePopper(
referenceElement: VirtualElement | null | undefined,
popperElement: HTMLElement | null | undefined,
{
enabled = true,
placement = 'bottom',
strategy = 'absolute',
modifiers = EMPTY_MODIFIERS,
...config
}: UsePopperOptions = {},
): UsePopperState {
const prevModifiers = useRef<UsePopperOptions['modifiers']>(modifiers);
const popperInstanceRef = useRef<Instance>(undefined);
const update = useCallback(() => {
popperInstanceRef.current?.update();
});
const forceUpdate = useCallback(() => {
popperInstanceRef.current?.forceUpdate();
});
const [popperState, setState] = useState<UsePopperState>({
placement,
update,
forceUpdate,
attributes: {},
styles: {
popper: {},
arrow: {},
},
});
const updateModifier = useMemo<Modifier<'updateStateModifier', any>, []>(
() => ({
name: 'updateStateModifier',
enabled: true,
phase: 'write',
requires: ['computeStyles'],
fn: ({ state }) => {
const styles: UsePopperState['styles'] = {};
const attributes: UsePopperState['attributes'] = {};
Object.keys(state.elements).forEach((element) => {
styles[element] = state.styles[element];
attributes[element] = state.attributes[element];
});
setState({
state,
styles,
attributes,
update,
forceUpdate,
placement: state.placement,
});
},
}),
[],
);
const nextModifiers = useMemo(() => {
if (!dequal(prevModifiers.current, modifiers)) {
prevModifiers.current = modifiers;
}
return prevModifiers.current!;
}, [modifiers]);
useEffect(() => {
if (!popperInstanceRef.current || !enabled) return;
popperInstanceRef.current.setOptions({
placement,
strategy,
modifiers: [
...nextModifiers,
updateModifier,
disabledApplyStylesModifier,
],
});
}, [strategy, placement, updateModifier, enabled, nextModifiers]);
useEffect(() => {
if (!enabled || referenceElement == null || popperElement == null) {
return undefined;
}
popperInstanceRef.current = createPopper(referenceElement, popperElement, {
...config,
placement,
strategy,
modifiers: [...nextModifiers, ariaDescribedByModifier, updateModifier],
});
return () => {
if (popperInstanceRef.current != null) {
popperInstanceRef.current.destroy();
popperInstanceRef.current = undefined;
setState((s) => ({
...s,
attributes: {},
styles: { popper: {} },
}));
}
};
// This is only run once to _create_ the popper
}, [enabled, referenceElement, popperElement]);
return popperState;
}
export default usePopper;