Tree
TREE MUST BE CONTROLLED
The Tree
component only deals with keyboard navigation, opening and closing items, and letting you know when to toggle an item's selected state, but does not deal with the selection itself.
This is done because an item's children are not rendered until you open it - meaning it would be difficult for the component to know the entire state of the tree.
<template>
<PeachyTree.Tree :count="3">
<PeachyTree.Item
id="project"
:position="1"
:selected="selection.includes('project')"
@toggle="() => selected.toggleItem('project')">
<ChevronSvg aria-hidden="true" />
<PeachyTree.ItemIndicator
:data-indeterminate="indeterminate('project', selection)">
<CheckboxSvg v-if="selection.includes('project')" />
<template v-else-if="indeterminate('project', selection)">-</template>
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-branch-title">
Projects
<PeachyVisuallyHidden v-if="indeterminate('project', selection)">
Has children selected.
</PeachyVisuallyHidden>
</PeachyTree.ItemLabel>
<template #children>
<PeachyTree.Tree :count="2">
<PeachyTree.Item
id="project-1"
:position="1"
:selected="selection.includes('project-1')"
disabled
@toggle="() => selected.toggleItem('project-1')">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
Project 1
</PeachyTree.ItemLabel>
</PeachyTree.Item>
<PeachyTree.Item
id="project-2"
:position="2"
:selected="selection.includes('project-2')"
@toggle="() => selected.toggleItem('project-2')">
<ChevronSvg aria-hidden="true" />
<PeachyTree.ItemIndicator
:data-indeterminate="indeterminate('project-2', selection)">
<CheckboxSvg v-if="selection.includes('project-2')" />
<template v-else-if="indeterminate('project-2', selection)">
-
</template>
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-branch-title">
Project 2
<PeachyVisuallyHidden
v-if="indeterminate('project-2', selection)">
Has children selected.
</PeachyVisuallyHidden>
</PeachyTree.ItemLabel>
<template #children>
<PeachyTree.Tree :count="1">
<PeachyTree.Item
id="sub-1"
:position="1"
:selected="selection.includes('sub-1')"
@toggle="() => selected.toggleItem('sub-1')">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
Project 2.1
</PeachyTree.ItemLabel>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>
</PeachyTree.Item>
<PeachyTree.Item
id="file-1"
:position="2"
:selected="selection.includes('file-1')"
@toggle="() => selected.toggleItem('file-1')">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
File 1
</PeachyTree.ItemLabel>
</PeachyTree.Item>
<PeachyTree.Item
id="letters"
:position="3"
:selected="selection.includes('letters')"
@toggle="() => selected.toggleItem('letters')">
<ChevronSvg />
<PeachyTree.ItemIndicator
:data-indeterminate="indeterminate('letters', selection)">
<CheckboxSvg v-if="selection.includes('letters')" />
<template v-else-if="indeterminate('letters', selection)">-</template>
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-branch-title">
Letters
<PeachyVisuallyHidden v-if="indeterminate('letters', selection)">
Has children selected.
</PeachyVisuallyHidden>
</PeachyTree.ItemLabel>
<template #children>
<PeachyTree.Tree :count="2">
<PeachyTree.Item
id="letter-1"
:position="1"
:selected="selection.includes('letter-1')"
@toggle="() => selected.toggleItem('letter-1')">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
Letter 1
</PeachyTree.ItemLabel>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>
.peachy-tree__tree {
grid-area: tree;
}
.peachy-tree__tree[role="tree"] {
width: 17rem;
margin-left: -2rem;
}
.peachy-tree__tree[role="group"] {
position: relative;
padding: 0;
margin: 0;
margin-left: 0.5rem;
padding-left: var(--gap);
border-left: 1px solid var(--border);
transition: border-color 0.25s;
}
.peachy-tree__tree[role="group"] {
margin-bottom: 0.5rem;
}
.peachy-tree__tree[role="tree"]
> .peachy-tree__item:not([aria-expanded])::before {
content: none;
}
.peachy-tree__item[aria-disabled="true"] {
opacity: 0.6;
cursor: default;
}
.peachy-tree__item[aria-disabled="true"] .peachy-tree__item-indicator {
opacity: 0.8;
cursor: not-allowed;
}
.peachy-tree__item:not([aria-expanded]) {
cursor: default;
}
.peachy-tree__item {
cursor: pointer;
padding-left: 0.5rem;
list-style: none;
display: grid;
gap: var(--gap);
grid-template-columns: 1rem 1fr 1rem;
grid-template-areas: "toggle title indicator" "tree tree tree";
align-items: center;
}
.peachy-tree__item[aria-expanded="true"] > svg {
transform-origin: center;
transform: rotate(90deg);
}
.tree-branch-title,
.tree-item-title {
grid-area: title;
}
.peachy-tree__item:focus {
outline: none !important;
}
.peachy-tree__item-label {
width: calc(100% - 1rem);
padding: 0.25rem;
border-radius: var(--border-radius);
}
.peachy-tree__item:focus > .peachy-tree__item-label {
background-color: var(--button-bg) !important;
}
.peachy-tree__item[aria-expanded] > .peachy-tree__item-label {
font-weight: var(--fw-bold);
}
.peachy-tree__item > .peachy-tree__tree:has(> .peachy-tree__item:focus) {
border-left-color: var(--text);
}
.peachy-tree__item-indicator[data-indeterminate="true"] {
background-color: var(--warning-color);
}
.peachy-tree__item-indicator svg {
display: none;
}
.peachy-tree__item-indicator[data-indeterminate="true"] svg,
.peachy-tree__item[aria-selected="true"] > .peachy-tree__item-indicator svg {
display: block;
}
.peachy-tree__item-indicator {
grid-area: indicator;
display: grid;
place-content: center;
cursor: pointer;
width: 1.5rem;
height: 1.5rem;
max-width: 1.5rem;
max-height: 1.5rem;
border-radius: 4px;
font-size: 1.6rem;
transform: translateX(-0.8rem);
color: var(--brand-button-text);
transition: background-color 0.25s;
background-color: var(--border);
border: none;
}
.peachy-tree__item-indicator svg {
width: 1rem;
height: 1rem;
}
.peachy-tree__item-indicator svg path {
stroke: var(--brand-button-text);
}
.peachy-tree__item-indicator[aria-pressed="true"] {
background-color: var(--brand-button-bg);
color: var(--brand-button-text);
}
@media (prefers-reduced-motion: no-preference) {
.tree-loading-icon {
transform-origin: center;
animation: Spin 1.25s linear infinite;
}
}
@keyframes Spin {
100% {
transform: rotate(360deg);
}
}
.tree-unload-button {
position: absolute;
bottom: 1rem;
right: 1rem;
border-radius: 4px !important;
}
Anatomy
<template>
<PeachyTree.Tree>
<PeachyTree.item>
<PeachyTree.ItemLabel />
<PeachyTree.ItemIndicator />
<template #children />
</PeachyTree.item>
</PeachyTree.Tree>
</template>
<script lang="ts" setup>
import { PeachyTree } from "typeach";
</script>
Usage
It is recommended to let the user know when a parent has any children selected. You'll see in the example we've done this by:
Using the
ItemIndicator
to communicate this visually.Using
<PeachyVisuallyHidden />
insideItemLabel
for screen readers.- For the screen reader text it is preferable to only inform when a child is selected, and not when no children are selected, to avoid repetition.
ItemIndicator
is hidden from screen readers, so placing the information there would be useless.
The children slot should always be rendered when the item has children - even if they're not loaded yet. And it should not render for a childless item.
Props & Emits
Tree
Props
Name | Default | Type | Description |
---|---|---|---|
count | number | ||
multiSelect | false | boolean | Every child tree uses the multiSelect from the root tree. |
Item
Props
Name | Default | Type | Description |
---|---|---|---|
selected | boolean | ||
id | string | ||
position | number | The position of the item in the list. | |
disabled | false | boolean | Will prevent toggle from emitting, but still allows for opening item. |
Emits
@ | Payload | Description |
---|---|---|
@toggle | boolean | Triggered when the selection for the item should toggle. |
@open | Triggered when opened. |
ItemLabel
If you wish to also toggle an item when clicking the item, you can listen for click
events on the ItemLabel
.
Styling
CSS Selectors
Follows our CSS classes convention.
State selectors
Selector | Description | For |
---|---|---|
[role=<"TreeRole>"] | For selecting a root tree vs a child tree. |
|
[aria-multiselectable="<boolean>"] | For if a tree is multiselect or not. |
|
[aria-level="<number>"] | The level of the item. |
|
[aria-selected="<number>"] | For the selected state. |
|
[aria-disabled="<boolean>"] | For a disabled or non-disabled item. |
|
[aria-expanded] | For an item with children. |
|
[aria-pressed="<boolean>"] | For the selected state for an indicator. |
|
:focus | For a focused/hovered item. |
|
export type TreeRole = "tree" | "group";
Accessibility
Resources: APG File Directory Treeview Example Using Declared Properties, React Accessible Treeview
Keyboard interactions
Key | Action |
---|---|
Space or ↵ on an Item | Selects the item. |
↓ on Item | Moves focus to the next item without opening/closing any items. |
↑ on Item | Moves focus to the previous item without opening/closing any items. |
→ on parent Item | If closed, opens the item otherwise moves focus to first child. |
← on Item | Focus moves to the parent if there is one, otherwise if the item is a parent, it will close itself. |
Home on Item | Moves focus to the first item without opening/closing any items. |
End on Item | Moves focus to the last item without opening/closing any items. |
AZ or az on Item | Loops through items starting with the letter typed, or searches for the first item matching the search. Without opening/closing any items. |
* | Opens all items on the same level. |
Asynchronous
<template>
<PeachyTree.Tree :count="2">
<PeachyTree.Item
id="project"
:position="1"
:selected="selection.includes('project')"
@toggle="() => selected.toggleItem('project')"
@open="loadProjects">
<LoadingSvg
v-if="isLoading"
aria-hidden="true"
class="tree-loading-icon" />
<ChevronSvg v-else aria-hidden="true" />
<PeachyTree.ItemIndicator
:data-indeterminate="indeterminate('project', selection)">
<template v-if="indeterminate('project', selection)">-</template>
<CheckboxSvg v-else />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-branch-title">
Projects
<PeachyVisuallyHidden role="status" aria-live="polite">
<template v-if="isLoading">Projects loading</template>
<template v-else-if="projects.length">Projects loaded</template>
</PeachyVisuallyHidden>
<PeachyVisuallyHidden v-if="indeterminate('project', selection)">
Has children selected.
</PeachyVisuallyHidden>
</PeachyTree.ItemLabel>
<template #children>
<PeachyTree.Tree
v-if="projects.length && !isLoading"
:count="projects.length">
<PeachyTree.Item
v-for="(project, i) in projects"
:key="project.id"
:id="project.id"
:position="i + 1"
:selected="selection.includes(project.id)"
@toggle="() => selected.toggleItem(project.id)">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
{{ project.label }}
</PeachyTree.ItemLabel>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>
</PeachyTree.Item>
<PeachyTree.Item
id="file-1"
:position="2"
:selected="selection.includes('file-1')"
@toggle="() => selected.toggleItem('file-1')">
<DocumentSvg aria-hidden="true" />
<PeachyTree.ItemIndicator>
<CheckboxSvg />
</PeachyTree.ItemIndicator>
<PeachyTree.ItemLabel class="tree-item-title">
File 1
</PeachyTree.ItemLabel>
</PeachyTree.Item>
</PeachyTree.Tree>
</template>