import * as FloatingUI from "@floating-ui/dom"; import { cancelPropagation, dom, svg, text, type Class, type NodeChildren, type RedrawableHTML } from "./dom.util"; import { button } from "./components.util"; import type { Reactive } from "./reactive"; export interface FloatingProperties { placement?: FloatingUI.Placement; offset?: FloatingUI.OffsetOptions; arrow?: boolean; class?: Class; style?: Record | string; viewport?: RedrawableHTML; cover?: 'width' | 'height' | 'all' | 'none'; persistant?: boolean; } export interface FollowerProperties extends FloatingProperties { blur?: () => void; priority?: boolean; } export type FloatState = 'shown' | 'showing' | 'hidden' | 'hiding' | 'pinned'; export interface PopperProperties extends FloatingProperties { content?: Reactive; delay?: number; events?: { show: Array; hide: Array; onshow?: (state: FloatState) => boolean; onhide?: (state: FloatState) => boolean; }; } export interface ModalProperties { priority?: boolean; open?: boolean; class?: { blocker?: Class, popup?: Class }, closeWhenOutside?: boolean; onClose?: () => boolean | void; } type ModalInternals = { container: RedrawableHTML; content: RedrawableHTML; stop: Function; start: Function; show: Function; hide: Function; persistant: boolean; }; export let teleport: RedrawableHTML, minimizeBox: RedrawableHTML, cache: ModalInternals[] = [], hook: VoidFunction = () => {}; export function init() { dispose(); teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0 z-40' }); minimizeBox = dom('div', { attributes: { id: 'minimize-container' }, class: 'absolute bottom-0 left-0 flex flex-row px-4 gap-4 z-40 h-[21px]' }); cache = []; document.body.appendChild(teleport); document.body.appendChild(minimizeBox); hook = useRouter().afterEach(clear); } export function dispose() { teleport?.remove(); minimizeBox?.remove(); cache.length = 0; hook(); } function clear() { cache = cache.filter(e => !(!e.persistant && e.content.remove())); } export function popper(container: RedrawableHTML, properties?: PopperProperties) { let state: FloatState = 'hidden', 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.map(e => typeof e === 'function' ? e() : e).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: RedrawableHTML) { (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); const result = { container, content: floater, stop, start, show: () => { if(typeof properties?.content === 'function') properties.content = properties.content(); if(properties?.content && empty) { content.replaceChildren(...properties.content.map(e => typeof e === 'function' ? e() : e).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); floater.toggleAttribute('data-pinned', false); state = 'hidden'; } }; cache.push({ ...result, persistant: properties?.persistant ?? false }); return result; } 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: RedrawableHTML, txt: string | Text, placement: FloatingUI.Placement, delay?: number): RedrawableHTML { 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: () => {}, open: () => {} }; const open = () => { _modal.parentElement === null && teleport.appendChild(_modal) }; const close = () => { _modal.parentElement !== null && (!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 }, properties?.class?.blocker], 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', properties?.class?.blocker] }, [ _modalBlocker, ...content]); (properties?.open ?? true) && open(); return { close, open }; } export function modal(content: NodeChildren, properties?: ModalProperties & { class?: { container?: Class } }) { 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', properties?.class?.container] }, 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, }); }) }