Tree
componentA tree of items, on the WAI-ARIA pattern.
Preview
- Documents
- Photos
Code
<twig:Tree label="Files">
<twig:Tree:Item label="Documents" :branch="true" :expanded="true">
<twig:Tree:Item label="Report" href="/report" />
</twig:Tree:Item>
<twig:Tree:Item label="Photos" />
</twig:Tree>
- Documents
- Photos
Code
<twig:Tree label="Files">
<twig:Tree:Item label="Documents" :branch="true" :expanded="true">
<twig:Tree:Item label="Report" href="/report" />
</twig:Tree:Item>
<twig:Tree:Item label="Photos" />
</twig:Tree>
Installation
php bin/console ux:install tree --kit=github.com/parler-haut-interagir-librement/ux-kit
Accessibility
One stop of the Tab key, then the arrows, mirrored right to left: down and up from item to item, forward to open or enter, back to close or leave. Home, End and first letters move, Enter and the Space bar choose. Before the script runs, everything it would fold shows. tree:select carries the value chosen.
Files to copy
assets/controllers/tree_controller.js
import { Controller } from '@hotwired/stimulus';
/** How long the letters of a search at the keyboard add up, in milliseconds. */
export const TYPING = 500;
const ITEM = '[role="treeitem"]';
/*
* A tree on the WAI-ARIA pattern: one stop of the Tab key, the arrows mirrored right to left, a branch opened and
* closed in place, Home, End, a search by first letters, Enter and the Space bar choosing. `tree:select` carries the
* value of the item chosen.
*/
export default class extends Controller {
static targets = ['item', 'toggle', 'link'];
#typed = '';
#typing = null;
connect() {
const first = this.itemTargets.find((item) => item.getAttribute('aria-selected') === 'true') ?? this.itemTargets[0];
this.#rove(first);
}
disconnect() {
clearTimeout(this.#typing);
}
navigate(event) {
const item = event.target.closest(ITEM);
if (!item || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (this.#handle(event.key, item)) {
event.preventDefault();
event.stopPropagation();
}
}
activate(event) {
const item = event.target.closest(ITEM);
if (!item) {
return;
}
this.#focus(item);
if (this.toggleTargets.includes(event.target)) {
this.#flip(item);
return;
}
this.#choose(item);
this.#flip(item);
}
#handle(key, item) {
const rtl = getComputedStyle(this.element).direction === 'rtl';
const open = rtl ? 'ArrowLeft' : 'ArrowRight';
const close = rtl ? 'ArrowRight' : 'ArrowLeft';
const visible = this.#visible();
const index = visible.indexOf(item);
if (key === 'ArrowDown') {
this.#focus(visible[index + 1]);
} else if (key === 'ArrowUp') {
this.#focus(visible[index - 1]);
} else if (key === 'Home') {
this.#focus(visible[0]);
} else if (key === 'End') {
this.#focus(visible[visible.length - 1]);
} else if (key === open) {
this.#forward(item, visible[index + 1]);
} else if (key === close) {
this.#back(item);
} else if (key === 'Enter' || key === ' ') {
this.#enter(item);
} else if (key.length === 1) {
this.#search(key, visible, index);
} else {
return false;
}
return true;
}
#forward(item, next) {
const expanded = item.getAttribute('aria-expanded');
if (expanded === 'false') {
item.setAttribute('aria-expanded', 'true');
} else if (expanded === 'true') {
this.#focus(next);
}
}
#back(item) {
if (item.getAttribute('aria-expanded') === 'true') {
item.setAttribute('aria-expanded', 'false');
} else {
this.#focus(item.parentElement.closest(ITEM));
}
}
#enter(item) {
const link = this.linkTargets.find((candidate) => candidate.closest(ITEM) === item);
if (link) {
link.click();
} else {
this.#choose(item);
this.#flip(item);
}
}
#search(key, visible, index) {
clearTimeout(this.#typing);
this.#typed += key.toLowerCase();
this.#typing = setTimeout(() => {
this.#typed = '';
}, TYPING);
const ordered = [...visible.slice(index + 1), ...visible.slice(0, index + 1)];
const found = ordered.find((item) => this.#name(item).startsWith(this.#typed));
this.#focus(found);
}
#name(item) {
return item.textContent.trim().toLowerCase();
}
#visible() {
return this.itemTargets.filter((item) => !item.parentElement.closest(`${ITEM}[aria-expanded="false"]`));
}
#flip(item) {
const expanded = item.getAttribute('aria-expanded');
if (expanded !== null) {
item.setAttribute('aria-expanded', String(expanded !== 'true'));
}
}
#choose(item) {
for (const other of this.itemTargets) {
other.setAttribute('aria-selected', String(other === item));
}
this.dispatch('select', { detail: { value: item.dataset.value } });
}
#focus(item) {
if (item) {
this.#rove(item);
item.focus();
}
}
#rove(current) {
for (const item of this.itemTargets) {
item.tabIndex = item === current ? 0 : -1;
}
}
}
templates/components/Tree.html.twig
{#-
# A tree on the WAI-ARIA pattern: a single stop of the Tab key, then the arrows, Home, End, a first letter, Enter and
# the Space bar. Before the script runs, everything it would fold shows.
#}
{%- props
## string What screen readers call the tree.
label
-%}
<ul role="tree" aria-label="{{ label }}" data-action="keydown->tree#navigate click->tree#activate"{{ attributes.defaults({class: 'isTree', 'data-controller': 'tree'}) }}>
{##- The items, each a `Tree:Item`. -#}
{%- block content %}{% endblock -%}
</ul>
templates/components/Tree/Item.html.twig
{%- props
## string The name of the item.
label,
## string|null What the event `tree:select` carries; the name when null.
value = null,
## bool Whether the item holds others, written as its content.
branch = false,
## bool Whether the branch shows what it holds at first.
expanded = false,
## bool Whether the item is the chosen one.
selected = false,
## string|null Where the item leads.
href = null
-%}
<li role="treeitem" tabindex="-1" aria-selected="{{ selected ? 'true' : 'false' }}"{% if branch %} aria-expanded="{{ expanded ? 'true' : 'false' }}"{% endif %} data-value="{{ value ?? label }}" data-tree-target="item"{{ attributes.defaults({class: 'isTreeItem'}) }}>
<span class="isTreeRow">
{%- if branch -%}<span class="isTreeToggle" aria-hidden="true" data-tree-target="toggle"></span>{%- endif -%}
{%- if href is not null -%}<a class="isTreeLabel" href="{{ href }}" tabindex="-1" data-tree-target="link">{{ label }}</a>{%- else -%}<span class="isTreeLabel">{{ label }}</span>{%- endif -%}
</span>
{%- if branch -%}
<ul class="isTreeGroup" role="group">
{##- The items this branch holds, each a `Tree:Item`. -#}
{%- block content %}{% endblock -%}
</ul>
{%- endif -%}
</li>