diff --git a/src/uix/soma/components/anchor-nav/anchor-nav-provider.svelte.test.ts b/src/uix/soma/components/anchor-nav/anchor-nav-provider.svelte.test.ts new file mode 100644 index 000000000..bed5eab5a --- /dev/null +++ b/src/uix/soma/components/anchor-nav/anchor-nav-provider.svelte.test.ts @@ -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(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) => + 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(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(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(); + }); +}); diff --git a/src/uix/soma/components/button/button-provider.svelte.test.ts b/src/uix/soma/components/button/button-provider.svelte.test.ts new file mode 100644 index 000000000..eabd4342b --- /dev/null +++ b/src/uix/soma/components/button/button-provider.svelte.test.ts @@ -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(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) => + 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(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(); + }); +}); diff --git a/src/uix/soma/components/textarea/textarea-provider.svelte.test.ts b/src/uix/soma/components/textarea/textarea-provider.svelte.test.ts new file mode 100644 index 000000000..1fe4afc2f --- /dev/null +++ b/src/uix/soma/components/textarea/textarea-provider.svelte.test.ts @@ -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(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) => + 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(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(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(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(); + }); +});