parent
b93e13c7d1
commit
83e00360a6
@ -0,0 +1,237 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { flushSync } from 'svelte';
|
||||
import '$uix/eidos/components/card/card.css';
|
||||
import { visit, type View } from '$uix/test/card-group/card-group-audit-view';
|
||||
const css = (node: Element) => getComputedStyle(node);
|
||||
const output = (v: View, key: string) => v.node('#audit-cg-' + key).textContent;
|
||||
it('native default CardGroup labels a passive grid through its static DIV title', () =>
|
||||
visit(async (v) => {
|
||||
expect(v.root().tagName).toBe('DIV');
|
||||
expect(v.root().getAttribute('role')).toBe('group');
|
||||
expect(v.root().getAttribute('aria-labelledby')).toBe(v.part('title').id);
|
||||
expect(v.part('title').tagName).toBe('DIV');
|
||||
expect(v.part('title').hasAttribute('role')).toBe(false);
|
||||
expect(v.item('a').tagName).toBe('DIV');
|
||||
expect(css(v.part('grid')).display).toBe('grid');
|
||||
expect(css(v.item('a')).display).toBe('flex');
|
||||
expect(v.root().querySelectorAll('[data-card-group-item]').length).toBe(3);
|
||||
await v.capture('default');
|
||||
}));
|
||||
for (const depth of ['raised', 'recessed', 'flush'] as const) {
|
||||
it('native CardGroup depth ' + depth + ' resolves foundation chrome', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.root().getAttribute('data-depth')).toBe(depth);
|
||||
if (depth === 'flush') expect(css(v.root()).boxShadow).toBe('none');
|
||||
else expect(css(v.root()).boxShadow).not.toBe('none');
|
||||
await v.capture('depth-' + depth);
|
||||
},
|
||||
{ depth }
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const [size, padding, gap, cardPadding] of [
|
||||
['sm', 12, 8, 16],
|
||||
['md', 16, 12, 20],
|
||||
['lg', 20, 16, 24]
|
||||
] as const) {
|
||||
it('native CardGroup size ' + size + ' drives grid and card density', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.root().getAttribute('data-size')).toBe(size);
|
||||
expect(css(v.part('grid')).paddingLeft).toBe(padding + 'px');
|
||||
expect(css(v.part('grid')).gap).toBe(gap + 'px');
|
||||
expect(v.item('a').getAttribute('data-size')).toBe(size);
|
||||
expect(css(v.item('a')).paddingLeft).toBe(cardPadding + 'px');
|
||||
await v.capture('size-' + size);
|
||||
},
|
||||
{ size }
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const variant of ['soft', 'solid', 'outline', 'ghost'] as const) {
|
||||
it('native CardGroup Item ' + variant + ' reuses the explicitly loaded Card recipe', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.item('a').hasAttribute('data-card')).toBe(true);
|
||||
expect(v.item('a').getAttribute('data-variant')).toBe(variant);
|
||||
expect(v.item('a').getAttribute('data-color')).toBe('teal');
|
||||
if (variant === 'ghost') expect(css(v.item('a')).backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
||||
else expect(css(v.item('a')).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await v.capture('variant-' + variant);
|
||||
},
|
||||
{ variant, color: 'teal' }
|
||||
)
|
||||
);
|
||||
}
|
||||
it('native CardGroup concentric radius owns Item geometry above its rounded axis', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(css(v.item('a')).borderTopLeftRadius).toBe('13px');
|
||||
expect(css(v.root()).borderTopLeftRadius).toBe('32px');
|
||||
await v.capture('concentric');
|
||||
},
|
||||
{ rounded: 'lg', style: '--card-group-card-radius:13px;--card-group-padding-md:19px;' }
|
||||
));
|
||||
it('native CardGroup numeric columns and minChildWidth precedence update without remounting', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
const root = v.root();
|
||||
expect(v.part('grid').style.gridTemplateColumns).toBe('repeat(3, minmax(0px, 1fr))');
|
||||
flushSync(() => v.controls().minChildWidth(260));
|
||||
expect(v.part('grid').style.gridTemplateColumns).toBe(
|
||||
'repeat(auto-fill, minmax(260px, 1fr))'
|
||||
);
|
||||
flushSync(() => {
|
||||
v.controls().minChildWidth(undefined);
|
||||
v.controls().columns(2);
|
||||
});
|
||||
expect(v.root()).toBe(root);
|
||||
expect(v.part('grid').style.gridTemplateColumns).toBe('repeat(2, minmax(0px, 1fr))');
|
||||
await v.capture('layout-reactive');
|
||||
},
|
||||
{ columns: 3 }
|
||||
));
|
||||
it('native CardGroup responsive density and columns follow viewport roundtrip', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
await expect.poll(() => v.root().getAttribute('data-size')).toBe('lg');
|
||||
await page.viewport(600, 900);
|
||||
await expect.poll(() => v.root().getAttribute('data-size')).toBe('sm');
|
||||
expect(v.part('grid').style.gridTemplateColumns).toBe('repeat(1, minmax(0px, 1fr))');
|
||||
await page.viewport(1100, 900);
|
||||
await expect.poll(() => v.root().getAttribute('data-size')).toBe('lg');
|
||||
expect(v.part('grid').style.gridTemplateColumns).toBe('repeat(3, minmax(0px, 1fr))');
|
||||
await v.capture('responsive');
|
||||
},
|
||||
{ size: { base: 'sm', md: 'lg' }, columns: { base: 1, md: 3 } }
|
||||
));
|
||||
it('native toggleable CardGroup connects disclosure button to mounted hidden content', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
const title = v.part('title');
|
||||
const content = v.part('content');
|
||||
expect(title.tagName).toBe('BUTTON');
|
||||
expect(title.hasAttribute('data-button')).toBe(true);
|
||||
expect(title.hasAttribute('data-collapsible-trigger')).toBe(true);
|
||||
expect(title.getAttribute('aria-controls')).toBe(content.id);
|
||||
expect(content.getAttribute('aria-labelledby')).toBe(title.id);
|
||||
await userEvent.click(title);
|
||||
await expect.poll(() => title.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(content.hidden).toBe(true);
|
||||
expect(css(content).display).toBe('none');
|
||||
expect(v.item('a').isConnected).toBe(true);
|
||||
expect(output(v, 'open-calls')).toBe('1');
|
||||
await v.capture('disclosure-close');
|
||||
},
|
||||
{ toggleable: true }
|
||||
));
|
||||
for (const [key, label] of [
|
||||
['{Enter}', 'enter'],
|
||||
[' ', 'space']
|
||||
] as const) {
|
||||
it('native CardGroup disclosure ' + label + ' opens once and stamps its content target', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
v.part('title').focus();
|
||||
await userEvent.keyboard(key);
|
||||
await expect.poll(() => v.part('title').getAttribute('aria-expanded')).toBe('true');
|
||||
expect(v.part('content').hidden).toBe(false);
|
||||
expect(output(v, 'open-calls')).toBe('1');
|
||||
await expect
|
||||
.poll(() =>
|
||||
v.stamps.some((s) => s.event === 'emerge-expand' && s.node === v.part('content'))
|
||||
)
|
||||
.toBe(true);
|
||||
await v.capture('disclosure-' + label);
|
||||
},
|
||||
{ toggleable: true, open: false }
|
||||
)
|
||||
);
|
||||
}
|
||||
it('native CardGroup static parts preserve consumer classes data attrs and title copy updates', () =>
|
||||
visit(async (v) => {
|
||||
expect(v.root().className).toBe('audit-group');
|
||||
for (const part of ['title', 'description', 'content'])
|
||||
expect(v.part(part).getAttribute('data-probe')).toBe(part);
|
||||
expect(v.item('a').className).toBe('audit-item');
|
||||
flushSync(() => v.controls().copy('Updated plans'));
|
||||
expect(v.part('title').textContent).toBe('Updated plans');
|
||||
await v.capture('static-passthrough');
|
||||
}));
|
||||
for (const [label, labelledby] of [
|
||||
['Explicit plans', undefined],
|
||||
[undefined, 'audit-cg-external']
|
||||
] as const) {
|
||||
it(
|
||||
'native CardGroup explicit name ' +
|
||||
(label ? 'label' : 'labelledby') +
|
||||
' supports omitted Title',
|
||||
() =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.root().querySelector('[data-card-group-title]')).toBeNull();
|
||||
if (label) {
|
||||
expect(v.root().getAttribute('aria-label')).toBe(label);
|
||||
expect(v.root().hasAttribute('aria-labelledby')).toBe(false);
|
||||
} else expect(v.root().getAttribute('aria-labelledby')).toBe(labelledby);
|
||||
await v.capture(label ? 'explicit-label' : 'explicit-labelledby');
|
||||
},
|
||||
{ title: false, label, labelledby }
|
||||
)
|
||||
);
|
||||
}
|
||||
it('native empty CardGroup omits Items without adding fallback content', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.part('grid').children.length).toBe(0);
|
||||
expect(v.part('grid').textContent?.trim()).toBe('');
|
||||
await v.capture('empty');
|
||||
},
|
||||
{ empty: true }
|
||||
));
|
||||
it('native CardGroup public tokens tune title description grid and concentric geometry', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(css(v.part('grid')).gap).toBe('23px');
|
||||
expect(css(v.part('title')).fontSize).toBe('25px');
|
||||
expect(css(v.part('description')).fontSize).toBe('18px');
|
||||
expect(css(v.item('a')).borderTopLeftRadius).toBe('15px');
|
||||
await v.capture('tokens');
|
||||
},
|
||||
{
|
||||
style:
|
||||
'--card-group-grid-gap-md:23px;--card-group-title-font-size:25px;--card-group-description-font-size:18px;--card-group-card-radius:15px;'
|
||||
}
|
||||
));
|
||||
it('native CardGroup fallback grid fits its 350px viewport with wrapped card copy', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
expect(v.root().getBoundingClientRect().right).toBeLessThanOrEqual(350);
|
||||
expect(v.item('a').getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
v.root().getBoundingClientRect().right
|
||||
);
|
||||
expect(v.part('grid').scrollWidth).toBeLessThanOrEqual(v.part('grid').clientWidth);
|
||||
await v.capture('narrow-fallback');
|
||||
},
|
||||
{ viewportWidth: 350 }
|
||||
));
|
||||
it('native passive CardGroup composes an independent consumer action inside an Item', () =>
|
||||
visit(
|
||||
async (v) => {
|
||||
await userEvent.click(v.node('#audit-cg-action'));
|
||||
expect(output(v, 'actions')).toBe('1');
|
||||
expect(v.value()).toEqual([]);
|
||||
expect(v.item('a').tagName).toBe('DIV');
|
||||
await v.capture('passive-action');
|
||||
},
|
||||
{ action: true }
|
||||
));
|
||||
it('native CardGroup consumer removal preserves neighboring controls', () =>
|
||||
visit(async (v) => {
|
||||
flushSync(() => v.controls().remove());
|
||||
expect(v.target.querySelector('#audit-cg')).toBeNull();
|
||||
expect(v.node('#audit-cg-before').isConnected).toBe(true);
|
||||
await v.capture('removed');
|
||||
}));
|
||||
@ -0,0 +1,181 @@
|
||||
<script module lang="ts">
|
||||
import type { CardGroupProps, CardGroupItemProps } from '$uix/eidos/components/card-group';
|
||||
export type Config = {
|
||||
toggleable?: boolean;
|
||||
open?: boolean;
|
||||
enabledSelections?: CardGroupProps['enabledSelections'];
|
||||
value?: string[];
|
||||
whenFull?: CardGroupProps['whenFull'];
|
||||
disabled?: boolean;
|
||||
size?: CardGroupProps['size'];
|
||||
depth?: CardGroupProps['depth'];
|
||||
columns?: CardGroupProps['columns'];
|
||||
minChildWidth?: CardGroupProps['minChildWidth'];
|
||||
motion?: CardGroupProps['motion'];
|
||||
staggerEach?: number;
|
||||
variant?: CardGroupItemProps['variant'];
|
||||
color?: CardGroupItemProps['color'];
|
||||
rounded?: CardGroupItemProps['rounded'];
|
||||
itemDisabled?: boolean;
|
||||
itemStyle?: string;
|
||||
style?: string;
|
||||
titleId?: string;
|
||||
title?: boolean;
|
||||
description?: boolean;
|
||||
content?: boolean;
|
||||
empty?: boolean;
|
||||
label?: string;
|
||||
labelledby?: string;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
reduce?: boolean;
|
||||
viewportWidth?: number;
|
||||
action?: boolean;
|
||||
};
|
||||
export type Controls = {
|
||||
open: (v: boolean) => void;
|
||||
value: (v: string[]) => void;
|
||||
disabled: (v: boolean) => void;
|
||||
size: (v: CardGroupProps['size']) => void;
|
||||
columns: (v: CardGroupProps['columns']) => void;
|
||||
minChildWidth: (v: CardGroupProps['minChildWidth']) => void;
|
||||
limit: (v: CardGroupProps['enabledSelections']) => void;
|
||||
copy: (v: string) => void;
|
||||
remove: () => void;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { setActiveUix, type ActiveUix } from '$active-uix';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import CardGroup from '$uix/eidos/components/card-group';
|
||||
let {
|
||||
uix,
|
||||
initial = {},
|
||||
onControls
|
||||
}: { uix: ActiveUix; initial?: Config; onControls: (v: 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 open = $state(initial.open ?? true);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let value = $state(initial.value ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let disabled = $state(initial.disabled ?? false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state(initial.size);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let columns = $state(initial.columns);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let minChildWidth = $state(initial.minChildWidth);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let limit = $state(initial.enabledSelections);
|
||||
let copy = $state('Workspace plans');
|
||||
let visible = $state(true);
|
||||
let selectionCalls = $state(0);
|
||||
let openCalls = $state(0);
|
||||
let actions = $state(0);
|
||||
onMount(() =>
|
||||
onControls({
|
||||
open: (v) => (open = v),
|
||||
value: (v) => (value = v),
|
||||
disabled: (v) => (disabled = v),
|
||||
size: (v) => (size = v),
|
||||
columns: (v) => (columns = v),
|
||||
minChildWidth: (v) => (minChildWidth = v),
|
||||
limit: (v) => (limit = v),
|
||||
copy: (v) => (copy = v),
|
||||
remove: () => (visible = false)
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="audit-cg-stage"
|
||||
dir={initial.dir ?? 'ltr'}
|
||||
data-motion={initial.reduce ? 'reduce' : undefined}
|
||||
style="padding:24px;font:16px Arial,sans-serif"
|
||||
>
|
||||
<div style="max-inline-size:820px">
|
||||
<button id="audit-cg-before" type="button">Before</button>
|
||||
<span id="audit-cg-external">External plans</span>
|
||||
{#if visible}
|
||||
<CardGroup
|
||||
id="audit-cg"
|
||||
class="audit-group"
|
||||
data-probe="group"
|
||||
toggleable={initial.toggleable}
|
||||
bind:open
|
||||
bind:value
|
||||
{disabled}
|
||||
enabledSelections={limit}
|
||||
whenFull={initial.whenFull}
|
||||
onValueChange={() => (selectionCalls += 1)}
|
||||
onOpenChange={() => (openCalls += 1)}
|
||||
{size}
|
||||
depth={initial.depth}
|
||||
{columns}
|
||||
{minChildWidth}
|
||||
motion={initial.motion}
|
||||
staggerEach={initial.staggerEach}
|
||||
style={initial.style}
|
||||
aria-label={initial.label}
|
||||
aria-labelledby={initial.labelledby}
|
||||
dir={initial.dir}
|
||||
>
|
||||
{#if initial.title !== false}<CardGroup.Title
|
||||
{...initial.titleId ? { id: initial.titleId } : {}}
|
||||
class="audit-title"
|
||||
data-probe="title"
|
||||
title="Consumer title">{copy}</CardGroup.Title
|
||||
>{/if}
|
||||
{#if initial.description !== false}<CardGroup.Description
|
||||
id="audit-cg-description"
|
||||
class="audit-description"
|
||||
data-probe="description"
|
||||
>Choose a workspace plan. Selection and disclosure are independent.</CardGroup.Description
|
||||
>{/if}
|
||||
{#if initial.content !== false}<CardGroup.Content
|
||||
id="audit-cg-content"
|
||||
class="audit-content"
|
||||
data-probe="content"
|
||||
>
|
||||
{#if !initial.empty}
|
||||
{#each ['a', 'b', 'c'] as key}
|
||||
<CardGroup.Item
|
||||
value={key}
|
||||
id={'audit-cg-' + key}
|
||||
class="audit-item"
|
||||
data-probe={key}
|
||||
disabled={key === 'b' && initial.itemDisabled}
|
||||
variant={initial.variant}
|
||||
color={initial.color}
|
||||
rounded={initial.rounded}
|
||||
style={initial.itemStyle}
|
||||
>
|
||||
<strong>Plan {key.toUpperCase()}</strong><span
|
||||
>Projects and shared tools for your team.</span
|
||||
>
|
||||
{#if key === 'a' && initial.action}<button
|
||||
id="audit-cg-action"
|
||||
type="button"
|
||||
onclick={() => (actions += 1)}>Details</button
|
||||
>{/if}
|
||||
</CardGroup.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</CardGroup.Content>{/if}
|
||||
</CardGroup>
|
||||
{/if}
|
||||
<button id="audit-cg-after" type="button">After</button>
|
||||
<output id="audit-cg-value">{JSON.stringify(value)}</output>
|
||||
<output id="audit-cg-open">{String(open)}</output>
|
||||
<output id="audit-cg-selection-calls">{selectionCalls}</output>
|
||||
<output id="audit-cg-open-calls">{openCalls}</output>
|
||||
<output id="audit-cg-actions">{actions}</output>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,87 @@
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { expect } from 'vitest';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { createActiveUix } from '$active-uix';
|
||||
import '$uix/eidos/index.css';
|
||||
import Harness, { type Config, type Controls } from './card-group-audit-harness.svelte';
|
||||
export function renderCardGroup(initial: Config = {}) {
|
||||
const uix = createActiveUix({
|
||||
langs: { schema: {}, defaultLocale: 'en' },
|
||||
prefs: { storage: false },
|
||||
projectPrefs: false
|
||||
});
|
||||
const target = document.createElement('div');
|
||||
document.body.append(target);
|
||||
const stamps: { node: Element; event: string; family: string | null }[] = [];
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const node = record.target as Element;
|
||||
const event = node.getAttribute('data-event');
|
||||
if (event) stamps.push({ node, event, family: node.getAttribute('data-event-family') });
|
||||
}
|
||||
});
|
||||
observer.observe(target, { subtree: true, attributes: true, attributeFilter: ['data-event'] });
|
||||
let controls!: Controls;
|
||||
let captured = false;
|
||||
const component = mount(Harness, {
|
||||
target,
|
||||
props: { uix, initial, onControls: (v) => (controls = v) }
|
||||
});
|
||||
flushSync();
|
||||
const node = (selector: string) => target.querySelector<HTMLElement>(selector)!;
|
||||
const ready = async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
for (const item of target.querySelectorAll<HTMLElement>('[data-card-group-item]')) {
|
||||
if (!item.closest('[hidden]'))
|
||||
await expect.poll(() => Number(getComputedStyle(item).opacity)).toBe(1);
|
||||
}
|
||||
};
|
||||
return {
|
||||
uix,
|
||||
target,
|
||||
stamps,
|
||||
node,
|
||||
controls: () => controls,
|
||||
root: () => node('#audit-cg'),
|
||||
part: (name: string) => node('[data-card-group-' + name + ']'),
|
||||
item: (key: string) => node('#audit-cg-' + key) as HTMLButtonElement,
|
||||
value: () => JSON.parse(node('#audit-cg-value').textContent!) as string[],
|
||||
ready,
|
||||
hasCaptured: () => captured,
|
||||
async capture(name: string) {
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await ready();
|
||||
if (captured) throw new Error('Duplicate manual capture');
|
||||
await page.screenshot({ path: 'tmp/card-group-' + name + '.png' });
|
||||
captured = true;
|
||||
}
|
||||
},
|
||||
async dispose() {
|
||||
observer.disconnect();
|
||||
await unmount(component);
|
||||
uix.dispose();
|
||||
target.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
export type View = ReturnType<typeof renderCardGroup>;
|
||||
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
|
||||
await page.viewport(initial.viewportWidth ?? 1100, 900);
|
||||
const view = renderCardGroup(initial);
|
||||
try {
|
||||
await userEvent.hover(view.node('#audit-cg-before'));
|
||||
await view.ready();
|
||||
await fn(view);
|
||||
} catch (error) {
|
||||
if (!view.hasCaptured())
|
||||
await view.capture(
|
||||
'unexpected-' +
|
||||
(expect.getState().currentTestName ?? 'case').replaceAll(/[^a-zA-Z0-9-]+/g, '-')
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue