From 8f90083b89850d1ae1aa96e44f81878389a65dfc Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Mon, 3 Nov 2025 12:44:41 -0600 Subject: [PATCH] fix(site): more reliable tabs (#153) --- site/astro.config.mjs | 3 +- site/src/components/CopyButton.tsx | 10 +- site/src/components/HomePageDemo/Base.tsx | 35 +- site/src/components/HomePageDemo/Eject.tsx | 41 +- site/src/components/Tabs.tsx | 396 ++++++++++-------- site/src/components/typography/Pre.astro | 17 +- .../content/docs/concepts/architecture.mdx | 18 +- .../content/docs/how-to/customize-skins.mdx | 64 +-- site/src/content/docs/how-to/write-guides.mdx | 86 ++-- .../docs/reference/fullscreen-button.mdx | 38 +- .../content/docs/reference/mute-button.mdx | 40 +- .../content/docs/reference/play-button.mdx | 38 +- .../content/docs/reference/time-slider.mdx | 38 +- .../content/docs/reference/volume-slider.mdx | 38 +- site/src/utils/rehypeGenerateTabsIds.js | 86 ---- site/src/utils/rehypePrepareCodeBlocks.js | 10 +- 16 files changed, 453 insertions(+), 505 deletions(-) delete mode 100644 site/src/utils/rehypeGenerateTabsIds.js diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 151d82dd..33adc7d4 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -8,7 +8,6 @@ import vercel from '@astrojs/vercel'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig, fontProviders } from 'astro/config'; -import rehypeGenerateTabsIds from './src/utils/rehypeGenerateTabsIds'; import rehypePrepareCodeBlocks from './src/utils/rehypePrepareCodeBlocks'; import remarkConditionalHeadings from './src/utils/remarkConditionalHeadings'; import { remarkReadingTime } from './src/utils/remarkReadingTime.mjs'; @@ -46,7 +45,7 @@ export default defineConfig({ transformers: [shikiTransformMetadata], }, remarkPlugins: [remarkConditionalHeadings, remarkReadingTime], - rehypePlugins: [rehypeGenerateTabsIds, rehypePrepareCodeBlocks], + rehypePlugins: [rehypePrepareCodeBlocks], }, image: { diff --git a/site/src/components/CopyButton.tsx b/site/src/components/CopyButton.tsx index c564d5ad..12f4b936 100644 --- a/site/src/components/CopyButton.tsx +++ b/site/src/components/CopyButton.tsx @@ -36,8 +36,16 @@ export default function CopyButton({ if (container) { // Find the target within that container const target = container.querySelector(copyFrom.target); - text = target?.textContent || ''; + if (target) { + text = target?.textContent || ''; + } else { + console.warn(`CopyButton: No target found for selector "${copyFrom.target}" within container "${copyFrom.container}"`); + } + } else { + console.warn(`CopyButton: No container found for selector "${copyFrom.container}"`); } + } else { + console.warn('CopyButton: buttonRef is null'); } if (text) { await navigator.clipboard.writeText(text.trim()); diff --git a/site/src/components/HomePageDemo/Base.tsx b/site/src/components/HomePageDemo/Base.tsx index 3897e713..a74c65e7 100644 --- a/site/src/components/HomePageDemo/Base.tsx +++ b/site/src/components/HomePageDemo/Base.tsx @@ -1,6 +1,6 @@ import type { Skin } from '@/stores/homePageDemos'; import { useStore } from '@nanostores/react'; -import { TabsPanel, TabsRoot } from '@/components/Tabs'; +import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs'; import { framework, skin } from '@/stores/homePageDemos'; import ClientCode from '../Code/ClientCode'; @@ -42,17 +42,17 @@ export default function BaseDemo({ className }: { className?: string }) { if ($framework === 'html') { return ( - - + + + + HTML + + JavaScript + + - + @@ -60,14 +60,13 @@ export default function BaseDemo({ className }: { className?: string }) { } return ( - - + + + + React + + + diff --git a/site/src/components/HomePageDemo/Eject.tsx b/site/src/components/HomePageDemo/Eject.tsx index ed2a81df..9d4407a5 100644 --- a/site/src/components/HomePageDemo/Eject.tsx +++ b/site/src/components/HomePageDemo/Eject.tsx @@ -1,6 +1,6 @@ import type { Skin } from '@/stores/homePageDemos'; import { useStore } from '@nanostores/react'; -import { TabsPanel, TabsRoot } from '@/components/Tabs'; +import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs'; import { framework, skin } from '@/stores/homePageDemos'; import { generateHTMLCSS, @@ -37,20 +37,21 @@ export default function EjectDemo({ className }: { className?: string }) { if ($framework === 'html') { return ( - - + + + + HTML + + CSS + JavaScript + + - + - + @@ -58,17 +59,17 @@ export default function EjectDemo({ className }: { className?: string }) { } return ( - - + + + + React + + CSS + + - + diff --git a/site/src/components/Tabs.tsx b/site/src/components/Tabs.tsx index 24c6f134..8ec0e33b 100644 --- a/site/src/components/Tabs.tsx +++ b/site/src/components/Tabs.tsx @@ -1,118 +1,66 @@ /** * 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. + * Built without context, using some unusual patterns, + * to work around some restrictions with Astro islands: + * namely, that separate islands can't share context, and that, + * depending on rendering context, Astro may render the component tree + * bottom-up or top-down */ -import type { KeyboardEvent, ReactNode } from 'react'; - -import { useStore } from '@nanostores/react'; import clsx from 'clsx'; import { Check, Copy } from 'lucide-react'; - +import { useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; -import CopyButton from '@/components/CopyButton'; -import { $tabs } from '@/stores/tabs'; import useIsHydrated from '@/utils/useIsHydrated'; +import CopyButton from './CopyButton'; 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; - + children: React.ReactNode; maxWidth?: boolean; + className?: string; + id?: string; } - -export function TabsRoot({ - id, - 'aria-label': ariaLabel, - titles, - className, - children, - maxWidth = true, -}: TabsRootProps) { +export function TabsRoot({ children, maxWidth = true, className, id: propId }: TabsRootProps) { + const ref = useRef(null); 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(); - } - }; + /** + * When this component initializes, + * it generates an ID for itself, and then + * uses that ID to + * - set [role="tab"] ID + * - set [role="tab"][aria-controls] + * - set [role="tabpanel"] ID + * - set [role="tabpanel"][aria-labelledby] + * + * This allows tabs and tabpanels to be associated + * without relying on context or parent-child relationships, + * as well as complying with WAI-ARIA authoring practices. + */ + useEffect(() => { + // I know this isHydrated check looks weird, + // but it actually delays this effect until later, + // giving tab and tabpanel elements time to mount. + if (!isHydrated) return; + const id = propId || new Date().getTime().toString(); + const tabs = ref.current?.querySelectorAll('[role="tab"]') || []; + const panels = ref.current?.querySelectorAll('[role="tabpanel"]') || []; + tabs.forEach((tab) => { + const value = tab.getAttribute('data-value'); + tab.id = `tab-${id}-${value}`; + tab.setAttribute('aria-controls', `panel-${id}-${value}`); + }); + panels.forEach((panel) => { + const value = panel.getAttribute('data-value'); + panel.id = `panel-${id}-${value}`; + panel.setAttribute('aria-labelledby', `tab-${id}-${value}`); + }); + }, [isHydrated, propId]); 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; +interface TabsListProps { + label: string; + children: React.ReactNode; +} +export function TabsList({ label, children }: TabsListProps) { + return ( +
+
    + {children} +
+ } + > + + +
+ ); } -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); - } +interface TabProps { + value: string; + children: React.ReactNode; + initial?: boolean; +} +export function Tab({ value, children, initial }: TabProps) { + const isHydrated = useIsHydrated(); + const ref = useRef(null); + const [isActive, setIsActive] = useState(initial); - // Subscribe to the tabs store - const allTabsState = useStore($tabs); - const activeValue = allTabsState[tabsId]; - const isActive = activeValue === value; ; + const onClick = () => { + if (ref.current) { + // set data-tab-active on this button to true + ref.current.setAttribute('data-tab-active', 'true'); + // set data-tab-active on all sibling buttons to false + const siblings = ref.current.closest('[data-tabs-root]')?.querySelectorAll('[role="tab"]') || []; + siblings.forEach((sibling) => { + if (sibling !== ref.current) { + sibling.setAttribute('data-tab-active', 'false'); + } + }); + // and each tab will handle updating its own React state with the effects below. + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + // Find all tab siblings within the tablist + const tabsRoot = ref.current?.closest('[data-tabs-root]'); + const allTabs = Array.from(tabsRoot?.querySelectorAll('[role="tab"]') || []) as HTMLElement[]; + const currentIndex = allTabs.indexOf(ref.current!); + + if (currentIndex === -1) return; + + let targetIndex: number | null = null; + + switch (e.key) { + case 'ArrowLeft': + // Move to previous tab, wrap to last if at start + targetIndex = currentIndex - 1; + if (targetIndex < 0) targetIndex = allTabs.length - 1; + break; + case 'ArrowRight': + // Move to next tab, wrap to first if at end + targetIndex = currentIndex + 1; + if (targetIndex >= allTabs.length) targetIndex = 0; + break; + case 'Home': + // Jump to first tab + targetIndex = 0; + break; + case 'End': + // Jump to last tab + targetIndex = allTabs.length - 1; + break; + } + + if (targetIndex !== null) { + e.preventDefault(); + const targetTab = allTabs[targetIndex]; + + // Activate the tab (reuse existing activation logic) + targetTab.click(); + + // Move focus to the tab + targetTab.focus(); + } + }; + + // since we're communicating through the DOM, not through context, + // we'll need to update isActive with a mutation observer + // that observer will listen to data-tab-active + useEffect(() => { + // on mount, let's set the initial state of data-tab-active + if (ref.current) { + ref.current.setAttribute('data-tab-active', initial ? 'true' : 'false'); + } + }, [initial]); + useEffect(() => { + // then, let's listen for the changes that our event handlers make + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'data-tab-active') { + // Fix: mutation.target is Node, cast to Element to use getAttribute + const target = mutation.target as Element; + const newValue = target.getAttribute('data-tab-active') === 'true'; + setIsActive(newValue); + } + }); + }); + if (ref.current) { + observer.observe(ref.current, { attributes: true }); + } + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
  • + +
  • + ); +} + +interface TabsPanelProps { + value: string; + children: React.ReactNode; + initial?: boolean; + className?: string; +} +export function TabsPanel({ value, children, initial, className }: TabsPanelProps) { + const ref = useRef(null); + const [isActive, setIsActive] = useState(initial); + + // Observe the corresponding Tab element's data-tab-active attribute + // to sync panel visibility with tab activation + useEffect(() => { + const tabsRoot = ref.current?.closest('[data-tabs-root]'); + const correspondingTab = tabsRoot?.querySelector(`[role="tab"][data-value="${value}"]`); + + if (!correspondingTab) return; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'data-tab-active') { + const target = mutation.target as Element; + const newValue = target.getAttribute('data-tab-active') === 'true'; + setIsActive(newValue); + } + }); + }); + + observer.observe(correspondingTab, { attributes: true }); + + return () => { + observer.disconnect(); + }; + }, [value]); return (