Aspect and Spell editor, multiselect component.

This commit is contained in:
2025-08-26 00:17:08 +02:00
parent 69ee62c08e
commit 893247e1eb
8 changed files with 1971 additions and 549 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter,
import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json';
import { fakeA } from "#shared/proses";
import { button, input, loading, numberpicker, select } from "#shared/components.util";
import { button, input, loading, numberpicker, select, toggle } from "#shared/components.util";
import { div, dom, icon, mergeClasses, text, type Class } from "#shared/dom.util";
import { followermenu, popper } from "#shared/floating.util";
import { clamp } from "#shared/general.util";
@@ -17,6 +17,7 @@ export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] as const;
export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] as const;
export const CATEGORIES = ["action","reaction","freeaction","misc"] as const;
export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const;
export const ALIGNMENTS: Alignment[] = [{ kindness: 'good', loyalty: 'loyal' }, { kindness: 'good', loyalty: 'neutral' }, { kindness: 'good', loyalty: 'chaotic' }, { kindness: 'neutral', loyalty: 'loyal' }, { kindness: 'neutral', loyalty: 'neutral' }, { kindness: 'neutral', loyalty: 'chaotic' }, { kindness: 'evil', loyalty: 'loyal' }, { kindness: 'evil', loyalty: 'neutral' }, { kindness: 'evil', loyalty: 'chaotic' }];
export const defaultCharacter: Character = {
id: -1,
@@ -715,14 +716,7 @@ class PeoplePicker implements BuilderTab
document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`;
}
});
this._visibilityInput = 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]`, attributes: { "data-state": "unckecked" }, listeners: {
click: (e: Event) => {
this._builder.character.visibility = this._builder.character.visibility === "private" ? "public" : "private";
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
}
}}, [ 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') ]);
this._visibilityInput = toggle({ defaultValue: this._builder.character.visibility === "private", change: (value) => this._builder.character.visibility = value ? "private" : "public" });
this._options = config.peoples.map(
(people, i) => dom("div", { class: "flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35 cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]", listeners: { click: () => {

View File

@@ -50,7 +50,7 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
return dom('div', { listeners: { click: () => {
textValue.textContent = e.text;
settings?.change && settings?.change(e.value);
close && close();
context && context.close && 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: () => {
@@ -96,6 +96,78 @@ export function select<T extends NonNullable<any>>(options: Array<{ text: string
})
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: () => {
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 && 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 };
@@ -294,7 +366,7 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
focus: () => settings?.focus && settings.focus(),
blur: () => settings?.blur && settings.blur(),
}});
if(settings?.defaultValue) field.value = storedValue.toString(10);
if(settings?.defaultValue !== undefined) field.value = storedValue.toString(10);
return field;
}
@@ -309,8 +381,23 @@ export function foldable(content: NodeChildren, title: NodeChildren, settings?:
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 } })
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: '' }, [ render(e[f]!) ]) : undefined)))) ]);
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;
}

View File

@@ -1,12 +1,12 @@
import type { Ability, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, Resistance } from "~/types/character";
import type { Ability, AspectConfig, CharacterConfig, Feature, FeatureEffect, FeatureItem, MainStat, Resistance, SpellConfig } from "~/types/character";
import { div, dom, icon, text, type NodeChildren } from "#shared/dom.util";
import { MarkdownEditor } from "#shared/editor.util";
import { fakeA } from "#shared/proses";
import { button, combobox, foldable, input, numberpicker, select, table, type Option } from "#shared/components.util";
import { button, combobox, foldable, input, multiselect, numberpicker, select, table, toggle, type Option } from "#shared/components.util";
import { fullblocker, tooltip } from "#shared/floating.util";
import { elementTexts, MAIN_STATS, mainStatShortTexts, mainStatTexts, spellTypeTexts } from "#shared/character.util";
import { ALIGNMENTS, alignmentToString, elementTexts, MAIN_STATS, mainStatShortTexts, mainStatTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json";
import { clamp, getID, ID_SIZE } from "#shared/general.util";
import { getID, ID_SIZE } from "#shared/general.util";
import renderMarkdown, { renderText } from "#shared/markdown.util";
import { Tree } from "#shared/tree";
import markdownUtil from "#shared/markdown.util";
@@ -42,8 +42,8 @@ export class HomebrewBuilder
new TrainingEditor(this, this._config),
new AbilityEditor(this, this._config),
new AspectEditor(this, this._config),
/* new SpellEditor(this),
new ListEditor(this), */
new SpellEditor(this, this._config),
/* new ListEditor(this), */
];
this._content = div('flex-1 outline-none max-w-full w-full overflow-y-auto');
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
@@ -173,53 +173,115 @@ class AbilityEditor extends BuilderTab
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
Object.entries(config.abilities).map(e => div('flex flex-col gap-4 border border-light-25 dark:border-dark-25', [ ]))
this._content = [ table(Object.entries(config.abilities).map(e => ({
max1: div('', [ text(mainStatTexts[e[1].max[0]]) ]),
max2: div('', [ text(mainStatTexts[e[1].max[1]]) ]),
name: div('', [ text(e[1].name) ]),
description: div('', [ text(e[1].description) ]),
id: div('', [ text(e[0]) ]),
})), { id: 'ID', name: 'Nom', description: 'Description', max1: 'Stat 1', max2: 'Stat 2' }) ];
this._content = [ div('flex px-24 py-4', [table(Object.entries(config.abilities).map(e => ({
max1: select(MAIN_STATS.map(e => ({ text: mainStatTexts[e], value: e })), { change: (value) => e[1].max[0] = value, defaultValue: e[1].max[0], class: { container: 'w-full !m-0' } }),
max2: select(MAIN_STATS.map(e => ({ text: mainStatTexts[e], value: e })), { change: (value) => e[1].max[1] = value, defaultValue: e[1].max[1], class: { container: 'w-full !m-0' } }),
name: input('text', { input: (value) => e[1].name = value, placeholder: 'Nom', defaultValue: e[1].name, class: 'w-full !m-0' }),
description: input('text', { input: (value) => e[1].description = value, placeholder: 'Description', defaultValue: e[1].description, class: 'w-full !m-0' }),
id: div('w-full !m-0', [ text(e[0]) ]),
})), { id: 'ID', name: 'Nom', description: 'Description', max1: 'Stat 1', max2: 'Stat 2' }, { class: { table: 'flex-1' } })] ) ];
}
}
class AspectEditor extends BuilderTab
{
private _filter: boolean = true;
private _options: HTMLDivElement[];
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
const render = (aspect: AspectConfig) => {
return {
name: input('text', { input: (value) => aspect.name = value, defaultValue: aspect.name, class: '!m-0 w-full' }),
description: input('text', { input: (value) => aspect.description = value, defaultValue: aspect.description, class: '!m-0 w-full' }),
stat: select(MAIN_STATS.map(f => ({ text: mainStatTexts[f], value: f })), { change: (value) => aspect.stat = value, defaultValue: aspect.stat, class: { container: '!m-0 w-full' } }),
alignment: select(ALIGNMENTS.map(f => ({ text: alignmentToString(f), value: f })), { change: (value) => aspect.alignment = value, defaultValue: aspect.alignment, class: { container: '!m-0 w-full' } }),
magic: toggle({ defaultValue: aspect.magic, change: (value) => aspect.magic = value, class: { container: '' } }),
difficulty: numberpicker({ min: 6, max: 13, input: (value) => aspect.difficulty = value, defaultValue: aspect.difficulty, class: '!m-0 w-full' }),
physic: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.physic.min, input: (value) => aspect.physic.min = value }), numberpicker({ defaultValue: aspect.physic.max, input: (value) => aspect.physic.max = value }) ]),
mental: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.mental.min, input: (value) => aspect.mental.min = value }), numberpicker({ defaultValue: aspect.mental.max, input: (value) => aspect.mental.max = value }) ]),
personality: div('flex flex-row justify-center gap-2', [ numberpicker({ defaultValue: aspect.personality.min, input: (value) => aspect.personality.min = value }), numberpicker({ defaultValue: aspect.personality.max, input: (value) => aspect.personality.max = value }) ]),
action: div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:file-text'), () => {}, 'p-1'), button(icon('radix-icons:trash'), () => remove(aspect), 'p-1') ])
};
}
const add = () => {
config.aspects.push({
name: '',
description: '',
stat: 'strength',
alignment: { kindness: 'good', loyalty: 'loyal' },
magic: false,
difficulty: 6,
physic: { min: 0, max: 30 },
mental: { min: 0, max: 20 },
personality: { min: 0, max: 20 },
options: []
});
/* this._options = config.aspects.map((e, i) => dom('div', { attributes: { "data-aspect": i.toString() }, listeners: { click: () => {
this._builder.character.aspect = i;
this._options.forEach(_e => _e.setAttribute('data-state', 'inactive'));
this._options[i]?.setAttribute('data-state', 'active');
}}, class: 'group flex flex-col w-[360px] border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 cursor-pointer' }, [
div('bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2 group-data-[state=active]:bg-accent-blue group-data-[state=active]:bg-opacity-10', [
div('flex flex-row gap-8 ps-4 items-center', [
div("flex flex-1 flex-col gap-2 justify-center", [ div('text-lg font-bold', [ text(e.name) ]), dom('span', { class: 'border-b w-full border-light-50 dark:border-dark-50 group-data-[state=active]:border-b-[4px] group-data-[state=active]:border-accent-blue' }) ]),
div('rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10')
])
]),
div('flex justify-stretch items-stretch py-2 px-4 gap-4', [
div('flex flex-col flex-1 items-stretch gap-4', [
div('flex flex-1 justify-between', [ text('Difficulté'), div('text-sm font-bold', [ text(e.difficulty.toString()) ]) ]),
div('flex flex-1 justify-between', [ text('Bonus'), div('text-sm font-bold', [ text(e.stat === 'special' ? 'Special' : mainStatTexts[e.stat]) ]) ])
]),
div('w-px h-full bg-light-50 dark:bg-dark-50'),
div('flex flex-col items-center justify-between py-2', [
div('text-sm italic', [ text(alignmentToString(e.alignment)) ]),
div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]),
]),
])
])); */
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
};
const remove = (aspect: AspectConfig) => {
config.aspects = config.aspects.filter(e => e !== aspect);
this._content = [ div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', /* this._options */)];
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
}
const redraw = () => table(config.aspects.map(render), { name: 'Nom', description: 'Description', stat: 'Buff de stat', alignment: 'Alignement', magic: 'Magie', difficulty: 'Difficulté', physic: 'Physique', mental: 'Mental', personality: 'Caractère', action: 'Actions' }, { class: { table: 'flex-1' } });
let content = redraw();
this._content = [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
}
}
class SpellEditor extends BuilderTab
{
constructor(builder: HomebrewBuilder, config: CharacterConfig)
{
super(builder, config);
const render = (spell: SpellConfig) => {
return {
id: spell.id,
name: input('text', { input: (value) => spell.name = value, defaultValue: spell.name, class: '!m-0 w-full' }),
rank: select([{ text: 'Rang 1', value: 1 }, { text: 'Rang 2', value: 2 }, { text: 'Rang 3', value: 3 }, { text: 'Spécial', value: 4 }], { change: (value: 1 | 2 | 3 | 4) => spell.rank = value, defaultValue: spell.rank, class: { container: '!m-0 w-full' } }),
type: select(SPELL_TYPES.map(f => ({ text: spellTypeTexts[f], value: f })), { change: (value) => spell.type = value, defaultValue: spell.type, class: { container: '!m-0 w-full' } }),
cost: numberpicker({ defaultValue: spell.cost, input: (value) => spell.cost = value, class: '!m-0 w-full' }),
speed: select<'action' | 'reaction' | number>([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: '1 minute', value: 1 }, { text: '10 minutes', value: 10 }], { change: (value) => spell.speed = value, defaultValue: spell.speed, class: { container: '!m-0 w-full' } }),
elements: multiselect(SPELL_ELEMENTS.map(f => ({ text: elementTexts[f].text, value: f })), { change: (value) => spell.elements = value, defaultValue: spell.elements, class: { container: '!m-0 w-full' } }),
effect: input('text', { input: (value) => spell.effect = value, defaultValue: spell.effect, class: '!m-0 w-full' }),
tags: multiselect([{ text: 'Dégâts', value: 'damage' }, { text: 'Buff', value: 'buff' }, { text: 'Debuff', value: 'debuff' }, { text: 'Support', value: 'support' }, { text: 'Tank', value: 'tank' }, { text: 'Mouvement', value: 'movement' }, { text: 'Utilitaire', value: 'utilitary' }], { change: (value) => spell.tags = value, defaultValue: spell.tags, class: { container: '!m-0 w-full' } }),
concentration: toggle({ change: (value) => spell.concentration = value, defaultValue: spell.concentration, class: { container: '!m-0' } }),
action: div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:trash'), () => remove(spell), 'p-1') ])
};
}
const add = () => {
config.spells.push({
id: getID(ID_SIZE),
name: '',
rank: 1,
type: 'precision',
cost: 1,
speed: 'action',
elements: [],
effect: '',
concentration: false,
tags: [],
});
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
};
const remove = (spell: SpellConfig) => {
config.spells = config.spells.filter(e => e !== spell);
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
}
const redraw = () => table(config.spells.map(render), { id: 'ID', name: 'Nom', rank: 'Rang', type: 'Type', cost: 'Coût', speed: 'Incantation', elements: 'Elements', effect: 'Effet', tags: 'Tag', concentration: 'Concentration', action: 'Actions' }, { class: { table: 'flex-1' } });
let content = redraw();
this._content = [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), add, 'p-1') ]), content ] ) ];
}
}