|
|
@ -3,6 +3,7 @@ |
|
|
|
*/ |
|
|
|
|
|
|
|
import * as React from 'react'; |
|
|
|
import cn from 'classnames'; |
|
|
|
import { |
|
|
|
FileTabs, |
|
|
|
useSandpack, |
|
|
@ -11,35 +12,75 @@ import { |
|
|
|
import {OpenInCodeSandboxButton} from './OpenInCodeSandboxButton'; |
|
|
|
import {ResetButton} from './ResetButton'; |
|
|
|
import {DownloadButton} from './DownloadButton'; |
|
|
|
import {FilesDropdown} from './FilesDropdown'; |
|
|
|
import {IconChevron} from '../../Icon/IconChevron'; |
|
|
|
import {Listbox} from '@headlessui/react'; |
|
|
|
|
|
|
|
// TODO: Replace with real useEvent.
|
|
|
|
export function useEvent(fn: any): any { |
|
|
|
const ref = React.useRef(null); |
|
|
|
React.useInsertionEffect(() => { |
|
|
|
ref.current = fn; |
|
|
|
}, [fn]); |
|
|
|
return React.useCallback((...args: any) => { |
|
|
|
const f = ref.current!; |
|
|
|
// @ts-ignore
|
|
|
|
return f(...args); |
|
|
|
}, []); |
|
|
|
} |
|
|
|
|
|
|
|
const getFileName = (filePath: string): string => { |
|
|
|
const lastIndexOfSlash = filePath.lastIndexOf('/'); |
|
|
|
return filePath.slice(lastIndexOfSlash + 1); |
|
|
|
}; |
|
|
|
|
|
|
|
export function NavigationBar({providedFiles}: {providedFiles: Array<string>}) { |
|
|
|
const {sandpack} = useSandpack(); |
|
|
|
const [dropdownActive, setDropdownActive] = React.useState(false); |
|
|
|
const {openPaths, clients} = sandpack; |
|
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null); |
|
|
|
const tabsRef = React.useRef<HTMLDivElement | null>(null); |
|
|
|
// By default, show the dropdown because all tabs may not fit.
|
|
|
|
// We don't know whether they'll fit or not until after hydration:
|
|
|
|
const [showDropdown, setShowDropdown] = React.useState(true); |
|
|
|
const {openPaths, clients, setActiveFile, activePath} = sandpack; |
|
|
|
const clientId = Object.keys(clients)[0]; |
|
|
|
const {refresh} = useSandpackNavigation(clientId); |
|
|
|
const isMultiFile = openPaths.length > 1; |
|
|
|
const hasJustToggledDropdown = React.useRef(false); |
|
|
|
|
|
|
|
const resizeHandler = React.useCallback(() => { |
|
|
|
const width = window.innerWidth || document.documentElement.clientWidth; |
|
|
|
if (!dropdownActive && width < 640) { |
|
|
|
setDropdownActive(true); |
|
|
|
// Keep track of whether we can show all tabs or just the dropdown.
|
|
|
|
const onContainerResize = useEvent((containerWidth: number) => { |
|
|
|
if (hasJustToggledDropdown.current === true) { |
|
|
|
// Ignore changes likely caused by ourselves.
|
|
|
|
hasJustToggledDropdown.current = false; |
|
|
|
return; |
|
|
|
} |
|
|
|
if (dropdownActive && width >= 640) { |
|
|
|
setDropdownActive(false); |
|
|
|
const tabsWidth = tabsRef.current!.getBoundingClientRect().width; |
|
|
|
const needsDropdown = tabsWidth >= containerWidth; |
|
|
|
if (needsDropdown !== showDropdown) { |
|
|
|
hasJustToggledDropdown.current = true; |
|
|
|
setShowDropdown(needsDropdown); |
|
|
|
} |
|
|
|
}, [dropdownActive]); |
|
|
|
}); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (openPaths.length > 1) { |
|
|
|
resizeHandler(); |
|
|
|
window.addEventListener('resize', resizeHandler); |
|
|
|
return () => { |
|
|
|
window.removeEventListener('resize', resizeHandler); |
|
|
|
}; |
|
|
|
if (isMultiFile) { |
|
|
|
const resizeObserver = new ResizeObserver((entries) => { |
|
|
|
for (const entry of entries) { |
|
|
|
if (entry.contentBoxSize) { |
|
|
|
const contentBoxSize = Array.isArray(entry.contentBoxSize) |
|
|
|
? entry.contentBoxSize[0] |
|
|
|
: entry.contentBoxSize; |
|
|
|
const width = contentBoxSize.inlineSize; |
|
|
|
onContainerResize(width); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
const container = containerRef.current!; |
|
|
|
resizeObserver.observe(container); |
|
|
|
return () => resizeObserver.unobserve(container); |
|
|
|
} else { |
|
|
|
return; |
|
|
|
}, [openPaths.length, resizeHandler]); |
|
|
|
} |
|
|
|
}, [isMultiFile]); |
|
|
|
|
|
|
|
const handleReset = () => { |
|
|
|
sandpack.resetAllFiles(); |
|
|
@ -48,11 +89,70 @@ export function NavigationBar({providedFiles}: {providedFiles: Array<string>}) { |
|
|
|
|
|
|
|
return ( |
|
|
|
<div className="bg-wash dark:bg-card-dark flex justify-between items-center relative z-10 border-b border-border dark:border-border-dark rounded-t-lg rounded-b-none"> |
|
|
|
<div className="px-4 lg:px-6"> |
|
|
|
{dropdownActive ? <FilesDropdown /> : <FileTabs />} |
|
|
|
<div className="flex-1 grow min-w-0 px-4 lg:px-6"> |
|
|
|
<Listbox value={activePath} onChange={setActiveFile}> |
|
|
|
<div ref={containerRef}> |
|
|
|
<div className="relative overflow-hidden"> |
|
|
|
<div |
|
|
|
ref={tabsRef} |
|
|
|
className={cn( |
|
|
|
// The container for all tabs is always in the DOM, but
|
|
|
|
// not always visible. This lets us measure how much space
|
|
|
|
// the tabs would take if displayed. We use this to decide
|
|
|
|
// whether to keep showing the dropdown, or show all tabs.
|
|
|
|
'w-[fit-content]', |
|
|
|
showDropdown ? 'invisible' : '' |
|
|
|
)}> |
|
|
|
<FileTabs /> |
|
|
|
</div> |
|
|
|
<Listbox.Button className="contents"> |
|
|
|
{({open}) => ( |
|
|
|
// If tabs don't fit, display the dropdown instead.
|
|
|
|
// The dropdown is absolutely positioned inside the
|
|
|
|
// space that's taken by the (invisible) tab list.
|
|
|
|
<div |
|
|
|
className={cn( |
|
|
|
'absolute top-0 left-0', |
|
|
|
!showDropdown && 'invisible' |
|
|
|
)}> |
|
|
|
<span |
|
|
|
className={cn( |
|
|
|
'h-full py-2 px-1 mt-px -mb-px flex border-b text-link dark:text-link-dark border-link dark:border-link-dark items-center text-md leading-tight truncate' |
|
|
|
)} |
|
|
|
style={{maxWidth: '160px'}}> |
|
|
|
{getFileName(activePath)} |
|
|
|
{isMultiFile && ( |
|
|
|
<span className="ml-2"> |
|
|
|
<IconChevron |
|
|
|
displayDirection={open ? 'up' : 'down'} |
|
|
|
/> |
|
|
|
</span> |
|
|
|
)} |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</Listbox.Button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
{isMultiFile && showDropdown && ( |
|
|
|
<Listbox.Options className="absolute mt-0.5 bg-card dark:bg-card-dark px-2 left-0 right-0 mx-0 rounded-b-lg border-1 border-border dark:border-border-dark rounded-sm shadow-md"> |
|
|
|
{openPaths.map((filePath: string) => ( |
|
|
|
<Listbox.Option |
|
|
|
key={filePath} |
|
|
|
value={filePath} |
|
|
|
className={cn( |
|
|
|
'text-md mx-2 my-4 cursor-pointer', |
|
|
|
filePath === activePath && 'text-link dark:text-link-dark' |
|
|
|
)}> |
|
|
|
{getFileName(filePath)} |
|
|
|
</Listbox.Option> |
|
|
|
))} |
|
|
|
</Listbox.Options> |
|
|
|
)} |
|
|
|
</Listbox> |
|
|
|
</div> |
|
|
|
<div |
|
|
|
className="px-3 flex items-center justify-end grow text-right" |
|
|
|
className="px-3 flex items-center justify-end text-right" |
|
|
|
translate="yes"> |
|
|
|
<DownloadButton providedFiles={providedFiles} /> |
|
|
|
<ResetButton onReset={handleReset} /> |
|
|
|