From df4692dbda29bd532a897288b82e2fdbe22d6046 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Wed, 29 Oct 2025 12:43:03 -0500 Subject: [PATCH] feat(site): tabs (#144) --- site/astro.config.mjs | 7 +- site/src/components/Code/ClientCode.tsx | 26 +++ site/src/components/Code/ServerCode.astro | 10 + .../{ClientCode.tsx => Code/Shared.tsx} | 31 +-- site/src/components/Code/createHighlighter.ts | 14 ++ site/src/components/Code/serverHighlighter.ts | 9 + site/src/components/CopyButton.tsx | 67 ++++++ site/src/components/HomePageDemo/Base.tsx | 50 +++- site/src/components/HomePageDemo/Eject.tsx | 67 ++++-- site/src/components/HomePageDemo/index.tsx | 4 +- site/src/components/Select.tsx | 3 +- site/src/components/ServerCode.astro | 28 --- site/src/components/Tabs.tsx | 217 ++++++++++++++++++ site/src/components/ThemeToggle.tsx | 2 - site/src/components/ToggleGroup.tsx | 2 +- site/src/components/docs/FrameworkCase.astro | 18 +- site/src/components/docs/StyleCase.astro | 18 +- .../components/typography/MarkdownCode.astro | 2 +- site/src/components/typography/Pre.astro | 43 ++-- site/src/content/docs/how-to/write-guides.mdx | 195 +++++++++++++--- .../docs/resources/fullscreen-button.mdx | 55 +++-- .../content/docs/resources/mute-button.mdx | 60 +++-- .../content/docs/resources/play-button.mdx | 57 +++-- .../content/docs/resources/time-slider.mdx | 57 +++-- .../content/docs/resources/volume-slider.mdx | 57 +++-- .../FullscreenButton/FullscreenButtonDemo.tsx | 2 +- .../react/MuteButton/MuteButtonDemo.tsx | 2 +- .../react/PlayButton/PlayButtonDemo.tsx | 2 +- .../react/TimeSlider/TimeSliderDemo.tsx | 2 +- .../react/VolumeSlider/VolumeSliderDemo.tsx | 2 +- site/src/stores/tabs.ts | 7 + site/src/utils/rehypeGenerateTabsIds.js | 86 +++++++ site/src/utils/rehypePrepareCodeBlocks.js | 79 ++++--- site/src/utils/shikiTransformMetadata.js | 12 + site/src/utils/useIsHydrated.ts | 10 + 35 files changed, 999 insertions(+), 304 deletions(-) create mode 100644 site/src/components/Code/ClientCode.tsx create mode 100644 site/src/components/Code/ServerCode.astro rename site/src/components/{ClientCode.tsx => Code/Shared.tsx} (52%) create mode 100644 site/src/components/Code/createHighlighter.ts create mode 100644 site/src/components/Code/serverHighlighter.ts create mode 100644 site/src/components/CopyButton.tsx delete mode 100644 site/src/components/ServerCode.astro create mode 100644 site/src/components/Tabs.tsx create mode 100644 site/src/stores/tabs.ts create mode 100644 site/src/utils/rehypeGenerateTabsIds.js create mode 100644 site/src/utils/shikiTransformMetadata.js create mode 100644 site/src/utils/useIsHydrated.ts diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 5cc489dd..75d6a08f 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -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: { diff --git a/site/src/components/Code/ClientCode.tsx b/site/src/components/Code/ClientCode.tsx new file mode 100644 index 00000000..f8a30347 --- /dev/null +++ b/site/src/components/Code/ClientCode.tsx @@ -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) { + return ; +} diff --git a/site/src/components/Code/ServerCode.astro b/site/src/components/Code/ServerCode.astro new file mode 100644 index 00000000..8e5f893a --- /dev/null +++ b/site/src/components/Code/ServerCode.astro @@ -0,0 +1,10 @@ +--- +import type { SharedProps } from './Shared'; +import Shared from './Shared'; +import serverHighlighter from './serverHighlighter'; + +type Props = Omit; +const props = Astro.props; +--- + + diff --git a/site/src/components/ClientCode.tsx b/site/src/components/Code/Shared.tsx similarity index 52% rename from site/src/components/ClientCode.tsx rename to site/src/components/Code/Shared.tsx index 390b9cd2..05ae91bb 100644 --- a/site/src/components/ClientCode.tsx +++ b/site/src/components/Code/Shared.tsx @@ -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 ( -
+    
       [0], 'themes'>,
+) {
+  return libCreateHighlighter(
+    {
+      ...config,
+      themes: [gruvboxLightHard, gruvboxDarkSoft],
+    },
+  );
+}
diff --git a/site/src/components/Code/serverHighlighter.ts b/site/src/components/Code/serverHighlighter.ts
new file mode 100644
index 00000000..af54f78c
--- /dev/null
+++ b/site/src/components/Code/serverHighlighter.ts
@@ -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;
diff --git a/site/src/components/CopyButton.tsx b/site/src/components/CopyButton.tsx
new file mode 100644
index 00000000..c564d5ad
--- /dev/null
+++ b/site/src/components/CopyButton.tsx
@@ -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(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 (
+    
+  );
+}
diff --git a/site/src/components/HomePageDemo/Base.tsx b/site/src/components/HomePageDemo/Base.tsx
index f75f1192..2a789577 100644
--- a/site/src/components/HomePageDemo/Base.tsx
+++ b/site/src/components/HomePageDemo/Base.tsx
@@ -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 (
+      
+        
+          
+        
+        
+          
+        
+        
+          
+        
+      
+    );
+  }
 
   return (
-    
+    
+      
+        
+      
+    
   );
 }
diff --git a/site/src/components/HomePageDemo/Eject.tsx b/site/src/components/HomePageDemo/Eject.tsx
index 0c92a45f..dc2ee374 100644
--- a/site/src/components/HomePageDemo/Eject.tsx
+++ b/site/src/components/HomePageDemo/Eject.tsx
@@ -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 (
+      
+        
+          
+        
+        
+          
+        
+        
+          
+        
+      
+    );
+  }
 
   return (
-    
+    
+      
+        
+      
+      
+        
+      
+    
   );
 }
diff --git a/site/src/components/HomePageDemo/index.tsx b/site/src/components/HomePageDemo/index.tsx
index 31142563..38dba474 100644
--- a/site/src/components/HomePageDemo/index.tsx
+++ b/site/src/components/HomePageDemo/index.tsx
@@ -14,14 +14,14 @@ export default function HomePageDemo({ className }: Props) {
           

Assemble your player

Feel at home with your framework, skin, and media source

- +

Take full control

Make your player truly your own with fully-editable components

- +
); diff --git a/site/src/components/Select.tsx b/site/src/components/Select.tsx index d47f4b5f..51aa045f 100644 --- a/site/src/components/Select.tsx +++ b/site/src/components/Select.tsx @@ -77,8 +77,7 @@ export function Select({ 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', )} > diff --git a/site/src/components/ServerCode.astro b/site/src/components/ServerCode.astro deleted file mode 100644 index 3048021c..00000000 --- a/site/src/components/ServerCode.astro +++ /dev/null @@ -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, 'class'> { - maxWidth?: ComponentProps['maxWidth']; - wrapperClass?: ComponentProps['class']; - codeClass?: ComponentProps['class']; -} - -const { code, lang, maxWidth = true, themes, wrapperClass, codeClass, style, ...codeProps } = Astro.props; ---- - -
-  
-
diff --git a/site/src/components/Tabs.tsx b/site/src/components/Tabs.tsx new file mode 100644 index 00000000..24c6f134 --- /dev/null +++ b/site/src/components/Tabs.tsx @@ -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; + + maxWidth?: boolean; +} + +export function TabsRoot({ + id, + 'aria-label': ariaLabel, + titles, + className, + children, + maxWidth = true, +}: TabsRootProps) { + const isHydrated = useIsHydrated(); + + // Derive default from first item in titles + // This assumes titles keys and TabsPanel children are in the same order... + // Which is... unfortunate. But. Astro's gonna astro. It's hard to communicate between components. + const defaultValue = Object.keys(titles)[0]; + if (!defaultValue) { + throw new Error('TabsRoot requires at least one item in titles.'); + } + + const currentState = $tabs.get(); + if (currentState[id] === undefined) { + $tabs.setKey(id, defaultValue); + } + + // Subscribe to the tabs store + const allTabsState = useStore($tabs); + + // Get current active value, or use first item as fallback + const activeValue = allTabsState[id] ?? defaultValue; + + const handleTabClick = (value: string) => { + $tabs.setKey(id, value); + }; + + const values = Object.keys(titles); + + const handleKeyDown = (e: KeyboardEvent, currentIndex: number) => { + let newIndex: number | null = null; + + switch (e.key) { + case 'ArrowLeft': + newIndex = currentIndex - 1; + if (newIndex < 0) newIndex = values.length - 1; // Circular + break; + case 'ArrowRight': + newIndex = currentIndex + 1; + if (newIndex >= values.length) newIndex = 0; // Circular + break; + case 'Home': + newIndex = 0; + break; + case 'End': + newIndex = values.length - 1; + break; + } + + if (newIndex !== null) { + e.preventDefault(); + const newValue = values[newIndex]; + $tabs.setKey(id, newValue); + // Focus the new tab + const newTabElement = document.getElementById(`${id}-tab-${newValue}`); + newTabElement?.focus(); + } + }; + + return ( +
+
+
    + {values.map((value, index) => { + const isActive = value === activeValue; + const isLoading = !isHydrated && !isActive; + + return ( +
  • + +
  • + ); + })} +
+ } + > + + +
+ {children} +
+ ); +} + +interface TabsPanelProps { + /** Unique ID matching the parent TabsRoot. Required for both Astro and React usage. Generated by Rehype in MDX */ + tabsId: string; + /** The value this panel corresponds to */ + value: string; + /** Additional CSS classes */ + className?: string; + /** Panel content */ + children: ReactNode; +} + +export function TabsPanel({ tabsId, value, className, children }: TabsPanelProps) { + // Initialize tabs store if necessary + // Astro SOMETIMES renders TabsPanel before TabsRoot, unfortunately + // so, in this case, let's assume this is the first TabsPanel in order + // and set ourselves as the active one + // I hate this, but it seems to work. + const currentState = $tabs.get(); + if (currentState[tabsId] === undefined) { + $tabs.setKey(tabsId, value); + } + + // Subscribe to the tabs store + const allTabsState = useStore($tabs); + const activeValue = allTabsState[tabsId]; + const isActive = activeValue === value; ; + + return ( + + ); +} diff --git a/site/src/components/ThemeToggle.tsx b/site/src/components/ThemeToggle.tsx index d5633594..3b815d23 100644 --- a/site/src/components/ThemeToggle.tsx +++ b/site/src/components/ThemeToggle.tsx @@ -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')} /> ); } diff --git a/site/src/components/ToggleGroup.tsx b/site/src/components/ToggleGroup.tsx index 45a3bb05..1438777a 100644 --- a/site/src/components/ToggleGroup.tsx +++ b/site/src/components/ToggleGroup.tsx @@ -55,7 +55,7 @@ export default function ToggleGroup({ 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']} diff --git a/site/src/components/docs/FrameworkCase.astro b/site/src/components/docs/FrameworkCase.astro index 5eb20f10..c078ed95 100644 --- a/site/src/components/docs/FrameworkCase.astro +++ b/site/src/components/docs/FrameworkCase.astro @@ -12,4 +12,20 @@ const { framework } = Astro.params; const shouldRender = !frameworks || frameworks.includes(framework as SupportedFramework); --- -{shouldRender && } +{ + /* + What I WANT to do is + ``` + {shouldRender && } + ``` + + 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 + */ +} + diff --git a/site/src/components/docs/StyleCase.astro b/site/src/components/docs/StyleCase.astro index 463e8363..5c5b8ffc 100644 --- a/site/src/components/docs/StyleCase.astro +++ b/site/src/components/docs/StyleCase.astro @@ -12,4 +12,20 @@ const { style } = Astro.params; const shouldRender = !styles || styles.includes(style as AnySupportedStyle); --- -{shouldRender && } +{ + /* + What I WANT to do is + ``` + {shouldRender && } + ``` + + 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 + */ +} + diff --git a/site/src/components/typography/MarkdownCode.astro b/site/src/components/typography/MarkdownCode.astro index bed8764d..192c8f80 100644 --- a/site/src/components/typography/MarkdownCode.astro +++ b/site/src/components/typography/MarkdownCode.astro @@ -22,7 +22,7 @@ const isCodeBlock = codeBlock === 'true'; ) : ( = 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'; --- -
- -
+{ + hasFrame ? ( + + + + ) : ( + + + + + + + + ) +} diff --git a/site/src/content/docs/how-to/write-guides.mdx b/site/src/content/docs/how-to/write-guides.mdx index 6b5ccab8..37be28e0 100644 --- a/site/src/content/docs/how-to/write-guides.mdx +++ b/site/src/content/docs/how-to/write-guides.mdx @@ -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 `` component to show content only for specific styling approaches. For example, ```mdx - - Tailwind-only content + + Css-only content ``` will render: - - Tailwind-only content + + Css-only content -## Displaying Code from Files - -Use the `` 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'; - - -``` - -will render: - - setCount(count + 1)}>{count}; -}`} lang="tsx" /> - -## Wrapping Live Demos - -Use the `` component to constrain live demos to a readable width. - -```mdx -import { MyDemo } from '@/examples/react/MyDemo'; -import Container from '@/components/docs/Container.astro'; - - - - -``` - ## Use Github-Flavored Markdown ### Headings @@ -279,3 +247,154 @@ Press CTRL + ALT + Delete to end the session. Most salamanders 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 `` and `` to show multiple code examples side-by-side: + +````mdx + + + ```ts + console.log('Hello, TypeScript!'); + ``` + + + + ```js + console.log('Hello, JavaScript!'); + ``` + + +```` +Which renders + + + ```ts + console.log('Hello, TypeScript!'); + ``` + + + + ```js + console.log('Hello, JavaScript!'); + ``` + + + +**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 `` and `` 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 `` 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, `` 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'; + + + + + + +``` + +Which will happily render + + + + Hello, world!; +}`} lang="tsx" /> + + + +## 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 `` component to constrain standalone demos to a readable width: + +```mdx +import { MyDemo } from '@/examples/react/MyDemo'; +import Container from '@/components/docs/Container.astro'; + + + + +``` + +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 `` (as a sibling to `` 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'; + + + + + + + + + + + + +``` + +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). \ No newline at end of file diff --git a/site/src/content/docs/resources/fullscreen-button.mdx b/site/src/content/docs/resources/fullscreen-button.mdx index c64c1b8b..57870317 100644 --- a/site/src/content/docs/resources/fullscreen-button.mdx +++ b/site/src/content/docs/resources/fullscreen-button.mdx @@ -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 - - - - - -## Usage +## Example + + + + -### Component - - - -### CSS Module - + + + + + + + + + -### HTML + + + - - -### CSS - - -### JavaScript - - + + + + + ## Data Attributes diff --git a/site/src/content/docs/resources/mute-button.mdx b/site/src/content/docs/resources/mute-button.mdx index d81efe01..dc811251 100644 --- a/site/src/content/docs/resources/mute-button.mdx +++ b/site/src/content/docs/resources/mute-button.mdx @@ -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 - - - - - -## Usage +## Example + + + + -### Component - - - -### CSS Module - - + + + + + + + + + + -### HTML + + + - - -### CSS - - - -### JavaScript - - + + + + + + + ## Data Attributes The MuteButton automatically sets data attributes based on volume level: diff --git a/site/src/content/docs/resources/play-button.mdx b/site/src/content/docs/resources/play-button.mdx index a3a74fae..3811b9ac 100644 --- a/site/src/content/docs/resources/play-button.mdx +++ b/site/src/content/docs/resources/play-button.mdx @@ -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 - - - - - -## Usage +## Example + + + + -### Component - - - -### CSS Module - - + + + + + + + + + -### HTML + + + - - -### CSS - - - -### JavaScript - - + + + + + ## Data Attributes diff --git a/site/src/content/docs/resources/time-slider.mdx b/site/src/content/docs/resources/time-slider.mdx index 7ee60643..a4e4d66b 100644 --- a/site/src/content/docs/resources/time-slider.mdx +++ b/site/src/content/docs/resources/time-slider.mdx @@ -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 - - - - - -## Usage +## Example + + + + -### Component - - - -### CSS Module - - + + + + + + + + + -### Horizontal Orientation + + + - - -### Vertical Orientation - - - -### CSS - - + + + + + diff --git a/site/src/content/docs/resources/volume-slider.mdx b/site/src/content/docs/resources/volume-slider.mdx index eb3267d7..267deb88 100644 --- a/site/src/content/docs/resources/volume-slider.mdx +++ b/site/src/content/docs/resources/volume-slider.mdx @@ -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 - - - - - -## Usage +## Example + + + + -### Component - - - -### CSS Module - - + + + + + + + + + -### Horizontal Orientation + + + - - -### Vertical Orientation - - - -### CSS - - + + + + + diff --git a/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx b/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx index 501ae24c..ca4d190c 100644 --- a/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx +++ b/site/src/examples/react/FullscreenButton/FullscreenButtonDemo.tsx @@ -9,7 +9,7 @@ import { BasicFullscreenButton } from './BasicFullscreenButton'; export function FullscreenButtonDemo() { return ( - +