Sortable list
A list whose items the user reorders by dragging or from the keyboard, with each step announced.
Goal
A to-do list, a playlist or a priority ranking: the order is data the user edits. The list keeps one tab stop, lifts an item with Space, moves it with the arrow keys and drops it with Space, and a drag with the pointer does the same. A live region says what happened.
Primitives
- SortableController decides where the item lands, by pointer or keyboard, and announces each step. It never moves a node, so the recipe keeps the order itself.
- With
preview: truethe dragged item follows the pointer above the page and a placeholder holds its slot;createPlaceholderreturns anlibecause the container is aul. The lifted item leaves the example, so its style selects it bydata-task, not by an ancestor. - reorder turns the
fromandtoof a change into the new array, so the order is one array the recipe owns. - RovingController gives the list one tab stop and moves it with the arrows. Its
enabledreturnsfalsewhile an item is lifted, so the arrow keys that steer the lifted item do not also move focus.
Left to the recipe: the order array and writing it to the DOM in onChange, and refocusing the dropped item.
Example
Tab into the list, press Space on an item, move it with the arrow keys and press Space to drop it, or drag it with the pointer.
The two forms below behave the same.
Plain JavaScript
A mount function wires the controllers to the markup and returns the cleanup that undoes it.
- Plan
- Build
- Test
- Ship
Focus an item, press Space, move with the arrows, press Space to drop
<div data-example="sortable-list">
<ul aria-label="Tasks">
<li data-task>Plan</li>
<li data-task>Build</li>
<li data-task>Test</li>
<li data-task>Ship</li>
</ul>
<p data-status aria-live="polite">Focus an item, press Space, move with the arrows, press Space to drop</p>
</div>import { RovingController, SortableController } from '@ecopages/radiant-ui/controllers';
import { reorder } from '@ecopages/radiant-ui/helpers';
export function mount(root: HTMLElement): () => void {
const list = root.querySelector('ul')!;
const status = root.querySelector('[data-status]')!;
let items = [...list.children] as HTMLElement[];
const roving = new RovingController({
getItems: () => items,
getTarget: () => list,
orientation: 'vertical',
getEnabled: () => sortable.getState() === null,
});
const sortable = new SortableController({
preview: true,
createPlaceholder: () => document.createElement('li'),
getContainer: () => list,
getItems: () => items,
announce: (message) => (status.textContent = message),
onChange: ({ item, from, to }) => {
items = reorder(items, from, to);
list.append(...items);
roving.focus(item);
},
});
sortable.attach();
roving.attach();
roving.sync();
return () => {
sortable.destroy();
roving.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.
- Plan
- Build
- Test
- Ship
Focus an item, press Space, move with the arrows, press Space to drop
<recipe-sortable-list>
<ul aria-label="Tasks">
<li data-task>Plan</li>
<li data-task>Build</li>
<li data-task>Test</li>
<li data-task>Ship</li>
</ul>
<p data-status aria-live="polite">Focus an item, press Space, move with the arrows, press Space to drop</p>
</recipe-sortable-list>import { RadiantElement, customElement, query } from '@ecopages/radiant';
import { createRovingController, createSortableController } from '@ecopages/radiant-ui/controllers';
import { reorder } from '@ecopages/radiant-ui/helpers';
@customElement('recipe-sortable-list')
export class RecipeSortableList extends RadiantElement {
@query({ selector: 'ul' }) private list!: HTMLElement;
@query({ selector: '[data-status]' }) private status!: HTMLElement;
private items: HTMLElement[] = [];
private readonly roving = createRovingController(this, {
getItems: () => this.items,
getTarget: () => this.list,
orientation: 'vertical',
getEnabled: () => this.sortable.getState() === null,
});
private readonly sortable = createSortableController(this, {
preview: true,
createPlaceholder: () => document.createElement('li'),
getContainer: () => this.list,
getItems: () => this.items,
announce: (message) => (this.status.textContent = message),
onChange: ({ item, from, to }) => {
this.items = reorder(this.items, from, to);
this.list.append(...this.items);
this.roving.focus(item);
},
});
override onConnected(): void {
this.items = [...this.list.children] as HTMLElement[];
this.roving.sync();
}
}createSortableController(this, ...) and createRovingController(this, ...) attach when the element connects and are destroyed when it disconnects. They are field initializers because each refers to the other from its callbacks: enabled reads the sortable and onChange focuses through the roving controller. The items are read in onConnected, once the element has its children.
What the Radiant form removes: the mount function and its registration, the cleanup that destroys both controllers, and the re-run after a client-side navigation.
Styles
The lifted item and the placeholder are styled by data-sortable-state and data-sortable-placeholder; touch-action: none on the items lets a touch drag reorder instead of scrolling.
[data-task] {
cursor: grab;
touch-action: none;
user-select: none;
}
[data-task][data-sortable-state='lifted'] {
z-index: 10;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--primary);
color: var(--on-primary);
font-size: 0.875rem;
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.25);
list-style: none;
cursor: grabbing;
}
:is([data-example='sortable-list'], recipe-sortable-list) [data-sortable-placeholder] {
min-height: 2.5rem;
box-sizing: border-box;
outline: 1px dashed var(--primary);
outline-offset: -4px;
background: transparent;
}
:is([data-example='sortable-list'], recipe-sortable-list) [data-sortable-drop] {
box-shadow: none;
}