parent
013a30e017
commit
d903ea8a89
@ -0,0 +1,225 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { visit } from '../../../test/avatar/avatar-audit-view';
|
||||
|
||||
const validImage =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="16" height="16"%3E%3Crect width="16" height="16" fill="red"/%3E%3C/svg%3E';
|
||||
|
||||
it('native default Avatar is a passive span with visible fallback', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.root();
|
||||
expect(root.tagName).toBe('SPAN');
|
||||
expect(root.getAttribute('data-status')).toBe('idle');
|
||||
expect(root.getAttribute('data-size')).toBe('md');
|
||||
expect(root.getAttribute('data-variant')).toBe('soft');
|
||||
expect(root.getAttribute('data-color')).toBe('neutral');
|
||||
expect(view.fallback()?.textContent).toBe('AL');
|
||||
expect(view.image()).toBeNull();
|
||||
await view.capture('default');
|
||||
}));
|
||||
|
||||
it('native solid avatar resolves a colored fill', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-variant')).toBe('solid');
|
||||
expect(view.root().getAttribute('data-color')).toBe('teal');
|
||||
expect(getComputedStyle(view.root()).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await view.capture('solid');
|
||||
},
|
||||
{ variant: 'solid', color: 'teal' }
|
||||
));
|
||||
|
||||
it('native outline avatar paints a border and square radius', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
const css = getComputedStyle(view.root());
|
||||
expect(view.root().getAttribute('data-radius')).toBe('none');
|
||||
expect(css.borderStyle).toBe('solid');
|
||||
expect(css.borderRadius).toBe('0px');
|
||||
await view.capture('outline-square');
|
||||
},
|
||||
{ variant: 'outline', radius: 'none', color: 'risk' }
|
||||
));
|
||||
|
||||
it('native xs avatar resolves the small size token', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-size')).toBe('xs');
|
||||
expect(view.root().getBoundingClientRect().width).toBeGreaterThan(0);
|
||||
await view.capture('xs');
|
||||
},
|
||||
{ size: 'xs' }
|
||||
));
|
||||
|
||||
it('native xxl avatar resolves a larger visual scale', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-size')).toBe('xxl');
|
||||
expect(view.root().getBoundingClientRect().width).toBeGreaterThan(60);
|
||||
await view.capture('xxl');
|
||||
},
|
||||
{ size: 'xxl' }
|
||||
));
|
||||
|
||||
it('native solid ring and width decorate the same root', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-ring')).toBe('solid');
|
||||
expect(view.root().getAttribute('data-ring-width')).toBe('lg');
|
||||
expect(getComputedStyle(view.root()).boxShadow).not.toBe('none');
|
||||
await view.capture('ring-solid');
|
||||
},
|
||||
{ ring: true, ringWidth: 'lg', ringColor: 'affirm' }
|
||||
));
|
||||
|
||||
it('native soft ring accepts a custom ring color', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().getAttribute('data-ring')).toBe('soft');
|
||||
expect(view.root().hasAttribute('data-ring-color-custom')).toBe(true);
|
||||
expect(view.root().style.getPropertyValue('--_avatar-ring-color-custom').trim()).toBe(
|
||||
'#123456'
|
||||
);
|
||||
await view.capture('ring-soft');
|
||||
},
|
||||
{ ring: 'soft', ringColor: '#123456' }
|
||||
));
|
||||
|
||||
it('native raw CSS color uses the custom palette bridge', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().hasAttribute('data-color-custom')).toBe(true);
|
||||
expect(view.root().style.getPropertyValue('--color-custom').trim()).toBe('#123456');
|
||||
await view.capture('custom-color');
|
||||
},
|
||||
{ color: '#123456', variant: 'solid' }
|
||||
));
|
||||
|
||||
it('native dot badge is decorative and anchored to a corner', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.badge()?.getAttribute('data-dot')).toBe('');
|
||||
expect(view.badge()?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(view.badge()?.getAttribute('data-position')).toBe('top-end');
|
||||
await view.capture('badge-dot');
|
||||
},
|
||||
{ badge: 'dot', badgePosition: 'top-end' }
|
||||
));
|
||||
|
||||
it('native counter badge keeps text and consumer color', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.badge()?.textContent).toBe('9+');
|
||||
expect(view.badge()?.getAttribute('data-color')).toBe('risk');
|
||||
expect(view.badge()?.hasAttribute('data-dot')).toBe(false);
|
||||
await view.capture('badge-count');
|
||||
},
|
||||
{ badge: 'count' }
|
||||
));
|
||||
|
||||
it('native consumer attributes and style reach the Avatar root', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.root().classList.contains('audit-avatar')).toBe(true);
|
||||
expect(view.root().getAttribute('data-probe')).toBe('avatar');
|
||||
expect(view.root().getAttribute('title')).toBe('Audit Avatar');
|
||||
expect(getComputedStyle(view.root()).letterSpacing).toBe('2px');
|
||||
await view.capture('style');
|
||||
},
|
||||
{ style: 'letter-spacing:2px' }
|
||||
));
|
||||
|
||||
it('native reactive size variant and color update the same root', () =>
|
||||
visit(async (view) => {
|
||||
const root = view.root();
|
||||
view.controls().size('lg');
|
||||
view.controls().variant('solid');
|
||||
view.controls().color('risk');
|
||||
await view.ready();
|
||||
expect(view.root()).toBe(root);
|
||||
expect(root.getAttribute('data-size')).toBe('lg');
|
||||
expect(root.getAttribute('data-variant')).toBe('solid');
|
||||
expect(root.getAttribute('data-color')).toBe('risk');
|
||||
await view.capture('reactive');
|
||||
}));
|
||||
|
||||
it('native root delay hides fallback while loading', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
view.controls().status('loading');
|
||||
await view.ready();
|
||||
expect(view.root().getAttribute('data-status')).toBe('loading');
|
||||
expect(view.fallback()).toBeNull();
|
||||
await view.capture('root-delay');
|
||||
},
|
||||
{ rootDelay: 10000 }
|
||||
));
|
||||
|
||||
it('native image load exposes img alt and root status callback', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await vi.waitFor(() => expect(view.root().getAttribute('data-status')).toBe('loaded'));
|
||||
expect(view.image()?.getAttribute('alt')).toBe('Ada Lovelace');
|
||||
expect(view.fallback()).toBeNull();
|
||||
expect(view.events).toContain('root:loaded');
|
||||
await view.capture('loaded');
|
||||
},
|
||||
{ includeImage: true, src: validImage }
|
||||
));
|
||||
|
||||
it('native image error reveals the fallback and root status', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
await vi.waitFor(() => expect(view.root().getAttribute('data-status')).toBe('error'));
|
||||
expect(view.image()).toBeNull();
|
||||
expect(view.fallback()?.textContent).toBe('AL');
|
||||
await view.capture('error');
|
||||
},
|
||||
{ includeImage: true, src: 'data:image/png;base64,broken' }
|
||||
));
|
||||
|
||||
it('native side by side group passes defaults while child override wins', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.group().getAttribute('data-stacking')).toBe('side-by-side');
|
||||
expect(view.member(1).getAttribute('data-size')).toBe('lg');
|
||||
expect(view.member(2).getAttribute('data-size')).toBe('sm');
|
||||
expect(view.member(3).getAttribute('data-variant')).toBe('outline');
|
||||
expect(getComputedStyle(view.group()).gap).not.toBe('0px');
|
||||
await view.capture('group-flat');
|
||||
},
|
||||
{ group: true, size: 'lg', variant: 'outline', stacking: 'side-by-side', gap: 'md' }
|
||||
));
|
||||
|
||||
it('native overlapping group caps visible children and shows overflow', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.group().getAttribute('data-has-max')).toBe('2');
|
||||
expect(getComputedStyle(view.member(3)).display).toBe('none');
|
||||
expect(view.group().querySelector('[data-avatar-overflow]')?.textContent?.trim()).toBe('+3');
|
||||
await view.capture('group-overflow');
|
||||
},
|
||||
{ group: true, max: 2, total: 5 }
|
||||
));
|
||||
|
||||
it('native group defaults remain reactive for nonoverridden children', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
view.controls().groupSize('xl');
|
||||
await view.ready();
|
||||
expect(view.member(1).getAttribute('data-size')).toBe('xl');
|
||||
expect(view.member(2).getAttribute('data-size')).toBe('sm');
|
||||
await view.capture('group-reactive');
|
||||
},
|
||||
{ group: true, size: 'md' }
|
||||
));
|
||||
|
||||
it('native RTL positions an end badge at the left inline edge', () =>
|
||||
visit(
|
||||
async (view) => {
|
||||
expect(view.badge()!.getBoundingClientRect().left).toBeLessThan(
|
||||
view.root().getBoundingClientRect().left + view.root().getBoundingClientRect().width / 2
|
||||
);
|
||||
await view.capture('rtl-badge');
|
||||
},
|
||||
{ badge: 'dot', badgePosition: 'bottom-end', dir: 'rtl' }
|
||||
));
|
||||
@ -0,0 +1,136 @@
|
||||
<script module lang="ts">
|
||||
import type { AvatarProps, AvatarGroupProps, BadgeProps } from '$uix/eidos/components/avatar';
|
||||
export type Config = {
|
||||
size?: AvatarProps['size'];
|
||||
variant?: AvatarProps['variant'];
|
||||
color?: AvatarProps['color'];
|
||||
radius?: AvatarProps['radius'];
|
||||
ring?: AvatarProps['ring'];
|
||||
ringColor?: AvatarProps['ringColor'];
|
||||
ringWidth?: AvatarProps['ringWidth'];
|
||||
rootDelay?: number;
|
||||
fallbackDelay?: number;
|
||||
includeImage?: boolean;
|
||||
src?: string;
|
||||
badge?: 'dot' | 'count';
|
||||
badgePosition?: BadgeProps['position'];
|
||||
group?: boolean;
|
||||
stacking?: AvatarGroupProps['stacking'];
|
||||
max?: number;
|
||||
total?: number;
|
||||
gap?: AvatarGroupProps['gap'];
|
||||
dir?: 'ltr' | 'rtl';
|
||||
style?: string;
|
||||
};
|
||||
export type Controls = {
|
||||
size: (value: AvatarProps['size']) => void;
|
||||
variant: (value: AvatarProps['variant']) => void;
|
||||
color: (value: AvatarProps['color']) => void;
|
||||
status: (value: NonNullable<AvatarProps['imageStatus']>) => void;
|
||||
src: (value: string) => void;
|
||||
groupSize: (value: AvatarProps['size']) => void;
|
||||
statusValue: () => AvatarProps['imageStatus'];
|
||||
};
|
||||
</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 { Avatar, AvatarGroup } from '$uix/eidos/components/avatar';
|
||||
let {
|
||||
uix,
|
||||
initial = {},
|
||||
onControls,
|
||||
onEvent
|
||||
}: {
|
||||
uix: ActiveUix;
|
||||
initial?: Config;
|
||||
onControls: (controls: Controls) => void;
|
||||
onEvent: (event: string) => 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 size = $state(initial.size);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let variant = $state(initial.variant);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state(initial.color);
|
||||
let status = $state<NonNullable<AvatarProps['imageStatus']>>('idle');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let src = $state(initial.src ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let groupSize = $state(initial.size);
|
||||
onMount(() =>
|
||||
onControls({
|
||||
size: (value) => (size = value),
|
||||
variant: (value) => (variant = value),
|
||||
color: (value) => (color = value),
|
||||
status: (value) => (status = value),
|
||||
src: (value) => (src = value),
|
||||
groupSize: (value) => (groupSize = value),
|
||||
statusValue: () => status
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="audit-avatar-stage"
|
||||
dir={initial.dir ?? 'ltr'}
|
||||
style="padding:32px;font:16px Arial,sans-serif"
|
||||
>
|
||||
{#if initial.group}
|
||||
<AvatarGroup
|
||||
id="audit-group"
|
||||
size={groupSize}
|
||||
{variant}
|
||||
radius={initial.radius}
|
||||
stacking={initial.stacking}
|
||||
max={initial.max}
|
||||
total={initial.total}
|
||||
gap={initial.gap}
|
||||
>
|
||||
<Avatar id="audit-member-1"><Avatar.Fallback>AL</Avatar.Fallback></Avatar>
|
||||
<Avatar id="audit-member-2" size="sm"><Avatar.Fallback>BO</Avatar.Fallback></Avatar>
|
||||
<Avatar id="audit-member-3"><Avatar.Fallback>CY</Avatar.Fallback></Avatar>
|
||||
<Avatar id="audit-member-4"><Avatar.Fallback>DI</Avatar.Fallback></Avatar>
|
||||
</AvatarGroup>
|
||||
{:else}
|
||||
<Avatar
|
||||
id="audit-avatar"
|
||||
class="audit-avatar"
|
||||
data-probe="avatar"
|
||||
title="Audit Avatar"
|
||||
{size}
|
||||
{variant}
|
||||
{color}
|
||||
radius={initial.radius}
|
||||
ring={initial.ring}
|
||||
ringColor={initial.ringColor}
|
||||
ringWidth={initial.ringWidth}
|
||||
bind:imageStatus={status}
|
||||
delayMs={initial.rootDelay}
|
||||
onImageStatusChange={(value) => onEvent('root:' + value)}
|
||||
style={initial.style}
|
||||
>
|
||||
{#if initial.includeImage}
|
||||
<Avatar.Image
|
||||
{src}
|
||||
alt="Ada Lovelace"
|
||||
onImageStatusChange={(value) => onEvent('image:' + value)}
|
||||
/>
|
||||
{/if}
|
||||
<Avatar.Fallback delayMs={initial.fallbackDelay}>AL</Avatar.Fallback>
|
||||
{#if initial.badge === 'dot'}
|
||||
<Avatar.Badge dot position={initial.badgePosition} color="affirm" />
|
||||
{:else if initial.badge === 'count'}
|
||||
<Avatar.Badge position={initial.badgePosition} color="risk">9+</Avatar.Badge>
|
||||
{/if}
|
||||
</Avatar>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,66 @@
|
||||
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 './avatar-audit-harness.svelte';
|
||||
|
||||
export function renderAvatar(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 events: string[] = [];
|
||||
const component = mount(Harness, {
|
||||
target,
|
||||
props: {
|
||||
uix,
|
||||
initial,
|
||||
onControls: (value) => (controls = value),
|
||||
onEvent: (event) => events.push(event)
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
const ready = async () =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
return {
|
||||
initial,
|
||||
target,
|
||||
events,
|
||||
controls: () => controls,
|
||||
root: () => target.querySelector<HTMLElement>('#audit-avatar')!,
|
||||
fallback: () => target.querySelector<HTMLElement>('[data-avatar-fallback]'),
|
||||
image: () => target.querySelector<HTMLImageElement>('[data-avatar-image]'),
|
||||
badge: () => target.querySelector<HTMLElement>('[data-avatar-badge]'),
|
||||
group: () => target.querySelector<HTMLElement>('#audit-group')!,
|
||||
member: (index: number) => target.querySelector<HTMLElement>('#audit-member-' + index)!,
|
||||
ready,
|
||||
async capture(name: string) {
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await ready();
|
||||
await page.screenshot({ path: 'tmp/avatar-' + name + '.png' });
|
||||
}
|
||||
},
|
||||
async dispose() {
|
||||
await unmount(component);
|
||||
uix.dispose();
|
||||
target.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
export type View = ReturnType<typeof renderAvatar>;
|
||||
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
|
||||
await page.viewport(1100, 900);
|
||||
const view = renderAvatar(initial);
|
||||
try {
|
||||
await view.ready();
|
||||
await fn(view);
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue