46
common/resources/client/ui/tabs/tab-line.tsx
Executable file
46
common/resources/client/ui/tabs/tab-line.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {useLayoutEffect} from '@react-aria/utils';
|
||||
import clsx from 'clsx';
|
||||
import {TabContext} from './tabs-context';
|
||||
|
||||
interface TabLineStyle {
|
||||
width?: string;
|
||||
transform?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabLine() {
|
||||
const {tabsRef, selectedTab} = useContext(TabContext);
|
||||
const [style, setStyle] = useState<TabLineStyle>({
|
||||
width: undefined,
|
||||
transform: undefined,
|
||||
className: undefined,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectedTab != null && tabsRef.current) {
|
||||
const el = tabsRef.current[selectedTab];
|
||||
if (!el) return;
|
||||
|
||||
setStyle(prevState => {
|
||||
return {
|
||||
width: `${el.offsetWidth}px`,
|
||||
transform: `translateX(${el.offsetLeft}px)`,
|
||||
// disable initial transition for tabline
|
||||
className: prevState.width === undefined ? '' : 'transition-all',
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [setStyle, selectedTab, tabsRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-0 h-2 bg-primary',
|
||||
style.className
|
||||
)}
|
||||
role="presentation"
|
||||
style={{width: style.width, transform: style.transform}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
common/resources/client/ui/tabs/tab-list.tsx
Executable file
47
common/resources/client/ui/tabs/tab-list.tsx
Executable file
@@ -0,0 +1,47 @@
|
||||
import React, {Children, cloneElement, isValidElement, ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {FocusScope} from '@react-aria/focus';
|
||||
import {TabProps} from './tab';
|
||||
import {TabLine} from './tab-line';
|
||||
|
||||
export interface TabListProps {
|
||||
children: ReactNode;
|
||||
// center tabs within tablist
|
||||
center?: boolean;
|
||||
// expand tabs to fill in tablist space fully. By default, tabs are only as wide as their content.
|
||||
expand?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
export function TabList({children, center, expand, className}: TabListProps) {
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
return (
|
||||
<FocusScope>
|
||||
<div
|
||||
className={clsx(
|
||||
// hide scrollbar completely on mobile, show compact one on desktop
|
||||
'flex relative max-w-full overflow-auto border-b max-sm:hidden-scrollbar md:compact-scrollbar',
|
||||
className
|
||||
)}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{childrenArray.map((child, index) => {
|
||||
if (isValidElement<TabProps>(child)) {
|
||||
return cloneElement<TabProps>(child, {
|
||||
index,
|
||||
className: clsx(
|
||||
child.props.className,
|
||||
expand && 'flex-auto',
|
||||
center && index === 0 && 'ml-auto',
|
||||
center && index === childrenArray.length - 1 && 'mr-auto'
|
||||
),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<TabLine />
|
||||
</div>
|
||||
</FocusScope>
|
||||
);
|
||||
}
|
||||
111
common/resources/client/ui/tabs/tab-panels.tsx
Executable file
111
common/resources/client/ui/tabs/tab-panels.tsx
Executable file
@@ -0,0 +1,111 @@
|
||||
import React, {
|
||||
Children,
|
||||
cloneElement,
|
||||
ComponentPropsWithoutRef,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useLayoutEffect} from '@react-aria/utils';
|
||||
import {getFocusableTreeWalker} from '@react-aria/focus';
|
||||
import {TabContext} from './tabs-context';
|
||||
|
||||
export interface TabPanelsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function TabPanels({children, className}: TabPanelsProps) {
|
||||
const {selectedTab, isLazy} = useContext(TabContext);
|
||||
|
||||
// filter out falsy values, in case of conditional rendering
|
||||
const panelArray = Children.toArray(children).filter(p => !!p);
|
||||
|
||||
let rendered: ReactNode;
|
||||
if (isLazy) {
|
||||
const el = panelArray[selectedTab] as ReactElement;
|
||||
rendered = isValidElement(el)
|
||||
? cloneElement<TabPanelProps>(panelArray[selectedTab] as ReactElement, {
|
||||
index: selectedTab,
|
||||
})
|
||||
: null;
|
||||
} else {
|
||||
rendered = panelArray.map((panel, index) => {
|
||||
if (isValidElement<TabPanelsProps>(panel)) {
|
||||
const isSelected = index === selectedTab;
|
||||
return cloneElement<TabPanelProps>(panel, {
|
||||
index,
|
||||
'aria-hidden': !isSelected,
|
||||
className: !isSelected
|
||||
? clsx(panel.props.className, 'hidden')
|
||||
: panel.props.className,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return <div className={className}>{rendered}</div>;
|
||||
}
|
||||
|
||||
interface TabPanelProps extends ComponentPropsWithoutRef<'div'> {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
index?: number;
|
||||
}
|
||||
export function TabPanel({
|
||||
className,
|
||||
children,
|
||||
index,
|
||||
...domProps
|
||||
}: TabPanelProps) {
|
||||
const {id} = useContext(TabContext);
|
||||
|
||||
const [tabIndex, setTabIndex] = useState<number | undefined>(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The tabpanel should have tabIndex=0 when there are no tabbable elements within it.
|
||||
// Otherwise, tabbing from the focused tab should go directly to the first tabbable element
|
||||
// within the tabpanel.
|
||||
useLayoutEffect(() => {
|
||||
if (ref?.current) {
|
||||
const update = () => {
|
||||
// Detect if there are any tabbable elements and update the tabIndex accordingly.
|
||||
const walker = getFocusableTreeWalker(ref.current!, {tabbable: true});
|
||||
setTabIndex(walker.nextNode() ? undefined : 0);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
// Update when new elements are inserted, or the tabIndex/disabled attribute updates.
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(ref.current, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['tabIndex', 'disabled'],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={tabIndex}
|
||||
ref={ref}
|
||||
id={`${id}-${index}-tabpanel`}
|
||||
aria-labelledby={`${id}-${index}-tab`}
|
||||
className={clsx(className, 'focus-visible:outline-primary-light')}
|
||||
role="tabpanel"
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
common/resources/client/ui/tabs/tab.tsx
Executable file
111
common/resources/client/ui/tabs/tab.tsx
Executable file
@@ -0,0 +1,111 @@
|
||||
import React, {JSXElementConstructor, ReactNode, useContext} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import {TabContext} from './tabs-context';
|
||||
import {LinkProps} from 'react-router-dom';
|
||||
|
||||
export interface TabProps {
|
||||
className?: string;
|
||||
index?: number;
|
||||
children: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
padding?: string;
|
||||
elementType?: 'button' | 'a' | JSXElementConstructor<any>;
|
||||
to?: LinkProps['to'];
|
||||
relative?: LinkProps['relative'];
|
||||
replace?: LinkProps['replace'];
|
||||
width?: string;
|
||||
}
|
||||
export function Tab({
|
||||
index,
|
||||
className,
|
||||
isDisabled,
|
||||
children,
|
||||
padding: paddingProp,
|
||||
elementType = 'button',
|
||||
to,
|
||||
relative,
|
||||
width = 'min-w-min',
|
||||
}: TabProps) {
|
||||
const {
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
tabsRef,
|
||||
size = 'md',
|
||||
id,
|
||||
} = useContext(TabContext);
|
||||
const isSelected = index === selectedTab;
|
||||
const focusManager = useFocusManager();
|
||||
const padding = paddingProp || (size === 'sm' ? 'px-12' : 'px-18');
|
||||
|
||||
const mergedClassname = clsx(
|
||||
'tracking-wide overflow-hidden capitalize text-sm flex items-center justify-center outline-none transition-colors',
|
||||
'focus-visible:ring focus-visible:ring-2 ring-inset rounded whitespace-nowrap cursor-pointer',
|
||||
width,
|
||||
textColor({isDisabled, isSelected}),
|
||||
className,
|
||||
size === 'md' && `${padding} h-48`,
|
||||
size === 'sm' && `${padding} h-32`,
|
||||
isDisabled && 'pointer-events-none',
|
||||
);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'Home':
|
||||
focusManager?.focusFirst();
|
||||
break;
|
||||
case 'End':
|
||||
focusManager?.focusLast();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const tabIndex = isSelected ? 0 : -1;
|
||||
const Element = elementType;
|
||||
|
||||
return (
|
||||
<Element
|
||||
disabled={isDisabled}
|
||||
id={`${id}-${index}-tab`}
|
||||
aria-controls={`${id}-${index}-tabpanel`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={isDisabled ? undefined : tabIndex}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={() => {
|
||||
setSelectedTab(index!);
|
||||
}}
|
||||
to={to}
|
||||
relative={relative}
|
||||
className={mergedClassname}
|
||||
ref={(el: HTMLElement) => {
|
||||
if (tabsRef.current && el) {
|
||||
tabsRef.current[index!] = el;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
interface TextColorProps {
|
||||
isDisabled?: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
function textColor({isDisabled, isSelected}: TextColorProps): string {
|
||||
if (isDisabled) {
|
||||
return 'text-disabled cursor-default';
|
||||
}
|
||||
if (isSelected) {
|
||||
return 'text-primary';
|
||||
}
|
||||
return 'text-muted hover:text-main';
|
||||
}
|
||||
12
common/resources/client/ui/tabs/tabs-context.tsx
Executable file
12
common/resources/client/ui/tabs/tabs-context.tsx
Executable file
@@ -0,0 +1,12 @@
|
||||
import React, {RefObject} from 'react';
|
||||
|
||||
export interface TabsContext {
|
||||
selectedTab: number;
|
||||
setSelectedTab: (newTab: number) => void;
|
||||
tabsRef: RefObject<HTMLElement[]>;
|
||||
size: 'sm' | 'md';
|
||||
isLazy?: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const TabContext = React.createContext<TabsContext>(null!);
|
||||
53
common/resources/client/ui/tabs/tabs.tsx
Executable file
53
common/resources/client/ui/tabs/tabs.tsx
Executable file
@@ -0,0 +1,53 @@
|
||||
import React, {ReactElement, useId, useMemo, useRef} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {TabContext, TabsContext} from './tabs-context';
|
||||
import {TabListProps} from './tab-list';
|
||||
import {TabPanelsProps} from './tab-panels';
|
||||
|
||||
export interface TabsProps {
|
||||
children: [ReactElement<TabListProps>, ReactElement<TabPanelsProps>];
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
selectedTab?: number;
|
||||
defaultSelectedTab?: number;
|
||||
onTabChange?: (newTab: number) => void;
|
||||
isLazy?: boolean;
|
||||
overflow?: string;
|
||||
}
|
||||
|
||||
export function Tabs(props: TabsProps) {
|
||||
const {
|
||||
size = 'md',
|
||||
children,
|
||||
className,
|
||||
isLazy,
|
||||
overflow = 'overflow-hidden',
|
||||
} = props;
|
||||
|
||||
const tabsRef = useRef<HTMLButtonElement[]>([]);
|
||||
const id = useId();
|
||||
|
||||
const [selectedTab, setSelectedTab] = useControlledState(
|
||||
props.selectedTab,
|
||||
props.defaultSelectedTab || 0,
|
||||
props.onTabChange
|
||||
);
|
||||
|
||||
const ContextValue: TabsContext = useMemo(() => {
|
||||
return {
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
tabsRef,
|
||||
size,
|
||||
isLazy,
|
||||
id,
|
||||
};
|
||||
}, [selectedTab, id, isLazy, setSelectedTab, size]);
|
||||
|
||||
return (
|
||||
<TabContext.Provider value={ContextValue}>
|
||||
<div className={clsx(className, overflow, 'max-w-full')}>{children}</div>
|
||||
</TabContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user