TreeSelect

A Select whose options are a tree: the field opens a hierarchy rather than a flat list, with checkable nodes for multiple values.

Accessibility

TreeSelect is a combobox whose popup is a tree. Most of it is wired for you, but the field and the tree are not linked for screen readers, and the tag remove control has no name.

What it renders

The closed field is a real <input type="text"> carrying role="combobox", with aria-expanded, aria-haspopup="listbox" and aria-autocomplete="list". That part is Select's, and TreeSelect shares it unchanged.
What opens is not a listbox. It is a tree, and the roles inside the popup are the ones a tree is supposed to have:
  • The scrolling list is role="tree" and carries its own aria-activedescendant.
  • Each row is role="treeitem" with aria-expanded (omitted on a leaf), aria-selected, aria-checked and aria-disabled.
  • A checkbox, when treeCheckable is on, is a separate role="checkbox" node pointed at the row's title with aria-labelledby.
  • An empty result is a role="listbox" div holding notFoundContent, which is the one place the popup keeps Select's role.
The combobox and the tree are not joined up, and you cannot join them from outside. The input's aria-controls and aria-owns point at ${id}_list, an id that only Select's own option list creates. TreeSelect replaces that list, so the attribute resolves to nothing, and the input never gets an aria-activedescendant. Treat this as a limitation to work around in the page, not as something a prop fixes.

Keyboard, and where it differs from Select

  • Enter or Space opens the popup from the closed field. With searching on, Space types a space instead, because the input is editable.
  • Up and Down move the active row through the flattened tree, so they walk into and out of open branches rather than stopping at siblings.
  • Left and Right collapse and expand. This is the behavior a plain Select does not have. Left closes the branch, or jumps to the parent when it is already closed. Right opens it, or jumps to the first child.
  • Enter chooses the active node, including a branch. In a bare Tree, Enter on a collapsed branch expands it. Here Enter selects instead, which is right for a field and a real difference in muscle memory.
  • Esc closes the popup and leaves the value alone.
  • Home and End do nothing. The tree implements them, but the popup only forwards the four arrows, Enter and Esc, so a long tree has no jump to either end. Say so in your own help text rather than letting a reader hunt for it.
  • Backspace removes the last tag under multiple, once the search box is empty, and skips disabled entries. It is the only keyboard route to removing a tag: see the naming section.
The tree itself is rendered with focusable: false, so it takes no tab stop of its own and focus stays in the field the whole time. One control, one tab stop, which is what a combobox should be.

Naming the field and its buttons

  • The field is an input, so a <label htmlFor> pointed at the id you pass names it properly. Inside a Form, Form.Item wires that for you and you should let it.
  • Failing that, aria-label and aria-labelledby are forwarded to the input rather than landing on a wrapper.
  • The clear button is a real button and its accessible name defaults to the literal "Clear". Name it for what it clears with allowClear={{ label: "Clear category" }}.
  • The per-tag remove control is not a button and has no name. It is a <span> with click handlers: no role, no label, no tab stop. Keyboard readers remove a tag with Backspace, so if the tags are the main way out of a selection, put a real “Clear all” affordance on the page as well.
  • placeholder is not a name. It disappears on the first keystroke and screen readers treat it as a hint, so a field whose only text is its placeholder is an unnamed field.

Announcing checked and half-checked

A parent whose children are only partly checked renders aria-checked="mixed", on the treeitem and on its checkbox alike, so “partially checked” is announced rather than only drawn as a smaller square.
  • Under treeCheckStrictly, half-checked is a state your code can see too: an entry in the value carries halfChecked: true. Do not render it as checked in a summary elsewhere on the page, or the summary and the popup disagree.
  • The active row is announced by a live region, and it announces the node's value. The popup renders a visually hidden aria-live="assertive" span holding the active node's value, which is how the arrow keys speak at all given the missing aria-activedescendant. On a tree keyed by uuids, that reads the uuid aloud.
  • That is a reason to prefer human-readable values where you can, or to accept that the live region is noise and keep the visible label doing the work. It is not configurable.
  • What the closed field reports is the label, not the value, so the summary a reader hears on focus stays readable either way.
By default the popup renders into document.body, far from the field in the DOM. The kit sets no getPopupContainer of its own, the same call every other portal component in antuikit makes.
  • Reading the page in DOM order therefore finds the tree at the end of the body rather than after the field. With aria-controls resolving to nothing, there is no programmatic link back either, which is the strongest argument for keeping the field's own visible label precise.
  • Reach for getPopupContainer when the field lives in a scrolling panel or a modal, so the popup scrolls and stacks with its own surface. That is a layout decision, not a theming one: the popup is themed wherever it lands.
  • Under virtual, which is on by default, only the visible rows exist in the DOM. A find-in-page or a browse-mode sweep sees a window, not the tree, so never rely on the popup as the page's record of what is selected.

Disabled nodes, contrast and motion

  • A node can be taken out of play three ways: disabled (the whole row), disableCheckbox (the checkbox only, so a branch can still be opened), and checkable: false. Only disabled sets aria-disabled, so the other two read as ordinary rows that happen not to respond.
  • maxCount disables every remaining node once the cap is reached. Nothing announces why, so pair the cap with visible text saying what it is.
  • Never let the grayed ink be the only thing saying “unavailable”. A disabled row is a contrast signal, and a contrast signal alone is not accessible.
  • Every color comes from the active theme, so contrast is a property of the theme rather than of the component. Check two pairs that are easy to miss: the placeholder against the field, and a tag's text against the tag's own fill, which is not the page background.
  • The popup slides in and the switcher arrow rotates. Both are decoration and both can be turned off wholesale with ThemeScope theme={{ token: { motion: false } }}. Anything you animate on top of them belongs behind motion-safe:.