258 lines
7.0 KiB
TypeScript
258 lines
7.0 KiB
TypeScript
import React, {
|
|
useEffect,
|
|
useState,
|
|
Fragment,
|
|
isValidElement,
|
|
useCallback,
|
|
} from "react";
|
|
import type { ReactNode, ReactElement } from "react";
|
|
import { Box, Button, Flex, Input, Stack, Text } from ".";
|
|
import {
|
|
Dialog,
|
|
DialogTrigger,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogClose,
|
|
} from "./Dialog";
|
|
import { Plus, X } from "phosphor-react";
|
|
import { styled } from "../stitches.config";
|
|
|
|
const ErrorText = styled(Text, {
|
|
color: "$crimson9",
|
|
mt: "$1",
|
|
display: "block",
|
|
});
|
|
|
|
interface TabProps {
|
|
header?: string;
|
|
children: ReactNode;
|
|
}
|
|
|
|
// TODO customise strings shown
|
|
interface Props {
|
|
activeIndex?: number;
|
|
activeHeader?: string;
|
|
headless?: boolean;
|
|
children: ReactElement<TabProps>[];
|
|
keepAllAlive?: boolean;
|
|
defaultExtension?: string;
|
|
forceDefaultExtension?: boolean;
|
|
onCreateNewTab?: (name: string) => any;
|
|
onCloseTab?: (index: number, header?: string) => any;
|
|
}
|
|
|
|
export const Tab = (props: TabProps) => null;
|
|
|
|
export const Tabs = ({
|
|
children,
|
|
activeIndex,
|
|
activeHeader,
|
|
headless,
|
|
keepAllAlive = false,
|
|
onCreateNewTab,
|
|
onCloseTab,
|
|
defaultExtension = "",
|
|
forceDefaultExtension,
|
|
}: Props) => {
|
|
const [active, setActive] = useState(activeIndex || 0);
|
|
const tabs: TabProps[] = children.map((elem) => elem.props);
|
|
|
|
const [isNewtabDialogOpen, setIsNewtabDialogOpen] = useState(false);
|
|
const [tabname, setTabname] = useState("");
|
|
const [newtabError, setNewtabError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (activeIndex) setActive(activeIndex);
|
|
}, [activeIndex]);
|
|
|
|
useEffect(() => {
|
|
if (activeHeader) {
|
|
const idx = tabs.findIndex((tab) => tab.header === activeHeader);
|
|
setActive(idx);
|
|
}
|
|
}, [activeHeader, tabs]);
|
|
|
|
// when filename changes, reset error
|
|
useEffect(() => {
|
|
setNewtabError(null);
|
|
}, [tabname, setNewtabError]);
|
|
|
|
const validateTabname = useCallback(
|
|
(tabname: string): { error: string | null } => {
|
|
if (tabs.find((tab) => tab.header === tabname)) {
|
|
return { error: "Name already exists." };
|
|
}
|
|
return { error: null };
|
|
},
|
|
[tabs]
|
|
);
|
|
|
|
const handleCreateTab = useCallback(() => {
|
|
// add default extension in case omitted
|
|
let _tabname = tabname.includes(".") ? tabname : tabname + defaultExtension;
|
|
if (forceDefaultExtension && !_tabname.endsWith(defaultExtension)) {
|
|
_tabname = _tabname + defaultExtension;
|
|
}
|
|
|
|
const chk = validateTabname(_tabname);
|
|
if (chk.error) {
|
|
setNewtabError(`Error: ${chk.error}`);
|
|
return;
|
|
}
|
|
|
|
setIsNewtabDialogOpen(false);
|
|
setTabname("");
|
|
// switch to new tab?
|
|
setActive(tabs.length);
|
|
|
|
onCreateNewTab?.(_tabname);
|
|
}, [tabname, defaultExtension, validateTabname, onCreateNewTab, tabs.length]);
|
|
|
|
const handleCloseTab = useCallback(
|
|
(idx: number) => {
|
|
if (idx <= active && active !== 0) {
|
|
setActive(active - 1);
|
|
}
|
|
|
|
onCloseTab?.(idx, tabs[idx].header);
|
|
},
|
|
[active, onCloseTab, tabs]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{!headless && (
|
|
<Stack
|
|
css={{
|
|
gap: "$3",
|
|
flex: 1,
|
|
flexWrap: "nowrap",
|
|
marginBottom: "-1px",
|
|
width: "100%",
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{tabs.map((tab, idx) => (
|
|
<Button
|
|
key={tab.header}
|
|
role="tab"
|
|
tabIndex={idx}
|
|
onClick={() => setActive(idx)}
|
|
onKeyPress={() => setActive(idx)}
|
|
outline={active !== idx}
|
|
size="sm"
|
|
css={{
|
|
"&:hover": {
|
|
span: {
|
|
visibility: "visible",
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
{tab.header || idx}
|
|
{onCloseTab && (
|
|
<Box
|
|
as="span"
|
|
css={{
|
|
display: "flex",
|
|
p: "2px",
|
|
borderRadius: "$full",
|
|
mr: "-4px",
|
|
"&:hover": {
|
|
// boxSizing: "0px 0px 1px",
|
|
backgroundColor: "$mauve2",
|
|
color: "$mauve12",
|
|
},
|
|
}}
|
|
onClick={(ev: React.MouseEvent<HTMLElement>) => {
|
|
ev.stopPropagation();
|
|
handleCloseTab(idx);
|
|
}}
|
|
>
|
|
<X size="9px" weight="bold" />
|
|
</Box>
|
|
)}
|
|
</Button>
|
|
))}
|
|
{onCreateNewTab && (
|
|
<Dialog
|
|
open={isNewtabDialogOpen}
|
|
onOpenChange={setIsNewtabDialogOpen}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
ghost
|
|
size="sm"
|
|
css={{ alignItems: "center", px: "$2", mr: "$3" }}
|
|
>
|
|
<Plus size="16px" /> {tabs.length === 0 && "Add new tab"}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogTitle>Create new tab</DialogTitle>
|
|
<DialogDescription>
|
|
<label>Tabname</label>
|
|
<Input
|
|
value={tabname}
|
|
onChange={(e) => setTabname(e.target.value)}
|
|
onKeyPress={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleCreateTab();
|
|
}
|
|
}}
|
|
/>
|
|
<ErrorText>{newtabError}</ErrorText>
|
|
</DialogDescription>
|
|
|
|
<Flex
|
|
css={{
|
|
marginTop: 25,
|
|
justifyContent: "flex-end",
|
|
gap: "$3",
|
|
}}
|
|
>
|
|
<DialogClose asChild>
|
|
<Button outline>Cancel</Button>
|
|
</DialogClose>
|
|
<Button variant="primary" onClick={handleCreateTab}>
|
|
Create
|
|
</Button>
|
|
</Flex>
|
|
<DialogClose asChild>
|
|
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
|
|
<X size="20px" />
|
|
</Box>
|
|
</DialogClose>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
{keepAllAlive ? (
|
|
tabs.map((tab, idx) => {
|
|
// TODO Maybe rule out fragments as children
|
|
if (!isValidElement(tab.children)) {
|
|
if (active !== idx) return null;
|
|
return tab.children;
|
|
}
|
|
let key = tab.children.key || tab.header || idx;
|
|
let { children } = tab;
|
|
let { style, ...props } = children.props;
|
|
return (
|
|
<children.type
|
|
key={key}
|
|
{...props}
|
|
style={{ ...style, display: active !== idx ? "none" : undefined }}
|
|
/>
|
|
);
|
|
})
|
|
) : (
|
|
<Fragment key={tabs[active].header || active}>
|
|
{tabs[active].children}
|
|
</Fragment>
|
|
)}
|
|
</>
|
|
);
|
|
};
|