517 lines
30 KiB
TypeScript
517 lines
30 KiB
TypeScript
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
|
|
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util";
|
|
import { contextmenu, followermenu } from "./floating.util";
|
|
import { clamp } from "./general.util";
|
|
import { Tree } from "./tree";
|
|
|
|
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren)
|
|
{
|
|
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'): HTMLElement
|
|
{
|
|
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<HTMLElement>): HTMLElement
|
|
{
|
|
const load = loading(size);
|
|
|
|
fn.then((element) => {
|
|
load.replaceWith(element);
|
|
}).catch(e => {
|
|
console.error(e);
|
|
load.remove();
|
|
})
|
|
|
|
return load;
|
|
}
|
|
export function button(content: Node, onClick?: () => void, cls?: Class)
|
|
{
|
|
const btn = dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
|
|
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
|
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: () => disabled || (onClick && onClick()) } }, [ content ]);
|
|
let disabled = false;
|
|
Object.defineProperty(btn, 'disabled', {
|
|
get: () => disabled,
|
|
set: (v) => {
|
|
disabled = !!v;
|
|
btn.toggleAttribute('disabled', disabled);
|
|
}
|
|
})
|
|
return btn;
|
|
}
|
|
export type Option<T> = { text: string, render?: () => HTMLElement, value: T | Option<T>[] } | undefined;
|
|
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredOption<T>> };
|
|
export function select<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
|
|
{
|
|
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<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T[], change?: (value: T[]) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
|
|
{
|
|
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', noobserver: true }) ]);
|
|
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<T extends NonNullable<any>>(options: Option<T>[], settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean, fill?: 'contain' | 'cover' }): HTMLElement
|
|
{
|
|
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
|
|
let selected = true, tree: StoredOption<T>[] = [];
|
|
let focused: number | undefined;
|
|
let currentOptions: StoredOption<T>[] = [];
|
|
|
|
const focus = (value?: T | Option<T>[]) => {
|
|
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<T>) => {
|
|
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<T>): StoredOption<T> | 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<T>): StoredOption<T>[] => {
|
|
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, 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: () => settings?.input && settings.input(input.value),
|
|
change: () => settings?.change && settings.change(input.value),
|
|
focus: () => settings?.focus,
|
|
blur: () => settings?.blur,
|
|
}})
|
|
if(settings?.defaultValue !== undefined) input.value = settings.defaultValue;
|
|
|
|
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 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: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue),
|
|
keydown: (e: KeyboardEvent) => {
|
|
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: NodeChildren | (() => NodeChildren), title: NodeChildren, settings?: { open?: boolean, class?: { container?: Class, title?: Class, content?: Class, icon?: Class } })
|
|
{
|
|
let _content: NodeChildren;
|
|
const display = (state: boolean) => {
|
|
if(state && !_content)
|
|
{
|
|
_content = typeof content === 'function' ? content() : content;
|
|
//@ts-ignore
|
|
contentContainer.replaceChildren(..._content);
|
|
}
|
|
}
|
|
const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]);
|
|
const fold = div(['group flex flex-1 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', noobserver: true }) ]), div(['flex-1', settings?.class?.title], title) ]),
|
|
contentContainer
|
|
]);
|
|
display(settings?.open ?? true);
|
|
fold.toggleAttribute('data-active', settings?.open ?? true);
|
|
return fold;
|
|
}
|
|
type TableRow = Record<string, (() => HTMLElement) | HTMLElement | string>;
|
|
export function table(content: TableRow[], headers: TableRow, properties?: { class?: { table?: Class, header?: Class, body?: Class, row?: Class, cell?: Class } })
|
|
{
|
|
const render = (item: (() => HTMLElement) | HTMLElement | 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" }, listeners: {
|
|
click: (e: Event) => {
|
|
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 interface ToastConfig
|
|
{
|
|
closeable?: boolean
|
|
duration: number
|
|
title?: string
|
|
content?: string
|
|
timer?: boolean
|
|
type?: ToastType
|
|
}
|
|
type ToastDom = ToastConfig & { dom: HTMLElement };
|
|
export type ToastType = 'info' | 'success' | 'error';
|
|
export class Toaster
|
|
{
|
|
private static _MAX_DRAG = 130;
|
|
private static _list: Array<ToastDom> = [];
|
|
private static _container: HTMLDivElement;
|
|
|
|
static init()
|
|
{
|
|
Toaster._container = div('fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72');
|
|
document.body.appendChild(Toaster._container);
|
|
}
|
|
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, noobserver: true, 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);
|
|
}
|
|
} |