sunpeak 0.5.9 → 0.5.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.5.9",
3
+ "version": "0.5.12",
4
4
  "description": "The MCP App SDK. Quickstart, build, & test your ChatGPT App locally!",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -73,8 +73,8 @@
73
73
  "@testing-library/react": "^16.3.0",
74
74
  "@testing-library/user-event": "^14.6.1",
75
75
  "@types/node": "^24.10.1",
76
- "@types/react": "^18.3.12",
77
- "@types/react-dom": "^18.3.1",
76
+ "@types/react": "^19.2.7",
77
+ "@types/react-dom": "^19.2.3",
78
78
  "@typescript-eslint/eslint-plugin": "^8.47.0",
79
79
  "@typescript-eslint/parser": "^8.47.0",
80
80
  "@vitejs/plugin-react": "^4.3.4",
@@ -84,8 +84,8 @@
84
84
  "eslint-plugin-react-hooks": "^7.0.1",
85
85
  "jsdom": "^27.2.0",
86
86
  "prettier": "^3.6.2",
87
- "react": "^18.3.1",
88
- "react-dom": "^18.3.1",
87
+ "react": "^19.2.0",
88
+ "react-dom": "^19.2.0",
89
89
  "tailwindcss": "^4.1.17",
90
90
  "ts-node": "^10.9.2",
91
91
  "tsx": "^4.20.6",
@@ -105,7 +105,7 @@
105
105
  "scripts": {
106
106
  "build": "vite build",
107
107
  "dev": "pnpm --filter my-sunpeak-app dev",
108
- "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
108
+ "format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
109
109
  "lint": "eslint . --ext .ts,.tsx --fix",
110
110
  "typecheck": "tsc --noEmit",
111
111
  "test": "vitest run",
@@ -6,7 +6,7 @@
6
6
  "scripts": {
7
7
  "build": "node scripts/build-all.mjs",
8
8
  "dev": "vite --port ${PORT:-6767}",
9
- "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
9
+ "format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
10
10
  "mcp": "nodemon",
11
11
  "mcp:serve": "tsx mcp/server.ts",
12
12
  "lint": "eslint . --ext .ts,.tsx --fix",
@@ -28,8 +28,8 @@
28
28
  "@testing-library/react": "^16.3.0",
29
29
  "@testing-library/user-event": "^14.6.1",
30
30
  "@types/node": "^24.10.1",
31
- "@types/react": "^18.3.12",
32
- "@types/react-dom": "^18.3.1",
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
33
  "@typescript-eslint/eslint-plugin": "^8.47.0",
34
34
  "@typescript-eslint/parser": "^8.47.0",
35
35
  "@vitejs/plugin-react": "^4.3.4",
@@ -41,8 +41,8 @@
41
41
  "nodemon": "^3.1.11",
42
42
  "postcss": "^8.4.49",
43
43
  "prettier": "^3.6.2",
44
- "react": "^18.3.1",
45
- "react-dom": "^18.3.1",
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0",
46
46
  "tailwindcss": "^4.1.17",
47
47
  "ts-node": "^10.9.2",
48
48
  "tsx": "^4.20.6",
@@ -130,8 +130,8 @@ try {
130
130
  printSuccess('pnpm build');
131
131
 
132
132
  // MCP Server Check
133
- console.log('\nRunning: pnpm mcp');
134
- const mcpProcess = spawn('pnpm', ['mcp'], {
133
+ console.log('\nRunning: pnpm mcp:serve');
134
+ const mcpProcess = spawn('pnpm', ['mcp:serve'], {
135
135
  cwd: PROJECT_ROOT,
136
136
  stdio: ['ignore', 'pipe', 'pipe'],
137
137
  env: { ...process.env, FORCE_COLOR: '1' },
@@ -7,15 +7,17 @@ export type AlbumCardProps = {
7
7
  album: Album;
8
8
  onSelect?: (album: Album) => void;
9
9
  className?: string;
10
+ buttonSize?: 'xs' | 'sm' | 'md' | 'lg';
10
11
  };
11
12
 
12
13
  export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
13
- ({ album, onSelect, className }, ref) => {
14
+ ({ album, onSelect, className, buttonSize = 'md' }, ref) => {
14
15
  return (
15
16
  <Button
16
17
  ref={ref}
17
18
  variant="ghost"
18
19
  color="secondary"
20
+ size={buttonSize}
19
21
  className={cn(
20
22
  '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
23
  className
@@ -6,12 +6,20 @@ import { Albums, type AlbumsData } from './albums';
6
6
  const mockSetWidgetState = vi.fn();
7
7
  const mockRequestDisplayMode = vi.fn();
8
8
  let mockWidgetData: AlbumsData = { albums: [] };
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
+ };
9
16
 
10
17
  vi.mock('sunpeak', () => ({
11
18
  useWidgetProps: () => mockWidgetData,
12
19
  useWidgetState: () => [{ selectedAlbumId: null }, mockSetWidgetState],
13
20
  useDisplayMode: () => 'default',
14
21
  useWidgetAPI: () => ({ requestDisplayMode: mockRequestDisplayMode }),
22
+ useUserAgent: () => mockUserAgent,
15
23
  }));
16
24
 
17
25
  // Mock child components to simplify testing
@@ -27,6 +35,22 @@ vi.mock('../carousel', () => ({
27
35
  ),
28
36
  }));
29
37
 
38
+ vi.mock('./album-card', () => ({
39
+ AlbumCard: ({
40
+ album,
41
+ onSelect,
42
+ buttonSize,
43
+ }: {
44
+ album: { title: string };
45
+ onSelect: (a: unknown) => void;
46
+ buttonSize?: string;
47
+ }) => (
48
+ <button onClick={() => onSelect(album)} data-button-size={buttonSize}>
49
+ {album.title}
50
+ </button>
51
+ ),
52
+ }));
53
+
30
54
  describe('Albums', () => {
31
55
  const mockAlbums = [
32
56
  {
@@ -46,6 +70,7 @@ describe('Albums', () => {
46
70
  beforeEach(() => {
47
71
  vi.clearAllMocks();
48
72
  mockWidgetData = { albums: mockAlbums };
73
+ mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
49
74
  });
50
75
 
51
76
  it('renders Carousel with all albums in default mode', () => {
@@ -85,4 +110,26 @@ describe('Albums', () => {
85
110
  const buttons = container.querySelectorAll('button');
86
111
  expect(buttons.length).toBe(0);
87
112
  });
113
+
114
+ it('passes larger button size for touch devices', () => {
115
+ mockUserAgent = { device: { type: 'mobile' }, capabilities: { hover: false, touch: true } };
116
+
117
+ render(<Albums />);
118
+
119
+ const albumButtons = screen.getAllByRole('button');
120
+ albumButtons.forEach((button) => {
121
+ expect(button).toHaveAttribute('data-button-size', 'lg');
122
+ });
123
+ });
124
+
125
+ it('passes standard button size for non-touch devices', () => {
126
+ mockUserAgent = { device: { type: 'desktop' }, capabilities: { hover: true, touch: false } };
127
+
128
+ render(<Albums />);
129
+
130
+ const albumButtons = screen.getAllByRole('button');
131
+ albumButtons.forEach((button) => {
132
+ expect(button).toHaveAttribute('data-button-size', 'md');
133
+ });
134
+ });
88
135
  });
@@ -1,5 +1,11 @@
1
1
  import * as React from 'react';
2
- import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from 'sunpeak';
2
+ import {
3
+ useWidgetState,
4
+ useDisplayMode,
5
+ useWidgetAPI,
6
+ useWidgetProps,
7
+ useUserAgent,
8
+ } from 'sunpeak';
3
9
  import { Carousel } from '../carousel';
4
10
  import { AlbumCard } from './album-card';
5
11
  import { FullscreenViewer } from './fullscreen-viewer';
@@ -34,9 +40,11 @@ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className
34
40
  }));
35
41
  const displayMode = useDisplayMode();
36
42
  const api = useWidgetAPI();
43
+ const userAgent = useUserAgent();
37
44
 
38
45
  const albums = data.albums || [];
39
46
  const selectedAlbum = albums.find((album) => album.id === widgetState?.selectedAlbumId);
47
+ const hasTouch = userAgent?.capabilities.touch ?? false;
40
48
 
41
49
  const handleSelectAlbum = React.useCallback(
42
50
  (album: Album) => {
@@ -54,7 +62,12 @@ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className
54
62
  <div ref={ref} className={className}>
55
63
  <Carousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
56
64
  {albums.map((album) => (
57
- <AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
65
+ <AlbumCard
66
+ key={album.id}
67
+ album={album}
68
+ onSelect={handleSelectAlbum}
69
+ buttonSize={hasTouch ? 'lg' : 'md'}
70
+ />
58
71
  ))}
59
72
  </Carousel>
60
73
  </div>
@@ -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: () => 800,
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 className="hidden md:block absolute pointer-events-none z-10 left-0 top-0 bottom-0 w-40">
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 className="flex-1 min-w-0 px-40 py-10 relative flex items-center justify-center">
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
- return <Albums ref={ref} />;
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 ref={ref}>
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}`),