Skip to content

PlTreeSelect

A value chosen out of a hierarchy rather than out of a list. A PlTree behind a field, for a category, a folder, a region or an org chart node, the shapes a flat list flattens away.

React
tsx
import { PlTreeSelect, type PlTreeSelectNode } from 'plass-ui';

const items: PlTreeSelectNode[] = [
  {
    id: 'europe',
    label: 'Europe',
    children: [{ id: 'france', label: 'France' }]
  },
  { id: 'antarctica', label: 'Antarctica' }
];

<PlTreeSelect items={items} label="Region" placeholder="Pick a region" />;
dart
import 'package:plass_ui/plass_ui.dart';

const List<PlTreeSelectNode> items = <PlTreeSelectNode>[
  PlTreeSelectNode(
    id: 'europe',
    label: 'Europe',
    children: <PlTreeSelectNode>[PlTreeSelectNode(id: 'france', label: 'France')],
  ),
  PlTreeSelectNode(id: 'antarctica', label: 'Antarctica'),
];

PlTreeSelect(
  items: items,
  label: const Text('Region'),
  value: chosen,
  onValueChanged: (Set<String> next) => setState(() => chosen = next),
);

The popup lifts itself out of the tree, so a tree select needs an Overlay above it, WidgetsApp with a navigator and MaterialApp both provide one.

Props

PropTypeDefaultDescription
items * readonly PlTreeSelectNode[]The whole tree, as data
valuereadonly string[]The ids of the chosen nodes. Use with onValueChange to control the picker
defaultValuereadonly string[]What starts chosen
onValueChange(value: string[]) => voidCalled when the selection changes
multiplebooleanfalseWhether more than one node may be held at once
selectableBranchesbooleanfalseWhether a node with children may itself be chosen. A node's own selectable overrides it either way
expandedreadonly string[]The ids of the branches that are open. Use with onExpandedChange to control them
defaultExpandedreadonly string[]The branches that start open
onExpandedChange(expanded: string[]) => voidCalled when a branch opens or closes
openbooleanWhether the popup is open. Use with onOpenChange to control it
defaultOpenbooleanfalseWhether the popup starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
placeholderReactNodeShown in the trigger while nothing is chosen
clearablebooleanfalseOffers the × that empties the control
closeOnSelectboolean!multipleCloses the popup as soon as a node is chosen
searchablebooleanfalseOffers a field above the tree that filters it. A match keeps its ancestors
searchLabelstring'Search'The word in the filter field
emptyLabelstring'Nothing here'What the popup says when the filter matched nothing
format(chosen: PlTreeSelectNode[]) => ReactNodeHow the trigger writes what is held. Defaults to the labels, comma-joined
namestringIdentifies the field when a form is submitted. One hidden input per value
variantshared'solid' | 'glass' | 'ghost''glass'The material of the trigger, drawn on PlTextField's shell
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth of the trigger. The popup has its own, fixed at 3
labelReactNodeLabel above the trigger
descriptionReactNodeHelper text below the trigger
errorReactNodeError message below. Its presence also turns the control invalid
invalidbooleanForces the invalid state without a message
startIconReactNodeThe glyph before the value
fullWidthbooleanfalseStretches to the width of the container
readOnlybooleanfalseThe value is shown but cannot be changed, and the popup does not open
disabledbooleanfalseUnavailable
requiredbooleanfalseWhether a value must be chosen before the form is submitted
PropTypeDefaultDescription
items * List<PlTreeSelectNode>The whole tree, as data
valueSet<String>{}The ids of the chosen nodes. Controlled
onValueChangedValueChanged<Set<String>>?Called with the whole selection after a node is chosen or dropped
multipleboolfalseWhether more than one node may be held at once
selectableBranchesboolfalseWhether a node with children may itself be chosen. A node's own selectable overrides it either way
expandedSet<String>?The ids of the branches that are open. Leave it out and the picker holds its own
onExpandedChangedValueChanged<Set<String>>?Called with the whole open set after a branch is opened or closed
openbool?Whether the popup is up. Leave it out and the picker holds its own
onOpenChangedValueChanged<bool>?Called when the popup opens or closes
placeholderWidget?Shown in the trigger while nothing is chosen
clearableboolfalseOffers the × that empties the control
closeOnSelectbool?!multipleCloses the popup as soon as a node is chosen
searchableboolfalseOffers a field above the tree that filters it. A match keeps its ancestors
searchLabelString?'Search'The word in the filter field
emptyLabelString?'Nothing here'What the popup says when the filter matched nothing
formatString Function(List<PlTreeSelectNode>)?How the trigger writes what is held. Defaults to the labels, comma-joined
variantsharedPlassVariantPlassVariant.glassThe material of the trigger, drawn on PlTextField's shell
sizesharedPlassSizePlassSize.mdHeight and type scale
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint0Drop shadow depth of the trigger. The popup has its own, fixed at 3
labelWidget?Label above the trigger
descriptionWidget?Helper text below the trigger
errorWidget?Error message below. Its presence also turns the control invalid
invalidbool?Forces the invalid state without a message
startIconWidget?The glyph before the value
fullWidthboolfalseStretches to the width of the container
readOnlyboolfalseThe value is shown but cannot be changed, and the popup does not open
disabledboolfalseUnavailable
semanticLabelString?The name a screen reader gives the trigger
focusNodeFocusNode?Drive focus from outside
autofocusboolfalseTakes focus as it is inserted into the tree

PlTreeSelectNode

PropTypeDefaultDescription
id * stringWhat identifies it, unique across the whole tree
label * ReactNodeWhat the row says
searchLabelstringWhat a search matches against. Falls back to the label when that is a string, and to the id when it is not
iconReactNodeA glyph before the label
childrenreadonly PlTreeSelectNode[]Its own children. An empty array is a **branch** with nothing in it; undefined is a **leaf**
selectablebooleanWhether this node may itself be chosen. Defaults to true for a leaf and to selectableBranches for a branch
disabledbooleanIn the tree but not selectable, and not a stop for the arrow keys
PropTypeDefaultDescription
id * StringWhat identifies it, unique across the whole tree
label * StringWhat the row says, what the filter matches, and what the trigger writes. A String rather than a widget, unlike the React build
iconWidget?A glyph before the label
childrenList<PlTreeSelectNode>?Its own children. An empty list is a **branch** with nothing in it; null is a **leaf**
selectablebool?Whether this node may itself be chosen. Defaults to true for a leaf and to selectableBranches for a branch
disabledboolfalseIn the tree but not selectable, and not a stop for the arrow keys

Every native <div> attribute passes straight through to the field wrapper. color is excluded because it collides with the color in the table above, defaultValue because the picker spells it as a list of ids rather than as a DOM attribute, and children because the tree is items.

A className lands on the stack that holds the label, the control and the two lines under it. classNames reaches the four parts inside it: label, control, description and error.

value is a Set<String> and it is controlled. There is no uncontrolled form, which is the package's rule for every input in it. expanded and open are the two exceptions: leave either out and the picker keeps it itself.

A node's label is a String here and a ReactNode in React, which is the divergence PlTransferItem already carries. The filter reads the label, the trigger writes it and a screen reader is handed it, so text is what keeps every node searchable by construction. That is also why there is no searchLabel on this side: the label is already the words.

What the shared axes mean across the library is in prop conventions.

Examples

searchable

A field above the tree that filters it. A match keeps its ancestors, a "Seoul" under nothing at all does not say which taxonomy it came out of, and every branch the filter kept is opened, since a match folded inside a shut parent is a match nobody was shown.

React

Clearing the field hands the folds back to the reader: the branches they had opened are still open, and the ones the filter opened are shut again.

The fold is accent- and case-insensitive, so jose finds José.

The fold is case-insensitive. It does not strip accents, because Dart's core has no String.normalize and this package has no dependencies. The React build folds accents as well.

selectableBranches

Off by default, which is the shape most of these trees have: the branches are the taxonomy and the leaves are the answers. A branch that cannot be chosen still opens and closes. Pressing it is how you get at what is under it.

React

A node's own selectable overrides it either way, so a "Home" that is a real category can be chosen while the rest of the branches stay roads.

multiple

Every node a press adds is kept, and the trigger writes them comma-joined. The popup stays open, because a picker that shut after the first of several answers would have to be reopened for each of the rest.

React

format takes the chosen nodes and writes them however you like, for a trigger that stops growing with its answer.

tsx
<PlTreeSelect items={items} multiple format={(chosen) => chosen.length + ' regions'} />

Controlled

The value, the folds and the popup are three separate questions, and each has its own pair.

tsx
<PlTreeSelect
  items={items}
  value={chosen}
  onValueChange={setChosen}
  expanded={open}
  onExpandedChange={setOpen}
/>

Opening a folder is not choosing it, which is why the second pair exists at all.

In a form

name puts one <input type="hidden"> on the page per held id, so a multiple picker submits as a repeated field.

tsx
<PlTreeSelect items={items} multiple name="region" defaultValue={['france', 'spain']} />

Accessibility

  • The trigger is a button, exactly as every other picker's is, and it carries the label, the description, the error and aria-invalid.
  • What is inside the popup is a real PlTree, role="tree" of role="treeitem"s, aria-level, aria-expanded, aria-selected, and one tab stop for the whole thing.
  • and walk the rows that are visible, opens a branch and steps into it, closes it or steps out, and Enter or Space chooses.
  • A node that cannot be chosen is not marked aria-disabled when it is only a branch: it is still an operable row, because pressing it opens what is under it. A disabled node is marked, and is not a stop for the arrow keys.
  • The filter field names itself with searchLabel, so it is announced without a visible label above it.

Released under the MIT License