feat(site): tabs (#144)

This commit is contained in:
Darius Cepulis
2025-10-29 12:43:03 -05:00
committed by GitHub
parent 419911f2f2
commit df4692dbda
35 changed files with 999 additions and 304 deletions
+26
View File
@@ -0,0 +1,26 @@
import type { SharedProps } from './Shared';
import css from 'shiki/langs/css.mjs';
import html from 'shiki/langs/html.mjs';
import javascript from 'shiki/langs/javascript.mjs';
import tsx from 'shiki/langs/tsx.mjs';
import createHighlighter from './createHighlighter';
import Shared from './Shared';
// eslint-disable-next-line antfu/no-top-level-await
const clientHighlighter = await createHighlighter({
langs: [html, tsx, css, javascript],
});
/**
* Renders HTML, TSX, CSS, and JavaScript. A strict subset, for lighter-weight client shipping
*
* Renders with top-level await, so, it's safe for ssr (client:idle or client:load)
* However, if you try importing more than one island with ClientCode in it,
* Safari MAY throw a hydration error because of this top-level await.
* https://github.com/withastro/astro/issues/10055
* consolidate the ClientCodes into a single island to work around... for now :(
*/
export default function ClientCode(props: Omit<SharedProps, 'highlighter'>) {
return <Shared {...props} highlighter={clientHighlighter} />;
}
+10
View File
@@ -0,0 +1,10 @@
---
import type { SharedProps } from './Shared';
import Shared from './Shared';
import serverHighlighter from './serverHighlighter';
type Props = Omit<SharedProps, 'highlighter'>;
const props = Astro.props;
---
<Shared {...props} highlighter={serverHighlighter} />
@@ -1,29 +1,14 @@
import type { Highlighter } from 'shiki';
import type { BundledLanguage, Highlighter } from 'shiki';
import clsx from 'clsx';
import { createHighlighter, hastToHtml } from 'shiki';
import html from 'shiki/langs/html.mjs';
import tsx from 'shiki/langs/tsx.mjs';
import gruvboxDarkSoft from 'shiki/themes/gruvbox-dark-soft.mjs';
import gruvboxLightHard from 'shiki/themes/gruvbox-light-hard.mjs';
import { hastToHtml } from 'shiki';
// If you try importing more than one island with ClientCode in it,
// Safari MAY throw a hydration error because of this top-level await.
// https://github.com/withastro/astro/issues/10055
// consolidate the ClientCodes into a single island to work around... for now :(
// eslint-disable-next-line antfu/no-top-level-await
const highlighter: Highlighter = await createHighlighter({
themes: [gruvboxLightHard, gruvboxDarkSoft],
langs: [html, tsx],
});
export interface ClientCodeProps {
export interface SharedProps {
code: string;
lang: 'html' | 'tsx';
className?: string;
lang: BundledLanguage;
highlighter: Highlighter;
}
export default function ClientCode({ code, lang, className }: ClientCodeProps) {
export default function Shared({ code, lang, highlighter }: SharedProps) {
const hast = highlighter.codeToHast(code, {
lang,
themes: {
@@ -54,9 +39,7 @@ export default function ClientCode({ code, lang, className }: ClientCodeProps) {
const { class: codeClassName } = codeProps;
return (
<pre
className={clsx('rounded-lg p-6 overflow-x-auto overflow-y-scroll max-h-96 border border-light-40 dark:border-dark-80 bg-light-100 dark:bg-dark-110', preClassName, className)}
>
<pre className={clsx('shiki', preClassName)}>
<code
className={clsx('font-mono text-code', codeClassName)}
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
@@ -0,0 +1,14 @@
import { createHighlighter as libCreateHighlighter } from 'shiki';
import gruvboxDarkSoft from 'shiki/themes/gruvbox-dark-soft.mjs';
import gruvboxLightHard from 'shiki/themes/gruvbox-light-hard.mjs';
export default function createHighlighter(
config: Omit<Parameters<typeof libCreateHighlighter>[0], 'themes'>,
) {
return libCreateHighlighter(
{
...config,
themes: [gruvboxLightHard, gruvboxDarkSoft],
},
);
}
@@ -0,0 +1,9 @@
import { bundledLanguages } from 'shiki';
import createHighlighter from './createHighlighter';
// eslint-disable-next-line antfu/no-top-level-await
const serverHighlighter = await createHighlighter({
langs: Object.values(bundledLanguages),
});
// TODO memory leak?
export default serverHighlighter;
+67
View File
@@ -0,0 +1,67 @@
import { useRef, useState } from 'react';
import useIsHydrated from '@/utils/useIsHydrated';
export interface CopyButtonProps {
children: React.ReactNode;
copied?: React.ReactNode; // Optional, passed via slot="copied" in Astro
copyFrom: {
container: string; // CSS selector for parent container (e.g., 'starlight-tabs')
target: string; // CSS selector for content element (e.g., '[role="tabpanel"]:not([hidden])')
};
className?: string;
style?: React.CSSProperties;
timeout?: number;
}
export default function CopyButton({
children,
copied,
copyFrom,
className,
style,
timeout = 2000,
}: CopyButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [isCopied, setIsCopied] = useState(false);
const isHydrated = useIsHydrated();
const disabled = !isHydrated;
const handleCopy = async () => {
try {
let text = '';
if (buttonRef.current) {
// Find the closest container
const container = buttonRef.current.closest(copyFrom.container);
if (container) {
// Find the target within that container
const target = container.querySelector(copyFrom.target);
text = target?.textContent || '';
}
}
if (text) {
await navigator.clipboard.writeText(text.trim());
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, timeout);
}
} catch (error) {
console.error('Failed to copy text:', error);
}
};
return (
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={handleCopy}
className={className}
style={style}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
>
{isCopied ? (copied || children) : children}
</button>
);
}
+40 -10
View File
@@ -1,7 +1,8 @@
import type { Media, Skin } from '@/stores/homePageDemos';
import { useStore } from '@nanostores/react';
import { TabsPanel, TabsRoot } from '@/components/Tabs';
import { framework, media, skin } from '@/stores/homePageDemos';
import ClientCode from '../ClientCode';
import ClientCode from '../Code/ClientCode';
function generateHTMLCode(skin: Skin, media: Media): string {
const skinTag = `${skin}-skin`;
@@ -35,21 +36,50 @@ export const VideoPlayer = () => {
};`;
}
interface Props {
className?: string;
function generateCSS(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
export default function BaseDemo({ className }: Props) {
function generateJS(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
export default function BaseDemo({ className }: { className?: string }) {
const $framework = useStore(framework);
const $skin = useStore(skin);
const $media = useStore(media);
const code = $framework === 'html'
? generateHTMLCode($skin, $media)
: generateReactCode($skin, $media);
const lang = $framework === 'html' ? 'html' : 'tsx';
if ($framework === 'html') {
return (
<TabsRoot
id="base-html"
aria-label="HTML implementation"
titles={{ html: 'HTML', css: 'CSS', javascript: 'JavaScript' }}
className={className}
>
<TabsPanel tabsId="base-html" value="html">
<ClientCode code={generateHTMLCode($skin, $media)} lang="html" />
</TabsPanel>
<TabsPanel tabsId="base-html" value="css">
<ClientCode code={generateCSS($skin, $media)} lang="css" />
</TabsPanel>
<TabsPanel tabsId="base-html" value="javascript">
<ClientCode code={generateJS($skin, $media)} lang="javascript" />
</TabsPanel>
</TabsRoot>
);
}
return (
<ClientCode code={code} lang={lang} className={className} />
<TabsRoot
id="base-react"
aria-label="React implementation"
titles={{ react: 'React' }}
className={className}
>
<TabsPanel tabsId="base-react" value="react">
<ClientCode code={generateReactCode($skin, $media)} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
}
+51 -16
View File
@@ -1,33 +1,68 @@
import type { Media, Skin } from '@/stores/homePageDemos';
import { useStore } from '@nanostores/react';
import { TabsPanel, TabsRoot } from '@/components/Tabs';
import { framework, media, skin } from '@/stores/homePageDemos';
import ClientCode from '../ClientCode';
import ClientCode from '../Code/ClientCode';
// eslint-disable-next-line unused-imports/no-unused-vars
function generateHTMLCode(skin: Skin, media: Media): string {
return `Coming soon`;
function generateHTMLCode(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
// eslint-disable-next-line unused-imports/no-unused-vars
function generateReactCode(skin: Skin, media: Media): string {
return `Coming soon`;
function generateReactCode(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
interface Props {
className?: string;
function generateCSSModuleCode(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
export default function EjectDemo({ className }: Props) {
function generateCSS(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
function generateJS(_skin: Skin, _media: Media): string {
return 'Coming soon';
}
export default function EjectDemo({ className }: { className?: string }) {
const $framework = useStore(framework);
const $skin = useStore(skin);
const $media = useStore(media);
const code = $framework === 'html'
? generateHTMLCode($skin, $media)
: generateReactCode($skin, $media);
const lang = $framework === 'html' ? 'html' : 'tsx';
if ($framework === 'html') {
return (
<TabsRoot
id="eject-html"
aria-label="HTML implementation"
titles={{ html: 'HTML', css: 'CSS', javascript: 'JavaScript' }}
className={className}
>
<TabsPanel tabsId="eject-html" value="html">
<ClientCode code={generateHTMLCode($skin, $media)} lang="html" />
</TabsPanel>
<TabsPanel tabsId="eject-html" value="css">
<ClientCode code={generateCSS($skin, $media)} lang="css" />
</TabsPanel>
<TabsPanel tabsId="eject-html" value="javascript">
<ClientCode code={generateJS($skin, $media)} lang="javascript" />
</TabsPanel>
</TabsRoot>
);
}
return (
<ClientCode code={code} lang={lang} className={className} />
<TabsRoot
id="eject-react"
aria-label="React implementation"
titles={{ react: 'React', css: 'CSS Module' }}
className={className}
>
<TabsPanel tabsId="eject-react" value="react">
<ClientCode code={generateReactCode($skin, $media)} lang="tsx" />
</TabsPanel>
<TabsPanel tabsId="eject-react" value="css">
<ClientCode code={generateCSSModuleCode($skin, $media)} lang="css" />
</TabsPanel>
</TabsRoot>
);
}
+2 -2
View File
@@ -14,14 +14,14 @@ export default function HomePageDemo({ className }: Props) {
<h2 className="text-h5 font-semibold lg:text-h4 mb-1">Assemble your player</h2>
<p>Feel at home with your framework, skin, and media source</p>
</header>
<BaseDemo className="lg:h-91" />
<BaseDemo className="lg:h-100" />
</section>
<section className="grid grid-rows-subgrid row-span-2 mb-6">
<header className="max-w-3xl">
<h2 className="text-h5 font-semibold lg:text-h4 mb-1">Take full control</h2>
<p>Make your player truly your own with fully-editable components</p>
</header>
<EjectDemo className="lg:h-91" />
<EjectDemo className="lg:h-100" />
</section>
</section>
);
+1 -2
View File
@@ -77,8 +77,7 @@ export function Select<T extends string = string>({
disabled={option.disabled}
className={clsx(
'flex items-center gap-2 p-2',
'cursor-pointer intent:bg-light-80 dark:intent:bg-dark-100',
option.disabled && 'opacity-50 cursor-not-allowed',
option.disabled ? 'opacity-50 cursor-default' : 'cursor-pointer intent:bg-light-80 dark:intent:bg-dark-100',
option.value === value && 'bg-light-80 dark:bg-dark-100',
)}
>
-28
View File
@@ -1,28 +0,0 @@
---
import type { ComponentProps } from 'astro/types';
import { Code } from 'astro:components';
import Pre from './typography/Pre.astro';
import { twMerge } from 'tailwind-merge';
interface Props extends Omit<ComponentProps<typeof Code>, 'class'> {
maxWidth?: ComponentProps<typeof Pre>['maxWidth'];
wrapperClass?: ComponentProps<typeof Pre>['class'];
codeClass?: ComponentProps<typeof Code>['class'];
}
const { code, lang, maxWidth = true, themes, wrapperClass, codeClass, style, ...codeProps } = Astro.props;
---
<Pre as="div" maxWidth={maxWidth} class={wrapperClass}>
<Code
code={code}
lang={lang}
themes={themes ?? {
light: 'gruvbox-light-hard',
dark: 'gruvbox-dark-medium',
}}
class={twMerge('font-mono text-code', codeClass)}
style={style ? `${style};` : '' + 'overflow-x:visible!important;background:none!important'}
{...codeProps}
/>
</Pre>
+217
View File
@@ -0,0 +1,217 @@
/**
* 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>
);
}
-2
View File
@@ -1,4 +1,3 @@
import clsx from 'clsx';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
@@ -91,7 +90,6 @@ export function ThemeToggle() {
value={preference ? [preference] : []}
onChange={(values) => { if (values.length > 0) setPreference(values[0]); }}
options={themeOptions}
toggleClassName={clsx(preference === null && 'cursor-wait')}
/>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export default function ToggleGroup<T extends string = string>({
className={twMerge(clsx(
'relative',
'flex items-center gap-1.5 px-2.5 py-1.5 rounded text-sm',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isDisabled ? 'cursor-wait opacity-50' : 'cursor-pointer',
isPressed ? 'bg-light-80 dark:bg-dark-100' : !isDisabled ? 'intent:bg-light-80/50 dark:intent:bg-dark-100/50' : '',
), toggleClassName)}
aria-label={option['aria-label']}
+17 -1
View File
@@ -12,4 +12,20 @@ const { framework } = Astro.params;
const shouldRender = !frameworks || frameworks.includes(framework as SupportedFramework);
---
{shouldRender && <slot />}
{
/*
What I WANT to do is
```
{shouldRender && <slot />}
```
But I'm running into some crazy problems with hydration.
Putting client:load in one of these conditionals causes Astro to just give up hydrating the whole app for some reason.
TODO: fix this
So, while I debug those... let's do this
*/
}
<div class="contents" hidden={!shouldRender}>
<slot />
</div>
+17 -1
View File
@@ -12,4 +12,20 @@ const { style } = Astro.params;
const shouldRender = !styles || styles.includes(style as AnySupportedStyle);
---
{shouldRender && <slot />}
{
/*
What I WANT to do is
```
{shouldRender && <slot />}
```
But I'm running into some crazy problems with hydration.
Putting client:load in one of these conditionals causes Astro to just give up hydrating the whole app for some reason.
TODO: fix this
So, while I debug those... let's do this
*/
}
<div class="contents" hidden={!shouldRender}>
<slot />
</div>
@@ -22,7 +22,7 @@ const isCodeBlock = codeBlock === 'true';
) : (
<Tag
class={twMerge(
'bg-light-60 dark:bg-dark-90 dark:text-light-100 border border-light-40 dark:border-dark-80 px-1 rounded font-mono text-code',
'bg-light-100 dark:bg-dark-110 dark:text-light-100 border border-light-40 dark:border-dark-80 px-1 rounded font-mono text-code',
className,
)}
{...props}
+30 -13
View File
@@ -1,23 +1,40 @@
---
import type { HTMLTag, Polymorphic } from 'astro/types';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { TabsRoot, TabsPanel } from '@/components/Tabs.tsx';
type Props<Tag extends HTMLTag = 'pre'> = Polymorphic<{ as: Tag }> & {
maxWidth?: boolean;
class?: string;
hasFrame?: boolean;
title?: string;
'data-tabs-id'?: string;
};
const { as: Tag = 'pre', maxWidth = true, class: className, style: _style, ...props } = Astro.props;
const { as: Tag = 'pre', maxWidth = false, class: className, style: _style, title, hasFrame, ...props } = Astro.props;
const language = props['data-language'];
const label = title || language || 'code';
// Use stable ID from rehype plugin (generated at build time)
const tabsId = props['data-tabs-id'] || '';
if (tabsId === '' && !hasFrame) {
throw new Error('Pre component requires a stable "data-tabs-id" prop when not used inside tabs.');
}
const value = 'code';
---
<div class={twMerge(clsx('relative my-6 group', maxWidth && 'max-w-3xl mx-auto'))}>
<Tag
class={twMerge(
'rounded-lg p-6 overflow-scroll max-h-96 border border-light-40 dark:border-dark-80 bg-light-100 dark:bg-dark-110',
className,
)}
{/* weird formatting to prevent leading whitespace */}
{...props}><slot /></Tag
>
</div>
{
hasFrame ? (
<Tag class={className} {...props}>
<slot />
</Tag>
) : (
<TabsRoot id={tabsId} aria-label={`${label} code`} titles={{ [value]: label }} client:load>
<TabsPanel tabsId={tabsId} value={value} client:load>
<Tag class={className} {...props}>
<slot />
</Tag>
</TabsPanel>
</TabsRoot>
)
}