Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tree-rows-in-custom-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@frontify/fondue-components": minor
"@frontify/fondue": minor
---

feat(Tree): allow rows inside custom components and fragments
fix(Tree): the first row is reachable with Tab when rows arrive after mount
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"category": "navigation",
"description": "A hierarchical list of items and nested folders. Supports keyboard navigation, single-select with a highlighted row or multi-select with cascading folder checkboxes (folders without loaded children are checkable as their own entity), optional drag-and-drop reordering, inline renaming, and async loading rows.",
"instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: <Tree.Label> (plain text, required) plus optional <Tree.Icon> (rows render no icon otherwise), <Tree.Decorator> for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and <Tree.Action> for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside <Tree.FolderHeader>, and every other folder child is a nested row. Render <Tree.Loading> as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.",
"instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: <Tree.Label> (plain text, required) plus optional <Tree.Icon> (rows render no icon otherwise), <Tree.Decorator> for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and <Tree.Action> for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside <Tree.FolderHeader>, and every other folder child is a nested row. Rows may also sit inside your own components or fragments, with four limits: <Tree.FolderHeader> and row parts stay direct children of their folder or item; rows rendered through a portal are not found; row parts (Icon, Decorator, Action) render under Tree.Root, so they do not see a context provider placed around rows; when server-rendered, custom components add React useLayoutEffect warnings. Render <Tree.Loading> as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.",
"name": "Tree",
"relatedComponents": ["Accordion", "OrderableList", "Dropdown"],
"storyFilePaths": ["src/components/Tree/Tree.stories.tsx"],
Expand Down
60 changes: 59 additions & 1 deletion packages/components/src/components/Tree/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
IconTrashBin,
} from '@frontify/fondue-icons';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useState, type ReactNode } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { action } from 'storybook/actions';

import { Badge, Button, Dropdown } from '#/index';
Expand Down Expand Up @@ -1272,6 +1272,64 @@ export const LoadMore: Story = {
},
};

const FolderContents = ({ folderId }: { folderId: string }) => {
const [names, setNames] = useState<string[] | null>(null);

useEffect(() => {
const timer = setTimeout(() => setNames(['q1.pdf', 'q2.pdf', 'q3.pdf']), 600);
return () => clearTimeout(timer);
}, []);

if (!names) {
return <Tree.Loading />;
}
return (
<>
{names.map((name) => (
<Tree.Item key={name} id={`${folderId}/${name}`}>
<Tree.Icon>
<IconDocument size={16} />
</Tree.Icon>
<Tree.Label>{name}</Tree.Label>
</Tree.Item>
))}
</>
);
};

export const RowsInCustomComponents: Story = {
parameters: {
docs: {
description: {
story:
'Rows may sit inside your own components and fragments, so a component can own the data for a folder ' +
'and render its rows, as `FolderContents` does here with its own fetch state. Limits: `Tree.FolderHeader` ' +
'and row parts stay direct children of their folder or item. Rows rendered through a portal are not ' +
'found. Row parts (`Tree.Icon`, `Tree.Decorator`, `Tree.Action`) render under `Tree.Root`, so they do ' +
'not see a context provider placed around rows. When the tree is server-rendered, custom components ' +
'add React `useLayoutEffect` warnings.',
},
},
},
render: (args) => {
const [isExpanded, setIsExpanded] = useState(false);

return (
<Tree.Root {...args}>
<Tree.Folder id="reports" isExpanded={isExpanded} onExpandChange={setIsExpanded}>
<Tree.FolderHeader>
<Tree.Icon>
<IconFolder size={16} />
</Tree.Icon>
<Tree.Label>Reports</Tree.Label>
</Tree.FolderHeader>
{isExpanded && <FolderContents folderId="reports" />}
</Tree.Folder>
</Tree.Root>
);
},
};

export const DisabledRows: Story = {
parameters: {
docs: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/* (c) Copyright Frontify Ltd., all rights reserved. */

import { act, render, screen } from '@testing-library/react';
import { Profiler, type ReactNode, useEffect, useState } from 'react';
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';

import { Tree } from '../Tree';

import { COLLECT_ATTR } from './TreeCollector';

const Leaf = ({ id }: { id: string }) => (
<Tree.Item id={id}>
<Tree.Label>{id}</Tree.Label>
</Tree.Item>
);

let treeRowRenders = 0;
vi.mock(import('./TreeRow'), async (importOriginal) => {
const mod = await importOriginal();
return {
...mod,
TreeRow: (props: Parameters<typeof mod.TreeRow>[0]) => {
treeRowRenders += 1;
return mod.TreeRow(props);
},
};
});

const Row = ({ id }: { id: string }) => (
<Tree.Item id={id}>
<Tree.Label>{id}</Tree.Label>
</Tree.Item>
);

const Rows = ({ ids }: { ids: string[] }) => (
<>
{ids.map((id) => (
<Row key={id} id={id} />
))}
</>
);

const GrowingRows = () => {
const [ids, setIds] = useState(['a', 'b', 'c']);
useEffect(() => {
const timer = setTimeout(() => setIds((prev) => [...prev, 'd']), 10);
return () => clearTimeout(timer);
}, []);
return <Rows ids={ids} />;
};

const Pair = () => (
<>
<Leaf id="B" />
<Leaf id="C" />
</>
);

describe('TreeCollector', () => {
it('renders to a string without throwing when rows sit inside a custom component', () => {
expect(() =>
renderToString(
<Tree.Root>
<Pair />
</Tree.Root>,
),
).not.toThrow();
});

it('adds no commit on re-render when no custom components are used', () => {
let commits = 0;
// A fresh element each call, so React cannot bail out on referential equality.
const ui = () => (
<Profiler
id="tree"
onRender={() => {
commits += 1;
}}
>
<Tree.Root>
<Tree.Item id="A">
<Tree.Label>A</Tree.Label>
</Tree.Item>
</Tree.Root>
</Profiler>
);
const { rerender } = render(ui());
commits = 0;
rerender(ui());
expect(commits).toBe(1);
});

it('does not reuse rows from an earlier collect pass', () => {
const Wrap = ({ children }: { children: ReactNode }) => <>{children}</>;
const onRenamingChange = vi.fn();
const ui = (wrapped: boolean, isRenaming: boolean) => {
const row = (
<Tree.Item id="x" onRename={() => {}} isRenaming={isRenaming} onRenamingChange={onRenamingChange}>
<Tree.Label>x</Tree.Label>
</Tree.Item>
);
if (wrapped) {
return (
<Tree.Root>
<Wrap>{row}</Wrap>
</Tree.Root>
);
}
return <Tree.Root>{row}</Tree.Root>;
};
const { rerender } = render(ui(true, true));
rerender(ui(false, false));
expect(onRenamingChange).toHaveBeenCalledWith(false);
onRenamingChange.mockClear();
rerender(ui(true, false));
expect(onRenamingChange).not.toHaveBeenCalled();
});

it('collects rows of custom components after mount', () => {
render(
<Tree.Root>
<Tree.Item id="A">
<Tree.Label>A</Tree.Label>
</Tree.Item>
<Pair />
</Tree.Root>,
);
expect(screen.getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual(['A', 'B', 'C']);
expect(document.querySelectorAll(`[${COLLECT_ATTR}]`)).toHaveLength(3);
});

it('keeps fragment rows of a folder distinct in the collect pass', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ui = (showX: boolean) => (
<Tree.Root>
<Leaf id="z" />
<Tree.Folder id="f" isExpanded>
<Tree.FolderHeader>
<Tree.Label>f</Tree.Label>
</Tree.FolderHeader>
{showX && (
<Tree.Item id="x">
<Tree.Label>x</Tree.Label>
</Tree.Item>
)}
<>
<Tree.Item id="a">
<Tree.Label>a</Tree.Label>
</Tree.Item>
<Tree.Item id="a2">
<Tree.Label>a2</Tree.Label>
</Tree.Item>
</>
<>
<Tree.Item id="b">
<Tree.Label>b</Tree.Label>
</Tree.Item>
<Tree.Item id="b2">
<Tree.Label>b2</Tree.Label>
</Tree.Item>
</>
</Tree.Folder>
</Tree.Root>
);
const { rerender } = render(ui(true));
rerender(ui(false));
expect(screen.getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual([
'z',
'f',
'a',
'a2',
'b',
'b2',
]);
expect(spy.mock.calls.some((call) => String(call[0]).includes('same key'))).toBe(false);
spy.mockRestore();
});

describe('collect pass render cost', () => {
it('renders rows twice per consumer commit at most', () => {
const ui = () => (
<Tree.Root>
<Rows ids={['a', 'b', 'c']} />
</Tree.Root>
);
const { rerender } = render(ui());
treeRowRenders = 0;
rerender(ui());
expect(treeRowRenders / 3).toBeLessThanOrEqual(2);
});

it('renders rows no more often than the static path when a row is added', () => {
const staticUi = (ids: string[]) => (
<Tree.Root>
{ids.map((id) => (
<Tree.Item key={id} id={id}>
<Tree.Label>{id}</Tree.Label>
</Tree.Item>
))}
</Tree.Root>
);
const staticView = render(staticUi(['a', 'b', 'c']));
treeRowRenders = 0;
staticView.rerender(staticUi(['a', 'b', 'c', 'd']));
const staticRenders = treeRowRenders;
staticView.unmount();

vi.useFakeTimers();
render(
<Tree.Root>
<GrowingRows />
</Tree.Root>,
);
treeRowRenders = 0;
act(() => {
vi.runAllTimers();
});
expect(screen.getAllByRole('treeitem')).toHaveLength(4);
expect(treeRowRenders).toBeLessThanOrEqual(staticRenders);
vi.useRealTimers();
});
});
});
Loading
Loading