# Tree view

Folders and files, or any list that nests, opened and closed level by level: one row each, with a caret, an icon and the label.

Block: `smbk-tree-view`. Status: experimental. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|

Parts: `smbk-tree-view__item`, `smbk-tree-view__row`, `smbk-tree-view__toggle`, `smbk-tree-view__caret`, `smbk-tree-view__icon`, `smbk-tree-view__label`, `smbk-tree-view__meta`, `smbk-tree-view__changed`, `smbk-tree-view__hidden`, `smbk-tree-view__group`

## Markup

```html
<ul aria-label="Files" role="tree" class="smbk-tree-view">
  <li
    role="treeitem"
    data-key="components"
    aria-level="1"
    aria-expanded="true"
    aria-selected="false"
    aria-labelledby="tree-view-1-label"
    tabindex="-1"
    class="smbk-tree-view__item"
    style="--smbk-tree-level:0"
  ><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"><i class="ph ph-caret-right smbk-icon smbk-icon--size-md smbk-tree-view__caret" aria-hidden="true"></i></span><i class="ph ph-folder-simple smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-1-label" class="smbk-tree-view__label" data-label="components">components</span></span><ul role="group" class="smbk-tree-view__group"><li role="treeitem" data-key="button" aria-level="2" aria-expanded="false" aria-selected="false" aria-labelledby="tree-view-2-label" tabindex="-1" class="smbk-tree-view__item" style="--smbk-tree-level:1"><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"><i class="ph ph-caret-right smbk-icon smbk-icon--size-md smbk-tree-view__caret" aria-hidden="true"></i></span><i class="ph ph-folder-simple smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-2-label" class="smbk-tree-view__label" data-label="button">button</span></span><ul role="group" class="smbk-tree-view__group" hidden=""><li role="treeitem" data-key="button-css" aria-level="3" aria-selected="false" aria-labelledby="tree-view-3-label" tabindex="-1" class="smbk-tree-view__item" style="--smbk-tree-level:2"><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"></span><i class="ph ph-note smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-3-label" class="smbk-tree-view__label" data-label="button.css">button.css</span></span></li></ul></li><li role="treeitem" data-key="card" aria-level="2" aria-expanded="true" aria-selected="false" aria-labelledby="tree-view-4-label" tabindex="-1" class="smbk-tree-view__item" style="--smbk-tree-level:1"><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"><i class="ph ph-caret-right smbk-icon smbk-icon--size-md smbk-tree-view__caret" aria-hidden="true"></i></span><i class="ph ph-folder-simple smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-4-label" class="smbk-tree-view__label" data-label="card">card</span></span><ul role="group" class="smbk-tree-view__group"><li role="treeitem" data-key="card-css" aria-level="3" aria-selected="true" aria-labelledby="tree-view-5-label" tabindex="0" class="smbk-tree-view__item" style="--smbk-tree-level:2"><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"></span><i class="ph ph-note smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-5-label" class="smbk-tree-view__label" data-label="card.css">card.css</span></span></li><li role="treeitem" data-key="card-manifest" aria-level="3" aria-selected="false" aria-labelledby="tree-view-6-label" tabindex="-1" class="smbk-tree-view__item" style="--smbk-tree-level:2"><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"></span><i class="ph ph-note smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-6-label" class="smbk-tree-view__label" data-label="manifest.ts">manifest.ts</span></span></li></ul></li></ul></li>
  <li
    role="treeitem"
    data-key="readme"
    aria-level="1"
    aria-selected="false"
    aria-labelledby="tree-view-7-label"
    tabindex="-1"
    class="smbk-tree-view__item"
    style="--smbk-tree-level:0"
  ><span class="smbk-tree-view__row"><span class="smbk-tree-view__toggle" aria-hidden="true"></span><i class="ph ph-note smbk-icon smbk-icon--size-md smbk-tree-view__icon" aria-hidden="true"></i><span id="tree-view-7-label" class="smbk-tree-view__label" data-label="README.md">README.md</span></span></li>
</ul>
```

## React

```tsx
import { TreeView, TreeItem } from './design-system/components/tree-view/tree-view.js';

<TreeView aria-label="Files" defaultExpandedKeys={["components", "card"]} defaultSelectedKey="card-css">
  <TreeItem id="components" label="components">
    <TreeItem id="button" label="button">
      <TreeItem id="button-css" label="button.css" />
    </TreeItem>
    <TreeItem id="card" label="card">
      <TreeItem id="card-css" label="card.css" />
      <TreeItem id="card-manifest" label="manifest.ts" />
    </TreeItem>
  </TreeItem>
  <TreeItem id="readme" label="README.md" />
</TreeView>
```

## Stylesheet

```css
/* smbk-tree-view — Tree view. Figma "Tree view" 7607:118417 and "Tree item" 7580:1383.
 * Folders and files, or any list that nests, one row each. A row is 32 tall: a caret on a folder (the same room
 * kept empty on a file), an icon, the label, then an optional count and a dot for a changed item. Each level steps
 * the row 16 further in, and a fine line runs down under each open folder's caret beside the rows inside it. Under
 * the pointer a row takes the hover fill; the chosen row takes the selected fill and its label a weight step bolder, so
 * it is not shown by color alone. The tree is one Tab stop; the arrow keys move inside it. A folder's rows are a
 * group that is hidden while it is closed. The level comes from the item's --smbk-tree-level, written beside its
 * aria-level (one less: the top rows are level 0). */

.smbk-tree-view,
.smbk-tree-view__group {
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}

.smbk-tree-view__group[hidden] {
  display: none;
}

.smbk-tree-view__item {
  --smbk-tree-level: 0;
  display: block;
  min-width: 0;
  outline: none;
}

.smbk-tree-view__row {
  box-sizing: border-box;
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--smbk-space-6);
  min-height: var(--smbk-size-control-sm);
  padding-block: var(--smbk-space-4);
  padding-inline: calc(var(--smbk-space-8) + var(--smbk-tree-level) * var(--smbk-space-16)) var(--smbk-space-8);
  border-radius: var(--smbk-radius-control-sm);
  color: var(--smbk-color-text-default);
  cursor: default;
  touch-action: manipulation;
}

/* The lines down the levels: one under each ancestor's caret, as fine as a hairline and centred on it. */
.smbk-tree-view__row::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-start: var(--smbk-space-8);
  width: calc(var(--smbk-tree-level) * var(--smbk-space-16));
  background-image: linear-gradient(
    to right,
    transparent round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px),
    var(--smbk-color-border-subtle) 0,
    var(--smbk-color-border-subtle) calc(round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px) + var(--smbk-border-width-hairline)),
    transparent 0
  );
  background-size: var(--smbk-space-16) 100%;
  background-repeat: repeat-x;
  pointer-events: none;
}

.smbk-tree-view__row:dir(rtl)::before {
  transform: scaleX(-1);
}

.smbk-tree-view__toggle {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: var(--smbk-size-icon-xs);
  height: var(--smbk-size-icon-xs);
  font-size: var(--smbk-size-icon-xs);
  color: var(--smbk-color-text-default);
}

.smbk-tree-view__caret {
  font-size: var(--smbk-size-icon-xs);
}

.smbk-tree-view__icon {
  font-size: var(--smbk-size-icon-sm);
  color: var(--smbk-color-text-default);
}

.smbk-tree-view__label {
  flex: 1 1 0;
  min-width: 0;
  overflow-wrap: anywhere;
  font-family: var(--smbk-typography-body-sm-font-family);
  font-size: var(--smbk-typography-body-sm-font-size);
  font-weight: var(--smbk-typography-body-sm-font-weight);
  line-height: var(--smbk-typography-body-sm-line-height);
  letter-spacing: var(--smbk-typography-body-sm-letter-spacing);
}

/* The label is always as wide as its bolder self: an invisible copy of its words (`data-label`, which the wrapper
   writes) at the chosen row's weight lies under it with no height, so a label that turns bold never pushes
   anything beside it. The copy is generated content, hidden, with empty words for assistive technology, so the
   label is read once; it wraps as the label does. */
.smbk-tree-view__label[data-label]::after {
  content: attr(data-label);
  content: attr(data-label) / "";
  display: block;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  font-family: var(--smbk-typography-body-sm-medium-font-family);
  font-weight: var(--smbk-typography-body-sm-medium-font-weight);
  letter-spacing: var(--smbk-typography-body-sm-medium-letter-spacing);
  pointer-events: none;
  user-select: none;
}

.smbk-tree-view__meta {
  flex: none;
  color: var(--smbk-color-text-subtlest);
  font-family: var(--smbk-typography-body-xs-font-family);
  font-size: var(--smbk-typography-body-xs-font-size);
  font-weight: var(--smbk-typography-body-xs-font-weight);
  line-height: var(--smbk-typography-body-xs-line-height);
  letter-spacing: var(--smbk-typography-body-xs-letter-spacing);
  font-variant-numeric: tabular-nums;
}

.smbk-tree-view__changed {
  flex: none;
  width: var(--smbk-size-6);
  height: var(--smbk-size-6);
  border-radius: var(--smbk-radius-full);
  background: var(--smbk-color-feedback-warning-icon);
}

/* .smbk-tree-view__hidden: hidden from view, kept for screen readers and the keyboard, by the shared rule in visually-hidden/visually-hidden.css. */

/* ---- states: hover, chosen, focus ---- */

@media (hover: hover) {
  .smbk-tree-view__item:not([aria-selected='true']) > .smbk-tree-view__row:hover {
    background: var(--smbk-color-surface-hover);
  }
}

.smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row {
  background: var(--smbk-color-surface-selected);
}

.smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row .smbk-tree-view__label {
  font-family: var(--smbk-typography-body-sm-medium-font-family);
  font-weight: var(--smbk-typography-body-sm-medium-font-weight);
  letter-spacing: var(--smbk-typography-body-sm-medium-letter-spacing);
}

/* The ring just inside the row's edge: rows touch, and a tree in a scrolling panel would cut one drawn outside. */
.smbk-tree-view__item:focus-visible > .smbk-tree-view__row {
  outline: var(--smbk-focus-ring-width) solid var(--smbk-color-border-focus);
  outline-offset: calc(var(--smbk-focus-ring-width) * -1);
}

/* ---- motion: a folder's caret turns as it opens; none under reduced motion ---- */

.smbk-tree-view__caret {
  transition: transform var(--smbk-motion-state-duration) var(--smbk-motion-state-easing);
}

.smbk-tree-view__item[aria-expanded='true'] > .smbk-tree-view__row .smbk-tree-view__caret {
  transform: rotate(90deg);
}

.smbk-tree-view__item[aria-expanded='false'] > .smbk-tree-view__row:dir(rtl) .smbk-tree-view__caret {
  transform: scaleX(-1);
}

@media (prefers-reduced-motion: reduce) {
  .smbk-tree-view__caret {
    transition: none;
  }
}

/* ---- forced colors: the chosen row framed, the lines and the changed dot in the text color ---- */

@media (forced-colors: active) {
  .smbk-tree-view__item[aria-selected='true'] > .smbk-tree-view__row {
    outline: var(--smbk-border-width-hairline) solid Highlight;
  }

  .smbk-tree-view__row::before {
    forced-color-adjust: none;
    background-image: linear-gradient(
      to right,
      transparent round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px),
      GrayText 0,
      GrayText calc(round(down, calc((var(--smbk-size-icon-xs) - var(--smbk-border-width-hairline)) / 2), 1px) + var(--smbk-border-width-hairline)),
      transparent 0
    );
  }

  .smbk-tree-view__changed {
    forced-color-adjust: none;
    background: CanvasText;
  }

  .smbk-tree-view__item:focus-visible > .smbk-tree-view__row {
    outline-color: CanvasText;
  }
}

/* ---- touch: every row takes an invisible press area ----
   On a touch screen an invisible area, centred on each row, takes the press out to the comfortable target where it
   falls short of it. The rows touch: a row reaches up only if it is the tree's first, and its reach down lies under
   the next row, which is drawn over it, so each row keeps its own whole box. What is drawn stays as it is. */

@media (pointer: coarse) {
  .smbk-tree-view__row::after {
    content: '';
    position: absolute;
    inset-inline: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
    inset-block-start: 0;
    inset-block-end: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
  }

  .smbk-tree-view > .smbk-tree-view__item:first-child > .smbk-tree-view__row::after {
    inset-block-start: min(0%, (100% - var(--smbk-size-target-comfortable)) / 2);
  }
}
```

## Tokens it reads

```json
{
  "space": {
    "4": {
      "$type": "dimension",
      "$value": "4px",
      "$description": "Raw space step, 4 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    },
    "6": {
      "$type": "dimension",
      "$value": "6px",
      "$description": "Raw space step, 6 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    },
    "8": {
      "$type": "dimension",
      "$value": "8px",
      "$description": "Raw space step, 8 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    },
    "16": {
      "$type": "dimension",
      "$value": "16px",
      "$description": "Raw space step, 16 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    }
  },
  "size": {
    "6": {
      "$type": "dimension",
      "$value": "6px",
      "$description": "Raw size step, 6 px. Only for fixed sizes inside a component; semantic sizes (size/control, icon, mark) point here."
    },
    "control": {
      "sm": {
        "$type": "dimension",
        "$value": "{{size.32}}",
        "$description": "Height of a small control: 32 comfortable, 28 compact. Dashboard recipe: use compact density; 40 for the page’s one main action and for forms; 32 for toolbars and filters, the size used most; 28 for actions inside table rows and cards. On phones switch to comfortable density, or give small controls an invisible 44 px tap area (size/target/comfortable)."
      }
    },
    "icon": {
      "xs": {
        "$type": "dimension",
        "$value": "{{size.12}}",
        "$description": "Icon box, extra small."
      },
      "sm": {
        "$type": "dimension",
        "$value": "{{size.16}}",
        "$description": "Icon box, small."
      }
    },
    "target": {
      "comfortable": {
        "$type": "dimension",
        "$value": "{{size.44}}",
        "$description": "Comfortable touch target (44)."
      }
    }
  },
  "radius": {
    "control": {
      "sm": {
        "$type": "dimension",
        "$value": "{{radius.4}}",
        "$description": "Corners of controls, small."
      }
    },
    "full": {
      "$type": "dimension",
      "$value": "{{radius.9999}}",
      "$description": "Capsule: pills, chips, switches."
    }
  },
  "color": {
    "text": {
      "default": {
        "$type": "color",
        "$value": "{{color.grey.900}}",
        "$description": "Main text."
      },
      "subtlest": {
        "$type": "color",
        "$value": "{{color.grey.600}}",
        "$description": "Quietest text: hints, captions, placeholders."
      }
    },
    "border": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.300}}",
        "$description": "Dividers and quiet edges."
      },
      "focus": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Keyboard focus ring on controls."
      }
    },
    "feedback": {
      "warning": {
        "icon": {
          "$type": "color",
          "$value": "{{color.orange.600}}",
          "$description": "The warning tone’s own icon (a warning sign), so the tone is never shown by colour alone."
        }
      }
    },
    "surface": {
      "hover": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.15}}",
        "$description": "A row or option under the pointer or the keyboard highlight: menu options, table rows. Translucent, so it works on any surface."
      },
      "selected": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.10}}",
        "$description": "The current or chosen item, such as the current page in the sidebar. Translucent, so it shows by the same step on the default, raised, sunken and canvas surfaces."
      }
    }
  },
  "border-width": {
    "hairline": {
      "$type": "dimension",
      "$value": "{{border-width.1}}",
      "$description": "Thin lines: dividers and almost every edge."
    }
  },
  "typography": {
    "body-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    },
    "body-sm-medium": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 500,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      },
      "$description": "Body SM one weight step bolder, for the current item, so the current item is not shown by colour alone."
    },
    "body-xs": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    }
  },
  "focus": {
    "ring": {
      "width": {
        "$type": "dimension",
        "$value": "{{border-width.2}}",
        "$description": "Thickness of the focus ring."
      }
    }
  },
  "motion": {
    "state": {
      "duration": {
        "$type": "duration",
        "$value": "{{duration.200}}"
      },
      "easing": {
        "$type": "cubicBezier",
        "$value": "{{easing.cubic-in-out}}"
      }
    }
  }
}
```

## Purpose

Shows a hierarchy the reader opens level by level: folders and files, sections and pages, categories and the things in them. Each folder opens to show the rows inside it, and the reader can choose a row to act on it. It differs from the sidebar, which is the site's or the application's navigation, and from the accordion, which opens sections of content rather than rows.

## Anatomy

- The block: the tree, a list of rows, with a name.
- `item`: one row's list item, a file or a folder. A folder holds `group`, the rows inside it, shown while it is open.
- `row`: what the reader sees and points at, 32 tall. Each level steps it 16 further in, and a fine line runs down under each open folder's caret beside the rows inside it.
- `toggle`: the caret's room, holding `caret` on a folder (pointing on while closed, down while open) and kept empty on a file, so labels line up.
- `icon`: a folder or a file, or any icon the row is given.
- `label`: the row's words, in Body SM; a weight step bolder on the chosen row.
- `meta`: an optional count or short note at the row's end, in Body XS.
- `changed`: an optional dot at the row's end for a changed item, with `hidden` words saying so.

## When to use

- Files and folders: a project's files, a library's assets, a document's parts.
- A hierarchy of three levels or more that the reader browses and chooses from, beside a pane that shows the chosen item.
- A structure the reader edits, such as the layers of a design or the parts of a page.

## When not to use

- The site's or the application's main navigation. Use the [sidebar](sidebar.md) or the [header](header.md); a tree is for browsing a structure, and a list of links that opens in sections suits most sites better.
- Two levels with a few items each. Use a [list](list.md) with headings, or an [accordion](accordion.md).
- Content the reader reads rather than chooses from. Use an accordion.
- Many columns of data per row. Use a [table](table.md).

## Behavior

The tree keeps which folders are open and which row is chosen, or the page controls both. Clicking a row, or pressing Enter on it, chooses it and opens or closes it if it is a folder; Space chooses it without opening it. The page acts on the chosen row, such as opening the file in the pane beside the tree. A tree can also be open-only, with no row chosen.

A closed folder's rows are hidden, and the page keeps them in place. The caret turns as a folder opens, at once under reduced motion. The chosen row's label is one weight bolder, and every label keeps the room of its bolder self, so nothing beside it moves as it turns bold (markup written by hand repeats the label's words in its `data-label` attribute). A long label wraps onto a second line rather than being cut short. The tree indents to any depth; past four or five levels, rethink the structure.

## Accessibility
### What it does for you

- It is a tree: the list has the tree role and a name, each row the tree item role with its level, each folder's rows a group, and each folder says whether it is open. The chosen row says it is selected. A screen reader hears "tree", its name, then each row's label, its level and its state.
- It is one Tab stop for the whole tree: Tab lands on the row focused last, else the chosen one, else the first.
- The focus ring is drawn just inside the row, so a tree in a scrolling panel never cuts it off.
- The chosen row is shown by a fill and a bolder label, not by color alone.
- The changed dot is said in words after the label ("changed", unless the page gives its own words).
- The meta is read after the label too.
- Each row is at least 32 tall.

### What you must do

- Write the meta so it makes sense after the label, such as "12 files", or add hidden words to it.
- Give the page's own words for the changed dot if "changed" does not fit.
- In hand-written markup, give the list the tree role and a name, each row the tree item role, `aria-level`, `aria-selected` where rows can be chosen, `aria-expanded` on each folder only, a group around each folder's rows, `--smbk-tree-level` beside its level (one less: the top rows are 0), and move focus with script; without script the tree cannot be used with the keyboard.

### How to test

- Tab: focus enters the tree once, on the last focused row, else the chosen one, else the first, with a focus ring just inside the row. Use the keys in the table to move, open and close folders, and choose rows; the next Tab leaves the tree.
- With a screen reader, it is read as a tree by its name, each row by its label, level and state, each folder as open or closed, and the chosen row as selected.
- In a right-to-left page, Left and Right swap.
- With text at 200% and the page 320 pixels wide, a long label wraps onto a second line rather than being cut short.

### Keyboard

| Key | What it does |
|---|---|
| Tab | Moves focus into the tree, onto the row focused last, else the chosen one, else the first; pressed again, leaves the tree. |
| Down Arrow or Up Arrow | Moves to the next or previous row the reader can see. |
| Right Arrow | Opens a closed folder, or moves into an open one's first row. |
| Left Arrow | Closes an open folder, or moves out to the folder a row is in. In a right-to-left page Left and Right swap. |
| Home or End | Moves to the first or the last row. |
| Enter | Acts on the row, and opens or closes it if it is a folder. |
| Space | Chooses the row without opening it. |
| * | Opens every folder beside the row. |
| Any character | Typing a row's first letters moves to it. |

## Usage rules

### Do

- Name the tree for what it shows, such as "Files".
- Open the folders on the way to the chosen row, so it can be seen.
- Keep labels short; the path is in the levels.

### Don't

- Put a button or a link inside a row; use the row itself, and the page's own commands.
- Use a tree for a list one level deep.
- Use the changed dot or the meta for something the reader must not miss; say it in the pane beside the tree as well.
