parent
9f60aa62a2
commit
ef157feccc
@ -0,0 +1,340 @@
|
||||
// @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 '$soma/components/field/field-provider.svelte';
|
||||
|
||||
import {
|
||||
GridListCellProvider,
|
||||
GridListProvider,
|
||||
GridListRowProvider,
|
||||
GridListSelectionCheckboxProvider
|
||||
} from './grid-list-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(field: FieldProvider | undefined = undefined) {
|
||||
const dom = createActiveDom();
|
||||
const timers = {
|
||||
schedule: vi.fn((key: string, delay: number, callback: () => void) => ({
|
||||
key,
|
||||
delay,
|
||||
callback,
|
||||
cancel: vi.fn()
|
||||
}))
|
||||
};
|
||||
const soma = {
|
||||
dom,
|
||||
prefs: { getDir: () => 'ltr' },
|
||||
langs: {
|
||||
ts: vi.fn((key: string) => (key.includes('select-row') ? 'Select row' : 'Grid list'))
|
||||
},
|
||||
uix: { timers },
|
||||
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(field);
|
||||
vi.spyOn(GridListProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom, timers };
|
||||
}
|
||||
|
||||
function createRoot() {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-grid-list', '');
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createRowEl(value: string, text: string, disabled = false) {
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-grid-list-row', '');
|
||||
row.setAttribute('data-value', value);
|
||||
row.setAttribute('data-text-value', text);
|
||||
row.textContent = text;
|
||||
if (disabled) row.setAttribute('data-disabled', '');
|
||||
return row;
|
||||
}
|
||||
|
||||
function gridListOpts(root = createRoot()) {
|
||||
return {
|
||||
id: state('grid-list-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
value: state<string[]>([]),
|
||||
selectionMode: state<'none' | 'single' | 'multiple'>('multiple'),
|
||||
loop: state(false),
|
||||
typeahead: state(true),
|
||||
typeaheadTimeout: state(500),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
name: state<string | undefined>(undefined),
|
||||
ariaLabel: state<string | undefined>(undefined),
|
||||
ariaLabelledby: state<string | undefined>(undefined),
|
||||
onValueChange: state<((value: string[]) => void) | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function rowOpts(id: string, value: string, el: HTMLElement, disabled = false) {
|
||||
return {
|
||||
id: state(id),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
value: state(value),
|
||||
textValue: state<string | undefined>(el.getAttribute('data-text-value') ?? undefined),
|
||||
disabled: state(disabled)
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(
|
||||
key: string,
|
||||
currentTarget: HTMLElement,
|
||||
init: Partial<KeyboardEvent> & { target?: HTMLElement } = {}
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
currentTarget,
|
||||
target: init.target ?? currentTarget,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init
|
||||
};
|
||||
}
|
||||
|
||||
function clickEvent(currentTarget: HTMLElement, init: Partial<MouseEvent> = {}) {
|
||||
return {
|
||||
currentTarget,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...init
|
||||
};
|
||||
}
|
||||
|
||||
function fakeField() {
|
||||
return {
|
||||
get isDisabled() {
|
||||
return false;
|
||||
},
|
||||
get isReadonly() {
|
||||
return false;
|
||||
},
|
||||
get isRequired() {
|
||||
return true;
|
||||
},
|
||||
get isInvalid() {
|
||||
return true;
|
||||
}
|
||||
} as FieldProvider;
|
||||
}
|
||||
|
||||
describe('GridListProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('merges Field flags, exposes row props and selects rows by click helpers', () => {
|
||||
const { dom } = installSomaHarness(fakeField());
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const root = createRoot();
|
||||
const alphaEl = createRowEl('alpha', 'Alpha');
|
||||
const betaEl = createRowEl('beta', 'Beta');
|
||||
const gammaEl = createRowEl('gamma', 'Gamma', true);
|
||||
root.append(alphaEl, betaEl, gammaEl);
|
||||
const opts = gridListOpts(root);
|
||||
opts.value.current = ['beta'];
|
||||
const onValueChange = vi.fn();
|
||||
opts.onValueChange.current = onValueChange;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = GridListProvider.create(opts);
|
||||
vi.spyOn(GridListProvider, 'require').mockReturnValue(provider);
|
||||
const alpha = GridListRowProvider.create(rowOpts('row-alpha', 'alpha', alphaEl));
|
||||
const beta = GridListRowProvider.create(rowOpts('row-beta', 'beta', betaEl));
|
||||
const gamma = GridListRowProvider.create(rowOpts('row-gamma', 'gamma', gammaEl, true));
|
||||
return { provider, alpha, beta, gamma };
|
||||
});
|
||||
|
||||
expect(result.provider.props).toMatchObject({
|
||||
role: 'grid',
|
||||
'aria-label': 'Grid list',
|
||||
'aria-multiselectable': true,
|
||||
'aria-required': true,
|
||||
'aria-invalid': true,
|
||||
'data-selection-mode': 'multiple',
|
||||
'data-required': '',
|
||||
'data-invalid': ''
|
||||
});
|
||||
expect(result.alpha.props).toMatchObject({
|
||||
role: 'row',
|
||||
tabindex: -1,
|
||||
'aria-selected': false,
|
||||
'data-state': 'unselected'
|
||||
});
|
||||
expect(result.beta.props).toMatchObject({
|
||||
tabindex: 0,
|
||||
'aria-selected': true,
|
||||
'data-state': 'selected',
|
||||
'data-highlighted': ''
|
||||
});
|
||||
expect(result.gamma.props).toMatchObject({
|
||||
tabindex: -1,
|
||||
'aria-disabled': true,
|
||||
'data-disabled': ''
|
||||
});
|
||||
|
||||
result.alpha.onclick(clickEvent(alphaEl) as never);
|
||||
expect(opts.value.current).toEqual(['alpha']);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(['alpha']);
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(alphaEl);
|
||||
|
||||
result.beta.onclick(clickEvent(betaEl, { ctrlKey: true }) as never);
|
||||
expect(opts.value.current).toEqual(['alpha', 'beta']);
|
||||
|
||||
result.provider.selectAll();
|
||||
expect(opts.value.current).toEqual(['alpha', 'beta']);
|
||||
result.provider.clear();
|
||||
expect(opts.value.current).toEqual([]);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('navigates rows, handles select-all/clear and typeahead', () => {
|
||||
const { dom, timers } = installSomaHarness();
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const root = createRoot();
|
||||
const alphaEl = createRowEl('alpha', 'Alpha');
|
||||
const betaEl = createRowEl('beta', 'Beta');
|
||||
const gammaEl = createRowEl('gamma', 'Gamma');
|
||||
root.append(alphaEl, betaEl, gammaEl);
|
||||
const opts = gridListOpts(root);
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => GridListProvider.create(opts));
|
||||
|
||||
const down = keyEvent('ArrowDown', alphaEl);
|
||||
provider.handleRowKeydown(down as never);
|
||||
expect(down.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(betaEl);
|
||||
|
||||
const end = keyEvent('End', alphaEl);
|
||||
provider.handleRowKeydown(end as never);
|
||||
expect(end.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(gammaEl);
|
||||
|
||||
const space = keyEvent(' ', betaEl);
|
||||
provider.handleRowKeydown(space as never);
|
||||
expect(space.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toEqual(['beta']);
|
||||
|
||||
const selectAll = keyEvent('a', betaEl, { ctrlKey: true });
|
||||
provider.handleRowKeydown(selectAll as never);
|
||||
expect(selectAll.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma']);
|
||||
|
||||
const escape = keyEvent('Escape', betaEl);
|
||||
provider.handleRowKeydown(escape as never);
|
||||
expect(escape.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toEqual([]);
|
||||
|
||||
provider.handleRowKeydown(keyEvent('g', alphaEl) as never);
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(gammaEl);
|
||||
expect(timers.schedule).toHaveBeenCalledWith(
|
||||
'soma:grid-list:grid-list-root:typeahead',
|
||||
500,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
replace: true,
|
||||
meta: { component: 'grid-list', action: 'typeahead-reset' }
|
||||
})
|
||||
);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('navigates focusable cells and toggles row selection through the checkbox part', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const root = createRoot();
|
||||
const rowEl = createRowEl('alpha', 'Alpha');
|
||||
const cellEl = document.createElement('div');
|
||||
const firstButton = document.createElement('button');
|
||||
const secondButton = document.createElement('button');
|
||||
const checkboxEl = document.createElement('button');
|
||||
cellEl.append(firstButton, secondButton, checkboxEl);
|
||||
rowEl.appendChild(cellEl);
|
||||
root.appendChild(rowEl);
|
||||
const opts = gridListOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = GridListProvider.create(opts);
|
||||
vi.spyOn(GridListProvider, 'require').mockReturnValue(provider);
|
||||
const cell = GridListCellProvider.create({
|
||||
id: state('grid-cell-alpha'),
|
||||
ref: state<HTMLElement | null>(cellEl)
|
||||
});
|
||||
const checkbox = GridListSelectionCheckboxProvider.create({
|
||||
id: state('grid-checkbox-alpha'),
|
||||
ref: state<HTMLButtonElement | null>(checkboxEl),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
});
|
||||
return { provider, cell, checkbox };
|
||||
});
|
||||
|
||||
expect(result.cell.props.role).toBe('gridcell');
|
||||
expect(result.checkbox.props).toMatchObject({
|
||||
type: 'button',
|
||||
role: 'checkbox',
|
||||
'aria-checked': false,
|
||||
'aria-label': 'Select row',
|
||||
'data-state': 'unchecked'
|
||||
});
|
||||
|
||||
const right = keyEvent('ArrowRight', cellEl, { target: firstButton });
|
||||
result.cell.onkeydown(right as never);
|
||||
expect(right.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(right.stopPropagation).toHaveBeenCalledOnce();
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(secondButton);
|
||||
|
||||
const left = keyEvent('ArrowLeft', cellEl, { target: firstButton });
|
||||
result.cell.onkeydown(left as never);
|
||||
expect(left.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(left.stopPropagation).toHaveBeenCalledOnce();
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(rowEl);
|
||||
|
||||
const checkboxClick = { currentTarget: checkboxEl, stopPropagation: vi.fn() };
|
||||
result.checkbox.onclick(checkboxClick as never);
|
||||
expect(checkboxClick.stopPropagation).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toEqual(['alpha']);
|
||||
expect(result.checkbox.props).toMatchObject({
|
||||
'aria-checked': true,
|
||||
'data-state': 'checked'
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue