375 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			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>
 | 
						|
          )}
 | 
						|
    </>
 | 
						|
  )
 | 
						|
}
 |