271 lines
9.7 KiB
TypeScript
271 lines
9.7 KiB
TypeScript
import * as FloatingUI from "@floating-ui/dom";
|
||
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren } from "./dom.util";
|
||
import { button } from "./proses";
|
||
|
||
export interface ContextProperties
|
||
{
|
||
placement?: FloatingUI.Placement;
|
||
offset?: number;
|
||
arrow?: boolean;
|
||
class?: Class;
|
||
}
|
||
export interface PopperProperties extends ContextProperties
|
||
{
|
||
content?: NodeChildren;
|
||
delay?: number;
|
||
viewport?: HTMLElement;
|
||
|
||
onShow?: (element: HTMLDivElement) => boolean | void;
|
||
onHide?: (element: HTMLDivElement) => boolean | void;
|
||
}
|
||
|
||
export interface ModalProperties
|
||
{
|
||
priority?: boolean;
|
||
closeWhenOutside?: boolean;
|
||
}
|
||
|
||
let teleport: HTMLDivElement;
|
||
export function init()
|
||
{
|
||
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0' });
|
||
document.body.appendChild(teleport);
|
||
}
|
||
|
||
export function popper(container: HTMLElement, properties?: PopperProperties): HTMLElement
|
||
{
|
||
let shown = false, timeout: Timer;
|
||
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,10" } })]);
|
||
const content = dom('div', { class: ['fixed hidden', properties?.class], attributes: { 'data-state': 'closed' } }, [...(properties?.content ?? []), arrow]);
|
||
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
|
||
|
||
function update()
|
||
{
|
||
FloatingUI.computePosition(container, content, {
|
||
placement: properties?.placement,
|
||
strategy: 'fixed',
|
||
middleware: [
|
||
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
||
FloatingUI.shift({ rootBoundary: rect }),
|
||
properties?.offset ? FloatingUI.shift({ padding: properties?.offset, rootBoundary: rect }) : undefined,
|
||
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
|
||
FloatingUI.hide({ rootBoundary: rect }),
|
||
]
|
||
}).then(({ x, y, placement, middlewareData }) => {
|
||
Object.assign(content.style, {
|
||
left: `${x}px`,
|
||
top: `${y}px`,
|
||
});
|
||
|
||
const side = placement.split('-')[0] as FloatingUI.Side;
|
||
|
||
content.setAttribute('data-side', side);
|
||
|
||
if(properties?.offset && properties?.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]: `-6px`,
|
||
transform: `rotate(${rotation}deg)`,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
let stop: () => void | undefined;
|
||
function show()
|
||
{
|
||
if(shown || !properties?.onShow || properties?.onShow(content) !== false)
|
||
{
|
||
clearTimeout(timeout);
|
||
|
||
timeout = setTimeout(() => {
|
||
if(!shown)
|
||
{
|
||
teleport!.appendChild(content);
|
||
|
||
content.setAttribute('data-state', 'open');
|
||
content.classList.toggle('hidden', false);
|
||
|
||
update();
|
||
stop = FloatingUI.autoUpdate(container, content, update, {
|
||
animationFrame: true,
|
||
layoutShift: false,
|
||
elementResize: false,
|
||
ancestorScroll: false,
|
||
ancestorResize: false,
|
||
});
|
||
}
|
||
shown = true;
|
||
}, properties?.delay ?? 0);
|
||
}
|
||
}
|
||
|
||
function hide()
|
||
{
|
||
if(!properties?.onHide || properties?.onHide(content) !== false)
|
||
{
|
||
clearTimeout(timeout);
|
||
|
||
timeout = setTimeout(() => {
|
||
content.remove();
|
||
stop && stop();
|
||
|
||
shown = false;
|
||
}, shown ? properties?.delay ?? 0 : 0);
|
||
}
|
||
}
|
||
|
||
function link(element: HTMLElement) {
|
||
Object.entries({
|
||
'mouseenter': show,
|
||
'mouseleave': hide,
|
||
'focus': show,
|
||
'blur': hide,
|
||
} as Record<keyof HTMLElementEventMap, () => void>).forEach(([event, listener]) => {
|
||
element.addEventListener(event, listener);
|
||
});
|
||
}
|
||
|
||
link(container);
|
||
link(content);
|
||
|
||
return container;
|
||
}
|
||
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: ContextProperties): () => void
|
||
{
|
||
const virtual = {
|
||
getBoundingClientRect() {
|
||
return {
|
||
x: x,
|
||
y: y,
|
||
top: y,
|
||
left: x,
|
||
bottom: y,
|
||
right: x,
|
||
width: 0,
|
||
height: 0,
|
||
};
|
||
},
|
||
};
|
||
|
||
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "10", height: "7", viewBox: "0 0 30 10" } }, [svg('polygon', { attributes: { points: "0,0 30,0 15,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] }, content);
|
||
|
||
function update()
|
||
{
|
||
FloatingUI.computePosition(virtual, container, {
|
||
placement: properties?.placement,
|
||
strategy: 'fixed',
|
||
middleware: [
|
||
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
|
||
FloatingUI.flip(),
|
||
properties?.offset ? FloatingUI.shift({ padding: properties?.offset }) : undefined,
|
||
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`,
|
||
});
|
||
|
||
const side = placement.split('-')[0] as FloatingUI.Side;
|
||
|
||
container.setAttribute('data-side', side);
|
||
|
||
if(properties?.offset && properties?.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]: `-6px`,
|
||
transform: `rotate(${rotation}deg)`,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
update();
|
||
|
||
document.addEventListener('mousedown', close);
|
||
container.addEventListener('mousedown', cancelPropagation);
|
||
|
||
const stop = FloatingUI.autoUpdate(virtual, container, update, {
|
||
animationFrame: true,
|
||
layoutShift: false,
|
||
elementResize: false,
|
||
ancestorScroll: false,
|
||
ancestorResize: false,
|
||
});
|
||
teleport!.appendChild(container);
|
||
|
||
function close()
|
||
{
|
||
document.removeEventListener('mousedown', close);
|
||
container.removeEventListener('mousedown', cancelPropagation);
|
||
|
||
container.remove();
|
||
stop();
|
||
}
|
||
|
||
return close;
|
||
}
|
||
|
||
export function modal(content: NodeChildren, properties?: ModalProperties)
|
||
{
|
||
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 ? (() => _modal.remove()) : undefined } });
|
||
const _closer = properties?.priority ? undefined : dom('span', { class: 'absolute top-4 right-4', text: '×', listeners: { click: () => _modal.remove() } });
|
||
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, 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)])
|
||
|
||
teleport.appendChild(_modal);
|
||
|
||
return {
|
||
close: () => _modal.remove(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
});
|
||
})
|
||
} |