first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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';
}

View 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!);

View 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>
);
}