fbredius/storybook

View on GitHub
lib/ui/src/components/sidebar/Tree.tsx

Summary

Maintainability
C
1 day
Test Coverage
import type { Group, Story, StoriesHash } from '@storybook/api';
import { isRoot, isStory } from '@storybook/api';
import { styled } from '@storybook/theming';
import { Button, Icons } from '@storybook/components';
import { transparentize } from 'polished';
import React, { MutableRefObject, useCallback, useMemo, useRef } from 'react';

import {
  ComponentNode,
  DocumentNode,
  GroupNode,
  RootNode,
  StoryNode,
  CollapseIcon,
} from './TreeNode';
import { useExpanded, ExpandAction, ExpandedState } from './useExpanded';
import { Highlight, Item } from './types';
import { createId, getAncestorIds, getDescendantIds, getLink } from './utils';

export const Action = styled.button(({ theme }) => ({
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'center',
  width: 20,
  height: 20,
  margin: 0,
  marginLeft: 'auto',
  padding: 0,
  outline: 0,
  lineHeight: 'normal',
  background: 'none',
  border: `1px solid transparent`,
  borderRadius: '100%',
  cursor: 'pointer',
  transition: 'all 150ms ease-out',
  color:
    theme.base === 'light'
      ? transparentize(0.3, theme.color.defaultText)
      : transparentize(0.6, theme.color.defaultText),

  '&:hover': {
    color: theme.color.secondary,
  },
  '&:focus': {
    color: theme.color.secondary,
    borderColor: theme.color.secondary,

    '&:not(:focus-visible)': {
      borderColor: 'transparent',
    },
  },

  svg: {
    width: 10,
    height: 10,
  },
}));

const CollapseButton = styled.button(({ theme }) => ({
  // Reset button
  background: 'transparent',
  border: 'none',
  outline: 'none',
  boxSizing: 'content-box',
  cursor: 'pointer',
  position: 'relative',
  textAlign: 'left',
  lineHeight: 'normal',
  font: 'inherit',
  color: 'inherit',
  letterSpacing: 'inherit',
  textTransform: 'inherit',

  display: 'flex',
  flex: '0 1 auto',
  padding: '3px 10px 1px 1px',
  margin: 0,
  marginLeft: -19,
  overflow: 'hidden',
  borderRadius: 26,
  transition: 'color 150ms, box-shadow 150ms',

  'span:first-of-type': {
    marginTop: 4,
    marginRight: 7,
  },

  '&:focus': {
    boxShadow: `0 0 0 1px ${theme.color.secondary}`,
    color: theme.color.secondary,
    'span:first-of-type': {
      color: theme.color.secondary,
    },

    '&:not(:focus-visible)': {
      boxShadow: 'none',
    },
  },
}));

const LeafNodeStyleWrapper = styled.div(({ theme }) => ({
  position: 'relative',
}));

const SkipToContentLink = styled(Button)(({ theme }) => ({
  display: 'none',
  '@media (min-width: 600px)': {
    display: 'block',
    zIndex: -1,
    position: 'absolute',
    top: 1,
    right: 20,
    height: '20px',
    fontSize: '10px',
    padding: '5px 10px',
    '&:focus': {
      background: 'white',
      zIndex: 1,
    },
  },
}));

interface NodeProps {
  item: Item;
  refId: string;
  isOrphan: boolean;
  isDisplayed: boolean;
  isSelected: boolean;
  isFullyExpanded?: boolean;
  isExpanded: boolean;
  setExpanded: (action: ExpandAction) => void;
  setFullyExpanded?: () => void;
  onSelectStoryId: (itemId: string) => void;
}

const Node = React.memo<NodeProps>(
  ({
    item,
    refId,
    isOrphan,
    isDisplayed,
    isSelected,
    isFullyExpanded,
    setFullyExpanded,
    isExpanded,
    setExpanded,
    onSelectStoryId,
  }) => {
    if (!isDisplayed) return null;

    const id = createId(item.id, refId);
    if (isStory(item)) {
      const LeafNode = item.isComponent ? DocumentNode : StoryNode;
      return (
        <LeafNodeStyleWrapper>
          <LeafNode
            key={id}
            id={id}
            className="sidebar-item"
            data-ref-id={refId}
            data-item-id={item.id}
            data-parent-id={item.parent}
            data-nodetype={item.isComponent ? 'document' : 'story'}
            data-selected={isSelected}
            data-highlightable={isDisplayed}
            depth={isOrphan ? item.depth : item.depth - 1}
            href={getLink(item.id, refId)}
            onClick={(event) => {
              event.preventDefault();
              onSelectStoryId(item.id);
            }}
          >
            {item.renderLabel?.(item) || item.name}
          </LeafNode>
          {isSelected && (
            <SkipToContentLink secondary outline isLink href="#storybook-preview-wrapper">
              Skip to canvas
            </SkipToContentLink>
          )}
        </LeafNodeStyleWrapper>
      );
    }

    if (isRoot(item)) {
      return (
        <RootNode
          key={id}
          id={id}
          className="sidebar-subheading"
          data-ref-id={refId}
          data-item-id={item.id}
          data-nodetype="root"
          aria-expanded={isExpanded}
        >
          <CollapseButton
            type="button"
            data-action="collapse-root"
            onClick={(event) => {
              event.preventDefault();
              setExpanded({ ids: [item.id], value: !isExpanded });
            }}
          >
            <CollapseIcon isExpanded={isExpanded} />
            {item.renderLabel?.(item) || item.name}
          </CollapseButton>
          {isExpanded && (
            <Action
              type="button"
              className="sidebar-subheading-action"
              aria-label="expand"
              data-action="expand-all"
              data-expanded={isFullyExpanded}
              onClick={(event) => {
                event.preventDefault();
                setFullyExpanded();
              }}
            >
              <Icons icon={isFullyExpanded ? 'collapse' : 'expandalt'} />
            </Action>
          )}
        </RootNode>
      );
    }

    const BranchNode = item.isComponent ? ComponentNode : GroupNode;
    return (
      <BranchNode
        key={id}
        id={id}
        className="sidebar-item"
        data-ref-id={refId}
        data-item-id={item.id}
        data-parent-id={item.parent}
        data-nodetype={item.isComponent ? 'component' : 'group'}
        data-highlightable={isDisplayed}
        aria-controls={item.children && item.children[0]}
        aria-expanded={isExpanded}
        depth={isOrphan ? item.depth : item.depth - 1}
        isComponent={item.isComponent}
        isExpandable={item.children && item.children.length > 0}
        isExpanded={isExpanded}
        onClick={(event) => {
          event.preventDefault();
          setExpanded({ ids: [item.id], value: !isExpanded });
          if (item.isComponent && !isExpanded) onSelectStoryId(item.id);
        }}
      >
        {item.renderLabel?.(item) || item.name}
      </BranchNode>
    );
  }
);

const Root = React.memo<NodeProps & { expandableDescendants: string[] }>(
  ({ setExpanded, isFullyExpanded, expandableDescendants, ...props }) => {
    const setFullyExpanded = useCallback(
      () => setExpanded({ ids: expandableDescendants, value: !isFullyExpanded }),
      [setExpanded, isFullyExpanded, expandableDescendants]
    );
    return (
      <Node
        {...props}
        setExpanded={setExpanded}
        isFullyExpanded={isFullyExpanded}
        setFullyExpanded={setFullyExpanded}
      />
    );
  }
);

const Container = styled.div<{ hasOrphans: boolean }>((props) => ({
  marginTop: props.hasOrphans ? 20 : 0,
  marginBottom: 20,
}));

export const Tree = React.memo<{
  isBrowsing: boolean;
  isMain: boolean;
  refId: string;
  data: StoriesHash;
  highlightedRef: MutableRefObject<Highlight>;
  setHighlightedItemId: (itemId: string) => void;
  selectedStoryId: string | null;
  onSelectStoryId: (storyId: string) => void;
}>(
  ({
    isBrowsing,
    isMain,
    refId,
    data,
    highlightedRef,
    setHighlightedItemId,
    selectedStoryId,
    onSelectStoryId,
  }) => {
    const containerRef = useRef<HTMLDivElement>(null);

    // Find top-level nodes and group them so we can hoist any orphans and expand any roots.
    const [rootIds, orphanIds, initialExpanded] = useMemo(
      () =>
        Object.keys(data).reduce<[string[], string[], ExpandedState]>(
          (acc, id) => {
            const item = data[id];
            if (isRoot(item)) acc[0].push(id);
            else if (!item.parent) acc[1].push(id);
            if (isRoot(item) && item.startCollapsed) acc[2][id] = false;
            return acc;
          },
          [[], [], {}]
        ),
      [data]
    );

    // Pull up (hoist) any "orphan" items that don't have a root item as ancestor so they get
    // displayed at the top of the tree, before any root items.
    // Also create a map of expandable descendants for each root/orphan item, which is needed later.
    // Doing that here is a performance enhancement, as it avoids traversing the tree again later.
    const { orphansFirst, expandableDescendants } = useMemo(() => {
      return orphanIds.concat(rootIds).reduce(
        (acc, nodeId) => {
          const descendantIds = getDescendantIds(data, nodeId, false);
          acc.orphansFirst.push(nodeId, ...descendantIds);
          acc.expandableDescendants[nodeId] = descendantIds.filter((d) => !data[d].isLeaf);
          return acc;
        },
        { orphansFirst: [] as string[], expandableDescendants: {} as Record<string, string[]> }
      );
    }, [data, rootIds, orphanIds]);

    // Create a list of component IDs which have exactly one story, which name exactly matches the component name.
    const singleStoryComponentIds = useMemo(() => {
      return orphansFirst.filter((nodeId) => {
        const { children = [], isComponent, isLeaf, name } = data[nodeId];
        return (
          !isLeaf &&
          isComponent &&
          children.length === 1 &&
          isStory(data[children[0]]) &&
          data[children[0]].name === name
        );
      });
    }, [data, orphansFirst]);

    // Omit single-story components from the list of nodes.
    const collapsedItems = useMemo(() => {
      return orphansFirst.filter((id) => !singleStoryComponentIds.includes(id));
    }, [orphanIds, orphansFirst, singleStoryComponentIds]);

    // Rewrite the dataset to place the child story in place of the component.
    const collapsedData = useMemo(() => {
      return singleStoryComponentIds.reduce(
        (acc, id) => {
          const { children, parent } = data[id] as Group;
          const [childId] = children;
          if (parent) {
            const siblings = [...data[parent].children];
            siblings[siblings.indexOf(id)] = childId;
            acc[parent] = { ...data[parent], children: siblings };
          }
          acc[childId] = { ...data[childId], parent, depth: data[childId].depth - 1 } as Story;
          return acc;
        },
        { ...data }
      );
    }, [data]);

    const ancestry = useMemo(() => {
      return collapsedItems.reduce(
        (acc, id) => Object.assign(acc, { [id]: getAncestorIds(collapsedData, id) }),
        {} as { [key: string]: string[] }
      );
    }, [collapsedItems, collapsedData]);

    // Track expanded nodes, keep it in sync with props and enable keyboard shortcuts.
    const [expanded, setExpanded] = useExpanded({
      containerRef,
      isBrowsing, // only enable keyboard shortcuts when tree is visible
      refId,
      data: collapsedData,
      initialExpanded,
      rootIds,
      highlightedRef,
      setHighlightedItemId,
      selectedStoryId,
      onSelectStoryId,
    });

    return (
      <Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
        {collapsedItems.map((itemId) => {
          const item = collapsedData[itemId];
          const id = createId(itemId, refId);

          if (isRoot(item)) {
            const descendants = expandableDescendants[item.id];
            const isFullyExpanded = descendants.every((d: string) => expanded[d]);
            return (
              <Root
                key={id}
                item={item}
                refId={refId}
                isOrphan={false}
                isDisplayed
                isSelected={selectedStoryId === itemId}
                isExpanded={!!expanded[itemId]}
                setExpanded={setExpanded}
                isFullyExpanded={isFullyExpanded}
                expandableDescendants={descendants}
                onSelectStoryId={onSelectStoryId}
              />
            );
          }

          const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]);
          return (
            <Node
              key={id}
              item={item}
              refId={refId}
              isOrphan={orphanIds.some((oid) => itemId === oid || itemId.startsWith(`${oid}-`))}
              isDisplayed={isDisplayed}
              isSelected={selectedStoryId === itemId}
              isExpanded={!!expanded[itemId]}
              setExpanded={setExpanded}
              onSelectStoryId={onSelectStoryId}
            />
          );
        })}
      </Container>
    );
  }
);