Feature Builder panel progress

This commit is contained in:
2025-08-11 09:39:41 +02:00
parent 86556ec604
commit 920ce2e1b6
23 changed files with 4924 additions and 4534 deletions

View File

@@ -1,11 +1,11 @@
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses } from "#shared/dom.util";
import { dom, icon, type NodeChildren, type Node, type NodeProperties, type Class, mergeClasses, text, div } from "#shared/dom.util";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { contextmenu, popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util";
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import { unifySlug } from "#shared/general.util";
import { clamp, unifySlug } from "#shared/general.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node;
export type Prose = { class: string } | { custom: CustomProse };
@@ -230,4 +230,194 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
return 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: onClick } }, [ content ]);
}
export function select<T extends NonNullable<any>>(options: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let close: Function | undefined;
let disabled = settings?.disabled ?? false;
const textValue = text(textFromValue(settings?.defaultValue));
const optionElements = options.map(e => {
if(e === undefined)
return;
return dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
textValue.textContent = text;
settings?.change && settings?.change(value);
close && close();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]);
});
const select = dom('div', { listeners: { click: () => {
if(disabled)
return;
const box = select.getBoundingClientRect();
close = 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` } }).close;
} }, 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: Array<string | undefined> | Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
{
const textFromValue = (value?: T): string => {
if(!value)
return '';
const found = options.find(e => (e as { value: string } | undefined)?.value === value || e === value);
if(!found)
return '';
return (found as { text: string } | undefined)?.text ?? found as string;
};
let context: { container: HTMLElement, content: NodeChildren, close: () => void };
let selected = true;
const show = () => {
if(disabled || (context && context.container.parentElement))
return;
const box = container.getBoundingClientRect();
context = contextmenu(box.x, box.y + box.height, optionElements.filter(e => !!e).length > 0 ? optionElements.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-auto', settings?.class?.popup], style: { "min-width": `${box.width}px` }, blur: () => { if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red') } });
if(!selected) container.classList.remove('!border-light-red', 'dark:!border-dark-red');
};
const hide = () => {
if(!context || !context.container.parentElement)
return;
context.close();
if(!selected) container.classList.add('!border-light-red', 'dark:!border-dark-red');
};
let disabled = settings?.disabled ?? false;
const optionElements = options.map((e, i) => {
if(e === undefined)
return;
return { item: e, dom: dom('div', { listeners: { click: () => {
let text, value;
if(typeof e === 'string')
{
text = value = e;
}
else
{
text = e.text;
value = e.value;
}
select.value = text;
settings?.change && settings?.change(value);
selected = true;
hide();
} }, class: ['hover:bg-light-30 dark:hover:bg-dark-30 text-light-70 dark:text-dark-70 hover:text-light-100 dark:hover:text-dark-100 py-1 px-2 cursor-pointer', settings?.class?.option] }, [ text((e as { text: string } | undefined)?.text ?? e as string) ]) };
});
const select = dom('input', { listeners: { focus: show, input: () => {
context && context?.container.replaceChildren(...optionElements.filter(e => {
if(e === undefined)
return false;
if(typeof e.item === 'string')
return (e.item as string).toLowerCase().includes(select.value.toLowerCase());
return e.item.text.toLowerCase().includes(select.value.toLowerCase());
}).map(e => e!.dom));
selected = false;
if(!context || !context.container.parentElement) container.classList.add('!border-light-red', 'dark:!border-dark-red')
} }, 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' });
select.value = textFromValue(settings?.defaultValue);
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 }): HTMLInputElement
{
const input = dom("input", { attributes: { disabled: settings?.disabled }, 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.shiftKey ? 10 : 1)) && settings?.input && settings.input(storedValue);
break;
case "ArrowDown":
validateAndChange(storedValue - (e.shiftKey ? 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) field.value = storedValue.toString(10);
return field;
}