import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router"; import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node, type RedrawableHTML, type Reactive } from "./dom.util"; import { contextmenu, followermenu, minimizeBox, popper, teleport, tooltip, type FloatState } from "./floating.util"; import { clamp } from "./general.util"; import { Tree } from "./tree"; import type { Placement } from "@floating-ui/dom"; export function link(children: NodeChildren, properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped) { const router = useRouter(); const nav = link ? router.resolve(link) : undefined; return dom('a', { ...properties, class: [properties?.class, properties?.active && router.currentRoute.value.fullPath === nav?.fullPath ? properties.active : undefined], attributes: { href: nav?.href, 'data-active': properties?.active ? mergeClasses(properties?.active) : undefined }, listeners: link ? { click: function(e) { e.preventDefault(); router.push(link); } } : undefined }, children); } export function loading(size: 'small' | 'normal' | 'large' = 'normal'): RedrawableHTML { return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] }) } export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise) { let state = { current: loading(size) }; fn.then((element) => { state.current.replaceWith(element); state.current = element; }).catch(e => { console.error(e); state.current.remove(); }) return state; } export function button(content: Node | NodeChildren, onClick?: (this: RedrawableHTML) => void, cls?: Class) { const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow] text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40 hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50 focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick.bind(btn)()) } }, Array.isArray(content) ? content : [content]); let disabled = false; Object.defineProperty(btn, 'disabled', { get: () => disabled, set: (v) => { disabled = !!v; btn.toggleAttribute('disabled', disabled); } }) return btn; } export function buttongroup(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void }) { let currentValue = settings?.value; const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none border border-light-40 dark:border-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[selected]:z-10 data-[selected]:border-light-50 dark:data-[selected]:border-dark-50 data-[selected]:shadow-raw transition-[box-shadow] data-[selected]:shadow-light-50 dark:data-[selected]:shadow-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40`, settings?.class?.option], text: e.text, attributes: { 'data-selected': settings?.value === e.value }, listeners: { click: function() { if(currentValue !== e.value) { elements.forEach(e => e.toggleAttribute('data-selected', false)); this.toggleAttribute('data-selected', true); if(!settings?.onChange || settings?.onChange(e.value) !== false) { currentValue = e.value; } } }}})) return div(['flex flex-row', settings?.class?.container], elements); } export function optionmenu(options: Array<{ title: string, click: () => void }>, settings?: { position?: Placement, class?: { container?: Class, option?: Class } }): (target?: RedrawableHTML) => void { let close: () => void; const element = div(['flex flex-col divide-y divide-light-30 dark:divide-dark-30 text-light-100 dark:text-dark-100', settings?.class?.container], options.map(e => dom('div', { class: ['flex flex-row px-2 py-1 hover:bg-light-35 dark:hover:bg-dark-35 cursor-pointer', settings?.class?.option], text: e.title, listeners: { click: () => { e.click(); close() } } }))); return function(this: RedrawableHTML, target?: RedrawableHTML) { close = followermenu(target ?? this, [ element ], { arrow: true, placement: settings?.position, offset: 8 }).close; } } export type Option = { text: string, render?: () => RedrawableHTML, value: T | Option[] } | undefined; type StoredOption = { item: Option, dom: RedrawableHTML, container?: RedrawableHTML, children?: Array> }; export function select>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): RedrawableHTML { let context: { close: Function }; let focused: number | undefined; options = options.filter(e => !!e); const focus = (i?: number) => { focused !== undefined && optionElements[focused]?.toggleAttribute('data-focused', false); i !== undefined && optionElements[i]?.toggleAttribute('data-focused', true) && optionElements[i]?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); focused = i; } let disabled = settings?.disabled ?? false; const textValue = text(options.find(e => Array.isArray(e) ? false : e?.value === settings?.defaultValue)?.text ?? ''); const optionElements = options.map((e, i) => { if(e === undefined) return; return dom('div', { listeners: { click: (_e) => { textValue.textContent = e.text; settings?.change && settings?.change(e.value); context && context.close && !_e.ctrlKey && context.close(); }, mouseenter: (e) => focus(i) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text(e.text) ]); }); const select = dom('div', { listeners: { click: () => { if(disabled) return; const handleKeys = (e: KeyboardEvent) => { switch(e.key.toLocaleLowerCase()) { case 'arrowdown': focus(clamp((focused ?? -1) + 1, 0, options.length - 1)); return; case 'arrowup': focus(clamp((focused ?? 1) - 1, 0, options.length - 1)); return; case 'pageup': focus(0); return; case 'pagedown': focus(optionElements.length - 1); return; case 'enter': focused && optionElements[focused]?.click(); return; case 'escape': context?.close(); return; default: return; } } window.addEventListener('keydown', handleKeys); const box = select.getBoundingClientRect(); context = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: () => window.removeEventListener('keydown', handleKeys) }); } }, class: ['mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1 bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark: data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ dom('span', {}, [ textValue ]), icon('radix-icons:caret-down') ]); Object.defineProperty(select, 'disabled', { get: () => disabled, set: (v) => { disabled = !!v; select.toggleAttribute('data-disabled', disabled); }, }) return select; } export function multiselect>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): RedrawableHTML { let context: { close: Function }; let focused: number | undefined; let selection: T[] = settings?.defaultValue ?? []; options = options.filter(e => !!e); const focus = (i?: number) => { focused !== undefined && optionElements[focused]?.toggleAttribute('data-focused', false); i !== undefined && optionElements[i]?.toggleAttribute('data-focused', true) && optionElements[i]?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); focused = i; } let disabled = settings?.disabled ?? false; const textValue = text(selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : ''); const optionElements = options.map((e, i) => { if(e === undefined) return; const element = dom('div', { listeners: { click: (_e) => { selection = selection.includes(e.value) ? selection.filter(f => f !== e.value) : [...selection, e.value]; textValue.textContent = selection.length > 0 ? ((options.find(f => f?.value === selection[0])?.text ?? '') + (selection.length > 1 ? ` +${selection.length - 1}` : '')) : ''; element.toggleAttribute('data-selected', selection.includes(e.value)); settings?.change && settings?.change(selection); context && context.close && !_e.ctrlKey && context.close(); }, mouseenter: (e) => focus(i) }, class: ['group flex flex-row justify-between items-center data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option], attributes: { 'data-selected': selection.includes(e.value) } }, [ text(e.text), icon('radix-icons:check', { class: 'hidden group-data-[selected]:block' }) ]); return element; }); const select = dom('div', { listeners: { click: () => { if(disabled) return; const handleKeys = (e: KeyboardEvent) => { switch(e.key.toLocaleLowerCase()) { case 'arrowdown': focus(clamp((focused ?? -1) + 1, 0, options.length - 1)); return; case 'arrowup': focus(clamp((focused ?? 1) - 1, 0, options.length - 1)); return; case 'pageup': focus(0); return; case 'pagedown': focus(optionElements.length - 1); return; case 'enter': focused && optionElements[focused]?.click(); return; case 'escape': context?.close(); return; default: return; } } window.addEventListener('keydown', handleKeys); const box = select.getBoundingClientRect(); context = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: () => window.removeEventListener('keydown', handleKeys) }); } }, class: ['mx-4 inline-flex items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1 bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark: data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ dom('span', {}, [ textValue ]), icon('radix-icons:caret-down') ]); Object.defineProperty(select, 'disabled', { get: () => disabled, set: (v) => { disabled = !!v; select.toggleAttribute('data-disabled', disabled); }, }) return select; } export function combobox>(options: Option[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' }): RedrawableHTML { let context: { container: RedrawableHTML, content: NodeChildren, close: () => void }; let selected = true, tree: StoredOption[] = []; let focused: number | undefined; let currentOptions: StoredOption[] = []; const focus = (value?: T | Option[]) => { focused !== undefined && currentOptions[focused]?.dom.toggleAttribute('data-focused', false); if(value !== undefined) { const i = currentOptions.findIndex(e => e.item?.value === value); if(i !== -1) { currentOptions[i]?.dom.toggleAttribute('data-focused', true); currentOptions[i]?.dom.scrollIntoView({ behavior: 'instant', block: 'nearest' }); focused = i; } else { focused = undefined; } } else { focused = undefined; } } const show = () => { if(disabled || (context && context.container.parentElement)) return; const box = container.getBoundingClientRect(); focus(); context = followermenu(container, currentOptions.length > 0 ? currentOptions.map(e => e.dom) : [ div('text-light-60 dark:text-dark-60 italic text-center px-2 py-1', [ text('Aucune option') ]) ], { placement: "bottom-start", class: ['flex flex-col max-h-[320px] overflow-y-auto overflow-x-hidden', settings?.class?.popup], style: { "min-width": settings?.fill === 'cover' && `${box.width}px`, "max-width": settings?.fill === 'contain' && `${box.width}px` }, blur: hide }); if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red'); }; const hide = () => { if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red'); tree = []; context && context.container.parentElement && context.close(); }; const progress = (option: StoredOption) => { if(!context || !context.container.parentElement || option.container === undefined) return; context.container.replaceChildren(option.container); tree.push(option); currentOptions = option.children!; focus(); }; const back = () => { tree.pop(); const last = tree.slice(-1)[0]; currentOptions = last?.children ?? optionElements; last ? context.container.replaceChildren(last.container ?? last.dom) : context.container.replaceChildren(...optionElements.filter(e => !!e).map(e => e.dom)); }; const render = (option: Option): StoredOption | undefined => { if(option === undefined) return; if(Array.isArray(option.value)) { const children = option.value.map(render).filter(e => !!e); const stored = { item: option, dom: dom('div', { listeners: { click: () => progress(stored), mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer flex justify-between items-center', settings?.class?.option] }, [ text(option.text), icon('radix-icons:caret-right', { width: 20, height: 20 }) ]), container: div('flex flex-1 flex-col', [div('flex flex-row justify-between items-center text-light-100 dark:text-dark-100 py-1 px-2 text-sm select-none sticky top-0 bg-light-20 dark:bg-dark-20 font-semibold', [button(icon('radix-icons:caret-left', { width: 16, height: 16 }), back, 'p-px'), text(option.text), div()]), div('flex flex-col flex-1', children.map(e => e?.dom))]), children }; return stored; } else { return { item: option, dom: dom('div', { listeners: { click: (_e) => { select.value = option.text; settings?.change && settings?.change(option.value as T); selected = true; !_e.ctrlKey && hide(); }, mouseenter: () => focus(option.value) }, class: ['data-[focused]:bg-light-30 dark:data-[focused]:bg-dark-30 text-light-70 dark:text-dark-70 data-[focused]:text-light-100 dark:data-[focused]:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ option?.render ? option?.render() : text(option.text) ]) }; } } const filter = (value: string, option?: StoredOption): StoredOption[] => { if(option && option.children !== undefined) { return option.children.flatMap(e => filter(value, e)); } else if(option && option.item) { return option.item.text.toLowerCase().normalize().includes(value) ? [ option ] : []; } else { return []; } } let disabled = settings?.disabled ?? false; const optionElements = currentOptions = options.map(render).filter(e => !!e); const select = dom('input', { listeners: { focus: show, input: () => { focus(); currentOptions = context && select.value ? optionElements.flatMap(e => filter(select.value.toLowerCase().trim().normalize(), e)) : optionElements.filter(e => !!e); context && context.container.replaceChildren(...currentOptions.map(e => e.dom)); selected = false; if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red') }, keydown: (e) => { switch(e.key.toLocaleLowerCase()) { case 'arrowdown': focus(currentOptions[clamp((focused ?? -1) + 1, 0, currentOptions.length - 1)]?.item?.value); return; case 'arrowup': focus(currentOptions[clamp((focused ?? 1) - 1, 0, currentOptions.length - 1)]?.item?.value); return; case 'pageup': focus(currentOptions[0]?.item?.value); return; case 'pagedown': focus(currentOptions[currentOptions.length - 1]?.item?.value); return; case 'enter': focused !== undefined ? currentOptions[focused]?.dom.click() : currentOptions[0]?.dom.click(); return; case 'escape': context?.close(); return; default: return; } } }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20' }); settings?.defaultValue && Tree.each(options, 'value', (item) => { if(item.value === settings?.defaultValue) select.value = item.text }); const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]); Object.defineProperty(container, 'disabled', { get: () => disabled, set: (v) => { disabled = !!v; container.toggleAttribute('data-disabled', disabled); select.toggleAttribute('disabled', disabled); }, }) return container; } export function input(type: 'text' | 'number' | 'email' | 'password' | 'tel', settings?: { defaultValue?: string, change?: (value: string) => void, input?: (value: string) => void | boolean, focus?: () => void, blur?: () => void, class?: Class, disabled?: boolean, placeholder?: string }): HTMLInputElement { const input = dom("input", { attributes: { disabled: settings?.disabled, placeholder: settings?.placeholder }, class: [`mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: { input: (e) => { if(settings?.input && settings.input(input.value) === false) input.value = value; else value = input.value; }, change: () => settings?.change && settings.change(input.value), focus: settings?.focus, blur: settings?.blur, }}) if(settings?.defaultValue !== undefined) input.value = settings.defaultValue; let value = input.value; return input; } export function numberpicker(settings?: { defaultValue?: number, change?: (value: number) => void, input?: (value: number) => void, focus?: () => void, blur?: () => void, class?: Class, min?: number, max?: number, disabled?: boolean }): HTMLInputElement { let storedValue = settings?.defaultValue ?? 0; const validateAndChange = (value: number) => { if(isNaN(value)) field.value = ''; else { value = clamp(value, settings?.min ?? -Infinity, settings?.max ?? Infinity); field.value = value.toString(10); if(storedValue !== value) { storedValue = value; return true; } } return false; } const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 disabled:shadow-none disabled:bg-light-20 dark:disabled:bg-dark-20 disabled:border-dashed disabled:border-light-30 dark:disabled:border-dark-30`, settings?.class], listeners: { input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue), keydown: (e: KeyboardEvent) => { if(field.disabled) return; switch(e.key) { case "ArrowUp": validateAndChange(storedValue + (e.ctrlKey ? 10 : 1)) && settings?.input && settings.input(storedValue); break; case "ArrowDown": validateAndChange(storedValue - (e.ctrlKey ? 10 : 1)) && settings?.input && settings.input(storedValue); break; case "PageUp": settings?.max && validateAndChange(settings.max) && settings?.input && settings.input(storedValue); break; case "PageDown": settings?.min && validateAndChange(settings.min) && settings?.input && settings.input(storedValue); break; default: return; } }, change: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.change && settings.change(storedValue), focus: () => settings?.focus && settings.focus(), blur: () => settings?.blur && settings.blur(), }}); if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10); return field; } // Open by default export function foldable(content: Reactive, title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } }) { let lazyContent: NodeChildren; const display = (state: boolean) => { if(state) { if(!lazyContent) { lazyContent = (typeof content === 'function' ? content() : content)?.map(e => typeof e ==='function' ? e() : e); lazyContent && contentContainer.replaceChildren(...lazyContent.map(e => typeof e ==='function' ? e() : e).filter(e => !!e)); } else contentContainer.update && contentContainer.update(true); } } const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]); const fold = div(['group flex w-full flex-col', settings?.class?.container], [ div('flex', [ dom('div', { listeners: { click: () => { display(fold.toggleAttribute('data-active')) } }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center' }) ]), div(['flex-1', settings?.class?.title], title) ]), contentContainer ]); display(settings?.open ?? true); fold.toggleAttribute('data-active', settings?.open ?? true); return fold; } type TableRow = Record RedrawableHTML) | RedrawableHTML | string>; export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } }) { const render = (item: (() => RedrawableHTML) | RedrawableHTML | string) => typeof item === 'string' ? text(item) : typeof item === 'function' ? item() : item; return dom('table', { class: ['', properties?.class?.table] }, [ dom('thead', { class: ['', properties?.class?.header] }, [ dom('tr', { class: '' }, Object.values(headers).map(e => dom('th', {}, [ render(e) ]))) ]), dom('tbody', { class: ['', properties?.class?.body] }, content.map(e => dom('tr', { class: ['', properties?.class?.row] }, Object.keys(headers).map(f => e.hasOwnProperty(f) ? dom('td', { class: ['', properties?.class?.cell] }, [ render(e[f]!) ]) : undefined)))) ]); } export function toggle(settings?: { defaultValue?: boolean, change?: (value: boolean) => void, disabled?: boolean, class?: { container?: Class } }) { let state = settings?.defaultValue ?? false; const element = dom("div", { class: [`group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: { click: function(e: Event) { if(this.hasAttribute('data-disabled')) return; state = !state; element.setAttribute('data-state', state ? "checked" : "unchecked"); settings?.change && settings.change(state); } } }, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]); return element; } export function checkbox(settings?: { defaultValue?: boolean, change?: (this: RedrawableHTML, value: boolean) => void, disabled?: boolean, class?: { container?: Class, icon?: Class } }) { let state = settings?.defaultValue ?? false; const element = dom("div", { class: [`group w-6 h-6 box-content flex items-center justify-center border border-light-50 dark:border-dark-50 bg-light-20 dark:bg-dark-20 cursor-pointer hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-60 dark:hover:border-dark-60 data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0 hover:data-[disabled]:bg-0 dark:hover:data-[disabled]:bg-0`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: { click: function(e: Event) { if(this.hasAttribute('data-disabled')) return; state = !state; element.setAttribute('data-state', state ? "checked" : "unchecked"); settings?.change && settings.change.bind(this)(state); } } }, [ icon('radix-icons:check', { width: 14, height: 14, class: ['hidden group-data-[state="checked"]:block data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50', settings?.class?.icon] }), ]); return element; } export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: Reactive }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): RedrawableHTML { let focus = settings?.focused ?? tabs[0]?.id; const lazyTabs = tabs.map((e, i) => ({ title: dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() { if(this.hasAttribute('data-focus')) return; if(settings?.switch && settings.switch(e.id) === false) return; lazyTabs.forEach(e => e.title.toggleAttribute('data-focus', false)); this.toggleAttribute('data-focus', true); focus = e.id; if(!lazyTabs[i]!.content) { lazyTabs[i]!.content = (typeof e.content === 'function' ? e.content() : e.content); lazyTabs[i]!.content && content.replaceChildren(...lazyTabs[i]!.content?.map(e => typeof e ==='function' ? e() : e)?.filter(e => !!e)); } else content.update && content.update(true); }}}, e.title), content: undefined as undefined | NodeChildren, })); const _content = tabs.find(e => e.id === focus)?.content; const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content); const container = div(['flex flex-col', settings?.class?.container], [ div(['flex flex-row items-center gap-1', settings?.class?.tabbar], lazyTabs.map(e => e.title)), content ]); return container as RedrawableHTML; } export function floater(container: RedrawableHTML, content: NodeChildren | (() => NodeChildren), settings?: { href?: RouteLocationRaw, class?: Class, style?: Record | string, position?: Placement, pinned?: boolean, minimizable?: boolean, cover?: 'width' | 'height' | 'all' | 'none', events?: { show: Array, hide: Array, onshow?: (state: FloatState) => boolean, onhide?: (state: FloatState) => boolean }, title?: string }) { let viewport = document.getElementById('mainContainer') ?? undefined; let diffX, diffY; let minimizeRect: DOMRect, minimized = false; const events: { show: Array, hide: Array, onshow?: (this: RedrawableHTML, state: FloatState) => boolean, onhide?: (this: RedrawableHTML, state: FloatState) => boolean } = Object.assign({ show: ['mouseenter', 'mousemove', 'focus'], hide: ['mouseleave', 'blur'], } as { show: Array, hide: Array }, settings?.events ?? {}); if(settings?.pinned) { events.onshow = (state) => { if(!settings?.events?.onshow || settings?.events?.onshow(state)) { floating.show(); pin(); } return false; }; } const dragstart = (e: MouseEvent) => { e.preventDefault(); if(minimized) return; window.addEventListener('mousemove', dragmove); window.addEventListener('mouseup', dragend); const box = floating.content.getBoundingClientRect(); diffX = e.clientX - box.x; diffY = e.clientY - box.y; }; const resizestart = (e: MouseEvent) => { e.preventDefault(); window.addEventListener('mousemove', resizemove); window.addEventListener('mouseup', resizeend); }; const dragmove = (e: MouseEvent) => { const box = floating.content.getBoundingClientRect(); const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight }; box.x = clamp(e.clientX - diffX!, viewbox?.left ?? 0, viewbox.right - box.width); box.y = clamp(e.clientY - diffY!, viewbox?.top ?? 0, viewbox.bottom - box.height); Object.assign(floating.content.style, { left: `${box.x}px`, top: `${box.y}px`, }); }; const resizemove = (e: MouseEvent) => { const box = floating.content.getBoundingClientRect(); const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight }; box.width = clamp(e.clientX - box.x, 200, Math.min(750, viewbox.right - box.x)); box.height = clamp(e.clientY - box.y, 150, Math.min(750, viewbox.bottom - box.y)); Object.assign(floating.content.style, { width: `${box.width}px`, height: `${box.height}px`, }); }; const dragend = (e: MouseEvent) => { e.preventDefault(); window.removeEventListener('mousemove', dragmove); window.removeEventListener('mouseup', dragend); }; const resizeend = (e: MouseEvent) => { e.preventDefault(); window.removeEventListener('mousemove', resizemove); window.removeEventListener('mouseup', resizeend); }; const pin = () => { if(floating.content.hasAttribute('data-pinned')) return; const box = floating.content.children.item(0)!.getBoundingClientRect(); const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight }; Object.assign(floating.content.style, { left: `${clamp(box.left, viewbox.left, viewbox.right)}px`, top: `${clamp(box.top, viewbox.top, viewbox.bottom)}px`, width: `${box.width + 21}px`, height: `${box.height + 21}px`, }); floating.content.attributeStyleMap.delete('bottom'); floating.content.attributeStyleMap.delete('right'); floating.stop(); floating.content.addEventListener('mousedown', function() { if(!floating.content.hasAttribute('data-pinned')) return; [...this.parentElement?.children ?? []].forEach(e => (e as any as RedrawableHTML).attributeStyleMap.set('z-index', -1)); this.attributeStyleMap.set('z-index', 0); }, { passive: true }); } const minimize = () => { minimized = !minimized; floating.content.toggleAttribute('data-minimized', minimized); if(minimized) { minimizeRect = floating.content.getBoundingClientRect(); Object.assign(floating.content.style, { width: `150px`, height: `21px`, position: 'initial', }); floating.content.style.setProperty('top', null); floating.content.style.setProperty('left', null); floating.content.style.setProperty('bottom', null); floating.content.style.setProperty('right', null); minimizeBox.appendChild(floating.content); } else { Object.assign(floating.content.style, { left: `${minimizeRect.left}px`, top: `${minimizeRect.top}px`, width: `${minimizeRect.width}px`, height: `${minimizeRect.height}px`, }); floating.content.style.setProperty('position', null); teleport.appendChild(floating.content); } }; const floating = popper(container, { arrow: true, delay: settings?.pinned ? 0 : 150, offset: 12, cover: settings?.cover, placement: settings?.position, style: settings?.style, class: 'bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 group-data-[pinned]:bg-light-15 dark:group-data-[pinned]:bg-dark-15 group-data-[pinned]:border-light-50 dark:group-data-[pinned]:border-dark-50 text-light-100 dark:text-dark-100 z-[45] relative group-data-[pinned]:h-full', content: () => [ settings?.pinned !== undefined ? div('hidden group-data-[pinned]:flex flex-row items-center border-b border-light-35 dark:border-dark-35', [ dom('span', { class: 'flex-1 w-full h-5 cursor-move group-data-[minimized]:cursor-default text-xs px-2', listeners: { mousedown: dragstart }, text: (settings?.title?.substring(0, 1)?.toUpperCase() ?? '') + (settings?.title?.substring(1)?.toLowerCase() ?? '') }), settings?.title ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { click: minimize } }, [icon('radix-icons:minus', { width: 12, height: 12, class: 'p-1' })]), text('Réduire'), 'top') : undefined, settings?.href ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => { ((e.ctrlKey || e.button === 1) ? window.open : useRouter().push)(useRouter().resolve(settings.href!).href); floating.hide(); } } }, [icon('radix-icons:external-link', { width: 12, height: 12, class: 'p-1' })]), 'Ouvrir', 'top') : undefined, tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => { e.stopImmediatePropagation(); floating.hide(); floating.content.toggleAttribute('data-minimized', false); minimized && Object.assign(floating.content.style, { left: `${minimizeRect.left}px`, top: `${minimizeRect.top}px`, width: `${minimizeRect.width}px`, height: `${minimizeRect.height}px`, }); minimized = false; } } }, [icon('radix-icons:cross-1', { width: 12, height: 12, class: 'p-1' })]), 'Fermer', 'top') ]) : undefined, div('group-data-[minimized]:hidden h-full group-data-[pinned]:h-[calc(100%-21px)] w-full min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] group-data-[pinned]:min-h-[initial] group-data-[pinned]:min-w-[initial] group-data-[pinned]:max-h-[initial] group-data-[pinned]:max-w-[initial] overflow-auto box-content', typeof content === 'function' ? content() : content), dom('span', { class: 'hidden group-data-[pinned]:flex group-data-[minimized]:hidden absolute bottom-0 right-0 cursor-nw-resize z-50', listeners: { mousedown: resizestart } }, [ icon('ph:notches', { width: 12, height: 12 }) ]) ], viewport, events: events }); if(settings?.pinned === false) floating.content.addEventListener('dblclick', pin); return container; } export interface ToastConfig { closeable?: boolean duration: number title?: string content?: string timer?: boolean type?: ToastType } type ToastDom = ToastConfig & { dom: RedrawableHTML }; export type ToastType = 'info' | 'success' | 'error'; export class Toaster { private static _MAX_DRAG = 150; private static _list: Array = []; private static _container: RedrawableHTML; static init() { Toaster.dispose(); Toaster._container = dom('div', { attributes: { id: 'toaster' }, class: 'fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72 empty:hidden' }); document.body.appendChild(Toaster._container); } static dispose() { Toaster._container?.remove(); } static add(_config: ToastConfig) { let start: number; const dragstart = (e: MouseEvent) => { window.addEventListener('mousemove', dragmove); window.addEventListener('mouseup', dragend); start = e.clientX; }; const dragmove = (e: MouseEvent) => { const drag = e.clientX - start; if(drag > Toaster._MAX_DRAG) { dragend(); config.dom.animate([{ transform: `translateX(${drag}px)` }, { transform: `translateX(150%)` }], { duration: 100, easing: 'ease-out' }); Toaster.close(config); } else if(drag > 0) { config.dom.style.transform = `translateX(${drag}px)`; } }; const dragend = () => { window.removeEventListener('mousemove', dragmove); window.removeEventListener('mouseup', dragend); config.dom.style.transform = `translateX(0px)`; }; const config = _config as ToastDom; const loader = config.timer ? div('bg-light-50 dark:bg-dark-50 h-full w-full transition-[width] ease-linear') : undefined; loader?.animate([{ width: '0' }, { width: '100%' }], { duration: config.duration, easing: 'linear' }); config.dom = dom('div', { class: 'ToastRoot bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 group select-none', attributes: { 'data-type': config.type, 'data-state': 'open' } }, [ div('grid grid-cols-8 px-3 pt-2 pb-2', [ config.title ? dom('h4', { class: 'font-semibold text-xl col-span-7 text-light-100 dark:text-dark-100', text: config.title }) : undefined, config.closeable ? dom('span', { class: 'translate-x-4 text-light-100 dark:text-dark-100', listeners: { click: () => Toaster.close(config), } }, [ icon('radix-icons:cross-1', { width: 12, height: 12, class: 'cursor-pointer' }) ]) : undefined, config.content ? dom('span', { class: 'text-sm col-span-8 text-light-100 dark:text-dark-100', text: config.content }) : undefined, ]), config.timer ? dom('div', { class: 'relative overflow-hidden bg-light-25 dark:bg-dark-25 h-1 mb-0 mt-0 w-full group-data-[type=error]:*:bg-light-red dark:group-data-[type=error]:*:bg-dark-red group-data-[type=success]:*:bg-light-green dark:group-data-[type=success]:*:bg-dark-green group-data-[type=error]:bg-light-red dark:group-data-[type=error]:bg-dark-red group-data-[type=success]:bg-light-green dark:group-data-[type=success]:bg-dark-green !bg-opacity-50' }, [ loader ]) : undefined ]); config.dom.addEventListener('mousedown', dragstart); config.dom.animate([{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], { duration: 150, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }); Toaster._container?.appendChild(config.dom); Toaster._list.push(config); setTimeout(() => Toaster.close(config), config.duration); } static clear(type?: ToastType) { Toaster._list.filter(e => e.type !== type || (Toaster.close(e), false)); } private static close(config: ToastDom) { config.dom.animate([ { opacity: 1 }, { opacity: 0 }, ], { easing: 'ease-in', duration: 100 }).onfinish = () => config.dom.remove(); Toaster._list = Toaster._list.filter(e => e !== config); } }