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