From cc895de40b2b448258dc264b671971d7fa51ac54 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 24 Oct 2026 15:05:58 +0200 Subject: [PATCH 1/3] Let hosts read the selected Kanban card --- .../blueprints/foundation-components.md | 5 ++ .../given/a_board_with_binding_outputs.tsx | 20 +++++ .../when_reading_the_original_card_rows.ts | 25 ++++++ .../for_KanbanBoard/when_selecting_a_card.tsx | 77 +++++++++++++++++++ ...d_with_the_keyboard_in_chromium.browser.ts | 38 +++++++++ .../when_selection_is_not_enabled.tsx | 29 +++++++ .../when_the_selected_card_is_removed.tsx | 22 ++++++ .../foundation/KanbanBoard.tsx | 34 ++++++-- .../foundation/boardModel.ts | 15 +++- .../foundation/foundationDescriptors.ts | 8 +- .../foundation/foundationStrings.ts | 2 + .../blueprint.default/shell/layout.css | 17 ++++ 12 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx create mode 100644 Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx diff --git a/Documentation/blueprints/foundation-components.md b/Documentation/blueprints/foundation-components.md index dee8baab..e87ca19c 100644 --- a/Documentation/blueprints/foundation-components.md +++ b/Documentation/blueprints/foundation-components.md @@ -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 diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx new file mode 100644 index 00000000..b32f3f92 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx @@ -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 = {}) { + const published: Record = {}; + 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 = {}) => ; + const rendered = render(draw()); + return { ...rendered, published, setOutput, onChange, onSelect, update: (overrides: Record) => rendered.rerender(draw(overrides)) }; +} diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts new file mode 100644 index 00000000..bc782c6b --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readCardRows } 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' }); + [...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' }); + rows.size.should.equal(1); + rows.get('42')!.should.equal(first); + }); + + 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); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx new file mode 100644 index 00000000..86f70b28 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx @@ -0,0 +1,77 @@ +// 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 { 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']); + }); + + 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'); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts new file mode 100644 index 00000000..3bf897bb --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts @@ -0,0 +1,38 @@ +// 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'); + (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); + chromium.errors.should.deep.equal([]); + }); + } +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx new file mode 100644 index 00000000..1fa83acf --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx @@ -0,0 +1,29 @@ +// 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 } 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]); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx new file mode 100644 index 00000000..43e9a7a6 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx @@ -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); + }); +}); diff --git a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx index 1d523be4..95acde6e 100644 --- a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx +++ b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx @@ -1,9 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DragEvent, useEffect, useMemo, useState } from 'react'; +import { DragEvent, useEffect, useMemo, useRef, useState } from 'react'; import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react'; -import { BoardCard, BoardColumn, BoardLane, cardsOf, lanesByAssignee, moveCard, overLimit, readCardFieldList, readCards, readColumnsOfBoard, totalOf } from './boardModel'; +import { BoardCard, BoardColumn, BoardLane, cardsOf, lanesByAssignee, moveCard, overLimit, readCardFieldList, readCardRows, readCards, readColumnsOfBoard, totalOf } from './boardModel'; import { ComponentState, readComponentState } from './ComponentState'; import { formatNumber, readLocale, readNumberFormat } from './formatting'; import { FoundationIcon } from './FoundationIcon'; @@ -29,6 +29,9 @@ const optional = (key: Key, value: unknown): { [Name in Key] * * `swimlane` set to `assignee` lays the board out in lanes, one for each person (in the order they first appear, the unassigned last), each across the same * columns: a task is moved between columns within its lane, by dragging or by its Move-to menu, and each column's total and count are for the lane. + * + * `selectionMode` set to `single` makes each card's title a toggle button (mouse, Enter or Space; Escape clears). `selectedItem` publishes the original + * query row, even with mapped fields and after a move, or null when cleared or removed from the supplied cards. Selection defaults to `none`. */ export function KanbanBoard({ element, interactions, bindingOutputs }: RegisteredComponentProps) { const columns = useMemo(() => readColumnsOfBoard(element.properties.columns), [element.properties.columns]); @@ -38,6 +41,14 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere }), [element.properties.cardId, element.properties.cardTitle, element.properties.cardColumn, element.properties.cardAssignee, element.properties.cardFields]); const supplied = useMemo(() => readCards(element.properties.cards, mapping), [element.properties.cards, mapping]); const suppliedKey = JSON.stringify(supplied); + const rows = useMemo(() => readCardRows(element.properties.cards, mapping), [element.properties.cards, mapping]); + const selectable = element.properties.selectionMode === 'single'; + const [selectedId, setSelectedId] = useState(); + const selectedCard = selectable ? supplied.find(card => card.id === selectedId) : undefined; + const selectedItem = selectedCard ? rows.get(selectedCard.id) ?? null : null; + const selectedKey = JSON.stringify(selectedItem); + const latestSelection = useRef(selectedItem); + latestSelection.current = selectedItem; const [cards, setCards] = useState(supplied); const [collapsed, setCollapsed] = useState>(new Set()); const [announcement, setAnnouncement] = useState(''); @@ -52,7 +63,13 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere const swimlane = element.properties.swimlane === 'assignee'; const people = useMemo(() => supplied.flatMap(card => (card.assignee ? [card.assignee] : [])), [suppliedKey]); - useEffect(() => setCards(supplied), [suppliedKey]); + useEffect(() => { + setCards(supplied); + setSelectedId(current => selectable && supplied.some(card => card.id === current) ? current : undefined); + }, [suppliedKey, selectable]); + useEffect(() => { + bindingOutputs?.setOutput('selectedItem', latestSelection.current); + }, [bindingOutputs, selectedKey]); useValueOutput(bindingOutputs, cards.map(card => ({ id: card.id, column: card.column }))); const move = (id: string, to: string) => { @@ -64,12 +81,17 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere setAnnouncement(formatText(strings.boardMoved, { title: card.title, column: target.label })); interactions?.onChange?.(); }; + const select = (card: BoardCard) => { + const next = selectedId === card.id ? undefined : card.id; + setSelectedId(next); + setAnnouncement(next === undefined ? '' : formatText(strings.boardSelected, { title: card.title })); + }; const lanes: BoardLane[] = swimlane ? lanesByAssignee(cards, people) : []; const drop = (event: DragEvent, to: string) => { event.preventDefault(); if (dragging) move(dragging, to); setDragging(undefined); }; const toggle = (id: string) => setCollapsed(current => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const board = (scope: BoardCard[], Heading: 'h3' | 'h4', laneLabel?: string) => ( -
+
{ if (event.key === 'Escape') setSelectedId(undefined); }}> {columns.map((column: BoardColumn) => { const inColumn = cardsOf(columns, scope, column.id); const closed = collapsed.has(column.id); @@ -90,8 +112,8 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere
    {inColumn.length === 0 &&
  • {strings.boardNoCards}
  • } {inColumn.map(card => ( -
  • setDragging(card.id)} onDragEnd={() => setDragging(undefined)}> - {card.title} +
  • setDragging(card.id)} onDragEnd={() => setDragging(undefined)}> + {selectable ? : {card.title}} {card.assignee && {card.assignee}} {card.fields.map(field => {field.label} {field.value})} {card.badges.length > 0 && {card.badges.map(badge => {badge})}} diff --git a/Source/JavaScript/blueprint.default/foundation/boardModel.ts b/Source/JavaScript/blueprint.default/foundation/boardModel.ts index b4e08a85..fa6b1b75 100644 --- a/Source/JavaScript/blueprint.default/foundation/boardModel.ts +++ b/Source/JavaScript/blueprint.default/foundation/boardModel.ts @@ -59,6 +59,19 @@ export interface BoardCardFields { const textOfValue = (value: unknown): string | undefined => typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : typeof value === 'boolean' ? String(value) : undefined; +const cardIdOf = (entry: Record, mapping: BoardCardFields): string | undefined => + mapping.id ? textOfValue(entry[mapping.id]) : typeof entry.id === 'string' ? entry.id : undefined; + +/** Reads the original query rows by card id, keeping the first row for each id. */ +export function readCardRows(value: unknown, mapping: BoardCardFields = {}): Map> { + const rows = new Map>(); + for (const entry of records(value)) { + const id = cardIdOf(entry, mapping); + if (id !== undefined && id.length > 0 && !rows.has(id)) rows.set(id, entry); + } + return rows; +} + /** Reads the mapping of an element's `cardFields` list: entries with a `field`, repeated fields dropped. */ export function readCardFieldList(value: unknown): { field: string; label?: string }[] { const seen = new Set(); @@ -77,7 +90,7 @@ export function readCards(value: unknown, mapping: BoardCardFields = {}): BoardC const seen = new Set(); const mapped = (mapping.fields ?? []).length > 0; return records(value).flatMap(entry => { - const id = mapping.id ? textOfValue(entry[mapping.id]) : typeof entry.id === 'string' ? entry.id : undefined; + const id = cardIdOf(entry, mapping); const title = mapping.title ? textOfValue(entry[mapping.title]) : typeof entry.title === 'string' ? entry.title : undefined; if (id === undefined || id.length === 0 || seen.has(id) || title === undefined) return []; seen.add(id); diff --git a/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts b/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts index f547d4fb..62cca9f6 100644 --- a/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts +++ b/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { BindingSourceKind, ComponentDescriptor, PropertyDescriptor, PropertyEditorKind, PropertyValueType } from '@cratis/scene.model'; +import { BindingMode, BindingSourceKind, ComponentDescriptor, PropertyDescriptor, PropertyEditorKind, PropertyValueType } from '@cratis/scene.model'; import { componentRegistryKey, valueOutputProperty } from '@cratis/scene.react'; import { ComponentName } from '../ComponentName'; import { defaultBlueprintName } from '../packageName'; @@ -563,6 +563,12 @@ export const foundationDescriptors: ComponentDescriptor[] = [ choice('format', 'Total format', 'Appearance', { number: 'Number', currency: 'Currency', percent: 'Percent', compact: 'Compact' }, 'number'), text('currency', 'Currency', 'Appearance', 'The ISO 4217 code of a currency total.'), rule('editPermission', 'Move permission', 'Enabled lets cards move; otherwise the board is read-only.'), + choice('selectionMode', 'Selection', 'Behavior', { none: 'None', single: 'Single' }, 'none', 'Lets a host read the selected card as its original query row.'), + { + path: 'selectedItem', label: 'Selected item', group: 'Outputs', valueType: PropertyValueType.Object, readOnly: true, + output: true, bindingMode: BindingMode.OneWay, + description: 'The currently selected row, or null after selection is cleared.', + }, valueOutputProperty(PropertyValueType.Json, 'Each card\'s id and column, published as cards move.'), ...standardStateProperties, ], diff --git a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts index 5469e1c4..baf31238 100644 --- a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts +++ b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts @@ -181,6 +181,8 @@ export const foundationStrings = { boardMoveTo: 'Move to…', boardMoveCard: 'Move {title}', boardMoved: '{title} moved to {column}', + boardSelectCard: 'Select {title}', + boardSelected: '{title} selected', boardColumn: '{column}, {count} cards', boardCollapse: 'Collapse {column}', boardExpand: 'Expand {column}', diff --git a/Source/JavaScript/blueprint.default/shell/layout.css b/Source/JavaScript/blueprint.default/shell/layout.css index eeceba37..cbc75291 100644 --- a/Source/JavaScript/blueprint.default/shell/layout.css +++ b/Source/JavaScript/blueprint.default/shell/layout.css @@ -2717,6 +2717,23 @@ tr[data-selectable='true'] { background: var(--foundation-surface); } +.foundation-board-card[data-selected='true'] { + border-color: var(--foundation-primary); + box-shadow: var(--foundation-focus); +} + +.foundation-board-card-select { + color: var(--foundation-text); + background: var(--foundation-surface); + border: 1px solid var(--foundation-border); + border-radius: var(--foundation-radius); + padding: var(--foundation-space-2); + font: inherit; + font-weight: 600; + text-align: start; + cursor: pointer; +} + .foundation-board-card[draggable='true'] { cursor: grab; } From 5c91db8fc9df23a5c2af7163f40b0510ca04bec4 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 24 Oct 2026 15:32:52 +0200 Subject: [PATCH 2/3] Fix Kanban selection row lookup and clearing feedback --- .../when_reading_the_original_card_rows.ts | 21 ++++++++++++-- .../for_KanbanBoard/when_selecting_a_card.tsx | 28 +++++++++++++++++++ ...d_with_the_keyboard_in_chromium.browser.ts | 5 ++++ .../foundation/KanbanBoard.tsx | 13 +++++++-- .../foundation/boardModel.ts | 6 +++- .../foundation/foundationStrings.ts | 1 + 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts index bc782c6b..7eea3d6c 100644 --- a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts @@ -1,23 +1,38 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { readCardRows } from '../foundation/boardModel'; +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' }); + 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' }); + 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); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx index 86f70b28..598e6c3c 100644 --- a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx @@ -28,6 +28,34 @@ describe('when selecting a card', () => { 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 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', () => { diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts index 3bf897bb..3b1ed057 100644 --- a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts @@ -23,6 +23,10 @@ describe('when selecting a card with the keyboard in Chromium', () => { 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'); @@ -32,6 +36,7 @@ describe('when selecting a card with the keyboard in Chromium', () => { 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([]); }); } diff --git a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx index 95acde6e..ffcbaa40 100644 --- a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx +++ b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DragEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react'; import { BoardCard, BoardColumn, BoardLane, cardsOf, lanesByAssignee, moveCard, overLimit, readCardFieldList, readCardRows, readCards, readColumnsOfBoard, totalOf } from './boardModel'; import { ComponentState, readComponentState } from './ComponentState'; @@ -84,14 +84,21 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere const select = (card: BoardCard) => { const next = selectedId === card.id ? undefined : card.id; setSelectedId(next); - setAnnouncement(next === undefined ? '' : formatText(strings.boardSelected, { title: card.title })); + setAnnouncement(next === undefined ? strings.boardSelectionCleared : formatText(strings.boardSelected, { title: card.title })); + }; + const clearWithEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (!(event.target instanceof HTMLElement)) return; + if (event.target.closest('input, select, textarea, [contenteditable]')) return; + setSelectedId(undefined); + setAnnouncement(strings.boardSelectionCleared); }; const lanes: BoardLane[] = swimlane ? lanesByAssignee(cards, people) : []; const drop = (event: DragEvent, to: string) => { event.preventDefault(); if (dragging) move(dragging, to); setDragging(undefined); }; const toggle = (id: string) => setCollapsed(current => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const board = (scope: BoardCard[], Heading: 'h3' | 'h4', laneLabel?: string) => ( -
    { if (event.key === 'Escape') setSelectedId(undefined); }}> +
    {columns.map((column: BoardColumn) => { const inColumn = cardsOf(columns, scope, column.id); const closed = collapsed.has(column.id); diff --git a/Source/JavaScript/blueprint.default/foundation/boardModel.ts b/Source/JavaScript/blueprint.default/foundation/boardModel.ts index fa6b1b75..2798bb03 100644 --- a/Source/JavaScript/blueprint.default/foundation/boardModel.ts +++ b/Source/JavaScript/blueprint.default/foundation/boardModel.ts @@ -62,11 +62,15 @@ const textOfValue = (value: unknown): string | undefined => const cardIdOf = (entry: Record, mapping: BoardCardFields): string | undefined => mapping.id ? textOfValue(entry[mapping.id]) : typeof entry.id === 'string' ? entry.id : undefined; +const cardTitleOf = (entry: Record, mapping: BoardCardFields): string | undefined => + mapping.title ? textOfValue(entry[mapping.title]) : typeof entry.title === 'string' ? entry.title : undefined; + /** Reads the original query rows by card id, keeping the first row for each id. */ export function readCardRows(value: unknown, mapping: BoardCardFields = {}): Map> { const rows = new Map>(); for (const entry of records(value)) { const id = cardIdOf(entry, mapping); + if (cardTitleOf(entry, mapping) === undefined) continue; if (id !== undefined && id.length > 0 && !rows.has(id)) rows.set(id, entry); } return rows; @@ -91,7 +95,7 @@ export function readCards(value: unknown, mapping: BoardCardFields = {}): BoardC const mapped = (mapping.fields ?? []).length > 0; return records(value).flatMap(entry => { const id = cardIdOf(entry, mapping); - const title = mapping.title ? textOfValue(entry[mapping.title]) : typeof entry.title === 'string' ? entry.title : undefined; + const title = cardTitleOf(entry, mapping); if (id === undefined || id.length === 0 || seen.has(id) || title === undefined) return []; seen.add(id); const fields = mapped diff --git a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts index baf31238..21dd44f9 100644 --- a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts +++ b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts @@ -183,6 +183,7 @@ export const foundationStrings = { boardMoved: '{title} moved to {column}', boardSelectCard: 'Select {title}', boardSelected: '{title} selected', + boardSelectionCleared: 'Selection cleared', boardColumn: '{column}, {count} cards', boardCollapse: 'Collapse {column}', boardExpand: 'Expand {column}', From f2c73183735a52be03e7a0224a9e09b79a257eb1 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 24 Oct 2026 16:17:46 +0200 Subject: [PATCH 3/3] Keep Escape inert on boards without a selection --- .../for_KanbanBoard/when_selecting_a_card.tsx | 24 +++++++++++++++++++ .../when_selection_is_not_enabled.tsx | 22 +++++++++++++++++ .../foundation/KanbanBoard.tsx | 4 ++++ 3 files changed, 50 insertions(+) diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx index 598e6c3c..57552e9e 100644 --- a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx @@ -3,6 +3,7 @@ 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'; @@ -41,6 +42,29 @@ describe('when selecting a card', () => { 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' }); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx index 1fa83acf..0906d590 100644 --- a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx @@ -3,6 +3,7 @@ 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', () => { @@ -26,4 +27,25 @@ describe('when selection is not enabled', () => { 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'); + }); }); diff --git a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx index ffcbaa40..1309e9a8 100644 --- a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx +++ b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx @@ -65,6 +65,8 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere useEffect(() => { setCards(supplied); + }, [suppliedKey]); + useEffect(() => { setSelectedId(current => selectable && supplied.some(card => card.id === current) ? current : undefined); }, [suppliedKey, selectable]); useEffect(() => { @@ -90,6 +92,8 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere if (event.key !== 'Escape') return; if (!(event.target instanceof HTMLElement)) return; if (event.target.closest('input, select, textarea, [contenteditable]')) return; + if (!selectable || selectedId === undefined) return; + event.stopPropagation(); setSelectedId(undefined); setAnnouncement(strings.boardSelectionCleared); };