/** * 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; 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 (
    {values.map((value, index) => { const isActive = value === activeValue; const isLoading = !isHydrated && !isActive; return (
  • ); })}
} >
{children}
); } 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 ( ); }