diff --git a/apps/docs/package.json b/apps/docs/package.json index b3158ecc..30359e68 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -39,10 +39,12 @@ "lucide-react": "catalog:", "lz-string": "catalog:", "monaco-editor": "catalog:", + "next-themes": "catalog:", "react": "catalog:", "react-aria-components": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", + "react-resizable-panels": "catalog:", "spin-doctor": "catalog:", "sucrase": "catalog:", "vite": "catalog:", diff --git a/apps/docs/src/components/playground/icon-toggle-button-group.tsx b/apps/docs/src/components/playground/icon-toggle-button-group.tsx new file mode 100644 index 00000000..e8908bba --- /dev/null +++ b/apps/docs/src/components/playground/icon-toggle-button-group.tsx @@ -0,0 +1,68 @@ +import { cx } from '@luke-ui/react/utils'; +import type { ComponentType } from 'react'; +import type { Selection } from 'react-aria-components/GridList'; +import { ToggleButton } from 'react-aria-components/ToggleButton'; +import { ToggleButtonGroup } from 'react-aria-components/ToggleButtonGroup'; + +type IconComponent = ComponentType<{ + 'aria-hidden'?: boolean; + className?: string; +}>; + +type IconToggleItem = { + Icon: IconComponent; + label: string; + value: Value; +}; + +type IconToggleButtonGroupProps = { + label: string; + onChange: (value: Value) => void; + options: ReadonlyArray>; + value: Value | null; +}; + +export function IconToggleButtonGroup({ + label, + onChange, + options, + value, +}: IconToggleButtonGroupProps) { + const handleSelectionChange = (selection: Selection) => { + if (selection === 'all') return; + + const selectedKey = selection.values().next().value; + const selectedOption = options.find((option) => option.value === selectedKey); + if (!selectedOption) return; + + onChange(selectedOption.value); + }; + + return ( + + {options.map(({ Icon, label: optionLabel, value: optionValue }) => ( + + cx( + 'flex size-8 cursor-pointer items-center justify-center rounded-full text-fd-muted-foreground transition-colors data-hovered:bg-fd-accent data-hovered:text-fd-accent-foreground data-pressed:bg-fd-accent', + isSelected && 'bg-fd-background text-fd-foreground shadow-sm', + ) + } + id={optionValue} + key={optionValue} + > + + + ))} + + ); +} diff --git a/apps/docs/src/components/playground/preview-runner.tsx b/apps/docs/src/components/playground/preview-runner.tsx index b8451373..178b73cb 100644 --- a/apps/docs/src/components/playground/preview-runner.tsx +++ b/apps/docs/src/components/playground/preview-runner.tsx @@ -24,7 +24,7 @@ export default function PreviewRunner() { const UserComponent = compileComponent(code); runId += 1; setRun({ runId, UserComponent }); - postToParent({ type: 'luke-playground:success' }); + postToParent({ type: 'playground:success' }); } catch (error) { reportError(error); } @@ -38,7 +38,7 @@ export default function PreviewRunner() { window.addEventListener('message', onMessage); const initialCode = decodeCodeHash(window.location.hash); if (initialCode) runCode(initialCode); - postToParent({ type: 'luke-playground:ready' }); + postToParent({ type: 'playground:ready' }); return () => window.removeEventListener('message', onMessage); }, []); @@ -99,5 +99,5 @@ function postToParent(message: PlaygroundPreviewMessage): void { function reportError(error: unknown): void { const message = error instanceof Error ? error.message : String(error); - postToParent({ message, type: 'luke-playground:error' }); + postToParent({ message, type: 'playground:error' }); } diff --git a/apps/docs/src/components/playground/theme-toggle.tsx b/apps/docs/src/components/playground/theme-toggle.tsx new file mode 100644 index 00000000..f765355e --- /dev/null +++ b/apps/docs/src/components/playground/theme-toggle.tsx @@ -0,0 +1,40 @@ +import { MoonIcon, SunIcon } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useSyncExternalStore } from 'react'; +import { IconToggleButtonGroup } from './icon-toggle-button-group'; + +const THEMES = [ + { Icon: SunIcon, label: 'Light theme', value: 'light' }, + { Icon: MoonIcon, label: 'Dark theme', value: 'dark' }, +] as const; + +type Theme = (typeof THEMES)[number]['value']; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const isMounted = useSyncExternalStore( + subscribeToHydration, + getHydratedSnapshot, + getServerSnapshot, + ); + + const theme = isMounted && isTheme(resolvedTheme) ? resolvedTheme : null; + + return ; +} + +function isTheme(value: string | undefined): value is Theme { + return value === 'light' || value === 'dark'; +} + +function subscribeToHydration() { + return () => {}; +} + +function getHydratedSnapshot() { + return true; +} + +function getServerSnapshot() { + return false; +} diff --git a/apps/docs/src/components/playground/viewport-toggle.tsx b/apps/docs/src/components/playground/viewport-toggle.tsx index 037562e7..9a9c43a5 100644 --- a/apps/docs/src/components/playground/viewport-toggle.tsx +++ b/apps/docs/src/components/playground/viewport-toggle.tsx @@ -1,13 +1,12 @@ -import { buttonVariants } from 'fumadocs-ui/components/ui/button'; -import { MonitorIcon, SmartphoneIcon, TabletIcon } from 'lucide-react'; +import { MonitorIcon, SmartphoneIcon } from 'lucide-react'; +import { IconToggleButtonGroup } from './icon-toggle-button-group'; const VIEWPORTS = [ - { Icon: MonitorIcon, label: 'Desktop', width: '100%' }, - { Icon: TabletIcon, label: 'Tablet', width: '768px' }, - { Icon: SmartphoneIcon, label: 'Mobile', width: '375px' }, + { Icon: MonitorIcon, label: 'Desktop', value: '100%' }, + { Icon: SmartphoneIcon, label: 'Mobile', value: '375px' }, ] as const; -export type ViewportWidth = (typeof VIEWPORTS)[number]['width']; +export type ViewportWidth = (typeof VIEWPORTS)[number]['value']; type ViewportToggleProps = { onChange: (width: ViewportWidth) => void; @@ -16,24 +15,11 @@ type ViewportToggleProps = { export function ViewportToggle({ onChange, value }: ViewportToggleProps) { return ( -
- {VIEWPORTS.map(({ Icon, label, width }) => ( - - ))} -
+ ); } diff --git a/apps/docs/src/lib/playground-protocol.ts b/apps/docs/src/lib/playground-protocol.ts index 2cd4bff2..0cdeddfc 100644 --- a/apps/docs/src/lib/playground-protocol.ts +++ b/apps/docs/src/lib/playground-protocol.ts @@ -1,14 +1,14 @@ import { z } from 'zod'; const codeMessageSchema = z.object({ - type: z.literal('luke-playground:code'), + type: z.literal('playground:code'), code: z.string(), }); const previewMessageSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('luke-playground:ready') }), - z.object({ type: z.literal('luke-playground:success') }), - z.object({ type: z.literal('luke-playground:error'), message: z.string() }), + z.object({ type: z.literal('playground:ready') }), + z.object({ type: z.literal('playground:success') }), + z.object({ type: z.literal('playground:error'), message: z.string() }), ]); export type PlaygroundCodeMessage = z.infer; diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx index 7f0feb79..92fce1a8 100644 --- a/apps/docs/src/routes/playground/index.tsx +++ b/apps/docs/src/routes/playground/index.tsx @@ -1,12 +1,25 @@ import { cx } from '@luke-ui/react/utils'; import { ClientOnly, createFileRoute, Link } from '@tanstack/react-router'; -import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import { Maximize2Icon, Minimize2Icon } from 'lucide-react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useReducer, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { Group, Panel, Separator } from 'react-resizable-panels'; import { useSpinDoctor } from 'spin-doctor'; import { EditorSkeleton, EditorSkeletonShapeScript, LoadingPill, } from '../../components/playground/editor-skeleton'; +import { ThemeToggle } from '../../components/playground/theme-toggle'; import type { ViewportWidth } from '../../components/playground/viewport-toggle'; import { ViewportToggle } from '../../components/playground/viewport-toggle'; import rawDefaultCode from '../../lib/playground-default-code.tsx?raw'; @@ -28,31 +41,30 @@ export const Route = createFileRoute('/playground/')({ function Playground() { const [initialCode] = useState(() => { - return typeof window === 'undefined' - ? rawDefaultCode - : (decodeCodeHash(window.location.hash) ?? rawDefaultCode); + if (typeof window === 'undefined') return rawDefaultCode; + return decodeCodeHash(window.location.hash) ?? rawDefaultCode; }); - const [error, setError] = useState(null); - const [isPreviewLoading, setIsPreviewLoading] = useState(true); + const { error, markError, markReady, markSuccess, previewReadyRef, showPreviewLoading } = + usePreviewStatus(); // Owned here (not by EditorSkeleton) so the pill survives the skeleton // remounting between loading phases instead of blinking on each one. const showEditorPill = useSpinDoctor(true, { delay: 800 }); - const showPreviewLoading = useSpinDoctor(isPreviewLoading, { - delay: 250, - minDuration: 200, - }); const [viewportWidth, setViewportWidth] = useState('100%'); + const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false); + const isDesktop = useIsDesktop(); const iframeRef = useRef(null); const codeRef = useRef(initialCode); - const previewReadyRef = useRef(false); const debounceRef = useRef | undefined>(undefined); - const postCode = (code: string) => { - const contentWindow = iframeRef.current?.contentWindow; - if (!previewReadyRef.current || !contentWindow) return; - const message: PlaygroundCodeMessage = { code, type: 'luke-playground:code' }; - contentWindow.postMessage(message, window.location.origin); - }; + const postCode = useCallback( + (code: string) => { + const contentWindow = iframeRef.current?.contentWindow; + if (!previewReadyRef.current || !contentWindow) return; + const message: PlaygroundCodeMessage = { code, type: 'playground:code' }; + contentWindow.postMessage(message, window.location.origin); + }, + [previewReadyRef], + ); useEffect(() => { const onMessage = (event: MessageEvent) => { @@ -62,16 +74,16 @@ function Playground() { // Any preview message proves the runner is listening, including a // success/error reply to the unguarded initial post below. previewReadyRef.current = true; - setIsPreviewLoading(false); - if (event.data.type === 'luke-playground:ready') { + markReady(); + if (event.data.type === 'playground:ready') { postCode(codeRef.current); return; } - if (event.data.type === 'luke-playground:error') { - setError(event.data.message); + if (event.data.type === 'playground:error') { + markError(event.data.message); return; } - setError(null); + markSuccess(); }; window.addEventListener('message', onMessage); @@ -80,14 +92,28 @@ function Playground() { // cover that race; it is dropped harmlessly if the runner isn't up yet. const message: PlaygroundCodeMessage = { code: codeRef.current, - type: 'luke-playground:code', + type: 'playground:code', }; iframeRef.current?.contentWindow?.postMessage(message, window.location.origin); return () => { window.removeEventListener('message', onMessage); clearTimeout(debounceRef.current); }; - }, []); + // previewReadyRef, postCode, markError, markReady, and markSuccess are all + // referentially stable (ref + useCallback/dispatch-based), so this still + // only runs once per mount despite listing them. + }, [previewReadyRef, postCode, markError, markReady, markSuccess]); + + useEffect(() => { + if (!isPreviewFullscreen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsPreviewFullscreen(false); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isPreviewFullscreen]); const handleChange = (code: string) => { codeRef.current = code; @@ -111,11 +137,28 @@ function Playground() { Docs - +
+ + + +
-
+ {/* Pane backgrounds match the Catppuccin Latte/Mocha `editor.background` values in monaco-setup.ts. */} -
+ @@ -132,8 +175,41 @@ function Playground() { /> -
-
+ + + + {isPreviewFullscreen ? ( + + ) : null} {error === null ? null : (
)} -
+
+ {/* Iframes swallow pointer events, which kills separator drags that cross into the preview — disable them while the separator is engaged. */}