import React, { useEffect, useState, Fragment, isValidElement, useCallback, } from "react"; import type { ReactNode, ReactElement } from "react"; import { Box, Button, Flex, Input, Label, Pre, Stack, Text } from "."; import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription, DialogClose, } from "./Dialog"; import { Plus, X } from "phosphor-react"; import { styled } from "../stitches.config"; import { capitalize } from "../utils/helpers"; import ContextMenu, { ContentMenuOption } from "./ContextMenu"; const ErrorText = styled(Text, { color: "$error", mt: "$1", display: "block", }); type Nullable = T | null | undefined | false; interface TabProps { header: string; children?: ReactNode; } // TODO customize messages shown interface Props { label?: string; activeIndex?: number; activeHeader?: string; headless?: boolean; children: ReactElement[]; keepAllAlive?: boolean; defaultExtension?: string; extensionRequired?: boolean; allowedExtensions?: string[]; headerExtraValidation?: { regex: string | RegExp; error: string; }; onCreateNewTab?: (name: string) => any; onRenameTab?: (index: number, nwName: string, oldName?: string) => any; onCloseTab?: (index: number, header?: string) => any; onChangeActive?: (index: number, header?: string) => any; } export const Tab = (props: TabProps) => null; export const Tabs = ({ label = "Tab", children, activeIndex, activeHeader, headless, keepAllAlive = false, onCreateNewTab, onCloseTab, onChangeActive, onRenameTab, headerExtraValidation, extensionRequired, defaultExtension = "", allowedExtensions, }: Props) => { const [active, setActive] = useState(activeIndex || 0); const tabs: TabProps[] = children.map(elem => elem.props); const [isNewtabDialogOpen, setIsNewtabDialogOpen] = useState(false); const [renamingTab, setRenamingTab] = useState(null); const [tabname, setTabname] = useState(""); const [tabnameError, setTabnameError] = useState(null); useEffect(() => { if (activeIndex) setActive(activeIndex); }, [activeIndex]); useEffect(() => { if (activeHeader) { const idx = tabs.findIndex(tab => tab.header === activeHeader); if (idx !== -1) setActive(idx); else setActive(0); } }, [activeHeader, tabs]); // when filename changes, reset error useEffect(() => { setTabnameError(null); }, [tabname, setTabnameError]); const validateTabname = useCallback( (tabname: string): { error?: string, result?: string } => { if (!tabname) { return { error: `Please enter ${label.toLocaleLowerCase()} name.` }; } let ext = (tabname.includes(".") && tabname.split(".").pop()) || ""; if (!ext && defaultExtension) { ext = defaultExtension tabname = `${tabname}.${defaultExtension}` } if (tabs.find(tab => tab.header === tabname)) { return { error: `${capitalize(label)} name already exists.` }; } if (extensionRequired && !ext) { return { error: "File extension is required!" }; } if (allowedExtensions && !allowedExtensions.includes(ext)) { return { error: "This file extension is not allowed!" }; } if ( headerExtraValidation && !tabname.match(headerExtraValidation.regex) ) { return { error: headerExtraValidation.error }; } return { result: tabname }; }, [ allowedExtensions, defaultExtension, extensionRequired, headerExtraValidation, label, tabs, ] ); const handleActiveChange = useCallback( (idx: number, header?: string) => { setActive(idx); onChangeActive?.(idx, header); }, [onChangeActive] ); const handleRenameTab = useCallback(() => { if (renamingTab === null) return; const res = validateTabname(tabname); if (res.error) { setTabnameError(`Error: ${res.error}`); return; } const { result: _tabname = tabname } = res setRenamingTab(null); setTabname(""); const oldName = tabs[renamingTab]?.header; onRenameTab?.(renamingTab, _tabname, oldName); handleActiveChange(renamingTab); }, [handleActiveChange, onRenameTab, renamingTab, tabname, tabs, validateTabname]); const handleCreateTab = useCallback(() => { const res = validateTabname(tabname); if (res.error) { setTabnameError(`Error: ${res.error}`); return; } const { result: _tabname = tabname } = res setIsNewtabDialogOpen(false); setTabname(""); onCreateNewTab?.(_tabname); handleActiveChange(tabs.length, _tabname); }, [validateTabname, tabname, onCreateNewTab, handleActiveChange, tabs.length]); const handleCloseTab = useCallback( (idx: number) => { if (idx <= active && active !== 0) { setActive(active - 1); } onCloseTab?.(idx, tabs[idx].header); handleActiveChange(idx, tabs[idx].header); }, [active, handleActiveChange, onCloseTab, tabs] ); const closeOption = (idx: number): Nullable => onCloseTab && { type: "text", label: "Close", key: "close", onSelect: () => handleCloseTab(idx), }; const renameOption = (idx: number): Nullable => onRenameTab && { type: "text", label: "Rename", key: "rename", onSelect: () => setRenamingTab(idx), }; return ( <> {!headless && ( {tabs.map((tab, idx) => ( ))} {onCreateNewTab && ( Create new {label.toLocaleLowerCase()} setTabname(e.target.value)} onKeyPress={e => { if (e.key === "Enter") { handleCreateTab(); } }} /> {tabnameError} )} {onRenameTab && ( setRenamingTab(null)} > Rename
{tabs[renamingTab || 0]?.header}
setTabname(e.target.value)} onKeyPress={e => { if (e.key === "Enter") { handleRenameTab(); } }} /> {tabnameError}
)}
)} {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 ( ); }) : tabs[active] && ( {tabs[active].children} )} ); };