parent
68e15ef884
commit
e9677e9617
@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* actx.svelte — test suite (versión reactiva)
|
|
||||||
*
|
|
||||||
* Estos tests requieren un entorno Svelte 5 con soporte para runes ($state).
|
|
||||||
*
|
|
||||||
* Ejecutar: npx vitest run src/lib/actx/test/actx.svelte.test.ts
|
|
||||||
*
|
|
||||||
* Módulos cubiertos:
|
|
||||||
* - engine.svelte (createACtx reactivo)
|
|
||||||
* - reactividad de $state
|
|
||||||
* - sincronización engines → $state
|
|
||||||
* - uiux reactivo
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CurrDefinition } from '../../glob/lib';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { createLing } from '../../ling/lib';
|
|
||||||
import { createACtx } from '../lib/engine.svelte';
|
|
||||||
import { LogLevel } from '../../logr/lib';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FIXTURES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const translations = {
|
|
||||||
common: {
|
|
||||||
ok: { es: 'Aceptar', en: 'OK' },
|
|
||||||
cancel: { es: 'Cancelar', en: 'Cancel' },
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
welcome: { es: 'Bienvenido', en: 'Welcome' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const EUR: CurrDefinition = {
|
|
||||||
symbol: '€',
|
|
||||||
code: 'EUR',
|
|
||||||
name: ({ count }) => ({
|
|
||||||
es: count === 1 ? 'euro' : 'euros',
|
|
||||||
en: count === 1 ? 'euro' : 'euros',
|
|
||||||
}),
|
|
||||||
conversionValue: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const USD: CurrDefinition = {
|
|
||||||
symbol: '$',
|
|
||||||
code: 'USD',
|
|
||||||
name: ({ count }) => ({
|
|
||||||
es: count === 1 ? 'dólar' : 'dólares',
|
|
||||||
en: count === 1 ? 'dollar' : 'dollars',
|
|
||||||
}),
|
|
||||||
conversionValue: 1.08,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CREATE ACTX (REACTIVE)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
describe('createACtx (reactivo con Svelte 5)', () => {
|
|
||||||
describe('creación básica', () => {
|
|
||||||
it('crea una instancia con ling', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.ling).toBeDefined();
|
|
||||||
expect(actx.locale).toBe('es');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('t() funciona directamente en actx', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.t('common.ok')).toBe('Aceptar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ts() funciona directamente en actx', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('módulos configurados', () => {
|
|
||||||
it('crea instancia con todos los módulos configurados', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
logr: { level: LogLevel.WARN },
|
|
||||||
numr: { maxDecimals: 2 },
|
|
||||||
dati: { selectedTimeFormat: '24h' },
|
|
||||||
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
|
|
||||||
unit: { selectedSystem: 'metric' },
|
|
||||||
uiuxConfig: { theme: { mode: 'auto' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Módulos básicos
|
|
||||||
expect(actx.ling).toBeDefined();
|
|
||||||
expect(actx.logr).toBeDefined();
|
|
||||||
expect(actx.numr).toBeDefined();
|
|
||||||
expect(actx.dati).toBeDefined();
|
|
||||||
expect(actx.curr).toBeDefined();
|
|
||||||
expect(actx.unit).toBeDefined();
|
|
||||||
|
|
||||||
// UIX reactivo
|
|
||||||
expect(actx.uiux).toBeDefined();
|
|
||||||
expect(actx.uiux.theme).toBeDefined();
|
|
||||||
expect(actx.uiux.toast).toBeDefined();
|
|
||||||
expect(actx.uiux.modal).toBeDefined();
|
|
||||||
expect(actx.uiux.break).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reactividad de locale', () => {
|
|
||||||
it('locale es reactivo y cambia con ling.setLocale()', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.locale).toBe('es');
|
|
||||||
|
|
||||||
// El cambio en ling debería reflejarse en el estado reactivo
|
|
||||||
// (en un entorno Svelte real, esto sería automático)
|
|
||||||
actx.ling.setLocale('en');
|
|
||||||
|
|
||||||
// En el entorno de test, verificamos que ling cambió
|
|
||||||
expect(actx.ling.getLocale()).toBe('en');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('uiux reactivo', () => {
|
|
||||||
it('theme tiene propiedades de lectura', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
uiuxConfig: { theme: { mode: 'dark' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Props de lectura (reactivas en Svelte)
|
|
||||||
expect(actx.uiux.theme.resolved).toBeDefined();
|
|
||||||
expect(actx.uiux.theme.mode).toBe('dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('theme tiene métodos de acción', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
uiuxConfig: { theme: { mode: 'light' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Métodos de acción
|
|
||||||
expect(actx.uiux.theme.set).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.theme.toggle).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.theme.onChange).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toast tiene propiedades de lectura', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
uiuxConfig: { toast: { defaultDuration: 4000 } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Props de lectura
|
|
||||||
expect(actx.uiux.toast.queue).toBeDefined();
|
|
||||||
expect(Array.isArray(actx.uiux.toast.queue)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toast tiene métodos de acción', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.uiux.toast.show).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.toast.dismiss).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.toast.clear).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.toast.onChange).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('modal tiene propiedades de lectura', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.uiux.modal.stack).toBeDefined();
|
|
||||||
expect(Array.isArray(actx.uiux.modal.stack)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('modal tiene métodos de acción', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.uiux.modal.open).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.modal.close).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.modal.closeAll).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.modal.isOpen).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.modal.onChange).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('break tiene propiedades de lectura', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.uiux.break.current).toBeDefined();
|
|
||||||
// Las propiedades computadas dependen del entorno
|
|
||||||
expect(typeof actx.uiux.break.isMobile).toBe('boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('break tiene métodos de acción', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({ ling });
|
|
||||||
|
|
||||||
expect(actx.uiux.break.is).toBeInstanceOf(Function);
|
|
||||||
expect(actx.uiux.break.onChange).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('integración con módulos', () => {
|
|
||||||
it('numr funciona correctamente', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
numr: { maxDecimals: 2 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = actx.numr.f(1234.5);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result).toContain('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('curr funciona correctamente con pluralización', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
|
|
||||||
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dati funciona correctamente', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
dati: { selectedTimeFormat: '24h' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const date = new Date('2024-03-15');
|
|
||||||
const result = actx.dati.f(date);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unit funciona correctamente', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
unit: { selectedSystem: 'metric' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = actx.unit.weight.f(75);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result).toContain('75');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('logr integrado con ling', () => {
|
|
||||||
it('logr usa ling para resolver mensajes', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const captured: string[] = [];
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
logr: {
|
|
||||||
level: LogLevel.INFO,
|
|
||||||
transports: [
|
|
||||||
{
|
|
||||||
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
actx.logr.info('test', '#?common.ok');
|
|
||||||
expect(captured[0]).toBe('Aceptar');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('casos de uso reales', () => {
|
|
||||||
it('simula una aplicación multilingüe completa', () => {
|
|
||||||
const ling = createLing(translations, 'es');
|
|
||||||
const actx = createACtx({
|
|
||||||
ling,
|
|
||||||
numr: { maxDecimals: 2 },
|
|
||||||
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
|
|
||||||
dati: { selectedTimeFormat: '24h' },
|
|
||||||
unit: { selectedSystem: 'metric' },
|
|
||||||
uiuxConfig: {
|
|
||||||
theme: { mode: 'auto' },
|
|
||||||
toast: { defaultDuration: 4000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Traducciones
|
|
||||||
expect(actx.t('common.ok')).toBe('Aceptar');
|
|
||||||
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
|
|
||||||
|
|
||||||
// Formateo
|
|
||||||
expect(actx.numr.f(1000)).toBeTruthy();
|
|
||||||
expect(actx.curr.f(100)).toBe('100,00 €');
|
|
||||||
expect(actx.dati.f(new Date())).toBeTruthy();
|
|
||||||
expect(actx.unit.weight.f(75)).toContain('75');
|
|
||||||
|
|
||||||
// Locale
|
|
||||||
expect(actx.locale).toBe('es');
|
|
||||||
|
|
||||||
// UIX
|
|
||||||
expect(actx.uiux.theme.mode).toBe('auto');
|
|
||||||
|
|
||||||
// Cambio de locale
|
|
||||||
actx.ling.setLocale('en');
|
|
||||||
expect(actx.t('common.ok')).toBe('OK');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +1,8 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
|
|
||||||
|
|
||||||
|
export * from './actx/lib';
|
||||||
|
export * from './glob/lib';
|
||||||
|
export * from './ling/lib';
|
||||||
|
export * from './logr/lib';
|
||||||
|
export * from './util';
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
import type { BreakConfig, BreakInstance, BreakpointKey } from './types.ts';
|
|
||||||
|
|
||||||
// Breakpoints por defecto — coinciden con Tailwind v3
|
|
||||||
const DEFAULT_POINTS: Record<string, number> = {
|
|
||||||
sm : 640,
|
|
||||||
md : 768,
|
|
||||||
lg : 1024,
|
|
||||||
xl : 1280,
|
|
||||||
'2xl': 1536,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-ordena los breakpoints de mayor a menor una sola vez.
|
|
||||||
* Evita re-ordenar en cada resize.
|
|
||||||
*/
|
|
||||||
function sortedEntries(points: Record<string, number>): [string, number][] {
|
|
||||||
return Object.entries(points).sort(([, a], [, b]) => b - a);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resuelve el breakpoint activo dado un ancho y entries pre-ordenados.
|
|
||||||
* Devuelve la clave del mayor breakpoint que el ancho cumple, o 'xs' si ninguno.
|
|
||||||
*/
|
|
||||||
function resolveBreakpoint(width: number, sorted: [string, number][]): BreakpointKey {
|
|
||||||
for (const [key, value] of sorted) {
|
|
||||||
if (width >= value) return key;
|
|
||||||
}
|
|
||||||
return 'xs';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBreak(config: BreakConfig = {}): BreakInstance {
|
|
||||||
const points = config.points ?? DEFAULT_POINTS;
|
|
||||||
const sorted = sortedEntries(points);
|
|
||||||
|
|
||||||
const getWidth = () => typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
||||||
|
|
||||||
let current : BreakpointKey = resolveBreakpoint(getWidth(), sorted);
|
|
||||||
const listeners = new Set<(breakpoint: BreakpointKey) => void>();
|
|
||||||
|
|
||||||
let observer: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
// ResizeObserver en browser, noop en SSR
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
observer = new ResizeObserver(() => {
|
|
||||||
const next = resolveBreakpoint(window.innerWidth, sorted);
|
|
||||||
if (next === current) return;
|
|
||||||
current = next;
|
|
||||||
listeners.forEach(fn => fn(current));
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
function is(breakpoint: BreakpointKey): boolean {
|
|
||||||
const min = points[breakpoint];
|
|
||||||
if (min === undefined) return false;
|
|
||||||
return getWidth() >= min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn: (breakpoint: BreakpointKey) => void): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispose(): void {
|
|
||||||
observer?.disconnect();
|
|
||||||
observer = null;
|
|
||||||
listeners.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get current() { return current; },
|
|
||||||
get isMobile() { return !is('md'); },
|
|
||||||
get isTablet() { return is('md') && !is('lg'); },
|
|
||||||
get isDesktop() { return is('lg'); },
|
|
||||||
is,
|
|
||||||
onChange,
|
|
||||||
dispose,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* uiux_engine.svelte.ts — Factory reactivo del contexto de UI.
|
|
||||||
*
|
|
||||||
* Encapsula todo el $state de uiux en su propio módulo,
|
|
||||||
* igual que numr_engine.ts o curr_engine.ts.
|
|
||||||
*
|
|
||||||
* Si uiux crece con nuevos módulos (notifications, shortcuts, etc.)
|
|
||||||
* solo hay que tocar este fichero y ReactiveUiux — actx no se entera.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { UiuxConfig, ReactiveUiux, ThemeMode, Toast, ModalEntry } from './types.ts';
|
|
||||||
import { createUiux } from './engine.ts';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function createUiuxState(config: UiuxConfig = {}): ReactiveUiux {
|
|
||||||
|
|
||||||
const _uiux = createUiux(config);
|
|
||||||
|
|
||||||
// ── $state ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let themeResolved = $state<'light' | 'dark'>(_uiux.theme.resolved);
|
|
||||||
let themeMode = $state<ThemeMode>(_uiux.theme.mode);
|
|
||||||
let toastQueue = $state<Toast[]>([]);
|
|
||||||
let modalStack = $state<ModalEntry[]>([]);
|
|
||||||
let breakCurrent = $state<string>(_uiux.break.current);
|
|
||||||
let openMenus = $state<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// ── Sincronización engines → $state ──────────────────────────────────────
|
|
||||||
|
|
||||||
_uiux.theme.onChange((mode, resolved) => {
|
|
||||||
themeMode = mode;
|
|
||||||
themeResolved = resolved;
|
|
||||||
});
|
|
||||||
|
|
||||||
_uiux.toast.onChange(q => { toastQueue = q; });
|
|
||||||
_uiux.modal.onChange(s => { modalStack = s; });
|
|
||||||
_uiux.break.onChange(bp => { breakCurrent = bp; });
|
|
||||||
_uiux.menu.onChange(ids => { openMenus = new Set(ids); });
|
|
||||||
|
|
||||||
// ── ReactiveUiux ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return {
|
|
||||||
theme: {
|
|
||||||
get resolved() { return themeResolved; },
|
|
||||||
get mode() { return themeMode; },
|
|
||||||
set : _uiux.theme.set.bind(_uiux.theme),
|
|
||||||
toggle : _uiux.theme.toggle.bind(_uiux.theme),
|
|
||||||
onChange: _uiux.theme.onChange.bind(_uiux.theme),
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
get queue() { return toastQueue; },
|
|
||||||
show : _uiux.toast.show.bind(_uiux.toast),
|
|
||||||
dismiss : _uiux.toast.dismiss.bind(_uiux.toast),
|
|
||||||
clear : _uiux.toast.clear.bind(_uiux.toast),
|
|
||||||
onChange: _uiux.toast.onChange.bind(_uiux.toast),
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
get stack() { return modalStack; },
|
|
||||||
get active() {
|
|
||||||
return modalStack.length > 0
|
|
||||||
? modalStack[modalStack.length - 1]
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
open : _uiux.modal.open.bind(_uiux.modal),
|
|
||||||
close : _uiux.modal.close.bind(_uiux.modal),
|
|
||||||
closeAll: _uiux.modal.closeAll.bind(_uiux.modal),
|
|
||||||
isOpen : _uiux.modal.isOpen.bind(_uiux.modal),
|
|
||||||
onChange: _uiux.modal.onChange.bind(_uiux.modal),
|
|
||||||
},
|
|
||||||
menu: {
|
|
||||||
open : _uiux.menu.open.bind(_uiux.menu),
|
|
||||||
close : _uiux.menu.close.bind(_uiux.menu),
|
|
||||||
toggle : _uiux.menu.toggle.bind(_uiux.menu),
|
|
||||||
isOpen : (id: string) => openMenus.has(id),
|
|
||||||
closeAll: _uiux.menu.closeAll.bind(_uiux.menu),
|
|
||||||
onChange: _uiux.menu.onChange.bind(_uiux.menu),
|
|
||||||
},
|
|
||||||
break: {
|
|
||||||
get current() { return breakCurrent; },
|
|
||||||
get isMobile() { return !_uiux.break.is('md'); },
|
|
||||||
get isTablet() { return _uiux.break.is('md') && !_uiux.break.is('lg'); },
|
|
||||||
get isDesktop() { return _uiux.break.is('lg'); },
|
|
||||||
is : _uiux.break.is.bind(_uiux.break),
|
|
||||||
onChange: _uiux.break.onChange.bind(_uiux.break),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import type { Uiux, UiuxConfig } from './types.ts';
|
|
||||||
import { createTheme } from './theme_engine.ts';
|
|
||||||
import { createBreak } from './break_engine.ts';
|
|
||||||
import { createToast } from './toast_engine.ts';
|
|
||||||
import { createModal } from './modal_engine.ts';
|
|
||||||
import { createMenu } from './menu_engine.ts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea el contexto de UI de la aplicación.
|
|
||||||
*
|
|
||||||
* Todos los módulos están siempre disponibles — no requieren config explícita.
|
|
||||||
* La config solo ajusta los defaults (tema inicial, breakpoints, duración de toasts).
|
|
||||||
*
|
|
||||||
* Diseñado para funcionar como módulo independiente o como parte de `ACtx`:
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Standalone
|
|
||||||
* const uiux = createUiux({ theme: { mode: 'auto' } });
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Integrado en ACtx (cuando se añada uiux a ACtxConfig)
|
|
||||||
* actx.uiux.theme.toggle()
|
|
||||||
* actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
|
|
||||||
* actx.uiux.modal.open('confirm', { title: '¿Eliminar?' })
|
|
||||||
* actx.uiux.menu.toggle('sidebar')
|
|
||||||
* actx.uiux.break.isMobile
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* **Persistencia**: el tema se guarda en memoria en esta versión.
|
|
||||||
* Cuando `stor` esté disponible, `ACtx` conectará `uiux.theme` con
|
|
||||||
* `stor.local.get/set('uiux:theme')` sin modificar esta librería.
|
|
||||||
*/
|
|
||||||
export function createUiux(config: UiuxConfig = {}): Uiux {
|
|
||||||
return {
|
|
||||||
theme : createTheme(config.theme),
|
|
||||||
break : createBreak(config.break),
|
|
||||||
toast : createToast(config.toast?.defaultDuration),
|
|
||||||
modal : createModal(),
|
|
||||||
menu : createMenu(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export * from './types.ts';
|
|
||||||
export * from './engine.ts';
|
|
||||||
export * from './theme_engine.ts';
|
|
||||||
export * from './break_engine.ts';
|
|
||||||
export * from './toast_engine.ts';
|
|
||||||
export * from './modal_engine.ts';
|
|
||||||
export * from './menu_engine.ts';
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import type { MenuInstance } from './types.ts';
|
|
||||||
|
|
||||||
export function createMenu(): MenuInstance {
|
|
||||||
const openIds = new Set<string>();
|
|
||||||
const listeners = new Set<(openIds: Set<string>) => void>();
|
|
||||||
|
|
||||||
function notify(): void {
|
|
||||||
const snapshot = new Set(openIds);
|
|
||||||
listeners.forEach(fn => fn(snapshot));
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(id: string): void {
|
|
||||||
if (openIds.has(id)) return;
|
|
||||||
openIds.add(id);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(id: string): void {
|
|
||||||
if (!openIds.has(id)) return;
|
|
||||||
openIds.delete(id);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(id: string): void {
|
|
||||||
openIds.has(id) ? close(id) : open(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen(id: string): boolean {
|
|
||||||
return openIds.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAll(): void {
|
|
||||||
if (openIds.size === 0) return;
|
|
||||||
openIds.clear();
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn: (openIds: Set<string>) => void): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { open, close, toggle, isOpen, closeAll, onChange };
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import type { ModalEntry, ModalInstance } from './types.ts';
|
|
||||||
|
|
||||||
export function createModal(): ModalInstance {
|
|
||||||
let stack: ModalEntry[] = [];
|
|
||||||
const listeners = new Set<(stack: ModalEntry[]) => void>();
|
|
||||||
|
|
||||||
function notify(): void {
|
|
||||||
const snapshot = [...stack];
|
|
||||||
listeners.forEach(fn => fn(snapshot));
|
|
||||||
}
|
|
||||||
|
|
||||||
function open<P extends Record<string, any> = Record<string, any>>(id: string, props?: P): void {
|
|
||||||
stack = [...stack, { id, props: props ?? ({} as P) }];
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
if (stack.length === 0) return;
|
|
||||||
stack = stack.slice(0, -1);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAll(): void {
|
|
||||||
if (stack.length === 0) return;
|
|
||||||
stack = [];
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen(id: string): boolean {
|
|
||||||
return stack.some(m => m.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn: (stack: ModalEntry[]) => void): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get stack() { return stack; },
|
|
||||||
get active() { return stack.length > 0 ? stack[stack.length - 1] : null; },
|
|
||||||
open,
|
|
||||||
close,
|
|
||||||
closeAll,
|
|
||||||
isOpen,
|
|
||||||
onChange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
import type { ThemeConfig, ThemeInstance, ThemeMode } from './types.ts';
|
|
||||||
|
|
||||||
const DEFAULT_DARK_CLASS = 'dark';
|
|
||||||
|
|
||||||
/** Resuelve 'auto' al modo del sistema operativo. */
|
|
||||||
function resolveSystemMode(): 'light' | 'dark' {
|
|
||||||
if (typeof window === 'undefined') return 'light';
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(
|
|
||||||
resolved : 'light' | 'dark',
|
|
||||||
darkClass : string,
|
|
||||||
vars? : ThemeConfig['vars']
|
|
||||||
): void {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const html = document.documentElement;
|
|
||||||
|
|
||||||
// Clase dark de Tailwind
|
|
||||||
resolved === 'dark'
|
|
||||||
? html.classList.add(darkClass)
|
|
||||||
: html.classList.remove(darkClass);
|
|
||||||
|
|
||||||
// Variables CSS custom
|
|
||||||
if (vars) {
|
|
||||||
const themeVars = vars[resolved] ?? {};
|
|
||||||
for (const [key, value] of Object.entries(themeVars)) {
|
|
||||||
html.style.setProperty(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTheme(config: ThemeConfig = {}): ThemeInstance {
|
|
||||||
const darkClass = config.darkClass ?? DEFAULT_DARK_CLASS;
|
|
||||||
|
|
||||||
let currentMode: ThemeMode = config.mode ?? 'auto';
|
|
||||||
let resolved : 'light' | 'dark' = currentMode === 'auto'
|
|
||||||
? resolveSystemMode()
|
|
||||||
: currentMode;
|
|
||||||
|
|
||||||
const listeners = new Set<(mode: ThemeMode, resolved: 'light' | 'dark') => void>();
|
|
||||||
|
|
||||||
// Escucha cambios del sistema cuando el modo es 'auto'
|
|
||||||
let mediaUnsubscribe: (() => void) | undefined;
|
|
||||||
|
|
||||||
function subscribeToSystem(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handler = () => {
|
|
||||||
if (currentMode !== 'auto') return;
|
|
||||||
resolved = mq.matches ? 'dark' : 'light';
|
|
||||||
applyTheme(resolved, darkClass, config.vars);
|
|
||||||
listeners.forEach(fn => fn(currentMode, resolved));
|
|
||||||
};
|
|
||||||
mq.addEventListener('change', handler);
|
|
||||||
mediaUnsubscribe = () => mq.removeEventListener('change', handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribeFromSystem(): void {
|
|
||||||
mediaUnsubscribe?.();
|
|
||||||
mediaUnsubscribe = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function set(mode: ThemeMode): void {
|
|
||||||
currentMode = mode;
|
|
||||||
|
|
||||||
if (mode === 'auto') {
|
|
||||||
resolved = resolveSystemMode();
|
|
||||||
subscribeToSystem();
|
|
||||||
} else {
|
|
||||||
unsubscribeFromSystem();
|
|
||||||
resolved = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTheme(resolved, darkClass, config.vars);
|
|
||||||
listeners.forEach(fn => fn(currentMode, resolved));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(): void {
|
|
||||||
set(resolved === 'dark' ? 'light' : 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aplicar tema inicial
|
|
||||||
applyTheme(resolved, darkClass, config.vars);
|
|
||||||
if (currentMode === 'auto') subscribeToSystem();
|
|
||||||
|
|
||||||
return {
|
|
||||||
get resolved() { return resolved; },
|
|
||||||
get mode() { return currentMode; },
|
|
||||||
set,
|
|
||||||
toggle,
|
|
||||||
onChange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import type { Toast, ToastInstance, ToastOptions } from './types.ts';
|
|
||||||
import type { LingString } from '@/ling/lib';
|
|
||||||
|
|
||||||
const DEFAULT_DURATION = 4000;
|
|
||||||
|
|
||||||
let _id = 0;
|
|
||||||
function nextId(): string { return `toast-${++_id}`; }
|
|
||||||
|
|
||||||
export function createToast(defaultDuration = DEFAULT_DURATION): ToastInstance {
|
|
||||||
let queue: Toast[] = [];
|
|
||||||
const listeners = new Set<(queue: Toast[]) => void>();
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function notify(): void {
|
|
||||||
const snapshot = [...queue];
|
|
||||||
listeners.forEach(fn => fn(snapshot));
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss(id: string): void {
|
|
||||||
const timer = timers.get(id);
|
|
||||||
if (timer) { clearTimeout(timer); timers.delete(id); }
|
|
||||||
queue = queue.filter(t => t.id !== id);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(message: LingString, opts: ToastOptions = {}): string {
|
|
||||||
const id = nextId();
|
|
||||||
const duration = opts.duration ?? defaultDuration;
|
|
||||||
|
|
||||||
const toast: Toast = {
|
|
||||||
id,
|
|
||||||
type : opts.type ?? 'info',
|
|
||||||
message,
|
|
||||||
duration,
|
|
||||||
context : opts.context,
|
|
||||||
};
|
|
||||||
|
|
||||||
queue = [...queue, toast];
|
|
||||||
notify();
|
|
||||||
|
|
||||||
// Auto-dismiss si duration > 0
|
|
||||||
if (duration > 0) {
|
|
||||||
timers.set(id, setTimeout(() => dismiss(id), duration));
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear(): void {
|
|
||||||
timers.forEach(t => clearTimeout(t));
|
|
||||||
timers.clear();
|
|
||||||
queue = [];
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn: (queue: Toast[]) => void): () => void {
|
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get queue() { return queue; },
|
|
||||||
show,
|
|
||||||
dismiss,
|
|
||||||
clear,
|
|
||||||
onChange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
// ==============================
|
|
||||||
// UIUX TYPES
|
|
||||||
// ==============================
|
|
||||||
|
|
||||||
import type { LingString } from '@/ling/lib';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// THEME
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type ThemeMode = 'light' | 'dark' | 'auto';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface ThemeConfig {
|
|
||||||
/** Modo inicial. Default: `'auto'`. */
|
|
||||||
mode? : ThemeMode;
|
|
||||||
/**
|
|
||||||
* Clase CSS aplicada a `<html>` en modo dark.
|
|
||||||
* Debe coincidir con la configuración de Tailwind (`darkMode: 'class'`).
|
|
||||||
* Default: `'dark'`.
|
|
||||||
*/
|
|
||||||
darkClass? : string;
|
|
||||||
/**
|
|
||||||
* Variables CSS custom por tema. Se inyectan en `<html>` como `style`.
|
|
||||||
* Útil para temas custom más allá de light/dark.
|
|
||||||
* @example
|
|
||||||
* vars: { light: { '--color-brand': '#3B82F6' }, dark: { '--color-brand': '#60A5FA' } }
|
|
||||||
*/
|
|
||||||
vars? : Partial<Record<ThemeMode | string, Record<string, string>>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeInstance {
|
|
||||||
/** Modo activo resuelto — nunca `'auto'`, siempre `'light'` o `'dark'`. */
|
|
||||||
readonly resolved : 'light' | 'dark';
|
|
||||||
/** Modo configurado — puede ser `'auto'`. */
|
|
||||||
readonly mode : ThemeMode;
|
|
||||||
set : (mode: ThemeMode) => void;
|
|
||||||
toggle : () => void;
|
|
||||||
onChange : (fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// BREAKPOINTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type BreakpointKey = string;
|
|
||||||
|
|
||||||
export interface BreakConfig {
|
|
||||||
/**
|
|
||||||
* Mapa de breakpoints en px. Default: breakpoints de Tailwind v3.
|
|
||||||
* @example { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }
|
|
||||||
*/
|
|
||||||
points?: Record<BreakpointKey, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BreakInstance {
|
|
||||||
/** Breakpoint activo según el ancho actual de la ventana. */
|
|
||||||
readonly current : BreakpointKey;
|
|
||||||
readonly isMobile : boolean; // < md
|
|
||||||
readonly isTablet : boolean; // >= md && < lg
|
|
||||||
readonly isDesktop: boolean; // >= lg
|
|
||||||
/** Devuelve true si el viewport cumple el breakpoint mínimo indicado. */
|
|
||||||
is : (breakpoint: BreakpointKey) => boolean;
|
|
||||||
onChange : (fn: (breakpoint: BreakpointKey) => void) => () => void;
|
|
||||||
/** Desconecta el ResizeObserver y limpia los listeners. */
|
|
||||||
dispose : () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TOAST
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'warn' | 'info';
|
|
||||||
|
|
||||||
export interface ToastOptions {
|
|
||||||
/** Tipo visual del toast. Default: `'info'`. */
|
|
||||||
type? : ToastType;
|
|
||||||
/**
|
|
||||||
* Duración en ms antes de auto-dismiss. `0` = permanente.
|
|
||||||
* Default: `4000`.
|
|
||||||
*/
|
|
||||||
duration?: number;
|
|
||||||
/** Contexto adicional — por ejemplo un código de error. */
|
|
||||||
context? : Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id : string;
|
|
||||||
type : ToastType;
|
|
||||||
message : LingString;
|
|
||||||
duration: number;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToastInstance {
|
|
||||||
/** Cola de toasts activos. */
|
|
||||||
readonly queue : Toast[];
|
|
||||||
show : (message: LingString, opts?: ToastOptions) => string;
|
|
||||||
dismiss : (id: string) => void;
|
|
||||||
clear : () => void;
|
|
||||||
onChange : (fn: (queue: Toast[]) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MODAL
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ModalEntry<P extends Record<string, any> = Record<string, any>> {
|
|
||||||
id : string;
|
|
||||||
props : P;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModalInstance {
|
|
||||||
/** Stack de modales abiertos. El último es el activo. */
|
|
||||||
readonly stack : ModalEntry[];
|
|
||||||
/** Modal activo (último del stack) o `null` si no hay ninguno. */
|
|
||||||
readonly active : ModalEntry | null;
|
|
||||||
open : <P extends Record<string, any> = Record<string, any>>(id: string, props?: P) => void;
|
|
||||||
close : () => void;
|
|
||||||
closeAll : () => void;
|
|
||||||
isOpen : (id: string) => boolean;
|
|
||||||
onChange : (fn: (stack: ModalEntry[]) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MENU
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface MenuInstance {
|
|
||||||
open : (id: string) => void;
|
|
||||||
close : (id: string) => void;
|
|
||||||
toggle : (id: string) => void;
|
|
||||||
isOpen : (id: string) => boolean;
|
|
||||||
closeAll: () => void;
|
|
||||||
onChange: (fn: (openIds: Set<string>) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// UIUX CONFIG & INSTANCE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface UiuxConfig {
|
|
||||||
theme? : ThemeConfig;
|
|
||||||
break? : BreakConfig;
|
|
||||||
toast? : { defaultDuration?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contexto de UI de la aplicación.
|
|
||||||
* Gestiona tema, breakpoints, toasts, modales y menús.
|
|
||||||
*
|
|
||||||
* Todos los módulos son siempre disponibles — no requieren config explícita.
|
|
||||||
* La config solo ajusta los defaults.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* uiux.theme.toggle()
|
|
||||||
* uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
|
|
||||||
* uiux.modal.open('confirm', { title: '¿Eliminar?' })
|
|
||||||
* uiux.menu.toggle('sidebar')
|
|
||||||
* uix.break.isMobile
|
|
||||||
*/
|
|
||||||
export interface Uiux {
|
|
||||||
theme : ThemeInstance;
|
|
||||||
break : BreakInstance;
|
|
||||||
toast : ToastInstance;
|
|
||||||
modal : ModalInstance;
|
|
||||||
menu : MenuInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// REACTIVE UIX
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Versión reactiva de `Uiux` para el singleton de Svelte (`actx.svelte.ts`).
|
|
||||||
*
|
|
||||||
* Idéntica a `Uiux` en métodos y estructura, pero las propiedades de solo
|
|
||||||
* lectura (`resolved`, `mode`, `queue`, `stack`, `active`, `current`,
|
|
||||||
* `isMobile`, `isTablet`, `isDesktop`) están respaldadas por `$state` —
|
|
||||||
* Svelte las observa y re-renderiza automáticamente cuando cambian.
|
|
||||||
*
|
|
||||||
* No añade ni elimina ninguna funcionalidad — solo garantiza reactividad
|
|
||||||
* en las propiedades que los componentes Svelte necesitan leer.
|
|
||||||
*/
|
|
||||||
export interface ReactiveUiux {
|
|
||||||
theme: Omit<ThemeInstance, 'resolved' | 'mode'> & {
|
|
||||||
readonly resolved: 'light' | 'dark'; // $state
|
|
||||||
readonly mode : ThemeMode; // $state
|
|
||||||
};
|
|
||||||
toast: Omit<ToastInstance, 'queue'> & {
|
|
||||||
readonly queue: Toast[]; // $state
|
|
||||||
};
|
|
||||||
modal: Omit<ModalInstance, 'stack' | 'active'> & {
|
|
||||||
readonly stack : ModalEntry[]; // $state
|
|
||||||
readonly active: ModalEntry | null; // $state derivado
|
|
||||||
};
|
|
||||||
menu : MenuInstance; // sin estado reactivo
|
|
||||||
break: Omit<BreakInstance, 'current' | 'isMobile' | 'isTablet' | 'isDesktop'> & {
|
|
||||||
readonly current : string; // $state
|
|
||||||
readonly isMobile : boolean; // derivado de $state
|
|
||||||
readonly isTablet : boolean; // derivado de $state
|
|
||||||
readonly isDesktop: boolean; // derivado de $state
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Reference in new issue