Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions packages/playground-website/e2e/ui.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/playground-website/samples/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,9 +68,8 @@ export const EditorCommandBar: FunctionComponent<EditorCommandBarProps> = ({
</Tooltip>
{samples && (
<>
<SamplesDropdown
<SamplesDrawerTrigger
samples={samples}
selectedSampleName={selectedSampleName}
onSelectedSampleNameChange={onSelectedSampleNameChange}
/>
<div className={style["spacer"]}></div>
Expand Down
1 change: 1 addition & 0 deletions packages/playground/src/react/samples-drawer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SamplesDrawerTrigger, type SamplesDrawerProps } from "./samples-drawer-trigger.js";
42 changes: 42 additions & 0 deletions packages/playground/src/react/samples-drawer/sample-card.tsx
Original file line number Diff line number Diff line change
@@ -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<SampleCardProps> = ({ name, sample, onSelect }) => {
return (
<Card
className={style["sample-card"]}
onClick={() => onSelect(name)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(name);
}
}}
>
<div className={style["sample-card-content"]}>
<SampleIcon name={name} />
<div className={style["sample-card-text"]}>
<Text as="h3" weight="semibold" className={style["sample-title"]}>
{name}
</Text>
{sample.description && (
<Text as="p" className={style["sample-description"]}>
{sample.description}
</Text>
)}
</div>
</div>
</Card>
);
};
107 changes: 107 additions & 0 deletions packages/playground/src/react/samples-drawer/sample-icon.tsx
Original file line number Diff line number Diff line change
@@ -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<SampleIconProps> = ({ 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 <circle cx={half} cy={half} r={half - 4} fill={colors.fg} opacity={0.15} />;
case "squares":
return (
<>
<rect x={4} y={4} width={16} height={16} fill={colors.fg} opacity={0.1} />
<rect x={28} y={28} width={16} height={16} fill={colors.fg} opacity={0.15} />
</>
);
case "triangle":
return (
<polygon
points={`${half},8 ${size - 8},${size - 8} 8,${size - 8}`}
fill={colors.fg}
opacity={0.12}
/>
);
case "hexagon":
return (
<polygon
points={`${half},4 ${size - 6},${half / 2 + 4} ${size - 6},${size - half / 2 - 4} ${half},${size - 4} 6,${size - half / 2 - 4} 6,${half / 2 + 4}`}
fill={colors.fg}
opacity={0.12}
/>
);
case "diamond":
return (
<polygon
points={`${half},6 ${size - 6},${half} ${half},${size - 6} 6,${half}`}
fill={colors.fg}
opacity={0.12}
/>
);
}
};

return (
<div className={style["sample-icon"]} style={{ backgroundColor: colors.bg }} aria-hidden="true">
<svg width="48" height="48" viewBox="0 0 48 48" className={style["sample-icon-pattern"]}>
{renderPattern()}
</svg>
<span className={style["sample-icon-initials"]} style={{ color: colors.fg }}>
{initials}
</span>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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<string, PlaygroundSample>;
onSelectedSampleNameChange: (sampleName: string) => void;
}

export const SamplesDrawerTrigger: FunctionComponent<SamplesDrawerProps> = ({
samples,
onSelectedSampleNameChange,
}) => {
const [isOpen, setIsOpen] = useState(false);

const handleSampleSelect = useCallback(
(sampleName: string) => {
onSelectedSampleNameChange(sampleName);
setIsOpen(false);
},
[onSelectedSampleNameChange],
);

return (
<>
<Tooltip content="Browse samples" relationship="description" withArrow>
<ToolbarButton
aria-label="Browse samples"
icon={<DocumentBulletList24Regular />}
onClick={() => setIsOpen(true)}
>
Samples
</ToolbarButton>
</Tooltip>

<OverlayDrawer
open={isOpen}
onOpenChange={(_, data) => setIsOpen(data.open)}
position="end"
size="large"
>
<DrawerHeader>
<DrawerHeaderTitle
action={
<Button
appearance="subtle"
aria-label="Close"
icon={<Dismiss24Regular />}
onClick={() => setIsOpen(false)}
/>
}
>
Sample Gallery
</DrawerHeaderTitle>
</DrawerHeader>
<DrawerBody>
<div className={style["samples-grid"]}>
{Object.entries(samples).map(([name, sample]) => (
<SampleCard key={name} name={name} sample={sample} onSelect={handleSampleSelect} />
))}
</div>
</DrawerBody>
</OverlayDrawer>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading