13 providers de soma no tenían test, y el guard que dos documentos afirmaban que lo impedía (`NO_MISSING_PROVIDER_TESTS`) no existe. Van los tres primeros, cada uno apuntado a la doctrina que el provider codifica y que hoy no cubría nadie — no a su superficie trivial. - **`button`** (7): la resolución `color`↔`intent`. Un intent evaluativo gana sobre la jerarquía Y suprime el marcador custom (la estética no puede contradecir a la semántica). Cubre también que `onPress` devuelve el intent como contexto VISUAL, no como carga del evento — el contacto no lleva intent (libro cap. 22 §11). - **`anchor-nav`** (6): la escalera de `activeId` (primero en banda → último pasado → primero en orden DOM) y el ref-counting que impide que un remount keyed tire un target aún referenciado por su hermano. - **`textarea`** (9): que el warn de overflow se emita SOLO en la transición —la proyección es `untilFix` y persiste, re-emitir sólo la reemplazaría—, su retracción al bajar del tope, y la matriz completa de `submitOn` (`enter` / `mod+enter` / `false`). Dos fallaron a la primera y en ambos el código tenía razón y la aserción no: `data-color-custom` es flag de presencia (el color viaja por la var CSS `--color-custom`, que estampa eidos), y `clearTarget` no dispara sin la parte `input` montada porque la retracción apunta a ese elemento. Arnés: el patrón ya establecido en los 87 tests existentes. Donde hace falta `langs`, el stub devuelve el fallback embebido del idlangref en vez de copia inventada — es lo que hace el resolver real cuando no encuentra entrada. Quedan 10: `chat-list`, `chronos`, `field-langs`, `gradient-builder`, `gradient-picker`, `month-grid`, `nav-tree`, `password-field`, `sidebar`, `year-grid`. Al cerrar la lista, escribir el guard que la mantenga cerrada. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
f8e35b8fd8
commit
feb4a84241
@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { AnchorNavLinkProvider, AnchorNavProvider } from './anchor-nav-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
// Mirrors the real resolver's miss path: an idlangref carries its own
|
||||
// fallback after the pipe, so no invented copy enters the assertions.
|
||||
ts: (ref: string) => ref.split('|')[1] ?? ref
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(AnchorNavProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function navOpts(el: HTMLElement, ariaLabel?: string) {
|
||||
return {
|
||||
id: state('anchor-nav'),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
topOffset: state(0),
|
||||
root: state(null),
|
||||
ariaLabel: state(ariaLabel)
|
||||
} as never;
|
||||
}
|
||||
|
||||
function linkOpts(el: HTMLElement, href: string, level = 2) {
|
||||
return {
|
||||
id: state(`link-${href}`),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
href: state(href),
|
||||
level: state(level)
|
||||
} as never;
|
||||
}
|
||||
|
||||
function mountNav(ariaLabel?: string) {
|
||||
const el = document.createElement('nav');
|
||||
document.body.append(el);
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
AnchorNavProvider.create(navOpts(el, ariaLabel))
|
||||
);
|
||||
vi.spyOn(AnchorNavProvider, 'get').mockReturnValue(result);
|
||||
return { el, provider: result, cleanup };
|
||||
}
|
||||
|
||||
describe('AnchorNavProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('falls back to the localized landmark name when no ariaLabel is given', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const explicit = mountNav('Contents');
|
||||
expect(explicit.provider.resolvedAriaLabel.current).toBe('Contents');
|
||||
explicit.cleanup();
|
||||
|
||||
const implicit = mountNav();
|
||||
expect(implicit.provider.resolvedAriaLabel.current).toBe('On this page');
|
||||
implicit.cleanup();
|
||||
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('resolves the target id from the href fragment', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const nav = mountNav();
|
||||
const el = document.createElement('a');
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
AnchorNavLinkProvider.create(linkOpts(el, '#usage'))
|
||||
);
|
||||
expect(result.targetId).toBe('usage');
|
||||
|
||||
cleanup();
|
||||
nav.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('falls back to the first registered target before any section intersects', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const nav = mountNav();
|
||||
|
||||
// No IntersectionObserver has fired: nothing is in-band and nothing is
|
||||
// above, so the ladder bottoms out at the first section in DOM order.
|
||||
nav.provider.registerLink('intro');
|
||||
nav.provider.registerLink('usage');
|
||||
nav.provider.registerLink('api');
|
||||
expect(nav.provider.activeId).toBe('intro');
|
||||
|
||||
nav.provider.unregisterLink('intro');
|
||||
expect(nav.provider.activeId).toBe('usage');
|
||||
|
||||
nav.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('ref-counts duplicate links so one unmount does not drop a live target', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const nav = mountNav();
|
||||
|
||||
// Two TOC entries pointing at the same section — or a keyed remount
|
||||
// where the new link registers before the old one tears down.
|
||||
nav.provider.registerLink('intro');
|
||||
nav.provider.registerLink('intro');
|
||||
nav.provider.registerLink('usage');
|
||||
|
||||
nav.provider.unregisterLink('intro');
|
||||
// Still referenced by the surviving link, so it stays observed and
|
||||
// remains the first target.
|
||||
expect(nav.provider.activeId).toBe('intro');
|
||||
|
||||
nav.provider.unregisterLink('intro');
|
||||
expect(nav.provider.activeId).toBe('usage');
|
||||
|
||||
nav.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('ignores an empty target id', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const nav = mountNav();
|
||||
|
||||
// A link with a bare `#` href must not register a nameless target.
|
||||
nav.provider.registerLink('');
|
||||
expect(nav.provider.activeId).toBe(null);
|
||||
|
||||
nav.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('marks only the link whose target is current', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const nav = mountNav();
|
||||
const first = document.createElement('a');
|
||||
const second = document.createElement('a');
|
||||
document.body.append(first, second);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => ({
|
||||
intro: AnchorNavLinkProvider.create(linkOpts(first, '#intro')),
|
||||
usage: AnchorNavLinkProvider.create(linkOpts(second, '#usage'))
|
||||
}));
|
||||
|
||||
// Links register from their constructor (A30), so `intro` is first in
|
||||
// DOM order and wins the fallback.
|
||||
await tick();
|
||||
expect(nav.provider.activeId).toBe('intro');
|
||||
expect(result.intro.active).toBe(true);
|
||||
expect(result.usage.active).toBe(false);
|
||||
|
||||
cleanup();
|
||||
nav.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,199 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { FieldProvider } from '../field';
|
||||
import { ButtonProvider } from './button-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
async function flushRuntimeTrigger() {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
// No enclosing Field in these cases — the provider reads it optionally.
|
||||
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
type ButtonOptsOverrides = {
|
||||
intent?: string;
|
||||
color?: string;
|
||||
colorCustom?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onPress?: (payload: { intent: string }) => void;
|
||||
};
|
||||
|
||||
function buttonOpts(el: HTMLElement, overrides: ButtonOptsOverrides = {}) {
|
||||
return {
|
||||
id: state('button-root'),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
intent: state(overrides.intent),
|
||||
color: state(overrides.color),
|
||||
colorCustom: state(overrides.colorCustom),
|
||||
type: state('button'),
|
||||
disabled: state(overrides.disabled ?? false),
|
||||
loading: state(overrides.loading ?? false),
|
||||
loadingText: state(undefined),
|
||||
spinnerPlacement: state(undefined),
|
||||
'aria-label': state(undefined),
|
||||
onPress: state(overrides.onPress)
|
||||
} as never;
|
||||
}
|
||||
|
||||
function mountButton(overrides: ButtonOptsOverrides = {}) {
|
||||
const el = document.createElement('button');
|
||||
document.body.append(el);
|
||||
const opts = buttonOpts(el, overrides);
|
||||
const { result, cleanup } = withEffectRoot(() => ButtonProvider.create(opts));
|
||||
return { el, provider: result, cleanup };
|
||||
}
|
||||
|
||||
describe('ButtonProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('projects identity and defaults the visual colour to primary', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { el, provider, cleanup } = mountButton();
|
||||
|
||||
expect(provider.props).toMatchObject({ id: 'button-root', 'data-button': '' });
|
||||
|
||||
await tick();
|
||||
// Button's default is PROMOTED — an action is normally the focal point
|
||||
// of its surface, so absent any prop the visual resolves to 'primary'.
|
||||
expect(el.getAttribute('data-color')).toBe('primary');
|
||||
expect(el.hasAttribute('data-disabled')).toBe(false);
|
||||
expect(el.hasAttribute('data-loading')).toBe(false);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('lets an evaluative intent WIN over a hierarchy colour override', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
// Doctrine: aesthetic must not contradict semantic. `color` asks for
|
||||
// secondary, but the intent is evaluative — the intent takes the visual.
|
||||
const { el, cleanup } = mountButton({ intent: 'threat', color: 'secondary' });
|
||||
|
||||
await tick();
|
||||
expect(el.getAttribute('data-color')).toBe('threat');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('routes a custom colour through data-color-custom and drops data-color', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
// The two markers are mutually exclusive: no canonical role may leak
|
||||
// into the DOM alongside the custom seed.
|
||||
const { el, cleanup } = mountButton({ colorCustom: '#3b82f6' });
|
||||
|
||||
await tick();
|
||||
expect(el.hasAttribute('data-color')).toBe(false);
|
||||
// `data-color-custom` is a PRESENCE flag, not a carrier: the morfo emits
|
||||
// '' from the truthy prop. The colour itself reaches CSS through the
|
||||
// `--color-custom` seed, which eidos stamps — soma never handles it.
|
||||
expect(el.getAttribute('data-color-custom')).toBe('');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('suppresses the custom colour when the intent is evaluative', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
// Same doctrine as above, applied to the custom path: an evaluative
|
||||
// intent wins the visual, so it suppresses the custom marker too.
|
||||
const { el, cleanup } = mountButton({ intent: 'risk', colorCustom: '#3b82f6' });
|
||||
|
||||
await tick();
|
||||
expect(el.getAttribute('data-color')).toBe('risk');
|
||||
expect(el.hasAttribute('data-color-custom')).toBe(false);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('fires onPress with the visual intent echoed back', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const onPress = vi.fn();
|
||||
const { el, provider, cleanup } = mountButton({ intent: 'threat', onPress });
|
||||
|
||||
provider.onclick({ currentTarget: el } as never);
|
||||
await flushRuntimeTrigger();
|
||||
|
||||
// The echoed intent is VISUAL context for analytics — the contact
|
||||
// event itself carries no intent (book cap. 22 §11).
|
||||
expect(onPress).toHaveBeenCalledWith({ intent: 'threat' });
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('swallows the press while disabled or loading', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
|
||||
const disabled = mountButton({ disabled: true, onPress: vi.fn() });
|
||||
disabled.provider.onclick({ currentTarget: disabled.el } as never);
|
||||
await flushRuntimeTrigger();
|
||||
expect(disabled.provider.props.disabled).toBe(true);
|
||||
expect(disabled.el.getAttribute('data-disabled')).toBe('');
|
||||
|
||||
const loading = mountButton({ loading: true, onPress: vi.fn() });
|
||||
loading.provider.onclick({ currentTarget: loading.el } as never);
|
||||
await flushRuntimeTrigger();
|
||||
expect(loading.el.getAttribute('data-loading')).toBe('');
|
||||
|
||||
disabled.cleanup();
|
||||
loading.cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('inherits disabled from an enclosing Field', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
// The Button is not disabled itself; the Field around it is.
|
||||
vi.spyOn(FieldProvider, 'get').mockReturnValue({
|
||||
isDisabled: true
|
||||
} as unknown as FieldProvider);
|
||||
|
||||
const { el, provider, cleanup } = mountButton();
|
||||
|
||||
await tick();
|
||||
expect(provider.isDisabled).toBe(true);
|
||||
expect(el.getAttribute('data-disabled')).toBe('');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,286 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { FieldProvider } from '../field';
|
||||
import { TextAreaInputProvider, TextAreaProvider } from './textarea-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
// An idlangref carries its own fallback after the pipe — mirrors the
|
||||
// real resolver's miss path instead of inventing copy.
|
||||
ts: (ref: string) => ref.split('|')[1] ?? ref,
|
||||
t: (ref: string) => ref.split('|')[1] ?? ref
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
|
||||
vi.spyOn(TextAreaProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
type Overrides = {
|
||||
value?: string;
|
||||
maxLength?: number;
|
||||
autosize?: boolean;
|
||||
resize?: string;
|
||||
submitOn?: string | false;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onValueChange?: (v: string) => void;
|
||||
onValueCommit?: (v: string) => void;
|
||||
};
|
||||
|
||||
function mountTextArea(overrides: Overrides = {}) {
|
||||
const el = document.createElement('div');
|
||||
document.body.append(el);
|
||||
const opts = {
|
||||
id: state('textarea-root'),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
inputId: state('textarea-input'),
|
||||
value: state(overrides.value ?? ''),
|
||||
rows: state(3),
|
||||
autosize: state(overrides.autosize ?? false),
|
||||
minRows: state(1),
|
||||
maxRows: state(undefined),
|
||||
resize: state(overrides.resize),
|
||||
submitOn: state(overrides.submitOn ?? false),
|
||||
maxLength: state(overrides.maxLength),
|
||||
disabled: state(overrides.disabled ?? false),
|
||||
readonly: state(overrides.readonly ?? false),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
name: state(undefined),
|
||||
placeholder: state(undefined),
|
||||
ariaLabel: state(undefined),
|
||||
onValueChange: state(overrides.onValueChange),
|
||||
onValueCommit: state(overrides.onValueCommit)
|
||||
} as never;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => TextAreaProvider.create(opts));
|
||||
vi.spyOn(TextAreaProvider, 'require').mockReturnValue(result);
|
||||
return { el, provider: result, opts: opts as never as { value: { current: string } }, cleanup };
|
||||
}
|
||||
|
||||
describe('TextAreaProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('clamps to maxLength and warns only on the TRANSITION into overflow', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { provider, opts, cleanup } = mountTextArea({ maxLength: 5 });
|
||||
const trigger = vi.spyOn(provider.runtime, 'trigger');
|
||||
|
||||
provider.commit('abcdefgh');
|
||||
expect(opts.value.current).toBe('abcde');
|
||||
expect(provider.isOverflow).toBe(true);
|
||||
expect(trigger).toHaveBeenCalledTimes(1);
|
||||
expect(trigger.mock.calls[0]![0]).toBe('signal-warn-count-overflow');
|
||||
|
||||
// Still over the cap — the `untilFix` projection persists, so re-emitting
|
||||
// would only replace it for no gain.
|
||||
provider.commit('abcdefghij');
|
||||
expect(trigger).toHaveBeenCalledTimes(1);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('clears the persistent warn when the value drops back under the cap', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { provider, cleanup } = mountTextArea({ maxLength: 5 });
|
||||
|
||||
// The retraction targets the INPUT element, so the part must be mounted
|
||||
// for `partRef('input')` to resolve — without it there is nothing to
|
||||
// clear and the provider correctly does nothing.
|
||||
const inputEl = document.createElement('textarea');
|
||||
document.body.append(inputEl);
|
||||
const input = withEffectRoot(() =>
|
||||
TextAreaInputProvider.create({
|
||||
id: state('textarea-input'),
|
||||
ref: state<HTMLElement | null>(inputEl)
|
||||
} as never)
|
||||
);
|
||||
|
||||
provider.commit('abcdefgh');
|
||||
expect(provider.isOverflow).toBe(true);
|
||||
|
||||
const clearTarget = vi.spyOn(provider.runtime, 'clearTarget');
|
||||
provider.commit('abc');
|
||||
expect(provider.isOverflow).toBe(false);
|
||||
// The warn is `untilFix`, so leaving overflow must actively retract it.
|
||||
expect(clearTarget).toHaveBeenCalledWith(inputEl);
|
||||
|
||||
input.cleanup();
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('refuses to commit while disabled or readonly', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
|
||||
const disabled = mountTextArea({ disabled: true, value: 'kept' });
|
||||
disabled.provider.commit('changed');
|
||||
expect(disabled.opts.value.current).toBe('kept');
|
||||
disabled.cleanup();
|
||||
|
||||
const readonly = mountTextArea({ readonly: true, value: 'kept' });
|
||||
readonly.provider.commit('changed');
|
||||
expect(readonly.opts.value.current).toBe('kept');
|
||||
readonly.cleanup();
|
||||
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('suppresses the resize handle while autosize owns the height', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
|
||||
// Browser resize would fight the scrollHeight measurement.
|
||||
const auto = mountTextArea({ autosize: true, resize: 'both' });
|
||||
expect(auto.provider.resolvedResize).toBe('none');
|
||||
auto.cleanup();
|
||||
|
||||
const manual = mountTextArea({ resize: 'both' });
|
||||
expect(manual.provider.resolvedResize).toBe('both');
|
||||
manual.cleanup();
|
||||
|
||||
const fallback = mountTextArea();
|
||||
expect(fallback.provider.resolvedResize).toBe('vertical');
|
||||
fallback.cleanup();
|
||||
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('notifies onValueChange only when the clamped value actually moves', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const onValueChange = vi.fn();
|
||||
const { provider, cleanup } = mountTextArea({ value: 'same', onValueChange });
|
||||
|
||||
provider.commit('same');
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
|
||||
provider.commit('other');
|
||||
expect(onValueChange).toHaveBeenCalledExactlyOnceWith('other');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('clears the value, drops overflow and refocuses the input', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const onValueChange = vi.fn();
|
||||
const { provider, opts, cleanup } = mountTextArea({ maxLength: 3, onValueChange });
|
||||
|
||||
provider.commit('abcdef');
|
||||
expect(provider.isOverflow).toBe(true);
|
||||
|
||||
provider.clear();
|
||||
expect(opts.value.current).toBe('');
|
||||
expect(provider.isOverflow).toBe(false);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith('');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextAreaInputProvider — submit modes', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function mountInput(submitOn: string | false, onValueCommit = vi.fn()) {
|
||||
const parent = mountTextArea({ submitOn, value: 'draft', onValueCommit });
|
||||
const el = document.createElement('textarea');
|
||||
document.body.append(el);
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
TextAreaInputProvider.create({
|
||||
id: state('textarea-input'),
|
||||
ref: state<HTMLElement | null>(el)
|
||||
} as never)
|
||||
);
|
||||
return { input: result, onValueCommit, cleanup: () => (cleanup(), parent.cleanup()) };
|
||||
}
|
||||
|
||||
function enter(mods: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } = {}) {
|
||||
return {
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...mods
|
||||
} as never;
|
||||
}
|
||||
|
||||
it('submits on bare Enter only in `enter` mode', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { input, onValueCommit, cleanup } = mountInput('enter');
|
||||
|
||||
input.handleKeydown(enter());
|
||||
expect(onValueCommit).toHaveBeenCalledWith('draft');
|
||||
|
||||
// A newline must stay a newline when the user asks for one.
|
||||
onValueCommit.mockClear();
|
||||
input.handleKeydown(enter({ shiftKey: true }));
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('submits on mod+Enter regardless of shift in `mod+enter` mode', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { input, onValueCommit, cleanup } = mountInput('mod+enter');
|
||||
|
||||
input.handleKeydown(enter());
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
|
||||
input.handleKeydown(enter({ metaKey: true }));
|
||||
expect(onValueCommit).toHaveBeenCalledWith('draft');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('never submits when submitOn is false', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { input, onValueCommit, cleanup } = mountInput(false);
|
||||
|
||||
input.handleKeydown(enter());
|
||||
input.handleKeydown(enter({ metaKey: true }));
|
||||
input.handleKeydown(enter({ shiftKey: true }));
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue