Skip to content

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.


vue
<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>
css
.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

vue
<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 /> inside ItemLabel 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

NameDefaultTypeDescription
countnumber
multiSelectfalsebooleanEvery child tree uses the multiSelect from the root tree.

Item


Props

NameDefaultTypeDescription
selectedboolean
idstring
positionnumberThe position of the item in the list.
disabledfalsebooleanWill prevent toggle from emitting, but still allows for opening item.

Emits

@PayloadDescription
@togglebooleanTriggered when the selection for the item should toggle.
@openTriggered 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

SelectorDescriptionFor
[role=<"TreeRole>"]For selecting a root tree vs a child tree.
  • Tree
[aria-multiselectable="<boolean>"]For if a tree is multiselect or not.
  • Tree
[aria-level="<number>"]The level of the item.
  • Item
[aria-selected="<number>"]For the selected state.
  • Item
[aria-disabled="<boolean>"]For a disabled or non-disabled item.
  • Item
[aria-expanded]For an item with children.
  • Item
[aria-pressed="<boolean>"]For the selected state for an indicator.
  • ItemIndicator
:focusFor a focused/hovered item.
  • Item
ts
export type TreeRole = "tree" | "group";

Accessibility

Resources: APG File Directory Treeview Example Using Declared Properties, React Accessible Treeview

Keyboard interactions

KeyAction
Space or on an ItemSelects the item.
on ItemMoves focus to the next item without opening/closing any items.
on ItemMoves focus to the previous item without opening/closing any items.
on parent ItemIf closed, opens the item otherwise moves focus to first child.
on ItemFocus moves to the parent if there is one, otherwise if the item is a parent, it will close itself.
Home on ItemMoves focus to the first item without opening/closing any items.
End on ItemMoves focus to the last item without opening/closing any items.
AZ or az on ItemLoops 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


vue
<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>