Floating UI - Tree & Node
- 文章發表於
- ...
FloatingTree is Floating UI's solution for managing hierarchical relationships between floating elements (tooltips, popovers, menus) that are rendered outside their React component tree via portals.
FloatingTree reconstructs this relationship so components can:
- Know their children (for Escape key handling)
- Know their ancestors (for focus restoration)
- Coordinate with siblings (for hover switching)
The Core Problem
Accessibility is one of the major challenges in the frontend world, and the UI library acts as its gatekeeper. Nowadays the web is increasingly composable, built from fundamental UI components. If the maintainer does not care enough, it can end up at the application level.
If the UI library lacks mechanisms to handle the child–parent relationship,
Questions that become impossible to answer:
- "When Escape is pressed in child menu, should deeper level of child menu close?"
- "When clicking outside, was the click inside ANY child menu?"
Floating UI Architecture
Storage
FloatingTreeStore is a class that store each tree's data and the communication methods,
FloatingTreeStoreimport { createEventEmitter } from '../utils/createEventEmitter';export class FloatingTreeStore {// Flat array of all nodespublic readonly nodesRef: React.RefObject<Array<FloatingNodeType>> = {current: []};public readonly events: FloatingEvents = createEventEmitter();public addNode(node: FloatingNodeType) {this.nodesRef.current.push(node);}public removeNode(node: FloatingNodeType) {const index = this.nodesRef.current.findIndex((n) => n === node);if (index !== -1) {this.nodesRef.current.splice(index, 1);}}}
In FloatingTreeStore, there are two patterns for inter-node communication. The first is a direct query, and the second is broadcasting to siblings.
- Direct query: querying the current node and its parent/child status, we can use getNodeChildren or getNodeAncestors.
- Boardcasting: coordinating multiple components that need to react to changes, createEventEmitter.
The node structure (this.nodesRef.current) is flat. Each node has two three properties: parent_id, node_id and the context.
const nodes = [{ id: '1', parentId: null },{ id: '2', parentId: '1' },{ id: '3', parentId: '1' },{ id: '4', parentId: '2' },];
The reason the data structure uses a flat array instead of a tree-like structure is that a flat array is simpler to implement and easier to reason about with portals.
// Adding a node with flat arraythis.nodesRef.current.push(node);// Adding a node with nested tree - requires traversal!function addToTree(tree, parentId, newNode) {if (tree.id === parentId) {tree.children.push(newNode);return true;}for (let child of tree.children) {if (addToTree(child, parentId, newNode)) return true;}return false;}
Lifecycle
Initialization
Once we have the concept in mind, the idea of using the floating UI in your menu or popover component becomes clearer:
Phase 1. Tree Initializationfunction App() {return (<FloatingTree><Menu /></FloatingTree>);}
Phase 2. Component Mounting - Register node & provide parent contextfunction Menu() {const nodeId = useFloatingNodeId();// nodeId = "floating-ui-1" (from React.useId)// tree.addNode({ id: "floating-ui-1", parentId: null })return (<FloatingNode id={nodeId}><MenuTrigger /><MenuPopup /></FloatingNode>);}
Separate Structure from State
After component mounted the node only structural info { id: "floating-ui-1", parentId: null } and the context attachment will comes after it
Phase 3. Context Attachmentfunction Menu() {const nodeId = useFloatingNodeId();const context = useFloating({nodeId,open,onOpenChange,// ... positioning options});useIsoLayoutEffect(() => {const node = tree?.nodesRef.current.find(n => n.id === nodeId);if (node) {node.context = context;}});...}// Tree state after context attachedtree.nodesRef.current = [{id: 'floating-ui-1',parentId: null,context: {open: false,onOpenChange: fn,elements: { floating: null, domReference: button },dataRef: { current: {} }}}];
Why separate?
- Registration happens early (
useFloatingNodeIdhook) - Context happens later (after
useFloatingcomputes positioning) - Structure is stable, state changes frequently
- Queries can handle missing context safely
Nested Menu Mounting
function MenuPopup() {return (<Portal><div><MenuItem>New</MenuItem><Menu> {/* Step 6: Nested menu mounts */}<MenuTrigger>Open Recent</MenuTrigger><MenuPopup /></Menu></div></Portal>);}// After nested menu renderedtree.nodesRef.current = [{id: 'floating-ui-1',parentId: null,context: { open: true, ... }},{id: 'floating-ui-2',parentId: 'floating-ui-1',context: { open: false, ... }}];
