From 729323b6dbfcd8069c1b6ce9f3cd5c21cc5e9c11 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sat, 25 Jul 2026 16:48:56 +1000 Subject: [PATCH] Move viewport and fullscreen controls into the preview panel (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move viewport and fullscreen controls into the preview panel The playground header mixed page-level appearance controls with preview-only controls, and the fullscreen buttons never selected an appearance/tone, so enter-fullscreen rendered as the loudest element on the page and exit-fullscreen was a white icon on a white circle with a leftover gradient overlay — a non-text contrast failure. Add a PreviewToolbar owned by the preview panel that holds the viewport toggle and a single fullscreen control. Enter-fullscreen uses the ghost appearance so it reads as a quiet toolbar utility; exit-fullscreen uses the solid appearance so both the icon and its container clear 3:1 non-text contrast against the toolbar background. The header keeps only whole-page controls (brand, docs link, theme controls). * Update fullscreen button --- .../preview-toolbar.browser.test.tsx | 84 +++++++++++++++++++ .../components/playground/preview-toolbar.tsx | 40 +++++++++ apps/docs/src/routes/playground/index.tsx | 27 ++---- 3 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 apps/docs/src/components/playground/preview-toolbar.browser.test.tsx create mode 100644 apps/docs/src/components/playground/preview-toolbar.tsx diff --git a/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx b/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx new file mode 100644 index 00000000..538d922f --- /dev/null +++ b/apps/docs/src/components/playground/preview-toolbar.browser.test.tsx @@ -0,0 +1,84 @@ +import '../../styles/app.css'; +import '@luke-ui/react/themes/paper.css'; +import '@luke-ui/react/themes/tactile.css'; +import { act, useState } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/context'; +import { StoryWrapper } from '../../lib/story-wrapper'; +import { DocsThemeRoot } from '../theme-controls'; +import { PreviewToolbar } from './preview-toolbar'; +import type { ViewportWidth } from './viewport-toggle'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = undefined; + root = undefined; +}); + +test('switches the preview viewport width', async () => { + renderToolbar(); + + await userEvent.click(page.getByRole('radio', { name: 'Mobile' })); + + expect(page.getByRole('radio', { name: 'Mobile' }).element()).toHaveAttribute( + 'aria-checked', + 'true', + ); +}); + +test('enters and exits fullscreen with a keyboard-reachable, clearly labelled toggle', async () => { + renderToolbar(); + + const enterButton = page.getByRole('button', { name: 'Enter fullscreen preview' }); + await userEvent.tab(); + await userEvent.tab(); + await expect.element(enterButton).toHaveFocus(); + + await userEvent.keyboard('{Enter}'); + + const exitButton = page.getByRole('button', { name: 'Exit fullscreen preview' }); + await expect.element(exitButton).toBeVisible(); + await expect.element(exitButton).toHaveFocus(); + expect(page.getByRole('button', { name: 'Enter fullscreen preview' }).elements()).toHaveLength(0); + + await userEvent.keyboard(' '); + + await expect + .element(page.getByRole('button', { name: 'Enter fullscreen preview' })) + .toBeVisible(); +}); + +function renderToolbar() { + container = document.body.appendChild(document.createElement('div')); + root = createRoot(container); + + act(() => { + root?.render( + + + + + , + ); + }); +} + +function Harness() { + const [viewportWidth, setViewportWidth] = useState('100%'); + const [isFullscreen, setIsFullscreen] = useState(false); + + return ( + + ); +} diff --git a/apps/docs/src/components/playground/preview-toolbar.tsx b/apps/docs/src/components/playground/preview-toolbar.tsx new file mode 100644 index 00000000..9ae9f4d4 --- /dev/null +++ b/apps/docs/src/components/playground/preview-toolbar.tsx @@ -0,0 +1,40 @@ +import { IconButton } from '@luke-ui/react/icon-button'; +import type { Dispatch, SetStateAction } from 'react'; +import type { ViewportWidth } from './viewport-toggle.js'; +import { ViewportToggle } from './viewport-toggle.js'; + +interface PreviewToolbarProps { + /** Whether the preview panel currently fills the viewport. */ + isFullscreen: boolean; + /** Called with the next fullscreen state when the toggle is pressed. */ + onFullscreenChange: Dispatch>; + /** Called when the preview viewport width changes. */ + onViewportChange: (width: ViewportWidth) => void; + /** Current preview viewport width. */ + viewportWidth: ViewportWidth; +} + +/** + * Toolbar for controls that act only on the preview pane, not the whole + * playground page. Rendered by the preview panel so it stays with the thing + * it controls, and stays available whether or not the panel is fullscreen. + */ +export function PreviewToolbar({ + isFullscreen, + onFullscreenChange, + onViewportChange, + viewportWidth, +}: PreviewToolbarProps) { + return ( +
+ + onFullscreenChange((prev) => !prev)} + size="small" + /> +
+ ); +} diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx index 4071b71d..4f48b402 100644 --- a/apps/docs/src/routes/playground/index.tsx +++ b/apps/docs/src/routes/playground/index.tsx @@ -1,4 +1,3 @@ -import { IconButton } from '@luke-ui/react/icon-button'; import { cx } from '@luke-ui/react/utils'; import { ClientOnly, createFileRoute, Link } from '@tanstack/react-router'; import { lazy, Suspense, useCallback, useEffect, useReducer, useRef, useState } from 'react'; @@ -10,9 +9,9 @@ import { EditorSkeletonShapeScript, LoadingPill, } from '../../components/playground/editor-skeleton'; +import { PreviewToolbar } from '../../components/playground/preview-toolbar'; import { useIsDesktop } from '../../components/playground/use-is-desktop'; import type { ViewportWidth } from '../../components/playground/viewport-toggle'; -import { ViewportToggle } from '../../components/playground/viewport-toggle'; import { ThemeControls, useDocsThemeIdentity } from '../../components/theme-controls'; import rawDefaultCode from '../../lib/playground-default-code.tsx?raw'; import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash'; @@ -149,15 +148,8 @@ function Playground() { Docs -
+
- - setIsPreviewFullscreen(true)} - size="small" - />
- {isPreviewFullscreen ? ( - setIsPreviewFullscreen(false)} - size="small" - /> - ) : null} + {error === null ? null : (