Skip to content

Commit c4a412e

Browse files
Sample picker as drawer (#9535)
Move samples to a drawer opening a sample gallary. This is to accomadate more sampels/consolidate with sampels from `packages/samples` <img width="2272" height="917" alt="image" src="https://github.com/user-attachments/assets/6e33f5a5-531f-494d-b7c2-cdff3869f1bd" />
1 parent 1bb017a commit c4a412e

File tree

12 files changed

+334
-46
lines changed

12 files changed

+334
-46
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
3+
changeKind: feature
4+
packages:
5+
- "@typespec/playground"
6+
---
7+
8+
Update sample dropdown to a drawer opening a sample gallery

packages/playground-website/e2e/ui.e2e.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ test.describe("playground UI tests", () => {
88

99
test("compiled http sample", async ({ page }) => {
1010
await page.goto(host);
11-
const samplesDropDown = page.locator("_react=SamplesDropdown").locator("select");
12-
await samplesDropDown.selectOption({ label: "HTTP service" });
11+
12+
// Click the Samples button to open the drawer
13+
const samplesButton = page.locator('button[aria-label="Browse samples"]');
14+
await samplesButton.click();
15+
16+
// Wait for the drawer to open and click on the HTTP service card
17+
const httpServiceCard = page.locator("text=HTTP service").first();
18+
await httpServiceCard.click();
19+
1320
const outputContainer = page.locator("_react=FileOutput");
1421
await expect(outputContainer).toContainText(`title: Widget Service`);
1522
});

packages/playground-website/samples/build.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,33 @@ await buildSamples_experimental(packageRoot, resolve(__dirname, "dist/samples.ts
1010
"API versioning": {
1111
filename: "samples/versioning.tsp",
1212
preferredEmitter: "@typespec/openapi3",
13+
description: "Learn how to version your API using TypeSpec's versioning library.",
1314
},
1415
"Discriminated unions": {
1516
filename: "samples/unions.tsp",
1617
preferredEmitter: "@typespec/openapi3",
18+
description: "Define discriminated unions for polymorphic types with different variants.",
1719
},
1820
"HTTP service": {
1921
filename: "samples/http.tsp",
2022
preferredEmitter: "@typespec/openapi3",
2123
compilerOptions: { linterRuleSet: { extends: ["@typespec/http/all"] } },
24+
description: "Build an HTTP service with routes, parameters, and responses.",
2225
},
2326
"REST framework": {
2427
filename: "samples/rest.tsp",
2528
preferredEmitter: "@typespec/openapi3",
2629
compilerOptions: { linterRuleSet: { extends: ["@typespec/http/all"] } },
30+
description: "Use the REST framework for resource-oriented API design patterns.",
2731
},
2832
"Protobuf Kiosk": {
2933
filename: "samples/kiosk.tsp",
3034
preferredEmitter: "@typespec/protobuf",
35+
description: "Generate Protocol Buffer definitions from TypeSpec models.",
3136
},
3237
"Json Schema": {
3338
filename: "samples/json-schema.tsp",
3439
preferredEmitter: "@typespec/json-schema",
40+
description: "Emit JSON Schema from TypeSpec type definitions.",
3541
},
3642
});

packages/playground/src/editor-command-bar/editor-command-bar.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Broom16Filled, Bug16Regular, Save16Regular } from "@fluentui/react-icon
33
import type { CompilerOptions } from "@typespec/compiler";
44
import { useMemo, type FunctionComponent, type ReactNode } from "react";
55
import { EmitterDropdown } from "../react/emitter-dropdown.js";
6-
import { SamplesDropdown } from "../react/samples-dropdown.js";
6+
import { SamplesDrawerTrigger } from "../react/samples-drawer/index.js";
77
import { CompilerSettingsDialogButton } from "../react/settings/compiler-settings-dialog-button.js";
88
import type { BrowserHost, PlaygroundSample } from "../types.js";
99
import style from "./editor-command-bar.module.css";
@@ -68,9 +68,8 @@ export const EditorCommandBar: FunctionComponent<EditorCommandBarProps> = ({
6868
</Tooltip>
6969
{samples && (
7070
<>
71-
<SamplesDropdown
71+
<SamplesDrawerTrigger
7272
samples={samples}
73-
selectedSampleName={selectedSampleName}
7473
onSelectedSampleNameChange={onSelectedSampleNameChange}
7574
/>
7675
<div className={style["spacer"]}></div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SamplesDrawerTrigger, type SamplesDrawerProps } from "./samples-drawer-trigger.js";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Card, Text } from "@fluentui/react-components";
2+
import type { FunctionComponent } from "react";
3+
import type { PlaygroundSample } from "../../types.js";
4+
import { SampleIcon } from "./sample-icon.js";
5+
import style from "./samples-drawer.module.css";
6+
7+
export interface SampleCardProps {
8+
name: string;
9+
sample: PlaygroundSample;
10+
onSelect: (name: string) => void;
11+
}
12+
13+
export const SampleCard: FunctionComponent<SampleCardProps> = ({ name, sample, onSelect }) => {
14+
return (
15+
<Card
16+
className={style["sample-card"]}
17+
onClick={() => onSelect(name)}
18+
role="button"
19+
tabIndex={0}
20+
onKeyDown={(e) => {
21+
if (e.key === "Enter" || e.key === " ") {
22+
e.preventDefault();
23+
onSelect(name);
24+
}
25+
}}
26+
>
27+
<div className={style["sample-card-content"]}>
28+
<SampleIcon name={name} />
29+
<div className={style["sample-card-text"]}>
30+
<Text as="h3" weight="semibold" className={style["sample-title"]}>
31+
{name}
32+
</Text>
33+
{sample.description && (
34+
<Text as="p" className={style["sample-description"]}>
35+
{sample.description}
36+
</Text>
37+
)}
38+
</div>
39+
</div>
40+
</Card>
41+
);
42+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { tokens } from "@fluentui/react-components";
2+
import { useMemo, type FunctionComponent } from "react";
3+
import style from "./samples-drawer.module.css";
4+
5+
/** Generate a deterministic hash from a string */
6+
function hashString(str: string): number {
7+
let hash = 0;
8+
for (let i = 0; i < str.length; i++) {
9+
const char = str.charCodeAt(i);
10+
hash = (hash << 5) - hash + char;
11+
hash = hash & hash; // Convert to 32bit integer
12+
}
13+
return Math.abs(hash);
14+
}
15+
16+
/** Color palette using FluentUI tokens - using Background2 for light bg */
17+
const iconColors = [
18+
{ bg: tokens.colorPaletteBlueBackground2, fg: tokens.colorPaletteBlueForeground2 },
19+
{ bg: tokens.colorPaletteGrapeBackground2, fg: tokens.colorPaletteGrapeForeground2 },
20+
{ bg: tokens.colorPaletteForestBackground2, fg: tokens.colorPaletteForestForeground2 },
21+
{ bg: tokens.colorPalettePumpkinBackground2, fg: tokens.colorPalettePumpkinForeground2 },
22+
{ bg: tokens.colorPaletteMagentaBackground2, fg: tokens.colorPaletteMagentaForeground2 },
23+
{ bg: tokens.colorPaletteTealBackground2, fg: tokens.colorPaletteTealForeground2 },
24+
{ bg: tokens.colorPaletteGoldBackground2, fg: tokens.colorPaletteGoldForeground2 },
25+
{ bg: tokens.colorPalettePlumBackground2, fg: tokens.colorPalettePlumForeground2 },
26+
{ bg: tokens.colorPaletteLavenderBackground2, fg: tokens.colorPaletteLavenderForeground2 },
27+
{ bg: tokens.colorPaletteSteelBackground2, fg: tokens.colorPaletteSteelForeground2 },
28+
];
29+
30+
/** Simple geometric patterns for variety */
31+
type PatternType = "circle" | "squares" | "triangle" | "hexagon" | "diamond";
32+
const patterns: PatternType[] = ["circle", "squares", "triangle", "hexagon", "diamond"];
33+
34+
export interface SampleIconProps {
35+
name: string;
36+
}
37+
38+
export const SampleIcon: FunctionComponent<SampleIconProps> = ({ name }) => {
39+
const { colors, pattern, initials } = useMemo(() => {
40+
const hash = hashString(name);
41+
const colorIndex = hash % iconColors.length;
42+
const patternIndex = (hash >> 4) % patterns.length;
43+
// Get first letter of first two words, or first two letters
44+
const words = name.split(/\s+/);
45+
const init =
46+
words.length >= 2
47+
? (words[0][0] + words[1][0]).toUpperCase()
48+
: name.slice(0, 2).toUpperCase();
49+
return {
50+
colors: iconColors[colorIndex],
51+
pattern: patterns[patternIndex],
52+
initials: init,
53+
};
54+
}, [name]);
55+
56+
const renderPattern = () => {
57+
const size = 48;
58+
const half = size / 2;
59+
60+
switch (pattern) {
61+
case "circle":
62+
return <circle cx={half} cy={half} r={half - 4} fill={colors.fg} opacity={0.15} />;
63+
case "squares":
64+
return (
65+
<>
66+
<rect x={4} y={4} width={16} height={16} fill={colors.fg} opacity={0.1} />
67+
<rect x={28} y={28} width={16} height={16} fill={colors.fg} opacity={0.15} />
68+
</>
69+
);
70+
case "triangle":
71+
return (
72+
<polygon
73+
points={`${half},8 ${size - 8},${size - 8} 8,${size - 8}`}
74+
fill={colors.fg}
75+
opacity={0.12}
76+
/>
77+
);
78+
case "hexagon":
79+
return (
80+
<polygon
81+
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}`}
82+
fill={colors.fg}
83+
opacity={0.12}
84+
/>
85+
);
86+
case "diamond":
87+
return (
88+
<polygon
89+
points={`${half},6 ${size - 6},${half} ${half},${size - 6} 6,${half}`}
90+
fill={colors.fg}
91+
opacity={0.12}
92+
/>
93+
);
94+
}
95+
};
96+
97+
return (
98+
<div className={style["sample-icon"]} style={{ backgroundColor: colors.bg }} aria-hidden="true">
99+
<svg width="48" height="48" viewBox="0 0 48 48" className={style["sample-icon-pattern"]}>
100+
{renderPattern()}
101+
</svg>
102+
<span className={style["sample-icon-initials"]} style={{ color: colors.fg }}>
103+
{initials}
104+
</span>
105+
</div>
106+
);
107+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
Button,
3+
DrawerBody,
4+
DrawerHeader,
5+
DrawerHeaderTitle,
6+
OverlayDrawer,
7+
ToolbarButton,
8+
Tooltip,
9+
} from "@fluentui/react-components";
10+
import { Dismiss24Regular, DocumentBulletList24Regular } from "@fluentui/react-icons";
11+
import { useCallback, useState, type FunctionComponent } from "react";
12+
import type { PlaygroundSample } from "../../types.js";
13+
import { SampleCard } from "./sample-card.js";
14+
import style from "./samples-drawer.module.css";
15+
16+
export interface SamplesDrawerProps {
17+
samples: Record<string, PlaygroundSample>;
18+
onSelectedSampleNameChange: (sampleName: string) => void;
19+
}
20+
21+
export const SamplesDrawerTrigger: FunctionComponent<SamplesDrawerProps> = ({
22+
samples,
23+
onSelectedSampleNameChange,
24+
}) => {
25+
const [isOpen, setIsOpen] = useState(false);
26+
27+
const handleSampleSelect = useCallback(
28+
(sampleName: string) => {
29+
onSelectedSampleNameChange(sampleName);
30+
setIsOpen(false);
31+
},
32+
[onSelectedSampleNameChange],
33+
);
34+
35+
return (
36+
<>
37+
<Tooltip content="Browse samples" relationship="description" withArrow>
38+
<ToolbarButton
39+
aria-label="Browse samples"
40+
icon={<DocumentBulletList24Regular />}
41+
onClick={() => setIsOpen(true)}
42+
>
43+
Samples
44+
</ToolbarButton>
45+
</Tooltip>
46+
47+
<OverlayDrawer
48+
open={isOpen}
49+
onOpenChange={(_, data) => setIsOpen(data.open)}
50+
position="end"
51+
size="large"
52+
>
53+
<DrawerHeader>
54+
<DrawerHeaderTitle
55+
action={
56+
<Button
57+
appearance="subtle"
58+
aria-label="Close"
59+
icon={<Dismiss24Regular />}
60+
onClick={() => setIsOpen(false)}
61+
/>
62+
}
63+
>
64+
Sample Gallery
65+
</DrawerHeaderTitle>
66+
</DrawerHeader>
67+
<DrawerBody>
68+
<div className={style["samples-grid"]}>
69+
{Object.entries(samples).map(([name, sample]) => (
70+
<SampleCard key={name} name={name} sample={sample} onSelect={handleSampleSelect} />
71+
))}
72+
</div>
73+
</DrawerBody>
74+
</OverlayDrawer>
75+
</>
76+
);
77+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
.samples-grid {
2+
display: grid;
3+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
4+
gap: 16px;
5+
padding: 8px 0;
6+
}
7+
8+
.sample-card {
9+
cursor: pointer;
10+
padding: 16px;
11+
transition:
12+
box-shadow 0.2s ease,
13+
border-color 0.2s ease;
14+
min-height: 100px;
15+
}
16+
17+
.sample-card:hover {
18+
box-shadow: var(--shadow8);
19+
}
20+
21+
.sample-card:focus-visible {
22+
outline: 2px solid var(--colorBrandStroke1);
23+
outline-offset: 2px;
24+
}
25+
26+
.sample-card-content {
27+
display: flex;
28+
gap: 16px;
29+
align-items: flex-start;
30+
}
31+
32+
.sample-card-text {
33+
display: flex;
34+
flex-direction: column;
35+
gap: 4px;
36+
flex: 1;
37+
min-width: 0;
38+
}
39+
40+
.sample-icon {
41+
width: 48px;
42+
height: 48px;
43+
border-radius: 8px;
44+
flex-shrink: 0;
45+
position: relative;
46+
display: flex;
47+
align-items: center;
48+
justify-content: center;
49+
overflow: hidden;
50+
user-select: none;
51+
}
52+
53+
.sample-icon-pattern {
54+
position: absolute;
55+
top: 0;
56+
left: 0;
57+
}
58+
59+
.sample-icon-initials {
60+
position: relative;
61+
font-size: 16px;
62+
font-weight: 600;
63+
z-index: 1;
64+
}
65+
66+
.sample-title {
67+
font-size: var(--fontSizeBase400);
68+
margin: 0;
69+
}
70+
71+
.sample-description {
72+
font-size: var(--fontSizeBase200);
73+
color: var(--colorNeutralForeground2);
74+
margin: 0;
75+
line-height: 1.4;
76+
}

0 commit comments

Comments
 (0)