sunpeak 0.5.10 → 0.5.13
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/bin/sunpeak.js +11 -1
- package/dist/index.cjs +27 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +27 -8
- package/dist/index.js.map +1 -1
- package/dist/style.css +12 -4
- package/package.json +1 -1
- package/template/_gitignore +47 -0
- package/template/dist/chatgpt/albums.js +49 -0
- package/template/dist/chatgpt/carousel.js +49 -0
- package/template/dist/chatgpt/counter.js +49 -0
- package/template/node_modules/.bin/eslint +21 -0
- package/template/node_modules/.bin/eslint-config-prettier +21 -0
- package/template/node_modules/.bin/nodemon +21 -0
- package/template/node_modules/.bin/prettier +21 -0
- package/template/node_modules/.bin/sunpeak +21 -0
- package/template/node_modules/.bin/ts-node +21 -0
- package/template/node_modules/.bin/ts-node-cwd +21 -0
- package/template/node_modules/.bin/ts-node-esm +21 -0
- package/template/node_modules/.bin/ts-node-script +21 -0
- package/template/node_modules/.bin/ts-node-transpile-only +21 -0
- package/template/node_modules/.bin/ts-script +21 -0
- package/template/node_modules/.bin/tsc +21 -0
- package/template/node_modules/.bin/tsserver +21 -0
- package/template/node_modules/.bin/tsx +21 -0
- package/template/node_modules/.bin/vite +21 -0
- package/template/node_modules/.bin/vitest +21 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js +20 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Checkbox.js +33 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Checkbox.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Icon.js +1498 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Icon.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Input.js +13 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Input.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +103 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +1019 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +95 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_theme.js +45 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_theme.js.map +7 -0
- package/template/node_modules/.vite/deps/_metadata.json +151 -0
- package/template/node_modules/.vite/deps/chunk-4TLBUCVB.js +1004 -0
- package/template/node_modules/.vite/deps/chunk-4TLBUCVB.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-BAG6OO6S.js +115 -0
- package/template/node_modules/.vite/deps/chunk-BAG6OO6S.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/template/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-CQ3GYAYB.js +112 -0
- package/template/node_modules/.vite/deps/chunk-CQ3GYAYB.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-EGRHWZRV.js +1 -0
- package/template/node_modules/.vite/deps/chunk-EGRHWZRV.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-EVJ3DVH5.js +628 -0
- package/template/node_modules/.vite/deps/chunk-EVJ3DVH5.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-ILHRZGIS.js +46 -0
- package/template/node_modules/.vite/deps/chunk-ILHRZGIS.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-KFGKZMLK.js +280 -0
- package/template/node_modules/.vite/deps/chunk-KFGKZMLK.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-PTVT3RFX.js +292 -0
- package/template/node_modules/.vite/deps/chunk-PTVT3RFX.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-QPJAV452.js +13 -0
- package/template/node_modules/.vite/deps/chunk-QPJAV452.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-SGWD4VEU.js +15195 -0
- package/template/node_modules/.vite/deps/chunk-SGWD4VEU.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-XB525PXG.js +4480 -0
- package/template/node_modules/.vite/deps/chunk-XB525PXG.js.map +7 -0
- package/template/node_modules/.vite/deps/chunk-YOJ6QPGS.js +231 -0
- package/template/node_modules/.vite/deps/chunk-YOJ6QPGS.js.map +7 -0
- package/template/node_modules/.vite/deps/clsx.js +10 -0
- package/template/node_modules/.vite/deps/clsx.js.map +7 -0
- package/template/node_modules/.vite/deps/embla-carousel-react.js +1712 -0
- package/template/node_modules/.vite/deps/embla-carousel-react.js.map +7 -0
- package/template/node_modules/.vite/deps/embla-carousel-wheel-gestures.js +589 -0
- package/template/node_modules/.vite/deps/embla-carousel-wheel-gestures.js.map +7 -0
- package/template/node_modules/.vite/deps/package.json +3 -0
- package/template/node_modules/.vite/deps/react-dom.js +7 -0
- package/template/node_modules/.vite/deps/react-dom.js.map +7 -0
- package/template/node_modules/.vite/deps/react-dom_client.js +20217 -0
- package/template/node_modules/.vite/deps/react-dom_client.js.map +7 -0
- package/template/node_modules/.vite/deps/react.js +6 -0
- package/template/node_modules/.vite/deps/react.js.map +7 -0
- package/template/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
- package/template/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
- package/template/node_modules/.vite/deps/react_jsx-runtime.js +7 -0
- package/template/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
- package/template/node_modules/.vite/deps/tailwind-merge.js +3095 -0
- package/template/node_modules/.vite/deps/tailwind-merge.js.map +7 -0
- package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/template/src/components/album/album-card.tsx +3 -1
- package/template/src/components/album/albums.test.tsx +47 -0
- package/template/src/components/album/albums.tsx +15 -2
- package/template/src/components/album/fullscreen-viewer.test.tsx +30 -1
- package/template/src/components/album/fullscreen-viewer.tsx +17 -3
- package/template/src/components/card/card.tsx +3 -0
- package/template/src/components/resources/albums-resource.test.tsx +81 -0
- package/template/src/components/resources/albums-resource.tsx +18 -1
- package/template/src/components/resources/carousel-resource.test.tsx +156 -0
- package/template/src/components/resources/carousel-resource.tsx +17 -2
- package/template/src/components/resources/counter-resource.test.tsx +116 -0
- package/template/src/components/resources/counter-resource.tsx +30 -5
- /package/template/{.prettierignore → _prettierignore} +0 -0
- /package/template/{.prettierrc → _prettierrc} +0 -0
|
@@ -4,8 +4,12 @@ import { FullscreenViewer } from './fullscreen-viewer';
|
|
|
4
4
|
import type { Album } from './albums';
|
|
5
5
|
|
|
6
6
|
// Mock sunpeak hooks
|
|
7
|
+
let mockMaxHeight = 800;
|
|
8
|
+
let mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
9
|
+
|
|
7
10
|
vi.mock('sunpeak', () => ({
|
|
8
|
-
useMaxHeight: () =>
|
|
11
|
+
useMaxHeight: () => mockMaxHeight,
|
|
12
|
+
useSafeArea: () => mockSafeArea,
|
|
9
13
|
}));
|
|
10
14
|
|
|
11
15
|
describe('FullscreenViewer', () => {
|
|
@@ -74,4 +78,29 @@ describe('FullscreenViewer', () => {
|
|
|
74
78
|
const images = container.querySelectorAll('img');
|
|
75
79
|
expect(images.length).toBe(0);
|
|
76
80
|
});
|
|
81
|
+
|
|
82
|
+
it('respects safe area insets for main photo area', () => {
|
|
83
|
+
mockSafeArea = { insets: { top: 20, bottom: 30, left: 10, right: 15 } };
|
|
84
|
+
|
|
85
|
+
const { container } = render(<FullscreenViewer album={mockAlbum} />);
|
|
86
|
+
|
|
87
|
+
// Find the main photo area
|
|
88
|
+
const mainPhotoArea = container.querySelector('.flex-1.min-w-0');
|
|
89
|
+
expect(mainPhotoArea).toBeInTheDocument();
|
|
90
|
+
|
|
91
|
+
// Note: calc values may not be computed in jsdom, so we check the element has the style attribute
|
|
92
|
+
expect(mainPhotoArea).toHaveAttribute('style');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('respects maxHeight constraint', () => {
|
|
96
|
+
mockMaxHeight = 600;
|
|
97
|
+
|
|
98
|
+
const { container } = render(<FullscreenViewer album={mockAlbum} />);
|
|
99
|
+
|
|
100
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
101
|
+
expect(mainDiv).toHaveStyle({
|
|
102
|
+
maxHeight: '600px',
|
|
103
|
+
height: '600px',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
77
106
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { useMaxHeight } from 'sunpeak';
|
|
2
|
+
import { useMaxHeight, useSafeArea } from 'sunpeak';
|
|
3
3
|
import { cn } from '../../lib/index';
|
|
4
4
|
import { FilmStrip } from './film-strip';
|
|
5
5
|
import type { Album } from './albums';
|
|
@@ -12,6 +12,7 @@ export type FullscreenViewerProps = {
|
|
|
12
12
|
export const FullscreenViewer = React.forwardRef<HTMLDivElement, FullscreenViewerProps>(
|
|
13
13
|
({ album, className }, ref) => {
|
|
14
14
|
const maxHeight = useMaxHeight();
|
|
15
|
+
const safeArea = useSafeArea();
|
|
15
16
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
16
17
|
|
|
17
18
|
React.useEffect(() => {
|
|
@@ -31,12 +32,25 @@ export const FullscreenViewer = React.forwardRef<HTMLDivElement, FullscreenViewe
|
|
|
31
32
|
>
|
|
32
33
|
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
|
33
34
|
{/* Film strip */}
|
|
34
|
-
<div
|
|
35
|
+
<div
|
|
36
|
+
className="hidden md:block absolute pointer-events-none z-10 left-0 top-0 bottom-0 w-40"
|
|
37
|
+
style={{
|
|
38
|
+
paddingLeft: `${safeArea?.insets.left ?? 0}px`,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
35
41
|
<FilmStrip album={album} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
|
|
36
42
|
</div>
|
|
37
43
|
|
|
38
44
|
{/* Main photo */}
|
|
39
|
-
<div
|
|
45
|
+
<div
|
|
46
|
+
className="flex-1 min-w-0 px-40 py-10 relative flex items-center justify-center"
|
|
47
|
+
style={{
|
|
48
|
+
paddingTop: `calc(2.5rem + ${safeArea?.insets.top ?? 0}px)`,
|
|
49
|
+
paddingBottom: `calc(2.5rem + ${safeArea?.insets.bottom ?? 0}px)`,
|
|
50
|
+
paddingLeft: `calc(10rem + ${safeArea?.insets.left ?? 0}px)`,
|
|
51
|
+
paddingRight: `calc(10rem + ${safeArea?.insets.right ?? 0}px)`,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
40
54
|
<div className="relative w-full h-full">
|
|
41
55
|
{selectedPhoto ? (
|
|
42
56
|
<img
|
|
@@ -19,6 +19,7 @@ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
19
19
|
button1?: CardButtonProps;
|
|
20
20
|
button2?: CardButtonProps;
|
|
21
21
|
variant?: 'default' | 'bordered' | 'elevated';
|
|
22
|
+
buttonSize?: 'xs' | 'sm' | 'md' | 'lg';
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
@@ -34,6 +35,7 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
|
34
35
|
button1,
|
|
35
36
|
button2,
|
|
36
37
|
variant = 'default',
|
|
38
|
+
buttonSize = 'sm',
|
|
37
39
|
className,
|
|
38
40
|
onClick,
|
|
39
41
|
...props
|
|
@@ -63,6 +65,7 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
|
63
65
|
color={isPrimary ? 'primary' : 'secondary'}
|
|
64
66
|
variant={isPrimary ? 'solid' : 'soft'}
|
|
65
67
|
onClick={handleClick}
|
|
68
|
+
size={buttonSize}
|
|
66
69
|
>
|
|
67
70
|
{children}
|
|
68
71
|
</Button>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { AlbumsResource } from './albums-resource';
|
|
4
|
+
|
|
5
|
+
// Mock sunpeak hooks
|
|
6
|
+
let mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
7
|
+
let mockMaxHeight = 600;
|
|
8
|
+
|
|
9
|
+
vi.mock('sunpeak', () => ({
|
|
10
|
+
useSafeArea: () => mockSafeArea,
|
|
11
|
+
useMaxHeight: () => mockMaxHeight,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Mock Albums component
|
|
15
|
+
vi.mock('../album/albums', () => ({
|
|
16
|
+
Albums: () => <div data-testid="albums-component">Albums Component</div>,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('AlbumsResource', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
23
|
+
mockMaxHeight = 600;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders Albums component', () => {
|
|
27
|
+
render(<AlbumsResource />);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByTestId('albums-component')).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByText('Albums Component')).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('respects safe area insets', () => {
|
|
34
|
+
mockSafeArea = { insets: { top: 20, bottom: 30, left: 10, right: 15 } };
|
|
35
|
+
|
|
36
|
+
const { container } = render(<AlbumsResource />);
|
|
37
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
38
|
+
|
|
39
|
+
expect(mainDiv).toHaveStyle({
|
|
40
|
+
paddingTop: '20px',
|
|
41
|
+
paddingBottom: '30px',
|
|
42
|
+
paddingLeft: '10px',
|
|
43
|
+
paddingRight: '15px',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('respects maxHeight constraint', () => {
|
|
48
|
+
mockMaxHeight = 800;
|
|
49
|
+
|
|
50
|
+
const { container } = render(<AlbumsResource />);
|
|
51
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
52
|
+
|
|
53
|
+
expect(mainDiv).toHaveStyle({
|
|
54
|
+
maxHeight: '800px',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('applies zero safe area insets when not provided', () => {
|
|
59
|
+
mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
60
|
+
|
|
61
|
+
const { container } = render(<AlbumsResource />);
|
|
62
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
63
|
+
|
|
64
|
+
expect(mainDiv).toHaveStyle({
|
|
65
|
+
paddingTop: '0px',
|
|
66
|
+
paddingBottom: '0px',
|
|
67
|
+
paddingLeft: '0px',
|
|
68
|
+
paddingRight: '0px',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles undefined maxHeight gracefully', () => {
|
|
73
|
+
mockMaxHeight = undefined as unknown as number;
|
|
74
|
+
|
|
75
|
+
const { container } = render(<AlbumsResource />);
|
|
76
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
77
|
+
|
|
78
|
+
// maxHeight should not be set when undefined
|
|
79
|
+
expect(mainDiv.style.maxHeight).toBe('');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { useSafeArea, useMaxHeight } from 'sunpeak';
|
|
2
3
|
import { Albums } from '../album/albums';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -8,6 +9,22 @@ import { Albums } from '../album/albums';
|
|
|
8
9
|
* Can be dropped into any production environment without changes.
|
|
9
10
|
*/
|
|
10
11
|
export const AlbumsResource = React.forwardRef<HTMLDivElement>((_props, ref) => {
|
|
11
|
-
|
|
12
|
+
const safeArea = useSafeArea();
|
|
13
|
+
const maxHeight = useMaxHeight();
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
ref={ref}
|
|
18
|
+
style={{
|
|
19
|
+
paddingTop: `${safeArea?.insets.top ?? 0}px`,
|
|
20
|
+
paddingBottom: `${safeArea?.insets.bottom ?? 0}px`,
|
|
21
|
+
paddingLeft: `${safeArea?.insets.left ?? 0}px`,
|
|
22
|
+
paddingRight: `${safeArea?.insets.right ?? 0}px`,
|
|
23
|
+
maxHeight: maxHeight ?? undefined,
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
<Albums />
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
12
29
|
});
|
|
13
30
|
AlbumsResource.displayName = 'AlbumsResource';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { CarouselResource } from './carousel-resource';
|
|
4
|
+
|
|
5
|
+
// Mock sunpeak hooks
|
|
6
|
+
interface Place {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
rating: number;
|
|
10
|
+
category: string;
|
|
11
|
+
location: string;
|
|
12
|
+
image: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let mockWidgetData: { places: Place[] } = { places: [] };
|
|
17
|
+
let mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
18
|
+
let mockMaxHeight = 600;
|
|
19
|
+
let mockUserAgent: {
|
|
20
|
+
device: { type: 'desktop' | 'mobile' | 'tablet' | 'unknown' };
|
|
21
|
+
capabilities: { hover: boolean; touch: boolean };
|
|
22
|
+
} = {
|
|
23
|
+
device: { type: 'desktop' },
|
|
24
|
+
capabilities: { hover: true, touch: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
vi.mock('sunpeak', () => ({
|
|
28
|
+
useWidgetProps: () => mockWidgetData,
|
|
29
|
+
useSafeArea: () => mockSafeArea,
|
|
30
|
+
useMaxHeight: () => mockMaxHeight,
|
|
31
|
+
useUserAgent: () => mockUserAgent,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Mock child components
|
|
35
|
+
vi.mock('../carousel/carousel', () => ({
|
|
36
|
+
Carousel: ({ children }: { children: React.ReactNode }) => (
|
|
37
|
+
<div data-testid="carousel">{children}</div>
|
|
38
|
+
),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('../card/card', () => ({
|
|
42
|
+
Card: ({ header, buttonSize }: { header: React.ReactNode; buttonSize?: string }) => (
|
|
43
|
+
<div data-testid="card" data-button-size={buttonSize}>
|
|
44
|
+
{header}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
describe('CarouselResource', () => {
|
|
50
|
+
const mockPlaces = [
|
|
51
|
+
{
|
|
52
|
+
id: 'place-1',
|
|
53
|
+
name: 'Beach Resort',
|
|
54
|
+
rating: 4.5,
|
|
55
|
+
category: 'Hotel',
|
|
56
|
+
location: 'Miami',
|
|
57
|
+
image: 'https://example.com/beach.jpg',
|
|
58
|
+
description: 'Beautiful beach resort',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'place-2',
|
|
62
|
+
name: 'Mountain Lodge',
|
|
63
|
+
rating: 4.8,
|
|
64
|
+
category: 'Lodge',
|
|
65
|
+
location: 'Colorado',
|
|
66
|
+
image: 'https://example.com/mountain.jpg',
|
|
67
|
+
description: 'Cozy mountain lodge',
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
mockWidgetData = { places: [] };
|
|
74
|
+
mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
75
|
+
mockMaxHeight = 600;
|
|
76
|
+
mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('renders carousel with places', () => {
|
|
80
|
+
mockWidgetData = { places: mockPlaces };
|
|
81
|
+
|
|
82
|
+
render(<CarouselResource />);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByTestId('carousel')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByText('Beach Resort')).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText('Mountain Lodge')).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders empty carousel when no places provided', () => {
|
|
90
|
+
mockWidgetData = { places: [] };
|
|
91
|
+
|
|
92
|
+
const { container } = render(<CarouselResource />);
|
|
93
|
+
|
|
94
|
+
expect(screen.getByTestId('carousel')).toBeInTheDocument();
|
|
95
|
+
expect(container.querySelectorAll('[data-testid="card"]').length).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('respects safe area insets', () => {
|
|
99
|
+
mockSafeArea = { insets: { top: 20, bottom: 30, left: 10, right: 15 } };
|
|
100
|
+
|
|
101
|
+
const { container } = render(<CarouselResource />);
|
|
102
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
103
|
+
|
|
104
|
+
expect(mainDiv).toHaveStyle({
|
|
105
|
+
paddingTop: '20px',
|
|
106
|
+
paddingBottom: '30px',
|
|
107
|
+
paddingLeft: '10px',
|
|
108
|
+
paddingRight: '15px',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('respects maxHeight constraint', () => {
|
|
113
|
+
mockMaxHeight = 500;
|
|
114
|
+
|
|
115
|
+
const { container } = render(<CarouselResource />);
|
|
116
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
117
|
+
|
|
118
|
+
expect(mainDiv).toHaveStyle({
|
|
119
|
+
maxHeight: '500px',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('passes larger button size for touch devices', () => {
|
|
124
|
+
mockUserAgent = { device: { type: 'mobile' }, capabilities: { hover: false, touch: true } };
|
|
125
|
+
mockWidgetData = { places: mockPlaces };
|
|
126
|
+
|
|
127
|
+
render(<CarouselResource />);
|
|
128
|
+
|
|
129
|
+
const cards = screen.getAllByTestId('card');
|
|
130
|
+
cards.forEach((card) => {
|
|
131
|
+
expect(card).toHaveAttribute('data-button-size', 'md');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes standard button size for non-touch devices', () => {
|
|
136
|
+
mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
|
|
137
|
+
mockWidgetData = { places: mockPlaces };
|
|
138
|
+
|
|
139
|
+
render(<CarouselResource />);
|
|
140
|
+
|
|
141
|
+
const cards = screen.getAllByTestId('card');
|
|
142
|
+
cards.forEach((card) => {
|
|
143
|
+
expect(card).toHaveAttribute('data-button-size', 'sm');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('renders all place information', () => {
|
|
148
|
+
mockWidgetData = { places: mockPlaces };
|
|
149
|
+
|
|
150
|
+
render(<CarouselResource />);
|
|
151
|
+
|
|
152
|
+
// Check that place names are rendered
|
|
153
|
+
expect(screen.getByText('Beach Resort')).toBeInTheDocument();
|
|
154
|
+
expect(screen.getByText('Mountain Lodge')).toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { useWidgetProps } from 'sunpeak';
|
|
2
|
+
import { useWidgetProps, useSafeArea, useMaxHeight, useUserAgent } from 'sunpeak';
|
|
3
3
|
import { Carousel } from '../carousel/carousel';
|
|
4
4
|
import { Card } from '../card/card';
|
|
5
5
|
|
|
@@ -26,9 +26,23 @@ interface CarouselData extends Record<string, unknown> {
|
|
|
26
26
|
|
|
27
27
|
export const CarouselResource = React.forwardRef<HTMLDivElement>((_props, ref) => {
|
|
28
28
|
const data = useWidgetProps<CarouselData>(() => ({ places: [] }));
|
|
29
|
+
const safeArea = useSafeArea();
|
|
30
|
+
const maxHeight = useMaxHeight();
|
|
31
|
+
const userAgent = useUserAgent();
|
|
32
|
+
|
|
33
|
+
const hasTouch = userAgent?.capabilities.touch ?? false;
|
|
29
34
|
|
|
30
35
|
return (
|
|
31
|
-
<div
|
|
36
|
+
<div
|
|
37
|
+
ref={ref}
|
|
38
|
+
style={{
|
|
39
|
+
paddingTop: `${safeArea?.insets.top ?? 0}px`,
|
|
40
|
+
paddingBottom: `${safeArea?.insets.bottom ?? 0}px`,
|
|
41
|
+
paddingLeft: `${safeArea?.insets.left ?? 0}px`,
|
|
42
|
+
paddingRight: `${safeArea?.insets.right ?? 0}px`,
|
|
43
|
+
maxHeight: maxHeight ?? undefined,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
32
46
|
<Carousel gap={16} showArrows={true} showEdgeGradients={true} cardWidth={220}>
|
|
33
47
|
{(data.places || []).map((place: CarouselCard) => (
|
|
34
48
|
<Card
|
|
@@ -37,6 +51,7 @@ export const CarouselResource = React.forwardRef<HTMLDivElement>((_props, ref) =
|
|
|
37
51
|
imageAlt={place.name}
|
|
38
52
|
header={place.name}
|
|
39
53
|
metadata={`⭐ ${place.rating} • ${place.category} • ${place.location}`}
|
|
54
|
+
buttonSize={hasTouch ? 'md' : 'sm'}
|
|
40
55
|
button1={{
|
|
41
56
|
isPrimary: true,
|
|
42
57
|
onClick: () => console.log(`Visit ${place.name}`),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { CounterResource } from './counter-resource';
|
|
4
|
+
|
|
5
|
+
// Mock sunpeak hooks
|
|
6
|
+
const mockSetWidgetState = vi.fn();
|
|
7
|
+
let mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
8
|
+
let mockMaxHeight = 600;
|
|
9
|
+
let mockUserAgent: {
|
|
10
|
+
device: { type: 'desktop' | 'mobile' | 'tablet' | 'unknown' };
|
|
11
|
+
capabilities: { hover: boolean; touch: boolean };
|
|
12
|
+
} = {
|
|
13
|
+
device: { type: 'desktop' },
|
|
14
|
+
capabilities: { hover: true, touch: false },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
vi.mock('sunpeak', () => ({
|
|
18
|
+
useWidgetState: () => [{ count: 0 }, mockSetWidgetState],
|
|
19
|
+
useSafeArea: () => mockSafeArea,
|
|
20
|
+
useMaxHeight: () => mockMaxHeight,
|
|
21
|
+
useUserAgent: () => mockUserAgent,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe('CounterResource', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
mockSafeArea = { insets: { top: 0, bottom: 0, left: 0, right: 0 } };
|
|
28
|
+
mockMaxHeight = 600;
|
|
29
|
+
mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders counter with initial count', () => {
|
|
33
|
+
render(<CounterResource />);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByText('Welcome to Sunpeak!')).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('increments counter when + button is clicked', () => {
|
|
40
|
+
render(<CounterResource />);
|
|
41
|
+
|
|
42
|
+
const incrementButton = screen.getByLabelText('Increment');
|
|
43
|
+
fireEvent.click(incrementButton);
|
|
44
|
+
|
|
45
|
+
expect(mockSetWidgetState).toHaveBeenCalledWith({ count: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('decrements counter when - button is clicked', () => {
|
|
49
|
+
render(<CounterResource />);
|
|
50
|
+
|
|
51
|
+
const decrementButton = screen.getByLabelText('Decrement');
|
|
52
|
+
fireEvent.click(decrementButton);
|
|
53
|
+
|
|
54
|
+
expect(mockSetWidgetState).toHaveBeenCalledWith({ count: -1 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resets counter when Reset button is clicked', () => {
|
|
58
|
+
render(<CounterResource />);
|
|
59
|
+
|
|
60
|
+
const resetButton = screen.getByText('Reset');
|
|
61
|
+
fireEvent.click(resetButton);
|
|
62
|
+
|
|
63
|
+
expect(mockSetWidgetState).toHaveBeenCalledWith({ count: 0 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('respects safe area insets', () => {
|
|
67
|
+
mockSafeArea = { insets: { top: 20, bottom: 30, left: 10, right: 15 } };
|
|
68
|
+
|
|
69
|
+
const { container } = render(<CounterResource />);
|
|
70
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
71
|
+
|
|
72
|
+
expect(mainDiv).toHaveStyle({
|
|
73
|
+
paddingTop: 'calc(2rem + 20px)',
|
|
74
|
+
paddingBottom: 'calc(2rem + 30px)',
|
|
75
|
+
paddingLeft: 'calc(2rem + 10px)',
|
|
76
|
+
paddingRight: 'calc(2rem + 15px)',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('respects maxHeight constraint', () => {
|
|
81
|
+
mockMaxHeight = 400;
|
|
82
|
+
|
|
83
|
+
const { container } = render(<CounterResource />);
|
|
84
|
+
const mainDiv = container.firstChild as HTMLElement;
|
|
85
|
+
|
|
86
|
+
expect(mainDiv).toHaveStyle({
|
|
87
|
+
maxHeight: '400px',
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('renders larger buttons for touch devices', () => {
|
|
92
|
+
mockUserAgent = { device: { type: 'mobile' }, capabilities: { hover: false, touch: true } };
|
|
93
|
+
|
|
94
|
+
render(<CounterResource />);
|
|
95
|
+
|
|
96
|
+
const incrementButton = screen.getByLabelText('Increment');
|
|
97
|
+
const resetButton = screen.getByText('Reset');
|
|
98
|
+
|
|
99
|
+
// Buttons should have larger size for touch
|
|
100
|
+
expect(incrementButton).toBeInTheDocument();
|
|
101
|
+
expect(resetButton).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders standard-sized buttons for non-touch devices', () => {
|
|
105
|
+
mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
|
|
106
|
+
|
|
107
|
+
render(<CounterResource />);
|
|
108
|
+
|
|
109
|
+
const incrementButton = screen.getByLabelText('Increment');
|
|
110
|
+
const resetButton = screen.getByText('Reset');
|
|
111
|
+
|
|
112
|
+
// Buttons should have standard size for non-touch
|
|
113
|
+
expect(incrementButton).toBeInTheDocument();
|
|
114
|
+
expect(resetButton).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useWidgetState } from 'sunpeak';
|
|
1
|
+
import { useWidgetState, useSafeArea, useMaxHeight, useUserAgent } from 'sunpeak';
|
|
2
2
|
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
3
3
|
|
|
4
4
|
interface CounterState extends Record<string, unknown> {
|
|
@@ -15,8 +15,12 @@ export function CounterResource() {
|
|
|
15
15
|
const [widgetState, setWidgetState] = useWidgetState<CounterState>(() => ({
|
|
16
16
|
count: 0,
|
|
17
17
|
}));
|
|
18
|
+
const safeArea = useSafeArea();
|
|
19
|
+
const maxHeight = useMaxHeight();
|
|
20
|
+
const userAgent = useUserAgent();
|
|
18
21
|
|
|
19
22
|
const count = widgetState?.count ?? 0;
|
|
23
|
+
const hasTouch = userAgent?.capabilities.touch ?? false;
|
|
20
24
|
|
|
21
25
|
const increment = () => {
|
|
22
26
|
setWidgetState({ count: count + 1 });
|
|
@@ -31,7 +35,16 @@ export function CounterResource() {
|
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
return (
|
|
34
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
className="flex flex-col items-center justify-center p-8 space-y-6"
|
|
40
|
+
style={{
|
|
41
|
+
paddingTop: `calc(2rem + ${safeArea?.insets.top ?? 0}px)`,
|
|
42
|
+
paddingBottom: `calc(2rem + ${safeArea?.insets.bottom ?? 0}px)`,
|
|
43
|
+
paddingLeft: `calc(2rem + ${safeArea?.insets.left ?? 0}px)`,
|
|
44
|
+
paddingRight: `calc(2rem + ${safeArea?.insets.right ?? 0}px)`,
|
|
45
|
+
maxHeight: maxHeight ?? undefined,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
35
48
|
<div className="text-center space-y-2">
|
|
36
49
|
<h1 className="text-3xl font-bold text-primary">Welcome to Sunpeak!</h1>
|
|
37
50
|
<p className="text-secondary">Build your MCP resource here</p>
|
|
@@ -41,15 +54,27 @@ export function CounterResource() {
|
|
|
41
54
|
<div className="text-6xl font-bold text-primary">{count}</div>
|
|
42
55
|
|
|
43
56
|
<div className="flex gap-2">
|
|
44
|
-
<Button
|
|
57
|
+
<Button
|
|
58
|
+
variant="soft"
|
|
59
|
+
color="secondary"
|
|
60
|
+
onClick={decrement}
|
|
61
|
+
aria-label="Decrement"
|
|
62
|
+
size={hasTouch ? 'lg' : 'md'}
|
|
63
|
+
>
|
|
45
64
|
−
|
|
46
65
|
</Button>
|
|
47
|
-
<Button
|
|
66
|
+
<Button
|
|
67
|
+
variant="solid"
|
|
68
|
+
color="primary"
|
|
69
|
+
onClick={increment}
|
|
70
|
+
aria-label="Increment"
|
|
71
|
+
size={hasTouch ? 'lg' : 'md'}
|
|
72
|
+
>
|
|
48
73
|
+
|
|
49
74
|
</Button>
|
|
50
75
|
</div>
|
|
51
76
|
|
|
52
|
-
<Button variant="outline" color="secondary" onClick={reset} size=
|
|
77
|
+
<Button variant="outline" color="secondary" onClick={reset} size={hasTouch ? 'md' : 'sm'}>
|
|
53
78
|
Reset
|
|
54
79
|
</Button>
|
|
55
80
|
</div>
|
|
File without changes
|
|
File without changes
|