fbredius/storybook

View on GitHub
lib/ui/src/components/sidebar/useExpanded.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import type { StoriesHash } from '@storybook/api';
import { useStorybookApi } from '@storybook/api';
import { STORIES_COLLAPSE_ALL, STORIES_EXPAND_ALL } from '@storybook/core-events';
import global from 'global';
import throttle from 'lodash/throttle';
import React, { Dispatch, MutableRefObject, useCallback, useEffect, useReducer } from 'react';
import { matchesKeyCode, matchesModifiers } from '../../keybinding';
import { Highlight } from './types';

import { isAncestor, getAncestorIds, getDescendantIds, scrollIntoView } from './utils';

const { document } = global;

export type ExpandedState = Record<string, boolean>;

export interface ExpandAction {
  ids: string[];
  value: boolean;
}

export interface ExpandedProps {
  containerRef: MutableRefObject<HTMLElement>;
  isBrowsing: boolean;
  refId: string;
  data: StoriesHash;
  initialExpanded?: ExpandedState;
  rootIds: string[];
  highlightedRef: MutableRefObject<Highlight>;
  setHighlightedItemId: (storyId: string) => void;
  selectedStoryId: string | null;
  onSelectStoryId: (storyId: string) => void;
}

const initializeExpanded = ({
  refId,
  data,
  initialExpanded,
  highlightedRef,
  rootIds,
}: {
  refId: string;
  data: StoriesHash;
  initialExpanded?: ExpandedState;
  highlightedRef: MutableRefObject<Highlight>;
  rootIds: string[];
}) => {
  const highlightedAncestors =
    highlightedRef.current?.refId === refId
      ? getAncestorIds(data, highlightedRef.current?.itemId)
      : [];
  return [...rootIds, ...highlightedAncestors].reduce<ExpandedState>(
    (acc, id) => Object.assign(acc, { [id]: id in initialExpanded ? initialExpanded[id] : true }),
    {}
  );
};

const noop = () => {};

export const useExpanded = ({
  containerRef,
  isBrowsing,
  refId,
  data,
  initialExpanded,
  rootIds,
  highlightedRef,
  setHighlightedItemId,
  selectedStoryId,
  onSelectStoryId,
}: ExpandedProps): [ExpandedState, Dispatch<ExpandAction>] => {
  const api = useStorybookApi();

  // Track the set of currently expanded nodes within this tree.
  // Root nodes are expanded by default.
  const [expanded, setExpanded] = useReducer<
    React.Reducer<ExpandedState, ExpandAction>,
    {
      refId: string;
      data: StoriesHash;
      highlightedRef: MutableRefObject<Highlight>;
      rootIds: string[];
      initialExpanded: ExpandedState;
    }
  >(
    (state, { ids, value }) =>
      ids.reduce((acc, id) => Object.assign(acc, { [id]: value }), { ...state }),
    { refId, data, highlightedRef, rootIds, initialExpanded },
    initializeExpanded
  );

  const getElementByDataItemId = useCallback(
    (id: string) => containerRef.current?.querySelector(`[data-item-id="${id}"]`),
    [containerRef]
  );

  const highlightElement = useCallback(
    (element: Element) => {
      setHighlightedItemId(element.getAttribute('data-item-id'));
      scrollIntoView(element);
    },
    [setHighlightedItemId]
  );

  const updateExpanded = useCallback(
    ({ ids, value }) => {
      setExpanded({ ids, value });
      if (ids.length === 1) {
        const element = containerRef.current?.querySelector(
          `[data-item-id="${ids[0]}"][data-ref-id="${refId}"]`
        );
        if (element) highlightElement(element);
      }
    },
    [containerRef, highlightElement, refId]
  );

  // Expand the whole ancestry of the currently selected story whenever it changes.
  useEffect(() => {
    setExpanded({ ids: getAncestorIds(data, selectedStoryId), value: true });
  }, [data, selectedStoryId]);

  const collapseAll = useCallback(() => {
    const ids = Object.keys(data).filter((id) => !rootIds.includes(id));
    setExpanded({ ids, value: false });
  }, [data, rootIds]);

  const expandAll = useCallback(() => {
    setExpanded({ ids: Object.keys(data), value: true });
  }, [data]);

  useEffect(() => {
    if (!api) return noop;

    api.on(STORIES_COLLAPSE_ALL, collapseAll);
    api.on(STORIES_EXPAND_ALL, expandAll);

    return () => {
      api.off(STORIES_COLLAPSE_ALL, collapseAll);
      api.off(STORIES_EXPAND_ALL, expandAll);
    };
  }, [api, collapseAll, expandAll]);

  // Expand, collapse or select nodes in the tree using keyboard shortcuts.
  useEffect(() => {
    const menuElement = document.getElementById('storybook-explorer-menu');

    // Even though we ignore repeated events, use throttle because IE doesn't support event.repeat.
    const navigateTree = throttle((event: KeyboardEvent) => {
      const highlightedItemId =
        highlightedRef.current?.refId === refId && highlightedRef.current?.itemId;
      if (!isBrowsing || !containerRef.current || !highlightedItemId || event.repeat) return;
      if (!matchesModifiers(false, event)) return;

      const isEnter = matchesKeyCode('Enter', event);
      const isSpace = matchesKeyCode('Space', event);
      const isArrowLeft = matchesKeyCode('ArrowLeft', event);
      const isArrowRight = matchesKeyCode('ArrowRight', event);
      if (!(isEnter || isSpace || isArrowLeft || isArrowRight)) return;

      const highlightedElement = getElementByDataItemId(highlightedItemId);
      if (!highlightedElement || highlightedElement.getAttribute('data-ref-id') !== refId) return;

      const target = event.target as Element;
      if (!isAncestor(menuElement, target) && !isAncestor(target, menuElement)) return;
      if (target.hasAttribute('data-action')) {
        if (isEnter || isSpace) return;
        (target as HTMLButtonElement).blur();
      }

      const type = highlightedElement.getAttribute('data-nodetype');
      if ((isEnter || isSpace) && ['component', 'story', 'document'].includes(type)) {
        onSelectStoryId(highlightedItemId);
      }

      const isExpanded = highlightedElement.getAttribute('aria-expanded');

      if (isArrowLeft) {
        if (isExpanded === 'true') {
          // The highlighted node is expanded, so we collapse it.
          setExpanded({ ids: [highlightedItemId], value: false });
          return;
        }

        const parentId = highlightedElement.getAttribute('data-parent-id');
        const parentElement = parentId && getElementByDataItemId(parentId);
        if (parentElement && parentElement.getAttribute('data-highlightable') === 'true') {
          // The highlighted node isn't expanded, so we move the highlight to its parent instead.
          highlightElement(parentElement);
          return;
        }

        // The parent can't be highlighted, which means it must be a root.
        // The highlighted node is already collapsed, so we collapse its descendants.
        setExpanded({ ids: getDescendantIds(data, highlightedItemId, true), value: false });
        return;
      }

      if (isArrowRight) {
        if (isExpanded === 'false') {
          updateExpanded({ ids: [highlightedItemId], value: true });
        } else if (isExpanded === 'true') {
          updateExpanded({ ids: getDescendantIds(data, highlightedItemId, true), value: true });
        }
      }
    }, 60);

    document.addEventListener('keydown', navigateTree);
    return () => document.removeEventListener('keydown', navigateTree);
  }, [
    containerRef,
    isBrowsing,
    refId,
    data,
    highlightedRef,
    setHighlightedItemId,
    onSelectStoryId,
  ]);

  return [expanded, updateExpanded];
};