import type { Meta, StoryObj } from '@storybook/html';
import { TreeView, type TreeViewOptions } from './TreeView';
import './treeview.css';
// `storybook-readme` (installed for this repo) targets Storybook 5 and is
// incompatible with this Storybook 8 setup, so the README is surfaced through
// SB8's native docs mechanism: imported as raw Markdown and shown on the Docs
// page via `parameters.docs.description.component`. See the Accordion stories.
import readme from './README.md?raw';
/**
* Storybook stories for the TreeView component.
*
* The library is framework-agnostic vanilla TS, so each story's `render` builds
* real nested-list DOM, then enhances it with `new TreeView(...)` — exactly how
* a consumer would use it. The `@storybook/addon-a11y` panel runs axe-core
* against the rendered output so regressions in the ARIA wiring surface here.
*/
interface Node {
label: string;
/** Start this parent expanded (maps to `data-expanded` on the item). */
open?: boolean;
/** Start this node selected (maps to `data-selected` on the item). */
selected?: boolean;
children?: readonly Node[];
}
const SAMPLE_TREE: readonly Node[] = [
{ label: 'README.md' },
{
label: 'src',
open: true,
children: [
{ label: 'index.ts', selected: true },
{
label: 'components',
open: true,
children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }],
},
],
},
{
label: 'docs',
children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }],
},
];
/** Recursively build the `
`/`` markup the component expects. */
function buildItems(nodes: readonly Node[]): HTMLLIElement[] {
return nodes.map((node) => {
const li = document.createElement('li');
li.className = 'treeview__item';
if (node.open) li.setAttribute('data-expanded', '');
if (node.selected) li.setAttribute('data-selected', '');
const label = document.createElement('span');
label.className = 'treeview__label';
label.textContent = node.label;
li.append(label);
if (node.children?.length) {
const group = document.createElement('ul');
group.className = 'treeview__group';
group.append(...buildItems(node.children));
li.append(group);
}
return li;
});
}
/** Build the semantic tree markup and activate the component. */
function renderTree(nodes: readonly Node[], options: TreeViewOptions = {}): HTMLElement {
const root = document.createElement('ul');
root.className = 'treeview';
root.setAttribute('data-treeview', '');
root.append(...buildItems(nodes));
new TreeView(root, { label: 'Project files', ...options });
return root;
}
const meta: Meta = {
title: 'Components/TreeView',
// `autodocs` (see `.storybook/main.ts` → `docs.autodocs: "tag"`) generates a
// "Docs" page for this component; the README is rendered at the top of it.
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: readme,
},
},
},
argTypes: {
// `table.defaultValue.summary` fills the Controls/Docs "Default" column.
// `@storybook/html` cannot read these from the TS interface, so they are
// kept in sync with the defaults in `TreeViewOptions` by hand.
selectionMode: {
control: 'select',
options: ['none', 'single', 'multiple'],
description:
'Selection behaviour: "none" (navigation only), "single" (one node), or "multiple" (any number; adds aria-multiselectable).',
table: { defaultValue: { summary: 'single' } },
},
wrapFocus: {
control: 'boolean',
description: 'Wrap Up/Down arrow focus around the first/last visible node.',
table: { defaultValue: { summary: 'false' } },
},
typeAhead: {
control: 'boolean',
description: 'Focus the next visible node whose label matches typed characters.',
table: { defaultValue: { summary: 'true' } },
},
},
render: (args) => renderTree(SAMPLE_TREE, args),
};
export default meta;
type Story = StoryObj;
/** Default configuration: single-selection navigation tree. */
export const Default: Story = {
args: {
selectionMode: 'single',
wrapFocus: false,
typeAhead: true,
},
};
/** Any number of nodes may be selected; the tree is `aria-multiselectable`. */
export const MultiSelect: Story = {
args: {
...Default.args,
selectionMode: 'multiple',
},
};
/** Pure navigation: no `aria-selected` is applied to nodes. */
export const NavigationOnly: Story = {
args: {
...Default.args,
selectionMode: 'none',
},
};