Tree

A nested list, such as files and folders, that expands, collapses and selects with full keyboard support.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add tree
pnpm dlx ermineui add tree
yarn dlx ermineui add tree
bunx ermineui add tree

That copies 3 files into src/components/ui/tree/, 1 helper into src/lib/ermine/. Files you already have are kept.

Use

import { Tree } from '@/components/ui/tree'
<PinInput v-model="code" :length="6" name="code" @complete="verify" />

<Rating v-model="score" label="Deploy experience" />
<Rating :model-value="3.5" readonly label="Average rating" />

<Tree v-model:expanded="open" v-model:selected="file" :nodes="[
  { id: 'app', label: 'app', children: [{ id: 'app/app.vue', label: 'app.vue' }] },
  { id: 'package.json', label: 'package.json' },
]" label="Project files" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Tree.vuesrc/components/ui/tree/Tree.vue
index.tssrc/components/ui/tree/index.ts
types.tssrc/components/ui/tree/types.ts
collection.ts helpersrc/lib/ermine/collection.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Tree.vue 232 lines
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { createTypeahead } from '../../lib/collection'
import type { TreeNode } from './types'

const props = defineProps<{
  nodes: readonly TreeNode[]
  /** Accessible name of the tree. */
  label?: string
}>()

defineSlots<{
  /** Replaces the contents of a row — for icons, badges or counts beside the label. */
  item?: (props: { node: TreeNode; level: number; expanded: boolean; selected: boolean }) => unknown
}>()

/** ids of the open folders. */
const expanded = defineModel<string[]>('expanded', { default: () => [] })
/** id of the chosen node. */
const selected = defineModel<string | undefined>('selected')

const root = ref<HTMLElement | null>(null)
/** The row Tab lands on and the arrow keys move from. */
const focused = ref<string>()
const typeahead = createTypeahead()

interface Row {
  node: TreeNode
  level: number
  parent?: string
  /** Position among its siblings and how many siblings there are, for screen readers. */
  position: number
  size: number
  folder: boolean
  open: boolean
}

/** Every row currently on screen, top to bottom. Children of a closed folder are not in it. */
const rows = computed(() => {
  const result: Row[] = []
  const walk = (nodes: readonly TreeNode[], level: number, parent?: string) => {
    nodes.forEach((node, index) => {
      const folder = node.children !== undefined
      const open = folder && expanded.value.includes(node.id)
      result.push({ node, level, parent, position: index + 1, size: nodes.length, folder, open })
      if (open) walk(node.children!, level + 1, node.id)
    })
  }
  walk(props.nodes, 1)
  return result
})

const tabStop = computed(() => {
  const ids = rows.value.map((row) => row.node.id)
  return [focused.value, selected.value].find((id) => id !== undefined && ids.includes(id)) ?? ids[0]
})

function setOpen(id: string, open: boolean) {
  if (open === expanded.value.includes(id)) return
  expanded.value = open ? [...expanded.value, id] : expanded.value.filter((other) => other !== id)
}

async function focusRow(id: string | undefined) {
  if (id === undefined) return
  focused.value = id
  await nextTick()
  const row = Array.from(root.value?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? []).find((el) => el.dataset.id === id)
  row?.focus()
  row?.scrollIntoView({ block: 'nearest' })
}

function activate(row: Row) {
  if (row.node.disabled) return
  focused.value = row.node.id
  selected.value = row.node.id
  if (row.folder) setOpen(row.node.id, !row.open)
}

function onKeydown(event: KeyboardEvent) {
  const list = rows.value
  const index = list.findIndex((row) => row.node.id === tabStop.value)
  const row = list[index]
  if (!row) return
  const enabled = (candidate: Row | undefined) => candidate && !candidate.node.disabled
  const step = (direction: 1 | -1) => {
    for (let next = index + direction; next >= 0 && next < list.length; next += direction) if (enabled(list[next])) return list[next]
    return undefined
  }
  let target: Row | undefined

  switch (event.key) {
    case 'ArrowDown':
      target = step(1)
      break
    case 'ArrowUp':
      target = step(-1)
      break
    case 'Home':
      target = list.find(enabled)
      break
    case 'End':
      target = [...list].reverse().find(enabled)
      break
    case 'ArrowRight':
      // Opens a closed folder; on an open one, steps into it.
      if (row.folder && !row.open) setOpen(row.node.id, true)
      else if (row.open) target = step(1)
      break
    case 'ArrowLeft':
      // Closes an open folder; otherwise goes up to the folder this row is in.
      if (row.open) setOpen(row.node.id, false)
      else target = list.find((candidate) => candidate.node.id === row.parent)
      break
    case 'Enter':
    case ' ':
      activate(row)
      break
    default: {
      const items = Array.from(root.value?.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled])') ?? [])
      const match = typeahead(event, items)
      if (!match) return
      focusRow(match.dataset.id)
    }
  }

  event.preventDefault()
  if (target) focusRow(target.node.id)
}
</script>

<template>
  <div ref="root" role="tree" class="er-tree" :aria-label="label" @keydown="onKeydown">
    <!-- A flat list of rows: each one says how deep it is, so screen readers still hear the nesting. -->
    <div
      v-for="row in rows"
      :key="row.node.id"
      role="treeitem"
      class="er-tree-item"
      :tabindex="row.node.id === tabStop ? 0 : -1"
      :aria-level="row.level"
      :aria-posinset="row.position"
      :aria-setsize="row.size"
      :aria-expanded="row.folder ? row.open : undefined"
      :aria-selected="selected === row.node.id"
      :aria-disabled="row.node.disabled || undefined"
      :data-id="row.node.id"
      :style="{ '--er-tree-level': row.level - 1 }"
      @click="activate(row)"
      @focus="focused = row.node.id"
    >
      <svg v-if="row.folder" class="er-tree-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m9 18 6-6-6-6" />
      </svg>
      <span v-else class="er-tree-chevron" />
      <slot name="item" :node="row.node" :level="row.level" :expanded="row.open" :selected="selected === row.node.id">
        <span class="er-tree-label">{{ row.node.label }}</span>
      </slot>
    </div>
  </div>
</template>

<style>
@layer components {
  .er-tree {
    display: grid;
    gap: 1px;
    width: 100%;
    min-width: 0;
    font-size: 0.875rem;
  }

  .er-tree-item {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    min-width: 0;
    height: 1.875rem;
    padding: 0 0.5rem 0 calc(0.375rem + var(--er-tree-level, 0) * 1rem);
    border-radius: var(--er-radius-sm);
    cursor: default;
    user-select: none;
    outline: none;
  }

  .er-tree-item:hover {
    background: color-mix(in oklab, var(--er-accent) 60%, transparent);
  }

  .er-tree-item[aria-selected='true'] {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
    font-weight: var(--er-weight-medium);
  }

  .er-tree-item:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: -2px;
  }

  .er-tree-item[aria-disabled='true'] {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-tree-chevron {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
    transition: transform var(--er-duration) var(--er-ease);
  }

  .er-tree-item[aria-expanded='true'] > .er-tree-chevron {
    transform: rotate(90deg);
  }

  .er-tree-item svg:not(.er-tree-chevron) {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
  }

  .er-tree-label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
</style>
index.ts 4 lines
/** Tree — a nested list, such as files and folders, that expands, collapses and selects with full keyboard support. */
export { default as Tree } from './Tree.vue'
export type { TreeNode } from './types'
types.ts 9 lines
export interface TreeNode {
  /** Different for every node in the tree. */
  id: string
  label: string
  /** Nodes inside this one. An empty array still counts as a folder; leave it out for a leaf. */
  children?: TreeNode[]
  disabled?: boolean
}