parent
9e6b85d89a
commit
e60e079a26
@ -0,0 +1,282 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { flushSync } from 'svelte';
|
||||
import { visit, type View } from '$uix/test/callout/callout-audit-view';
|
||||
const css = (view: View) => getComputedStyle(view.root());
|
||||
const titles = { neutral: 'Note', affirm: 'Tip', risk: 'Warning', threat: 'Caution' } as const;
|
||||
it('native default Callout is a passive named note with decorative icon and body', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.root();
|
||||
expect(root.tagName).toBe('DIV');
|
||||
expect(root.getAttribute('role')).toBe('note');
|
||||
expect(root.getAttribute('aria-labelledby')).toBe(view.title()!.id);
|
||||
expect(view.title()!.textContent).toBe('Note');
|
||||
expect(view.icon()!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(view.icon()!.querySelector('svg')).not.toBeNull();
|
||||
expect(root.hasAttribute('aria-live')).toBe(false);
|
||||
expect(root.hasAttribute('tabindex')).toBe(false);
|
||||
expect(root.querySelector('h1,h2,h3,h4,h5,h6')).toBeNull();
|
||||
expect(root.getAttribute('data-intent')).toBe('neutral');
|
||||
expect(root.getAttribute('data-color')).toBe('neutral');
|
||||
expect(root.getAttribute('data-probe')).toBe('callout');
|
||||
expect(css(view).display).toBe('grid');
|
||||
await expect.element(page.getByRole('note', { name: 'Note', exact: true })).toBeVisible();
|
||||
await view.capture('default');
|
||||
}));
|
||||
for (const intent of ['neutral', 'affirm', 'risk', 'threat'] as const) {
|
||||
it('native Callout ' + intent + ' resolves title glyph and palette from its intent', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const control = getComputedStyle(view.node('#audit-callout-palette-control'));
|
||||
expect(view.title()!.textContent).toBe(titles[intent]);
|
||||
expect(view.icon()!.querySelector('svg')).not.toBeNull();
|
||||
expect(css(view).backgroundColor).toBe(control.backgroundColor);
|
||||
expect(css(view).borderInlineStartColor).toBe(control.borderInlineStartColor);
|
||||
expect(getComputedStyle(view.title()!).color).toBe(control.color);
|
||||
expect(control.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await view.capture('intent-' + intent);
|
||||
},
|
||||
{ intent }
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const color of ['primary', 'secondary', 'tertiary', 'teal', 'plum'] as const) {
|
||||
it('native neutral Callout ' + color + ' paint override keeps its Note label', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-color')).toBe(color);
|
||||
expect(view.title()!.textContent).toBe('Note');
|
||||
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(css(view).backgroundColor).not.toBe('rgb(255, 255, 255)');
|
||||
await view.capture('color-' + color);
|
||||
},
|
||||
{ color }
|
||||
)
|
||||
);
|
||||
}
|
||||
it('native evaluative Callout intent wins over a donor color override', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-color')).toBe('risk');
|
||||
expect(view.title()!.textContent).toBe('Warning');
|
||||
expect(css(view).backgroundColor).toBe(
|
||||
getComputedStyle(view.node('#audit-callout-palette-control')).backgroundColor
|
||||
);
|
||||
await view.capture('intent-wins');
|
||||
},
|
||||
{ intent: 'risk', color: 'plum' }
|
||||
));
|
||||
it('native Callout raw CSS color seeds a nontransparent custom palette', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().hasAttribute('data-color')).toBe(false);
|
||||
expect(view.root().hasAttribute('data-color-custom')).toBe(true);
|
||||
expect(view.root().style.getPropertyValue('--color-custom')).toBe('rgb(20, 110, 150)');
|
||||
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await view.capture('custom-color');
|
||||
},
|
||||
{ color: 'rgb(20, 110, 150)' }
|
||||
));
|
||||
it('native Callout custom color composes after a terminated inline style', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(css(view).paddingTop).toBe('28px');
|
||||
expect(view.root().style.getPropertyValue('--color-custom')).toBe('rgb(20, 110, 150)');
|
||||
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await view.capture('custom-style-terminated');
|
||||
},
|
||||
{ color: 'rgb(20, 110, 150)', style: '--callout-padding:28px;' }
|
||||
));
|
||||
it('native Callout custom title and icon replace both defaults', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.title()!.textContent).toBe('Important');
|
||||
expect(view.icon()!.textContent).toBe('★');
|
||||
expect(view.icon()!.querySelector('svg')).toBeNull();
|
||||
expect(view.icon()!.getAttribute('aria-hidden')).toBe('true');
|
||||
await view.capture('custom-parts');
|
||||
},
|
||||
{ customTitle: 'Important', customIcon: true, color: 'plum' }
|
||||
));
|
||||
it('native Callout localized titles react to Spanish and back to English', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
flushSync(() => view.uix.langs.setLocale('es'));
|
||||
expect(view.title()!.textContent).toBe('Precaución');
|
||||
await view.capture('spanish');
|
||||
flushSync(() => view.uix.langs.setLocale('en'));
|
||||
expect(view.title()!.textContent).toBe('Caution');
|
||||
},
|
||||
{ intent: 'threat' }
|
||||
));
|
||||
it('native Callout title registry clears on unmount and restores on remount', () =>
|
||||
visit(async (view) => {
|
||||
const originalId = view.title()!.id;
|
||||
flushSync(() => view.controls().title(false));
|
||||
expect(view.title()).toBeNull();
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
|
||||
flushSync(() => view.controls().title(true));
|
||||
expect(view.title()!.id).toBe(originalId);
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBe(originalId);
|
||||
await view.capture('title-remount');
|
||||
}));
|
||||
it('native Callout without Title remains an unnamed note', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.title()).toBeNull();
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
|
||||
expect(view.root().getAttribute('role')).toBe('note');
|
||||
await view.capture('no-title');
|
||||
},
|
||||
{ title: false }
|
||||
));
|
||||
it('native Callout without Icon collapses the empty grid column', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.icon()).toBeNull();
|
||||
expect(css(view).gridTemplateColumns.split(' ').length).toBe(1);
|
||||
expect(getComputedStyle(view.title()!).gridColumnStart).toBe('1');
|
||||
await view.capture('no-icon');
|
||||
},
|
||||
{ icon: false }
|
||||
));
|
||||
it('native Callout empty composition retains a passive surface', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().children.length).toBe(0);
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
|
||||
expect(css(view).gridTemplateColumns.split(' ').length).toBe(1);
|
||||
await view.capture('empty');
|
||||
},
|
||||
{ icon: false, title: false, content: false }
|
||||
));
|
||||
for (const role of ['status', 'alert', 'none'] as const) {
|
||||
it('native Callout explicit ' + role + ' role leaves escalation to the consumer', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('role')).toBe(role === 'none' ? null : role);
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
|
||||
expect(view.root().hasAttribute('aria-live')).toBe(false);
|
||||
await view.capture('role-' + role);
|
||||
},
|
||||
{ role }
|
||||
)
|
||||
);
|
||||
}
|
||||
it('native Callout explicit aria-label names a note without a Title', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await expect
|
||||
.element(page.getByRole('note', { name: 'Consumer note', exact: true }))
|
||||
.toBeVisible();
|
||||
await view.capture('aria-label');
|
||||
},
|
||||
{ title: false, ariaLabel: 'Consumer note' }
|
||||
));
|
||||
it('native Callout presentation mutates on the same root with reactive defaults', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.root();
|
||||
const icon = view.icon();
|
||||
const path = icon!.querySelector('svg')!.innerHTML;
|
||||
flushSync(() => view.controls().intent('risk'));
|
||||
expect(view.root()).toBe(root);
|
||||
expect(view.icon()).toBe(icon);
|
||||
expect(view.icon()!.querySelector('svg')!.innerHTML).not.toBe(path);
|
||||
expect(view.title()!.textContent).toBe('Warning');
|
||||
flushSync(() => {
|
||||
view.controls().intent('neutral');
|
||||
view.controls().color('teal');
|
||||
view.controls().customTitle('Information');
|
||||
});
|
||||
expect(view.title()!.textContent).toBe('Information');
|
||||
expect(root.getAttribute('data-color')).toBe('teal');
|
||||
await view.capture('reactive');
|
||||
}));
|
||||
it('native Callout role changes preserve Title registration when returning to note', () =>
|
||||
visit(async (view) => {
|
||||
const titleId = view.title()!.id;
|
||||
flushSync(() => view.controls().role('status'));
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
|
||||
flushSync(() => view.controls().role('note'));
|
||||
expect(view.root().getAttribute('aria-labelledby')).toBe(titleId);
|
||||
await view.capture('role-roundtrip');
|
||||
}));
|
||||
it('native Callout peers own independent title IDs and contexts', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const peer = view.node('#audit-callout-peer');
|
||||
expect(peer.getAttribute('aria-labelledby')).not.toBe(
|
||||
view.root().getAttribute('aria-labelledby')
|
||||
);
|
||||
expect(peer.querySelector('[data-callout-title]')!.textContent).toBe('Tip');
|
||||
await view.capture('peers');
|
||||
},
|
||||
{ peer: true }
|
||||
));
|
||||
it('native nested Callout owns a separate intent name and spacing', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const nested = view.node('#audit-callout-nested');
|
||||
expect(nested.querySelector('[data-callout-title]')!.textContent).toBe('Warning');
|
||||
expect(nested.getAttribute('aria-labelledby')).not.toBe(
|
||||
view.root().getAttribute('aria-labelledby')
|
||||
);
|
||||
expect(getComputedStyle(nested).marginBlockStart).toBe('8px');
|
||||
expect(view.title()!.textContent).toBe('Note');
|
||||
await view.capture('nested');
|
||||
},
|
||||
{ nested: true }
|
||||
));
|
||||
it('native Callout logical accent and grid mirror in RTL', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(css(view).borderRightWidth).toBe('3px');
|
||||
expect(css(view).borderLeftWidth).toBe('0px');
|
||||
expect(view.icon()!.getBoundingClientRect().left).toBeGreaterThan(
|
||||
view.title()!.getBoundingClientRect().right
|
||||
);
|
||||
await view.capture('rtl');
|
||||
},
|
||||
{ dir: 'rtl' }
|
||||
));
|
||||
it('native Callout wraps within a 350px viewport', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getBoundingClientRect().right).toBeLessThanOrEqual(350);
|
||||
expect(view.node('#audit-callout-content').scrollWidth).toBeLessThanOrEqual(
|
||||
view.node('#audit-callout-content').clientWidth
|
||||
);
|
||||
await view.capture('narrow');
|
||||
},
|
||||
{ viewportWidth: 350 }
|
||||
));
|
||||
it('native Callout token overrides apply to typography spacing and accent', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(css(view).paddingTop).toBe('24px');
|
||||
expect(css(view).fontSize).toBe('20px');
|
||||
expect(css(view).borderInlineStartWidth).toBe('5px');
|
||||
expect(css(view).borderRadius).toBe('12px');
|
||||
await view.capture('tokens');
|
||||
},
|
||||
{
|
||||
style:
|
||||
'--callout-padding:24px;--callout-font-size:20px;--callout-accent-width:5px;--callout-radius:12px;'
|
||||
}
|
||||
));
|
||||
it('native passive Callout adds no Tab stop between adjacent controls', () =>
|
||||
visit(async (view) => {
|
||||
await userEvent.click(page.getByRole('button', { name: 'Before', exact: true }));
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(view.node('#audit-callout-after'));
|
||||
await view.capture('tab');
|
||||
}));
|
||||
it('native Callout reduced motion remains a static note', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(css(view).animationName).toBe('none');
|
||||
expect(css(view).transitionDuration).toBe('0s');
|
||||
await view.capture('reduce');
|
||||
},
|
||||
{ reduce: true }
|
||||
));
|
||||
@ -0,0 +1,137 @@
|
||||
<script module lang="ts">
|
||||
import type { CalloutProps } from '$uix/eidos/components/callout';
|
||||
export type Config = {
|
||||
intent?: CalloutProps['intent'];
|
||||
color?: CalloutProps['color'];
|
||||
role?: CalloutProps['role'];
|
||||
style?: string;
|
||||
title?: boolean;
|
||||
icon?: boolean;
|
||||
content?: boolean;
|
||||
customTitle?: string;
|
||||
customIcon?: boolean;
|
||||
ariaLabel?: string;
|
||||
nested?: boolean;
|
||||
peer?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
reduce?: boolean;
|
||||
viewportWidth?: number;
|
||||
};
|
||||
export type Controls = {
|
||||
intent: (next: CalloutProps['intent']) => void;
|
||||
color: (next: CalloutProps['color']) => void;
|
||||
role: (next: CalloutProps['role']) => void;
|
||||
title: (next: boolean) => void;
|
||||
icon: (next: boolean) => void;
|
||||
customTitle: (next: string | undefined) => void;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { setActiveUix, type ActiveUix } from '$active-uix';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import Callout from '$uix/eidos/components/callout';
|
||||
let {
|
||||
uix,
|
||||
initial = {},
|
||||
onControls
|
||||
}: {
|
||||
uix: ActiveUix;
|
||||
initial?: Config;
|
||||
onControls: (controls: Controls) => void;
|
||||
} = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
setActiveUix(uix);
|
||||
Soma.create();
|
||||
const eidos = ActiveEidos.create({ applyDom: false });
|
||||
onDestroy(() => eidos.dispose());
|
||||
// svelte-ignore state_referenced_locally
|
||||
let intent = $state(initial.intent);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state(initial.color);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let role = $state(initial.role);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(initial.title ?? true);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let icon = $state(initial.icon ?? true);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let customTitle = $state(initial.customTitle);
|
||||
const effectiveColor = $derived(intent && intent !== 'neutral' ? intent : (color ?? 'neutral'));
|
||||
onMount(() =>
|
||||
onControls({
|
||||
intent: (next) => (intent = next),
|
||||
color: (next) => (color = next),
|
||||
role: (next) => (role = next),
|
||||
title: (next) => (title = next),
|
||||
icon: (next) => (icon = next),
|
||||
customTitle: (next) => (customTitle = next)
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet titleChildren()}{customTitle}{/snippet}
|
||||
{#snippet iconChildren()}<span data-audit-glyph>★</span>{/snippet}
|
||||
<div
|
||||
id="audit-callout-stage"
|
||||
dir={initial.dir ?? 'ltr'}
|
||||
data-motion={initial.reduce ? 'reduce' : undefined}
|
||||
style="padding:24px;font:16px Arial,sans-serif;color:rgb(10,20,30)"
|
||||
>
|
||||
<div style="max-inline-size:520px">
|
||||
<p id="audit-callout-external">Maintenance window</p>
|
||||
<button id="audit-callout-before" type="button">Before</button>
|
||||
<Callout
|
||||
id="audit-callout"
|
||||
class="audit-callout"
|
||||
data-probe="callout"
|
||||
{intent}
|
||||
{color}
|
||||
{role}
|
||||
style={initial.style}
|
||||
aria-label={initial.ariaLabel}
|
||||
>
|
||||
{#if icon}<Callout.Icon
|
||||
class="audit-icon"
|
||||
data-probe="icon"
|
||||
children={initial.customIcon ? iconChildren : undefined}
|
||||
/>{/if}
|
||||
{#if title}<Callout.Title
|
||||
class="audit-title"
|
||||
data-probe="title"
|
||||
children={customTitle === undefined ? undefined : titleChildren}
|
||||
/>{/if}
|
||||
{#if initial.content !== false}
|
||||
<Callout.Content id="audit-callout-content" class="audit-content" data-probe="content">
|
||||
Ancillary information about the maintenance window. Read the documentation before
|
||||
proceeding.
|
||||
{#if initial.nested}
|
||||
<Callout id="audit-callout-nested" intent="risk">
|
||||
<Callout.Icon /><Callout.Title />
|
||||
<Callout.Content>Nested warning</Callout.Content>
|
||||
</Callout>
|
||||
{/if}
|
||||
</Callout.Content>
|
||||
{/if}
|
||||
</Callout>
|
||||
{#if initial.peer}<Callout id="audit-callout-peer" intent="affirm"
|
||||
><Callout.Title /><Callout.Content>Peer</Callout.Content></Callout
|
||||
>{/if}
|
||||
<button id="audit-callout-after" type="button">After</button>
|
||||
<div
|
||||
id="audit-callout-palette-control"
|
||||
aria-hidden="true"
|
||||
style={'block-size:16px;background:var(--color-' +
|
||||
effectiveColor +
|
||||
'-track);color:var(--color-' +
|
||||
effectiveColor +
|
||||
'-text);border:3px solid var(--color-' +
|
||||
effectiveColor +
|
||||
'-solid)'}
|
||||
>
|
||||
palette
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,56 @@
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { createActiveUix } from '$active-uix';
|
||||
import '$uix/eidos/index.css';
|
||||
import Harness, { type Config, type Controls } from './callout-audit-harness.svelte';
|
||||
export function renderCallout(initial: Config = {}) {
|
||||
const uix = createActiveUix({
|
||||
langs: { schema: {}, defaultLocale: 'en' },
|
||||
prefs: { storage: false },
|
||||
projectPrefs: false
|
||||
});
|
||||
const target = document.createElement('div');
|
||||
document.body.append(target);
|
||||
let controls!: Controls;
|
||||
const component = mount(Harness, {
|
||||
target,
|
||||
props: { uix, initial, onControls: (value) => (controls = value) }
|
||||
});
|
||||
flushSync();
|
||||
const ready = async () =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
return {
|
||||
uix,
|
||||
target,
|
||||
controls: () => controls,
|
||||
root: () => target.querySelector<HTMLElement>('#audit-callout')!,
|
||||
title: () => target.querySelector<HTMLElement>('#audit-callout > [data-callout-title]'),
|
||||
icon: () => target.querySelector<HTMLElement>('#audit-callout > [data-callout-icon]'),
|
||||
node: (selector: string) => target.querySelector<HTMLElement>(selector)!,
|
||||
ready,
|
||||
async capture(name: string) {
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await ready();
|
||||
await page.screenshot({ path: 'tmp/callout-' + name + '.png' });
|
||||
}
|
||||
},
|
||||
async dispose() {
|
||||
await unmount(component);
|
||||
uix.dispose();
|
||||
target.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
export type View = ReturnType<typeof renderCallout>;
|
||||
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
|
||||
await page.viewport(initial.viewportWidth ?? 1100, 900);
|
||||
const view = renderCallout(initial);
|
||||
try {
|
||||
await view.ready();
|
||||
await fn(view);
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue