Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Documentation/blueprints/foundation-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ the matrix publishes are always the whole set.

## Boards

Card selection is opt-in: set `selectionMode` to `single` (`none` by default). Each title becomes a pressed-state toggle button: click, Enter or Space selects
it, pressing it again or Escape clears it. The board announces the selected title. Bind to `selectedItem` to read the original query row, including properties
not drawn on the card, or `null` when nothing is selected. Field mappings do not reshape this output. Removing the selected card from `cards` clears selection;
moving it between columns keeps the same original row selected. The existing `value` output remains the list of card ids and columns for saving moves.

`kanbanBoard` shows a count and the over-limit warning in each column header. Name a numeric card field in `totalField` (a deal's `amount`) and the
header also shows the total of that field over the cards in the column, formatted by `format` (`currency` with a `currency` code, `percent`,
`compact` or a plain number) in the element's `locale`. The totals follow a card when it is moved, by dragging or by its Move-to menu, so a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { render } from '@testing-library/react';
import sinon from 'sinon';
import { KanbanBoard, foundationElement } from '../../foundation';

export const columns = [{ id: 'todo', label: 'To do' }, { id: 'done', label: 'Done' }];
export const cards = [{ id: 'one', title: 'First task', column: 'todo', ownerId: 'alice' }, { id: 'two', title: 'Second task', column: 'done', ownerId: 'bob' }];

export function aBoardWithBindingOutputs(properties: Record<string, unknown> = {}) {
const published: Record<string, unknown> = {};
const setOutput = sinon.spy((name: string, value: unknown) => { published[name] = value; });
const bindingOutputs = { setOutput, clearOutput: () => undefined, clearAll: () => undefined };
const onChange = sinon.spy();
const onSelect = sinon.spy();
const draw = (overrides: Record<string, unknown> = {}) => <KanbanBoard element={foundationElement('kanbanBoard', { columns, cards, selectionMode: 'single', ...properties, ...overrides })} slots={{}} bindingOutputs={bindingOutputs} interactions={{ onChange, onSelect }} />;
const rendered = render(draw());
return { ...rendered, published, setOutput, onChange, onSelect, update: (overrides: Record<string, unknown>) => rendered.rerender(draw(overrides)) };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { readCardRows, readCards } from '../foundation/boardModel';

describe('when reading the original card rows', () => {
it('should key the original rows by the mapped id without copying them', () => {
const row = { productId: 42, name: 'Mug', secretToTheCard: 'retained' };
const rows = readCardRows([row], { id: 'productId', title: 'name' });
[...rows.keys()].should.deep.equal(['42']);
rows.get('42')!.should.equal(row);
});

it('should keep the first row when mapped ids repeat', () => {
const first = { productId: 42, name: 'First' };
const rows = readCardRows([first, { productId: '42', name: 'Duplicate' }], { id: 'productId', title: 'name' });
rows.size.should.equal(1);
rows.get('42')!.should.equal(first);
});

it('should skip an untitled duplicate just as the drawn cards do', () => {
const row = { id: 'a', title: 'T' };
const supplied = [{ id: 'a' }, row];
readCards(supplied).map(card => card.title).should.deep.equal(['T']);
readCardRows(supplied).get('a')!.should.equal(row);
});

it('should skip a duplicate with an invalid mapped title just as the drawn cards do', () => {
const row = { productId: 42, name: 'Mug' };
const supplied = [{ productId: 42, name: null }, row];
const mapping = { id: 'productId', title: 'name' };
readCards(supplied, mapping).map(card => card.title).should.deep.equal(['Mug']);
readCardRows(supplied, mapping).get('42')!.should.equal(row);
});

it('should ignore invalid rows and empty or missing ids', () => {
[...readCardRows([null, [], 'not a row', {}, { id: '' }, { id: 7 }, { id: 'valid', title: 'Task' }]).keys()].should.deep.equal(['valid']);
readCardRows(undefined).size.should.equal(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, fireEvent, screen } from '@testing-library/react';
import { afterEach } from 'vitest';
import sinon from 'sinon';
import { BindingMode, PropertyValueType } from '@cratis/scene.model';
import { foundationDescriptors } from '../foundation/foundationDescriptors';
import { aBoardWithBindingOutputs, cards } from './given/a_board_with_binding_outputs';

describe('when selecting a card', () => {
afterEach(cleanup);

it('should publish the original row and announce its title with an accessible pressed state', () => {
const { published, container, onSelect } = aBoardWithBindingOutputs();
[published.selectedItem].should.deep.equal([null]);
const button = screen.getByRole('button', { name: 'Select First task' });
fireEvent.click(button);
published.selectedItem!.should.equal(cards[0]);
button.getAttribute('aria-pressed')!.should.equal('true');
container.querySelectorAll('.foundation-board-card[data-selected="true"]').length.should.equal(1);
screen.getByRole('status').textContent!.should.equal('First task selected');
onSelect.callCount.should.equal(0);
});

it('should clear the output when the title is pressed again', () => {
const { published } = aBoardWithBindingOutputs();
const button = screen.getByRole('button', { name: 'Select First task' });
fireEvent.click(button);
fireEvent.click(button);
[published.selectedItem, button.getAttribute('aria-pressed')].should.deep.equal([null, 'false']);
screen.getByRole('status').textContent!.should.equal('Selection cleared');
});

it('should clear the output and announce it when Escape is pressed on the title', () => {
const { published, container } = aBoardWithBindingOutputs();
const button = screen.getByRole('button', { name: 'Select First task' });
fireEvent.click(button);
fireEvent.keyDown(button, { key: 'Escape' });
[published.selectedItem, button.getAttribute('aria-pressed')].should.deep.equal([null, 'false']);
container.querySelectorAll('[data-selected="true"]').length.should.equal(0);
screen.getByRole('status').textContent!.should.equal('Selection cleared');
});

it('should stop propagation only when Escape clears a selection', () => {
const { container } = aBoardWithBindingOutputs();
const reached = sinon.spy();
document.body.addEventListener('keydown', reached);
try {
const button = screen.getByRole('button', { name: 'Select First task' });
fireEvent.keyDown(button, { key: 'Escape' });
reached.callCount.should.equal(1);
fireEvent.click(button);
fireEvent.keyDown(button, { key: 'Escape' });
reached.callCount.should.equal(1);
} finally {
document.body.removeEventListener('keydown', reached);
}
container.querySelectorAll('[data-selected="true"]').length.should.equal(0);
});

it('should make no announcement when Escape is pressed with nothing selected', () => {
aBoardWithBindingOutputs();
fireEvent.keyDown(screen.getByRole('button', { name: 'Select First task' }), { key: 'Escape' });
screen.getByRole('status').textContent!.should.equal('');
});

it('should leave selection alone when Escape is pressed in the move menu', () => {
const { published } = aBoardWithBindingOutputs();
const button = screen.getByRole('button', { name: 'Select First task' });
fireEvent.click(button);
fireEvent.keyDown(screen.getByRole('combobox', { name: 'Move First task' }), { key: 'Escape' });
published.selectedItem!.should.equal(cards[0]);
button.getAttribute('aria-pressed')!.should.equal('true');
screen.getByRole('status').textContent!.should.equal('First task selected');
});

it('should publish the drawn row rather than an earlier untitled duplicate', () => {
const row = { id: 'a', title: 'T', column: 'todo' };
const { published } = aBoardWithBindingOutputs({ cards: [{ id: 'a' }, row] });
fireEvent.click(screen.getByRole('button', { name: 'Select T' }));
published.selectedItem!.should.equal(row);
});

it('should publish the unmapped query row for a mapped board', () => {
const row = { productId: 42, name: 'Mug', status: 'todo', sku: 'ACM-1000' };
const { published } = aBoardWithBindingOutputs({ cards: [row], cardId: 'productId', cardTitle: 'name', cardColumn: 'status' });
fireEvent.click(screen.getByRole('button', { name: 'Select Mug' }));
published.selectedItem!.should.equal(row);
});

it('should keep the selected original row after moving and still publish the move value', () => {
const { published, onChange } = aBoardWithBindingOutputs();
fireEvent.click(screen.getByRole('button', { name: 'Select First task' }));
fireEvent.change(screen.getByRole('combobox', { name: 'Move First task' }), { target: { value: 'done' } });
published.selectedItem!.should.equal(cards[0]);
published.value!.should.deep.equal([{ id: 'two', column: 'done' }, { id: 'one', column: 'done' }]);
screen.getByRole('button', { name: 'Select First task' }).getAttribute('aria-pressed')!.should.equal('true');
onChange.callCount.should.equal(1);
});

it('should publish selection by value rather than republishing an unchanged row', () => {
const { setOutput, update, published } = aBoardWithBindingOutputs();
fireEvent.click(screen.getByRole('button', { name: 'Select First task' }));
const count = () => setOutput.getCalls().filter(call => call.args[0] === 'selectedItem').length;
count().should.equal(2);
update({ cards: cards.map(card => ({ ...card })) });
count().should.equal(2);
update({ cards: [{ ...cards[0], ownerId: 'charlie' }, cards[1]] });
count().should.equal(3);
published.selectedItem!.should.deep.equal({ ...cards[0], ownerId: 'charlie' });
});

it('should allow selection even when moving is disabled', () => {
const { published } = aBoardWithBindingOutputs({ editPermission: 'disabled' });
fireEvent.click(screen.getByRole('button', { name: 'Select First task' }));
published.selectedItem!.should.equal(cards[0]);
screen.queryAllByRole('combobox').length.should.equal(0);
});

it('should describe the same selected item output contract as tables and default selection to none', () => {
const descriptor = foundationDescriptors.find(item => item.displayName === 'Kanban board')!;
const output = descriptor.properties!.find(property => property.path === 'selectedItem')!;
[output.valueType, output.readOnly, output.output, output.bindingMode, output.description].should.deep.equal([
PropertyValueType.Object, true, true, BindingMode.OneWay, 'The currently selected row, or null after selection is cleared.',
]);
descriptor.properties!.find(property => property.path === 'selectionMode')!.default!.should.equal('none');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { FoundationPage, openFoundationPage } from '../for_foundation/given/a_foundation_page';

const properties = {
selectionMode: 'single',
columns: [{ id: 'todo', label: 'To do' }, { id: 'done', label: 'Done' }],
cards: [{ id: 'one', title: 'First task', column: 'todo' }, { id: 'two', title: 'Second task', column: 'done' }],
};

describe('when selecting a card with the keyboard in Chromium', () => {
let chromium: FoundationPage;
beforeAll(async () => { chromium = await openFoundationPage(); });
afterAll(async () => { await chromium.browser.close(); });

for (const theme of ['light', 'dark'] as const) {
it(`should select with Enter and Space and clear with Escape without accessibility violations in the ${theme} theme`, async () => {
(await chromium.audit('kanbanBoard', theme, properties)).should.deep.equal([]);
const board = chromium.page.locator('.foundation-board').first();
const first = board.getByRole('button', { name: 'Select First task' });
const second = board.getByRole('button', { name: 'Select Second task' });
await first.focus();
await first.press('Enter');
(await first.getAttribute('aria-pressed'))!.should.equal('true');
const move = board.getByRole('combobox', { name: 'Move First task' });
await move.focus();
await move.press('Escape');
(await first.getAttribute('aria-pressed'))!.should.equal('true');
(await chromium.audit('kanbanBoard', theme, properties)).should.deep.equal([]);
await second.focus();
await second.press('Space');
(await second.getAttribute('aria-pressed'))!.should.equal('true');
(await first.getAttribute('aria-pressed'))!.should.equal('false');
(await board.locator('[data-selected="true"]').count()).should.equal(1);
await second.press('Escape');
(await second.getAttribute('aria-pressed'))!.should.equal('false');
(await board.locator('[data-selected="true"]').count()).should.equal(0);
(await chromium.page.locator('.foundation-visually-hidden[role="status"]').first().textContent())!.should.equal('Selection cleared');
chromium.errors.should.deep.equal([]);
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, fireEvent, screen } from '@testing-library/react';
import { afterEach } from 'vitest';
import sinon from 'sinon';
import { aBoardWithBindingOutputs } from './given/a_board_with_binding_outputs';

describe('when selection is not enabled', () => {
afterEach(cleanup);

for (const selectionMode of [undefined, 'none']) {
it(`should leave titles as text and publish null with selection mode ${selectionMode ?? 'omitted'}`, () => {
const { container, published } = aBoardWithBindingOutputs({ selectionMode });
container.querySelectorAll('.foundation-board-card button').length.should.equal(0);
container.querySelectorAll('.foundation-board-card strong').length.should.equal(2);
[published.selectedItem].should.deep.equal([null]);
});
}

it('should clear an existing selection when selection is turned off', () => {
const { update, published, container } = aBoardWithBindingOutputs();
fireEvent.click(screen.getByRole('button', { name: 'Select First task' }));
update({ selectionMode: 'none' });
[published.selectedItem].should.deep.equal([null]);
container.querySelectorAll('[data-selected="true"]').length.should.equal(0);
update({ selectionMode: 'single' });
[published.selectedItem].should.deep.equal([null]);
});

it('should make no announcement and not stop propagation when Escape is pressed', () => {
const { container } = aBoardWithBindingOutputs({ selectionMode: 'none' });
const reached = sinon.spy();
document.body.addEventListener('keydown', reached);
try {
fireEvent.keyDown(container.querySelector('strong')!, { key: 'Escape' });
} finally {
document.body.removeEventListener('keydown', reached);
}
reached.callCount.should.equal(1);
screen.getByRole('status').textContent!.should.equal('');
});

it('should keep a moved card in its column when selection is toggled', () => {
const { update } = aBoardWithBindingOutputs({ selectionMode: 'none', editPermission: 'enabled' });
fireEvent.change(screen.getByRole('combobox', { name: 'Move First task' }), { target: { value: 'done' } });
(screen.getByRole('combobox', { name: 'Move First task' }) as HTMLSelectElement).value.should.equal('done');
update({ selectionMode: 'single' });
(screen.getByRole('combobox', { name: 'Move First task' }) as HTMLSelectElement).value.should.equal('done');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, fireEvent, screen } from '@testing-library/react';
import { afterEach } from 'vitest';
import { aBoardWithBindingOutputs, cards } from './given/a_board_with_binding_outputs';

describe('when the selected card is removed', () => {
afterEach(cleanup);

it('should publish null and leave no card selected', () => {
const { published, container, update } = aBoardWithBindingOutputs();
fireEvent.click(screen.getByRole('button', { name: 'Select First task' }));
update({ cards: [cards[1]] });
[published.selectedItem].should.deep.equal([null]);
container.querySelectorAll('[data-selected="true"]').length.should.equal(0);
container.querySelectorAll('[aria-pressed="true"]').length.should.equal(0);
update({ cards });
[published.selectedItem].should.deep.equal([null]);
container.querySelectorAll('[aria-pressed="true"]').length.should.equal(0);
});
});
Loading
Loading