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; viewport?: HTMLElement; cover?: 'width' | 'height' | 'all' | 'none'; } export interface FollowerProperties extends FloatingProperties { blur?: () => void; priority?: boolean; } export interface PopperProperties extends FloatingProperties { content?: NodeChildren; delay?: number; 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: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]); const content = dom('div', { class: ['fixed hidden', properties?.class], style: properties?.style, 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.hide({ rootBoundary: rect, strategy: "escaped" }), 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') content.style.width = `${availableWidth}px`; if(properties?.cover === 'height' || properties?.cover === 'all') content.style.height = `${availableHeight}px`; } }), properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined, ] }).then(({ x, y, placement, middlewareData }) => { Object.assign(content.style, { left: `${x}px`, top: `${y}px`, visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible', }); const side = placement.split('-')[0] as FloatingUI.Side; content.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)`, }); } }); } 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 void>).forEach(([event, listener]) => { element.addEventListener(event, listener); }); } link(container); link(content); return container; } 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, placement: FloatingUI.Placement, delay?: number): HTMLElement { return popper(container, { arrow: true, offset: 8, delay: delay, content: [ text(txt) ], placement: placement, class: "fixed hidden TooltipContent 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" }); } export function fullblocker(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 _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: () => _modal.remove(), } } 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 { 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, }); }) }