diff --git a/packages/ui/README.md b/packages/ui/README.md index ec7936934..f95399715 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -38,12 +38,76 @@ Every component forwards `className` and `style` to its root element, and default rules use single-class specificity, so a consumer class imported after the library overrides any default (width, height, spacing). -Where VS Code's stable rendering and its Modern UI preview -(`workbench.experimental.modernUI`) diverge, components follow Modern UI, -and new components should too. Webviews get no signal for the setting, so -the default cannot follow the host. Until the design settles, -`data-ui-style="stable"` on the document root restores the stable-parity -menu motion; Storybook's "UI style" toolbar switch toggles it live. +VS Code currently uses its stable UI by default; Modern UI remains behind the +experimental `workbench.experimental.modernUI` setting. `@repo/ui` +intentionally uses Modern UI as its package default because webviews receive no +host signal for that setting. The divergence is isolated: set +`data-ui-style="stable"` on the document root to restore stable row geometry, +focus behavior, and menu motion. Storybook's "UI style" toolbar switch toggles +that override live. + +## Tree + +`Tree`, `TreeItem`, and `TreeGroup` form a declarative hierarchy: + +```tsx +const [selectedItemId, setSelectedItemId] = useState("src"); +const [expanded, setExpanded] = useState(true); + + + + src + + + Tree.tsx + + + +; +``` + +`itemId` and `textValue` are required. The ID provides stable selection and +registry identity; the text value supplies the fallback accessible name and +drives case-insensitive, buffered type-ahead without depending on rendered DOM +text. Keep it aligned with the visible label unless you provide an explicit +`aria-label` or `aria-labelledby`. Selection is controlled by `Tree`, and each +branch's expansion is controlled by its `TreeItem`. A `TreeGroup` must be a +direct child of its `TreeItem` (arrays and fragments are fine); wrapping it in +another component hides it from branch detection and throws. The suite +intentionally does not provide default state or multi-selection. + +Arrow Up/Down, Home, End, and type-ahead move focus through visible enabled +items. Arrow Right expands a branch or enters it; Arrow Left collapses a branch +or returns to its parent. Enter and Space select the focused item and toggle a +branch. Clicking a row selects it and toggles a branch; clicking the twistie +only toggles, leaving selection in place like the native tree. Interactive +content in the trailing `action` slot is isolated from tree selection and +expansion. + +Logical navigation order derives from rendered DOM position, so reordering +rows needs no extra wiring. + +Tree rows are 22px tall. By default every row keeps the VS Code twistie +gutter, matching trees whose branch rows render icons. For file trees whose +folders render without icons — the native Explorer default — use +`variant="explorer"`: leaf rows collapse the unused gutter so file icons align +with branch twisties. Don't combine the explorer variant with branch icons; the +collapsed gutter pulls leaf icons out of alignment with branch content. +The package's intentional Modern default uses 4px side +insets, 4px corner radii, and keyboard-only focus outlines. Setting +`data-ui-style="stable"` on the document root makes rows edge-to-edge and square +and restores VS Code's current stable focus behavior. The tree renders +hierarchy guides on hover and highlights active or selected ancestor paths. ## Overlays @@ -79,7 +143,6 @@ until the exit animation ends. High contrast, `forced-colors`, and - Keybinding hints show the contributed defaults the consumer passes, not user remaps: VS Code exposes no API for extensions to resolve a command's effective keybinding. -- List/selection-row tokens are deferred to the Tree suite (#1037). ## Codicons @@ -97,4 +160,6 @@ declared CSS exports. Shared internals are reached through `package.json` subpath imports (`#cx`, `#codicons`, `#storybook`). These resolve only inside this package and ship -with it, so they survive a standalone NPM split. +with it, so they survive a standalone NPM split. Component families keep +their own internals (contexts, stores) inside their folder and import them +relatively, so a family can lift out wholesale. diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css new file mode 100644 index 000000000..7352523ec --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.css @@ -0,0 +1,164 @@ +.ui-tree { + --ui-tree-indent-size: 8px; + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.ui-tree-item { + outline: 0; +} + +.ui-tree-item__row { + position: relative; + display: flex; + align-items: center; + box-sizing: border-box; + height: 22px; + padding-inline-end: var(--ui-spacing-120); + cursor: pointer; + user-select: none; +} + +.ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) + > .ui-tree-item__row:hover { + color: var(--ui-list-hover-foreground); + background: var(--ui-list-hover-background); + outline: 1px dashed var(--ui-list-hover-outline); + outline-offset: -1px; +} + +.ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: var(--ui-list-inactive-selection-foreground); + background: var(--ui-list-inactive-selection-background); + outline: 1px dotted var(--ui-list-selection-outline); + outline-offset: -1px; +} + +.ui-tree--focused .ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: var(--ui-list-active-selection-foreground); + background: var(--ui-list-active-selection-background); +} + +.ui-tree-item[aria-disabled="true"] > .ui-tree-item__row { + color: var(--ui-disabled-foreground, currentColor); + cursor: default; +} + +.ui-tree-item__indent { + position: absolute; + inset-block: 0; + inset-inline-start: calc(2 * var(--ui-tree-indent-size)); + display: flex; + pointer-events: none; +} + +.ui-tree-item__indent-slot { + position: relative; + width: var(--ui-tree-indent-size); + flex: none; +} + +.ui-tree-item__indent-slot::after { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + border-inline-start: 1px solid var(--ui-tree-indent-guide-inactive); + content: ""; + opacity: 0; +} + +.ui-tree:hover .ui-tree-item__indent-slot::after, +.ui-tree-item__indent-slot--active::after { + opacity: 1; +} + +.ui-tree-item__indent-slot--active::after { + border-inline-start-color: var(--ui-tree-indent-guide-active); +} + +.ui-tree-item__chevron { + display: flex; + align-items: center; + justify-content: center; + box-sizing: content-box; + width: 16px; + height: 22px; + padding-inline-end: 6px; + flex: none; + transform: translateX(3px); +} + +.ui-tree-item__chevron:dir(rtl) { + transform: translateX(-3px); +} + +/* Keep 3px of the collapsed gutter so leaf icons clear the innermost + indent guide and line up with the translateX'd branch twisties. */ +.ui-tree--explorer + .ui-tree-item:not([aria-expanded]) + > .ui-tree-item__row + > .ui-tree-item__chevron { + width: 3px; + padding-inline-end: 0; + visibility: hidden; +} + +.ui-tree-item__chevron > .ui-icon { + width: 10px; + font-size: 10px; +} + +.ui-tree-item__content { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + line-height: 22px; + overflow: hidden; + white-space: nowrap; +} + +.ui-tree-item__content > .ui-icon { + margin-inline-end: var(--ui-spacing-60); + flex: none; +} + +.ui-tree-item__action { + display: none; + align-items: center; + align-self: stretch; + flex: none; + gap: 2px; +} + +.ui-tree-item[aria-selected="true"] > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:hover .ui-tree-item__action, +.ui-tree-item:focus > .ui-tree-item__row .ui-tree-item__action, +.ui-tree-item__row:focus-within .ui-tree-item__action { + display: inline-flex; +} + +/* Collapse wins over consumer display values on the group. */ +.ui-tree-group[hidden] { + display: none !important; +} + +@media (prefers-reduced-motion: no-preference) { + .ui-tree-item__indent-slot::after { + transition: opacity 100ms linear; + } +} + +@media (forced-colors: active) { + .ui-tree-item:not([aria-disabled="true"]):not([aria-selected="true"]) + > .ui-tree-item__row:hover, + .ui-tree-item[aria-selected="true"] > .ui-tree-item__row { + color: HighlightText; + background: Highlight; + } + + .ui-tree-item__indent-slot::after { + border-color: CanvasText; + } +} diff --git a/packages/ui/src/components/Tree/Tree.modern.css b/packages/ui/src/components/Tree/Tree.modern.css new file mode 100644 index 000000000..b0d07e292 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.modern.css @@ -0,0 +1,19 @@ +:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row { + margin-inline: var(--ui-spacing-40); + border-radius: var(--ui-radius-small); +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item:focus-visible + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root:not([data-ui-style="stable"])) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus-visible + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stable.css b/packages/ui/src/components/Tree/Tree.stable.css new file mode 100644 index 000000000..d5f4de92c --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stable.css @@ -0,0 +1,14 @@ +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item:focus + > .ui-tree-item__row { + outline: 1px solid var(--ui-list-focus-outline); + outline-offset: -1px; +} + +:where(:root[data-ui-style="stable"]) + .ui-tree--focused + .ui-tree-item[aria-selected="true"]:focus + > .ui-tree-item__row { + outline-color: var(--ui-list-focus-and-selection-outline); +} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx new file mode 100644 index 000000000..484e0d5b9 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Icon } from "../Icon/Icon"; +import { IconButton } from "../IconButton/IconButton"; + +import { Tree } from "./Tree"; +import { TreeGroup } from "./TreeGroup"; +import { TreeItem } from "./TreeItem"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeStates = (): React.JSX.Element => { + const [selectedItemId, setSelectedItemId] = useState("components"); + const [sourceExpanded, setSourceExpanded] = useState(true); + const [componentsExpanded, setComponentsExpanded] = useState(true); + + // The native default Explorer: branch rows render without icons, so the + // explorer variant aligns leaf file icons with the branch twisties. + return ( + + + src + + + components + + } + > + + Tree.tsx + + + + Tree.css + + + + + + tests + + + + + + README.md + + + ); +}; + +const meta: Meta = { + title: "UI/Tree", + component: TreeStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +const exerciseTree = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}): Promise => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("treeitem", { name: "components" }), + ).toHaveAttribute("aria-selected", "true"); + + // Click the trailing action while another row owns selection to prove + // action clicks never select their host row. The button is display:none + // until its row is hovered, selected, or focused, so query it hidden; + // the synthetic click still dispatches and bubbles. + const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); + await userEvent.click( + canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), + ); + await expect( + canvas.getByRole("treeitem", { name: "components" }), + ).toHaveAttribute("aria-selected", "true"); + await expect(treeItem).toHaveAttribute("aria-selected", "false"); + + await userEvent.click(treeItem); + await expect(treeItem).toHaveAttribute("aria-selected", "true"); + + await userEvent.click(canvas.getByRole("treeitem", { name: "README.md" })); + await expect( + canvas.getByRole("treeitem", { name: "README.md" }), + ).toHaveAttribute("aria-selected", "true"); +}; + +export const States: Story = { play: exerciseTree }; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, + play: exerciseTree, +}; + +export const Nested: Story = {}; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx new file mode 100644 index 000000000..4c07b3ec7 --- /dev/null +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -0,0 +1,110 @@ +import { + type ComponentPropsWithRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { cx } from "#cx"; + +import { TreeContext, TreeHierarchyContext } from "./context"; +import { TreeStore } from "./store/TreeStore"; +import "./Tree.css"; +import "./Tree.modern.css"; +import "./Tree.stable.css"; + +const ROOT_HIERARCHY = { level: 1, pathItemIds: [] } as const; + +function focusBelongsToTree( + tree: HTMLElement, + target: EventTarget | null, +): boolean { + return target instanceof Element && target.closest(".ui-tree") === tree; +} + +export interface TreeProps extends Omit< + ComponentPropsWithRef<"div">, + "role" | "onSelect" +> { + /** + * "explorer" collapses the unused twistie gutter on leaf rows so file + * icons align with branch twisties, like the native Explorer whose + * folders render without icons. Keep "default" when branch rows render + * icons, or leaf icons fall out of alignment with branch content. + */ + variant?: "default" | "explorer"; + selectedItemId?: string; + onSelectedItemChange?: (itemId: string) => void; +} + +/** A controlled, single-selection tree with native VS Code keyboard behavior. */ +export function Tree({ + variant = "default", + selectedItemId, + onSelectedItemChange, + className, + children, + onBlur, + onFocus, + onKeyDown, + ...props +}: TreeProps): React.JSX.Element { + const [store] = useState( + () => new TreeStore(selectedItemId, onSelectedItemChange), + ); + const [hasDomFocus, setHasDomFocus] = useState(false); + + useLayoutEffect(() => { + store.setConfiguration(selectedItemId, onSelectedItemChange); + }, [onSelectedItemChange, selectedItemId, store]); + + useLayoutEffect(() => { + // Any commit can reorder rows without updating them. + store.invalidateItemOrder(); + store.flushPendingChanges(); + }); + useEffect(() => () => store.dispose(), [store]); + + return ( + + +
{ + onFocus?.(event); + if ( + !event.defaultPrevented && + focusBelongsToTree(event.currentTarget, event.target) + ) { + setHasDomFocus(true); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !focusBelongsToTree(event.currentTarget, event.relatedTarget) + ) { + setHasDomFocus(false); + } + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (!event.defaultPrevented) { + store.onKeyDown(event); + } + }} + > + {children} +
+
+
+ ); +} diff --git a/packages/ui/src/components/Tree/TreeGroup.stories.tsx b/packages/ui/src/components/Tree/TreeGroup.stories.tsx new file mode 100644 index 000000000..5ac7d494b --- /dev/null +++ b/packages/ui/src/components/Tree/TreeGroup.stories.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Tree } from "./Tree"; +import { TreeGroup } from "./TreeGroup"; +import { TreeItem } from "./TreeItem"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeGroupStates = (): React.JSX.Element => { + const [expanded, setExpanded] = useState(true); + + return ( + + + Grouped items + + + First child + + + Second child + + + + + ); +}; + +const meta: Meta = { + title: "UI/TreeGroup", + component: TreeGroupStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; diff --git a/packages/ui/src/components/Tree/TreeGroup.tsx b/packages/ui/src/components/Tree/TreeGroup.tsx new file mode 100644 index 000000000..4e84cd2e2 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeGroup.tsx @@ -0,0 +1,32 @@ +import { type ComponentPropsWithRef, use } from "react"; + +import { cx } from "#cx"; + +import { TreeRowContentContext, useTreeItemContext } from "./context"; + +export type TreeGroupProps = Omit, "role">; + +/** The child-item container for its nearest parent TreeItem. */ +export function TreeGroup({ + className, + children, + ...props +}: TreeGroupProps): React.JSX.Element { + const { expanded } = useTreeItemContext(); + if (use(TreeRowContentContext)) { + throw new Error( + "TreeGroup must be a direct child of TreeItem; a wrapper component hides it from branch detection and renders it as row content.", + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/Tree/TreeItem.stories.tsx b/packages/ui/src/components/Tree/TreeItem.stories.tsx new file mode 100644 index 000000000..731ce83de --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.stories.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; + +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Icon } from "../Icon/Icon"; +import { IconButton } from "../IconButton/IconButton"; + +import { Tree } from "./Tree"; +import { TreeGroup } from "./TreeGroup"; +import { TreeItem } from "./TreeItem"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TreeItemStates = (): React.JSX.Element => { + const [selectedItemId, setSelectedItemId] = useState("selected"); + const [expanded, setExpanded] = useState(true); + const [collapsedExpanded, setCollapsedExpanded] = useState(false); + + return ( + + + + Plain item + + + + Selected branch + + + Child item + + + + + + Collapsed branch + + + Hidden item + + + + } + > + Item with action + + + Disabled item + + + ); +}; + +const meta: Meta = { + title: "UI/TreeItem", + component: TreeItemStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = {}; + +export const Stable: Story = { + globals: { uiStyle: "stable" }, +}; diff --git a/packages/ui/src/components/Tree/TreeItem.tsx b/packages/ui/src/components/Tree/TreeItem.tsx new file mode 100644 index 000000000..68a790b01 --- /dev/null +++ b/packages/ui/src/components/Tree/TreeItem.tsx @@ -0,0 +1,313 @@ +import { + type ComponentPropsWithRef, + Fragment, + isValidElement, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + use, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; + +import { + TreeHierarchyContext, + TreeItemContext, + TreeRowContentContext, + useTreeContext, +} from "./context"; +import { INTERACTIVE_SELECTOR } from "./store/TreeStore"; +import { TreeGroup } from "./TreeGroup"; + +export interface TreeItemProps extends Omit< + ComponentPropsWithRef<"div">, + "children" | "id" | "role" | "onSelect" +> { + itemId: string; + textValue: string; + disabled?: boolean; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + children?: ReactNode; + action?: ReactNode; +} + +interface ClassifiedChildren { + group?: ReactElement; + rowContent: ReactNode[]; +} + +function isTreeGroupElement(child: ReactNode): child is ReactElement { + return isValidElement(child) && child.type === TreeGroup; +} + +function isFragmentElement( + child: ReactNode, +): child is ReactElement<{ children?: ReactNode }> { + return ( + isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment + ); +} + +function classifyChildren(children: ReactNode): ClassifiedChildren { + const groups: ReactElement[] = []; + const rowContent: ReactNode[] = []; + + const visit = (child: ReactNode): void => { + if (Array.isArray(child)) { + child.forEach(visit); + return; + } + if (isFragmentElement(child)) { + visit(child.props.children); + return; + } + if (isTreeGroupElement(child)) { + groups.push(child); + return; + } + rowContent.push(child); + }; + visit(children); + + if (groups.length > 1) { + throw new Error("TreeItem accepts at most one TreeGroup."); + } + return { group: groups[0], rowContent }; +} + +function eventBelongsToRow( + event: { currentTarget: HTMLElement; target: EventTarget | null }, + row: HTMLElement | null, +): boolean { + return ( + event.target === event.currentTarget || + (event.target instanceof Node && row?.contains(event.target) === true) + ); +} + +function isNestedInteractiveTarget( + event: ReactMouseEvent, +): boolean { + const target = event.target; + if (!(target instanceof Element) || target === event.currentTarget) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + return ( + interactiveTarget !== null && + interactiveTarget !== event.currentTarget && + event.currentTarget.contains(interactiveTarget) + ); +} + +/** A controlled tree row. Place one TreeGroup among its children to create a branch. */ +export function TreeItem({ + itemId, + textValue, + expanded, + onExpandedChange, + children, + action, + disabled = false, + className, + style, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + onClick, + onFocus, + ref, + ...props +}: TreeItemProps): React.JSX.Element { + const store = useTreeContext(); + const hierarchy = use(TreeHierarchyContext); + const internalRef = useRef(null); + const rowRef = useRef(null); + const chevronRef = useRef(null); + const { group, rowContent } = classifyChildren(children); + const hasChildren = group !== undefined; + const canChangeExpansion = hasChildren && onExpandedChange !== undefined; + const isExpanded = hasChildren && expanded === true; + const itemPathIds = [...hierarchy.pathItemIds, itemId]; + const { + indentGuideOwnerIds, + selected: isSelected, + tabIndex, + } = useSyncExternalStore( + store.subscribe, + () => store.getItemSnapshot(itemId), + () => store.getItemSnapshot(itemId), + ); + + useLayoutEffect(() => { + const element = internalRef.current; + if (!element) { + return; + } + // Disabled placeholder; the update effect below fills the real + // props in the same commit, before the tree reconciles. + return store.registerItem({ + id: itemId, + textValue: "", + disabled: true, + expanded: false, + hasChildren: false, + element, + select: () => undefined, + }); + }, [itemId, store]); + + useLayoutEffect(() => { + const element = internalRef.current; + if (!element) { + return; + } + store.updateItem(itemId, { + parentId: hierarchy.parentItemId, + textValue, + disabled, + expanded: isExpanded, + hasChildren, + element, + select: () => store.requestSelection(itemId), + setExpanded: canChangeExpansion + ? (nextExpanded) => onExpandedChange?.(nextExpanded) + : undefined, + }); + }, [ + canChangeExpansion, + disabled, + hasChildren, + hierarchy.parentItemId, + isExpanded, + itemId, + onExpandedChange, + store, + textValue, + ]); + + const itemContext = { + itemId, + level: hierarchy.level, + expanded: isExpanded, + }; + const groupHierarchy = { + level: hierarchy.level + 1, + parentItemId: itemId, + pathItemIds: itemPathIds, + }; + + const selectItem = (): void => { + if (!disabled) { + store.requestSelection(itemId); + } + }; + const toggleExpanded = (): void => { + if (!disabled && canChangeExpansion) { + onExpandedChange?.(!isExpanded); + } + }; + + return ( + +
{ + internalRef.current = element; + if (typeof ref === "function") { + ref(element); + } else if (ref) { + ref.current = element; + } + }} + role="treeitem" + aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? textValue)} + aria-labelledby={ariaLabelledBy} + aria-level={hierarchy.level} + aria-selected={isSelected} + aria-disabled={disabled || undefined} + aria-expanded={hasChildren ? isExpanded : undefined} + tabIndex={tabIndex} + className={cx("ui-tree-item", className)} + style={style} + onFocus={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onFocus?.(event); + if (!event.defaultPrevented && event.target === event.currentTarget) { + store.onItemFocus(itemId); + } + }} + onClick={(event) => { + if (!eventBelongsToRow(event, rowRef.current)) { + return; + } + onClick?.(event); + if ( + event.defaultPrevented || + disabled || + isNestedInteractiveTarget(event) + ) { + return; + } + // Twistie clicks toggle the branch without moving + // selection, matching the native tree. + const onTwistie = + hasChildren && + event.target instanceof Node && + chevronRef.current?.contains(event.target) === true; + if (!onTwistie) { + selectItem(); + } + if (hasChildren) { + toggleExpanded(); + } + }} + > +
+
+ + {group} + +
+
+ ); +} diff --git a/packages/ui/src/components/Tree/context.ts b/packages/ui/src/components/Tree/context.ts new file mode 100644 index 000000000..bfb2409c6 --- /dev/null +++ b/packages/ui/src/components/Tree/context.ts @@ -0,0 +1,42 @@ +import { createContext, use } from "react"; + +import type { TreeStore } from "./store/TreeStore"; + +export interface TreeItemContextValue { + itemId: string; + level: number; + expanded?: boolean; +} + +export interface TreeHierarchyContextValue { + level: number; + parentItemId?: string; + pathItemIds: readonly string[]; +} + +export const TreeContext = createContext(undefined); +/** True inside a TreeItem's row content, where a TreeGroup is a misuse. */ +export const TreeRowContentContext = createContext(false); +export const TreeItemContext = createContext( + undefined, +); +export const TreeHierarchyContext = createContext({ + level: 1, + pathItemIds: [], +}); + +export function useTreeContext(): TreeStore { + const context = use(TreeContext); + if (!context) { + throw new Error("Tree components must be rendered inside Tree."); + } + return context; +} + +export function useTreeItemContext(): TreeItemContextValue { + const context = use(TreeItemContext); + if (!context) { + throw new Error("TreeGroup must be rendered inside TreeItem."); + } + return context; +} diff --git a/packages/ui/src/components/Tree/store/TreeStore.ts b/packages/ui/src/components/Tree/store/TreeStore.ts new file mode 100644 index 000000000..5f47e2bec --- /dev/null +++ b/packages/ui/src/components/Tree/store/TreeStore.ts @@ -0,0 +1,627 @@ +import { + compareDomOrder, + computeVisibility, + hasCompleteAncestry, + isDescendant, + itemPath, +} from "./hierarchy"; +import { + findTypeAheadMatch, + nextTypeAheadQuery, + TYPE_AHEAD_TIMEOUT_MS, +} from "./typeAhead"; + +import type { KeyboardEvent } from "react"; + +import type { + RegisteredItem, + TreeItemSnapshot, + TreeStoreItem, + TreeStoreItemUpdate, +} from "./types"; + +export const INTERACTIVE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable]:not([contenteditable='false'])", + "[role='button']", + "[role='checkbox']", + "[role='combobox']", + "[role='link']", + "[role='menuitem']", + "[role='option']", + "[role='radio']", + "[role='slider']", + "[role='spinbutton']", + "[role='switch']", + "[role='textbox']", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function arraysEqual( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +/** + * Registry and interaction engine for one tree: roving tab stop, focus and + * selection reconciliation, and VS Code keyboard behavior over rows that + * register themselves as they mount. + */ +export class TreeStore { + private readonly items = new Map(); + private readonly itemsByElement = new Map(); + private readonly knownOrder = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly itemSnapshots = new Map(); + private nextOrder = 0; + private tabStopId: string | undefined; + private removedTabStopPath: readonly string[] = []; + private focusedId: string | undefined; + private focusedRegistration: object | undefined; + private selectedId: string | undefined; + private pendingSelectedId: string | undefined; + private onSelectedItemChange: ((itemId: string) => void) | undefined; + private visibility: ReadonlyMap = new Map(); + private visibleItems: readonly RegisteredItem[] = []; + private visibleItemsDirty = true; + private indentGuideOwnerIdsDirty = true; + private indentGuideOwnerIds: ReadonlySet = new Set(); + private structuralChangeScheduled = false; + private structuralChangeToken = 0; + private revision = 0; + private typeAheadBuffer = ""; + private typeAheadTimer: ReturnType | undefined; + + constructor( + selectedId?: string, + onSelectedItemChange?: (itemId: string) => void, + ) { + this.selectedId = selectedId; + this.pendingSelectedId = selectedId; + this.onSelectedItemChange = onSelectedItemChange; + } + + readonly subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange); + return () => this.listeners.delete(onChange); + }; + + readonly getRevision = (): number => this.revision; + + readonly flushPendingChanges = (): void => { + if (!this.structuralChangeScheduled) { + return; + } + this.structuralChangeScheduled = false; + this.structuralChangeToken += 1; + this.reconcileTabStop(); + this.reconcileFocusedItem(); + this.publishChange(); + }; + + readonly setConfiguration = ( + selectedId: string | undefined, + onSelectedItemChange?: (itemId: string) => void, + ): void => { + this.onSelectedItemChange = onSelectedItemChange; + if (this.selectedId === selectedId) { + return; + } + this.selectedId = selectedId; + this.pendingSelectedId = selectedId; + this.indentGuideOwnerIdsDirty = true; + this.reconcilePendingSelection(); + this.publishChange(); + }; + + readonly dispose = (): void => { + this.structuralChangeScheduled = false; + this.structuralChangeToken += 1; + if (this.typeAheadTimer !== undefined) { + clearTimeout(this.typeAheadTimer); + this.typeAheadTimer = undefined; + } + this.typeAheadBuffer = ""; + }; + + readonly requestSelection = (itemId: string): void => { + this.onSelectedItemChange?.(itemId); + }; + + /** DOM order can change without any item update; re-sort on next read. */ + readonly invalidateItemOrder = (): void => { + this.visibleItemsDirty = true; + }; + + readonly registerItem = (item: TreeStoreItem): (() => void) => { + if (this.items.has(item.id)) { + throw new Error( + `Tree keyboard navigation item id "${item.id}" is already registered by another row. Item ids must be unique.`, + ); + } + + let order = this.knownOrder.get(item.id); + if (order === undefined) { + order = this.nextOrder; + this.nextOrder += 1; + this.knownOrder.set(item.id, order); + } + + const registration = {}; + const registered = { ...item, order, registration }; + this.items.set(item.id, registered); + if (registered.element) { + this.itemsByElement.set(registered.element, registered); + } + this.invalidateStructure(); + this.scheduleStructuralChange(); + + return (): void => { + if (this.items.get(item.id)?.registration !== registration) { + return; + } + if (item.id === this.tabStopId) { + // Capture ancestry before deletion so reconciliation keeps + // the tab stop near the removed row. + this.removedTabStopPath = itemPath(item.id, this.items); + } + this.items.delete(item.id); + this.dropElementMapping(registered); + this.invalidateStructure(); + this.scheduleStructuralChange(); + queueMicrotask(() => this.releaseItemState(item.id)); + }; + }; + + readonly updateItem = (id: string, update: TreeStoreItemUpdate): void => { + const current = this.items.get(id); + if (!current) { + return; + } + + const structureChanged = + current.parentId !== update.parentId || + current.disabled !== update.disabled || + current.expanded !== update.expanded || + current.hasChildren !== update.hasChildren || + current.element !== update.element; + if (current.element !== update.element) { + this.dropElementMapping(current); + if (update.element) { + this.itemsByElement.set(update.element, current); + } + } + // The update is the item's full next state, not a patch; absent + // optional keys clear their previous values. + Object.assign(current, { + parentId: update.parentId, + textValue: update.textValue, + disabled: update.disabled, + expanded: update.expanded, + hasChildren: update.hasChildren, + element: update.element, + select: update.select, + setExpanded: update.setExpanded, + }); + if (structureChanged) { + this.invalidateStructure(); + this.scheduleStructuralChange(); + } + }; + + readonly getTabIndex = (id: string): 0 | -1 => + this.tabStopId === id ? 0 : -1; + + readonly getItemSnapshot = (id: string): TreeItemSnapshot => { + this.ensureIndentGuideOwnerIds(); + const indentGuideOwnerIds = itemPath(id, this.items) + .slice(0, -1) + .filter((pathId) => this.indentGuideOwnerIds.has(pathId)); + const previous = this.itemSnapshots.get(id); + const tabIndex = this.getTabIndex(id); + const selected = this.selectedId === id; + if ( + previous?.tabIndex === tabIndex && + previous.selected === selected && + arraysEqual(previous.indentGuideOwnerIds, indentGuideOwnerIds) + ) { + return previous; + } + + const snapshot = { tabIndex, selected, indentGuideOwnerIds }; + this.itemSnapshots.set(id, snapshot); + return snapshot; + }; + + readonly onItemFocus = (id: string): void => { + const item = this.items.get(id); + const canReceiveFocus = + item !== undefined && !item.disabled && this.isItemVisible(item); + if (canReceiveFocus) { + // The user took over; the initial selection no longer claims the + // tab stop when its branch is revealed later. + this.pendingSelectedId = undefined; + } + const focusChanged = this.setFocusedItem( + canReceiveFocus ? item : undefined, + ); + const tabStopChanged = canReceiveFocus + ? this.setTabStopValue(item.id) + : false; + if (focusChanged || tabStopChanged) { + this.publishChange(); + } + }; + + readonly onKeyDown = (event: KeyboardEvent): void => { + if (this.isInteractiveTarget(event)) { + return; + } + const eventItem = this.findEventItem(event); + const visibleItems = this.getVisibleEnabledItems(); + const currentItem = + eventItem ?? + (this.focusedId === undefined + ? undefined + : this.items.get(this.focusedId)) ?? + (this.tabStopId === undefined + ? undefined + : this.items.get(this.tabStopId)) ?? + visibleItems[0]; + if (!currentItem) { + return; + } + + const currentIndex = visibleItems.findIndex( + ({ id }) => id === currentItem.id, + ); + // A disabled or hidden event source is absent from visibleItems; + // derive its neighbors from tree order so arrows step past it. + let nextIndex = currentIndex + 1; + let previousIndex = currentIndex - 1; + if (currentIndex === -1) { + const insertionIndex = visibleItems.findIndex( + (item) => compareDomOrder(currentItem, item) < 0, + ); + nextIndex = insertionIndex === -1 ? visibleItems.length : insertionIndex; + previousIndex = nextIndex - 1; + } + let handled = true; + + switch (event.key) { + case "ArrowDown": + this.focusItem(visibleItems[nextIndex]); + break; + case "ArrowUp": + this.focusItem(visibleItems[previousIndex]); + break; + case "Home": + this.focusItem(visibleItems[0]); + break; + case "End": + this.focusItem(visibleItems.at(-1)); + break; + case "ArrowRight": + if ( + !currentItem.disabled && + currentItem.hasChildren && + !currentItem.expanded && + currentItem.setExpanded + ) { + currentItem.setExpanded(true); + } else if ( + !currentItem.disabled && + currentItem.hasChildren && + currentItem.expanded + ) { + this.focusItem( + visibleItems + .slice(nextIndex) + .find((item) => isDescendant(item, currentItem.id, this.items)), + ); + } + break; + case "ArrowLeft": + if ( + !currentItem.disabled && + currentItem.hasChildren && + currentItem.expanded && + currentItem.setExpanded + ) { + currentItem.setExpanded(false); + } else if (!currentItem.disabled) { + this.focusParent(currentItem); + } + break; + case "Enter": + case " ": + if (currentItem.disabled) { + break; + } + currentItem.select(); + if (currentItem.hasChildren && currentItem.setExpanded) { + currentItem.setExpanded(!currentItem.expanded); + } + break; + default: + handled = false; + } + + if (handled) { + event.preventDefault(); + return; + } + + if ( + event.key.length !== 1 || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + this.typeAheadBuffer = nextTypeAheadQuery( + this.typeAheadBuffer, + event.key.toLocaleLowerCase(), + ); + if (this.typeAheadTimer !== undefined) { + clearTimeout(this.typeAheadTimer); + } + this.typeAheadTimer = setTimeout(() => { + this.typeAheadBuffer = ""; + this.typeAheadTimer = undefined; + }, TYPE_AHEAD_TIMEOUT_MS); + + this.focusItem( + findTypeAheadMatch(visibleItems, nextIndex, this.typeAheadBuffer), + ); + event.preventDefault(); + }; + + private dropElementMapping(item: RegisteredItem): void { + if (item.element && this.itemsByElement.get(item.element) === item) { + this.itemsByElement.delete(item.element); + } + } + + private findEventItem( + event: KeyboardEvent, + ): RegisteredItem | undefined { + let element: Element | null = + event.target instanceof Element ? event.target : null; + while (element) { + const item = + element instanceof HTMLElement + ? this.itemsByElement.get(element) + : undefined; + if (item) { + return item; + } + element = element.parentElement; + } + return undefined; + } + + private isInteractiveTarget(event: KeyboardEvent): boolean { + const target = event.target; + if (!(target instanceof Element)) { + return false; + } + const interactiveTarget = target.closest(INTERACTIVE_SELECTOR); + if ( + interactiveTarget === null || + interactiveTarget === event.currentTarget || + !event.currentTarget.contains(interactiveTarget) + ) { + return false; + } + // Row elements are the navigation surface, not embedded controls. + return !( + interactiveTarget instanceof HTMLElement && + this.itemsByElement.has(interactiveTarget) + ); + } + + private releaseItemState(id: string): void { + if (this.items.has(id)) { + return; + } + this.knownOrder.delete(id); + this.itemSnapshots.delete(id); + } + + private invalidateStructure(): void { + this.visibleItemsDirty = true; + this.indentGuideOwnerIdsDirty = true; + } + + private getIndentGuideOwnerId(id: string | undefined): string | undefined { + if (id === undefined) { + return undefined; + } + const item = this.items.get(id); + if (!item) { + return undefined; + } + return item.expanded && item.hasChildren ? item.id : item.parentId; + } + + private ensureIndentGuideOwnerIds(): void { + if (!this.indentGuideOwnerIdsDirty) { + return; + } + this.indentGuideOwnerIds = new Set( + [ + this.getIndentGuideOwnerId(this.focusedId), + this.getIndentGuideOwnerId(this.selectedId), + ].filter((id): id is string => id !== undefined), + ); + this.indentGuideOwnerIdsDirty = false; + } + + private scheduleStructuralChange(): void { + if (this.structuralChangeScheduled) { + return; + } + this.structuralChangeScheduled = true; + const token = this.structuralChangeToken; + queueMicrotask(() => { + if ( + !this.structuralChangeScheduled || + this.structuralChangeToken !== token + ) { + return; + } + this.flushPendingChanges(); + }); + } + + private ensureVisibility(): void { + if (!this.visibleItemsDirty) { + return; + } + this.visibility = computeVisibility(this.items); + this.visibleItems = [...this.items.values()] + .filter((item) => !item.disabled && this.visibility.get(item.id) === true) + .sort(compareDomOrder); + this.visibleItemsDirty = false; + } + + private isItemVisible(item: RegisteredItem): boolean { + this.ensureVisibility(); + return this.visibility.get(item.id) === true; + } + + private getVisibleEnabledItems(): readonly RegisteredItem[] { + this.ensureVisibility(); + return this.visibleItems; + } + + private reconcilePendingSelection(): boolean { + const pendingSelectedId = this.pendingSelectedId; + if (pendingSelectedId === undefined) { + return false; + } + const selectedItem = this.items.get(pendingSelectedId); + if (!selectedItem || !hasCompleteAncestry(selectedItem, this.items)) { + return false; + } + if (selectedItem.disabled) { + this.pendingSelectedId = undefined; + return false; + } + if (!this.isItemVisible(selectedItem)) { + // Keep the claim; revealing the branch hands it the tab stop. + return false; + } + this.pendingSelectedId = undefined; + return this.setTabStopValue(pendingSelectedId); + } + + private reconcileTabStop(): boolean { + if (this.reconcilePendingSelection()) { + return true; + } + + const currentId = this.tabStopId; + const currentItem = + currentId === undefined ? undefined : this.items.get(currentId); + if ( + currentItem && + !currentItem.disabled && + this.isItemVisible(currentItem) + ) { + return false; + } + + // An unregistered tab stop is gone from the map; fall back to the + // ancestry captured when it was removed. + const ancestorIds = currentItem + ? itemPath(currentItem.id, this.items).slice(0, -1) + : this.removedTabStopPath.slice(0, -1); + for (const ancestorId of [...ancestorIds].reverse()) { + const ancestor = this.items.get(ancestorId); + if (ancestor && !ancestor.disabled && this.isItemVisible(ancestor)) { + return this.setTabStopValue(ancestorId); + } + } + + return this.setTabStopValue(this.getVisibleEnabledItems()[0]?.id); + } + + private reconcileFocusedItem(): boolean { + const focusedItem = + this.focusedId === undefined ? undefined : this.items.get(this.focusedId); + if ( + focusedItem !== undefined && + focusedItem.registration === this.focusedRegistration && + !focusedItem.disabled && + this.isItemVisible(focusedItem) + ) { + return false; + } + return this.setFocusedItem(undefined); + } + + private setTabStopValue(id: string | undefined): boolean { + if (this.tabStopId === id) { + return false; + } + this.tabStopId = id; + return true; + } + + private setFocusedItem(item: RegisteredItem | undefined): boolean { + const id = item?.id; + const registration = item?.registration; + if (this.focusedId === id && this.focusedRegistration === registration) { + return false; + } + this.focusedId = id; + this.focusedRegistration = registration; + this.indentGuideOwnerIdsDirty = true; + return true; + } + + private focusItem(item: RegisteredItem | undefined): void { + if (!item) { + return; + } + const tabStopChanged = this.setTabStopValue(item.id); + const revisionBeforeFocus = this.revision; + item.element?.focus(); + if (tabStopChanged && this.revision === revisionBeforeFocus) { + this.publishChange(); + } + } + + private focusParent(item: RegisteredItem): void { + let parentId = item.parentId; + while (parentId !== undefined) { + const parent = this.items.get(parentId); + if (!parent) { + return; + } + if (!parent.disabled && this.isItemVisible(parent)) { + this.focusItem(parent); + return; + } + parentId = parent.parentId; + } + } + + private publishChange(): void { + this.revision += 1; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/packages/ui/src/components/Tree/store/hierarchy.ts b/packages/ui/src/components/Tree/store/hierarchy.ts new file mode 100644 index 000000000..e2faeb1a8 --- /dev/null +++ b/packages/ui/src/components/Tree/store/hierarchy.ts @@ -0,0 +1,109 @@ +import type { RegisteredItem } from "./types"; + +type Items = ReadonlyMap; + +/** + * Visibility for every item in one shared pass. An item is visible when its + * whole ancestor chain is registered and expanded; missing parents and + * parentId cycles hide the item. + */ +export function computeVisibility(items: Items): ReadonlyMap { + const visibility = new Map(); + const resolve = (item: RegisteredItem, trail: Set): boolean => { + const known = visibility.get(item.id); + if (known !== undefined) { + return known; + } + if (trail.has(item.id)) { + return false; + } + trail.add(item.id); + const parent = + item.parentId === undefined ? undefined : items.get(item.parentId); + const visible = + item.parentId === undefined || + (parent !== undefined && parent.expanded && resolve(parent, trail)); + visibility.set(item.id, visible); + return visible; + }; + for (const item of items.values()) { + resolve(item, new Set()); + } + return visibility; +} + +/** Whether every ancestor up to a root is registered, without cycles. */ +export function hasCompleteAncestry( + item: RegisteredItem, + items: Items, +): boolean { + const visited = new Set([item.id]); + let parentId = item.parentId; + while (parentId !== undefined) { + if (visited.has(parentId)) { + return false; + } + visited.add(parentId); + const parent = items.get(parentId); + if (!parent) { + return false; + } + parentId = parent.parentId; + } + return true; +} + +export function isDescendant( + item: RegisteredItem, + ancestorId: string, + items: Items, +): boolean { + const visited = new Set([item.id]); + let parentId = item.parentId; + while (parentId !== undefined) { + if (parentId === ancestorId) { + return true; + } + if (visited.has(parentId)) { + return false; + } + visited.add(parentId); + parentId = items.get(parentId)?.parentId; + } + return false; +} + +/** Ids from the root through the requested item, cycle-safe. */ +export function itemPath( + id: string | undefined, + items: Items, +): readonly string[] { + const path: string[] = []; + const visited = new Set(); + let item = id === undefined ? undefined : items.get(id); + while (item && !visited.has(item.id)) { + visited.add(item.id); + path.unshift(item.id); + item = item.parentId === undefined ? undefined : items.get(item.parentId); + } + return path; +} + +/** DOM position, falling back to registration order for unmounted rows. */ +export function compareDomOrder( + left: RegisteredItem, + right: RegisteredItem, +): number { + if (left.element && right.element && left.element !== right.element) { + const position = left.element.compareDocumentPosition(right.element); + if (!(position & Node.DOCUMENT_POSITION_DISCONNECTED)) { + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + } + } + return left.order - right.order; +} diff --git a/packages/ui/src/components/Tree/store/typeAhead.ts b/packages/ui/src/components/Tree/store/typeAhead.ts new file mode 100644 index 000000000..8d4a25fc0 --- /dev/null +++ b/packages/ui/src/components/Tree/store/typeAhead.ts @@ -0,0 +1,25 @@ +import type { RegisteredItem } from "./types"; + +export const TYPE_AHEAD_TIMEOUT_MS = 500; + +/** Repeating one character cycles matches instead of growing the query. */ +export function nextTypeAheadQuery(buffer: string, character: string): string { + const isRepeat = + buffer.length > 0 && [...buffer].every((value) => value === character); + return isRepeat ? character : `${buffer}${character}`; +} + +/** First prefix match by textValue, searching forward from fromIndex and wrapping. */ +export function findTypeAheadMatch( + visibleItems: readonly RegisteredItem[], + fromIndex: number, + query: string, +): RegisteredItem | undefined { + const searchOrder = [ + ...visibleItems.slice(fromIndex), + ...visibleItems.slice(0, fromIndex), + ]; + return searchOrder.find(({ textValue }) => + textValue.toLocaleLowerCase().startsWith(query), + ); +} diff --git a/packages/ui/src/components/Tree/store/types.ts b/packages/ui/src/components/Tree/store/types.ts new file mode 100644 index 000000000..60d47a9f1 --- /dev/null +++ b/packages/ui/src/components/Tree/store/types.ts @@ -0,0 +1,25 @@ +export interface TreeStoreItem { + id: string; + parentId?: string; + textValue: string; + disabled: boolean; + expanded: boolean; + hasChildren: boolean; + element?: HTMLElement | null; + select: () => void; + setExpanded?: (expanded: boolean) => void; +} + +export type TreeStoreItemUpdate = Omit; + +export interface TreeItemSnapshot { + tabIndex: 0 | -1; + selected: boolean; + indentGuideOwnerIds: readonly string[]; +} + +/** A TreeStoreItem plus registry bookkeeping. */ +export interface RegisteredItem extends TreeStoreItem { + order: number; + registration: object; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 89b11ffb0..3cd503798 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,4 +72,7 @@ export { TooltipProvider, type TooltipProviderProps, } from "./components/Tooltip/Tooltip"; +export { Tree, type TreeProps } from "./components/Tree/Tree"; +export { TreeGroup, type TreeGroupProps } from "./components/Tree/TreeGroup"; +export { TreeItem, type TreeItemProps } from "./components/Tree/TreeItem"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index e4345fa09..df1fab02a 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -147,11 +147,55 @@ --ui-radius-circle: var(--vscode-cornerRadius-circle, 9999px); /* Spacing, VS Code's scale (baseSizes.ts); names are px times ten */ + --ui-spacing-40: var(--vscode-spacing-size40, 4px); --ui-spacing-60: var(--vscode-spacing-size60, 6px); --ui-spacing-120: var(--vscode-spacing-size120, 12px); --ui-spacing-160: var(--vscode-spacing-size160, 16px); --ui-spacing-240: var(--vscode-spacing-size240, 24px); + /* Lists and trees */ + --ui-list-hover-background: var(--vscode-list-hoverBackground, transparent); + --ui-list-hover-foreground: var( + --vscode-list-hoverForeground, + var(--ui-foreground) + ); + --ui-list-active-selection-background: var( + --vscode-list-activeSelectionBackground, + var(--ui-list-hover-background) + ); + --ui-list-active-selection-foreground: var( + --vscode-list-activeSelectionForeground, + var(--ui-foreground) + ); + --ui-list-inactive-selection-background: var( + --vscode-list-inactiveSelectionBackground, + var(--ui-list-active-selection-background) + ); + --ui-list-inactive-selection-foreground: var( + --vscode-list-inactiveSelectionForeground, + var(--ui-foreground) + ); + --ui-list-focus-outline: var( + --vscode-list-focusOutline, + var(--ui-focus-border) + ); + --ui-list-selection-outline: var(--vscode-list-selectionOutline, transparent); + --ui-list-hover-outline: var(--vscode-list-hoverOutline, transparent); + --ui-list-focus-and-selection-outline: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-list-selectionOutline, var(--ui-list-focus-outline)) + ); + /* Outside a webview, approximate the native guides (inactive is the + active stroke at 40%) instead of disappearing. */ + --ui-tree-indent-guide-inactive: var( + --vscode-tree-inactiveIndentGuidesStroke, + color-mix(in srgb, currentColor 16%, transparent) + ); + --ui-tree-indent-guide-active: var( + --vscode-tree-indentGuidesStroke, + color-mix(in srgb, currentColor 40%, transparent) + ); + /* Menus */ --ui-menu-background: var(--vscode-menu-background); --ui-menu-foreground: var(--vscode-menu-foreground); diff --git a/test/webview/ui/tree.test.tsx b/test/webview/ui/tree.test.tsx new file mode 100644 index 000000000..79afa7a35 --- /dev/null +++ b/test/webview/ui/tree.test.tsx @@ -0,0 +1,958 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createRef, Fragment, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tree, TreeGroup, TreeItem } from "@repo/ui"; + +function ControlledTree({ + onSelectedItemChange = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelectedItemChange?: (itemId: string) => void; + onExpandedChange?: (expanded: boolean) => void; +}): React.JSX.Element { + const [selectedItemId, setSelectedItemId] = useState("child"); + const [expanded, setExpanded] = useState(true); + + return ( + { + onSelectedItemChange(itemId); + setSelectedItemId(itemId); + }} + > + { + onExpandedChange(nextExpanded); + setExpanded(nextExpanded); + }} + > + Parent + + + Child + + + Disabled + + + + + Last + + + ); +} + +function NavTree({ + onSelect = vi.fn(), + onExpandedChange = vi.fn(), +}: { + onSelect?: (itemId: string) => void; + onExpandedChange?: (itemId: string, expanded: boolean) => void; +}): React.JSX.Element { + const [expandedIds, setExpandedIds] = useState(() => new Set(["alpha"])); + const branch = ( + itemId: string, + ): Pick< + React.ComponentProps, + "expanded" | "onExpandedChange" + > => ({ + expanded: expandedIds.has(itemId), + onExpandedChange: (nextExpanded: boolean) => { + onExpandedChange(itemId, nextExpanded); + setExpandedIds((current) => { + const next = new Set(current); + if (nextExpanded) { + next.add(itemId); + } else { + next.delete(itemId); + } + return next; + }); + }, + }); + + return ( + + + Alpha + + + Disabled + + + Apricot + + + Amber + + + + + Beta + + + Blue + + + + + Bravo + + + + ); +} + +const revealTree = (expanded: boolean): React.JSX.Element => ( + + + Top + + + Parent + + + Child + + + + +); + +describe("Tree", () => { + it("forwards tree semantics, className, style, and ref", () => { + const ref = createRef(); + render( + + + File + + , + ); + + const tree = screen.getByRole("tree", { name: "Explorer" }); + expect(tree).toHaveClass("ui-tree", "ui-tree--explorer", "custom-tree"); + expect(tree).toHaveStyle({ width: "240px" }); + expect(ref.current).toBe(tree); + }); + + it("exposes levels, selection, disabled state, groups, and branch expansion", () => { + render(); + + const parent = screen.getByRole("treeitem", { name: "Parent" }); + const child = screen.getByRole("treeitem", { name: "Child" }); + const disabled = screen.getByRole("treeitem", { name: "Disabled" }); + expect(parent).toHaveAttribute("aria-level", "1"); + expect(parent).toHaveAttribute("aria-expanded", "true"); + expect(parent).toHaveAttribute("aria-selected", "false"); + expect(child).toHaveAttribute("aria-level", "2"); + expect(child).toHaveAttribute("aria-selected", "true"); + expect(child).not.toHaveAttribute("aria-expanded"); + expect(disabled).toHaveAttribute("aria-disabled", "true"); + const group = screen.getByRole("group"); + expect(group).not.toHaveAttribute("hidden"); + expect(parent).toContainElement(group); + expect(group.closest('[role="treeitem"]')).toBe(parent); + expect(child.querySelector(".ui-tree-item__indent-slot")).toHaveClass( + "ui-tree-item__indent-slot--active", + ); + }); + + it("keeps exactly one visible enabled item in the tab order", () => { + render(); + const items = screen.getAllByRole("treeitem"); + expect(items.filter((item) => item.tabIndex === 0)).toHaveLength(1); + expect(items.find((item) => item.tabIndex === 0)).not.toHaveAttribute( + "aria-disabled", + "true", + ); + expect(screen.getByRole("treeitem", { name: "Child" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(screen.getByRole("treeitem", { name: "Disabled" })).toHaveAttribute( + "tabindex", + "-1", + ); + }); + + it("hands the tab stop to a selection revealed by expansion", () => { + const { rerender } = render(revealTree(false)); + expect(screen.getByRole("treeitem", { name: "Top" })).toHaveAttribute( + "tabindex", + "0", + ); + + rerender(revealTree(true)); + expect(screen.getByRole("treeitem", { name: "Child" })).toHaveAttribute( + "tabindex", + "0", + ); + }); + + it("keeps the tab stop with the user once they focus another row", () => { + const { rerender } = render(revealTree(false)); + act(() => screen.getByRole("treeitem", { name: "Parent" }).focus()); + + rerender(revealTree(true)); + expect(screen.getByRole("treeitem", { name: "Parent" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(screen.getByRole("treeitem", { name: "Child" })).toHaveAttribute( + "tabindex", + "-1", + ); + }); + + it("moves the tab stop to an ancestor when its row unmounts", async () => { + const renderTree = (showLeaf: boolean): React.JSX.Element => ( + + + Top + + + Parent + + {showLeaf ? ( + + Leaf + + ) : null} + + + + ); + const { rerender } = render(renderTree(true)); + act(() => screen.getByRole("treeitem", { name: "Leaf" }).focus()); + + rerender(renderTree(false)); + await act(() => Promise.resolve()); + expect(screen.getByRole("treeitem", { name: "Parent" })).toHaveAttribute( + "tabindex", + "0", + ); + }); + + it("leaves keys to interactive elements rendered outside rows", () => { + render( + + + + Alpha + + , + ); + + const input = screen.getByRole("textbox", { name: "New file" }); + act(() => input.focus()); + const arrowNotPrevented = fireEvent.keyDown(input, { key: "ArrowDown" }); + const typeAheadNotPrevented = fireEvent.keyDown(input, { key: "a" }); + + expect(document.activeElement).toBe(input); + expect(arrowNotPrevented).toBe(true); + expect(typeAheadNotPrevented).toBe(true); + }); + + it("derives indent guide owners from focus and controlled selection", () => { + const renderTree = (selectedItemId?: string): React.JSX.Element => ( + + + Alpha + + + Alpha leaf + + + + + Beta + + + Beta leaf + + + + + ); + const { rerender } = render(renderTree()); + const alphaLeaf = screen.getByRole("treeitem", { name: "Alpha leaf" }); + const betaLeaf = screen.getByRole("treeitem", { name: "Beta leaf" }); + const alphaGuide = alphaLeaf.querySelector(".ui-tree-item__indent-slot"); + const betaGuide = betaLeaf.querySelector(".ui-tree-item__indent-slot"); + + expect(screen.getByRole("treeitem", { name: "Alpha" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(alphaGuide).not.toHaveClass("ui-tree-item__indent-slot--active"); + expect(betaGuide).not.toHaveClass("ui-tree-item__indent-slot--active"); + + act(() => betaLeaf.focus()); + expect(betaGuide).toHaveClass("ui-tree-item__indent-slot--active"); + + rerender(renderTree("alpha-leaf")); + expect(alphaLeaf).toHaveAttribute("tabindex", "0"); + expect(betaLeaf).toHaveAttribute("tabindex", "-1"); + expect(alphaGuide).toHaveClass("ui-tree-item__indent-slot--active"); + expect(betaGuide).toHaveClass("ui-tree-item__indent-slot--active"); + }); + + it("uses only the expanded focused branch as its indent guide owner", () => { + render( + + + Root + + + Branch + + + Leaf + + + + + + , + ); + + act(() => screen.getByRole("treeitem", { name: "Branch" }).focus()); + const guideSlots = screen + .getByRole("treeitem", { name: "Leaf" }) + .querySelectorAll(".ui-tree-item__indent-slot"); + expect(guideSlots).toHaveLength(2); + expect(guideSlots[0]).not.toHaveClass("ui-tree-item__indent-slot--active"); + expect(guideSlots[1]).toHaveClass("ui-tree-item__indent-slot--active"); + }); + + it("clears hidden, disabled, and unmounted focused guide owners", async () => { + const renderTree = ({ + expanded = true, + disabled = false, + showChild = true, + }: { + expanded?: boolean; + disabled?: boolean; + showChild?: boolean; + }): React.JSX.Element => ( + + + Parent + + {showChild && ( + + Child + + )} + + + + ); + const { rerender } = render(renderTree({})); + const getChildGuide = (): Element | null => + screen + .getByRole("treeitem", { name: "Child", hidden: true }) + .querySelector(".ui-tree-item__indent-slot"); + + act(() => screen.getByRole("treeitem", { name: "Child" }).focus()); + expect(getChildGuide()).toHaveClass("ui-tree-item__indent-slot--active"); + rerender(renderTree({ expanded: false })); + rerender(renderTree({ expanded: true })); + expect(getChildGuide()).not.toHaveClass( + "ui-tree-item__indent-slot--active", + ); + + act(() => screen.getByRole("treeitem", { name: "Child" }).focus()); + rerender(renderTree({ disabled: true })); + rerender(renderTree({})); + expect(getChildGuide()).not.toHaveClass( + "ui-tree-item__indent-slot--active", + ); + + act(() => screen.getByRole("treeitem", { name: "Child" }).focus()); + rerender(renderTree({ showChild: false })); + await act(() => Promise.resolve()); + rerender(renderTree({})); + expect(getChildGuide()).not.toHaveClass( + "ui-tree-item__indent-slot--active", + ); + }); + + it("updates controlled selection before the rerender is observable", () => { + const { rerender } = render( + + + + , + ); + + rerender( + + + + , + ); + + expect(screen.getByRole("treeitem", { name: "First" })).toHaveAttribute( + "aria-selected", + "false", + ); + expect(screen.getByRole("treeitem", { name: "Second" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("uses focus from this tree only for active selection colors", () => { + render( + <> + + + First item + + + + + Second item + + + , + ); + + const firstTree = screen.getByRole("tree", { name: "First" }); + const secondTree = screen.getByRole("tree", { name: "Second" }); + fireEvent.focus(screen.getByRole("treeitem", { name: "First item" })); + expect(firstTree).toHaveClass("ui-tree--focused"); + expect(secondTree).not.toHaveClass("ui-tree--focused"); + + fireEvent.blur(screen.getByRole("treeitem", { name: "First item" }), { + relatedTarget: screen.getByRole("treeitem", { name: "Second item" }), + }); + fireEvent.focus(screen.getByRole("treeitem", { name: "Second item" })); + expect(firstTree).not.toHaveClass("ui-tree--focused"); + expect(secondTree).toHaveClass("ui-tree--focused"); + }); +}); + +describe("TreeItem", () => { + it("uses textValue as the accessible-name fallback", () => { + render( + + + , + ); + + expect( + screen.getByRole("treeitem", { name: "Empty item" }), + ).toBeInTheDocument(); + }); + + it("supports consumer-provided accessible names", () => { + render( + + Custom labelled item + + + , + ); + + expect( + screen.getByRole("treeitem", { name: "Custom labelled item" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("treeitem", { name: "Custom label" }), + ).toBeInTheDocument(); + }); + + it("reports controlled selection and expansion from a row click", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("treeitem", { name: "Parent" })); + expect(onSelectedItemChange).toHaveBeenCalledWith("parent"); + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(screen.getByTestId("children")).toHaveAttribute("hidden"); + }); + + it("toggles a branch from its twistie without changing selection", () => { + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + const onClick = vi.fn(); + render( + + + Branch + + + Child + + + + , + ); + + const chevron = screen + .getByRole("treeitem", { name: "Branch" }) + .querySelector(".ui-tree-item__chevron"); + expect(chevron).not.toBeNull(); + if (!chevron) { + throw new Error("Expected a branch twistie."); + } + fireEvent.click(chevron); + + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(onClick).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("recursively finds a TreeGroup inside arrays and fragments", () => { + render( + + + {[ + Branch, + + {[ + + + Child + + , + ]} + , + ]} + + , + ); + + expect(screen.getByRole("treeitem", { name: "Branch" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getByRole("treeitem", { name: "Child" })).toHaveAttribute( + "aria-level", + "2", + ); + }); + + it("rejects more than one TreeGroup after recursive classification", () => { + expect(() => + render( + + + Branch + + {[]} + + , + ), + ).toThrow("TreeItem accepts at most one TreeGroup."); + }); + + it("rejects a TreeGroup hidden from detection by a wrapper component", () => { + const WrappedGroup = (): React.JSX.Element => ( + + + + ); + expect(() => + render( + + + Branch + + + , + ), + ).toThrow(/direct child of TreeItem/); + }); + + it("isolates a trailing action from tree selection and expansion", () => { + const onAction = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + Delete + + } + > + Branch + + + Child + + + + , + ); + + const treeItem = screen.getByRole("treeitem", { name: "Branch" }); + expect(treeItem).toHaveAccessibleName("Branch"); + expect(treeItem.querySelector(".ui-tree-item__action")).toContainElement( + screen.getByRole("button", { name: "Delete" }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(onAction).toHaveBeenCalledOnce(); + expect(onSelectedItemChange).not.toHaveBeenCalled(); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("keeps parent row handlers isolated from descendant treeitems", () => { + const onParentClick = vi.fn(); + const onParentFocus = vi.fn(); + const onChildClick = vi.fn(); + const onChildFocus = vi.fn(); + const onSelectedItemChange = vi.fn(); + const onExpandedChange = vi.fn(); + render( + + + Parent + + + Child + + + + , + ); + + const child = screen.getByRole("treeitem", { name: "Child" }); + const childContent = child.querySelector(".ui-tree-item__content"); + expect(childContent).not.toBeNull(); + if (!childContent) { + throw new Error("Expected child row content."); + } + fireEvent.click(childContent); + expect(onChildClick).toHaveBeenCalledOnce(); + expect(onParentClick).not.toHaveBeenCalled(); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + + fireEvent.focus(child); + expect(onChildFocus).toHaveBeenCalledOnce(); + expect(onParentFocus).not.toHaveBeenCalled(); + + onSelectedItemChange.mockClear(); + fireEvent.keyDown(child, { key: "Enter" }); + expect(onSelectedItemChange).toHaveBeenCalledWith("child"); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("does not activate a disabled row that receives programmatic focus", () => { + const onSelectedItemChange = vi.fn(); + render(); + const disabled = screen.getByRole("treeitem", { name: "Disabled" }); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "Enter" }); + fireEvent.keyDown(disabled, { key: " " }); + + expect(onSelectedItemChange).not.toHaveBeenCalled(); + }); + + it("marks selected rows so their actions remain visible", () => { + render( + + Selected action} + > + Selected + + Plain action} + > + Plain + + , + ); + + const selectedAction = screen.getByRole("button", { + name: "Selected action", + }).parentElement; + const plainAction = screen.getByRole("button", { + name: "Plain action", + }).parentElement; + expect(selectedAction).toHaveClass("ui-tree-item__action"); + expect(plainAction).toHaveClass("ui-tree-item__action"); + const selectedItem = screen.getByRole("treeitem", { name: "Selected" }); + const plainItem = screen.getByRole("treeitem", { name: "Plain" }); + expect(selectedItem).toHaveClass("ui-tree-item"); + expect(selectedItem).toHaveAttribute("aria-selected", "true"); + expect(selectedItem.firstElementChild).toHaveClass("ui-tree-item__row"); + expect(plainItem).toHaveAttribute("aria-selected", "false"); + }); + + it("forwards root className, style, and ref", () => { + const ref = createRef(); + render( + + + File + + , + ); + + const item = screen.getByRole("treeitem", { name: "File" }); + expect(item).toHaveClass("ui-tree-item", "custom-item"); + expect(item.firstElementChild).toHaveClass("ui-tree-item__row"); + expect(item.style.color).toBe("red"); + expect(ref.current).toBe(item); + }); +}); + +describe("TreeGroup", () => { + it("forwards className, style, and ref", () => { + const ref = createRef(); + render( + + + Branch + + + Child + + + + , + ); + + const group = screen.getByRole("group"); + expect(group).toHaveClass("ui-tree-group", "custom-group"); + expect(group.style.color).toBe("blue"); + expect(ref.current).toBe(group); + }); +}); + +describe("Tree keyboard navigation", () => { + const navItem = (name: string): HTMLElement => + screen.getByRole("treeitem", { name }); + + it("moves through visible enabled items with arrows, Home, and End", () => { + render(); + fireEvent.keyDown(navItem("Alpha"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(navItem("Apricot")); + fireEvent.keyDown(navItem("Apricot"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(navItem("Amber")); + fireEvent.keyDown(navItem("Amber"), { key: "End" }); + expect(document.activeElement).toBe(navItem("Bravo")); + fireEvent.keyDown(navItem("Bravo"), { key: "Home" }); + expect(document.activeElement).toBe(navItem("Alpha")); + fireEvent.keyDown(navItem("Alpha"), { key: "ArrowUp" }); + expect(document.activeElement).toBe(navItem("Alpha")); + }); + + it("expands a branch, enters it, collapses, and returns to the parent", () => { + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(navItem("Beta"), { key: "ArrowRight" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(navItem("Beta"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(navItem("Blue")); + fireEvent.keyDown(navItem("Blue"), { key: "ArrowLeft" }); + expect(document.activeElement).toBe(navItem("Beta")); + fireEvent.keyDown(navItem("Beta"), { key: "ArrowLeft" }); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("selects and toggles a branch with Enter and Space", () => { + const onSelect = vi.fn(); + const onExpandedChange = vi.fn(); + render(); + fireEvent.keyDown(navItem("Beta"), { key: "Enter" }); + expect(onSelect).toHaveBeenLastCalledWith("beta"); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", true); + fireEvent.keyDown(navItem("Beta"), { key: " " }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith("beta", false); + }); + + it("steps from a focused disabled row to its enabled neighbors", () => { + render(); + const disabled = navItem("Disabled"); + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowDown" }); + expect(document.activeElement).toBe(navItem("Apricot")); + + act(() => disabled.focus()); + fireEvent.keyDown(disabled, { key: "ArrowUp" }); + expect(document.activeElement).toBe(navItem("Alpha")); + }); + + it("ignores keys from interactive content nested in a row", () => { + const onSelect = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Action" }), { + key: "Enter", + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("follows DOM order after rows reorder without item updates", () => { + const renderPair = (reversed: boolean): React.JSX.Element => { + const rows = [ + + One + , + + Two + , + ]; + return ( + + {reversed ? [...rows].reverse() : rows} + + ); + }; + const { rerender } = render(renderPair(false)); + fireEvent.keyDown(navItem("One"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(navItem("Two")); + + rerender(renderPair(true)); + fireEvent.keyDown(navItem("Two"), { key: "ArrowDown" }); + expect(document.activeElement).toBe(navItem("One")); + }); + + it("rejects duplicate item ids across rows", () => { + expect(() => + render( + + + + , + ), + ).toThrow(/already registered by another row/i); + }); + + it("finds renamed items by type-ahead without re-registering", () => { + const renderNames = (label: string): React.JSX.Element => ( + + + Alpha + + + {label} + + + ); + const { rerender } = render(renderNames("Amber")); + rerender(renderNames("Cedar")); + fireEvent.keyDown(navItem("Alpha"), { key: "c" }); + expect(document.activeElement).toBe(navItem("Cedar")); + }); + + describe("type-ahead", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("matches case-insensitively, wraps, and cycles repeated characters", () => { + render(); + fireEvent.keyDown(navItem("Amber"), { key: "B" }); + expect(document.activeElement).toBe(navItem("Beta")); + fireEvent.keyDown(navItem("Beta"), { key: "b" }); + expect(document.activeElement).toBe(navItem("Bravo")); + fireEvent.keyDown(navItem("Bravo"), { key: "b" }); + expect(document.activeElement).toBe(navItem("Beta")); + }); + + it("buffers characters and clears the buffer after the timeout", () => { + render(); + fireEvent.keyDown(navItem("Alpha"), { key: "a" }); + fireEvent.keyDown(navItem("Apricot"), { key: "m" }); + expect(document.activeElement).toBe(navItem("Amber")); + + act(() => { + vi.advanceTimersByTime(500); + }); + fireEvent.keyDown(navItem("Amber"), { key: "a" }); + expect(document.activeElement).toBe(navItem("Alpha")); + }); + }); +});