Board

Columns of cards you move from one column to another, with the card following the pointer and a placeholder marking where it lands.

Goal

A task board, a kanban or a triage view: each column is a list, and a card moves within it or to another column. The columns scroll, and the card must stay visible when it leaves one, so it is lifted out of its column while it moves.

Primitives

  • SortableController with preview: true lifts the card into a layer on document.body through PortalController, keeps a placeholder in its slot and moves the placeholder to the target index as the pointer or the keys move. It handles the pointer drag, the keyboard path (Space lifts, the arrows move within the column, PageDown and PageUp move to the next and previous column, Space drops, Escape cancels) and the announcements.
  • SortableGroup makes the columns containers that exchange cards. There is one controller per column, all sharing the group.

Left to the recipe: where the cards live (here, the DOM: onChange inserts the card into toContainer and focuses it), the look of the lifted card ([data-sortable-state='lifted']) and the placeholder ([data-sortable-placeholder]). The lifted card is no longer inside the example, so its styles select it by its own attribute, not by an ancestor.

Example

Drag a card to another column, or focus one, press Space, press PageDown to move it to the next column, use the arrows to choose its place and press Space to drop it. The first column scrolls.

The three forms below behave the same.

Plain JavaScript

A mount function wires the controllers to the markup and returns the cleanup that undoes it.

To do

  • Write the brief
  • Sketch the layout
  • Pick the colors
  • Draft the copy
  • Review accessibility
  • Plan the launch

Doing

  • Build the header
  • Wire the form

Done

  • Set up the repo

Focus a card, press Space, PageDown for the next column, Space to drop

<div data-example="board">
	<div data-board>
		<section data-column>
			<h3>To do</h3>
			<ul aria-label="To do">
				<li data-card tabindex="0">Write the brief</li>
				<li data-card tabindex="0">Sketch the layout</li>
				<li data-card tabindex="0">Pick the colors</li>
				<li data-card tabindex="0">Draft the copy</li>
				<li data-card tabindex="0">Review accessibility</li>
				<li data-card tabindex="0">Plan the launch</li>
			</ul>
		</section>
		<section data-column>
			<h3>Doing</h3>
			<ul aria-label="Doing">
				<li data-card tabindex="0">Build the header</li>
				<li data-card tabindex="0">Wire the form</li>
			</ul>
		</section>
		<section data-column>
			<h3>Done</h3>
			<ul aria-label="Done">
				<li data-card tabindex="0">Set up the repo</li>
			</ul>
		</section>
	</div>
	<p data-status aria-live="polite">Focus a card, press Space, PageDown for the next column, Space to drop</p>
</div>
import { SortableController, SortableGroup } from '@ecopages/radiant-ui/controllers';
 
export function mount(root: HTMLElement): () => void {
	const status = root.querySelector('[data-status]')!;
	const group = new SortableGroup();
 
	const sortables = [...root.querySelectorAll('ul')].map(
		(list) =>
			new SortableController({
				group,
				preview: true,
				createPlaceholder: () => document.createElement('li'),
				getContainer: () => list,
				getItems: () => [...list.children] as HTMLElement[],
				announce: (message) => (status.textContent = message),
				onChange: ({ item, to, toContainer }) => {
					const others = [...toContainer.children].filter((child) => child !== item);
					toContainer.insertBefore(item, others[to] ?? null);
					item.focus();
				},
			}),
	);
 
	sortables.forEach((sortable) => sortable.attach());
	return () => sortables.forEach((sortable) => sortable.destroy());
}

mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.

Radiant element

The same behavior as a RadiantElement. The markup is authored inside the element and the element finds its parts with @query; there is nothing to mount, clean up or run again.

To do

  • Write the brief
  • Sketch the layout
  • Pick the colors
  • Draft the copy
  • Review accessibility
  • Plan the launch

Doing

  • Build the header
  • Wire the form

Done

  • Set up the repo

Focus a card, press Space, PageDown for the next column, Space to drop

<recipe-board>
	<div data-board>
		<section data-column>
			<h3>To do</h3>
			<ul aria-label="To do">
				<li data-card tabindex="0">Write the brief</li>
				<li data-card tabindex="0">Sketch the layout</li>
				<li data-card tabindex="0">Pick the colors</li>
				<li data-card tabindex="0">Draft the copy</li>
				<li data-card tabindex="0">Review accessibility</li>
				<li data-card tabindex="0">Plan the launch</li>
			</ul>
		</section>
		<section data-column>
			<h3>Doing</h3>
			<ul aria-label="Doing">
				<li data-card tabindex="0">Build the header</li>
				<li data-card tabindex="0">Wire the form</li>
			</ul>
		</section>
		<section data-column>
			<h3>Done</h3>
			<ul aria-label="Done">
				<li data-card tabindex="0">Set up the repo</li>
			</ul>
		</section>
	</div>
	<p data-status aria-live="polite">Focus a card, press Space, PageDown for the next column, Space to drop</p>
</recipe-board>
import { RadiantElement, customElement, query } from '@ecopages/radiant';
import { SortableGroup, createSortableController } from '@ecopages/radiant-ui/controllers';
 
@customElement('recipe-board')
export class RecipeBoard extends RadiantElement {
	@query({ selector: 'ul', all: true }) private lists!: HTMLElement[];
	@query({ selector: '[data-status]' }) private status!: HTMLElement;
 
	private readonly group = new SortableGroup();
 
	private readonly sortables = Array.from({ length: 3 }, (_, column) =>
		createSortableController(this, {
			group: this.group,
			preview: true,
			createPlaceholder: () => document.createElement('li'),
			getContainer: () => this.lists[column] ?? null,
			getItems: () => [...this.lists[column].children] as HTMLElement[],
			announce: (message) => (this.status.textContent = message),
			onChange: ({ item, to, toContainer }) => {
				const others = [...toContainer.children].filter((child) => child !== item);
				toContainer.insertBefore(item, others[to] ?? null);
				item.focus();
			},
		}),
	);
}

Each column gets its own createSortableController(this, ...), all sharing a SortableGroup kept in a field. The controllers are created in the element, one for each of the three columns, and look their column up by index when they attach, so @query({ selector: 'ul', all: true }) is read lazily and the markup stays plain. They attach when the element connects and are destroyed when it disconnects.

What the Radiant form removes: the mount function and its registration, the cleanup that destroys each controller, and the re-run after a client-side navigation.

Radiant component

The same board as one self-contained tag. The element owns its markup: it keeps the columns and their cards as state and draws them with render(), so there is no markup to author or copy and the page holds only the tag.

<recipe-board-app></recipe-board-app>
import { RadiantElement, customElement, state } from '@ecopages/radiant';
import { SortableGroup, createSortableController } from '@ecopages/radiant-ui/controllers';
 
type Card = { id: string; title: string };
type Column = { id: string; title: string; cards: Card[] };
 
const initialColumns: Column[] = [
	{
		id: 'todo',
		title: 'To do',
		cards: [
			{ id: 'brief', title: 'Write the brief' },
			{ id: 'layout', title: 'Sketch the layout' },
			{ id: 'colors', title: 'Pick the colors' },
			{ id: 'copy', title: 'Draft the copy' },
			{ id: 'a11y', title: 'Review accessibility' },
			{ id: 'launch', title: 'Plan the launch' },
		],
	},
	{
		id: 'doing',
		title: 'Doing',
		cards: [
			{ id: 'header', title: 'Build the header' },
			{ id: 'form', title: 'Wire the form' },
		],
	},
	{ id: 'done', title: 'Done', cards: [{ id: 'repo', title: 'Set up the repo' }] },
];
 
function moveCard(columns: Column[], cardId: string, toColumn: string, to: number): Column[] {
	const card = columns.flatMap((column) => column.cards).find((candidate) => candidate.id === cardId)!;
	return columns.map((column) => {
		const cards = column.cards.filter((candidate) => candidate.id !== cardId);
		if (column.id === toColumn) cards.splice(to, 0, card);
		return { ...column, cards };
	});
}
 
@customElement('recipe-board-app')
export class RecipeBoardApp extends RadiantElement<{ status: string }> {
	@state columns = initialColumns;
	@state status = 'Focus a card, press Space, PageDown for the next column, Space to drop';
 
	private focusCard: string | null = null;
	private readonly group = new SortableGroup();
 
	private readonly sortables = initialColumns.map((_, column) =>
		createSortableController(this, {
			group: this.group,
			preview: true,
			createPlaceholder: () => document.createElement('li'),
			getContainer: () => this.querySelectorAll('ul')[column] ?? null,
			getItems: () => [...(this.querySelectorAll('ul')[column]?.children ?? [])] as HTMLElement[],
			announce: (message) => (this.status = message),
			onChange: ({ item, to, toContainer }) => {
				this.focusCard = item.dataset.card!;
				this.columns = moveCard(this.columns, item.dataset.card!, toContainer.dataset.columnId!, to);
			},
		}),
	);
 
	/**
	 * Attaches the sortables to the lists the render produced, and focuses the card that was just moved.
	 *
	 * @remarks
	 * The controllers attach when the element connects, before the first render, when no list exists yet; a second
	 * `attach()` resolves each list again. The keyed sections keep their lists across renders, so later calls change
	 * nothing, but one that replaced a list would need them. A card that changed column is a new node, so it is found
	 * by its id.
	 */
	protected override updated(): void {
		for (const sortable of this.sortables) sortable.attach();
		if (this.focusCard) {
			this.querySelector<HTMLElement>(`[data-card="${this.focusCard}"]`)?.focus();
			this.focusCard = null;
		}
	}
 
	override render() {
		return (
			<>
				<div data-board>
					{this.columns.map((column) => (
						<section key={column.id} data-column>
							<h3>{column.title}</h3>
							<ul aria-label={column.title} data-column-id={column.id}>
								{column.cards.map((card) => (
									<li key={card.id} data-card={card.id} tabindex="0">
										{card.title}
									</li>
								))}
							</ul>
						</section>
					))}
				</div>
				<p data-status aria-live="polite">
					{this.$.status}
				</p>
			</>
		);
	}
}

The columns are @state, so a move is a new array: onChange does not touch the DOM, it writes the moved card into columns with moveCard, and render() draws the result. A render can replace a list, so updated(), which runs after each commit, attaches the sortables again, and focuses the card that moved by its id, because a card that changed column is a new node. The status line is bound with this.$.status, so announcing during a sort patches one text node and never re-renders the columns while a card is lifted.

Compared with the Radiant element above, the markup lives inside the element, so there is nothing to keep in step with the page and the data is state you can change, but restyling the structure means editing the class. It also needs the render path on the server to have content before the script runs: this docs build prerenders the tag empty and the client draws it, so the page shows nothing until the script loads, which a page of authored children does not.

Styles

The same attributes style the lifted card, the placeholder and the column a card is over; touch-action: none on the cards lets a touch drag move them instead of scrolling.

[data-example='board'].primitive-example,
.primitive-example:has(> recipe-board, > recipe-board-app) {
	flex-direction: column;
	flex-wrap: nowrap;
	align-items: stretch;
}
 
:is([data-example='board'], recipe-board, recipe-board-app) :where([data-board]) {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	gap: var(--space-3);
}
 
:is([data-example='board'], recipe-board, recipe-board-app) :where([data-column]) {
	display: flex;
	flex-direction: column;
	gap: var(--space-2);
	min-width: 0;
	padding: var(--space-2);
	border: 1px solid var(--border);
	border-radius: var(--radius-container);
	background: var(--surface-container);
	color: var(--on-surface-container);
}
 
:is([data-example='board'], recipe-board, recipe-board-app) :where(h3) {
	margin: 0;
	font-size: 0.875rem;
}
 
:is([data-example='board'], recipe-board, recipe-board-app) :where(ul) {
	min-height: 3rem;
	max-height: 8rem;
	overflow-y: auto;
	border-radius: var(--radius-control);
}
 
:is([data-example='board'], recipe-board, recipe-board-app) ul[data-sortable-over] {
	outline: 2px solid var(--primary);
	outline-offset: -2px;
}
 
[data-card] {
	padding: var(--space-1) var(--space-3);
	border: 1px solid var(--border);
	border-radius: var(--radius-control);
	background: var(--surface);
	color: var(--on-surface);
	font-size: 0.875rem;
	cursor: grab;
	touch-action: none;
	user-select: none;
}
 
[data-card]:focus-visible {
	outline: 2px solid var(--focus-ring);
	outline-offset: 2px;
}
 
[data-card][data-sortable-state='lifted'] {
	z-index: 10;
	background: var(--primary);
	color: var(--on-primary);
	box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.25);
	list-style: none;
	cursor: grabbing;
}
 
:is([data-example='board'], recipe-board, recipe-board-app) [data-sortable-placeholder] {
	border: 1px dashed var(--primary);
	border-radius: var(--radius-control);
	background: transparent;
}
 
:is([data-example='board'], recipe-board, recipe-board-app) [data-sortable-drop] {
	box-shadow: none;
}