sunpeak 0.3.8 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/chatgpt/chatgpt-simulator.d.ts +13 -3
- package/dist/chatgpt/index.d.ts +1 -0
- package/dist/index.cjs +24 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +1 -1
- package/dist/style.css +179 -7
- package/package.json +1 -1
- package/template/data/albums.json +112 -0
- package/template/data/places.json +49 -0
- package/template/dev/main.tsx +4 -59
- package/template/index.html +1 -1
- package/template/mcp/server.ts +4 -50
- package/template/src/App.tsx +87 -39
- package/template/src/components/album/album-card.tsx +45 -0
- package/template/src/components/album/albums.tsx +77 -0
- package/template/src/components/album/film-strip.tsx +50 -0
- package/template/src/components/album/fullscreen-viewer.tsx +60 -0
- package/template/src/components/album/index.ts +4 -0
- package/template/src/components/{openai-card.test.tsx → card/card.test.tsx} +12 -12
- package/template/src/components/{openai-card.tsx → card/card.tsx} +8 -8
- package/template/src/components/card/index.ts +1 -0
- package/template/src/components/{openai-carousel.test.tsx → carousel/carousel.test.tsx} +10 -10
- package/template/src/components/{openai-carousel.tsx → carousel/carousel.tsx} +7 -7
- package/template/src/components/carousel/index.ts +1 -0
- package/template/src/components/index.ts +4 -2
- package/template/src/components/simulations/albums-simulation.tsx +20 -0
- package/template/src/components/simulations/app-simulation.tsx +13 -0
- package/template/src/components/simulations/carousel-simulation.tsx +63 -0
- package/template/src/components/simulations/index.tsx +14 -0
- package/template/src/styles/globals.css +2 -0
package/template/mcp/server.ts
CHANGED
|
@@ -1,59 +1,13 @@
|
|
|
1
1
|
import { runMCPServer } from 'sunpeak/mcp';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
4
5
|
|
|
5
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
id: '1',
|
|
12
|
-
name: 'Lady Bird Lake',
|
|
13
|
-
rating: 4.5,
|
|
14
|
-
category: 'Waterfront',
|
|
15
|
-
location: 'Austin',
|
|
16
|
-
image: 'https://images.unsplash.com/photo-1520950237264-dfe336995c34?w=400&h=400&fit=crop',
|
|
17
|
-
description: 'Scenic lake perfect for kayaking, paddleboarding, and trails.',
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
id: '2',
|
|
21
|
-
name: 'Texas State Capitol',
|
|
22
|
-
rating: 4.8,
|
|
23
|
-
category: 'Historic Site',
|
|
24
|
-
location: 'Austin',
|
|
25
|
-
image: 'https://images.unsplash.com/photo-1664231978322-4d0b45c7027b?w=400&h=400&fit=crop',
|
|
26
|
-
description: 'Stunning capitol building with free tours and beautiful grounds.',
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: '3',
|
|
30
|
-
name: 'The Paramount Theatre',
|
|
31
|
-
rating: 4.7,
|
|
32
|
-
category: 'Architecture',
|
|
33
|
-
location: 'Austin',
|
|
34
|
-
image: 'https://images.unsplash.com/photo-1583097090970-4d3b940ea1a0?w=400&h=400&fit=crop',
|
|
35
|
-
description: 'Century-old performance and movie theatre in the heart of downtown Austin.',
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
id: '4',
|
|
39
|
-
name: 'Zilker Park',
|
|
40
|
-
rating: 4.7,
|
|
41
|
-
category: 'Park',
|
|
42
|
-
location: 'Austin',
|
|
43
|
-
image: 'https://images.unsplash.com/photo-1563828568124-f800803ba13c?w=400&h=400&fit=crop',
|
|
44
|
-
description: 'Popular park with trails, sports fields, and Barton Springs Pool.',
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: '5',
|
|
48
|
-
name: 'South Congress Avenue',
|
|
49
|
-
rating: 4.6,
|
|
50
|
-
category: 'Landmark',
|
|
51
|
-
location: 'Austin',
|
|
52
|
-
image: 'https://images.unsplash.com/photo-1588993608283-7f0eda4438be?w=400&h=400&fit=crop',
|
|
53
|
-
description: 'Vibrant street with unique shops, restaurants, and live music.',
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
};
|
|
8
|
+
// Load places data from JSON file
|
|
9
|
+
const placesDataPath = path.resolve(__dirname, '../data/places.json');
|
|
10
|
+
const toolOutput = JSON.parse(readFileSync(placesDataPath, 'utf-8'));
|
|
57
11
|
|
|
58
12
|
runMCPServer({
|
|
59
13
|
name: 'my-sunpeak-app',
|
package/template/src/App.tsx
CHANGED
|
@@ -1,49 +1,97 @@
|
|
|
1
1
|
import './styles/globals.css';
|
|
2
|
-
import 'sunpeak/style.css';
|
|
3
|
-
|
|
4
|
-
import { useWidgetProps } from 'sunpeak';
|
|
5
|
-
import { OpenAICarousel, OpenAICard } from './components';
|
|
6
|
-
|
|
7
|
-
export interface Place {
|
|
8
|
-
id: string;
|
|
9
|
-
name: string;
|
|
10
|
-
rating: number;
|
|
11
|
-
category: string;
|
|
12
|
-
location: string;
|
|
13
|
-
image: string;
|
|
14
|
-
description: string;
|
|
15
|
-
}
|
|
16
2
|
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
import { useWidgetState } from 'sunpeak';
|
|
4
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
5
|
+
|
|
6
|
+
interface CounterState extends Record<string, unknown> {
|
|
7
|
+
count?: number;
|
|
19
8
|
}
|
|
20
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Welcome to your Sunpeak App!
|
|
12
|
+
*
|
|
13
|
+
* This is a simple counter app to get you started.
|
|
14
|
+
* Try building your own app here!
|
|
15
|
+
*
|
|
16
|
+
* Tips:
|
|
17
|
+
* - Use the Component dropdown in the sidebar to see example apps
|
|
18
|
+
* - Check out the components folder for reusable components
|
|
19
|
+
* - Use sunpeak hooks for state management and display modes
|
|
20
|
+
* - Edit this file and see your changes live
|
|
21
|
+
* - Edit ./components/simulations/app-simulation.tsx to customize your simulation
|
|
22
|
+
*/
|
|
21
23
|
export function App() {
|
|
22
|
-
const
|
|
24
|
+
const [widgetState, setWidgetState] = useWidgetState<CounterState>(() => ({
|
|
25
|
+
count: 0,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const count = widgetState?.count ?? 0;
|
|
29
|
+
|
|
30
|
+
const increment = () => {
|
|
31
|
+
setWidgetState({ count: count + 1 });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const decrement = () => {
|
|
35
|
+
setWidgetState({ count: count - 1 });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const reset = () => {
|
|
39
|
+
setWidgetState({ count: 0 });
|
|
40
|
+
};
|
|
23
41
|
|
|
24
42
|
return (
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
<div className="flex flex-col items-center justify-center p-8 space-y-6">
|
|
44
|
+
<div className="text-center space-y-2">
|
|
45
|
+
<h1 className="text-3xl font-bold text-primary">
|
|
46
|
+
Welcome to Sunpeak!
|
|
47
|
+
</h1>
|
|
48
|
+
<p className="text-secondary">
|
|
49
|
+
Build your ChatGPT app here
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="flex flex-col items-center space-y-4 p-8 border border-subtle rounded-2xl bg-surface shadow-sm">
|
|
54
|
+
<div className="text-6xl font-bold text-primary">
|
|
55
|
+
{count}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="flex gap-2">
|
|
59
|
+
<Button
|
|
60
|
+
variant="soft"
|
|
61
|
+
color="secondary"
|
|
62
|
+
onClick={decrement}
|
|
63
|
+
aria-label="Decrement"
|
|
64
|
+
>
|
|
65
|
+
−
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
variant="solid"
|
|
69
|
+
color="primary"
|
|
70
|
+
onClick={increment}
|
|
71
|
+
aria-label="Increment"
|
|
72
|
+
>
|
|
73
|
+
+
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<Button
|
|
78
|
+
variant="outline"
|
|
79
|
+
color="secondary"
|
|
80
|
+
onClick={reset}
|
|
81
|
+
size="sm"
|
|
43
82
|
>
|
|
44
|
-
|
|
45
|
-
</
|
|
46
|
-
|
|
47
|
-
|
|
83
|
+
Reset
|
|
84
|
+
</Button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="text-center text-sm text-secondary max-w-md space-y-2">
|
|
88
|
+
<p>
|
|
89
|
+
This counter persists its state using <code className="px-1.5 py-0.5 rounded bg-surface-secondary text-primary">useWidgetState</code>
|
|
90
|
+
</p>
|
|
91
|
+
<p>
|
|
92
|
+
Try switching between examples in the sidebar!
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
48
96
|
);
|
|
49
97
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Button } from "@openai/apps-sdk-ui/components/Button"
|
|
3
|
+
import { cn } from "../../lib/index"
|
|
4
|
+
import type { Album } from "./albums"
|
|
5
|
+
|
|
6
|
+
export type AlbumCardProps = {
|
|
7
|
+
album: Album
|
|
8
|
+
onSelect?: (album: Album) => void
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
|
|
13
|
+
({ album, onSelect, className }, ref) => {
|
|
14
|
+
return (
|
|
15
|
+
<Button
|
|
16
|
+
ref={ref}
|
|
17
|
+
variant="ghost"
|
|
18
|
+
color="secondary"
|
|
19
|
+
className={cn(
|
|
20
|
+
"rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer",
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
onClick={() => onSelect?.(album)}
|
|
24
|
+
>
|
|
25
|
+
<div className="aspect-[4/3] w-full overflow-hidden rounded-xl flex-shrink-0">
|
|
26
|
+
<img
|
|
27
|
+
src={album.cover}
|
|
28
|
+
alt={album.title}
|
|
29
|
+
className="w-full h-full object-cover"
|
|
30
|
+
loading="lazy"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex-shrink-0 w-full p-2">
|
|
34
|
+
<div className="text-base font-normal text-primary">
|
|
35
|
+
{album.title}
|
|
36
|
+
</div>
|
|
37
|
+
<div className="text-sm text-secondary">
|
|
38
|
+
{album.photos.length} {album.photos.length === 1 ? "photo" : "photos"}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</Button>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
AlbumCard.displayName = "AlbumCard"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from "sunpeak"
|
|
3
|
+
import { Carousel } from "../carousel"
|
|
4
|
+
import { AlbumCard } from "./album-card"
|
|
5
|
+
import { FullscreenViewer } from "./fullscreen-viewer"
|
|
6
|
+
|
|
7
|
+
export interface Album {
|
|
8
|
+
id: string
|
|
9
|
+
title: string
|
|
10
|
+
cover: string
|
|
11
|
+
photos: Array<{
|
|
12
|
+
id: string
|
|
13
|
+
title: string
|
|
14
|
+
url: string
|
|
15
|
+
}>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OpenAIAlbumsData extends Record<string, unknown> {
|
|
19
|
+
albums: Album[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpenAIAlbumsState extends Record<string, unknown> {
|
|
23
|
+
selectedAlbumId?: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type OpenAIAlbumsProps = {
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const OpenAIAlbums = React.forwardRef<HTMLDivElement, OpenAIAlbumsProps>(
|
|
31
|
+
({ className }, ref) => {
|
|
32
|
+
const data = useWidgetProps<OpenAIAlbumsData>(() => ({ albums: [] }))
|
|
33
|
+
const [widgetState, setWidgetState] = useWidgetState<OpenAIAlbumsState>(() => ({
|
|
34
|
+
selectedAlbumId: null,
|
|
35
|
+
}))
|
|
36
|
+
const displayMode = useDisplayMode()
|
|
37
|
+
const api = useWidgetAPI()
|
|
38
|
+
|
|
39
|
+
const albums = data.albums || []
|
|
40
|
+
const selectedAlbum = albums.find(
|
|
41
|
+
(album) => album.id === widgetState?.selectedAlbumId
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const handleSelectAlbum = React.useCallback(
|
|
45
|
+
(album: Album) => {
|
|
46
|
+
setWidgetState({ selectedAlbumId: album.id })
|
|
47
|
+
api?.requestDisplayMode?.({ mode: "fullscreen" })
|
|
48
|
+
},
|
|
49
|
+
[setWidgetState, api]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if (displayMode === "fullscreen" && selectedAlbum) {
|
|
53
|
+
return (
|
|
54
|
+
<FullscreenViewer
|
|
55
|
+
ref={ref}
|
|
56
|
+
album={selectedAlbum}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div ref={ref} className={className}>
|
|
63
|
+
<Carousel
|
|
64
|
+
gap={20}
|
|
65
|
+
showArrows={false}
|
|
66
|
+
showEdgeGradients={false}
|
|
67
|
+
cardWidth={272}
|
|
68
|
+
>
|
|
69
|
+
{albums.map((album) => (
|
|
70
|
+
<AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
|
|
71
|
+
))}
|
|
72
|
+
</Carousel>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
OpenAIAlbums.displayName = "OpenAIAlbums"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Button } from "@openai/apps-sdk-ui/components/Button"
|
|
3
|
+
import { cn } from "../../lib/index"
|
|
4
|
+
import type { Album } from "./albums"
|
|
5
|
+
|
|
6
|
+
export type FilmStripProps = {
|
|
7
|
+
album: Album
|
|
8
|
+
selectedIndex: number
|
|
9
|
+
onSelect?: (index: number) => void
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
|
|
14
|
+
({ album, selectedIndex, onSelect, className }, ref) => {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
ref={ref}
|
|
18
|
+
className={cn(
|
|
19
|
+
"h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5",
|
|
20
|
+
className
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
{album.photos.map((photo, idx) => (
|
|
24
|
+
<Button
|
|
25
|
+
key={photo.id}
|
|
26
|
+
variant="ghost"
|
|
27
|
+
color="secondary"
|
|
28
|
+
onClick={() => onSelect?.(idx)}
|
|
29
|
+
className={cn(
|
|
30
|
+
"block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all",
|
|
31
|
+
idx === selectedIndex
|
|
32
|
+
? "border-primary shadow-md"
|
|
33
|
+
: "border-transparent hover:border-primary/30 opacity-60 hover:opacity-100"
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<div className="aspect-[5/3] rounded-lg overflow-hidden w-full">
|
|
37
|
+
<img
|
|
38
|
+
src={photo.url}
|
|
39
|
+
alt={photo.title || `Photo ${idx + 1}`}
|
|
40
|
+
className="h-full w-full object-cover"
|
|
41
|
+
loading="lazy"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</Button>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
FilmStrip.displayName = "FilmStrip"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { useMaxHeight } from "sunpeak"
|
|
3
|
+
import { cn } from "../../lib/index"
|
|
4
|
+
import { FilmStrip } from "./film-strip"
|
|
5
|
+
import type { Album } from "./albums"
|
|
6
|
+
|
|
7
|
+
export type FullscreenViewerProps = {
|
|
8
|
+
album: Album
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FullscreenViewer = React.forwardRef<
|
|
13
|
+
HTMLDivElement,
|
|
14
|
+
FullscreenViewerProps
|
|
15
|
+
>(({ album, className }, ref) => {
|
|
16
|
+
const maxHeight = useMaxHeight()
|
|
17
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
|
18
|
+
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
setSelectedIndex(0)
|
|
21
|
+
}, [album?.id])
|
|
22
|
+
|
|
23
|
+
const selectedPhoto = album?.photos?.[selectedIndex]
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
ref={ref}
|
|
28
|
+
className={cn("relative w-full h-full bg-surface", className)}
|
|
29
|
+
style={{
|
|
30
|
+
maxHeight: maxHeight ?? undefined,
|
|
31
|
+
height: maxHeight ?? undefined,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
|
35
|
+
{/* Film strip */}
|
|
36
|
+
<div className="hidden md:block absolute pointer-events-none z-10 left-0 top-0 bottom-0 w-40">
|
|
37
|
+
<FilmStrip
|
|
38
|
+
album={album}
|
|
39
|
+
selectedIndex={selectedIndex}
|
|
40
|
+
onSelect={setSelectedIndex}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Main photo */}
|
|
45
|
+
<div className="flex-1 min-w-0 px-40 py-10 relative flex items-center justify-center">
|
|
46
|
+
<div className="relative w-full h-full">
|
|
47
|
+
{selectedPhoto ? (
|
|
48
|
+
<img
|
|
49
|
+
src={selectedPhoto.url}
|
|
50
|
+
alt={selectedPhoto.title || album.title}
|
|
51
|
+
className="absolute inset-0 m-auto rounded-3xl shadow-sm border border-primary/10 max-w-full max-h-full object-contain"
|
|
52
|
+
/>
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
FullscreenViewer.displayName = "FullscreenViewer"
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { Card } from './card';
|
|
4
4
|
|
|
5
|
-
describe('
|
|
5
|
+
describe('Card', () => {
|
|
6
6
|
it('renders correct variant classes', () => {
|
|
7
7
|
const { container, rerender } = render(
|
|
8
|
-
<
|
|
8
|
+
<Card variant="default" data-testid="card">
|
|
9
9
|
Content
|
|
10
|
-
</
|
|
10
|
+
</Card>
|
|
11
11
|
);
|
|
12
12
|
|
|
13
13
|
const card = container.firstChild as HTMLElement;
|
|
14
14
|
expect(card.className).toContain('border border-subtle bg-surface');
|
|
15
15
|
|
|
16
16
|
rerender(
|
|
17
|
-
<
|
|
17
|
+
<Card variant="bordered" data-testid="card">
|
|
18
18
|
Content
|
|
19
|
-
</
|
|
19
|
+
</Card>
|
|
20
20
|
);
|
|
21
21
|
expect(card.className).toContain('border-2 border-default bg-surface');
|
|
22
22
|
|
|
23
23
|
rerender(
|
|
24
|
-
<
|
|
24
|
+
<Card variant="elevated" data-testid="card">
|
|
25
25
|
Content
|
|
26
|
-
</
|
|
26
|
+
</Card>
|
|
27
27
|
);
|
|
28
28
|
expect(card.className).toContain('shadow-lg');
|
|
29
29
|
});
|
|
@@ -33,12 +33,12 @@ describe('OpenAICard', () => {
|
|
|
33
33
|
const button1OnClick = vi.fn();
|
|
34
34
|
|
|
35
35
|
render(
|
|
36
|
-
<
|
|
36
|
+
<Card
|
|
37
37
|
onClick={cardOnClick}
|
|
38
38
|
button1={{ onClick: button1OnClick, children: 'Click Me' }}
|
|
39
39
|
>
|
|
40
40
|
Content
|
|
41
|
-
</
|
|
41
|
+
</Card>
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
const button = screen.getByText('Click Me');
|
|
@@ -53,12 +53,12 @@ describe('OpenAICard', () => {
|
|
|
53
53
|
const button2OnClick = vi.fn();
|
|
54
54
|
|
|
55
55
|
render(
|
|
56
|
-
<
|
|
56
|
+
<Card
|
|
57
57
|
button1={{ onClick: button1OnClick, children: 'Button 1', isPrimary: true }}
|
|
58
58
|
button2={{ onClick: button2OnClick, children: 'Button 2' }}
|
|
59
59
|
>
|
|
60
60
|
Content
|
|
61
|
-
</
|
|
61
|
+
</Card>
|
|
62
62
|
);
|
|
63
63
|
|
|
64
64
|
const button1 = screen.getByText('Button 1');
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
import { Button } from "@openai/apps-sdk-ui/components/Button"
|
|
3
|
-
import { cn } from "
|
|
3
|
+
import { cn } from "../../lib/index"
|
|
4
4
|
|
|
5
|
-
export interface
|
|
5
|
+
export interface CardButtonProps {
|
|
6
6
|
isPrimary?: boolean
|
|
7
7
|
onClick: () => void
|
|
8
8
|
children: React.ReactNode
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export interface
|
|
11
|
+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
12
12
|
children?: React.ReactNode
|
|
13
13
|
image?: string
|
|
14
14
|
imageAlt?: string
|
|
@@ -16,12 +16,12 @@ export interface OpenAICardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
16
16
|
imageMaxHeight?: number
|
|
17
17
|
header?: React.ReactNode
|
|
18
18
|
metadata?: React.ReactNode
|
|
19
|
-
button1?:
|
|
20
|
-
button2?:
|
|
19
|
+
button1?: CardButtonProps
|
|
20
|
+
button2?: CardButtonProps
|
|
21
21
|
variant?: "default" | "bordered" | "elevated"
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export const
|
|
24
|
+
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
25
25
|
(
|
|
26
26
|
{
|
|
27
27
|
children,
|
|
@@ -50,7 +50,7 @@ export const OpenAICard = React.forwardRef<HTMLDivElement, OpenAICardProps>(
|
|
|
50
50
|
onClick?.(e)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const renderButton = (buttonProps:
|
|
53
|
+
const renderButton = (buttonProps: CardButtonProps) => {
|
|
54
54
|
const { isPrimary = false, onClick: buttonOnClick, children } = buttonProps
|
|
55
55
|
|
|
56
56
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
@@ -123,4 +123,4 @@ export const OpenAICard = React.forwardRef<HTMLDivElement, OpenAICardProps>(
|
|
|
123
123
|
)
|
|
124
124
|
}
|
|
125
125
|
)
|
|
126
|
-
|
|
126
|
+
Card.displayName = "Card"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './card';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { Carousel } from './carousel';
|
|
4
4
|
|
|
5
5
|
const mockUseDisplayMode = vi.fn(() => 'inline');
|
|
6
6
|
|
|
@@ -20,18 +20,18 @@ vi.mock('embla-carousel-wheel-gestures', () => ({
|
|
|
20
20
|
WheelGesturesPlugin: vi.fn(() => ({})),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
-
describe('
|
|
23
|
+
describe('Carousel', () => {
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
mockUseDisplayMode.mockReturnValue('inline');
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it('renders all children with correct card width', () => {
|
|
29
29
|
const { container } = render(
|
|
30
|
-
<
|
|
30
|
+
<Carousel cardWidth={300}>
|
|
31
31
|
<div>Card 1</div>
|
|
32
32
|
<div>Card 2</div>
|
|
33
33
|
<div>Card 3</div>
|
|
34
|
-
</
|
|
34
|
+
</Carousel>
|
|
35
35
|
);
|
|
36
36
|
|
|
37
37
|
const cardContainers = container.querySelectorAll('.flex-none');
|
|
@@ -48,9 +48,9 @@ describe('OpenAICarousel', () => {
|
|
|
48
48
|
// Test inline mode
|
|
49
49
|
mockUseDisplayMode.mockReturnValue('inline');
|
|
50
50
|
const { container: inlineContainer } = render(
|
|
51
|
-
<
|
|
51
|
+
<Carousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
52
52
|
<div>Card 1</div>
|
|
53
|
-
</
|
|
53
|
+
</Carousel>
|
|
54
54
|
);
|
|
55
55
|
|
|
56
56
|
let cardContainer = inlineContainer.querySelector('.flex-none') as HTMLElement;
|
|
@@ -59,9 +59,9 @@ describe('OpenAICarousel', () => {
|
|
|
59
59
|
// Test fullscreen mode
|
|
60
60
|
mockUseDisplayMode.mockReturnValue('fullscreen');
|
|
61
61
|
const { container: fullscreenContainer } = render(
|
|
62
|
-
<
|
|
62
|
+
<Carousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
63
63
|
<div>Card 1</div>
|
|
64
|
-
</
|
|
64
|
+
</Carousel>
|
|
65
65
|
);
|
|
66
66
|
|
|
67
67
|
cardContainer = fullscreenContainer.querySelector('.flex-none') as HTMLElement;
|
|
@@ -70,10 +70,10 @@ describe('OpenAICarousel', () => {
|
|
|
70
70
|
|
|
71
71
|
it('applies custom gap between cards', () => {
|
|
72
72
|
const { container } = render(
|
|
73
|
-
<
|
|
73
|
+
<Carousel gap={24}>
|
|
74
74
|
<div>Card 1</div>
|
|
75
75
|
<div>Card 2</div>
|
|
76
|
-
</
|
|
76
|
+
</Carousel>
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
const carouselTrack = container.querySelector('.flex.touch-pan-y') as HTMLElement;
|
|
@@ -4,13 +4,13 @@ import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
|
|
4
4
|
import { ArrowLeft, ArrowRight } from "@openai/apps-sdk-ui/components/Icon"
|
|
5
5
|
import { useWidgetState, useDisplayMode } from "sunpeak"
|
|
6
6
|
import { Button } from "@openai/apps-sdk-ui/components/Button"
|
|
7
|
-
import { cn } from "
|
|
7
|
+
import { cn } from "../../lib/index"
|
|
8
8
|
|
|
9
|
-
export interface
|
|
9
|
+
export interface CarouselState extends Record<string, unknown> {
|
|
10
10
|
currentIndex?: number
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export type
|
|
13
|
+
export type CarouselProps = {
|
|
14
14
|
children?: React.ReactNode
|
|
15
15
|
gap?: number
|
|
16
16
|
showArrows?: boolean
|
|
@@ -19,9 +19,9 @@ export type OpenAICarouselProps = {
|
|
|
19
19
|
className?: string
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export const
|
|
22
|
+
export const Carousel = React.forwardRef<
|
|
23
23
|
HTMLDivElement,
|
|
24
|
-
|
|
24
|
+
CarouselProps
|
|
25
25
|
>(
|
|
26
26
|
(
|
|
27
27
|
{
|
|
@@ -34,7 +34,7 @@ export const OpenAICarousel = React.forwardRef<
|
|
|
34
34
|
},
|
|
35
35
|
ref
|
|
36
36
|
) => {
|
|
37
|
-
const [widgetState, setWidgetState] = useWidgetState<
|
|
37
|
+
const [widgetState, setWidgetState] = useWidgetState<CarouselState>(() => ({
|
|
38
38
|
currentIndex: 0,
|
|
39
39
|
}))
|
|
40
40
|
const displayMode = useDisplayMode()
|
|
@@ -175,4 +175,4 @@ export const OpenAICarousel = React.forwardRef<
|
|
|
175
175
|
)
|
|
176
176
|
}
|
|
177
177
|
)
|
|
178
|
-
|
|
178
|
+
Carousel.displayName = "Carousel"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './carousel';
|