Navigation

v0.0.4
Navigation

Tree

Nested rows for things that contain things.

On this page

A tree is nested rows for things that contain things: folders, an outline, categories. Without script, a branch is a native <details> whose <summary> is the row, so it folds and unfolds with the keys the browser already gives it, and find-in-page opens a folded branch that holds a match.

Tree

<ul class="tree max-w(260) mx-auto">  <li>    <details class="tree-group" open>      <summary class="tree-item tree-toggle">packages</summary>      <ul class="tree-sub">        <li>          <details class="tree-group" open>            <summary class="tree-item tree-toggle">ui</summary>            <ul class="tree-sub">              <li>                <a class="tree-item" href="#button" aria-current="page">button.css</a>              </li>              <li>                <a class="tree-item" href="#input">input.css</a>              </li>              <li>                <a class="tree-item" href="#theme">theme.css</a>              </li>            </ul>          </details>        </li>        <li>          <details class="tree-group">            <summary class="tree-item tree-toggle">tests</summary>            <ul class="tree-sub">              <li>                <a class="tree-item" href="#test">master.test.ts</a>              </li>            </ul>          </details>        </li>      </ul>    </details>  </li>  <li>    <a class="tree-item" href="#readme">README.md</a>  </li>  <li>    <a class="tree-item" href="#license">LICENSE</a>  </li></ul>

#Rows

  • tree-item is a row: a link, a button, or the <summary> of a branch. Rows are 28px, dense and quiet; the one under the pointer and the current one take a chip tone.
  • tree-toggle on a branch's row draws a chevron in the leading column, which turns down while the branch is open. Every row keeps that column, so a leaf lines up with the branches beside it.
  • tree-sub holds the rows under a branch, one level in, on a hairline that runs down from the chevron.

#With icons

Put icon on an icon after the chevron's column. It stays quiet until its row is current.

With icons

<ul class="tree max-w(260) mx-auto">  <li>    <details class="tree-group" open>      <summary class="tree-item tree-toggle">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />        </svg>Design</summary>      <ul class="tree-sub">        <li>          <a class="tree-item" href="#tokens" aria-current="page">            <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">              <path d="M14 3v4a1 1 0 0 0 1 1h4" />              <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />            </svg>Tokens</a>        </li>        <li>          <a class="tree-item" href="#materials">            <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">              <path d="M14 3v4a1 1 0 0 0 1 1h4" />              <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />            </svg>Materials</a>        </li>      </ul>    </details>  </li>  <li>    <details class="tree-group">      <summary class="tree-item tree-toggle">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />        </svg>Engineering</summary>      <ul class="tree-sub">        <li>          <a class="tree-item" href="#api">API</a>        </li>      </ul>    </details>  </li></ul>

#The behavior

The WAI-ARIA tree pattern gives one tab stop and arrow keys that walk and fold the tree. That needs a script, the tree behavior, and the same classes serve it: put role="tree" on the list, role="treeitem" and aria-expanded on each branch's row, and role="group" on its tree-sub. The chevron then follows aria-expanded, a row that says aria-expanded="false" hides the tree-sub that follows it, and aria-selected="true" marks the selected row.

Driven by ARIA

<ul class="tree max-w(260) mx-auto" role="tree" aria-label="Outline">  <li role="none">    <div class="tree-item tree-toggle" role="treeitem" aria-expanded="true" tabindex="0">Introduction</div>    <ul class="tree-sub" role="group">      <li role="none">        <div class="tree-item" role="treeitem" aria-selected="true" tabindex="-1">How it fits together</div>      </li>      <li role="none">        <div class="tree-item" role="treeitem" tabindex="-1">Principles</div>      </li>    </ul>  </li>  <li role="none">    <div class="tree-item tree-toggle" role="treeitem" aria-expanded="false" tabindex="-1">Theme</div>    <ul class="tree-sub" role="group">      <li role="none">        <div class="tree-item" role="treeitem" tabindex="-1">Surfaces</div>      </li>    </ul>  </li></ul><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Focus the tree and use the arrow keys. Enter, Space or a click opens a branch, or selects a row that is not one.

tree takes the root, the element with the tree class, has no options, and returns the function that detaches it.

app.js
import { tree } from '@master/ui/behaviors/tree.js'const root = document.querySelector('.tree')tree(root)root.addEventListener('change', (event) => console.log(event.detail.item))
  • One tab stop. The behavior keeps one row in the tab order with tabindex: the row the markup gave tabindex="0", or the selected or current row, or the first. A row that takes focus becomes the tab stop. Detaching gives every row back the tabindex it had.
  • Walking. The keys skip rows inside a closed branch, hidden rows and disabled ones.
  • Activation. A summary, a link and a button keep the activation the browser gives them. A row that has none, role="treeitem" on a div, is activated by Enter, Space and a click: a branch opens or closes, and a leaf becomes the selected row. aria-selected="true" moves to it from the row that had it.
  • The event. Selecting a row dispatches a bubbling change from the root with the row in detail.item.
  • Disabled. A row with disabled or aria-disabled="true" does nothing: its link is not followed and its branch stays as it is.

The behavior serves a tree of <details> too, as in the first two previews, which run without it. It opens and closes a tree-group through open, and the tree becomes one tab stop walked with the same keys. attach() attaches it to every tree; to keep a tree of <details> as the platform has it, import behaviors by name.

#Tree or nav

A nav is a short list of destinations with one level of grouping, and its current row rises as a sheet. A tree is for depth: many levels, dense rows, and a chevron that leads each branch.

#Keyboard

KeysAction
TabWith details, every row is a tab stop. With the script, the tree is one tab stop: the current or selected row, or the first
Enter, SpaceOpen or close a branch, or follow the link of a row; on a summary the browser does it without script. With the script, a row with role="treeitem" that is not a branch becomes the selected row
ArrowDown, ArrowUpWith the script: move to the next or the previous visible row
ArrowRightWith the script: open a closed branch; on an open branch, move to its first row. ArrowLeft from right to left
ArrowLeftWith the script: close an open branch; on a closed branch or a leaf, move to the row of its parent. ArrowRight from right to left
Home, EndWith the script: move to the first or the last visible row
A characterWith the script: move to the next visible row that starts with it; characters typed quickly are read as one word
*With the script: open every closed branch at the level of the row that has focus

#Accessibility

  • With <details>, do not add tree roles: the native disclosure is announced correctly as it is, and without the behavior every row is a tab stop.
  • Mark the current row with aria-current="page", or aria-selected="true" inside the tree pattern.
  • On touch screens a row is 44px.

#React, Svelte and Vue

The tree component renders the rows from data, attaches the behavior and passes the selected row in and out. Copy it into the project with npx master-ui add tree; React, Svelte and Vue says how the components are built and used.

ui/tree.tsx
'use client'import { useState, type ComponentProps, type ReactNode } from 'react'import { tree } from '@master/ui/behaviors/tree.js'import { useBehavior } from './use-behavior'export interface TreeNode {  label: ReactNode  /** What selecting the row gives `onValueChange`. A row with neither `href` nor `children` can be selected. */  value?: string  /** A row that is a link. `current` marks the page that is open. */  href?: string  current?: boolean  /** The rows under a branch, and whether it starts open. */  children?: TreeNode[]  open?: boolean  disabled?: boolean}export interface TreeProps extends Omit<ComponentProps<'ul'>, 'defaultValue' | 'children' | 'role'> {  nodes: TreeNode[]  /** The value of the selected row, null for none. */  value?: string | null  defaultValue?: string | null  onValueChange?: (value: string | null) => void}/** A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`. */export function Tree({ nodes, value, defaultValue, onValueChange, className, ...root }: TreeProps) {  const ref = useBehavior(tree, undefined, { value, defaultValue, onValueChange })  const [first] = useState(value ?? defaultValue)  const rows = (list: TreeNode[]): ReactNode => list.map((node, index) => (    <li role="none" key={index}>      {node.children        ? (            <>              <div className="tree-item tree-toggle" role="treeitem" aria-expanded={node.open ?? false} aria-disabled={node.disabled || undefined}>{node.label}</div>              <ul className="tree-sub" role="group">{rows(node.children)}</ul>            </>          )        : node.href          ? <a className="tree-item" role="treeitem" href={node.href} aria-current={node.current ? 'page' : undefined} aria-disabled={node.disabled || undefined}>{node.label}</a>          : <div className="tree-item" role="treeitem" data-value={node.value} aria-selected={(node.value !== undefined && node.value === first) || undefined} aria-disabled={node.disabled || undefined}>{node.label}</div>}    </li>  ))  return <ul className={className ? `tree ${className}` : 'tree'} role="tree" ref={ref} {...root}>{rows(nodes)}</ul>}
ui/Tree.svelte
<script lang="ts" module>  import type { HTMLAttributes } from 'svelte/elements'  export interface TreeNode {    label: string    /** What selecting the row gives `onValueChange`. A row with neither `href` nor `children` can be selected. */    value?: string    /** A row that is a link. `current` marks the page that is open. */    href?: string    current?: boolean    /** The rows under a branch, and whether it starts open. */    children?: TreeNode[]    open?: boolean    disabled?: boolean  }  export interface TreeProps extends Omit<HTMLAttributes<HTMLUListElement>, 'role' | 'children'> {    nodes: TreeNode[]    /** The value of the selected row, null for none. Bind it, or leave it out. */    value?: string | null    onValueChange?: (value: string | null) => void  }</script><script lang="ts">  import { untrack } from 'svelte'  import { tree } from '@master/ui/behaviors/tree.js'  import { behavior } from './behavior.svelte.js'  let { nodes, value = $bindable(), onValueChange, class: className, ...root }: TreeProps = $props()  const first = untrack(() => value)  const attach = behavior(tree, undefined, {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script>{#snippet rows(list: TreeNode[])}  <!-- The first value's row is selected in the first render; after that the behavior writes `aria-selected`. -->  <!-- svelte-ignore a11y_role_has_required_aria_props -->  {#each list as node}    <li role="none">      {#if node.children}        <div class="tree-item tree-toggle" role="treeitem" aria-expanded={node.open ?? false} aria-disabled={node.disabled || undefined}>{node.label}</div>        <ul class="tree-sub" role="group">{@render rows(node.children)}</ul>      {:else if node.href}        <a class="tree-item" role="treeitem" href={node.href} aria-current={node.current ? 'page' : undefined} aria-disabled={node.disabled || undefined}>{node.label}</a>      {:else}        <div class="tree-item" role="treeitem" data-value={node.value} aria-selected={(node.value !== undefined && node.value === first) || undefined} aria-disabled={node.disabled || undefined}>{node.label}</div>      {/if}    </li>  {/each}{/snippet}<!--  @component  A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`.--><ul class={['tree', className]} role="tree" {@attach attach} {...root}>{@render rows(nodes)}</ul>
ui/Tree.vue
<script lang="ts">export interface TreeNode {  label: string  /** What selecting the row gives the model. A row with neither `href` nor `children` can be selected. */  value?: string  /** A row that is a link. `current` marks the page that is open. */  href?: string  current?: boolean  /** The rows under a branch, and whether it starts open. */  children?: TreeNode[]  open?: boolean  disabled?: boolean}</script><script setup lang="ts">import { h, useTemplateRef, type FunctionalComponent } from 'vue'import { tree } from '@master/ui/behaviors/tree.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })defineProps<{  nodes: TreeNode[]}>()/** The value of the selected row, null for none: `v-model`. */const model = defineModel<string | null>()/* A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`. */const root = useTemplateRef<HTMLUListElement>('root')useBehavior(root, tree, undefined, model)/** The first value's row is selected in the first render; after that the behavior writes `aria-selected`. */const first = model.value/** The rows of one level, and under each branch the rows of the next. */const Rows: FunctionalComponent<{ nodes: TreeNode[] }> = ({ nodes }) => nodes.map((node, index) => h('li', { role: 'none', key: index }, node.children  ? [      h('div', { 'class': 'tree-item tree-toggle', 'role': 'treeitem', 'aria-expanded': node.open ?? false, 'aria-disabled': node.disabled || undefined }, node.label),      h('ul', { class: 'tree-sub', role: 'group' }, h(Rows, { nodes: node.children })),    ]  : node.href    ? h('a', { 'class': 'tree-item', 'role': 'treeitem', 'href': node.href, 'aria-current': node.current ? 'page' : undefined, 'aria-disabled': node.disabled || undefined }, node.label)    : h('div', { 'class': 'tree-item', 'role': 'treeitem', 'data-value': node.value, 'aria-selected': (node.value !== undefined && node.value === first) || undefined, 'aria-disabled': node.disabled || undefined }, node.label)))Rows.props = ['nodes']</script><template>  <ul class="tree" role="tree" ref="root" v-bind="$attrs"><Rows :nodes="nodes" /></ul></template>

#Classes

ClassDescription
treeThe list; role="tree" with a label when the script drives it
tree-itemA row: a link, a button, the summary of a branch, or an element with role="treeitem". It keeps the chevron's column, so a leaf lines up with the branches beside it
tree-toggleOn the row of a branch: draws a chevron in the leading column, which turns down while the branch is open. With tree-item
tree-groupA details that holds a branch: its summary is the row, its tree-sub the rows under it
tree-subThe rows under a branch, one level in, on a hairline that runs down from the chevron; role="group" when the script drives the tree

#Custom properties

PropertyDefaultDescription
--tree-item-height1.75remRow height; at least 2.75rem on touch screens
--tree-indent1remHow far each level is set in

#Source

@master/ui/components/tree.css
/* * Tree: nested rows for things that contain things: folders, outlines, categories. * Without script a branch is a native <details> whose <summary> is the row, so it folds and unfolds with the * keys the browser already gives it. With a script that provides the tree pattern, the same classes read * aria-expanded instead. Rows are dense and quiet; the current one takes a chip tone, and each level is set * in on a hairline guide. */@layer components {  .tree {    --tree-item-height: 1.75rem;    --tree-indent: 1rem;    --icon-size: 1rem;    display: flex;    flex-direction: column;    gap: 1px;    min-width: 0;    margin: 0;    padding: 0;    color: var(--color-text-body);    font: 400 .8125rem/1.2 var(--font-family-sans);    font-feature-settings: inherit;    list-style: none;  }  /* A row: a link, a button, the <summary> of a branch, or an element with role="treeitem". */  .tree-item {    --icon-offset: -.0625rem;    --icon-color: var(--color-text-muted);    display: flex;    align-items: center;    gap: .375rem;    min-width: 0;    min-height: var(--tree-item-height);    margin: 0;    padding-block: 0;    padding-inline: .25rem .5rem;    border: 0;    border-radius: calc(var(--radius-chip) * var(--corner-scale));    corner-shape: var(--corner-shape);    outline: none;    background: transparent;    color: inherit;    font: inherit;    font-feature-settings: inherit;    text-align: start;    text-decoration: none;    white-space: nowrap;    list-style: none;    appearance: none;    user-select: none;    -webkit-user-select: none;    -webkit-tap-highlight-color: transparent;    cursor: pointer;    touch-action: manipulation;    &::-webkit-details-marker {      display: none;    }    /* Every row keeps the chevron's column, so a leaf lines up with the branches beside it. */    &::before {      content: '';      flex: none;      width: .875rem;      height: .875rem;    }    @media (hover: hover) {      &:hover:not([aria-disabled="true"]) {        background: var(--color-surface-chip);        color: var(--color-text-strong);      }    }    /* Pressed: a step deeper than the pointer's tone. */    &:active:not([aria-disabled="true"]) {      background: var(--color-surface-inset-hover);      color: var(--color-text-strong);    }    &:is([aria-current="page"], [aria-current="true"], [aria-selected="true"]) {      --icon-color: currentColor;      background: var(--color-surface-chip);      color: var(--color-text-strong);    }    &:focus-visible {      outline: 2px solid var(--color-focus);      outline-offset: -2px;    }    &[aria-disabled="true"] {      --icon-color: currentColor;      color: var(--color-text-disabled);      cursor: not-allowed;    }    @media (pointer: coarse) {      min-height: max(var(--tree-item-height), 2.75rem);    }    @media (forced-colors: active) {      &:is([aria-current="page"], [aria-current="true"], [aria-selected="true"]) {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /* The row of a branch: its column holds a chevron that turns down when the branch is open. */  .tree-toggle::before {    background: var(--color-text-muted);    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6.5 4.5 3.5 3.5-3.5 3.5'/%3E%3C/svg%3E") center / contain no-repeat;    transition: rotate var(--duration-quick) var(--easing-glide);    @media (prefers-reduced-motion: reduce) {      transition: none;    }    @media (forced-colors: active) {      background: CanvasText;    }  }  .tree-toggle:dir(rtl)::before {    scale: -1 1;  }  /* Open: by its <details>, or by aria-expanded. A closed branch inside an open one keeps its own chevron. */  .tree-group[open] .tree-toggle::before, .tree-toggle[aria-expanded="true"]::before {    rotate: 90deg;  }  .tree-group[open] .tree-group:not([open]) .tree-toggle::before {    rotate: none;  }  /* A <details> that holds a branch. */  .tree-group {    display: flex;    flex-direction: column;    gap: 1px;    min-width: 0;  }  /* The rows under a branch, one level in, on a hairline that runs down from the chevron. */  .tree-sub {    display: flex;    flex-direction: column;    gap: 1px;    margin: 0;    margin-inline-start: calc(.25rem + .4375rem - .5px);    padding: 0;    padding-inline-start: calc(var(--tree-indent) - .25rem - .4375rem - .5px);    border-inline-start: 1px solid var(--color-line-hair);    list-style: none;  }  /* Driven by script: a row that says it is collapsed hides the rows that follow it. */  .tree-item[aria-expanded="false"] + .tree-sub {    display: none;  }}