All files / src/components tabs.tsx

90.24% Statements 37/41
80% Branches 32/40
90% Functions 9/10
91.66% Lines 33/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204                                                                                      8x                                           8x             7x   7x     7x     21x               7x 6x 1x   15x 5x 1x           1x   4x     7x   1x       1x   1x         1x           7x   11x 7x         20x 7x 1x 3x 3x                 6x     7x   7x 6x             7x                           21x                                                              
import {
  useState,
  useCallback,
  Children,
  type HTMLAttributes,
  type ReactElement,
  type ReactNode,
  useId,
} from 'react';
import cn from 'classnames';
import type { Except } from 'type-fest';
 
import '../styles/components/tabs.scss';
 
type TabProps = {
  /**
   * Title of that tab
   */
  title: ReactNode;
  /**
   * Optional ID for that tab, one of the expected options for the parent <Tabs> component
   */
  id?: string;
  /**
   * Content of that tab
   */
  children?: ReactNode;
  /**
   * Choose that tab as the default to be displayed
   */
  defaultSelected?: boolean;
  /**
   * Option to render and hide tab (display:none) rather than remove from the DOM
   */
  cache?: boolean;
  /**
   * Option to disable selection of tab
   */
  disabled?: boolean;
} & Except<HTMLAttributes<HTMLDivElement>, 'title' | 'id'>;
 
// This is just a configuration component, it doesn't need to render anything as
// it will be used by a <Tabs> component
export const Tab = (_: TabProps) => null;
 
type TabsProps = {
  /**
   * <Tab> elements defining the content and title of each tab
   */
  children:
    | Array<ReactElement<TabProps> | null>
    | ReactElement<TabProps>
    | null;
  /**
   * Optional way of controling the tabs from the outside of this component by
   * assigning here a value corresponding to an 'id' prop of one of the child
   * <Tab>
   */
  active?: string | number;
  /**
   * Optional bordered styling of tab headers
   */
  bordered?: boolean;
} & Except<HTMLAttributes<HTMLDivElement>, 'children'>;
 
export const Tabs = ({
  children,
  active,
  className,
  bordered = false,
  ...props
}: TabsProps) => {
  const tabId = useId();
 
  const isManaged = typeof active !== 'undefined';
 
  // create an array of tab description objects out of the children's props
  const childrenArray = Children.toArray(children).filter(Boolean) as Array<
    ReactElement<TabProps>
  >;
  const tabs = childrenArray.map(({ props: { id, ...props } }, index) => ({
    // set a default value for id depending on their index if needed
    id: id === undefined ? `${index}` : id,
    // and get the rest of the props as they are
    ...props,
  }));
 
  // state to use to decide which tab to render if this component is not managed
  const [selectedState, setSelectedState] = useState(() => {
    if (isManaged) {
      return active;
    }
    const defaultSelected = tabs.filter((tab) => tab.defaultSelected);
    if (defaultSelected.length) {
      Iif (defaultSelected.length > 1) {
        // eslint-disable-next-line no-console
        console.warn(
          `a <Tabs> component has been rendered with ${defaultSelected.length} <Tab defaultSelected> children. There should be a maximum of 1 default selected child.`
        );
      }
      return defaultSelected[0].id;
    }
    return tabs[0].id;
  });
 
  const handleClick = useCallback(
    (event: MouseEvent | KeyboardEvent) => {
      Iif (isManaged || !(event.currentTarget instanceof HTMLElement)) {
        return;
      }
 
      const { target } = event.currentTarget.dataset;
      // mouse click event, or if keyboard event, restrict to 'Enter' and spacebar keys
      Eif (
        event &&
        target !== undefined &&
        (!('key' in event) || event.key === 'Enter' || event.key === ' ')
      ) {
        setSelectedState(target);
      }
    },
    [isManaged]
  );
 
  const activeFromPropsOrState = isManaged ? active : selectedState;
 
  const selectedTab = tabs.find((tab) => tab.id === activeFromPropsOrState);
  Iif (!selectedTab) {
    throw new Error(`Could not find a tab with the id: "${selectedState}"`);
  }
 
  let content;
  const hasCacheTab = tabs.some(({ cache }) => cache);
  if (hasCacheTab) {
    content = tabs.map((tab) => {
      const selected = tab.id === selectedTab.id;
      return (
        (tab.cache || selected) && (
          <div key={tab.id} style={{ display: selected ? 'block' : 'none' }}>
            {tab.children}
          </div>
        )
      );
    });
  } else {
    content = selectedTab.children;
  }
 
  let unmanagedProps = {};
  // add event listeners in case this is not an externally managed component
  if (!isManaged) {
    unmanagedProps = {
      onClick: handleClick,
      onKeyPress: handleClick,
      tabIndex: 0,
    };
  }
 
  return (
    <div className={cn('tabs', className)} {...props}>
      <div className="tabs__header" role="tablist">
        {tabs.map(
          ({
            title,
            id,
            className,
            disabled = false,
            children: _,
            defaultSelected: __,
            cache: ___,
            ...props
          }) =>
            title && (
              <div
                key={id}
                data-testid="tab-title"
                data-target={id}
                role="tab"
                aria-controls={tabId}
                className={cn(
                  'tabs__header__item',
                  {
                    'tabs__header__item--bordered': bordered,
                    'tabs__header__item--disabled': disabled,
                    'tabs__header__item--active': id === activeFromPropsOrState,
                  },
                  className
                )}
                aria-disabled={disabled ? true : undefined}
                {...(disabled ? {} : unmanagedProps)}
                {...props}
              >
                {title}
              </div>
            )
        )}
      </div>
      <div role="tabpanel" id={tabId} data-testid="tab-content">
        {content}
      </div>
    </div>
  );
};