import React, { RefObject, useCallback, useState } from "react"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import "react-tabs/style/react-tabs.css"; import styled from "styled-components"; import Icon, { IconName, IconSize } from "./Icon"; import { Classes, CommonComponentProps } from "./common"; import { useEffect } from "react"; import { Indices } from "constants/Layers"; import { theme } from "constants/DefaultTheme"; import useResizeObserver from "utils/hooks/useResizeObserver"; export const TAB_MIN_HEIGHT = `36px`; export type TabProp = { key: string; title: string; count?: number; panelComponent?: JSX.Element; icon?: IconName; iconSize?: IconSize; }; const TabsWrapper = styled.div<{ shouldOverflow?: boolean; vertical?: boolean; responseViewer?: boolean; }>` border-radius: 0px; height: 100%; overflow: hidden; .react-tabs { height: 100%; } .react-tabs__tab-panel { height: ${() => `calc(100% - ${TAB_MIN_HEIGHT})`}; overflow: auto; } .react-tabs__tab-list { margin: 0px; display: flex; flex-direction: ${(props) => (!!props.vertical ? "column" : "row")}; align-items: ${(props) => (!!props.vertical ? "stretch" : "center")}; border-bottom: none; gap: ${(props) => !props.vertical ? `${props.theme.spaces[12] + 2}px` : 0}; color: ${(props) => props.theme.colors.tabs.normal}; path { fill: ${(props) => props.theme.colors.tabs.icon}; } ${(props) => props.shouldOverflow && ` overflow-y: hidden; overflow-x: auto; white-space: nowrap; `} ${(props) => props.responseViewer && ` margin-left: 30px; display: flex; align-items: center; height: 24px; background-color: ${props.theme.colors.multiSwitch.bg} !important; width: fit-content; padding-left: 1px; margin-top: 10px !important; margin-bottom: 10px !important; `} } .react-tabs__tab { align-items: center; text-align: center; display: inline-flex; justify-content: center; border-color: transparent; position: relative; padding: 0; ${(props) => props.responseViewer && ` `} } .react-tabs__tab, .react-tabs__tab:focus { box-shadow: none; border: none; &:after { content: none; } ${(props) => props.responseViewer && ` display: flex; align-items: center; cursor: pointer; height: 22px; padding: 0 12px; border: 1px solid ${props.theme.colors.multiSwitch.border}; margin-right: -1px; margin-left: -1px; margin-top: -2px; height: 100%; `} } .react-tabs__tab--selected { background-color: transparent; path { fill: ${(props) => props.theme.colors.tabs.hover}; } ${(props) => props.responseViewer && ` background-color: ${props.theme.colors.multiSwitch.selectedBg}; border: 1px solid ${props.theme.colors.multiSwitch.border}; border-radius: 0px; font-weight: normal; `} } ${(props) => props.responseViewer && ` padding: 0px; margin-top: 10px; `} `; export const TabTitle = styled.span<{ responseViewer?: boolean }>` font-size: ${(props) => props.theme.typography.h4.fontSize}px; font-weight: ${(props) => props.theme.fontWeights[1]}; line-height: ${(props) => props.theme.spaces[11]}px; letter-spacing: ${(props) => props.theme.typography.h4.letterSpacing}px; margin: 0; display: flex; align-items: center; ${(props) => props.responseViewer && ` font-size: 12px; font-weight: normal; line-height: 16px; letter-spacing: normal; text-transform: uppercase; color: ${props.theme.colors.text.normal}; `} `; export const TabCount = styled.div` background-color: ${(props) => props.theme.colors.tabs.countBg}; border-radius: 8px; min-width: 17px; height: 17px; font-size: 9px; margin-left: 4px; display: flex; align-items: center; justify-content: center; padding: 0 2px; `; const TabTitleWrapper = styled.div<{ selected: boolean; vertical: boolean; responseViewer?: boolean; }>` display: flex; width: 100%; padding: ${(props) => props.theme.spaces[3] - 1}px ${(props) => (props.vertical ? `${props.theme.spaces[4] - 1}px` : 0)} ${(props) => props.theme.spaces[4] - 1}px ${(props) => (props.vertical ? `${props.theme.spaces[4] - 1}px` : 0)}; color: ${(props) => props.theme.colors.tabs.normal}; &:hover { color: ${(props) => props.theme.colors.tabs.hover}; .${Classes.ICON} { svg { fill: ${(props) => props.theme.colors.tabs.hover}; path { fill: ${(props) => props.theme.colors.tabs.hover}; } } } } ${(props) => props.responseViewer && ` padding: 0px; `} .${Classes.ICON} { margin-right: ${(props) => props.theme.spaces[1]}px; border-radius: 50%; svg { width: 16px; height: 16px; margin: auto; fill: ${(props) => props.theme.colors.tabs.normal}; path { fill: ${(props) => props.theme.colors.tabs.normal}; } } } ${(props) => props.selected ? ` background-color: transparent; color: var(--appsmith-color-black-900); .${Classes.ICON} { svg { path { fill: var(--appsmith-color-black-900) } } } .tab-title { ${props.responseViewer && ` font-weight: normal; `} } &::after { content: ""; position: absolute; width: ${props.vertical ? `${props.theme.spaces[1] - 2}px` : "100%"}; bottom: ${props.vertical ? "0%" : `${props.theme.spaces[0] - 1}px`}; top: ${ props.vertical ? `${props.theme.spaces[0] - 1}px` : "calc(100% - 2px)" }; left: ${props.theme.spaces[0]}px; height: ${props.vertical ? "100%" : `${props.theme.spaces[1] - 2}px`}; background-color: ${props.theme.colors.info.main}; z-index: ${Indices.Layer3}; ${props.responseViewer && ` display: none; `} } ` : ""} `; const CollapseIconWrapper = styled.div` position: absolute; right: 14px; top: ${() => theme.spaces[3] - 1}px; cursor: pointer; `; export type TabItemProps = { tab: TabProp; selected: boolean; vertical: boolean; responseViewer?: boolean; }; function DefaultTabItem(props: TabItemProps) { const { responseViewer, selected, tab, vertical } = props; return ( {tab.icon ? ( ) : null} {tab.title} {tab.count && tab.count > 0 ? {tab.count} : null} ); } export type TabbedViewComponentType = CommonComponentProps & { tabs: Array; selectedIndex?: number; onSelect?: (tabIndex: number) => void; overflow?: boolean; vertical?: boolean; tabItemComponent?: (props: TabItemProps) => JSX.Element; responseViewer?: boolean; canCollapse?: boolean; // Reference to container for collapsing or expanding content containerRef?: RefObject; // height of container when expanded expandedHeight?: string; }; // Props required to support a collapsible (foldable) tab component export type CollapsibleTabProps = { // Reference to container for collapsing or expanding content containerRef: RefObject; // height of container when expanded( usually the default height of the tab component) expandedHeight: string; }; export type CollapsibleTabbedViewComponentType = TabbedViewComponentType & CollapsibleTabProps; export const collapsibleTabRequiredPropKeys: Array = [ "containerRef", "expandedHeight", ]; // Tab is considered collapsible only when all required collapsible props are present export const isCollapsibleTabComponent = ( props: TabbedViewComponentType | CollapsibleTabbedViewComponentType, ): props is CollapsibleTabbedViewComponentType => collapsibleTabRequiredPropKeys.every((key) => key in props); export function TabComponent( props: TabbedViewComponentType | CollapsibleTabbedViewComponentType, ) { const TabItem = props.tabItemComponent || DefaultTabItem; // for setting selected state of an uncontrolled component const [selectedIndex, setSelectedIndex] = useState(props.selectedIndex || 0); const [isExpanded, setIsExpanded] = useState(true); useEffect(() => { if (typeof props.selectedIndex === "number") setSelectedIndex(props.selectedIndex); }, [props.selectedIndex]); const handleContainerResize = () => { if (!isCollapsibleTabComponent(props)) return; const { containerRef, expandedHeight } = props; if (containerRef?.current && expandedHeight) { containerRef.current.style.height = isExpanded ? TAB_MIN_HEIGHT : expandedHeight; } setIsExpanded((prev) => !prev); }; const resizeCallback = useCallback( (entries: ResizeObserverEntry[]) => { if (entries && entries.length) { const { contentRect: { height }, } = entries[0]; if (height > Number(TAB_MIN_HEIGHT.replace("px", "")) + 6) { !isExpanded && setIsExpanded(true); } else { isExpanded && setIsExpanded(false); } } }, [isExpanded], ); useResizeObserver( isCollapsibleTabComponent(props) ? props.containerRef?.current : null, resizeCallback, ); useEffect(() => { if (!isCollapsibleTabComponent(props)) return; const { containerRef } = props; if (!isExpanded && containerRef.current) { containerRef.current.style.height = TAB_MIN_HEIGHT; } }, [isExpanded]); return ( {isCollapsibleTabComponent(props) && ( )} { props.onSelect && props.onSelect(index); setSelectedIndex(index); }} selectedIndex={props.selectedIndex} > {props.tabs.map((tab, index) => ( ))} {props.tabs.map((tab) => ( {tab.panelComponent} ))} ); }