Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Tree Select

A button that opens a tree of options in a popup and shows the one picked, such as a folder or a category picker.

Rendered by React 19.3.1

import type * as React from 'react'
import { TreeSelect } from '@hamur/react/tree-select'

const labels: Record<string, string> = {
  documents: 'Documents',
  work: 'Work',
  reports: 'Reports',
  slides: 'Slides',
  personal: 'Personal',
  pictures: 'Pictures',
  music: 'Music',
}
const children: Record<string, string[]> = {
  documents: ['work', 'personal'],
  work: ['reports', 'slides'],
}
const roots = ['documents', 'pictures', 'music']

function renderNodes(values: string[]): React.ReactNode {
  return values.map((value) => (
    <TreeSelect.Item key={value} value={value} className="tree-item">
      <TreeSelect.ItemContent className="tree-row">
        <TreeSelect.ItemToggle keepMounted className="tree-toggle">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
            <path d="M6 4l4 4-4 4" />
          </svg>
        </TreeSelect.ItemToggle>
        <TreeSelect.ItemText className="tree-text">
          {labels[value]}
        </TreeSelect.ItemText>
      </TreeSelect.ItemContent>
      {children[value] ? (
        <TreeSelect.Group className="tree-group">
          {renderNodes(children[value])}
        </TreeSelect.Group>
      ) : null}
    </TreeSelect.Item>
  ))
}

export default function TreeSelectExample() {
  return (
    // The whole tree as data, so the trigger can name the value while the popup is closed.
    <TreeSelect.Root
      items={roots}
      getItemChildren={(value: string) => children[value]}
      itemToStringLabel={(value: string) => labels[value]}
      defaultExpanded={['documents']}
    >
      <div className="field">
        <div id="tree-select-label" className="select-label">
          Folder
        </div>
        <TreeSelect.Trigger
          className="select"
          aria-labelledby="tree-select-label"
        >
          <TreeSelect.Value
            className="select-value"
            placeholder="Choose a folder"
          />
          <TreeSelect.Icon className="select-icon" />
        </TreeSelect.Trigger>
      </div>
      <TreeSelect.Portal>
        <TreeSelect.Positioner className="select-positioner" sideOffset={6}>
          <TreeSelect.Popup className="select-popup tree-select-popup">
            <TreeSelect.Tree
              className="tree tree-select-tree"
              aria-label="Folders"
            >
              {renderNodes(roots)}
            </TreeSelect.Tree>
          </TreeSelect.Popup>
        </TreeSelect.Positioner>
      </TreeSelect.Portal>
    </TreeSelect.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<TreeSelect.Item>
  <TreeSelect.ItemContent>
    <TreeSelect.ItemToggle />
    <TreeSelect.ItemText />
  </TreeSelect.ItemContent>
  <TreeSelect.Group />
</TreeSelect.Item>
<TreeSelect.Root>
  <TreeSelect.Trigger>
    <TreeSelect.Value />
    <TreeSelect.Icon />
  </TreeSelect.Trigger>
  <TreeSelect.Portal>
    <TreeSelect.Positioner>
      <TreeSelect.Popup>
        <TreeSelect.Tree />
      </TreeSelect.Popup>
    </TreeSelect.Positioner>
  </TreeSelect.Portal>
</TreeSelect.Root>

// Not in this example: TreeSelect.Arrow, TreeSelect.Backdrop, TreeSelect.Clear, TreeSelect.Empty, TreeSelect.Input, TreeSelect.ItemIndicator, TreeSelect.Status

Examples

Filter

An Input at the top of the popup: typing keeps the matching cities with their countries, marks the matches in bold, and shows "No places found" when nothing matches.

TreeSelectFilter.tsx
import type * as React from 'react'
import { TreeSelect } from '@hamur/react/tree-select'

const labels: Record<string, string> = {
  europe: 'Europe',
  france: 'France',
  paris: 'Paris',
  lyon: 'Lyon',
  germany: 'Germany',
  berlin: 'Berlin',
  munich: 'Munich',
  asia: 'Asia',
  japan: 'Japan',
  tokyo: 'Tokyo',
  osaka: 'Osaka',
}
const children: Record<string, string[]> = {
  europe: ['france', 'germany'],
  france: ['paris', 'lyon'],
  germany: ['berlin', 'munich'],
  asia: ['japan'],
  japan: ['tokyo', 'osaka'],
}
const roots = ['europe', 'asia']

function renderNodes(values: string[]): React.ReactNode {
  return values.map((value) => (
    <TreeSelect.Item key={value} value={value} className="tree-item">
      <TreeSelect.ItemContent className="tree-row">
        <TreeSelect.ItemToggle keepMounted className="tree-toggle">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
            <path d="M6 4l4 4-4 4" />
          </svg>
        </TreeSelect.ItemToggle>
        <TreeSelect.ItemText className="tree-text">
          {labels[value]}
        </TreeSelect.ItemText>
      </TreeSelect.ItemContent>
      {children[value] ? (
        <TreeSelect.Group className="tree-group">
          {renderNodes(children[value])}
        </TreeSelect.Group>
      ) : null}
    </TreeSelect.Item>
  ))
}

export default function TreeSelectFilterExample() {
  return (
    // Typing keeps the matching rows and their ancestors, and opens the branches that hold a match.
    <TreeSelect.Root
      items={roots}
      getItemChildren={(value: string) => children[value]}
      itemToStringLabel={(value: string) => labels[value]}
    >
      <div className="field">
        <div id="tree-select-filter-label" className="select-label">
          City
        </div>
        <TreeSelect.Trigger
          className="select"
          aria-labelledby="tree-select-filter-label"
        >
          <TreeSelect.Value
            className="select-value"
            placeholder="Choose a city"
          />
          <TreeSelect.Icon className="select-icon" />
        </TreeSelect.Trigger>
      </div>
      <TreeSelect.Portal>
        <TreeSelect.Positioner className="select-positioner" sideOffset={6}>
          <TreeSelect.Popup className="select-popup tree-select-popup">
            <TreeSelect.Input
              className="input tree-select-input"
              placeholder="Search places"
              aria-label="Search places"
            />
            <TreeSelect.Tree
              className="tree tree-select-tree"
              aria-label="Places"
            >
              {renderNodes(roots)}
            </TreeSelect.Tree>
            <TreeSelect.Empty className="tree-select-empty">
              No places found
            </TreeSelect.Empty>
            <TreeSelect.Status className="tree-select-status" />
          </TreeSelect.Popup>
        </TreeSelect.Positioner>
      </TreeSelect.Portal>
    </TreeSelect.Root>
  )
}

Form

multiple, cascade with cascadeValue="leaves" and required in a Field of a Form: checking a region checks its countries, Save submits one regions= entry per country, and Clear empties it.

TreeSelectForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { TreeSelect } from '@hamur/react/tree-select'

const labels: Record<string, string> = {
  americas: 'Americas',
  canada: 'Canada',
  mexico: 'Mexico',
  emea: 'EMEA',
  ireland: 'Ireland',
  kenya: 'Kenya',
  apac: 'APAC',
  australia: 'Australia',
  singapore: 'Singapore',
}
const children: Record<string, string[]> = {
  americas: ['canada', 'mexico'],
  emea: ['ireland', 'kenya'],
  apac: ['australia', 'singapore'],
}
const roots = ['americas', 'emea', 'apac']

function renderNodes(values: string[]): React.ReactNode {
  return values.map((value) => (
    <TreeSelect.Item key={value} value={value} className="tree-item">
      <TreeSelect.ItemContent className="tree-row">
        <TreeSelect.ItemToggle keepMounted className="tree-toggle">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
            <path d="M6 4l4 4-4 4" />
          </svg>
        </TreeSelect.ItemToggle>
        <TreeSelect.ItemIndicator keepMounted className="tree-check">
          <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
            <path className="tree-check-mark" d="M3 8.5l3 3 7-7" />
            <path className="tree-check-dash" d="M4 8h8" />
          </svg>
        </TreeSelect.ItemIndicator>
        <TreeSelect.ItemText className="tree-text">
          {labels[value]}
        </TreeSelect.ItemText>
      </TreeSelect.ItemContent>
      {children[value] ? (
        <TreeSelect.Group className="tree-group">
          {renderNodes(children[value])}
        </TreeSelect.Group>
      ) : null}
    </TreeSelect.Item>
  ))
}

export default function TreeSelectFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        // A multiple tree select submits one entry per value: regions=canada&regions=mexico.
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="regions" className="field">
        <Field.Label className="select-label">Regions</Field.Label>
        <TreeSelect.Root
          multiple
          cascade
          cascadeValue="leaves"
          required
          items={roots}
          getItemChildren={(value: string) => children[value]}
          itemToStringLabel={(value: string) => labels[value]}
          defaultExpanded={roots}
        >
          <div className="tree-select-row">
            <TreeSelect.Trigger className="select">
              <TreeSelect.Value
                className="select-value"
                placeholder="Choose regions"
              />
              <TreeSelect.Icon className="select-icon" />
            </TreeSelect.Trigger>
            <TreeSelect.Clear
              className="tree-select-clear"
              aria-label="Clear regions"
            >
              <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
                <path d="M4 4l8 8M12 4l-8 8" />
              </svg>
            </TreeSelect.Clear>
          </div>
          <TreeSelect.Portal>
            <TreeSelect.Positioner
              className="select-positioner"
              sideOffset={6}
            >
              <TreeSelect.Popup className="select-popup tree-select-popup">
                <TreeSelect.Tree
                  className="tree tree-select-tree"
                  aria-label="Regions"
                >
                  {renderNodes(roots)}
                </TreeSelect.Tree>
              </TreeSelect.Popup>
            </TreeSelect.Positioner>
          </TreeSelect.Portal>
        </TreeSelect.Root>
        <Field.Error className="error" />
      </Field.Root>
      <button className="button" type="submit">
        Save
      </button>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

77 props across 20 parts, in React syntax. className, style, render are listed once, under Shared props.

ArrowTreeSelect.Arrow

Displays an element positioned against the popover anchor. Renders a <div>.

Arrow data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-uncenteredPresent when the popover arrow is uncentered.

BackdropTreeSelect.Backdrop

An overlay displayed beneath the popover. Renders a <div>.

Backdrop data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the popup begins animating in.
data-ending-stylePresent when the popup is animating out.

ClearTreeSelect.Clear

Empties the value. Place it outside the trigger. Renders a <button>.

Clear props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue

EmptyTreeSelect.Empty

Content shown while the tree has no items, or the Input's text matches none. Renders nothing otherwise. Renders a <div>.

GroupTreeSelect.Group

The children of an item, shown while it is open. Renders a <div>.

Group props
PropTypeDefault
keepMountedWhether to keep the element in the DOM while the item is closed.booleanfalse
hiddenUntilFoundAllows the browser's built-in page search to find and open the group's items.booleanfalse

IconTreeSelect.Icon

An icon that shows the trigger opens a popup. Hidden from assistive technology. Renders a <span>.

InputTreeSelect.Input

A text input that filters the tree. It keeps focus while the keys move the highlight through the tree, named by aria-activedescendant. Renders an <input>.

ItemTreeSelect.Item

An item of the tree, with its row (Tree.ItemContent) and its children (Tree.Group). Renders a <div>.

Item props
PropTypeDefault
childrenReact.ReactNode—
valueA unique value that identifies this item.anynull
labelOverrides the text that typeahead matches.string—
disabledWhether the component should ignore user interaction.booleanfalse
selectableWhether the item can be selected.booleantrue
expandableWhether the item is a parent whose Tree.Group is not rendered yet, so expanding it callsbooleanfalse
activeWhether the item is the current one (the page shown), as aria-current="page".booleanfalse
indexVirtualized only: the position of the row among the visible rows.number—
Item data attributes
Data attributeDescription
data-expandedPresent when the item is open.
data-selectedPresent when the item is selected (without cascade).
data-checkedPresent when the item is checked (with cascade).
data-indeterminatePresent when some of the item's descendants are checked, not all (with cascade).
data-disabledPresent when the item is disabled.
data-highlightedPresent when the item is highlighted (holds focus, or the input's active descendant).
data-activePresent when the item is the current one (active).
data-loadingPresent while the item's children load.
data-load-errorPresent when the last load of the item's children failed.
data-leafPresent when the item has no children.
data-levelThe depth of the item, from 1 at the top level.
data-matchPresent when the item matches TreeSelect.Input's query.
data-movingPresent while the item is being moved.
data-drop-targetWhere a dragged item would land: before, after or inside this one.

ItemContentTreeSelect.ItemContent

The row of an item: its toggle, indicator and text. Its text names the item. Renders a <div>.

ItemIndicatorTreeSelect.ItemIndicator

Shows whether the item is selected or checked. A press toggles the item in a multiple tree. Renders a <span>.

ItemIndicator props
PropTypeDefault
childrenReact.ReactNode—
keepMountedWhether to keep the element in the DOM when the item is not selected.booleanfalse

ItemTextTreeSelect.ItemText

The text of an item: what typeahead matches and what TreeSelect.Value shows. Renders a <span>.

ItemToggleTreeSelect.ItemToggle

Opens and closes its item without selecting it. Hidden from assistive technology and not focusable: the item's keys do the same. Renders a <span>.

ItemToggle props
PropTypeDefault
keepMountedWhether to render the toggle for leaves too, to align the rows.booleanfalse

PopupTreeSelect.Popup

The container of the tree and the optional Input. Focus moves to the Input, else to the selected item, when it opens, and returns to the trigger when it closes. Renders a <div>.

Popup props
PropTypeDefault
initialFocusDetermines the element to focus when the popover is opened.boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)—
finalFocusDetermines the element to focus when the popover is closed.boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)—

PortalTreeSelect.Portal

A portal element that moves the popup to a different part of the DOM. By default, the portal element is appended to <body>. Renders a <div>.

Portal props
PropTypeDefault
keepMountedWhether to keep the portal mounted in the DOM while the popup is hidden.booleanfalse
containerA parent element to render the portal element into.HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null—

PositionerTreeSelect.Positioner

Positions the popup against the trigger. Renders a <div>.

Positioner props
PropTypeDefault
anchorAn element to position the popup against.Element | VirtualElement | React.RefObject<Element | null> | (() => Element | VirtualElement | null) | null—
positionMethodDetermines which CSS position property to use."absolute" | "fixed"'absolute'
sideWhich side of the anchor element to align the popup against.Side'bottom'
sideOffsetDistance between the anchor and the popup in pixels.number | OffsetFunction0
alignOffsetAdditional offset along the alignment axis in pixels.number | OffsetFunction0
collisionBoundaryAn element or a rectangle that delimits the area that the popup is confined to.Boundary'clipping-ancestors'
collisionPaddingAdditional space to maintain from the edge of the collision boundary.Padding5
stickyWhether to maintain the popup in the viewport afterbooleanfalse
arrowPaddingMinimum distance to maintain between the arrow and the edges of the popup.number5
disableAnchorTrackingWhether to disable the popup from tracking any layout shift of its positioning anchor.booleanfalse
collisionAvoidanceDetermines how to handle collisions when positioning the popup.CollisionAvoidance—
alignHow to align the popup relative to the specified side.Align'start'

RootTreeSelect.Root

Groups all parts of the tree select: a trigger that opens a tree in a popup., only the form inputs. Doesn’t render its own element.

Root props
PropTypeDefault
childrenReact.ReactNode—
idThe id of the field control, for the hidden input.string—
valueThe value of the tree select.TreeSelectInputValue<Value, Multiple> | null—
defaultValueThe uncontrolled value when initially rendered.TreeSelectInputValue<Value, Multiple> | null—
onValueChangeEvent handler called when the value changes.((value: TreeSelectOutputValue<Value, Multiple>, eventDetails: TreeSelectRootChangeEventDetails) => void)—
multipleWhether multiple items can be selected.Multiplefalse
cascadeWith multiple: checking an item checks its enabled descendants.booleanfalse
cascadeValueWhat the value holds under cascade: 'all', 'parents' or 'leaves'.TreeCascadeValue'all'
selectableWhether items can be selected.booleantrue
disallowEmptySelectionWhether the last selected item of a multiple tree select cannot be deselected.booleanfalse
expandedThe values of the open items.readonly Value[]—
defaultExpandedThe values of the items open when initially rendered.readonly Value[]—
onExpandedChangeEvent handler called when items open or close.((expanded: Value[], eventDetails: TreeRootExpandedChangeEventDetails) => void)—
expandOnPressWhether a press on a parent's row toggles it as well as selecting it.booleantrue
loadChildrenCalled when an item whose children are not loaded is expanded.((item: Value, options: { signal: AbortSignal; }) => Promise<void | readonly Value[]>)—
onLoadChildrenErrorCalled when loadChildren rejects.((item: Value, error: unknown) => void)—
itemsThe whole tree as data, so the value text and the filter know unmounted items.readonly Value[]—
getItemChildrenThe children of an item of items: an array, null (not loaded) or undefined (a leaf).((item: Value) => readonly Value[] | null | undefined)—
virtualizedWhether the app renders only some rows (a virtualizer).booleanfalse
onItemHighlightedVirtualized only: called when navigation moves to a row that is not mounted.((item: Value, eventDetails: TreeRootHighlightEventDetails) => void)—
itemToStringLabelConverts an item to its text, for the value text, typeahead and the filter.((item: Value) => string)—
itemToStringValueConverts an item value to a string for form submission.((item: Value) => string)—
isItemEqualToValueCustom comparison logic used to determine if an item value matches the current value.((itemValue: Value, value: Value) => boolean)—
isItemDisabledWhether an item of items is disabled, for items that are not mounted.((item: Value) => boolean)—
onHighlightChangeEvent handler called when the highlighted item changes.((value: Value | null) => void)—
loopFocusWhether Down on the last item moves to the first, and Up on the first to the last.booleanfalse
openWhether the popup is open.boolean—
defaultOpenWhether the popup is initially open.booleanfalse
onOpenChangeEvent handler called when the popup opens or closes.((open: boolean, eventDetails: TreeSelectRootOpenChangeEventDetails) => void)—
onOpenChangeCompleteEvent handler called after any animations complete when the popup opens or closes.((open: boolean) => void)—
actionsRefA ref to imperative actions.React.RefObject<PopoverRootActions | null>—
modalWhether the rest of the page is inert while the popup is open.boolean | "trap-focus"true
inputValueThe text of TreeSelect.Input.string—
defaultInputValueThe text of TreeSelect.Input when initially rendered.string—
onInputValueChangeEvent handler called when the text of TreeSelect.Input changes.((value: string, eventDetails: TreeSelectRootInputValueChangeEventDetails) => void)—
filterWhether an item matches the Input's text.((item: Value, query: string, itemToString?: ((item: Value) => string) | undefined) => boolean) | null—
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the value.booleanfalse
requiredWhether the user must choose a value before submitting a form.booleanfalse
nameIdentifies the field when a form is submitted.string—
formIdentifies the form that owns the hidden inputs.string—
inputRefA ref to access the hidden input element.React.Ref<HTMLInputElement>—
labelsThe texts of the tree's status region.Partial<TreeLabels>—

StatusTreeSelect.Status

A polite status region for the filter's result, such as the number of matches. Renders a <div>.

TreeTreeSelect.Tree

The tree inside the popup, followed by its status region. It takes the value, the expansion and the items from TreeSelect.Root. Renders a <div>.

TriggerTreeSelect.Trigger

A button that opens the popup and shows the value. It is a combobox whose popup is a tree. Renders a <button>.

Trigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
idID of the trigger.string—

ValueTreeSelect.Value

The text of the selected value: the item's label, or the labels joined in multiple. Renders a <span>.

Value props
PropTypeDefault
childrenWhat to show for the value instead of the item labels: a node, or a function of the value.React.ReactNode | ((value: any) => React.ReactNode)—
placeholderWhat to show while no value is selected.React.ReactNode—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
EnterSpaceArrowDownArrowUpOpens the popup from the TriggerTrigger
HomeEndMove through the tree and open and close parents, as in a TreeTree
EnterPicks the focused item, closes the popup and returns focus to the TriggerItem
EnterSpacemultipleToggles the focused item and keeps the popup openItem
EscapeCloses the popup and returns focus to the TriggerPopup
TabCloses the popupPopup
ArrowDownArrowUpInputMoves the highlight while focus stays in the InputInput
EnterInputPicks the highlighted item and closes the popupInput
ArrowLeftInputMoves the caret, and at the start of the text closes the highlighted parentInput
ArrowRightInputAt the end of the text, opens the highlighted parentInput
InputType into it and filter the tree; no typeaheadInput

Accessibility

What the parts set
PartSetsSo that
Triggerrole="combobox", aria-haspopup="tree", aria-expanded and, while open, aria-controls; labelled by Field.Label or aria-labelledbythe button says it opens a tree, and whether it is open
Iconaria-hiddenthe chevron is for sight
Treerole="tree" with the roles and states of a Tree: treeitem, aria-level, aria-expanded, aria-selected or aria-checkedthe popup is read as the tree it is
Inputaria-controls the tree and aria-activedescendant the highlighted itema screen reader follows the highlight while focus stays in the text
Itemdata-match on a row that matches the Inputthe examples set it in bold, with the ancestors of the match in normal weight
Emptyrendered only while nothing matches"No places found" is there exactly when it is true
Statusa polite role="status" regiona count or a hint is announced as the filter changes
Cleara button named "Clear" when it has no content, or by its text or aria-labelthe value is emptied from the keyboard, with reason clear-press

Focus order

  1. The Trigger is the Tab stop, then Clear when there is one. Nothing takes focus on mount.
  2. Opening moves focus to the selected item, its nearest visible ancestor, or the first item; with an Input, to the Input.
  3. Enter, Escape or a pick returns focus to the Trigger; Tab closes the popup.
  4. A required, empty tree select in a Form gets focus on submit, on its Trigger.