diff --git a/.chronus/changes/playground-sample-drawer-2026-0-30-16-22-1.md b/.chronus/changes/playground-sample-drawer-2026-0-30-16-22-1.md new file mode 100644 index 00000000000..a20ca1d1ff7 --- /dev/null +++ b/.chronus/changes/playground-sample-drawer-2026-0-30-16-22-1.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/playground" +--- + +Update sample dropdown to a drawer opening a sample gallery diff --git a/packages/playground-website/e2e/ui.e2e.ts b/packages/playground-website/e2e/ui.e2e.ts index c5097d542db..fd754ca3971 100644 --- a/packages/playground-website/e2e/ui.e2e.ts +++ b/packages/playground-website/e2e/ui.e2e.ts @@ -8,8 +8,15 @@ test.describe("playground UI tests", () => { test("compiled http sample", async ({ page }) => { await page.goto(host); - const samplesDropDown = page.locator("_react=SamplesDropdown").locator("select"); - await samplesDropDown.selectOption({ label: "HTTP service" }); + + // Click the Samples button to open the drawer + const samplesButton = page.locator('button[aria-label="Browse samples"]'); + await samplesButton.click(); + + // Wait for the drawer to open and click on the HTTP service card + const httpServiceCard = page.locator("text=HTTP service").first(); + await httpServiceCard.click(); + const outputContainer = page.locator("_react=FileOutput"); await expect(outputContainer).toContainText(`title: Widget Service`); }); diff --git a/packages/playground-website/samples/build.js b/packages/playground-website/samples/build.js index 6ba443d8842..82c345d40b3 100644 --- a/packages/playground-website/samples/build.js +++ b/packages/playground-website/samples/build.js @@ -10,27 +10,33 @@ await buildSamples_experimental(packageRoot, resolve(__dirname, "dist/samples.ts "API versioning": { filename: "samples/versioning.tsp", preferredEmitter: "@typespec/openapi3", + description: "Learn how to version your API using TypeSpec's versioning library.", }, "Discriminated unions": { filename: "samples/unions.tsp", preferredEmitter: "@typespec/openapi3", + description: "Define discriminated unions for polymorphic types with different variants.", }, "HTTP service": { filename: "samples/http.tsp", preferredEmitter: "@typespec/openapi3", compilerOptions: { linterRuleSet: { extends: ["@typespec/http/all"] } }, + description: "Build an HTTP service with routes, parameters, and responses.", }, "REST framework": { filename: "samples/rest.tsp", preferredEmitter: "@typespec/openapi3", compilerOptions: { linterRuleSet: { extends: ["@typespec/http/all"] } }, + description: "Use the REST framework for resource-oriented API design patterns.", }, "Protobuf Kiosk": { filename: "samples/kiosk.tsp", preferredEmitter: "@typespec/protobuf", + description: "Generate Protocol Buffer definitions from TypeSpec models.", }, "Json Schema": { filename: "samples/json-schema.tsp", preferredEmitter: "@typespec/json-schema", + description: "Emit JSON Schema from TypeSpec type definitions.", }, }); 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 05932be20ba..a9d6e47c8a9 100644 --- a/packages/playground/src/editor-command-bar/editor-command-bar.tsx +++ b/packages/playground/src/editor-command-bar/editor-command-bar.tsx @@ -3,7 +3,7 @@ import { Broom16Filled, Bug16Regular, Save16Regular } from "@fluentui/react-icon import type { CompilerOptions } from "@typespec/compiler"; import { useMemo, type FunctionComponent, type ReactNode } from "react"; import { EmitterDropdown } from "../react/emitter-dropdown.js"; -import { SamplesDropdown } from "../react/samples-dropdown.js"; +import { SamplesDrawerTrigger } from "../react/samples-drawer/index.js"; import { CompilerSettingsDialogButton } from "../react/settings/compiler-settings-dialog-button.js"; import type { BrowserHost, PlaygroundSample } from "../types.js"; import style from "./editor-command-bar.module.css"; @@ -68,9 +68,8 @@ export const EditorCommandBar: FunctionComponent = ({ {samples && ( <> -
diff --git a/packages/playground/src/react/samples-drawer/index.ts b/packages/playground/src/react/samples-drawer/index.ts new file mode 100644 index 00000000000..a33875b17a5 --- /dev/null +++ b/packages/playground/src/react/samples-drawer/index.ts @@ -0,0 +1 @@ +export { SamplesDrawerTrigger, type SamplesDrawerProps } from "./samples-drawer-trigger.js"; diff --git a/packages/playground/src/react/samples-drawer/sample-card.tsx b/packages/playground/src/react/samples-drawer/sample-card.tsx new file mode 100644 index 00000000000..d20ff972495 --- /dev/null +++ b/packages/playground/src/react/samples-drawer/sample-card.tsx @@ -0,0 +1,42 @@ +import { Card, Text } from "@fluentui/react-components"; +import type { FunctionComponent } from "react"; +import type { PlaygroundSample } from "../../types.js"; +import { SampleIcon } from "./sample-icon.js"; +import style from "./samples-drawer.module.css"; + +export interface SampleCardProps { + name: string; + sample: PlaygroundSample; + onSelect: (name: string) => void; +} + +export const SampleCard: FunctionComponent = ({ name, sample, onSelect }) => { + return ( + onSelect(name)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(name); + } + }} + > +
+ +
+ + {name} + + {sample.description && ( + + {sample.description} + + )} +
+
+
+ ); +}; diff --git a/packages/playground/src/react/samples-drawer/sample-icon.tsx b/packages/playground/src/react/samples-drawer/sample-icon.tsx new file mode 100644 index 00000000000..aed30b3cec1 --- /dev/null +++ b/packages/playground/src/react/samples-drawer/sample-icon.tsx @@ -0,0 +1,107 @@ +import { tokens } from "@fluentui/react-components"; +import { useMemo, type FunctionComponent } from "react"; +import style from "./samples-drawer.module.css"; + +/** Generate a deterministic hash from a string */ +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +/** Color palette using FluentUI tokens - using Background2 for light bg */ +const iconColors = [ + { bg: tokens.colorPaletteBlueBackground2, fg: tokens.colorPaletteBlueForeground2 }, + { bg: tokens.colorPaletteGrapeBackground2, fg: tokens.colorPaletteGrapeForeground2 }, + { bg: tokens.colorPaletteForestBackground2, fg: tokens.colorPaletteForestForeground2 }, + { bg: tokens.colorPalettePumpkinBackground2, fg: tokens.colorPalettePumpkinForeground2 }, + { bg: tokens.colorPaletteMagentaBackground2, fg: tokens.colorPaletteMagentaForeground2 }, + { bg: tokens.colorPaletteTealBackground2, fg: tokens.colorPaletteTealForeground2 }, + { bg: tokens.colorPaletteGoldBackground2, fg: tokens.colorPaletteGoldForeground2 }, + { bg: tokens.colorPalettePlumBackground2, fg: tokens.colorPalettePlumForeground2 }, + { bg: tokens.colorPaletteLavenderBackground2, fg: tokens.colorPaletteLavenderForeground2 }, + { bg: tokens.colorPaletteSteelBackground2, fg: tokens.colorPaletteSteelForeground2 }, +]; + +/** Simple geometric patterns for variety */ +type PatternType = "circle" | "squares" | "triangle" | "hexagon" | "diamond"; +const patterns: PatternType[] = ["circle", "squares", "triangle", "hexagon", "diamond"]; + +export interface SampleIconProps { + name: string; +} + +export const SampleIcon: FunctionComponent = ({ name }) => { + const { colors, pattern, initials } = useMemo(() => { + const hash = hashString(name); + const colorIndex = hash % iconColors.length; + const patternIndex = (hash >> 4) % patterns.length; + // Get first letter of first two words, or first two letters + const words = name.split(/\s+/); + const init = + words.length >= 2 + ? (words[0][0] + words[1][0]).toUpperCase() + : name.slice(0, 2).toUpperCase(); + return { + colors: iconColors[colorIndex], + pattern: patterns[patternIndex], + initials: init, + }; + }, [name]); + + const renderPattern = () => { + const size = 48; + const half = size / 2; + + switch (pattern) { + case "circle": + return ; + case "squares": + return ( + <> + + + + ); + case "triangle": + return ( + + ); + case "hexagon": + return ( + + ); + case "diamond": + return ( + + ); + } + }; + + return ( + + ); +}; diff --git a/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx b/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx new file mode 100644 index 00000000000..68db54f0b37 --- /dev/null +++ b/packages/playground/src/react/samples-drawer/samples-drawer-trigger.tsx @@ -0,0 +1,77 @@ +import { + Button, + DrawerBody, + DrawerHeader, + DrawerHeaderTitle, + OverlayDrawer, + ToolbarButton, + Tooltip, +} from "@fluentui/react-components"; +import { Dismiss24Regular, DocumentBulletList24Regular } from "@fluentui/react-icons"; +import { useCallback, useState, type FunctionComponent } from "react"; +import type { PlaygroundSample } from "../../types.js"; +import { SampleCard } from "./sample-card.js"; +import style from "./samples-drawer.module.css"; + +export interface SamplesDrawerProps { + samples: Record; + onSelectedSampleNameChange: (sampleName: string) => void; +} + +export const SamplesDrawerTrigger: FunctionComponent = ({ + samples, + onSelectedSampleNameChange, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const handleSampleSelect = useCallback( + (sampleName: string) => { + onSelectedSampleNameChange(sampleName); + setIsOpen(false); + }, + [onSelectedSampleNameChange], + ); + + return ( + <> + + } + onClick={() => setIsOpen(true)} + > + Samples + + + + setIsOpen(data.open)} + position="end" + size="large" + > + + } + onClick={() => setIsOpen(false)} + /> + } + > + Sample Gallery + + + +
+ {Object.entries(samples).map(([name, sample]) => ( + + ))} +
+
+
+ + ); +}; diff --git a/packages/playground/src/react/samples-drawer/samples-drawer.module.css b/packages/playground/src/react/samples-drawer/samples-drawer.module.css new file mode 100644 index 00000000000..bfdf344f597 --- /dev/null +++ b/packages/playground/src/react/samples-drawer/samples-drawer.module.css @@ -0,0 +1,76 @@ +.samples-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + padding: 8px 0; +} + +.sample-card { + cursor: pointer; + padding: 16px; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; + min-height: 100px; +} + +.sample-card:hover { + box-shadow: var(--shadow8); +} + +.sample-card:focus-visible { + outline: 2px solid var(--colorBrandStroke1); + outline-offset: 2px; +} + +.sample-card-content { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.sample-card-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.sample-icon { + width: 48px; + height: 48px; + border-radius: 8px; + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + user-select: none; +} + +.sample-icon-pattern { + position: absolute; + top: 0; + left: 0; +} + +.sample-icon-initials { + position: relative; + font-size: 16px; + font-weight: 600; + z-index: 1; +} + +.sample-title { + font-size: var(--fontSizeBase400); + margin: 0; +} + +.sample-description { + font-size: var(--fontSizeBase200); + color: var(--colorNeutralForeground2); + margin: 0; + line-height: 1.4; +} diff --git a/packages/playground/src/react/samples-dropdown.tsx b/packages/playground/src/react/samples-dropdown.tsx deleted file mode 100644 index dade744f3e9..00000000000 --- a/packages/playground/src/react/samples-dropdown.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Select } from "@fluentui/react-components"; -import { useCallback, type FunctionComponent } from "react"; -import type { PlaygroundSample } from "../types.js"; - -export interface SamplesDropdownProps { - samples: Record; - selectedSampleName: string; - onSelectedSampleNameChange: (sampleName: string) => void; -} - -export const SamplesDropdown: FunctionComponent = ({ - samples, - selectedSampleName, - onSelectedSampleNameChange, -}) => { - const options = Object.keys(samples).map((sample) => { - return ; - }); - - const handleSelected = useCallback( - (evt: any) => { - if (samples[evt.target.value]) { - onSelectedSampleNameChange(evt.target.value); - } - }, - [onSelectedSampleNameChange, samples], - ); - return ( - - ); -}; diff --git a/packages/playground/src/tooling/index.ts b/packages/playground/src/tooling/index.ts index 8a1df2f1a8f..56b7a091012 100644 --- a/packages/playground/src/tooling/index.ts +++ b/packages/playground/src/tooling/index.ts @@ -20,6 +20,7 @@ export async function buildSamples_experimental( filename: config.filename, content, preferredEmitter: config.preferredEmitter, + description: config.description, compilerOptions: config.compilerOptions, }; } diff --git a/packages/playground/src/types.ts b/packages/playground/src/types.ts index 7b7dab483a2..f9431be5236 100644 --- a/packages/playground/src/types.ts +++ b/packages/playground/src/types.ts @@ -11,6 +11,11 @@ export interface PlaygroundSample { preferredEmitter?: string; content: string; + /** + * A short description of what this sample demonstrates. + */ + description?: string; + /** * Compiler options for the sample. */