Files
xrpl-hooks-ide/components/Tabs.tsx
2022-08-17 11:50:49 +05:30

375 lines
11 KiB
TypeScript

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> = T | null | undefined | false
interface TabProps {
header: string
children?: ReactNode
renameDisabled?: boolean
}
// TODO customize messages shown
interface Props {
label?: string
activeIndex?: number
activeHeader?: string
headless?: boolean
children: ReactElement<TabProps>[]
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<number | null>(null)
const [tabname, setTabname] = useState('')
const [tabnameError, setTabnameError] = useState<string | null>(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: nwName = tabname } = res
setRenamingTab(null)
setTabname('')
const oldName = tabs[renamingTab]?.header
onRenameTab?.(renamingTab, nwName, oldName)
handleActiveChange(renamingTab, nwName)
}, [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) => {
onCloseTab?.(idx, tabs[idx].header)
if (idx <= active && active !== 0) {
const nwActive = active - 1
handleActiveChange(nwActive, tabs[nwActive].header)
}
},
[active, handleActiveChange, onCloseTab, tabs]
)
const closeOption = (idx: number): Nullable<ContentMenuOption> =>
onCloseTab && {
type: 'text',
label: 'Close',
key: 'close',
onSelect: () => handleCloseTab(idx)
}
const renameOption = (idx: number, tab: TabProps): Nullable<ContentMenuOption> => {
return (
onRenameTab &&
!tab.renameDisabled && {
type: 'text',
label: 'Rename',
key: 'rename',
onSelect: () => setRenamingTab(idx)
}
)
}
return (
<>
{!headless && (
<Stack
css={{
gap: '$3',
flex: 1,
flexWrap: 'nowrap',
marginBottom: '$2',
width: '100%',
overflow: 'auto'
}}
>
{tabs.map((tab, idx) => (
<ContextMenu
key={tab.header}
options={
[closeOption(idx), renameOption(idx, tab)].filter(Boolean) as ContentMenuOption[]
}
>
<Button
role="tab"
tabIndex={idx}
onClick={() => handleActiveChange(idx, tab.header)}
onKeyPress={() => handleActiveChange(idx, tab.header)}
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>
</ContextMenu>
))}
{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 ${label.toLocaleLowerCase()}`}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create new {label.toLocaleLowerCase()}</DialogTitle>
<DialogDescription>
<Label>{label} name</Label>
<Input
value={tabname}
onChange={e => setTabname(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleCreateTab()
}
}}
/>
<ErrorText>{tabnameError}</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>
)}
{onRenameTab && (
<Dialog open={renamingTab !== null} onOpenChange={() => setRenamingTab(null)}>
<DialogContent>
<DialogTitle>
Rename <Pre>{tabs[renamingTab || 0]?.header}</Pre>
</DialogTitle>
<DialogDescription>
<Label>Enter new name</Label>
<Input
value={tabname}
onChange={e => setTabname(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleRenameTab()
}
}}
/>
<ErrorText>{tabnameError}</ErrorText>
</DialogDescription>
<Flex
css={{
marginTop: 25,
justifyContent: 'flex-end',
gap: '$3'
}}
>
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
<Button variant="primary" onClick={handleRenameTab}>
Confirm
</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
}}
/>
)
})
: tabs[active] && (
<Fragment key={tabs[active].header || active}>{tabs[active].children}</Fragment>
)}
</>
)
}