grommet/grommet

View on GitHub
src/js/components/Box/Box.js

Summary

Maintainability
D
1 day
Test Coverage
import React, {
  Children,
  forwardRef,
  useContext,
  useEffect,
  useMemo,
  useState,
} from 'react';

import { ThemeContext } from 'styled-components';
import { defaultProps } from '../../default-props';
import { backgroundIsDark } from '../../utils';
import { Keyboard } from '../Keyboard';

import { StyledBox, StyledBoxGap } from './StyledBox';
import { BoxPropTypes } from './propTypes';
import { SkeletonContext, useSkeleton } from '../Skeleton';
import { AnnounceContext } from '../../contexts/AnnounceContext';
import { OptionsContext } from '../../contexts/OptionsContext';

const Box = forwardRef(
  (
    {
      a11yTitle,
      background: backgroundProp,
      border,
      children,
      cssGap, // internal for now
      direction = 'column',
      elevation, // munged to avoid styled-components putting it in the DOM
      fill, // munged to avoid styled-components putting it in the DOM
      gap,
      kind, // munged to avoid styled-components putting it in the DOM
      onBlur,
      onClick,
      onFocus,
      overflow, // munged to avoid styled-components putting it in the DOM
      responsive = true,
      tag,
      as,
      wrap, // munged to avoid styled-components putting it in the DOM,
      width, // munged to avoid styled-components putting it in the DOM
      height, // munged to avoid styled-components putting it in the DOM
      tabIndex,
      skeleton: skeletonProp,
      ...rest
    },
    ref,
  ) => {
    const theme = useContext(ThemeContext) || defaultProps.theme;
    // boxOptions was created to preserve backwards compatibility but
    // should not be supported in v3
    const { box: boxOptions } = useContext(OptionsContext);

    const skeleton = useSkeleton();

    let background = backgroundProp;

    const announce = useContext(AnnounceContext);

    useEffect(() => {
      if (skeletonProp?.message?.start) announce(skeletonProp.message.start);
      else if (typeof skeletonProp?.message === 'string')
        announce(skeletonProp.message);
      return () =>
        skeletonProp?.message?.end && announce(skeletonProp.message.end);
    }, [announce, skeletonProp]);

    const focusable = useMemo(
      () => onClick && !(tabIndex < 0),
      [onClick, tabIndex],
    );

    const [focus, setFocus] = useState();

    const clickProps = useMemo(() => {
      if (focusable) {
        return {
          onClick,
          onFocus: (event) => {
            setFocus(true);
            if (onFocus) onFocus(event);
          },
          onBlur: (event) => {
            setFocus(false);
            if (onBlur) onBlur(event);
          },
        };
      }
      const result = {};
      if (onBlur) result.onBlur = onBlur;
      if (onClick) result.onClick = onClick;
      if (onFocus) result.onFocus = onFocus;
      return result;
    }, [focusable, onClick, onFocus, onBlur]);

    const adjustedTabIndex = useMemo(() => {
      if (tabIndex !== undefined) return tabIndex;
      if (focusable) return 0;
      return undefined;
    }, [focusable, tabIndex]);

    if (
      (border === 'between' ||
        (border && border.side === 'between') ||
        (Array.isArray(border) && border.find((b) => b.side === 'between'))) &&
      !gap
    ) {
      console.warn('Box must have a gap to use border between');
    }

    let contents = children;
    if (
      gap &&
      gap !== 'none' &&
      (!(boxOptions?.cssGap || cssGap || typeof gap === 'object') ||
        // need this approach to show border between
        border === 'between' ||
        border?.side === 'between' ||
        (Array.isArray(border) && border.find((b) => b.side === 'between')))
    ) {
      // if border is an array, we need to extract the border between object
      const styledBoxGapBorder = Array.isArray(border)
        ? border.find((b) => b.side === 'between')
        : border;
      const boxAs = !as && tag ? tag : as;
      contents = [];
      let firstIndex;
      Children.forEach(children, (child, index) => {
        if (child) {
          if (firstIndex === undefined) {
            firstIndex = index;
          } else {
            contents.push(
              <StyledBoxGap
                // eslint-disable-next-line react/no-array-index-key
                key={`gap-${index}`}
                as={boxAs === 'span' ? boxAs : 'div'}
                gap={gap}
                directionProp={direction}
                responsive={responsive}
                border={styledBoxGapBorder}
              />,
            );
          }
        }
        contents.push(child);
      });
    }

    const nextSkeleton = useMemo(() => {
      // Decide if we need to add a new SkeletonContext. We need one if:
      //   1. skeleton info was set in a property OR
      //   2. there already is a SkeletonContext but this box has a
      //      background or border. This means the box probably is more
      //      distinguishable from the area around it.
      // We keep track of a depth so we know how to alternate backgrounds.
      if (skeletonProp || ((background || border) && skeleton)) {
        const depth = skeleton ? skeleton.depth + 1 : 0;
        return {
          ...skeleton,
          depth,
          ...(typeof skeletonProp === 'object' ? skeletonProp : {}),
        };
      }
      return undefined;
    }, [background, border, skeleton, skeletonProp]);

    let skeletonProps = {};
    if (nextSkeleton) {
      const {
        colors: skeletonThemeColors,
        size: skeletonThemeSize,
        ...skeletonThemeProps
      } = theme.skeleton;
      const skeletonColors = nextSkeleton.colors
        ? nextSkeleton.colors[theme.dark ? 'dark' : 'light']
        : skeletonThemeColors?.[theme.dark ? 'dark' : 'light'];
      skeletonProps = { ...skeletonThemeProps };
      background = skeletonColors[nextSkeleton.depth % skeletonColors.length];
      if (skeletonProp?.animation) {
        skeletonProps.animation = skeletonProp.animation;
      }
      contents = (
        <SkeletonContext.Provider value={nextSkeleton}>
          {contents}
        </SkeletonContext.Provider>
      );
    }

    // construct a new theme object in case we have a background that wants
    // to change the background color context
    const nextTheme = useMemo(() => {
      let result;
      if (background || theme.darkChanged) {
        const dark = backgroundIsDark(background, theme);
        const darkChanged = dark !== undefined && dark !== theme.dark;
        if (darkChanged || theme.darkChanged) {
          result = { ...theme };
          result.dark = dark === undefined ? theme.dark : dark;
          result.background = background;
        } else if (background) {
          // This allows DataTable to intelligently set the background
          // of a pinned header or footer.
          result = { ...theme };
          result.background = background;
        }
      }
      return result || theme;
    }, [background, theme]);

    let content = (
      <StyledBox
        as={!as && tag ? tag : as}
        aria-label={a11yTitle}
        background={background}
        border={border}
        ref={ref}
        directionProp={direction}
        elevationProp={elevation}
        fillProp={fill}
        focus={focus}
        gap={
          (boxOptions?.cssGap || cssGap || typeof gap === 'object') &&
          gap &&
          gap !== 'none' &&
          border !== 'between' &&
          border?.side !== 'between' &&
          (!Array.isArray(border) ||
            !border.find((b) => b.side === 'between')) &&
          gap
        }
        kindProp={kind}
        overflowProp={overflow}
        wrapProp={wrap}
        widthProp={width}
        heightProp={height}
        responsive={responsive}
        tabIndex={adjustedTabIndex}
        {...clickProps}
        {...rest}
        {...skeletonProps}
      >
        <ThemeContext.Provider value={nextTheme}>
          {contents}
        </ThemeContext.Provider>
      </StyledBox>
    );

    if (onClick) {
      content = <Keyboard onEnter={onClick}>{content}</Keyboard>;
    }

    return content;
  },
);

Box.displayName = 'Box';
Box.propTypes = BoxPropTypes;
export { Box };