fbredius/storybook

View on GitHub
lib/ui/src/components/layout/container.tsx

Summary

Maintainability
F
3 days
Test Coverage
import React, { Component, Fragment, FunctionComponent, CSSProperties, ReactNode } from 'react';
import { styled, withTheme, Theme } from '@storybook/theming';
import { State } from '@storybook/api';
import * as persistence from './persist';

import { Draggable, Handle, DraggableData, DraggableEvent } from './draggers';

const MIN_NAV_WIDTH = 200; // visually there's an additional 10px due to the canvas' left margin
const MIN_CANVAS_WIDTH = 200; // visually it's 10px less due to the canvas' left margin
const MIN_CANVAS_HEIGHT = 200; // visually it's 50px less due to the canvas toolbar and top margin
const MIN_PANEL_WIDTH = 200; // visually it's 10px less due to the canvas' right margin
const MIN_PANEL_HEIGHT = 200; // visually it's 50px less due to the panel toolbar and bottom margin
const DEFAULT_NAV_WIDTH = 220;
const DEFAULT_PANEL_WIDTH = 400;

const Pane = styled.div<{
  border?: 'left' | 'right' | 'top' | 'bottom';
  animate?: boolean;
  top?: boolean;
  hidden?: boolean;
  children: ReactNode;
}>(
  {
    position: 'absolute',
    boxSizing: 'border-box',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
  },
  ({ hidden }) =>
    hidden
      ? {
          opacity: 0,
        }
      : {
          opacity: 1,
        },
  ({ top }) =>
    top
      ? {
          zIndex: 9,
        }
      : {},
  ({ border, theme }) => {
    switch (border) {
      case 'left': {
        return {
          borderLeft: `1px solid ${theme.appBorderColor}`,
        };
      }
      case 'right': {
        return {
          borderRight: `1px solid ${theme.appBorderColor}`,
        };
      }
      case 'top': {
        return {
          borderTop: `1px solid ${theme.appBorderColor}`,
        };
      }
      case 'bottom': {
        return {
          borderBottom: `1px solid ${theme.appBorderColor}`,
        };
      }
      default: {
        return {};
      }
    }
  },
  ({ animate }) =>
    animate
      ? {
          transition: ['width', 'height', 'top', 'left', 'background', 'opacity', 'transform']
            .map((p) => `${p} 0.1s ease-out`)
            .join(','),
        }
      : {}
);

const Paper = styled.div<{ isFullscreen: boolean }>(
  {
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
  },
  ({ isFullscreen, theme }) =>
    isFullscreen
      ? {
          boxShadow: 'none',
          borderRadius: 0,
        }
      : {
          borderRadius: theme.appBorderRadius,
          overflow: 'hidden',
          boxShadow: '0 1px 5px 0 rgba(0, 0, 0, 0.1)',
        }
);

export const Sidebar: FunctionComponent<{ hidden: boolean; position: CSSProperties }> = ({
  hidden = false,
  children,
  position = undefined,
  ...props
}) =>
  hidden ? null : (
    <Pane style={position} {...props}>
      {children}
    </Pane>
  );

export const Main: FunctionComponent<{ isFullscreen: boolean; position: CSSProperties }> = ({
  isFullscreen = false,
  children,
  position = undefined,
  ...props
}) => (
  <Pane style={position} top {...props} role="main">
    <Paper isFullscreen={isFullscreen}>{children}</Paper>
  </Pane>
);

export const Preview: FunctionComponent<{ hidden: boolean; position: CSSProperties }> = ({
  hidden = false,
  children,
  position = undefined,
  ...props
}) => (
  <Pane style={position} top hidden={hidden} {...props}>
    {children}
  </Pane>
);

export const Panel: FunctionComponent<{
  hidden: boolean;
  position: CSSProperties;
  align: 'bottom' | 'right';
}> = ({ hidden = false, children, position = undefined, align = 'right', ...props }) => (
  <Pane style={position} hidden={hidden} {...props} border={align === 'bottom' ? 'top' : 'left'}>
    {children}
  </Pane>
);

const HoverBlocker = styled.div({
  position: 'absolute',
  left: 0,
  top: 0,
  zIndex: 15,
  height: '100vh',
  width: '100vw',
});

export type PanelPosition = 'right' | 'bottom';
export interface Bounds {
  top: number;
  width: number;
  left: number;
  height: number;
}

export interface Coordinates {
  x: number;
  y: number;
}

const getPreviewPosition = ({
  panelPosition,
  isPanelHidden,
  isNavHidden,
  isFullscreen,
  bounds,
  resizerPanel,
  resizerNav,
  margin,
}: {
  panelPosition: PanelPosition;
  isPanelHidden: boolean;
  isNavHidden: boolean;
  isFullscreen: boolean;
  bounds: Bounds;
  resizerPanel: Coordinates;
  resizerNav: Coordinates;
  margin: number;
}): Bounds => {
  if (isFullscreen || isPanelHidden) {
    return {} as Bounds;
  }

  const navX = isNavHidden ? 0 : resizerNav.x;
  const panelX = resizerPanel.x;
  const panelY = resizerPanel.y;

  return panelPosition === 'bottom'
    ? {
        height: panelY - margin,
        left: 0,
        top: 0,
        width: bounds.width - navX - 2 * margin,
      }
    : {
        height: bounds.height - 2 * margin,
        left: 0,
        top: 0,
        width: panelX - navX - margin,
      };
};

const getMainPosition = ({
  bounds,
  resizerNav,
  isNavHidden,
  isFullscreen,
  margin,
}: {
  bounds: Bounds;
  resizerNav: Coordinates;
  isNavHidden: boolean;
  isFullscreen: boolean;
  margin: number;
}): Bounds => {
  if (isFullscreen) {
    return {} as Bounds;
  }

  const navX = isNavHidden ? 0 : resizerNav.x;

  return {
    height: bounds.height - margin * 2,
    left: navX + margin,
    top: margin,
    width: bounds.width - navX - margin * 2,
  };
};

const getPanelPosition = ({
  isPanelBottom,
  isPanelHidden,
  isNavHidden,
  bounds,
  resizerPanel,
  resizerNav,
  margin,
}: {
  isPanelBottom: boolean;
  isPanelHidden: boolean;
  isNavHidden: boolean;
  bounds: Bounds;
  resizerPanel: Coordinates;
  resizerNav: Coordinates;
  margin: number;
}): Bounds => {
  const navX = isNavHidden ? 0 : resizerNav.x;
  const panelX = resizerPanel.x;
  const panelY = resizerPanel.y;

  if (isPanelBottom && isPanelHidden) {
    return {
      height: bounds.height - panelY - margin,
      left: 0,
      top: panelY - margin,
      width: bounds.width - navX - 2 * margin,
    };
  }
  if (!isPanelBottom && isPanelHidden) {
    return {
      height: bounds.height - 2 * margin,
      left: panelX - navX - margin,
      top: 0,
      width: bounds.width - panelX - margin,
    };
  }

  return isPanelBottom
    ? {
        height: bounds.height - panelY - margin,
        left: 0,
        top: panelY - margin,
        width: bounds.width - navX - 2 * margin,
      }
    : {
        height: bounds.height - 2 * margin,
        left: panelX - navX - margin,
        top: 0,
        width: bounds.width - panelX - margin,
      };
};

export interface BasePanelRenderProps {
  viewMode?: State['viewMode'];
  animate: boolean;
  isFullscreen?: boolean;
  position: Bounds;
}

export interface LayoutRenderProps {
  mainProps: BasePanelRenderProps;
  previewProps: BasePanelRenderProps & {
    docsOnly: boolean;
    isToolshown: boolean;
  };
  navProps: BasePanelRenderProps & {
    hidden: boolean;
  };
  panelProps: BasePanelRenderProps & {
    align: PanelPosition;
    hidden: boolean;
  };
}

export interface LayoutState {
  isDragging: 'nav' | 'panel' | false;
  resizerNav: Coordinates;
  resizerPanel: Coordinates;
}
export interface LayoutProps {
  children: (data: LayoutRenderProps) => ReactNode;
  panelCount: number;
  bounds: {
    width: number;
    height: number;
    top: number;
    left: number;
  };
  options: {
    isFullscreen: boolean;
    showNav: boolean;
    showPanel: boolean;
    panelPosition: 'bottom' | 'right';
    isToolshown: boolean;
  };
  viewMode: State['viewMode'];
  docsOnly: boolean;
  theme: Theme;
}

class Layout extends Component<LayoutProps, LayoutState> {
  static defaultProps: Partial<LayoutProps> = {
    viewMode: undefined,
    docsOnly: false,
  };

  constructor(props: LayoutProps) {
    super(props);

    const { bounds, options } = props;

    const { resizerNav, resizerPanel } = persistence.get();

    this.state = {
      isDragging: false,
      resizerNav: resizerNav || { x: DEFAULT_NAV_WIDTH, y: 0 },
      resizerPanel:
        resizerPanel ||
        (options.panelPosition === 'bottom'
          ? { x: 0, y: Math.round(bounds.height * 0.6) }
          : { x: bounds.width - DEFAULT_PANEL_WIDTH, y: 0 }),
    };
  }

  static getDerivedStateFromProps(props: LayoutProps, state: LayoutState) {
    const { bounds, options } = props;
    const { resizerPanel, resizerNav } = state;

    const isNavHidden = options.isFullscreen || !options.showNav;
    const isPanelHidden = options.isFullscreen || !options.showPanel;

    const { panelPosition } = options;
    const isPanelRight = panelPosition === 'right';
    const isPanelBottom = panelPosition === 'bottom';

    const navX = resizerNav.x;
    const panelX = resizerPanel.x;
    const panelY = resizerPanel.y;

    const mutation = {} as LayoutState;

    if (!isNavHidden) {
      const minPanelWidth = !isPanelHidden && isPanelRight ? MIN_PANEL_WIDTH : 0;
      const minMainWidth = MIN_CANVAS_WIDTH + minPanelWidth;
      const maxNavX = bounds.width - minMainWidth;
      const minNavX = MIN_NAV_WIDTH; // coordinate translates directly to width here
      if (navX > maxNavX) {
        // upper bound
        mutation.resizerNav = {
          x: maxNavX,
          y: 0,
        };
      } else if (navX < minNavX || maxNavX < minNavX) {
        // lower bound, supercedes upper bound if needed
        mutation.resizerNav = {
          x: minNavX,
          y: 0,
        };
      }
    }

    if (isPanelRight && !isPanelHidden) {
      const maxPanelX = bounds.width - MIN_PANEL_WIDTH;
      const minPanelX = navX + MIN_CANVAS_WIDTH;
      if (panelX > maxPanelX || panelX === 0) {
        // upper bound or when switching orientation
        mutation.resizerPanel = {
          x: maxPanelX,
          y: 0,
        };
      } else if (panelX < minPanelX) {
        // lower bound
        mutation.resizerPanel = {
          x: minPanelX,
          y: 0,
        };
      }
    }

    if (isPanelBottom && !isPanelHidden) {
      const maxPanelY = bounds.height - MIN_PANEL_HEIGHT;
      if (panelY > maxPanelY || panelY === 0) {
        // lower bound or when switching orientation
        mutation.resizerPanel = {
          x: 0,
          y: bounds.height - 200,
        };
      }
      // upper bound is enforced by the Draggable's bounds
    }

    return mutation.resizerPanel || mutation.resizerNav ? { ...state, ...mutation } : state;
  }

  componentDidUpdate(prevProps: LayoutProps, prevState: LayoutState) {
    const { resizerPanel, resizerNav } = this.state;

    persistence.set({
      resizerPanel,
      resizerNav,
    });
    const { width: prevWidth, height: prevHeight } = prevProps.bounds;
    const { bounds, options } = this.props;
    const { width, height } = bounds;
    if (width !== prevWidth || height !== prevHeight) {
      const { panelPosition } = options;
      const isPanelBottom = panelPosition === 'bottom';
      if (isPanelBottom) {
        this.setState({
          resizerPanel: {
            x: prevState.resizerPanel.x,
            y: prevState.resizerPanel.y - (prevHeight - height),
          },
        });
      } else {
        this.setState({
          resizerPanel: {
            x: prevState.resizerPanel.x - (prevWidth - width),
            y: prevState.resizerPanel.y,
          },
        });
      }
    }
  }

  resizeNav = (e: DraggableEvent, data: DraggableData) => {
    if (data.deltaX) {
      this.setState({ resizerNav: { x: data.x, y: data.y } });
    }
  };

  resizePanel = (e: DraggableEvent, data: DraggableData) => {
    const { options } = this.props;

    if (
      (data.deltaY && options.panelPosition === 'bottom') ||
      (data.deltaX && options.panelPosition === 'right')
    ) {
      this.setState({ resizerPanel: { x: data.x, y: data.y } });
    }
  };

  setDragNav = () => {
    this.setState({ isDragging: 'nav' });
  };

  setDragPanel = () => {
    this.setState({ isDragging: 'panel' });
  };

  unsetDrag = () => {
    this.setState({ isDragging: false });
  };

  render() {
    const { children, bounds, options, theme, viewMode, docsOnly, panelCount } = this.props;
    const { isDragging, resizerNav, resizerPanel } = this.state;

    const margin = theme.layoutMargin;
    const isNavHidden = options.isFullscreen || !options.showNav;
    const isPanelHidden =
      options.isFullscreen ||
      !options.showPanel ||
      docsOnly ||
      viewMode !== 'story' ||
      panelCount === 0;
    const isFullscreen = options.isFullscreen || (isNavHidden && isPanelHidden);
    const { isToolshown } = options;

    const { panelPosition } = options;
    const isPanelBottom = panelPosition === 'bottom';
    const isPanelRight = panelPosition === 'right';

    const panelX = resizerPanel.x;
    const navX = resizerNav.x;

    return bounds ? (
      <Fragment>
        {isNavHidden ? null : (
          <Draggable
            axis="x"
            position={resizerNav}
            bounds={{
              left: MIN_NAV_WIDTH,
              top: 0,
              right:
                isPanelRight && !isPanelHidden
                  ? panelX - MIN_CANVAS_WIDTH
                  : bounds.width - MIN_CANVAS_WIDTH,
              bottom: 0,
            }}
            onStart={this.setDragNav}
            onDrag={this.resizeNav}
            onStop={this.unsetDrag}
          >
            <Handle axis="x" isDragging={isDragging === 'nav'} />
          </Draggable>
        )}

        {isPanelHidden ? null : (
          <Draggable
            axis={isPanelBottom ? 'y' : 'x'}
            position={resizerPanel}
            bounds={
              isPanelBottom
                ? {
                    left: 0,
                    top: MIN_CANVAS_HEIGHT,
                    right: 0,
                    bottom: bounds.height - MIN_PANEL_HEIGHT,
                  }
                : {
                    left: isNavHidden ? MIN_CANVAS_WIDTH : navX + MIN_CANVAS_WIDTH,
                    top: 0,
                    right: bounds.width - MIN_PANEL_WIDTH,
                    bottom: 0,
                  }
            }
            onStart={this.setDragPanel}
            onDrag={this.resizePanel}
            onStop={this.unsetDrag}
          >
            <Handle
              isDragging={isDragging === 'panel'}
              style={
                isPanelBottom
                  ? {
                      left: navX + margin,
                      width: bounds.width - navX - 2 * margin,
                      marginTop: -margin,
                    }
                  : {
                      marginLeft: -margin,
                    }
              }
              axis={isPanelBottom ? 'y' : 'x'}
            />
          </Draggable>
        )}

        {isDragging ? <HoverBlocker /> : null}
        {children({
          mainProps: {
            viewMode,
            animate: !isDragging,
            isFullscreen,
            position: getMainPosition({ bounds, resizerNav, isNavHidden, isFullscreen, margin }),
          },
          previewProps: {
            viewMode,
            docsOnly,
            animate: !isDragging,
            isFullscreen,
            isToolshown,
            position: getPreviewPosition({
              isFullscreen,
              isNavHidden,
              isPanelHidden,
              resizerNav,
              resizerPanel,
              bounds,
              panelPosition,
              margin,
            }),
          },
          navProps: {
            viewMode,
            animate: !isDragging,
            hidden: isNavHidden,
            position: {
              height: bounds.height,
              left: 0,
              top: 0,
              width: navX + margin,
            },
          },
          panelProps: {
            viewMode,
            animate: !isDragging,
            align: options.panelPosition,
            hidden: isPanelHidden,
            position: getPanelPosition({
              isPanelBottom,
              isPanelHidden,
              isNavHidden,
              bounds,
              resizerPanel,
              resizerNav,
              margin,
            }),
          },
        })}
      </Fragment>
    ) : null;
  }
}

const ThemedLayout = withTheme(Layout);

export { ThemedLayout as Layout };