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
10 changes: 10 additions & 0 deletions .changeset/12883-kanban-card-enter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/plugin-kanban': patch
---

A focused kanban card opens on Enter and on Space (objectui#12883). Every card was already a keyboard tab stop, but its only activation was the pointer click, so a keyboard user could reach a card and could not open its record from the board.

- Enter, and Space on its release, call the same `onCardClick` a pointer click calls, handing it the key event. Its readers take `target` (the popover anchor) and `metaKey` / `ctrlKey` (open in a new tab) from it, which a key event carries; a key event has no `button`, so it never reads as a middle click.
- The card is now a `button` inside a `listitem`, the shape for an activatable item in a list. The list item keeps its name, and the card's description (objectui#12837) stays on the focusable button.
- A key pressed on an element inside a card, such as a link a field renders, does not open the card.
- Keyboard drag is unchanged: the board still builds no keyboard sensor.
66 changes: 60 additions & 6 deletions examples/schema-catalog/test/kanban-column-cards-6939.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,16 @@ const DND_KIT_DEFAULT_INSTRUCTIONS =
const KANBAN_DND_INSTRUCTIONS_EN = 'To move this card to another column, drag it with a pointer or by touch.';

/**
* The AUTHORED (`cards`) spelling as it reads TODAY, after objectui#12837
* replaced the board's instructions text. Written as its own literal, not
* derived from `PRE_REPAIR_PRE_12837`, so the delta case compares two
* independently written readings.
* The AUTHORED (`cards`) spelling after objectui#12837 replaced the board's
* instructions text. Written as its own literal, not derived from
* `PRE_REPAIR_PRE_12837`, so the delta case compares two independently
* written readings.
*
* ⚠️ SUPERSEDED as the expected reading by objectui#12883 (see `PRE_REPAIR`
* below), and kept: the objectui#12837 delta case ends on it, and the
* objectui#12883 delta case starts from it.
*/
const PRE_REPAIR: Record<(typeof IDS)[number], Reading> = {
const PRE_REPAIR_PRE_12883: Record<(typeof IDS)[number], Reading> = {
'plugin-kanban/basic-kanban-board': {
elements: 64,
tags: { DIV: 52, SPAN: 6, H3: 3, STYLE: 3 },
Expand All @@ -241,6 +245,29 @@ const PRE_REPAIR: Record<(typeof IDS)[number], Reading> = {
},
};

/**
* The AUTHORED (`cards`) spelling as it reads TODAY, after objectui#12883 made
* each card a `button` inside its own `listitem` wrapper: one more `<div>` per
* card, and nothing else — no text moved. Written as its own literal, not
* derived from `PRE_REPAIR_PRE_12883`, so the delta case below compares two
* independently written readings. The `items` readings do not move: that
* board draws no card.
*/
const PRE_REPAIR: Record<(typeof IDS)[number], Reading> = {
'plugin-kanban/basic-kanban-board': {
elements: 68,
tags: { DIV: 56, SPAN: 6, H3: 3, STYLE: 3 },
sha256: 'fe90e1cc6d10ddd0ba415df89ecdf6be451a9ba1831ce4adfdacbbb77702d263',
visibleText: "To Do2Design new featureCreate mockups and wireframesWrite documentationUpdate API docsIn Progress1Implement featureCode the new componentHigh PriorityDone1Setup projectInitialize repositoryTo move this card to another column, drag it with a pointer or by touch.",
},
'plugin-kanban/advanced-kanban-with-badges-and-limits': {
elements: 90,
tags: { DIV: 72, SPAN: 10, H3: 4, STYLE: 4 },
sha256: 'f80e461758a7005a05d13bd53afe4604cc9b465cbb9d6e70961108b69fdec303',
visibleText: "Backlog2User AuthenticationImplement OAuth2.0BackendHighDark ModeAdd theme switcherUIWork In Progress1 / 3API IntegrationConnect to backend servicesIn ProgressCode Review0No cardsCompleted1Project SetupRepository initializedTo move this card to another column, drag it with a pointer or by touch.",
},
};

/**
* The `items` spelling as it reads TODAY, after objectui#12837. objectui#6939's
* result is unchanged: the board is still empty, says "No cards", and reads
Expand Down Expand Up @@ -480,7 +507,7 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer
// instructions node every card's `aria-describedby` points at stays, and
// only its text changes from dnd-kit's default to the bundle sentence.
const pairs = [
['authored (`cards`)', PRE_REPAIR_PRE_12837[id], PRE_REPAIR[id]],
['authored (`cards`)', PRE_REPAIR_PRE_12837[id], PRE_REPAIR_PRE_12883[id]],
['`items`', ITEMS_SPELLING_PRE_12837[id], ITEMS_SPELLING[id]],
] as const;
for (const [spelling, before, after] of pairs) {
Expand All @@ -499,4 +526,31 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer
expect(after.sha256, `${spelling}: one hash referenced twice`).not.toBe(before.sha256);
}
});

it.each(IDS)('%s: the authored census moved by exactly one wrapper <div> per card (objectui#12883)', (id) => {
// The same discipline again. objectui#12883 made each card a `button` (the
// control Enter and Space activate) inside a `listitem` wrapper, because a
// `button` cannot itself be the item its `role="list"` needs. That adds
// one `<div>` per card and nothing else. The card count is derived from
// the DOCUMENT, so a fixture that gains a card cannot satisfy this with
// the other entry's number.
const before = PRE_REPAIR_PRE_12883[id];
const after = PRE_REPAIR[id];
const cardCount = (getExample(id).schema as { columns: Array<{ cards: unknown[] }> }).columns.reduce(
(n, c) => n + c.cards.length,
0,
);
expect(cardCount, 'a board with no card would make this delta vacuous').toBeGreaterThan(0);

// (1) exactly one element per card, and every one of them is a <div>.
expect(after.elements).toBe(before.elements + cardCount);
const { DIV: divBefore, ...otherBefore } = before.tags;
const { DIV: divAfter, ...otherAfter } = after.tags;
expect(divAfter, 'not one <div> per card').toBe(divBefore + cardCount);
expect(otherAfter, 'a tag other than <div> moved').toEqual(otherBefore);

// (2) ⛔ no text moved: the wrapper draws nothing.
expect(after.visibleText, 'the visible text moved').toBe(before.visibleText);
expect(after.sha256, 'the text hash moved').toBe(before.sha256);
});
});
48 changes: 44 additions & 4 deletions packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,19 @@ export type { KanbanCard, KanbanColumn } from './types'
// `@object-ui/types` gives the grid's and list view's rule (objectui#6349,
// batch 7); no package entry reached the alias.

/**
* The event a card activation hands `onCardClick`: the pointer click, or the
* Enter / Space key that activates a focused card the same way
* (objectui#12883). Both carry what the readers of this channel take from it —
* `target` for the popover anchor, `metaKey` / `ctrlKey` for open-in-new-tab;
* a key event has no `button`, so it never reads as a middle click.
*/
export type KanbanCardActivationEvent = React.MouseEvent | React.KeyboardEvent

export interface KanbanBoardProps {
columns: KanbanColumn[]
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
onCardClick?: (card: KanbanCard, event?: React.MouseEvent) => void
onCardClick?: (card: KanbanCard, event?: KanbanCardActivationEvent) => void
className?: string
quickAdd?: boolean
onQuickAdd?: (columnId: string, title: string) => void
Expand Down Expand Up @@ -218,7 +227,7 @@ function getCardStyles(
return resolveConditionalFormatting(card as Record<string, unknown>, rules as any, scope, objectFields as never) as React.CSSProperties
}

function SortableCard({ card, onCardClick, conditionalFormatting, objectFields }: { card: KanbanCard; onCardClick?: (card: KanbanCard, event?: React.MouseEvent) => void; conditionalFormatting?: KanbanConditionalFormattingRule[]; objectFields?: unknown }) {
function SortableCard({ card, onCardClick, conditionalFormatting, objectFields }: { card: KanbanCard; onCardClick?: (card: KanbanCard, event?: KanbanCardActivationEvent) => void; conditionalFormatting?: KanbanConditionalFormattingRule[]; objectFields?: unknown }) {
// A disabled sortable attaches no drag listeners and reports
// `aria-disabled` (objectui#12082).
const movable = React.useContext(KanbanCardsMovable)
Expand All @@ -244,8 +253,38 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields }
const owner = (card as { cardOwner?: KanbanCardOwner }).cardOwner

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
// objectui#12883 — the list item and the control are two elements. The
// card is a tab stop (dnd-kit's `tabIndex`), so it is a control a key
// activates: a `button`, the role dnd-kit itself gives a draggable, whose
// Enter and Space take the SAME `onCardClick` a pointer click takes. A
// `button` cannot also be the `listitem` its `list` needs, so the item
// wraps it — the APG shape for an activatable item in a list. The
// description dnd-kit points `aria-describedby` at (objectui#12837) stays
// on the focusable element, where it is read.
<div role="listitem" aria-label={card.title}>
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="button" aria-label={card.title}
onClick={(e) => onCardClick?.(card, e)}
onKeyDown={(e) => {
// A key on something inside the card (a link a field renders) is that
// element's own; only the card's own key opens it.
if (e.target !== e.currentTarget) return
if (e.key === 'Enter') {
e.preventDefault()
onCardClick?.(card, e)
} else if (e.key === ' ') {
// A button activates on Space's release; held here, the press
// would scroll the board. No sensor binds a key (the board builds
// pointer and touch only), so Space is free.
e.preventDefault()
}
}}
onKeyUp={(e) => {
if (e.target !== e.currentTarget) return
if (e.key === ' ') {
e.preventDefault()
onCardClick?.(card, e)
}
}}
>
<Card className={cn("mb-2 border-border border-l-4 border-l-primary/40 bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation", movable ? "cursor-grab active:cursor-grabbing" : "cursor-pointer")} style={cardStyles}>
{card.coverImage && (
Expand Down Expand Up @@ -328,6 +367,7 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields }
)}
</Card>
</div>
</div>
)
}

Expand Down Expand Up @@ -607,7 +647,7 @@ function KanbanColumnView({
}: {
column: KanbanColumn
cards: KanbanCard[]
onCardClick?: (card: KanbanCard, event?: React.MouseEvent) => void
onCardClick?: (card: KanbanCard, event?: KanbanCardActivationEvent) => void
quickAdd?: boolean
onQuickAdd?: (columnId: string, title: string) => void
conditionalFormatting?: KanbanConditionalFormattingRule[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async function mountBoard(perms: MePermissionsResponse | null) {
}

/** The card's own element — the sortable node, whose `aria-disabled` dnd-kit sets. */
const cardNode = () => screen.getByRole('listitem', { name: CARD });
const cardNode = () => screen.getByRole('button', { name: CARD });

async function dropOnInProgress() {
expect(dnd.onDragEnd).toBeTypeOf('function');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* A focused kanban card opens on Enter and on Space, through the SAME
* `onCardClick` a pointer click takes — objectui#12883.
*
* The defect: every card is a keyboard tab stop (dnd-kit's `tabIndex`), but
* its only activation was `onClick`, so Enter and Space on a focused card did
* nothing and a keyboard user could not open a record from the board. The
* card was a bare `listitem`, a role no key activates.
*
* The instrument: the real board, the real `DndContext`, real DOM events.
* Space is free for the card: the board builds no `KeyboardSensor`
* (objectui#12837's pin), and only that sensor binds a key.
*
* The controls: a key on an element inside the card is that element's own and
* opens nothing; a pointer click still calls `onCardClick` once, with the click.
*/

import '@testing-library/jest-dom/vitest'
import * as React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { I18nProvider } from '@object-ui/i18n'
import type { KanbanCard, KanbanColumn } from '../types'
import KanbanBoard from '../KanbanImpl'

const TITLE = 'Content backlog'

const columns = (): KanbanColumn[] => [
{
id: 'todo',
title: 'To Do',
cards: [
{
id: 'c1',
title: TITLE,
// Two interactive elements inside the card: the controls' targets.
cardFieldCells: [
{ field: 'site', label: 'Site', node: <a href="#site">example.com</a> },
{ field: 'note', label: 'Note', node: <input aria-label="Note" defaultValue="" /> },
],
},
],
},
{ id: 'done', title: 'Done', cards: [] },
]

type Call = { card: KanbanCard; type: string | undefined }

async function renderBoard() {
const calls: Call[] = []
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<KanbanBoard
columns={columns()}
onCardMove={() => {}}
onCardClick={(card, event) => calls.push({ card, type: event?.type })}
/>
</I18nProvider>,
)
await screen.findByText('No cards')
return calls
}

/** The card's focusable control. */
const card = () => screen.getByRole('button', { name: TITLE })

afterEach(() => {
cleanup()
})

describe('a focused kanban card opens on Enter and Space, as a click does (objectui#12883)', () => {
it('the card is a button inside the listitem its list needs', async () => {
await renderBoard()
const item = screen.getByRole('listitem', { name: TITLE })
expect(within(item).getByRole('button', { name: TITLE })).toBe(card())
expect(item.closest('[role="list"]')).not.toBeNull()
// Still a tab stop: the focus is what the keys act on.
expect(card()).toHaveAttribute('tabindex', '0')
})

it('Enter on the focused card calls onCardClick once, with that card and the key event', async () => {
const calls = await renderBoard()
const el = card()
el.focus()
expect(el).toHaveFocus()
fireEvent.keyDown(el, { key: 'Enter', code: 'Enter' })
expect(calls).toHaveLength(1)
expect(calls[0].card.id).toBe('c1')
expect(calls[0].type).toBe('keydown')
})

it('Space activates on release, as a button does; the press itself opens nothing', async () => {
const calls = await renderBoard()
const el = card()
el.focus()
const pressed = fireEvent.keyDown(el, { key: ' ', code: 'Space' })
// The press is claimed (no page scroll) and opens nothing yet.
expect(pressed).toBe(false)
expect(calls).toHaveLength(0)
fireEvent.keyUp(el, { key: ' ', code: 'Space' })
expect(calls).toHaveLength(1)
expect(calls[0].card.id).toBe('c1')
expect(calls[0].type).toBe('keyup')
})

it('CONTROL: a key on an element inside the card does not open the card', async () => {
const calls = await renderBoard()
const link = screen.getByRole('link', { name: 'example.com' })
const input = screen.getByRole('textbox', { name: 'Note' })
expect(card()).toContainElement(link)
expect(card()).toContainElement(input)
for (const el of [link, input]) {
fireEvent.keyDown(el, { key: 'Enter', code: 'Enter' })
fireEvent.keyDown(el, { key: ' ', code: 'Space' })
fireEvent.keyUp(el, { key: ' ', code: 'Space' })
}
expect(calls).toHaveLength(0)
})

it('CONTROL: other keys on the focused card open nothing', async () => {
const calls = await renderBoard()
const el = card()
el.focus()
for (const key of ['a', 'Tab', 'Escape', 'ArrowDown']) {
fireEvent.keyDown(el, { key })
fireEvent.keyUp(el, { key })
}
expect(calls).toHaveLength(0)
})

it('CONTROL: a pointer click still calls onCardClick once, with the click', async () => {
const calls = await renderBoard()
fireEvent.click(card())
expect(calls).toHaveLength(1)
expect(calls[0].card.id).toBe('c1')
expect(calls[0].type).toBe('click')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ async function renderBoard(lang: 'en' | 'zh') {

/** What the live region says now. */
const said = () => document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? null
const card = (title: string) => screen.getByRole('listitem', { name: title })
// The sortable node — the `button` inside each card's `listitem` (objectui#12883).
const card = (title: string) => screen.getByRole('button', { name: title })

/**
* Pick the card up with the pointer, then move it through `bands` (band 1, 2,
Expand Down Expand Up @@ -217,7 +218,7 @@ describe('the kanban board announces titles and labels, not ids (objectui#12716)
</I18nProvider>,
)
await screen.findByText(NO_CARDS.en)
const el = document.querySelector('[aria-roledescription="sortable"][role="listitem"]') as HTMLElement
const el = document.querySelector('[aria-roledescription="sortable"][role="button"]') as HTMLElement
el.dataset.cardId = CONTENT
const heard = pointerDrag(el, [DOING])
expect(heard[0]).toBe('Picked up an untitled card in To Do.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ async function renderBoard(lang: 'en' | 'zh') {
await screen.findByText(NO_CARDS[lang])
}

const card = () => screen.getByRole('listitem', { name: 'Content backlog' })
// The focusable control, which carries the description; since objectui#12883
// it is the `button` inside the card's `listitem`.
const card = () => screen.getByRole('button', { name: 'Content backlog' })

/** The text of the node the card's `aria-describedby` points at. */
function description(el: HTMLElement): string {
Expand Down
Loading