Belar

Building an accessible list


Static list

By default, lists are accessible, accessibility tools (e.g. a screen reader) can handle semantically defined HTML lists. For example:

<ul>
	<li>Key</li>
	<li>Bag</li>
</ul>

However, things change once you start dynamically modifying a list, by adding and removing items.

Dynamic focus

There doesn’t seem to be a lot of info on how to handle focus after an item has been deleted. The general rule you can use is to ensure context’s continuity and that meaning and operability are preserved (based on focus order criteria).

Addition of an item gives you 2 primary paths:

Item removal, by its nature, requires you to handle it. With some of the options being:

  1. move focus to a dummy-item that is a representation of a deleted one e.g. a list item with a ”” label that communicates context,
  2. move focus to the next or the previous item, whichever works best for the navigational order,
  3. move focus to an element that communicates context best,
  4. reset focus to the top of a list.

Personally, I think the last item should be the last resort, and primarily you should use a mix of 1 or 2 and 3.

An example list

Note: Code examples are done in Svelte, with project running the default SvelteKit setup.

Full source code for the project can be found on GitHub, in the a11y-list repo.

Scenario 1: Delete a non-last list item

Decision between soft delete or focus switch depends on the mechanics of an application. For example, in my opinion, elaborate resources like a set of multi-input forms requires a stronger action that is a switch of a focus to a resource with similar capabilities - for example, an earlier resource in a list. However, for a simple item, like a text entry, a replacement with a text (a label, that has the same level of complexity) is better.

The example has a text-based list, generated from an array of objects:

type Entry = {
	name: string;
	isDeleted?: boolean;
};

let list: Entry[] = [
	{
		name: "Keys",
	},
	{
		name: "Bag",
	},
	{
		name: "Phone",
	},
];

and will use soft delete for item removal. List is rendered with the List component, that accepts an array of entries and offers 2 events, deletion request (delete) and a clean up of deleted item(s) (deleteClear).

const dispatch = createEventDispatcher<{
	delete: Entry;
	deleteClear: null;
}>();
<List {list} on:delete={({ detail }) => deleteEntry(detail)} on:deleteClear={clearDeleted} />

On delete, an entry will be marked as deleted ({ isDeleted: true }), which will make it render in a deleted state. Afterwards, a deletion handler would also apply focus to the body of the entry, retaining the context:

function deleteEntry(entry: (typeof list)[number]) {
	const listUpdated = list.map((existingEntry) =>
		existingEntry === entry
			? { ...existingEntry, isDeleted: true }
			: existingEntry
	);
	list = listUpdated;
}
<script lang="ts">
	const ITEM_DELETED_PLACEHOLDER = '<Deleted>';

	let softDeleteHolderElement: HTMLElement | null;

	async function handleInitialDelete(event: MouseEvent | KeyboardEvent, entry: Entry) {
		if (event.target instanceof Element) {
			softDeleteHolderElement = event.target?.parentElement;
			dispatchDelete(entry);

			softDeleteHolderElement?.focus();
		}
	}

	async function dispatchDelete(entry: Entry) {
		dispatch('delete', entry);
	}
</script>

{#if isDeleted}
  {ITEM_DELETED_PLACEHOLDER}
{:else}
  {name}
  <Button on:click={(event) => handleInitialDelete(event, entry)}>Delete</Button>
{/if}

At this point you need to handle 2 scenarios, a traditional action coming from a mouse or touch, or a focus-based tool like a keyboard. If the action is coming from the first, a deletion with immediate clean up is ordered:

if (!isKeyPress(event)) {
	dispatchDelete(entry);
	await tick();
	dispatchDeleteCleanUp();
	return;
}

in other cases, a keyboard navigation, you wait for user’s action:

<script lang="ts">
let itemRefs: HTMLElement[] = [];

function handleContextFocus({ nextItem }: { nextItem?: HTMLElement } = {}) {
	softDeleteHolderElement?.addEventListener("keydown", (event) => {
		const isForwardNav = isForwardKeyboardNavigation(event);
		const isBackNav = isBackKeyboardNavigation(event);

		if (isForwardNav || isBackNav) {
			const nextFocusableElement =
				nextItem && getFocusableElements(nextItem)?.[0];
			if (isForwardNav && nextFocusableElement) {
				nextFocusableElement.focus();
			}

			dispatchDeleteCleanUp();
		}
	});
}

async function dispatchDeleteCleanUp() {
	softDeleteHolderElement = null;

	dispatch('deleteClear');
}
</script>

<li
	tabindex="-1"
	bind:this={itemRefs[i]}
	on:focus={() => handleContextFocus({ nextItem: itemRefs[i++] })}
></li>

As long as a user keeps focus on a deleted element, it will stay in a UI with a dummy, a deleted state. On a navigation, to the previous or the next element, a clean up will be executed and the dummy item will be removed from the records and the DOM.

Above could be extended to use blur event, supporting any action resulting in focus removal from a deleted entry.

Scenario 2: The last list item

That covers the case of removing one of multiple items of a list, but a list can have a single item where deletion makes it empty. That scenario is a good case for “moving focus to an element that communicates context best”. In the example, it’s a paragraph informing a user about a list being empty.

<script lang="ts">
	let emptyListRef: HTMLElement;

	$: hasRecords = !!list.length;
</script>

{#if hasRecords}
	...
{:else}
	<p tabindex="-1" bind:this={emptyListRef}>List is empty</p>
{/if}

That also means, that in an event of removing the last item, it’s safe to perform an immediate clean up (there are no items for a user to navigate to)

async function handleInitialDelete(
	event: MouseEvent | KeyboardEvent,
	entry: Entry
) {
	// if (!isKeyPress(event)) {
	// 	dispatchDelete(entry);
	// 	await tick();
	// 	dispatchDeleteCleanUp();
	// 	return;
	// }

	// if (event.target instanceof Element) {
	// 	softDeleteHolderElement = event.target?.parentElement;
	// 	dispatchDelete(entry);

	const isTheLastElement = list.length === 1;
	if (isTheLastElement) {
		dispatchDeleteCleanUp();
		return;
	}

	// 	softDeleteHolderElement?.focus();
	// }
}

and you should move focus to the paragraph about an empty list.

async function dispatchDeleteCleanUp() {
	// softDeleteHolderElement = null;

	// dispatch("deleteClear");

	await tick();
	if (!hasRecords) {
		emptyListRef?.focus();
	}
}

With above a list can handle both scenarios, a removal of a list item and emptying of a list.