From 8def0d048c8072fc6908e961402348867f6a0073 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 20:10:16 -0400 Subject: [PATCH 01/13] Split view --- .../src/react/playground.module.css | 7 ++ packages/playground/src/react/playground.tsx | 116 +++++++++++------- packages/playground/src/react/use-mobile.ts | 27 ++++ .../src/react/view-toggle.module.css | 13 ++ packages/playground/src/react/view-toggle.tsx | 40 ++++++ 5 files changed, 157 insertions(+), 46 deletions(-) create mode 100644 packages/playground/src/react/use-mobile.ts create mode 100644 packages/playground/src/react/view-toggle.module.css create mode 100644 packages/playground/src/react/view-toggle.tsx 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..38f062fe435 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -27,7 +27,9 @@ import { OutputView } from "./output-view/output-view.js"; import style from "./playground.module.css"; import { ProblemPane } from "./problem-pane/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 }; @@ -330,56 +332,78 @@ 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 editorPanel = ( + + } + /> + ); + + const outputPanel = ( + + ); + + const mainContent = + viewMode === "both" ? ( + + {editorPanel} + {outputPanel} + + ) : viewMode === "editor" ? ( +
{editorPanel}
+ ) : ( +
{outputPanel}
+ ); + return (
+ {isMobile && } - - - - - } - /> - - - - - - + {mainContent} { + 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..0957446babd --- /dev/null +++ b/packages/playground/src/react/view-toggle.module.css @@ -0,0 +1,13 @@ +.view-toggle-bar { + display: flex; + justify-content: center; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid var(--colorNeutralStroke1); + background-color: var(--colorNeutralBackground1); + flex-shrink: 0; +} + +.view-toggle-tabs { + min-height: auto; +} diff --git a/packages/playground/src/react/view-toggle.tsx b/packages/playground/src/react/view-toggle.tsx new file mode 100644 index 00000000000..9ed981f07e1 --- /dev/null +++ b/packages/playground/src/react/view-toggle.tsx @@ -0,0 +1,40 @@ +import { Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components"; +import { useCallback, type FunctionComponent } from "react"; +import style from "./view-toggle.module.css"; + +export type ViewMode = "editor" | "output" | "both"; + +export interface ViewToggleProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; +} + +export const ViewToggle: FunctionComponent = ({ viewMode, onViewModeChange }) => { + const onTabSelect = useCallback( + (_, data) => { + onViewModeChange(data.value as ViewMode); + }, + [onViewModeChange], + ); + + return ( +
+ + + Editor + + + Both + + + Output + + +
+ ); +}; From 41ee1df8b83364dd570a647945f204a6f84c5514 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 08:20:30 -0400 Subject: [PATCH 02/13] Better --- packages/playground-website/src/import.tsx | 54 ++++-- packages/playground-website/src/main.tsx | 3 +- .../editor-command-bar/editor-command-bar.tsx | 170 ++++++++++++++++-- packages/playground/src/react/playground.tsx | 40 +++-- .../src/react/samples-drawer/index.ts | 7 +- .../samples-drawer/samples-drawer-trigger.tsx | 87 +++++---- .../src/react/view-toggle.module.css | 8 +- packages/playground/src/react/view-toggle.tsx | 11 +- 8 files changed, 300 insertions(+), 80 deletions(-) diff --git a/packages/playground-website/src/import.tsx b/packages/playground-website/src/import.tsx index 964a2b933a2..851dba70372 100644 --- a/packages/playground-website/src/import.tsx +++ b/packages/playground-website/src/import.tsx @@ -23,7 +23,7 @@ import { useMonacoModel, usePlaygroundContext, } from "@typespec/playground/react"; -import { ReactNode, useState } from "react"; +import { type FunctionComponent, type ReactNode, useState } from "react"; import { parse } from "yaml"; import style from "./import.module.css"; @@ -50,21 +50,51 @@ export const ImportToolbarButton = () => { - setOpen(undefined)}> - - - Settings - - {open === "openapi3" && setOpen(undefined)} />} - {open === "tsp" && setOpen(undefined)} />} - - - - + setOpen(undefined)} /> ); }; +export const ImportMenuItem = () => { + const [open, setOpen] = useState<"openapi3" | "tsp" | undefined>(); + + return ( + <> + + + }>Import + + + + setOpen("tsp")}>Remote TypeSpec + setOpen("openapi3")}>From OpenAPI 3 spec + + + + setOpen(undefined)} /> + + ); +}; + +const ImportDialog: FunctionComponent<{ + open: "openapi3" | "tsp" | undefined; + onClose: () => void; +}> = ({ open, onClose }) => { + return ( + onClose()}> + + + Settings + + {open === "openapi3" && } + {open === "tsp" && } + + + + + ); +}; + const ImportOpenAPI3 = ({ onImport }: { onImport: () => void }) => { const [error, setError] = useState(null); const model = useMonacoModel("openapi3.yaml"); diff --git a/packages/playground-website/src/main.tsx b/packages/playground-website/src/main.tsx index e4fa8a5c71a..41f9054487c 100644 --- a/packages/playground-website/src/main.tsx +++ b/packages/playground-website/src/main.tsx @@ -10,7 +10,7 @@ import { import { SwaggerUIViewer } from "@typespec/playground/react/viewers"; import "@typespec/playground/styles.css"; import samples from "../samples/dist/samples.js"; -import { ImportToolbarButton } from "./import.js"; +import { ImportMenuItem, ImportToolbarButton } from "./import.js"; import "./style.css"; registerMonacoDefaultWorkersForVite(); @@ -53,6 +53,7 @@ await renderReactPlayground({ }, footer: , commandBarButtons: , + commandBarMenuItems: , onFileBug: () => { const bodyPayload = encodeURIComponent(`\n\n\n[Playground Link](${document.location.href})`); const url = `https://github.com/microsoft/typespec/issues/new?body=${bodyPayload}`; 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..e2971a1a2c4 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx +++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx @@ -1,8 +1,28 @@ -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 { + Link, + Menu, + MenuDivider, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + Toolbar, + ToolbarButton, + Tooltip, +} from "@fluentui/react-components"; +import { + BookOpen16Regular, + Broom16Filled, + Bug16Regular, + Checkmark16Regular, + DocumentBulletList24Regular, + MoreHorizontal24Filled, + Save16Regular, +} from "@fluentui/react-icons"; +import { useCallback, useMemo, useState, type FunctionComponent, type ReactNode } from "react"; import { EmitterDropdown } from "../react/emitter-dropdown.js"; -import { SamplesDrawerTrigger } from "../react/samples-drawer/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"; @@ -12,6 +32,8 @@ export interface EditorCommandBarProps { formatCode: () => Promise | void; fileBug?: () => Promise | void; commandBarButtons?: ReactNode; + /** Menu items version of commandBarButtons for use in mobile overflow menu */ + commandBarMenuItems?: ReactNode; host: BrowserHost; selectedEmitter: string; onSelectedEmitterChange: (emitter: string) => void; @@ -32,16 +54,9 @@ export const EditorCommandBar: FunctionComponent = ({ selectedSampleName, onSelectedSampleNameChange, commandBarButtons, + commandBarMenuItems, }) => { - const documentation = documentationUrl ? ( - - ) : undefined; - - const bugButton = fileBug ? : undefined; + const isMobile = useIsMobile(); const emitters = useMemo( () => @@ -51,6 +66,31 @@ export const EditorCommandBar: FunctionComponent = ({ [host.libraries], ); + if (isMobile) { + return ( + + ); + } + + const documentation = documentationUrl ? ( + + ) : undefined; + return (
@@ -83,12 +123,114 @@ export const EditorCommandBar: FunctionComponent = ({ )}
{commandBarButtons} - {bugButton} + {fileBug && }
); }; +interface MobileCommandBarProps { + documentationUrl?: string; + saveCode: () => Promise | void; + formatCode: () => Promise | void; + fileBug?: () => Promise | void; + emitters: string[]; + selectedEmitter: string; + onSelectedEmitterChange: (emitter: string) => void; + samples?: Record; + onSelectedSampleNameChange: (sampleName: string) => void; + commandBarMenuItems?: ReactNode; +} + +const MobileCommandBar: FunctionComponent = ({ + documentationUrl, + saveCode, + formatCode, + fileBug, + emitters, + selectedEmitter, + onSelectedEmitterChange, + samples, + onSelectedSampleNameChange, + commandBarMenuItems, +}) => { + const [samplesOpen, setSamplesOpen] = useState(false); + + const handleFileBug = useCallback(() => { + if (fileBug) void fileBug(); + }, [fileBug]); + + return ( +
+ + + } onClick={saveCode as any} /> + + + } onClick={formatCode as any} /> + +
+ + + + } + appearance="subtle" + /> + + + + + {emitters.map((emitter) => ( + : undefined} + onClick={() => onSelectedEmitterChange(emitter)} + > + {emitter} + + ))} + + {samples && ( + } + onClick={() => setSamplesOpen(true)} + > + Browse Samples + + )} + {commandBarMenuItems} + {fileBug && ( + } onClick={handleFileBug}> + File Bug + + )} + {documentationUrl && ( + } + onClick={() => window.open(documentationUrl, "_blank")} + > + Documentation + + )} + + + +
+ + {samples && ( + + )} +
+ ); +}; + interface FileBugButtonProps { onClick: () => Promise | void; } diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 38f062fe435..e8f78f0d2a1 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -64,6 +64,9 @@ export interface PlaygroundProps { /** Additional buttons to show up in the command bar */ commandBarButtons?: ReactNode; + /** Menu items version of commandBarButtons for use in mobile overflow menu */ + commandBarMenuItems?: ReactNode; + /** Playground links */ links?: PlaygroundLinks; @@ -344,6 +347,23 @@ export const Playground: FunctionComponent = (props) => { } }, [isMobile]); + const commandBar = ( + + ); + const editorPanel = ( = (props) => { compilerOptions={compilerOptions} onCompilerOptionsChange={onCompilerOptionsChange} onSelectedEmitterChange={onSelectedEmitterChange} - commandBar={ - - } + commandBar={isMobile ? undefined : commandBar} /> ); @@ -401,7 +407,9 @@ export const Playground: FunctionComponent = (props) => { return (
- {isMobile && } + {isMobile && ( + + )} {mainContent} 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/view-toggle.module.css b/packages/playground/src/react/view-toggle.module.css index 0957446babd..e45cc1be49a 100644 --- a/packages/playground/src/react/view-toggle.module.css +++ b/packages/playground/src/react/view-toggle.module.css @@ -1,8 +1,7 @@ .view-toggle-bar { display: flex; - justify-content: center; align-items: center; - padding: 4px 0; + padding: 0 4px; border-bottom: 1px solid var(--colorNeutralStroke1); background-color: var(--colorNeutralBackground1); flex-shrink: 0; @@ -11,3 +10,8 @@ .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 index 9ed981f07e1..11d98b3c38a 100644 --- a/packages/playground/src/react/view-toggle.tsx +++ b/packages/playground/src/react/view-toggle.tsx @@ -1,5 +1,5 @@ import { Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components"; -import { useCallback, type FunctionComponent } from "react"; +import { useCallback, type FunctionComponent, type ReactNode } from "react"; import style from "./view-toggle.module.css"; export type ViewMode = "editor" | "output" | "both"; @@ -7,9 +7,15 @@ 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 }) => { +export const ViewToggle: FunctionComponent = ({ + viewMode, + onViewModeChange, + actions, +}) => { const onTabSelect = useCallback( (_, data) => { onViewModeChange(data.value as ViewMode); @@ -35,6 +41,7 @@ export const ViewToggle: FunctionComponent = ({ viewMode, onVie Output + {actions &&
{actions}
}
); }; From 20278d00914759db7c6b6d8b9aaec1afcfab54d2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 10:20:38 -0400 Subject: [PATCH 03/13] better setup --- packages/playground-website/src/import.tsx | 78 ++-- packages/playground-website/src/index.ts | 2 +- packages/playground-website/src/main.tsx | 5 +- .../editor-command-bar/editor-command-bar.tsx | 425 +++++++++++------- packages/playground/src/react/index.ts | 1 + packages/playground/src/react/playground.tsx | 12 +- .../playground-component/playground.tsx | 4 +- 7 files changed, 293 insertions(+), 234 deletions(-) diff --git a/packages/playground-website/src/import.tsx b/packages/playground-website/src/import.tsx index 851dba70372..61cdbdfeb62 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,58 +15,41 @@ import { Editor, useMonacoModel, usePlaygroundContext, + type CommandBarItem, } from "@typespec/playground/react"; -import { type FunctionComponent, type 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 - - - +/** Creates a CommandBarItem for the Import action with sub-menu items. */ +export function createImportCommandBarItem(): CommandBarItem { + const openRef: { current?: (type: ImportType) => void } = { current: undefined }; - setOpen(undefined)} /> - - ); -}; + return { + id: "import", + label: "Import", + icon: , + children: [ + { id: "import-tsp", label: "Remote TypeSpec", onClick: () => openRef.current?.("tsp") }, + { + id: "import-openapi3", + label: "From OpenAPI 3 spec", + onClick: () => openRef.current?.("openapi3"), + }, + ], + content: , + }; +} -export const ImportMenuItem = () => { - const [open, setOpen] = useState<"openapi3" | "tsp" | undefined>(); +const ImportDialogContent: FunctionComponent<{ + openRef: { current?: (type: ImportType) => void }; +}> = ({ openRef }) => { + const [open, setOpen] = useState(); + openRef.current = setOpen; - return ( - <> - - - }>Import - - - - setOpen("tsp")}>Remote TypeSpec - setOpen("openapi3")}>From OpenAPI 3 spec - - - - setOpen(undefined)} /> - - ); + return setOpen(undefined)} />; }; const ImportDialog: FunctionComponent<{ diff --git a/packages/playground-website/src/index.ts b/packages/playground-website/src/index.ts index 1bc54a0a6fd..895b39d4301 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 { createImportCommandBarItem } from "./import.js"; diff --git a/packages/playground-website/src/main.tsx b/packages/playground-website/src/main.tsx index 41f9054487c..a4f2ef6e92e 100644 --- a/packages/playground-website/src/main.tsx +++ b/packages/playground-website/src/main.tsx @@ -10,7 +10,7 @@ import { import { SwaggerUIViewer } from "@typespec/playground/react/viewers"; import "@typespec/playground/styles.css"; import samples from "../samples/dist/samples.js"; -import { ImportMenuItem, ImportToolbarButton } from "./import.js"; +import { createImportCommandBarItem } from "./import.js"; import "./style.css"; registerMonacoDefaultWorkersForVite(); @@ -52,8 +52,7 @@ await renderReactPlayground({ useShim: true, }, footer: , - commandBarButtons: , - commandBarMenuItems: , + commandBarItems: [createImportCommandBarItem()], onFileBug: () => { const bodyPayload = encodeURIComponent(`\n\n\n[Playground Link](${document.location.href})`); const url = `https://github.com/microsoft/typespec/issues/new?body=${bodyPayload}`; 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 e2971a1a2c4..edb37887255 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx +++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx @@ -19,21 +19,55 @@ import { MoreHorizontal24Filled, Save16Regular, } from "@fluentui/react-icons"; -import { useCallback, useMemo, useState, type FunctionComponent, type ReactNode } from "react"; +import { + Fragment, + useCallback, + useMemo, + useState, + type FunctionComponent, + type ReactNode, +} from "react"; import { EmitterDropdown } from "../react/emitter-dropdown.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"; +/** Defines a single item in the editor command bar. */ +export interface CommandBarItem { + /** Unique identifier for the item. */ + readonly id: string; + /** Display label used for tooltip (desktop) and menu text (mobile). */ + readonly label: string; + /** Icon element. */ + readonly icon?: ReactNode; + /** Click handler for simple items. */ + readonly onClick?: () => 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 this item (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; + /** Renders a divider after this item in the mobile overflow menu. */ + readonly overflowDivider?: boolean; + /** Renders a flex spacer before this item on the desktop toolbar. */ + readonly toolbarSpacer?: boolean; + /** Renders a divider before this item on the desktop toolbar. */ + readonly toolbarDivider?: boolean; +} + export interface EditorCommandBarProps { documentationUrl?: string; saveCode: () => Promise | void; formatCode: () => Promise | void; fileBug?: () => Promise | void; - commandBarButtons?: ReactNode; - /** Menu items version of commandBarButtons for use in mobile overflow menu */ - commandBarMenuItems?: ReactNode; + /** Additional items provided by the consumer. */ + commandBarItems?: readonly CommandBarItem[]; host: BrowserHost; selectedEmitter: string; onSelectedEmitterChange: (emitter: string) => void; @@ -42,21 +76,23 @@ export interface EditorCommandBarProps { selectedSampleName: string; onSelectedSampleNameChange: (sampleName: string) => void; } -export const EditorCommandBar: FunctionComponent = ({ - documentationUrl, - saveCode, - formatCode, - fileBug, - host, - selectedEmitter, - onSelectedEmitterChange, - samples, - selectedSampleName, - onSelectedSampleNameChange, - commandBarButtons, - commandBarMenuItems, -}) => { + +export const EditorCommandBar: FunctionComponent = (props) => { + const { + documentationUrl, + saveCode, + formatCode, + fileBug, + host, + selectedEmitter, + onSelectedEmitterChange, + samples, + onSelectedSampleNameChange, + commandBarItems: externalItems, + } = props; + const isMobile = useIsMobile(); + const [samplesDrawerOpen, setSamplesDrawerOpen] = useState(false); const emitters = useMemo( () => @@ -66,183 +102,234 @@ export const EditorCommandBar: FunctionComponent = ({ [host.libraries], ); - if (isMobile) { - return ( - - ); - } + const handleFileBug = useCallback(() => { + if (fileBug) void fileBug(); + }, [fileBug]); - const documentation = documentationUrl ? ( - - ) : undefined; + 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, + }, + ]; - return ( -
- - - } onClick={saveCode as any} /> - - - } onClick={formatCode as any} /> - - {samples && ( - <> - -
- - )} + if (samples) { + result.push({ + id: "samples", + label: "Browse Samples", + icon: , + onClick: () => setSamplesDrawerOpen(true), + toolbarItem: ( + + ), + }); + } + + result.push({ + id: "emitter", + label: "Emitter", + toolbarSpacer: true, + toolbarItem: ( + ), + menuItem: ( + <> + {emitters.map((emitter) => ( + : undefined} + onClick={() => onSelectedEmitterChange(emitter)} + > + {emitter} + + ))} + + ), + overflowDivider: true, + }); - {documentation && ( - <> -
- {documentation} - - )} -
- {commandBarButtons} - {fileBug && } -
-
- ); -}; + if (documentationUrl) { + result.push({ + id: "docs", + label: "Documentation", + icon: , + onClick: () => window.open(documentationUrl, "_blank"), + toolbarSpacer: true, + toolbarItem: ( + + ), + }); + } -interface MobileCommandBarProps { - documentationUrl?: string; - saveCode: () => Promise | void; - formatCode: () => Promise | void; - fileBug?: () => Promise | void; - emitters: string[]; - selectedEmitter: string; - onSelectedEmitterChange: (emitter: string) => void; - samples?: Record; - onSelectedSampleNameChange: (sampleName: string) => void; - commandBarMenuItems?: ReactNode; -} + if (externalItems && externalItems.length > 0) { + const [first, ...rest] = externalItems; + result.push({ ...first, toolbarDivider: first.toolbarDivider ?? true }); + result.push(...rest); + } -const MobileCommandBar: FunctionComponent = ({ - documentationUrl, - saveCode, - formatCode, - fileBug, - emitters, - selectedEmitter, - onSelectedEmitterChange, - samples, - onSelectedSampleNameChange, - commandBarMenuItems, -}) => { - const [samplesOpen, setSamplesOpen] = useState(false); + if (fileBug) { + result.push({ + id: "file-bug", + label: "File Bug", + icon: , + onClick: handleFileBug, + }); + } - const handleFileBug = useCallback(() => { - if (fileBug) void fileBug(); - }, [fileBug]); + return result; + }, [ + saveCode, + formatCode, + samples, + onSelectedSampleNameChange, + emitters, + selectedEmitter, + onSelectedEmitterChange, + documentationUrl, + externalItems, + fileBug, + handleFileBug, + ]); + + const pinnedItems = items.filter((i) => i.pinned); + const overflowItems = items.filter((i) => !i.pinned); return (
- - } onClick={saveCode as any} /> - - - } onClick={formatCode as any} /> - -
- - - - } - appearance="subtle" - /> - - - - - {emitters.map((emitter) => ( - : undefined} - onClick={() => onSelectedEmitterChange(emitter)} - > - {emitter} - - ))} - - {samples && ( - } - onClick={() => setSamplesOpen(true)} - > - Browse Samples - - )} - {commandBarMenuItems} - {fileBug && ( - } onClick={handleFileBug}> - File Bug - - )} - {documentationUrl && ( - } - onClick={() => window.open(documentationUrl, "_blank")} - > - Documentation - - )} - - - + {isMobile ? ( + <> + {pinnedItems.map((item) => ( + + ))} + {overflowItems.length > 0 && ( + <> +
+ + + + } + appearance="subtle" + /> + + + + + {overflowItems.map((item) => ( + + + {item.overflowDivider && } + + ))} + + + + + )} + + ) : ( + items.map((item) => ( + + {item.toolbarSpacer &&
} + {item.toolbarDivider &&
} + + + )) + )} - - {samples && ( + {isMobile && samples && ( )} + {items.map((item) => item.content && {item.content})}
); }; -interface FileBugButtonProps { - onClick: () => Promise | void; -} -const FileBugButton: FunctionComponent = ({ onClick }) => { +const ToolbarItemRenderer: FunctionComponent<{ item: CommandBarItem }> = ({ item }) => { + if (item.toolbarItem) return <>{item.toolbarItem}; + if (item.children) { + return ( + + + + + + + + + {item.children.map((child) => ( + + {child.label} + + ))} + + + + ); + } return ( - + } - onClick={onClick as any} - > + aria-label={item.label} + icon={item.icon as any} + onClick={item.onClick as any} + /> ); }; + +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/index.ts b/packages/playground/src/react/index.ts index 78571a7f948..6f2d4438a66 100644 --- a/packages/playground/src/react/index.ts +++ b/packages/playground/src/react/index.ts @@ -1,3 +1,4 @@ +export type { CommandBarItem } from "../editor-command-bar/editor-command-bar.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.tsx b/packages/playground/src/react/playground.tsx index e8f78f0d2a1..90197e668e8 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -15,7 +15,7 @@ import { } from "react"; import { CompletionItemTag } from "vscode-languageserver"; import { resolveVirtualPath } from "../browser-host.js"; -import { EditorCommandBar } from "../editor-command-bar/editor-command-bar.js"; +import { EditorCommandBar, type CommandBarItem } from "../editor-command-bar/editor-command-bar.js"; import { getMonacoRange, updateDiagnosticsForCodeFixes } from "../services.js"; import type { BrowserHost, PlaygroundSample } from "../types.js"; import { PlaygroundContextProvider } from "./context/playground-context.js"; @@ -61,11 +61,8 @@ export interface PlaygroundProps { onFileBug?: () => void; - /** Additional buttons to show up in the command bar */ - commandBarButtons?: ReactNode; - - /** Menu items version of commandBarButtons for use in mobile overflow menu */ - commandBarMenuItems?: ReactNode; + /** Additional items to show in the command bar. */ + commandBarItems?: CommandBarItem[]; /** Playground links */ links?: PlaygroundLinks; @@ -358,8 +355,7 @@ export const Playground: FunctionComponent = (props) => { saveCode={saveCode} formatCode={formatCode} fileBug={props.onFileBug ? fileBug : undefined} - commandBarButtons={props.commandBarButtons} - commandBarMenuItems={props.commandBarMenuItems} + commandBarItems={props.commandBarItems} documentationUrl={props.links?.documentationUrl} /> ); diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index fd1b3618093..61ee48bbbc2 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 { createImportCommandBarItem, TypeSpecPlaygroundConfig } from "@typespec/playground-website"; import { Footer, FooterVersionItem, @@ -40,7 +40,7 @@ export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => { footer={} fallback={} onFileBug={fileBugToGithub} - commandBarButtons={} + commandBarItems={[createImportCommandBarItem()]} /> ); }; From fdf2045b6c3dac65fb7ad5ae67443b4b0c147af3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 10:33:30 -0400 Subject: [PATCH 04/13] better emitter --- .../editor-command-bar/editor-command-bar.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) 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 edb37887255..e47cbc97037 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx +++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx @@ -150,19 +150,12 @@ export const EditorCommandBar: FunctionComponent = (props onSelectedEmitterChange={onSelectedEmitterChange} /> ), - menuItem: ( - <> - {emitters.map((emitter) => ( - : undefined} - onClick={() => onSelectedEmitterChange(emitter)} - > - {emitter} - - ))} - - ), + children: emitters.map((emitter) => ({ + id: `emitter-${emitter}`, + label: emitter, + icon: emitter === selectedEmitter ? : undefined, + onClick: () => onSelectedEmitterChange(emitter), + })), overflowDivider: true, }); From 409c44939616cbdeee5b29444e896a04282946c9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 11:04:58 -0400 Subject: [PATCH 05/13] tweak name --- packages/playground/src/react/view-toggle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playground/src/react/view-toggle.tsx b/packages/playground/src/react/view-toggle.tsx index 11d98b3c38a..ebf4bdaa4c1 100644 --- a/packages/playground/src/react/view-toggle.tsx +++ b/packages/playground/src/react/view-toggle.tsx @@ -32,7 +32,7 @@ export const ViewToggle: FunctionComponent = ({ className={style["view-toggle-tabs"]} > - Editor + TSP Both From 22dae1fc3e9b970f5d49c416a8e96f50a65a40e0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 08:18:44 -0700 Subject: [PATCH 06/13] Change changeKind to feature and improve mobile UI Make UI more mobile friendly by adding a toggle switch, hiding less important tools, and updating the custom toolbar. --- .../changes/playground-mobile-2026-2-13-15-5-48.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .chronus/changes/playground-mobile-2026-2-13-15-5-48.md 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..4fc7739274d --- /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 hide less important tools behind `...` + - [API] Update custom toolbar to take a menu item instead of generic react node. From c885fd2903644fb5ec3bd061b0eea47430613c33 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 12:15:41 -0400 Subject: [PATCH 07/13] simplify --- .../editor-command-bar.module.css | 4 -- .../editor-command-bar/editor-command-bar.tsx | 50 +++++++++---------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/playground/src/editor-command-bar/editor-command-bar.module.css b/packages/playground/src/editor-command-bar/editor-command-bar.module.css index d2a83ae6c1a..d3b9a8de710 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.module.css +++ b/packages/playground/src/editor-command-bar/editor-command-bar.module.css @@ -5,7 +5,3 @@ .divider { flex: 1; } - -.spacer { - width: 10px; -} 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 e47cbc97037..20d6de45be2 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx +++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx @@ -53,12 +53,8 @@ export interface CommandBarItem { readonly toolbarItem?: ReactNode; /** Custom menu element for mobile overflow menu. Overrides default and children-based rendering. */ readonly menuItem?: ReactNode; - /** Renders a divider after this item in the mobile overflow menu. */ - readonly overflowDivider?: boolean; - /** Renders a flex spacer before this item on the desktop toolbar. */ - readonly toolbarSpacer?: boolean; - /** Renders a divider before this item on the desktop toolbar. */ - readonly toolbarDivider?: boolean; + /** Alignment group on the desktop toolbar. Defaults to "left". */ + readonly align?: "left" | "right"; } export interface EditorCommandBarProps { @@ -142,7 +138,7 @@ export const EditorCommandBar: FunctionComponent = (props result.push({ id: "emitter", label: "Emitter", - toolbarSpacer: true, + align: "right", toolbarItem: ( = (props icon: emitter === selectedEmitter ? : undefined, onClick: () => onSelectedEmitterChange(emitter), })), - overflowDivider: true, }); if (documentationUrl) { @@ -165,7 +160,7 @@ export const EditorCommandBar: FunctionComponent = (props label: "Documentation", icon: , onClick: () => window.open(documentationUrl, "_blank"), - toolbarSpacer: true, + align: "right", toolbarItem: (