mirror of
https://github.com/zoriya/v10.git
synced 2026-08-07 14:48:09 +00:00
feat(site): tabs (#144)
This commit is contained in:
@@ -8,9 +8,11 @@ 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';
|
||||
import shikiTransformMetadata from './src/utils/shikiTransformMetadata';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -38,10 +40,11 @@ export default defineConfig({
|
||||
light: 'gruvbox-light-hard',
|
||||
dark: 'gruvbox-dark-medium',
|
||||
},
|
||||
// TODO shiki transformers
|
||||
// TODO more shiki transformers
|
||||
transformers: [shikiTransformMetadata],
|
||||
},
|
||||
remarkPlugins: [remarkConditionalHeadings, remarkReadingTime],
|
||||
rehypePlugins: [rehypePrepareCodeBlocks],
|
||||
rehypePlugins: [rehypeGenerateTabsIds, rehypePrepareCodeBlocks],
|
||||
},
|
||||
|
||||
image: {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ description: 'A guide on writing documentation for the Video.js project, and a t
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import StyleCase from '@/components/docs/StyleCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs.tsx';
|
||||
|
||||
## What kind of guide are you writing?
|
||||
I haven't written this section yet, but when I do, it'll rehash [Diátaxis](https://diataxis.fr/).
|
||||
@@ -32,50 +33,17 @@ will render:
|
||||
Use the `<StyleCase>` component to show content only for specific styling approaches. For example,
|
||||
|
||||
```mdx
|
||||
<StyleCase styles={["tailwind"]}>
|
||||
Tailwind-only content
|
||||
<StyleCase styles={["css"]}>
|
||||
Css-only content
|
||||
</StyleCase>
|
||||
```
|
||||
|
||||
will render:
|
||||
|
||||
<StyleCase styles={["tailwind"]}>
|
||||
Tailwind-only content
|
||||
<StyleCase styles={["css"]}>
|
||||
Css-only content
|
||||
</StyleCase>
|
||||
|
||||
## Displaying Code from Files
|
||||
|
||||
Use the `<ServerCode>` component to display code imported from source files with syntax highlighting. Supports any language that [Shiki supports](https://shiki.style/languages).
|
||||
|
||||
```mdx
|
||||
import exampleCode from '@/examples/react/Example.tsx?raw';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
|
||||
<ServerCode code={exampleCode} lang="tsx" />
|
||||
```
|
||||
|
||||
will render:
|
||||
|
||||
<ServerCode code={`import { useState } from 'react';
|
||||
|
||||
export function Example() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
||||
}`} lang="tsx" />
|
||||
|
||||
## Wrapping Live Demos
|
||||
|
||||
Use the `<Container>` component to constrain live demos to a readable width.
|
||||
|
||||
```mdx
|
||||
import { MyDemo } from '@/examples/react/MyDemo';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
|
||||
<Container>
|
||||
<MyDemo client:load />
|
||||
</Container>
|
||||
```
|
||||
|
||||
## Use Github-Flavored Markdown
|
||||
|
||||
### Headings
|
||||
@@ -279,3 +247,154 @@ Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
|
||||
|
||||
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
|
||||
|
||||
## Code and Code Frames
|
||||
|
||||
### Default frame
|
||||
|
||||
Regular markdown code blocks automatically get wrapped in tabs with a copy button:
|
||||
|
||||
````markdown
|
||||
```ts
|
||||
console.log('Hello, TypeScript!');
|
||||
```
|
||||
````
|
||||
Renders
|
||||
```ts
|
||||
console.log('Hello, TypeScript!');
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
Use `<TabsRoot>` and `<TabsPanel>` to show multiple code examples side-by-side:
|
||||
|
||||
````mdx
|
||||
<TabsRoot
|
||||
aria-label="Code examples"
|
||||
titles={{ typescript: 'TypeScript', javascript: 'JavaScript' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="typescript" client:load>
|
||||
```ts
|
||||
console.log('Hello, TypeScript!');
|
||||
```
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="javascript" client:load>
|
||||
```js
|
||||
console.log('Hello, JavaScript!');
|
||||
```
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
````
|
||||
Which renders
|
||||
<TabsRoot
|
||||
aria-label="Code examples"
|
||||
titles={{ typescript: 'TypeScript', javascript: 'JavaScript' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="typescript" client:load>
|
||||
```ts
|
||||
console.log('Hello, TypeScript!');
|
||||
```
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="javascript" client:load>
|
||||
```js
|
||||
console.log('Hello, JavaScript!');
|
||||
```
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
|
||||
**Important notes:**
|
||||
- You might've noticed that elsewhere in the codebase, TabsRoot requires `id` and TabsPanel requires `tabsId`. In MDX, these are generated for you with a rehype plugin
|
||||
- Both `<TabsRoot>` and `<TabsPanel>` require `client:load` directive in MDX
|
||||
- The first key in `titles` will be the default active tab. Your panels should be in the same order as the keys in `titles`.
|
||||
- Use descriptive `aria-label` for accessibility
|
||||
|
||||
|
||||
## Displaying Code from Files
|
||||
|
||||
Use the `<ServerCode>` component to display code imported from source files with syntax highlighting. Supports any language that [Shiki supports](https://shiki.style/languages).
|
||||
|
||||
**Important:** Unlike regular markdown code blocks, `<ServerCode>` does **not** automatically get wrapped in a frame. You should use `TabsRoot` with a single `TabPanel` to make it look pretty
|
||||
|
||||
```mdx
|
||||
import componentCode from '@/examples/react/Component.tsx?raw';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
|
||||
<TabsRoot
|
||||
aria-label="Component implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentCode} lang="tsx" />
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
```
|
||||
|
||||
Which will happily render
|
||||
|
||||
<TabsRoot
|
||||
aria-label="Component implementation"
|
||||
titles={{ component: 'Component' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={`import React from 'react';
|
||||
|
||||
function Component() {
|
||||
return <div>Hello, world!</div>;
|
||||
}`} lang="tsx" />
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
|
||||
## Wrapping Live Demos
|
||||
|
||||
There are two main patterns for displaying live demos, depending on whether you want to show code alongside the demo.
|
||||
|
||||
### Option 1: Standalone Demo with Container
|
||||
|
||||
Use the `<Container>` component to constrain standalone demos to a readable width:
|
||||
|
||||
```mdx
|
||||
import { MyDemo } from '@/examples/react/MyDemo';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
|
||||
<Container>
|
||||
<MyDemo client:load />
|
||||
</Container>
|
||||
```
|
||||
|
||||
This is best for demos that don't need accompanying code, or when the code is shown separately elsewhere on the page.
|
||||
|
||||
### Option 2: Demo with Code in TabsRoot
|
||||
|
||||
Place the demo directly inside `<TabsRoot>` (as a sibling to `<TabsPanel>` elements) to keep code and demo together:
|
||||
|
||||
```mdx
|
||||
import { MyDemo } from '@/examples/react/MyDemo';
|
||||
import componentCode from '@/examples/react/MyDemo.tsx?raw';
|
||||
import cssCode from '@/examples/react/MyDemo.module.css?raw';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
|
||||
<TabsRoot
|
||||
aria-label="MyDemo implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentCode} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssCode} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<MyDemo client:load />
|
||||
</TabsRoot>
|
||||
```
|
||||
|
||||
The demo will appear at the bottom of the tabs component, creating a cohesive unit of code and preview. This pattern is used throughout our resource documentation (see [PlayButton](/docs/framework/react/style/css/resources/play-button) for an example).
|
||||
@@ -13,7 +13,8 @@ import htmlCssStr from '@/examples/html/fullscreen-button/fullscreen-button.css?
|
||||
import htmlJsStr from '@/examples/html/fullscreen-button/fullscreen-button.js?raw';
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
|
||||
## Features
|
||||
|
||||
@@ -22,38 +23,46 @@ import ServerCode from '@/components/ServerCode.astro';
|
||||
- Falls back gracefully when fullscreen not supported
|
||||
- Accessible keyboard navigation
|
||||
|
||||
## Live Example
|
||||
|
||||
<Container>
|
||||
<FullscreenButtonDemo client:load />
|
||||
</Container>
|
||||
|
||||
## Usage
|
||||
## Example
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
<TabsRoot
|
||||
aria-label="React implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
### Component
|
||||
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
|
||||
### CSS Module
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<FullscreenButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
<TabsRoot
|
||||
aria-label="HTML implementation"
|
||||
titles={{ html: 'HTML', css: 'CSS', javascript: 'JS' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="html" client:load>
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
### HTML
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
|
||||
### CSS
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
|
||||
### JavaScript
|
||||
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
<TabsPanel value="javascript" client:load>
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
</TabsPanel>
|
||||
|
||||
<FullscreenButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
## Data Attributes
|
||||
|
||||
@@ -13,7 +13,8 @@ import htmlCssStr from '@/examples/html/mute-button/mute-button.css?raw';
|
||||
import htmlJsStr from '@/examples/html/mute-button/mute-button.js?raw';
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
|
||||
## Features
|
||||
|
||||
@@ -22,41 +23,50 @@ import ServerCode from '@/components/ServerCode.astro';
|
||||
- Toggles mute/unmute on click
|
||||
- Accessible keyboard navigation
|
||||
|
||||
## Live Example
|
||||
|
||||
<Container>
|
||||
<MuteButtonDemo client:load />
|
||||
</Container>
|
||||
|
||||
## Usage
|
||||
## Example
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
<TabsRoot
|
||||
aria-label="React implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
### Component
|
||||
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
|
||||
### CSS Module
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<MuteButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
<TabsRoot
|
||||
aria-label="HTML implementation"
|
||||
titles={{ html: 'HTML', css: 'CSS', javascript: 'JS' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="html" client:load>
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
### HTML
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
|
||||
### CSS
|
||||
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
|
||||
### JavaScript
|
||||
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
<TabsPanel value="javascript" client:load>
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
</TabsPanel>
|
||||
|
||||
<MuteButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
|
||||
|
||||
## Data Attributes
|
||||
|
||||
The MuteButton automatically sets data attributes based on volume level:
|
||||
|
||||
@@ -13,7 +13,8 @@ import htmlCssStr from '@/examples/html/play-button/play-button.css?raw';
|
||||
import htmlJsStr from '@/examples/html/play-button/play-button.js?raw';
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
|
||||
## Features
|
||||
|
||||
@@ -22,40 +23,46 @@ import ServerCode from '@/components/ServerCode.astro';
|
||||
- Accessible keyboard navigation
|
||||
- Works with any media element
|
||||
|
||||
## Live Example
|
||||
|
||||
<Container>
|
||||
<PlayButtonDemo client:load />
|
||||
</Container>
|
||||
|
||||
## Usage
|
||||
## Example
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
<TabsRoot
|
||||
aria-label="React implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
### Component
|
||||
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
|
||||
### CSS Module
|
||||
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<PlayButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
<TabsRoot
|
||||
aria-label="HTML implementation"
|
||||
titles={{ html: 'HTML', css: 'CSS', javascript: 'JS' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="html" client:load>
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
### HTML
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<ServerCode code={htmlStr} lang="html" />
|
||||
|
||||
### CSS
|
||||
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
|
||||
### JavaScript
|
||||
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
<TabsPanel value="javascript" client:load>
|
||||
<ServerCode code={htmlJsStr} lang="js" />
|
||||
</TabsPanel>
|
||||
|
||||
<PlayButtonDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
## Data Attributes
|
||||
|
||||
@@ -13,7 +13,8 @@ import htmlVerticalStr from '@/examples/html/time-slider/snippet-vertical.html?r
|
||||
import htmlCssStr from '@/examples/html/time-slider/time-slider.css?raw';
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
|
||||
## Features
|
||||
|
||||
@@ -23,40 +24,46 @@ import ServerCode from '@/components/ServerCode.astro';
|
||||
- Keyboard accessible (Arrow keys for seeking)
|
||||
- Touch-friendly drag interaction
|
||||
|
||||
## Live Example
|
||||
|
||||
<Container>
|
||||
<TimeSliderDemo client:load />
|
||||
</Container>
|
||||
|
||||
## Usage
|
||||
## Example
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
<TabsRoot
|
||||
aria-label="React implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
### Component
|
||||
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
|
||||
### CSS Module
|
||||
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<TimeSliderDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
<TabsRoot
|
||||
aria-label="HTML implementation"
|
||||
titles={{ 'html-horizontal': 'HTML (Horizontal)', 'html-vertical': 'HTML (Vertical)', css: 'CSS' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="html-horizontal" client:load>
|
||||
<ServerCode code={htmlHorizontalStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
### Horizontal Orientation
|
||||
<TabsPanel value="html-vertical" client:load>
|
||||
<ServerCode code={htmlVerticalStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
<ServerCode code={htmlHorizontalStr} lang="html" />
|
||||
|
||||
### Vertical Orientation
|
||||
|
||||
<ServerCode code={htmlVerticalStr} lang="html" />
|
||||
|
||||
### CSS
|
||||
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<TimeSliderDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
|
||||
@@ -13,7 +13,8 @@ import htmlVerticalStr from '@/examples/html/volume-slider/snippet-vertical.html
|
||||
import htmlCssStr from '@/examples/html/volume-slider/volume-slider.css?raw';
|
||||
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
|
||||
import Container from '@/components/docs/Container.astro';
|
||||
import ServerCode from '@/components/ServerCode.astro';
|
||||
import ServerCode from '@/components/Code/ServerCode.astro';
|
||||
import { TabsRoot, TabsPanel } from '@/components/Tabs';
|
||||
|
||||
## Features
|
||||
|
||||
@@ -23,40 +24,46 @@ import ServerCode from '@/components/ServerCode.astro';
|
||||
- Keyboard accessible (Arrow keys for volume adjustment)
|
||||
- Touch-friendly drag interaction
|
||||
|
||||
## Live Example
|
||||
|
||||
<Container>
|
||||
<VolumeSliderDemo client:load />
|
||||
</Container>
|
||||
|
||||
## Usage
|
||||
## Example
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
<TabsRoot
|
||||
aria-label="React implementation"
|
||||
titles={{ component: 'Component', css: 'CSS Module' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="component" client:load>
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
</TabsPanel>
|
||||
|
||||
### Component
|
||||
|
||||
<ServerCode code={componentModuleStr} lang="tsx" />
|
||||
|
||||
### CSS Module
|
||||
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={cssModuleStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<VolumeSliderDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
<TabsRoot
|
||||
aria-label="HTML implementation"
|
||||
titles={{ 'html-horizontal': 'HTML (Horizontal)', 'html-vertical': 'HTML (Vertical)', css: 'CSS' }}
|
||||
client:load
|
||||
>
|
||||
<TabsPanel value="html-horizontal" client:load>
|
||||
<ServerCode code={htmlHorizontalStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
### Horizontal Orientation
|
||||
<TabsPanel value="html-vertical" client:load>
|
||||
<ServerCode code={htmlVerticalStr} lang="html" />
|
||||
</TabsPanel>
|
||||
|
||||
<ServerCode code={htmlHorizontalStr} lang="html" />
|
||||
|
||||
### Vertical Orientation
|
||||
|
||||
<ServerCode code={htmlVerticalStr} lang="html" />
|
||||
|
||||
### CSS
|
||||
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
<TabsPanel value="css" client:load>
|
||||
<ServerCode code={htmlCssStr} lang="css" />
|
||||
</TabsPanel>
|
||||
|
||||
<VolumeSliderDemo client:load />
|
||||
</TabsRoot>
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BasicFullscreenButton } from './BasicFullscreenButton';
|
||||
export function FullscreenButtonDemo() {
|
||||
return (
|
||||
<MediaProvider>
|
||||
<MediaContainer style={{ maxWidth: '640px', position: 'relative' }}>
|
||||
<MediaContainer style={{ position: 'relative' }}>
|
||||
<Video
|
||||
src="https://stream.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA.m3u8"
|
||||
poster="https://image.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA/thumbnail.webp"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BasicMuteButton } from './BasicMuteButton';
|
||||
export function MuteButtonDemo() {
|
||||
return (
|
||||
<MediaProvider>
|
||||
<MediaContainer style={{ maxWidth: '640px', position: 'relative' }}>
|
||||
<MediaContainer style={{ position: 'relative' }}>
|
||||
<Video
|
||||
src="https://stream.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA.m3u8"
|
||||
poster="https://image.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA/thumbnail.webp"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BasicPlayButton } from './BasicPlayButton';
|
||||
export function PlayButtonDemo() {
|
||||
return (
|
||||
<MediaProvider>
|
||||
<MediaContainer style={{ maxWidth: '640px', position: 'relative' }}>
|
||||
<MediaContainer style={{ position: 'relative' }}>
|
||||
<Video
|
||||
src="https://stream.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA.m3u8"
|
||||
poster="https://image.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA/thumbnail.webp"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BasicTimeSlider } from './BasicTimeSlider';
|
||||
export function TimeSliderDemo() {
|
||||
return (
|
||||
<MediaProvider>
|
||||
<MediaContainer style={{ maxWidth: '640px', position: 'relative' }}>
|
||||
<MediaContainer style={{ position: 'relative' }}>
|
||||
<Video
|
||||
src="https://stream.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA.m3u8"
|
||||
poster="https://image.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA/thumbnail.webp"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BasicVolumeSlider } from './BasicVolumeSlider';
|
||||
export function VolumeSliderDemo() {
|
||||
return (
|
||||
<MediaProvider>
|
||||
<MediaContainer style={{ maxWidth: '640px', position: 'relative' }}>
|
||||
<MediaContainer style={{ position: 'relative' }}>
|
||||
<Video
|
||||
src="https://stream.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA.m3u8"
|
||||
poster="https://image.mux.com/UZMwOY6MgmhFNXLbSFXAuPKlRPss5XNA/thumbnail.webp"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { map } from 'nanostores';
|
||||
|
||||
/**
|
||||
* Store for managing tabs state across Astro islands.
|
||||
* Maps tabs ID to currently active tab value.
|
||||
*/
|
||||
export const $tabs = map<Record<string, string>>({});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Rehype plugin that generates stable IDs for TabsRoot and TabsPanel components.
|
||||
*
|
||||
* This plugin:
|
||||
* 1. Generates stable IDs for TabsRoot components (if not already present)
|
||||
* 2. Propagates TabsRoot IDs to child TabsPanel components via tabsId attribute
|
||||
*/
|
||||
|
||||
let tabsRootCounter = 0;
|
||||
|
||||
export default function rehypeGenerateTabsIds() {
|
||||
return (tree) => {
|
||||
// Process the tree with a stateful visitor
|
||||
function visitWithContext(node, context = { tabsRootId: null }) {
|
||||
// Handle TabsRoot JSX component
|
||||
if (node.type === 'mdxJsxFlowElement' && node.name === 'TabsRoot') {
|
||||
// Generate stable ID
|
||||
const generatedId = `tabs-${tabsRootCounter++}`;
|
||||
|
||||
// Check if ID already exists in attributes
|
||||
const hasExistingId = node.attributes?.some(
|
||||
attr => attr.type === 'mdxJsxAttribute' && attr.name === 'id',
|
||||
);
|
||||
|
||||
// Only add ID if not already present (allow manual override)
|
||||
if (!hasExistingId) {
|
||||
if (!node.attributes) node.attributes = [];
|
||||
node.attributes.push({
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'id',
|
||||
value: generatedId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the actual ID (generated or existing)
|
||||
const idAttr = node.attributes.find(attr => attr.type === 'mdxJsxAttribute' && attr.name === 'id');
|
||||
const tabsRootId = idAttr?.value || generatedId;
|
||||
|
||||
// Create new context with TabsRoot ID
|
||||
const newContext = { tabsRootId };
|
||||
|
||||
// Visit children with new context
|
||||
if (node.children) {
|
||||
node.children.forEach(child => visitWithContext(child, newContext));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle TabsPanel JSX component
|
||||
if (node.type === 'mdxJsxFlowElement' && node.name === 'TabsPanel') {
|
||||
// Check if tabsId already exists
|
||||
const hasExistingTabsId = node.attributes?.some(
|
||||
attr => attr.type === 'mdxJsxAttribute' && attr.name === 'tabsId',
|
||||
);
|
||||
|
||||
// Add tabsId from context if not present
|
||||
if (!hasExistingTabsId && context.tabsRootId) {
|
||||
if (!node.attributes) node.attributes = [];
|
||||
node.attributes.push({
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'tabsId',
|
||||
value: context.tabsRootId,
|
||||
});
|
||||
}
|
||||
|
||||
// Visit children (preserve context for nested panels)
|
||||
if (node.children) {
|
||||
node.children.forEach(child => visitWithContext(child, context));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively visit children for other node types
|
||||
if (node.children) {
|
||||
node.children.forEach(child => visitWithContext(child, context));
|
||||
}
|
||||
}
|
||||
|
||||
// Start visiting from root
|
||||
if (tree.children) {
|
||||
tree.children.forEach(child => visitWithContext(child));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,47 +1,56 @@
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
/**
|
||||
* Adapted from https://mdxjs.com/guides/syntax-highlighting/
|
||||
*
|
||||
* This plugin:
|
||||
* 1. Generates stable IDs for <pre> blocks (for tabs)
|
||||
* 2. Tags <code> children of <pre> blocks so they know they're in a pre block
|
||||
* 3. Marks <pre> blocks with hasFrame based on whether they're inside a <TabsPanel> JSX component
|
||||
*/
|
||||
export default function rehypePrepareCodeBlocks() {
|
||||
// A regex that looks for a simplified attribute name, optionally followed
|
||||
// by a double, single, or unquoted attribute value
|
||||
const re = /\b([-\w]+)(?:=(?:"([^"]*)"|'([^']*)'|([^"'\s]+)))?/g;
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
let match;
|
||||
|
||||
/**
|
||||
* We're looking for <pre> blocks containing <code> blocks to do some work on them because MDX 2 is weird.
|
||||
* Here's what we're up to here:
|
||||
* 1. taking the classname from code and moving it up to pre
|
||||
* 2. notifying code that it's in a pre block so it wouldn't try to format itself as inline code
|
||||
* 3. parsing the code block's metadata and putting the results into the pre block
|
||||
* metadata, you ask? Stuff like title and lineNumbers in this example below.
|
||||
* ```js title="src/pages/index.js" lineNumbers=true
|
||||
* ```
|
||||
*/
|
||||
if (node.tagName === 'pre') {
|
||||
let preBlockCounter = 0;
|
||||
|
||||
export default function rehypePrepareCodeBlocks() {
|
||||
return (tree) => {
|
||||
// Process the tree with a stateful visitor
|
||||
function visitWithContext(node, context = { hasFrame: false }) {
|
||||
// Handle TabsPanel JSX component
|
||||
if (node.type === 'mdxJsxFlowElement' && node.name === 'TabsPanel') {
|
||||
// Create new context for children (inside tabs)
|
||||
const newContext = { hasFrame: true };
|
||||
|
||||
// Visit children with new context
|
||||
if (node.children) {
|
||||
node.children.forEach(child => visitWithContext(child, newContext));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle <pre> elements
|
||||
if (node.type === 'element' && node.tagName === 'pre') {
|
||||
// Mark whether this pre block is inside tabs
|
||||
node.properties.hasFrame = context.hasFrame;
|
||||
|
||||
// Generate stable ID for this pre block
|
||||
node.properties['data-tabs-id'] = `pre-${preBlockCounter++}`;
|
||||
|
||||
// Tag <code> children
|
||||
node.children.forEach((child) => {
|
||||
if (child.tagName === 'code') {
|
||||
const { className } = child.properties;
|
||||
if (className) {
|
||||
node.properties.className = className;
|
||||
}
|
||||
|
||||
child.properties.codeBlock = 'true';
|
||||
|
||||
if (child.data && child.data.meta) {
|
||||
re.lastIndex = 0; // Reset regex.
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = re.exec(child.data.meta))) {
|
||||
node.properties[match[1]] = match[2] || match[3] || match[4] || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively visit children for other node types
|
||||
if (node.children) {
|
||||
node.children.forEach(child => visitWithContext(child, context));
|
||||
}
|
||||
}
|
||||
|
||||
// Start visiting from root
|
||||
if (tree.children) {
|
||||
tree.children.forEach(child => visitWithContext(child));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const shikiTransformMetadata = {
|
||||
pre(hast) {
|
||||
// get stuff out of this.options.meta?.__raw;
|
||||
// for now, let's start with just title="abc" or title='abc' or title=abc
|
||||
const raw = this.options.meta?.__raw || '';
|
||||
const titleMatch = raw.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s"']+))/);
|
||||
if (titleMatch) {
|
||||
hast.properties.title = titleMatch[1] || titleMatch[2] || titleMatch[3];
|
||||
}
|
||||
},
|
||||
};
|
||||
export default shikiTransformMetadata;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useIsHydrated() {
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
return isHydrated;
|
||||
}
|
||||
Reference in New Issue
Block a user