383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import * as FloatingUI from "@floating-ui/dom";
|
|
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
|
|
import { button } from "./components.util";
|
|
|
|
export interface FloatingProperties
|
|
{
|
|
placement?: FloatingUI.Placement;
|
|
offset?: FloatingUI.OffsetOptions;
|
|
arrow?: boolean;
|
|
class?: Class;
|
|
style?: Record<string, string | undefined | boolean | number> | string;
|
|
viewport?: HTMLElement;
|
|
cover?: 'width' | 'height' | 'all' | 'none';
|
|
}
|
|
export interface FollowerProperties extends FloatingProperties
|
|
{
|
|
blur?: () => void;
|
|
priority?: boolean;
|
|
}
|
|
export type FloatState = 'shown' | 'showing' | 'hidden' | 'hiding' | 'pinned';
|
|
export interface PopperProperties extends FloatingProperties
|
|
{
|
|
content?: NodeChildren | (() => NodeChildren);
|
|
delay?: number;
|
|
events?: {
|
|
show: Array<keyof HTMLElementEventMap>;
|
|
hide: Array<keyof HTMLElementEventMap>;
|
|
onshow?: (state: FloatState) => boolean;
|
|
onhide?: (state: FloatState) => boolean;
|
|
};
|
|
}
|
|
|
|
export interface ModalProperties
|
|
{
|
|
priority?: boolean;
|
|
closeWhenOutside?: boolean;
|
|
onClose?: () => boolean | void;
|
|
}
|
|
|
|
let teleport: HTMLDivElement;
|
|
export function init()
|
|
{
|
|
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0 z-40' });
|
|
document.body.appendChild(teleport);
|
|
}
|
|
|
|
export function popper(container: HTMLElement, properties?: PopperProperties)
|
|
{
|
|
let state: FloatState = 'hidden', manualStop = false, timeout: Timer;
|
|
const arrow = svg('svg', { class: ' group-data-[pinned]:hidden absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
|
|
const content = dom('div', { class: properties?.class, style: properties?.style });
|
|
const floater = dom('div', { class: 'fixed hidden group', attributes: { 'data-state': 'closed' } }, [ content, properties?.arrow ? arrow : undefined ]);
|
|
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
|
|
|
|
function update()
|
|
{
|
|
FloatingUI.computePosition(container, floater, {
|
|
placement: properties?.placement,
|
|
strategy: 'fixed',
|
|
middleware: [
|
|
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
|
FloatingUI.hide({ rootBoundary: rect }),
|
|
FloatingUI.shift({ rootBoundary: rect }),
|
|
FloatingUI.flip({ rootBoundary: rect }),
|
|
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
|
|
if(properties?.cover === 'width' || properties?.cover === 'all')
|
|
floater.style.maxWidth = `${availableWidth}px`;
|
|
if(properties?.cover === 'height' || properties?.cover === 'all')
|
|
floater.style.maxHeight = `${availableHeight}px`;
|
|
} }),
|
|
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
|
|
]
|
|
}).then(({ x, y, placement, middlewareData }) => {
|
|
Object.assign(floater.style, {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible',
|
|
});
|
|
|
|
const side = placement.split('-')[0] as FloatingUI.Side;
|
|
|
|
floater.setAttribute('data-side', side);
|
|
|
|
if(middlewareData.arrow)
|
|
{
|
|
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
|
|
|
const staticSide = {
|
|
top: 'bottom',
|
|
right: 'left',
|
|
bottom: 'top',
|
|
left: 'right',
|
|
}[side]!;
|
|
|
|
const rotation = {
|
|
top: "0",
|
|
bottom: "180",
|
|
left: "270",
|
|
right: "90"
|
|
}[side]!;
|
|
|
|
Object.assign(arrow.style, {
|
|
left: arrowX != null ? `${arrowX}px` : '',
|
|
top: arrowY != null ? `${arrowY}px` : '',
|
|
right: '',
|
|
bottom: '',
|
|
[staticSide]: `-7px`,
|
|
transform: `rotate(${rotation}deg)`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
let _stop: () => void | undefined, empty = true;
|
|
function show()
|
|
{
|
|
if(state !== 'shown' && state !== 'showing' && state !== 'pinned' && (!properties?.events?.onshow || properties?.events?.onshow(state) !== false))
|
|
{
|
|
if(typeof properties?.content === 'function')
|
|
properties.content = properties.content();
|
|
|
|
if(properties?.content && empty)
|
|
{
|
|
content.replaceChildren(...properties!.content.filter(e => !!e));
|
|
empty = false;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
state = 'showing';
|
|
|
|
timeout = setTimeout(() => {
|
|
if(state !== 'shown')
|
|
{
|
|
teleport!.appendChild(floater);
|
|
|
|
floater.setAttribute('data-state', 'open');
|
|
floater.classList.toggle('hidden', false);
|
|
|
|
update();
|
|
_stop && _stop();
|
|
_stop = FloatingUI.autoUpdate(container, floater, update, {
|
|
animationFrame: true,
|
|
layoutShift: false,
|
|
elementResize: false,
|
|
ancestorScroll: false,
|
|
ancestorResize: false,
|
|
});
|
|
}
|
|
state = 'shown';
|
|
}, properties?.delay ?? 0);
|
|
}
|
|
}
|
|
|
|
function hide()
|
|
{
|
|
if(state !== 'hiding' && state !== 'pinned' && (!properties?.events?.onhide || properties?.events?.onhide(state) !== false))
|
|
{
|
|
clearTimeout(timeout);
|
|
state = 'hiding';
|
|
|
|
timeout = setTimeout(() => {
|
|
floater.remove();
|
|
_stop && _stop();
|
|
|
|
floater.setAttribute('data-state', 'closed');
|
|
floater.classList.toggle('hidden', true);
|
|
|
|
state = 'hidden';
|
|
}, properties?.delay ?? 0);
|
|
}
|
|
}
|
|
|
|
function start()
|
|
{
|
|
state = 'hidden';
|
|
floater.toggleAttribute('data-pinned', false);
|
|
update();
|
|
_stop && _stop();
|
|
_stop = FloatingUI.autoUpdate(container, floater, update, {
|
|
animationFrame: true,
|
|
layoutShift: false,
|
|
elementResize: false,
|
|
ancestorScroll: false,
|
|
ancestorResize: false,
|
|
});
|
|
}
|
|
|
|
function stop()
|
|
{
|
|
state = 'pinned';
|
|
floater.toggleAttribute('data-pinned', true);
|
|
_stop && _stop();
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
function link(element: HTMLElement) {
|
|
(properties?.events?.show ?? ['mouseenter', 'mousemove', 'focus']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, show));
|
|
(properties?.events?.hide ?? ['mouseleave', 'blur']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, hide));
|
|
}
|
|
|
|
link(container);
|
|
link(floater);
|
|
|
|
return { container, content: floater, stop, start, show: () => {
|
|
if(typeof properties?.content === 'function')
|
|
properties.content = properties.content();
|
|
|
|
if(properties?.content && empty)
|
|
{
|
|
content.replaceChildren(...properties!.content.filter(e => !!e));
|
|
empty = false;
|
|
}
|
|
|
|
if(state !== 'shown')
|
|
{
|
|
teleport!.appendChild(floater);
|
|
|
|
floater.setAttribute('data-state', 'open');
|
|
floater.classList.toggle('hidden', false);
|
|
|
|
update();
|
|
}
|
|
state = 'shown';
|
|
}, hide: () => {
|
|
floater.remove();
|
|
_stop && _stop();
|
|
|
|
floater.setAttribute('data-state', 'closed');
|
|
floater.classList.toggle('hidden', true);
|
|
|
|
manualStop = false;
|
|
floater.toggleAttribute('data-pinned', false);
|
|
|
|
state = 'hidden';
|
|
} };
|
|
}
|
|
export function followermenu(target: FloatingUI.ReferenceElement, content: NodeChildren, properties?: FollowerProperties)
|
|
{
|
|
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
|
|
|
|
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
|
|
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class], style: properties?.style }, content);
|
|
|
|
function update()
|
|
{
|
|
FloatingUI.computePosition(target, container, {
|
|
placement: properties?.placement,
|
|
strategy: 'fixed',
|
|
middleware: [
|
|
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
|
FloatingUI.hide({ rootBoundary: rect }),
|
|
FloatingUI.shift({ rootBoundary: rect }),
|
|
FloatingUI.flip({ rootBoundary: rect }),
|
|
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
|
|
if(properties?.cover === 'width' || properties?.cover === 'all')
|
|
container.style.width = `${availableWidth}px`;
|
|
if(properties?.cover === 'height' || properties?.cover === 'all')
|
|
container.style.height = `${availableHeight}px`;
|
|
} }),
|
|
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
|
|
]
|
|
}).then(({ x, y, placement, middlewareData }) => {
|
|
Object.assign(container.style, {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
|
|
});
|
|
|
|
const side = placement.split('-')[0] as FloatingUI.Side;
|
|
|
|
container.setAttribute('data-side', side);
|
|
|
|
if(middlewareData.arrow)
|
|
{
|
|
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
|
|
|
const staticSide = {
|
|
top: 'bottom',
|
|
right: 'left',
|
|
bottom: 'top',
|
|
left: 'right',
|
|
}[side]!;
|
|
|
|
const rotation = {
|
|
top: "0",
|
|
bottom: "180",
|
|
left: "270",
|
|
right: "90"
|
|
}[side]!;
|
|
|
|
Object.assign(arrow.style, {
|
|
left: arrowX != null ? `${arrowX}px` : '',
|
|
top: arrowY != null ? `${arrowY}px` : '',
|
|
right: '',
|
|
bottom: '',
|
|
[staticSide]: `-8px`,
|
|
transform: `rotate(${rotation}deg)`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
update();
|
|
|
|
properties?.priority || document.addEventListener('mousedown', close);
|
|
container.addEventListener('mousedown', cancelPropagation);
|
|
|
|
const stop = FloatingUI.autoUpdate(target, container, update, {
|
|
animationFrame: true,
|
|
layoutShift: false,
|
|
elementResize: false,
|
|
ancestorScroll: false,
|
|
ancestorResize: false,
|
|
});
|
|
teleport!.appendChild(container);
|
|
|
|
function close()
|
|
{
|
|
properties?.priority || document.removeEventListener('mousedown', close);
|
|
container.removeEventListener('mousedown', cancelPropagation);
|
|
|
|
container.remove();
|
|
stop();
|
|
properties?.blur && properties.blur();
|
|
}
|
|
|
|
return { close, container, content };
|
|
}
|
|
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: FollowerProperties)
|
|
{
|
|
return followermenu({
|
|
getBoundingClientRect() {
|
|
return {
|
|
x: x,
|
|
y: y,
|
|
top: y,
|
|
left: x,
|
|
bottom: y,
|
|
right: x,
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
},
|
|
}, content, properties);
|
|
}
|
|
export function tooltip(container: HTMLElement, txt: string | Text, placement: FloatingUI.Placement, delay?: number): HTMLElement
|
|
{
|
|
return popper(container, {
|
|
arrow: true,
|
|
offset: 8,
|
|
delay: delay,
|
|
content: () => [ typeof txt === 'string' ? text(txt) : txt ],
|
|
placement: placement,
|
|
class: "border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50 max-w-96"
|
|
}).container;
|
|
}
|
|
|
|
export function fullblocker(content: NodeChildren, properties?: ModalProperties)
|
|
{
|
|
if(!content)
|
|
return { close: () => {} };
|
|
|
|
const close = () => (!properties?.onClose || properties.onClose() !== false) && _modal.remove();
|
|
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (close) : undefined } });
|
|
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]);
|
|
|
|
teleport.appendChild(_modal);
|
|
|
|
return { close };
|
|
}
|
|
export function modal(content: NodeChildren, properties?: ModalProperties)
|
|
{
|
|
return fullblocker([ dom('div', { class: 'max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative' }, content) ], properties);
|
|
}
|
|
|
|
export function confirm(title: string): Promise<boolean>
|
|
{
|
|
return new Promise(res => {
|
|
const mod = modal([ dom('div', { class: 'flex flex-col justify-start gap-4' }, [ dom('div', { class: 'text-xl' }, [ text(title) ]), dom('div', { class: 'flex flex-row gap-2' }, [ button(text("Non"), () => (mod.close(), res(false)), 'h-[35px] px-[15px]'), button(text("Oui"), () => (mod.close(), res(true)), 'h-[35px] px-[15px] !border-light-red dark:!border-dark-red text-light:red dark:text-dark-red') ]) ]) ], {
|
|
priority: true,
|
|
closeWhenOutside: false,
|
|
});
|
|
})
|
|
} |