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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/api-reference/resources/carousel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ interface CarouselCard {
location: string
image: string
description: string
address?: string
phone?: string
hours?: string
priceRange?: string
highlights?: string[]
tips?: string[]
}
```

Expand Down Expand Up @@ -69,6 +75,30 @@ interface CarouselCard {
Description text displayed in the card body.
</ResponseField>

<ResponseField name="places[].address" type="string">
Street address, shown in the fullscreen detail view.
</ResponseField>

<ResponseField name="places[].phone" type="string">
Phone number, shown in the fullscreen detail view.
</ResponseField>

<ResponseField name="places[].hours" type="string">
Operating hours, shown in the fullscreen detail view.
</ResponseField>

<ResponseField name="places[].priceRange" type="string">
Price range indicator (e.g. "Free", "$$"), shown as a badge in the detail view.
</ResponseField>

<ResponseField name="places[].highlights" type="string[]">
List of highlight tags displayed as badges in the fullscreen detail view.
</ResponseField>

<ResponseField name="places[].tips" type="string[]">
List of tips displayed in the fullscreen detail view.
</ResponseField>

## Features

### Carousel Navigation
Expand All @@ -89,6 +119,21 @@ Each card includes:
- Description text
- Two action buttons (Visit and Learn More)

Clicking a card or its "Learn More" button opens a fullscreen detail view. The primary "Visit" button triggers its own action without opening fullscreen.

### Fullscreen Detail View

When a card is clicked, the resource requests fullscreen mode and displays a detail view with:
- Centered hero image (preserves aspect ratio, never stretched)
- Star rating and category/price badges
- Full description text
- Info grid with address, hours, and phone when available
- Highlight tags displayed as colored badges
- Tips displayed as a styled list
- Action buttons (Visit, Learn More)

The detail view relies on the host's built-in close button to return to the inline carousel. When the host switches back to inline mode, the carousel automatically re-renders without needing explicit back navigation.

### Responsive Button Sizing

The resource detects touch capabilities from the host context and automatically adjusts button sizes for easier interaction on touch devices.
Expand Down
18 changes: 16 additions & 2 deletions examples/carousel-example/src/resources/carousel/carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ let mockToolOutput: { places: Place[] } = { places: [] };
let mockDeviceCapabilities: { hover?: boolean; touch?: boolean } = { hover: true, touch: false };
let mockDisplayMode = 'inline';

const mockRequestDisplayMode = vi.fn();

vi.mock('sunpeak', () => ({
useToolData: () => ({
output: mockToolOutput,
Expand All @@ -29,6 +31,7 @@ vi.mock('sunpeak', () => ({
}),
useDeviceCapabilities: () => mockDeviceCapabilities,
useDisplayMode: () => mockDisplayMode,
useRequestDisplayMode: () => ({ requestDisplayMode: mockRequestDisplayMode }),
SafeArea: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
<div data-testid="safe-area" {...props}>
{children}
Expand All @@ -41,11 +44,22 @@ vi.mock('./components', () => ({
Carousel: ({ children }: { children: React.ReactNode }) => (
<div data-testid="carousel">{children}</div>
),
Card: ({ header, buttonSize }: { header: React.ReactNode; buttonSize?: string }) => (
<div data-testid="card" data-button-size={buttonSize}>
Card: ({
header,
buttonSize,
onClick,
}: {
header: React.ReactNode;
buttonSize?: string;
onClick?: () => void;
}) => (
<div data-testid="card" data-button-size={buttonSize} onClick={onClick}>
{header}
</div>
),
PlaceDetail: ({ place }: { place: { name: string } }) => (
<div data-testid="place-detail">{place.name}</div>
),
}));

describe('CarouselResource', () => {
Expand Down
56 changes: 42 additions & 14 deletions examples/carousel-example/src/resources/carousel/carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { useToolData, useDeviceCapabilities, useDisplayMode, SafeArea } from 'sunpeak';
import { useState, useRef, useCallback } from 'react';
import {
useToolData,
useDeviceCapabilities,
useDisplayMode,
useRequestDisplayMode,
SafeArea,
} from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
import { Carousel, Card } from './components';
import { Carousel, Card, PlaceDetail } from './components';
import type { PlaceDetailData } from './components';

export const resource: ResourceConfig = {
title: 'Carousel',
Expand All @@ -19,18 +27,10 @@ export const resource: ResourceConfig = {
* Production-ready Carousel Resource
*
* This resource displays places in a carousel layout with cards.
* Can be dropped into any production environment without changes.
* Click a card to open it fullscreen with detailed information.
*/

interface CarouselCard {
id: string;
name: string;
rating: number;
category: string;
location: string;
image: string;
description: string;
}
type CarouselCard = PlaceDetailData;

interface CarouselInput {
city?: string;
Expand All @@ -50,8 +50,20 @@ export function CarouselResource() {
>();
const { touch: hasTouch = false } = useDeviceCapabilities();
const displayMode = useDisplayMode();
const { requestDisplayMode } = useRequestDisplayMode();
const [selectedPlace, setSelectedPlace] = useState<CarouselCard | null>(null);
const isDraggingRef = useRef(false);
const places = output?.places ?? [];

// Only show detail view when actually in fullscreen. If the host externally
// switches back to inline, the condition below naturally falls through to the
// carousel without needing to clear selectedPlace via an effect.
const showDetail = displayMode === 'fullscreen' && selectedPlace !== null;

const handleDraggingChange = useCallback((dragging: boolean) => {
isDraggingRef.current = dragging;
}, []);

if (isLoading) {
const searchContext = inputPartial?.city;
return (
Expand Down Expand Up @@ -86,6 +98,20 @@ export function CarouselResource() {
);
}

const handleCardClick = async (place: CarouselCard) => {
if (isDraggingRef.current) return;
setSelectedPlace(place);
await requestDisplayMode('fullscreen');
};

if (showDetail) {
return (
<SafeArea className="h-full">
<PlaceDetail place={selectedPlace} buttonSize={hasTouch ? 'md' : 'sm'} />
</SafeArea>
);
}

return (
<SafeArea className="p-4">
<Carousel
Expand All @@ -94,23 +120,25 @@ export function CarouselResource() {
showEdgeGradients={true}
cardWidth={220}
displayMode={displayMode}
onDraggingChange={handleDraggingChange}
>
{places.map((place: CarouselCard) => (
<Card
key={place.id}
image={place.image}
imageAlt={place.name}
header={place.name}
metadata={`\u2B50 ${place.rating} \u2022 ${place.category} \u2022 ${place.location}`}
metadata={` ${place.rating} ${place.category} ${place.location}`}
buttonSize={hasTouch ? 'md' : 'sm'}
onClick={() => handleCardClick(place)}
button1={{
isPrimary: true,
onClick: () => console.log(`Visit ${place.name}`),
children: 'Visit',
}}
button2={{
isPrimary: false,
onClick: () => console.log(`Learn more about ${place.name}`),
onClick: () => handleCardClick(place),
children: 'Learn More',
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type CarouselProps = {
cardWidth?: number | { inline?: number; fullscreen?: number };
displayMode?: string;
className?: string;
/** Called with true when a drag starts, false when it settles. Useful for suppressing click handlers during drag. */
onDraggingChange?: (dragging: boolean) => void;
};

export function Carousel({
Expand All @@ -23,6 +25,7 @@ export function Carousel({
cardWidth,
displayMode = 'inline',
className,
onDraggingChange,
}: CarouselProps) {
const [currentIndex, setCurrentIndex] = React.useState(0);

Expand Down Expand Up @@ -79,6 +82,23 @@ export function Carousel({
};
}, [emblaApi, onSelect]);

// Track drag state so consumers can suppress click handlers during/after a drag.
// Uses 'scroll' (not 'pointerDown') so simple taps don't count as drags.
React.useEffect(() => {
if (!emblaApi || !onDraggingChange) return;

const onScroll = () => onDraggingChange(true);
const onSettle = () => onDraggingChange(false);

emblaApi.on('scroll', onScroll);
emblaApi.on('settle', onSettle);

return () => {
emblaApi.off('scroll', onScroll);
emblaApi.off('settle', onSettle);
};
}, [emblaApi, onDraggingChange]);

// Sync external index changes to carousel scroll position
React.useEffect(() => {
if (!emblaApi) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './carousel';
export * from './card';
export * from './place-detail';
Loading
Loading