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 ( - <> - - - - } - /> - - - - - setOpen("tsp")}>Remote TypeSpec - setOpen("openapi3")}>From OpenAPI 3 spec - - - +/** 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)} />, + }; +} - setOpen(undefined)}> - - - Settings - - {open === "openapi3" && setOpen(undefined)} />} - {open === "tsp" && setOpen(undefined)} />} - - - - - +const ImportDialog: FunctionComponent<{ + open: "openapi3" | "tsp" | undefined; + onClose: () => void; +}> = ({ open, onClose }) => { + return ( + onClose()}> + + + Settings + + {open === "openapi3" && } + {open === "tsp" && } + + + + ); }; 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 && ( + <> +
+ + + + } + appearance="subtle" + /> + + + + + {leftOverflow.map((item) => ( + + ))} + {leftOverflow.length > 0 && rightOverflow.length > 0 && } + {rightOverflow.map((item) => ( + + ))} + + + + + )} + + ) : ( + <> + {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 ( + + + + + + + + + {item.children.map((child) => ( + + {child.label} + + ))} + + + + ); + } + return ( + + + + ); +}; + +const MenuItemRenderer: FunctionComponent<{ item: CommandBarItem }> = ({ item }) => { + if (item.menuItem) return <>{item.menuItem}; + if (item.children) { + return ( + + + {item.label} + + + + {item.children.map((child) => ( + + {child.label} + + ))} + + + + ); + } + return ( + + {item.label} + + ); +}; 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]} /> ); };