mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
/**
|
|
* Accessible tabs component implementing the WAI-ARIA Tabs pattern.
|
|
*
|
|
* Reference: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
*
|
|
* Built as a custom component instead of using Base UI because Astro islands
|
|
* cannot share React Context across separate component instances. We use
|
|
* nanostores for cross-island state management instead.
|
|
*
|
|
* Oh, and by the way. The first key in the titles object OR the first panel
|
|
* will be used as the default active tab. Ensure that TabsPanel children
|
|
* are provided in the same order for predictable behavior.
|
|
*/
|
|
|
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
|
|
import { useStore } from '@nanostores/react';
|
|
import clsx from 'clsx';
|
|
import { Check, Copy } from 'lucide-react';
|
|
|
|
import { twMerge } from 'tailwind-merge';
|
|
import CopyButton from '@/components/CopyButton';
|
|
import { $tabs } from '@/stores/tabs';
|
|
import useIsHydrated from '@/utils/useIsHydrated';
|
|
|
|
interface TabsRootProps {
|
|
/** Unique ID for this tabs instance. Required for both Astro and React usage. Generated by Rehype in MDX */
|
|
id: string;
|
|
/** Accessible label for the tablist */
|
|
'aria-label': string;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
/**
|
|
* TabsPanel children.
|
|
* Order of children should match the order of keys in titles object.
|
|
* The first key in titles or the first panel will be the default active tab.
|
|
*/
|
|
children: ReactNode;
|
|
/**
|
|
* Map of tab values to their display labels.
|
|
* Order of children should match the order of keys in this object.
|
|
* The first key in titles or the first panel will be the default active tab.
|
|
*/
|
|
titles: Record<string, string>;
|
|
|
|
maxWidth?: boolean;
|
|
}
|
|
|
|
export function TabsRoot({
|
|
id,
|
|
'aria-label': ariaLabel,
|
|
titles,
|
|
className,
|
|
children,
|
|
maxWidth = true,
|
|
}: TabsRootProps) {
|
|
const isHydrated = useIsHydrated();
|
|
|
|
// Derive default from first item in titles
|
|
// This assumes titles keys and TabsPanel children are in the same order...
|
|
// Which is... unfortunate. But. Astro's gonna astro. It's hard to communicate between components.
|
|
const defaultValue = Object.keys(titles)[0];
|
|
if (!defaultValue) {
|
|
throw new Error('TabsRoot requires at least one item in titles.');
|
|
}
|
|
|
|
const currentState = $tabs.get();
|
|
if (currentState[id] === undefined) {
|
|
$tabs.setKey(id, defaultValue);
|
|
}
|
|
|
|
// Subscribe to the tabs store
|
|
const allTabsState = useStore($tabs);
|
|
|
|
// Get current active value, or use first item as fallback
|
|
const activeValue = allTabsState[id] ?? defaultValue;
|
|
|
|
const handleTabClick = (value: string) => {
|
|
$tabs.setKey(id, value);
|
|
};
|
|
|
|
const values = Object.keys(titles);
|
|
|
|
const handleKeyDown = (e: KeyboardEvent, currentIndex: number) => {
|
|
let newIndex: number | null = null;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
newIndex = currentIndex - 1;
|
|
if (newIndex < 0) newIndex = values.length - 1; // Circular
|
|
break;
|
|
case 'ArrowRight':
|
|
newIndex = currentIndex + 1;
|
|
if (newIndex >= values.length) newIndex = 0; // Circular
|
|
break;
|
|
case 'Home':
|
|
newIndex = 0;
|
|
break;
|
|
case 'End':
|
|
newIndex = values.length - 1;
|
|
break;
|
|
}
|
|
|
|
if (newIndex !== null) {
|
|
e.preventDefault();
|
|
const newValue = values[newIndex];
|
|
$tabs.setKey(id, newValue);
|
|
// Focus the new tab
|
|
const newTabElement = document.getElementById(`${id}-tab-${newValue}`);
|
|
newTabElement?.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
twMerge(
|
|
clsx(
|
|
'rounded-lg overflow-hidden border border-light-40 dark:border-dark-80',
|
|
'bg-light-100 dark:bg-dark-110 flex flex-col',
|
|
'my-6',
|
|
maxWidth && 'w-full max-w-3xl mx-auto',
|
|
className,
|
|
),
|
|
)
|
|
}
|
|
id={id}
|
|
>
|
|
<div className="w-full border-b border-light-40 dark:border-dark-80 flex bg-light-80 dark:bg-dark-100 overflow-x-scroll not-content">
|
|
<ul role="tablist" aria-label={ariaLabel} className="flex list-none p-0 m-0">
|
|
{values.map((value, index) => {
|
|
const isActive = value === activeValue;
|
|
const isLoading = !isHydrated && !isActive;
|
|
|
|
return (
|
|
<li key={value} role="presentation" className="flex">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
id={`${id}-tab-${value}`}
|
|
aria-selected={isActive}
|
|
aria-controls={`${id}-panel-${value}`}
|
|
tabIndex={isActive ? 0 : -1}
|
|
onClick={() => handleTabClick(value)}
|
|
onKeyDown={e => handleKeyDown(e, index)}
|
|
className={clsx(
|
|
'flex items-center h-9 px-4 py-2 text-sm',
|
|
'border-x border-light-40 dark:border-dark-80',
|
|
'first:-ml-px last:-mr-px -mx-[0.5px] no-underline',
|
|
isActive
|
|
? 'bg-light-100 dark:bg-dark-110'
|
|
: 'bg-light-80 dark:bg-dark-100',
|
|
isLoading ? 'cursor-wait' : 'cursor-pointer intent:bg-light-100 dark:intent:bg-dark-110',
|
|
)}
|
|
>
|
|
{titles[value]}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<CopyButton
|
|
copyFrom={{
|
|
container: `#${id}`,
|
|
target: '[role="tabpanel"]:not([hidden])',
|
|
}}
|
|
className="ml-auto sticky right-0 border-l border-light-40 dark:border-dark-80 h-9 w-9 flex items-center justify-center not-disabled:intent:bg-light-100 dark:not-disabled:intent:bg-dark-110 cursor-pointer disabled:cursor-wait"
|
|
copied={<Check size={16} />}
|
|
>
|
|
<Copy size={16} />
|
|
</CopyButton>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TabsPanelProps {
|
|
/** Unique ID matching the parent TabsRoot. Required for both Astro and React usage. Generated by Rehype in MDX */
|
|
tabsId: string;
|
|
/** The value this panel corresponds to */
|
|
value: string;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
/** Panel content */
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function TabsPanel({ tabsId, value, className, children }: TabsPanelProps) {
|
|
// Initialize tabs store if necessary
|
|
// Astro SOMETIMES renders TabsPanel before TabsRoot, unfortunately
|
|
// so, in this case, let's assume this is the first TabsPanel in order
|
|
// and set ourselves as the active one
|
|
// I hate this, but it seems to work.
|
|
const currentState = $tabs.get();
|
|
if (currentState[tabsId] === undefined) {
|
|
$tabs.setKey(tabsId, value);
|
|
}
|
|
|
|
// Subscribe to the tabs store
|
|
const allTabsState = useStore($tabs);
|
|
const activeValue = allTabsState[tabsId];
|
|
const isActive = activeValue === value; ;
|
|
|
|
return (
|
|
<div
|
|
role="tabpanel"
|
|
id={`${tabsId}-panel-${value}`}
|
|
aria-labelledby={`${tabsId}-tab-${value}`}
|
|
hidden={!isActive}
|
|
className={clsx('overflow-scroll p-6 max-h-96 flex-1', className)}
|
|
tabIndex={0}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|