soapbox/app/soapbox/components/ui/tabs/tabs.tsx

180 wiersze
4.4 KiB
TypeScript

import { useRect } from '@reach/rect';
import {
Tabs as ReachTabs,
TabList as ReachTabList,
Tab as ReachTab,
useTabsContext,
} from '@reach/tabs';
import clsx from 'clsx';
import React from 'react';
import { useHistory } from 'react-router-dom';
import Counter from '../counter/counter';
import './tabs.css';
const HORIZONTAL_PADDING = 8;
const AnimatedContext = React.createContext(null);
interface IAnimatedInterface {
/** Callback when a tab is chosen. */
onChange(index: number): void
/** Default tab index. */
defaultIndex: number
children: React.ReactNode
}
/** Tabs with a sliding active state. */
const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
const [activeRect, setActiveRect] = React.useState(null);
const ref = React.useRef();
const rect = useRect(ref);
// @ts-ignore
const top: number = (activeRect && activeRect.bottom) - (rect && rect.top);
// @ts-ignore
const width: number = activeRect && activeRect.width - HORIZONTAL_PADDING * 2;
// @ts-ignore
const left: number = (activeRect && activeRect.left) - (rect && rect.left) + HORIZONTAL_PADDING;
return (
// @ts-ignore
<AnimatedContext.Provider value={setActiveRect}>
<ReachTabs
{...rest}
// @ts-ignore
ref={ref}
>
<div
className='absolute h-[3px] w-full bg-primary-200 dark:bg-gray-800'
style={{ top }}
/>
<div
className={clsx('absolute h-[3px] bg-primary-500 transition-all duration-200', {
'hidden': top <= 0,
})}
style={{ left, top, width }}
/>
{children}
</ReachTabs>
</AnimatedContext.Provider>
);
};
interface IAnimatedTab {
/** ARIA role. */
role: 'button'
/** Element to represent the tab. */
as: 'a' | 'button'
/** Route to visit when the tab is chosen. */
href?: string
/** Tab title text. */
title: string
/** Index value of the tab. */
index: number
}
/** A single animated tab. */
const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
// get the currently selected index from useTabsContext
const { selectedIndex } = useTabsContext();
const isSelected: boolean = selectedIndex === index;
// measure the size of our element, only listen to rect if active
const ref = React.useRef();
const rect = useRect(ref, { observe: isSelected });
// get the style changing function from context
const setActiveRect = React.useContext(AnimatedContext);
// callup to set styles whenever we're active
React.useLayoutEffect(() => {
if (isSelected) {
// @ts-ignore
setActiveRect(rect);
}
}, [isSelected, rect, setActiveRect]);
return (
// @ts-ignore
<ReachTab ref={ref} {...props} />
);
};
/** Structure to represent a tab. */
export type Item = {
/** Tab text. */
text: React.ReactNode
/** Tab tooltip text. */
title?: string
/** URL to visit when the tab is selected. */
href?: string
/** Route to visit when the tab is selected. */
to?: string
/** Callback when the tab is selected. */
action?: () => void
/** Display a counter over the tab. */
count?: number
/** Unique name for this tab. */
name: string
}
interface ITabs {
/** Array of structured tab items. */
items: Item[]
/** Name of the active tab item. */
activeItem: string
}
/** Animated tabs component. */
const Tabs = ({ items, activeItem }: ITabs) => {
const defaultIndex = items.findIndex(({ name }) => name === activeItem);
const history = useHistory();
const onChange = (selectedIndex: number) => {
const item = items[selectedIndex];
if (typeof item.action === 'function') {
item.action();
} else if (item.to) {
history.push(item.to);
}
};
const renderItem = (item: Item, idx: number) => {
const { name, text, title, count } = item;
return (
<AnimatedTab
key={name}
as='button'
role='button'
// @ts-ignore
title={title}
index={idx}
>
<div className='relative'>
{count ? (
<span className='absolute -top-2 left-full ml-1'>
<Counter count={count} />
</span>
) : null}
{text}
</div>
</AnimatedTab>
);
};
return (
<AnimatedTabs onChange={onChange} defaultIndex={defaultIndex}>
<ReachTabList>
{items.map((item, i) => renderItem(item, i))}
</ReachTabList>
</AnimatedTabs>
);
};
export default Tabs;