diff --git a/.chronus/changes/playground-mobile-2026-2-13-15-5-48.md b/.chronus/changes/playground-mobile-2026-2-13-15-5-48.md
new file mode 100644
index 00000000000..ab3e95a2bc4
--- /dev/null
+++ b/.chronus/changes/playground-mobile-2026-2-13-15-5-48.md
@@ -0,0 +1,11 @@
+---
+# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
+changeKind: feature
+packages:
+ - "@typespec/playground"
+---
+
+Make UI more mobile friendly.
+ - Add a new switch to toggle between TypeSpec and output panels
+ - Command bar hides less important tools behind `...`
+ - [API] Update custom toolbar to take a menu item instead of generic react node.
diff --git a/packages/playground-website/src/import.tsx b/packages/playground-website/src/import.tsx
index 964a2b933a2..4176eee63d8 100644
--- a/packages/playground-website/src/import.tsx
+++ b/packages/playground-website/src/import.tsx
@@ -7,13 +7,6 @@ import {
DialogTitle,
Input,
Label,
- Menu,
- MenuItem,
- MenuList,
- MenuPopover,
- MenuTrigger,
- ToolbarButton,
- Tooltip,
} from "@fluentui/react-components";
import { ArrowUploadFilled } from "@fluentui/react-icons";
import { combineProjectIntoFile, createRemoteHost } from "@typespec/pack";
@@ -22,46 +15,51 @@ import {
Editor,
useMonacoModel,
usePlaygroundContext,
+ type CommandBarItem,
} from "@typespec/playground/react";
-import { ReactNode, useState } from "react";
+import { useState, type FunctionComponent, type ReactNode } from "react";
import { parse } from "yaml";
import style from "./import.module.css";
-export const ImportToolbarButton = () => {
- const [open, setOpen] = useState<"openapi3" | "tsp" | undefined>();
+type ImportType = "openapi3" | "tsp";
- return (
- <>
-
+/** Hook that creates a CommandBarItem for the Import action with sub-menu items. */
+export function useImportCommandBarItem(): CommandBarItem {
+ const [open, setOpen] = useState();
+
+ return {
+ id: "import",
+ label: "Import",
+ icon: ,
+ align: "right",
+ children: [
+ { id: "import-tsp", label: "Remote TypeSpec", onClick: () => setOpen("tsp") },
+ {
+ id: "import-openapi3",
+ label: "From OpenAPI 3 spec",
+ onClick: () => setOpen("openapi3"),
+ },
+ ],
+ content: setOpen(undefined)} />,
+ };
+}
-
- >
+const ImportDialog: FunctionComponent<{
+ open: "openapi3" | "tsp" | undefined;
+ onClose: () => void;
+}> = ({ open, onClose }) => {
+ return (
+
);
};
diff --git a/packages/playground-website/src/index.ts b/packages/playground-website/src/index.ts
index 1bc54a0a6fd..4b4bdefadfd 100644
--- a/packages/playground-website/src/index.ts
+++ b/packages/playground-website/src/index.ts
@@ -1,2 +1,2 @@
export { TypeSpecPlaygroundConfig } from "./config.js";
-export { ImportToolbarButton } from "./import.js";
+export { useImportCommandBarItem } from "./import.js";
diff --git a/packages/playground-website/src/main.tsx b/packages/playground-website/src/main.tsx
index e4fa8a5c71a..6ce6da3025b 100644
--- a/packages/playground-website/src/main.tsx
+++ b/packages/playground-website/src/main.tsx
@@ -1,3 +1,4 @@
+import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MANIFEST } from "@typespec/compiler";
import { registerMonacoDefaultWorkersForVite } from "@typespec/playground";
import PlaygroundManifest from "@typespec/playground/manifest";
@@ -5,12 +6,13 @@ import {
Footer,
FooterItem,
FooterVersionItem,
- renderReactPlayground,
+ StandalonePlayground,
} from "@typespec/playground/react";
import { SwaggerUIViewer } from "@typespec/playground/react/viewers";
import "@typespec/playground/styles.css";
+import { createRoot } from "react-dom/client";
import samples from "../samples/dist/samples.js";
-import { ImportToolbarButton } from "./import.js";
+import { useImportCommandBarItem } from "./import.js";
import "./style.css";
registerMonacoDefaultWorkersForVite();
@@ -42,20 +44,31 @@ const PlaygroundFooter = () => {
);
};
-await renderReactPlayground({
- ...PlaygroundManifest,
- samples,
- emitterViewers: {
- "@typespec/openapi3": [SwaggerUIViewer],
- },
- importConfig: {
- useShim: true,
- },
- footer: ,
- commandBarButtons: ,
- onFileBug: () => {
- const bodyPayload = encodeURIComponent(`\n\n\n[Playground Link](${document.location.href})`);
- const url = `https://github.com/microsoft/typespec/issues/new?body=${bodyPayload}`;
- window.open(url, "_blank");
- },
-});
+const onFileBug = () => {
+ const bodyPayload = encodeURIComponent(`\n\n\n[Playground Link](${document.location.href})`);
+ const url = `https://github.com/microsoft/typespec/issues/new?body=${bodyPayload}`;
+ window.open(url, "_blank");
+};
+
+const App = () => {
+ const importItem = useImportCommandBarItem();
+
+ return (
+ }
+ commandBarItems={[importItem]}
+ onFileBug={onFileBug}
+ />
+ );
+};
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/packages/playground/package.json b/packages/playground/package.json
index 4c5293c611d..e8a78e4a6ef 100644
--- a/packages/playground/package.json
+++ b/packages/playground/package.json
@@ -61,8 +61,8 @@
"build:storybook": "sb build",
"preview": "pnpm build && vite preview",
"start": "vite",
- "test": "echo 'no tests'",
- "test:ci": "echo 'no tests'",
+ "test": "vitest run",
+ "test:ci": "vitest run --coverage --reporter=junit --reporter=default",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix"
},
@@ -100,6 +100,9 @@
"@playwright/test": "^1.57.0",
"@storybook/cli": "^10.1.8",
"@storybook/react-vite": "^10.1.8",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.2.0",
"@types/debounce": "~1.2.4",
"@types/node": "~25.3.0",
"@types/react": "~19.2.7",
@@ -116,6 +119,7 @@
"typescript": "~5.9.3",
"vite": "^7.2.7",
"vite-plugin-checker": "^0.12.0",
- "vite-plugin-dts": "4.5.4"
+ "vite-plugin-dts": "4.5.4",
+ "vitest": "^4.0.18"
}
}
diff --git a/packages/playground/src/editor-command-bar/editor-command-bar.tsx b/packages/playground/src/editor-command-bar/editor-command-bar.tsx
index c41ad269ab3..58a198ca0f9 100644
--- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx
+++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx
@@ -1,17 +1,24 @@
-import { Link, Toolbar, ToolbarButton, Tooltip } from "@fluentui/react-components";
-import { Broom16Filled, Bug16Regular, Save16Regular } from "@fluentui/react-icons";
-import { useMemo, type FunctionComponent, type ReactNode } from "react";
+import {
+ Broom16Filled,
+ Bug16Regular,
+ Checkmark16Regular,
+ DocumentBulletList24Regular,
+ Save16Regular,
+} from "@fluentui/react-icons";
+import { useCallback, useMemo, useState, type FunctionComponent } from "react";
import { EmitterDropdown } from "../react/emitter-dropdown.js";
-import { SamplesDrawerTrigger } from "../react/samples-drawer/index.js";
+import type { CommandBarItem } from "../react/responsive-command-bar/index.js";
+import { ResponsiveCommandBar } from "../react/responsive-command-bar/index.js";
+import { SamplesDrawerOverlay, SamplesDrawerTrigger } from "../react/samples-drawer/index.js";
+import { useIsMobile } from "../react/use-mobile.js";
import type { BrowserHost, PlaygroundSample } from "../types.js";
-import style from "./editor-command-bar.module.css";
export interface EditorCommandBarProps {
- documentationUrl?: string;
saveCode: () => Promise | void;
formatCode: () => Promise | void;
fileBug?: () => Promise | void;
- commandBarButtons?: ReactNode;
+ /** Additional items provided by the consumer. */
+ commandBarItems?: readonly CommandBarItem[];
host: BrowserHost;
selectedEmitter: string;
onSelectedEmitterChange: (emitter: string) => void;
@@ -20,28 +27,22 @@ export interface EditorCommandBarProps {
selectedSampleName: string;
onSelectedSampleNameChange: (sampleName: string) => void;
}
-export const EditorCommandBar: FunctionComponent = ({
- documentationUrl,
- saveCode,
- formatCode,
- fileBug,
- host,
- selectedEmitter,
- onSelectedEmitterChange,
- samples,
- selectedSampleName,
- onSelectedSampleNameChange,
- commandBarButtons,
-}) => {
- const documentation = documentationUrl ? (
-
- ) : undefined;
- const bugButton = fileBug ? : undefined;
+export const EditorCommandBar: FunctionComponent = (props) => {
+ const {
+ saveCode,
+ formatCode,
+ fileBug,
+ host,
+ selectedEmitter,
+ onSelectedEmitterChange,
+ samples,
+ onSelectedSampleNameChange,
+ commandBarItems: externalItems,
+ } = props;
+
+ const isMobile = useIsMobile();
+ const [samplesDrawerOpen, setSamplesDrawerOpen] = useState(false);
const emitters = useMemo(
() =>
@@ -51,56 +52,100 @@ export const EditorCommandBar: FunctionComponent = ({
[host.libraries],
);
- return (
-
-
-
- } onClick={saveCode as any} />
-
-
- } onClick={formatCode as any} />
-
- {samples && (
- <>
-
-
- >
- )}
+ const handleFileBug = useCallback(() => {
+ if (fileBug) void fileBug();
+ }, [fileBug]);
+
+ const items = useMemo(() => {
+ const result: CommandBarItem[] = [
+ {
+ id: "save",
+ label: "Save",
+ icon: ,
+ onClick: saveCode as () => void,
+ pinned: true,
+ },
+ {
+ id: "format",
+ label: "Format",
+ icon: ,
+ onClick: formatCode as () => void,
+ pinned: true,
+ },
+ ];
+
+ if (samples) {
+ result.push({
+ id: "samples",
+ label: "Browse Samples",
+ icon: ,
+ onClick: () => setSamplesDrawerOpen(true),
+ toolbarItem: (
+
+ ),
+ });
+ }
+
+ result.push({
+ id: "emitter",
+ label: "Emitter",
+ toolbarItem: (
+ ),
+ children: emitters.map((emitter) => ({
+ id: `emitter-${emitter}`,
+ label: emitter,
+ icon: emitter === selectedEmitter ? : undefined,
+ onClick: () => onSelectedEmitterChange(emitter),
+ })),
+ });
- {documentation && (
- <>
-
- {documentation}
- >
- )}
-
- {commandBarButtons}
- {bugButton}
-
-
- );
-};
+ if (externalItems) {
+ result.push(...externalItems);
+ }
+
+ if (fileBug) {
+ result.push({
+ id: "file-bug",
+ label: "File Bug",
+ align: "right",
+ icon: ,
+ onClick: handleFileBug,
+ });
+ }
+
+ return result;
+ }, [
+ saveCode,
+ formatCode,
+ samples,
+ onSelectedSampleNameChange,
+ emitters,
+ selectedEmitter,
+ onSelectedEmitterChange,
+ externalItems,
+ fileBug,
+ handleFileBug,
+ ]);
-interface FileBugButtonProps {
- onClick: () => Promise | void;
-}
-const FileBugButton: FunctionComponent = ({ onClick }) => {
return (
-
- }
- onClick={onClick as any}
- >
-
+ <>
+
+ {isMobile && samples && (
+
+ )}
+ >
);
};
diff --git a/packages/playground/src/react/index.ts b/packages/playground/src/react/index.ts
index 78571a7f948..e0894f80339 100644
--- a/packages/playground/src/react/index.ts
+++ b/packages/playground/src/react/index.ts
@@ -1,3 +1,4 @@
+export type { CommandBarItem } from "../react/responsive-command-bar/index.js";
export { usePlaygroundContext } from "./context/index.js";
export { DiagnosticList } from "./diagnostic-list/diagnostic-list.js";
export type { DiagnosticListProps } from "./diagnostic-list/diagnostic-list.js";
diff --git a/packages/playground/src/react/playground.module.css b/packages/playground/src/react/playground.module.css
index 6f6b9ac94d7..52a4c5aaee8 100644
--- a/packages/playground/src/react/playground.module.css
+++ b/packages/playground/src/react/playground.module.css
@@ -10,3 +10,10 @@
display: flex;
flex-direction: column;
}
+
+.single-pane {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+}
diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx
index 43040743f1c..4598b46ddf2 100644
--- a/packages/playground/src/react/playground.tsx
+++ b/packages/playground/src/react/playground.tsx
@@ -26,8 +26,11 @@ import { useMonacoModel, type OnMountData } from "./editor.js";
import { OutputView } from "./output-view/output-view.js";
import style from "./playground.module.css";
import { ProblemPane } from "./problem-pane/index.js";
+import type { CommandBarItem } from "./responsive-command-bar/index.js";
import type { CompilationState, FileOutputViewer, ProgramViewer } from "./types.js";
+import { useIsMobile } from "./use-mobile.js";
import { usePlaygroundState, type PlaygroundState } from "./use-playground-state.js";
+import { ViewToggle, type ViewMode } from "./view-toggle.js";
// Re-export the PlaygroundState type for convenience
export type { PlaygroundState };
@@ -59,11 +62,8 @@ export interface PlaygroundProps {
onFileBug?: () => void;
- /** Additional buttons to show up in the command bar */
- commandBarButtons?: ReactNode;
-
- /** Playground links */
- links?: PlaygroundLinks;
+ /** Additional items to show in the command bar. */
+ commandBarItems?: CommandBarItem[];
/** Custom viewers to view the typespec program */
viewers?: ProgramViewer[];
@@ -93,11 +93,6 @@ export interface PlaygroundSaveData extends PlaygroundState {
emitter: string;
}
-export interface PlaygroundLinks {
- /** Link to documentation */
- documentationUrl?: string;
-}
-
/**
* Playground component for TypeSpec with consolidated state management.
*
@@ -330,56 +325,81 @@ export const Playground: FunctionComponent = (props) => {
};
}, [host, typespecModel, onContentChange]);
+ const isMobile = useIsMobile();
+ const [viewMode, setViewMode] = useState("editor");
+
+ // Reset to "editor" when entering mobile, force "both" on desktop
+ useEffect(() => {
+ if (!isMobile) {
+ setViewMode("both");
+ } else {
+ setViewMode("editor");
+ }
+ }, [isMobile]);
+
+ const commandBar = (
+
+ );
+
+ const editorPanel = (
+
+ );
+
+ const outputPanel = (
+
+ );
+
+ const mainContent =
+ viewMode === "both" ? (
+
+ {editorPanel}
+ {outputPanel}
+
+ ) : viewMode === "editor" ? (
+ {editorPanel}
+ ) : (
+ {outputPanel}
+ );
+
return (
+ {isMobile && (
+
+ )}
-
-
-
-
- }
- />
-
-
-
-
-
-
+ {mainContent}
void;
+ /** If true, always visible as an icon button. If false (default), goes to overflow menu on mobile. */
+ readonly pinned?: boolean;
+ /** Sub-items rendered as a dropdown (desktop) or nested submenu (mobile). */
+ readonly children?: readonly CommandBarItem[];
+ /** Additional content rendered alongside the command bar (e.g., dialogs triggered by children). */
+ readonly content?: ReactNode;
+ /** Custom toolbar element for desktop rendering. Overrides default and children-based rendering. */
+ readonly toolbarItem?: ReactNode;
+ /** Custom menu element for mobile overflow menu. Overrides default and children-based rendering. */
+ readonly menuItem?: ReactNode;
+ /** Alignment group on the desktop toolbar. Defaults to "left". */
+ readonly align?: "left" | "right";
+}
+
+export interface ResponsiveCommandBarProps {
+ /** The items to render in the command bar. */
+ readonly items: readonly CommandBarItem[];
+ /** Whether to render in mobile (compact) mode. */
+ readonly isMobile: boolean;
+}
+
+/**
+ * A generic responsive command bar that renders items as a toolbar on desktop
+ * and collapses non-pinned items into a hamburger overflow menu on mobile.
+ */
+export const ResponsiveCommandBar: FunctionComponent = ({
+ items,
+ isMobile,
+}) => {
+ const pinnedItems = items.filter((i) => i.pinned);
+ const overflowItems = items.filter((i) => !i.pinned);
+ const leftItems = items.filter((i) => (i.align ?? "left") === "left");
+ const rightItems = items.filter((i) => i.align === "right");
+ const leftOverflow = overflowItems.filter((i) => (i.align ?? "left") === "left");
+ const rightOverflow = overflowItems.filter((i) => i.align === "right");
+
+ return (
+
+
+ {isMobile ? (
+ <>
+ {pinnedItems.map((item) => (
+
+ ))}
+ {overflowItems.length > 0 && (
+ <>
+
+
+ >
+ )}
+ >
+ ) : (
+ <>
+ {leftItems.map((item) => (
+
+ ))}
+ {rightItems.length > 0 && }
+ {rightItems.map((item) => (
+
+ ))}
+ >
+ )}
+
+ {items.map((item) => item.content &&
{item.content})}
+
+ );
+};
+
+const ToolbarItemRenderer: FunctionComponent<{ item: CommandBarItem }> = ({ item }) => {
+ if (item.toolbarItem) return <>{item.toolbarItem}>;
+ if (item.children) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ );
+};
+
+const MenuItemRenderer: FunctionComponent<{ item: CommandBarItem }> = ({ item }) => {
+ if (item.menuItem) return <>{item.menuItem}>;
+ if (item.children) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
diff --git a/packages/playground/src/react/samples-drawer/index.ts b/packages/playground/src/react/samples-drawer/index.ts
index a33875b17a5..aa039ba7f91 100644
--- a/packages/playground/src/react/samples-drawer/index.ts
+++ b/packages/playground/src/react/samples-drawer/index.ts
@@ -1 +1,6 @@
-export { SamplesDrawerTrigger, type SamplesDrawerProps } from "./samples-drawer-trigger.js";
+export {
+ SamplesDrawerOverlay,
+ SamplesDrawerTrigger,
+ type SamplesDrawerOverlayProps,
+ type SamplesDrawerProps,
+} from "./samples-drawer-trigger.js";
diff --git a/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx b/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx
index 68db54f0b37..b9e1401a012 100644
--- a/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx
+++ b/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx
@@ -18,19 +18,64 @@ export interface SamplesDrawerProps {
onSelectedSampleNameChange: (sampleName: string) => void;
}
-export const SamplesDrawerTrigger: FunctionComponent = ({
+export interface SamplesDrawerOverlayProps extends SamplesDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/** The overlay drawer showing the sample gallery. Controlled via open/onOpenChange. */
+export const SamplesDrawerOverlay: FunctionComponent = ({
samples,
onSelectedSampleNameChange,
+ open,
+ onOpenChange,
}) => {
- const [isOpen, setIsOpen] = useState(false);
-
const handleSampleSelect = useCallback(
(sampleName: string) => {
onSelectedSampleNameChange(sampleName);
- setIsOpen(false);
+ onOpenChange(false);
},
- [onSelectedSampleNameChange],
+ [onSelectedSampleNameChange, onOpenChange],
+ );
+
+ return (
+ onOpenChange(data.open)}
+ position="end"
+ size="large"
+ >
+
+ }
+ onClick={() => onOpenChange(false)}
+ />
+ }
+ >
+ Sample Gallery
+
+
+
+
+ {Object.entries(samples).map(([name, sample]) => (
+
+ ))}
+
+
+
);
+};
+
+/** Toolbar button trigger + overlay drawer for samples. */
+export const SamplesDrawerTrigger: FunctionComponent = ({
+ samples,
+ onSelectedSampleNameChange,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
return (
<>
@@ -44,34 +89,12 @@ export const SamplesDrawerTrigger: FunctionComponent = ({
- setIsOpen(data.open)}
- position="end"
- size="large"
- >
-
- }
- onClick={() => setIsOpen(false)}
- />
- }
- >
- Sample Gallery
-
-
-
-
- {Object.entries(samples).map(([name, sample]) => (
-
- ))}
-
-
-
+ onOpenChange={setIsOpen}
+ />
>
);
};
diff --git a/packages/playground/src/react/use-mobile.ts b/packages/playground/src/react/use-mobile.ts
new file mode 100644
index 00000000000..6da4e1fb2d4
--- /dev/null
+++ b/packages/playground/src/react/use-mobile.ts
@@ -0,0 +1,27 @@
+import { useCallback, useEffect, useState } from "react";
+
+const MobileBreakpoint = 768;
+
+/**
+ * Hook that detects whether the viewport is at or below the mobile breakpoint.
+ * Uses `matchMedia` with a listener for responsive changes.
+ */
+export function useIsMobile(): boolean {
+ const query = `(max-width: ${MobileBreakpoint}px)`;
+
+ const getMatch = useCallback(() => {
+ return typeof window !== "undefined" ? window.matchMedia(query).matches : false;
+ }, [query]);
+
+ const [isMobile, setIsMobile] = useState(getMatch);
+
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+ mql.addEventListener("change", handler);
+ setIsMobile(mql.matches);
+ return () => mql.removeEventListener("change", handler);
+ }, [query]);
+
+ return isMobile;
+}
diff --git a/packages/playground/src/react/view-toggle.module.css b/packages/playground/src/react/view-toggle.module.css
new file mode 100644
index 00000000000..e45cc1be49a
--- /dev/null
+++ b/packages/playground/src/react/view-toggle.module.css
@@ -0,0 +1,17 @@
+.view-toggle-bar {
+ display: flex;
+ align-items: center;
+ padding: 0 4px;
+ border-bottom: 1px solid var(--colorNeutralStroke1);
+ background-color: var(--colorNeutralBackground1);
+ flex-shrink: 0;
+}
+
+.view-toggle-tabs {
+ min-height: auto;
+}
+
+.view-toggle-actions {
+ margin-left: auto;
+ overflow: hidden;
+}
diff --git a/packages/playground/src/react/view-toggle.tsx b/packages/playground/src/react/view-toggle.tsx
new file mode 100644
index 00000000000..ebf4bdaa4c1
--- /dev/null
+++ b/packages/playground/src/react/view-toggle.tsx
@@ -0,0 +1,47 @@
+import { Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components";
+import { useCallback, type FunctionComponent, type ReactNode } from "react";
+import style from "./view-toggle.module.css";
+
+export type ViewMode = "editor" | "output" | "both";
+
+export interface ViewToggleProps {
+ viewMode: ViewMode;
+ onViewModeChange: (mode: ViewMode) => void;
+ /** Additional toolbar actions rendered on the right side of the bar. */
+ actions?: ReactNode;
+}
+
+export const ViewToggle: FunctionComponent = ({
+ viewMode,
+ onViewModeChange,
+ actions,
+}) => {
+ const onTabSelect = useCallback(
+ (_, data) => {
+ onViewModeChange(data.value as ViewMode);
+ },
+ [onViewModeChange],
+ );
+
+ return (
+
+
+
+ TSP
+
+
+ Both
+
+
+ Output
+
+
+ {actions &&
{actions}
}
+
+ );
+};
diff --git a/packages/playground/src/vite/types.ts b/packages/playground/src/vite/types.ts
index ace8aebf3ff..34bff44b122 100644
--- a/packages/playground/src/vite/types.ts
+++ b/packages/playground/src/vite/types.ts
@@ -1,4 +1,3 @@
-import type { PlaygroundLinks } from "../react/playground.js";
import type { PlaygroundSampleConfig } from "../tooling/types.js";
import type { PlaygroundSample } from "../types.js";
@@ -14,5 +13,4 @@ export interface PlaygroundConfig {
readonly defaultEmitter: string;
readonly libraries: readonly string[];
readonly samples: Record;
- readonly links?: PlaygroundLinks;
}
diff --git a/packages/playground/test/responsive-command-bar.test.tsx b/packages/playground/test/responsive-command-bar.test.tsx
new file mode 100644
index 00000000000..d6869fd3f96
--- /dev/null
+++ b/packages/playground/test/responsive-command-bar.test.tsx
@@ -0,0 +1,201 @@
+import { FluentProvider, webLightTheme } from "@fluentui/react-components";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ ResponsiveCommandBar,
+ type CommandBarItem,
+} from "../src/react/responsive-command-bar/index.js";
+
+function renderBar(items: CommandBarItem[], isMobile = false) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ResponsiveCommandBar", () => {
+ describe("desktop mode", () => {
+ it("renders all items as toolbar buttons", () => {
+ const items: CommandBarItem[] = [
+ { id: "save", label: "Save", onClick: vi.fn() },
+ { id: "format", label: "Format", onClick: vi.fn() },
+ ];
+ renderBar(items, false);
+ expect(screen.getByLabelText("Save")).toBeInTheDocument();
+ expect(screen.getByLabelText("Format")).toBeInTheDocument();
+ });
+
+ it("renders left items before right items with a divider between", () => {
+ const items: CommandBarItem[] = [
+ { id: "left1", label: "Left One", onClick: vi.fn() },
+ { id: "right1", label: "Right One", onClick: vi.fn(), align: "right" },
+ ];
+ renderBar(items, false);
+ expect(screen.getByLabelText("Left One")).toBeInTheDocument();
+ expect(screen.getByLabelText("Right One")).toBeInTheDocument();
+ });
+
+ it("calls onClick when a toolbar button is clicked", () => {
+ const onClick = vi.fn();
+ renderBar([{ id: "action", label: "Action", onClick }], false);
+ fireEvent.click(screen.getByLabelText("Action"));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("renders custom toolbarItem when provided", () => {
+ const items: CommandBarItem[] = [
+ {
+ id: "custom",
+ label: "Custom",
+ toolbarItem: ,
+ },
+ ];
+ renderBar(items, false);
+ expect(screen.getByTestId("custom-btn")).toBeInTheDocument();
+ });
+
+ it("renders children as a dropdown menu", () => {
+ const childClick = vi.fn();
+ const items: CommandBarItem[] = [
+ {
+ id: "parent",
+ label: "Parent",
+ children: [{ id: "child1", label: "Child One", onClick: childClick }],
+ },
+ ];
+ renderBar(items, false);
+ // The parent renders as a button with aria-label
+ const trigger = screen.getByLabelText("Parent");
+ fireEvent.click(trigger);
+ // After clicking, the dropdown should show the child
+ expect(screen.getByText("Child One")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Child One"));
+ expect(childClick).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("mobile mode", () => {
+ it("renders pinned items directly and overflow in a menu", () => {
+ const items: CommandBarItem[] = [
+ { id: "pinned", label: "Pinned", onClick: vi.fn(), pinned: true },
+ { id: "overflow", label: "Overflow Item", onClick: vi.fn() },
+ ];
+ renderBar(items, true);
+ // Pinned item is directly visible
+ expect(screen.getByLabelText("Pinned")).toBeInTheDocument();
+ // Overflow item is hidden behind the hamburger
+ expect(screen.queryByText("Overflow Item")).not.toBeInTheDocument();
+ // Open the hamburger menu
+ fireEvent.click(screen.getByLabelText("More actions"));
+ expect(screen.getByText("Overflow Item")).toBeInTheDocument();
+ });
+
+ it("calls onClick on overflow menu item click", () => {
+ const onClick = vi.fn();
+ const items: CommandBarItem[] = [{ id: "action", label: "Action", onClick }];
+ renderBar(items, true);
+ fireEvent.click(screen.getByLabelText("More actions"));
+ fireEvent.click(screen.getByText("Action"));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("renders a divider between left and right overflow items", () => {
+ const items: CommandBarItem[] = [
+ { id: "left", label: "Left Item", onClick: vi.fn() },
+ { id: "right", label: "Right Item", onClick: vi.fn(), align: "right" },
+ ];
+ const { container } = renderBar(items, true);
+ fireEvent.click(screen.getByLabelText("More actions"));
+ // Both items should be visible in the menu
+ expect(screen.getByText("Left Item")).toBeInTheDocument();
+ expect(screen.getByText("Right Item")).toBeInTheDocument();
+ // MenuDivider renders as an element with role="separator"
+ const divider = container.ownerDocument.querySelector(
+ "[class*='fui-MenuDivider'], [role='separator']",
+ );
+ expect(divider).toBeTruthy();
+ });
+
+ it("does not render divider when all overflow items are on the same side", () => {
+ const items: CommandBarItem[] = [
+ { id: "a", label: "Item A", onClick: vi.fn() },
+ { id: "b", label: "Item B", onClick: vi.fn() },
+ ];
+ renderBar(items, true);
+ fireEvent.click(screen.getByLabelText("More actions"));
+ const menuPopover = screen.getByText("Item A").closest("[role='menu']");
+ expect(menuPopover?.querySelector("[role='separator']")).not.toBeInTheDocument();
+ });
+
+ it("renders custom menuItem when provided", () => {
+ const items: CommandBarItem[] = [
+ {
+ id: "custom",
+ label: "Custom",
+ menuItem: Custom Menu Content
,
+ },
+ ];
+ renderBar(items, true);
+ fireEvent.click(screen.getByLabelText("More actions"));
+ expect(screen.getByTestId("custom-menu")).toBeInTheDocument();
+ });
+
+ it("does not show hamburger when all items are pinned", () => {
+ const items: CommandBarItem[] = [
+ { id: "a", label: "A", onClick: vi.fn(), pinned: true },
+ { id: "b", label: "B", onClick: vi.fn(), pinned: true },
+ ];
+ renderBar(items, true);
+ expect(screen.queryByLabelText("More actions")).not.toBeInTheDocument();
+ });
+
+ it("renders children as a nested submenu in overflow", () => {
+ const childClick = vi.fn();
+ const items: CommandBarItem[] = [
+ {
+ id: "parent",
+ label: "Parent",
+ children: [{ id: "child", label: "Nested Child", onClick: childClick }],
+ },
+ ];
+ renderBar(items, true);
+ fireEvent.click(screen.getByLabelText("More actions"));
+ // Parent appears as a menu item that triggers a submenu
+ const parentItem = screen.getByText("Parent");
+ expect(parentItem).toBeInTheDocument();
+ fireEvent.click(parentItem);
+ expect(screen.getByText("Nested Child")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Nested Child"));
+ expect(childClick).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("content rendering", () => {
+ it("renders item content outside the toolbar", () => {
+ const items: CommandBarItem[] = [
+ {
+ id: "with-content",
+ label: "Item",
+ onClick: vi.fn(),
+ content: Extra
,
+ },
+ ];
+ renderBar(items, false);
+ expect(screen.getByTestId("extra-content")).toBeInTheDocument();
+ });
+
+ it("renders content in mobile mode too", () => {
+ const items: CommandBarItem[] = [
+ {
+ id: "with-content",
+ label: "Item",
+ onClick: vi.fn(),
+ content: Mobile Extra
,
+ },
+ ];
+ renderBar(items, true);
+ expect(screen.getByTestId("mobile-content")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/playground/test/setup.ts b/packages/playground/test/setup.ts
new file mode 100644
index 00000000000..e26219327da
--- /dev/null
+++ b/packages/playground/test/setup.ts
@@ -0,0 +1,7 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json
index eb96db71ba9..2b6c4756fe3 100644
--- a/packages/playground/tsconfig.json
+++ b/packages/playground/tsconfig.json
@@ -14,5 +14,6 @@
"jsx": "react-jsx",
"lib": ["DOM"]
},
+ "exclude": ["test", "vitest.config.ts"],
"references": [{ "path": "../compiler/tsconfig.json" }]
}
diff --git a/packages/playground/vitest.config.ts b/packages/playground/vitest.config.ts
new file mode 100644
index 00000000000..127225407fb
--- /dev/null
+++ b/packages/playground/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig, mergeConfig } from "vitest/config";
+import { defaultTypeSpecVitestConfig } from "../../vitest.config.js";
+
+export default mergeConfig(
+ defaultTypeSpecVitestConfig,
+ defineConfig({
+ test: {
+ environment: "happy-dom",
+ setupFiles: "./test/setup.ts",
+ },
+ }),
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 99e5c06ef65..aefd46454c1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1481,6 +1481,15 @@ importers:
'@storybook/react-vite':
specifier: ^10.1.8
version: 10.2.12(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.49.0)(storybook@10.2.12(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2))
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.1
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.2.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/debounce':
specifier: ~1.2.4
version: 1.2.4
@@ -1529,6 +1538,9 @@ importers:
vite-plugin-dts:
specifier: 4.5.4
version: 4.5.4(@types/node@25.3.0)(rollup@4.49.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2))
+ vitest:
+ specifier: ^4.0.18
+ version: 4.0.18(@types/node@25.3.0)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2)
packages/playground-website:
dependencies:
diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx
index fd1b3618093..2b19e2f401b 100644
--- a/website/src/components/playground-component/playground.tsx
+++ b/website/src/components/playground-component/playground.tsx
@@ -1,6 +1,6 @@
import versions from "@site/playground-versions.json";
import { useTheme } from "@typespec/astro-utils/utils/theme-react";
-import { ImportToolbarButton, TypeSpecPlaygroundConfig } from "@typespec/playground-website";
+import { TypeSpecPlaygroundConfig, useImportCommandBarItem } from "@typespec/playground-website";
import {
Footer,
FooterVersionItem,
@@ -27,6 +27,7 @@ export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => {
return { theme: theme === "dark" ? "typespec-dark" : "typespec" };
}, [theme]);
+ const importItem = useImportCommandBarItem();
const imports = Object.keys(versionData.importMap.imports).filter(
(x) => (x.match(/\//g) || []).length < 2, // Don't include sub imports as libraries.
);
@@ -40,7 +41,7 @@ export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => {
footer={}
fallback={}
onFileBug={fileBugToGithub}
- commandBarButtons={}
+ commandBarItems={[importItem]}
/>
);
};