@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
328 lines • 16.9 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades';
import { usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CollectionService } from '../../services/collection-service.js';
import { DataGridRow } from './data-grid-row.js';
describe('DataGridRow', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
// Mock scrollTo for jsdom
Element.prototype.scrollTo = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
});
const renderRow = async (props) => {
const injector = createInjector();
const root = document.getElementById('root');
// Create shade-data-grid element manually to simulate production structure
// shade-data-grid is the scrollable container in production
const shadeDataGrid = document.createElement('shade-data-grid');
shadeDataGrid.style.overflow = 'auto';
shadeDataGrid.style.height = '200px';
shadeDataGrid.style.display = 'block';
root.appendChild(shadeDataGrid);
initializeShadeRoot({
injector,
rootElement: shadeDataGrid,
jsxElement: (createComponent("div", { className: "shade-grid-wrapper" },
createComponent("table", null,
createComponent("thead", null,
createComponent("tr", null,
createComponent("th", null, "ID"),
createComponent("th", null, "Name"))),
createComponent("tbody", null,
createComponent(DataGridRow, { entry: props.entry, service: props.service, columns: props.columns ?? ['id', 'name'], onRowClick: props.onRowClick, onRowDoubleClick: props.onRowDoubleClick, focusedRowStyle: props.focusedRowStyle, selectedRowStyle: props.selectedRowStyle, unfocusedRowStyle: props.unfocusedRowStyle, unselectedRowStyle: props.unselectedRowStyle, rowComponents: props.rowComponents }))))),
});
await flushUpdates();
return {
injector,
getRow: () => root.querySelector('shades-data-grid-row'),
getCells: () => root.querySelectorAll('td'),
getScrollContainer: () => shadeDataGrid,
[Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](),
};
};
describe('rendering', () => {
it('should render as a table row element', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row).toBeTruthy();
expect(row?.tagName.toLowerCase()).toBe('shades-data-grid-row');
});
});
});
it('should render a cell for each column', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service, columns: ['id', 'name'] }), async ({ getCells }) => {
expect(getCells().length).toBe(2);
});
});
});
it('should render entry property values in cells', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 42, name: 'Test Entry' };
await usingAsync(await renderRow({ entry, service }), async ({ getCells }) => {
const cells = getCells();
expect(cells[0]?.textContent).toBe('42');
expect(cells[1]?.textContent).toBe('Test Entry');
});
});
});
it('should use custom row components when provided', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Custom' };
await usingAsync(await renderRow({
entry,
service,
rowComponents: {
id: (e) => createComponent("span", { "data-testid": "custom-id" },
"ID: ",
e.id),
name: (e) => createComponent("strong", { "data-testid": "custom-name" }, e.name),
},
}), async ({ getCells }) => {
const cells = getCells();
expect(cells[0]?.querySelector('[data-testid="custom-id"]')?.textContent).toContain('ID: 1');
expect(cells[1]?.querySelector('[data-testid="custom-name"]')?.textContent).toBe('Custom');
});
});
});
});
describe('selection state', () => {
it('should not have selected class when entry is not selected', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(false);
});
});
});
it('should have selected class when entry is in selection', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.selection.setValue([entry]);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(true);
});
});
});
it('should update selected class when selection changes', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(false);
service.selection.setValue([entry]);
await flushUpdates();
expect(row?.hasAttribute('data-selected')).toBe(true);
service.selection.setValue([]);
await flushUpdates();
expect(row?.hasAttribute('data-selected')).toBe(false);
});
});
});
it('should set aria-selected attribute based on selection', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.getAttribute('aria-selected')).toBe('false');
service.selection.setValue([entry]);
await flushUpdates();
expect(row?.getAttribute('aria-selected')).toBe('true');
});
});
});
it('should apply selectedRowStyle when entry is selected', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.selection.setValue([entry]);
await usingAsync(await renderRow({
entry,
service,
selectedRowStyle: { backgroundColor: 'rgb(255, 0, 0)' },
}), async ({ getRow }) => {
const row = getRow();
expect(row?.style.backgroundColor).toBe('rgb(255, 0, 0)');
});
});
});
it('should apply unselectedRowStyle when entry is not selected', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({
entry,
service,
unselectedRowStyle: { backgroundColor: 'rgb(0, 255, 0)' },
}), async ({ getRow }) => {
const row = getRow();
expect(row?.style.backgroundColor).toBe('rgb(0, 255, 0)');
});
});
});
});
describe('focus state', () => {
it('should not have focused class when entry is not focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-focused')).toBe(false);
});
});
});
it('should have focused class when entry is focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.focusedEntry.setValue(entry);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-focused')).toBe(true);
});
});
});
it('should update focused class when focus changes', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-focused')).toBe(false);
service.focusedEntry.setValue(entry);
await flushUpdates();
expect(row?.hasAttribute('data-focused')).toBe(true);
service.focusedEntry.setValue(undefined);
await flushUpdates();
expect(row?.hasAttribute('data-focused')).toBe(false);
});
});
});
it('should apply focusedRowStyle when entry is focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.focusedEntry.setValue(entry);
await usingAsync(await renderRow({
entry,
service,
focusedRowStyle: { fontWeight: 'bold' },
}), async ({ getRow }) => {
const row = getRow();
expect(row?.style.fontWeight).toBe('bold');
});
});
});
it('should apply unfocusedRowStyle when entry is not focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({
entry,
service,
unfocusedRowStyle: { opacity: '0.8' },
}), async ({ getRow }) => {
const row = getRow();
expect(row?.style.opacity).toBe('0.8');
});
});
});
it('should not have focused class when different entry is focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
const otherEntry = { id: 2, name: 'Other' };
service.focusedEntry.setValue(otherEntry);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-focused')).toBe(false);
});
});
});
});
describe('click handlers', () => {
it('should call onRowClick when cell is clicked', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
const onRowClick = vi.fn();
await usingAsync(await renderRow({ entry, service, onRowClick }), async ({ getCells }) => {
const cell = getCells()[0];
cell.click();
expect(onRowClick).toHaveBeenCalledWith(entry, expect.any(MouseEvent));
});
});
});
it('should call onRowDoubleClick when cell is double-clicked', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
const onRowDoubleClick = vi.fn();
await usingAsync(await renderRow({ entry, service, onRowDoubleClick }), async ({ getCells }) => {
const cell = getCells()[0];
const dblClickEvent = new MouseEvent('dblclick', { bubbles: true });
cell.dispatchEvent(dblClickEvent);
expect(onRowDoubleClick).toHaveBeenCalledWith(entry, expect.any(MouseEvent));
});
});
});
it('should not throw when onRowClick is not provided', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getCells }) => {
const cell = getCells()[0];
expect(() => cell.click()).not.toThrow();
});
});
});
it('should not throw when onRowDoubleClick is not provided', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
await usingAsync(await renderRow({ entry, service }), async ({ getCells }) => {
const cell = getCells()[0];
const dblClickEvent = new MouseEvent('dblclick', { bubbles: true });
expect(() => cell.dispatchEvent(dblClickEvent)).not.toThrow();
});
});
});
});
describe('combined selection and focus states', () => {
it('should have both selected and focused classes when applicable', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.selection.setValue([entry]);
service.focusedEntry.setValue(entry);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(true);
expect(row?.hasAttribute('data-focused')).toBe(true);
});
});
});
it('should be selected but not focused', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
const otherEntry = { id: 2, name: 'Other' };
service.selection.setValue([entry]);
service.focusedEntry.setValue(otherEntry);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(true);
expect(row?.hasAttribute('data-focused')).toBe(false);
});
});
});
it('should be focused but not selected', async () => {
await usingAsync(new CollectionService(), async (service) => {
const entry = { id: 1, name: 'Test' };
service.focusedEntry.setValue(entry);
await usingAsync(await renderRow({ entry, service }), async ({ getRow }) => {
const row = getRow();
expect(row?.hasAttribute('data-selected')).toBe(false);
expect(row?.hasAttribute('data-focused')).toBe(true);
});
});
});
});
});
//# sourceMappingURL=data-grid-row.spec.js.map