431 lines
11 KiB
TypeScript
431 lines
11 KiB
TypeScript
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 (
|
|
<TabTitleWrapper
|
|
responseViewer={responseViewer}
|
|
selected={selected}
|
|
vertical={vertical}
|
|
>
|
|
{tab.icon ? (
|
|
<Icon
|
|
name={tab.icon}
|
|
size={tab.iconSize ? tab.iconSize : IconSize.XXXL}
|
|
/>
|
|
) : null}
|
|
<TabTitle className="tab-title" responseViewer={responseViewer}>
|
|
{tab.title}
|
|
</TabTitle>
|
|
{tab.count && tab.count > 0 ? <TabCount>{tab.count}</TabCount> : null}
|
|
</TabTitleWrapper>
|
|
);
|
|
}
|
|
|
|
export type TabbedViewComponentType = CommonComponentProps & {
|
|
tabs: Array<TabProp>;
|
|
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<HTMLElement>;
|
|
// 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<HTMLDivElement>;
|
|
// height of container when expanded( usually the default height of the tab component)
|
|
expandedHeight: string;
|
|
};
|
|
|
|
export type CollapsibleTabbedViewComponentType = TabbedViewComponentType &
|
|
CollapsibleTabProps;
|
|
|
|
export const collapsibleTabRequiredPropKeys: Array<keyof CollapsibleTabProps> = [
|
|
"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 (
|
|
<TabsWrapper
|
|
data-cy={props.cypressSelector}
|
|
responseViewer={props.responseViewer}
|
|
shouldOverflow={props.overflow}
|
|
vertical={props.vertical}
|
|
>
|
|
{isCollapsibleTabComponent(props) && (
|
|
<CollapseIconWrapper>
|
|
<Icon
|
|
name={isExpanded ? "expand-more" : "expand-less"}
|
|
onClick={handleContainerResize}
|
|
size={IconSize.XXXXL}
|
|
/>
|
|
</CollapseIconWrapper>
|
|
)}
|
|
|
|
<Tabs
|
|
onSelect={(index: number) => {
|
|
props.onSelect && props.onSelect(index);
|
|
setSelectedIndex(index);
|
|
}}
|
|
selectedIndex={props.selectedIndex}
|
|
>
|
|
<TabList>
|
|
{props.tabs.map((tab, index) => (
|
|
<Tab
|
|
data-cy={`t--tab-${tab.key}`}
|
|
data-replay-id={tab.key}
|
|
key={tab.key}
|
|
>
|
|
<TabItem
|
|
responseViewer={props.responseViewer}
|
|
selected={
|
|
index === props.selectedIndex || index === selectedIndex
|
|
}
|
|
tab={tab}
|
|
vertical={!!props.vertical}
|
|
/>
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
{props.tabs.map((tab) => (
|
|
<TabPanel key={tab.key}>{tab.panelComponent}</TabPanel>
|
|
))}
|
|
</Tabs>
|
|
</TabsWrapper>
|
|
);
|
|
}
|