You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
178 lines
6.3 KiB
178 lines
6.3 KiB
/*
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*/
|
|
|
|
import {
|
|
useRef,
|
|
useInsertionEffect,
|
|
useCallback,
|
|
useState,
|
|
useEffect,
|
|
Fragment,
|
|
} from 'react';
|
|
import cn from 'classnames';
|
|
import {
|
|
FileTabs,
|
|
useSandpack,
|
|
useSandpackNavigation,
|
|
} from '@codesandbox/sandpack-react';
|
|
import {OpenInCodeSandboxButton} from './OpenInCodeSandboxButton';
|
|
import {ResetButton} from './ResetButton';
|
|
import {DownloadButton} from './DownloadButton';
|
|
import {IconChevron} from '../../Icon/IconChevron';
|
|
import {Listbox} from '@headlessui/react';
|
|
|
|
// TODO: Replace with real useEvent.
|
|
export function useEvent(fn: any): any {
|
|
const ref = useRef(null);
|
|
useInsertionEffect(() => {
|
|
ref.current = fn;
|
|
}, [fn]);
|
|
return 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 containerRef = useRef<HTMLDivElement | null>(null);
|
|
const tabsRef = 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] = useState(true);
|
|
const {activeFile, setActiveFile, visibleFiles, clients} = sandpack;
|
|
const clientId = Object.keys(clients)[0];
|
|
const {refresh} = useSandpackNavigation(clientId);
|
|
const isMultiFile = visibleFiles.length > 1;
|
|
const hasJustToggledDropdown = useRef(false);
|
|
|
|
// 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 (tabsRef.current === null) {
|
|
// Some ResizeObserver calls come after unmount.
|
|
return;
|
|
}
|
|
const tabsWidth = tabsRef.current.getBoundingClientRect().width;
|
|
const needsDropdown = tabsWidth >= containerWidth;
|
|
if (needsDropdown !== showDropdown) {
|
|
hasJustToggledDropdown.current = true;
|
|
setShowDropdown(needsDropdown);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
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;
|
|
}
|
|
}, [isMultiFile]);
|
|
|
|
const handleReset = () => {
|
|
if (confirm('Reset all your edits too?')) {
|
|
sandpack.resetAllFiles();
|
|
}
|
|
refresh();
|
|
};
|
|
|
|
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 text-lg">
|
|
<div className="flex-1 grow min-w-0 px-4 lg:px-6">
|
|
<Listbox value={activeFile} 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 as={Fragment}>
|
|
{({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.
|
|
<button
|
|
className={cn(
|
|
'absolute top-0 left-[2px]',
|
|
!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(activeFile)}
|
|
{isMultiFile && (
|
|
<span className="ml-2">
|
|
<IconChevron
|
|
displayDirection={open ? 'up' : 'down'}
|
|
/>
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</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">
|
|
{visibleFiles.map((filePath: string) => (
|
|
<Listbox.Option key={filePath} value={filePath} as={Fragment}>
|
|
{({active}) => (
|
|
<li
|
|
className={cn(
|
|
'text-md mx-2 my-4 cursor-pointer',
|
|
active && 'text-link dark:text-link-dark'
|
|
)}>
|
|
{getFileName(filePath)}
|
|
</li>
|
|
)}
|
|
</Listbox.Option>
|
|
))}
|
|
</Listbox.Options>
|
|
)}
|
|
</Listbox>
|
|
</div>
|
|
<div
|
|
className="px-3 flex items-center justify-end text-right"
|
|
translate="yes">
|
|
<DownloadButton providedFiles={providedFiles} />
|
|
<ResetButton onReset={handleReset} />
|
|
<OpenInCodeSandboxButton />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|