diff --git a/docs/api-reference/resources/carousel.mdx b/docs/api-reference/resources/carousel.mdx index e026bd4..a65e959 100644 --- a/docs/api-reference/resources/carousel.mdx +++ b/docs/api-reference/resources/carousel.mdx @@ -34,6 +34,12 @@ interface CarouselCard { location: string image: string description: string + address?: string + phone?: string + hours?: string + priceRange?: string + highlights?: string[] + tips?: string[] } ``` @@ -69,6 +75,30 @@ interface CarouselCard { Description text displayed in the card body. + + Street address, shown in the fullscreen detail view. + + + + Phone number, shown in the fullscreen detail view. + + + + Operating hours, shown in the fullscreen detail view. + + + + Price range indicator (e.g. "Free", "$$"), shown as a badge in the detail view. + + + + List of highlight tags displayed as badges in the fullscreen detail view. + + + + List of tips displayed in the fullscreen detail view. + + ## Features ### Carousel Navigation @@ -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. diff --git a/examples/carousel-example/src/resources/carousel/carousel.test.tsx b/examples/carousel-example/src/resources/carousel/carousel.test.tsx index b04a27c..e76fd76 100644 --- a/examples/carousel-example/src/resources/carousel/carousel.test.tsx +++ b/examples/carousel-example/src/resources/carousel/carousel.test.tsx @@ -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, @@ -29,6 +31,7 @@ vi.mock('sunpeak', () => ({ }), useDeviceCapabilities: () => mockDeviceCapabilities, useDisplayMode: () => mockDisplayMode, + useRequestDisplayMode: () => ({ requestDisplayMode: mockRequestDisplayMode }), SafeArea: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
{children} @@ -41,11 +44,22 @@ vi.mock('./components', () => ({ Carousel: ({ children }: { children: React.ReactNode }) => (
{children}
), - Card: ({ header, buttonSize }: { header: React.ReactNode; buttonSize?: string }) => ( -
+ Card: ({ + header, + buttonSize, + onClick, + }: { + header: React.ReactNode; + buttonSize?: string; + onClick?: () => void; + }) => ( +
{header}
), + PlaceDetail: ({ place }: { place: { name: string } }) => ( +
{place.name}
+ ), })); describe('CarouselResource', () => { diff --git a/examples/carousel-example/src/resources/carousel/carousel.tsx b/examples/carousel-example/src/resources/carousel/carousel.tsx index 210bf26..b070637 100644 --- a/examples/carousel-example/src/resources/carousel/carousel.tsx +++ b/examples/carousel-example/src/resources/carousel/carousel.tsx @@ -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', @@ -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; @@ -50,8 +50,20 @@ export function CarouselResource() { >(); const { touch: hasTouch = false } = useDeviceCapabilities(); const displayMode = useDisplayMode(); + const { requestDisplayMode } = useRequestDisplayMode(); + const [selectedPlace, setSelectedPlace] = useState(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 ( @@ -86,6 +98,20 @@ export function CarouselResource() { ); } + const handleCardClick = async (place: CarouselCard) => { + if (isDraggingRef.current) return; + setSelectedPlace(place); + await requestDisplayMode('fullscreen'); + }; + + if (showDetail) { + return ( + + + + ); + } + return ( {places.map((place: CarouselCard) => ( handleCardClick(place)} button1={{ isPrimary: true, onClick: () => console.log(`Visit ${place.name}`), @@ -110,7 +138,7 @@ export function CarouselResource() { }} button2={{ isPrimary: false, - onClick: () => console.log(`Learn more about ${place.name}`), + onClick: () => handleCardClick(place), children: 'Learn More', }} > diff --git a/examples/carousel-example/src/resources/carousel/components/carousel.tsx b/examples/carousel-example/src/resources/carousel/components/carousel.tsx index 9d1da10..111cb51 100644 --- a/examples/carousel-example/src/resources/carousel/components/carousel.tsx +++ b/examples/carousel-example/src/resources/carousel/components/carousel.tsx @@ -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({ @@ -23,6 +25,7 @@ export function Carousel({ cardWidth, displayMode = 'inline', className, + onDraggingChange, }: CarouselProps) { const [currentIndex, setCurrentIndex] = React.useState(0); @@ -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; diff --git a/examples/carousel-example/src/resources/carousel/components/index.ts b/examples/carousel-example/src/resources/carousel/components/index.ts index e63ae3e..a5a1792 100644 --- a/examples/carousel-example/src/resources/carousel/components/index.ts +++ b/examples/carousel-example/src/resources/carousel/components/index.ts @@ -1,2 +1,3 @@ export * from './carousel'; export * from './card'; +export * from './place-detail'; diff --git a/examples/carousel-example/src/resources/carousel/components/place-detail.tsx b/examples/carousel-example/src/resources/carousel/components/place-detail.tsx new file mode 100644 index 0000000..38dda82 --- /dev/null +++ b/examples/carousel-example/src/resources/carousel/components/place-detail.tsx @@ -0,0 +1,153 @@ +import { Button } from '@/components/button'; +import { Star } from '@/components/icon'; + +export interface PlaceDetailData { + id: string; + name: string; + rating: number; + category: string; + location: string; + image: string; + description: string; + address?: string; + phone?: string; + hours?: string; + priceRange?: string; + tips?: string[]; + highlights?: string[]; +} + +interface PlaceDetailProps { + place: PlaceDetailData; + buttonSize?: 'xs' | 'sm' | 'md' | 'lg'; +} + +function RatingStars({ rating }: { rating: number }) { + const fullStars = Math.floor(rating); + const hasHalf = rating - fullStars >= 0.5; + + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + {rating} + + ); +} + +export function PlaceDetail({ place, buttonSize = 'sm' }: PlaceDetailProps) { + return ( +
+ {/* Header */} +
+

{place.name}

+
+ + {/* Hero image */} +
+ {place.name} +
+ + {/* Content */} +
+ {/* Rating and category */} +
+ +
+ + {place.category} + + {place.priceRange && ( + + {place.priceRange} + + )} +
+
+ + {/* Description */} +

{place.description}

+ + {/* Info grid */} +
+ {place.address && } + {place.hours && } + {place.phone && } +
+ + {/* Highlights */} + {place.highlights && place.highlights.length > 0 && ( +
+

Highlights

+
+ {place.highlights.map((h) => ( + + {h} + + ))} +
+
+ )} + + {/* Tips */} + {place.tips && place.tips.length > 0 && ( +
+

Tips

+
    + {place.tips.map((tip, i) => ( +
  • + {tip} +
  • + ))} +
+
+ )} + + {/* Action buttons */} +
+ + +
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/examples/carousel-example/tests/e2e/carousel.spec.ts b/examples/carousel-example/tests/e2e/carousel.spec.ts index 1d268bf..324b513 100644 --- a/examples/carousel-example/tests/e2e/carousel.spec.ts +++ b/examples/carousel-example/tests/e2e/carousel.spec.ts @@ -121,6 +121,131 @@ for (const host of hosts) { }); }); + test.describe('Fullscreen Mode', () => { + test('should render correctly in fullscreen displayMode', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + await page.waitForLoadState('networkidle'); + const root = page.locator('#root'); + await expect(root).not.toBeEmpty(); + }); + + test('should show detail view with place info in fullscreen', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open fullscreen detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + // Should show detail view with place name, description, and detail fields + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + await expect(iframe.locator('text=Highlights')).toBeVisible(); + await expect(iframe.locator('text=Tips')).toBeVisible(); + }); + + test('should show detail view when Learn More is clicked', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click "Learn More" on the first card + const learnMore = iframe.locator('button:has-text("Learn More")').first(); + await expect(learnMore).toBeVisible(); + await learnMore.click(); + + // Should show detail view + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + await expect(iframe.locator('text=Address')).toBeVisible(); + }); + + test('should not have a back button in detail view', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + + // Back button should not exist + const backButton = iframe.locator('button[aria-label="Back to carousel"]'); + await expect(backButton).not.toBeAttached(); + }); + + test('should center the hero image without stretching', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + + // Check the image container is centered + const imageContainer = iframe.locator('img').first().locator('..'); + const styles = await imageContainer.evaluate((el) => { + const computed = window.getComputedStyle(el); + return { + justifyContent: computed.justifyContent, + }; + }); + + expect(styles.justifyContent).toBe('center'); + }); + }); + test.describe('Dark Mode', () => { test('should render carousel cards with correct styles', async ({ page }) => { await page.goto(createInspectorUrl({ simulation: 'show-carousel', theme: 'dark', host })); diff --git a/examples/carousel-example/tests/simulations/show-carousel.json b/examples/carousel-example/tests/simulations/show-carousel.json index 8ad81f9..6644707 100644 --- a/examples/carousel-example/tests/simulations/show-carousel.json +++ b/examples/carousel-example/tests/simulations/show-carousel.json @@ -17,7 +17,17 @@ "category": "Waterfront", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin1.jpeg", - "description": "Scenic lake perfect for kayaking, paddleboarding, and trails." + "description": "Scenic lake perfect for kayaking, paddleboarding, and trails. The hike-and-bike trail that circles the lake is one of the most popular outdoor destinations in Austin, offering 10 miles of paths with views of the downtown skyline.", + "address": "1820 S Lakeshore Blvd, Austin, TX 78741", + "phone": "(512) 974-6700", + "hours": "Open 24 hours", + "priceRange": "Free", + "highlights": ["Kayaking", "Paddleboarding", "Hike & Bike Trail", "Dog Friendly"], + "tips": [ + "Go early in the morning to avoid crowds on the trail.", + "Rent kayaks from the boathouse on the south shore for the best skyline views.", + "The Boardwalk section on the south side is the newest and least crowded part." + ] }, { "id": "2", @@ -26,7 +36,17 @@ "category": "Historic Site", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin2.jpeg", - "description": "Stunning capitol building with free tours and beautiful grounds." + "description": "Stunning capitol building with free tours and beautiful grounds. Built in 1888, the building is taller than the U.S. Capitol and features a stunning rotunda with portraits of every Texas governor.", + "address": "1100 Congress Ave, Austin, TX 78701", + "phone": "(512) 463-0063", + "hours": "Mon-Fri 7am-10pm, Sat-Sun 9am-8pm", + "priceRange": "Free", + "highlights": ["Free Guided Tours", "Architecture", "Historical Exhibits", "Gift Shop"], + "tips": [ + "Free guided tours run every 30-45 minutes and are well worth the time.", + "Visit the underground extension for the modern legislative offices.", + "The south grounds have great photo spots with the building in the background." + ] }, { "id": "3", @@ -35,7 +55,17 @@ "category": "Architecture", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin3.jpeg", - "description": "Century-old performance and movie theatre in the heart of downtown Austin." + "description": "Century-old performance and movie theatre in the heart of downtown Austin. Opened in 1915, this beautifully restored venue hosts classic film screenings, live performances, and comedy shows year-round.", + "address": "713 Congress Ave, Austin, TX 78701", + "phone": "(512) 472-5470", + "hours": "Box office: Mon-Fri 10am-6pm", + "priceRange": "$$", + "highlights": ["Classic Films", "Live Music", "Comedy Shows", "Historic Venue"], + "tips": [ + "Check the Summer Classic Film Series for discounted screenings.", + "The balcony seats offer the best view of the ornate ceiling.", + "Arrive early to grab a drink at the bar before the show." + ] }, { "id": "4", @@ -44,7 +74,17 @@ "category": "Park", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin4.jpeg", - "description": "Popular park with trails, sports fields, and Barton Springs Pool." + "description": "Popular park with trails, sports fields, and Barton Springs Pool. This 350-acre metropolitan park sits along the south bank of Lady Bird Lake and is home to the Austin City Limits music festival.", + "address": "2100 Barton Springs Rd, Austin, TX 78704", + "phone": "(512) 974-6700", + "hours": "5am-10pm daily", + "priceRange": "Free", + "highlights": ["Barton Springs Pool", "Botanical Garden", "Disc Golf", "ACL Festival"], + "tips": [ + "Barton Springs Pool is spring-fed and stays 68°F year-round.", + "The botanical garden entrance is on the west side of the park.", + "Parking fills up fast on weekends. Take the bus or bike instead." + ] }, { "id": "5", @@ -53,7 +93,16 @@ "category": "Landmark", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin5.jpeg", - "description": "Vibrant street with unique shops, restaurants, and live music." + "description": "Lively street with unique shops, restaurants, and live music. Known locally as SoCo, this stretch south of Lady Bird Lake is the heart of Austin's eclectic culture, with vintage stores, food trucks, and murals.", + "address": "South Congress Ave, Austin, TX 78704", + "hours": "Shops typically 10am-8pm", + "priceRange": "$-$$$", + "highlights": ["Shopping", "Food Trucks", "Street Art", "Live Music"], + "tips": [ + "First Thursday of every month features extended hours and street vendors.", + "The 'I love you so much' mural on the side of Jo's Coffee is an iconic photo spot.", + "Walk south past Oltorf St for less crowded, more local shops." + ] } ] } diff --git a/packages/sunpeak/template/src/resources/carousel/carousel.test.tsx b/packages/sunpeak/template/src/resources/carousel/carousel.test.tsx index b04a27c..e76fd76 100644 --- a/packages/sunpeak/template/src/resources/carousel/carousel.test.tsx +++ b/packages/sunpeak/template/src/resources/carousel/carousel.test.tsx @@ -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, @@ -29,6 +31,7 @@ vi.mock('sunpeak', () => ({ }), useDeviceCapabilities: () => mockDeviceCapabilities, useDisplayMode: () => mockDisplayMode, + useRequestDisplayMode: () => ({ requestDisplayMode: mockRequestDisplayMode }), SafeArea: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
{children} @@ -41,11 +44,22 @@ vi.mock('./components', () => ({ Carousel: ({ children }: { children: React.ReactNode }) => (
{children}
), - Card: ({ header, buttonSize }: { header: React.ReactNode; buttonSize?: string }) => ( -
+ Card: ({ + header, + buttonSize, + onClick, + }: { + header: React.ReactNode; + buttonSize?: string; + onClick?: () => void; + }) => ( +
{header}
), + PlaceDetail: ({ place }: { place: { name: string } }) => ( +
{place.name}
+ ), })); describe('CarouselResource', () => { diff --git a/packages/sunpeak/template/src/resources/carousel/carousel.tsx b/packages/sunpeak/template/src/resources/carousel/carousel.tsx index 210bf26..b070637 100644 --- a/packages/sunpeak/template/src/resources/carousel/carousel.tsx +++ b/packages/sunpeak/template/src/resources/carousel/carousel.tsx @@ -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', @@ -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; @@ -50,8 +50,20 @@ export function CarouselResource() { >(); const { touch: hasTouch = false } = useDeviceCapabilities(); const displayMode = useDisplayMode(); + const { requestDisplayMode } = useRequestDisplayMode(); + const [selectedPlace, setSelectedPlace] = useState(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 ( @@ -86,6 +98,20 @@ export function CarouselResource() { ); } + const handleCardClick = async (place: CarouselCard) => { + if (isDraggingRef.current) return; + setSelectedPlace(place); + await requestDisplayMode('fullscreen'); + }; + + if (showDetail) { + return ( + + + + ); + } + return ( {places.map((place: CarouselCard) => ( handleCardClick(place)} button1={{ isPrimary: true, onClick: () => console.log(`Visit ${place.name}`), @@ -110,7 +138,7 @@ export function CarouselResource() { }} button2={{ isPrimary: false, - onClick: () => console.log(`Learn more about ${place.name}`), + onClick: () => handleCardClick(place), children: 'Learn More', }} > diff --git a/packages/sunpeak/template/src/resources/carousel/components/carousel.tsx b/packages/sunpeak/template/src/resources/carousel/components/carousel.tsx index 9d1da10..111cb51 100644 --- a/packages/sunpeak/template/src/resources/carousel/components/carousel.tsx +++ b/packages/sunpeak/template/src/resources/carousel/components/carousel.tsx @@ -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({ @@ -23,6 +25,7 @@ export function Carousel({ cardWidth, displayMode = 'inline', className, + onDraggingChange, }: CarouselProps) { const [currentIndex, setCurrentIndex] = React.useState(0); @@ -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; diff --git a/packages/sunpeak/template/src/resources/carousel/components/index.ts b/packages/sunpeak/template/src/resources/carousel/components/index.ts index e63ae3e..a5a1792 100644 --- a/packages/sunpeak/template/src/resources/carousel/components/index.ts +++ b/packages/sunpeak/template/src/resources/carousel/components/index.ts @@ -1,2 +1,3 @@ export * from './carousel'; export * from './card'; +export * from './place-detail'; diff --git a/packages/sunpeak/template/src/resources/carousel/components/place-detail.tsx b/packages/sunpeak/template/src/resources/carousel/components/place-detail.tsx new file mode 100644 index 0000000..38dda82 --- /dev/null +++ b/packages/sunpeak/template/src/resources/carousel/components/place-detail.tsx @@ -0,0 +1,153 @@ +import { Button } from '@/components/button'; +import { Star } from '@/components/icon'; + +export interface PlaceDetailData { + id: string; + name: string; + rating: number; + category: string; + location: string; + image: string; + description: string; + address?: string; + phone?: string; + hours?: string; + priceRange?: string; + tips?: string[]; + highlights?: string[]; +} + +interface PlaceDetailProps { + place: PlaceDetailData; + buttonSize?: 'xs' | 'sm' | 'md' | 'lg'; +} + +function RatingStars({ rating }: { rating: number }) { + const fullStars = Math.floor(rating); + const hasHalf = rating - fullStars >= 0.5; + + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + {rating} + + ); +} + +export function PlaceDetail({ place, buttonSize = 'sm' }: PlaceDetailProps) { + return ( +
+ {/* Header */} +
+

{place.name}

+
+ + {/* Hero image */} +
+ {place.name} +
+ + {/* Content */} +
+ {/* Rating and category */} +
+ +
+ + {place.category} + + {place.priceRange && ( + + {place.priceRange} + + )} +
+
+ + {/* Description */} +

{place.description}

+ + {/* Info grid */} +
+ {place.address && } + {place.hours && } + {place.phone && } +
+ + {/* Highlights */} + {place.highlights && place.highlights.length > 0 && ( +
+

Highlights

+
+ {place.highlights.map((h) => ( + + {h} + + ))} +
+
+ )} + + {/* Tips */} + {place.tips && place.tips.length > 0 && ( +
+

Tips

+
    + {place.tips.map((tip, i) => ( +
  • + {tip} +
  • + ))} +
+
+ )} + + {/* Action buttons */} +
+ + +
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/packages/sunpeak/template/tests/e2e/carousel.spec.ts b/packages/sunpeak/template/tests/e2e/carousel.spec.ts index 1d268bf..324b513 100644 --- a/packages/sunpeak/template/tests/e2e/carousel.spec.ts +++ b/packages/sunpeak/template/tests/e2e/carousel.spec.ts @@ -121,6 +121,131 @@ for (const host of hosts) { }); }); + test.describe('Fullscreen Mode', () => { + test('should render correctly in fullscreen displayMode', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + await page.waitForLoadState('networkidle'); + const root = page.locator('#root'); + await expect(root).not.toBeEmpty(); + }); + + test('should show detail view with place info in fullscreen', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open fullscreen detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + // Should show detail view with place name, description, and detail fields + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + await expect(iframe.locator('text=Highlights')).toBeVisible(); + await expect(iframe.locator('text=Tips')).toBeVisible(); + }); + + test('should show detail view when Learn More is clicked', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click "Learn More" on the first card + const learnMore = iframe.locator('button:has-text("Learn More")').first(); + await expect(learnMore).toBeVisible(); + await learnMore.click(); + + // Should show detail view + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + await expect(iframe.locator('text=Address')).toBeVisible(); + }); + + test('should not have a back button in detail view', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + + // Back button should not exist + const backButton = iframe.locator('button[aria-label="Back to carousel"]'); + await expect(backButton).not.toBeAttached(); + }); + + test('should center the hero image without stretching', async ({ page }) => { + await page.goto( + createInspectorUrl({ + simulation: 'show-carousel', + theme: 'light', + displayMode: 'fullscreen', + host, + }) + ); + + const iframe = page.frameLocator('iframe').frameLocator('iframe'); + + // Click the first card to open detail + const card = iframe.locator('.rounded-2xl').first(); + await expect(card).toBeVisible(); + await card.click(); + + await expect(iframe.locator('h1:has-text("Lady Bird Lake")')).toBeVisible({ + timeout: 5000, + }); + + // Check the image container is centered + const imageContainer = iframe.locator('img').first().locator('..'); + const styles = await imageContainer.evaluate((el) => { + const computed = window.getComputedStyle(el); + return { + justifyContent: computed.justifyContent, + }; + }); + + expect(styles.justifyContent).toBe('center'); + }); + }); + test.describe('Dark Mode', () => { test('should render carousel cards with correct styles', async ({ page }) => { await page.goto(createInspectorUrl({ simulation: 'show-carousel', theme: 'dark', host })); diff --git a/packages/sunpeak/template/tests/live/carousel.spec.ts b/packages/sunpeak/template/tests/live/carousel.spec.ts index f1a9ca3..1ba626f 100644 --- a/packages/sunpeak/template/tests/live/carousel.spec.ts +++ b/packages/sunpeak/template/tests/live/carousel.spec.ts @@ -5,7 +5,7 @@ test('carousel tool renders cards with correct styles', async ({ live }) => { // First place from simulation data: "Lady Bird Lake" await expect(app.locator('img').first()).toBeVisible({ timeout: 15_000 }); - await expect(app.getByText('Lady Bird Lake')).toBeVisible(); + await expect(app.getByRole('heading', { name: 'Lady Bird Lake' })).toBeVisible(); const buttons = app.locator('button'); expect(await buttons.count()).toBeGreaterThanOrEqual(1); @@ -43,7 +43,7 @@ test('carousel tool renders cards with correct styles', async ({ live }) => { // Switch to dark mode and verify the app re-themes correctly await live.setColorScheme('dark', app); - await expect(app.getByText('Lady Bird Lake')).toBeVisible(); + await expect(app.getByRole('heading', { name: 'Lady Bird Lake' })).toBeVisible(); const darkBorderColor = await app .locator('div[class*="rounded"]') .first() diff --git a/packages/sunpeak/template/tests/simulations/show-carousel.json b/packages/sunpeak/template/tests/simulations/show-carousel.json index 8ad81f9..6644707 100644 --- a/packages/sunpeak/template/tests/simulations/show-carousel.json +++ b/packages/sunpeak/template/tests/simulations/show-carousel.json @@ -17,7 +17,17 @@ "category": "Waterfront", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin1.jpeg", - "description": "Scenic lake perfect for kayaking, paddleboarding, and trails." + "description": "Scenic lake perfect for kayaking, paddleboarding, and trails. The hike-and-bike trail that circles the lake is one of the most popular outdoor destinations in Austin, offering 10 miles of paths with views of the downtown skyline.", + "address": "1820 S Lakeshore Blvd, Austin, TX 78741", + "phone": "(512) 974-6700", + "hours": "Open 24 hours", + "priceRange": "Free", + "highlights": ["Kayaking", "Paddleboarding", "Hike & Bike Trail", "Dog Friendly"], + "tips": [ + "Go early in the morning to avoid crowds on the trail.", + "Rent kayaks from the boathouse on the south shore for the best skyline views.", + "The Boardwalk section on the south side is the newest and least crowded part." + ] }, { "id": "2", @@ -26,7 +36,17 @@ "category": "Historic Site", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin2.jpeg", - "description": "Stunning capitol building with free tours and beautiful grounds." + "description": "Stunning capitol building with free tours and beautiful grounds. Built in 1888, the building is taller than the U.S. Capitol and features a stunning rotunda with portraits of every Texas governor.", + "address": "1100 Congress Ave, Austin, TX 78701", + "phone": "(512) 463-0063", + "hours": "Mon-Fri 7am-10pm, Sat-Sun 9am-8pm", + "priceRange": "Free", + "highlights": ["Free Guided Tours", "Architecture", "Historical Exhibits", "Gift Shop"], + "tips": [ + "Free guided tours run every 30-45 minutes and are well worth the time.", + "Visit the underground extension for the modern legislative offices.", + "The south grounds have great photo spots with the building in the background." + ] }, { "id": "3", @@ -35,7 +55,17 @@ "category": "Architecture", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin3.jpeg", - "description": "Century-old performance and movie theatre in the heart of downtown Austin." + "description": "Century-old performance and movie theatre in the heart of downtown Austin. Opened in 1915, this beautifully restored venue hosts classic film screenings, live performances, and comedy shows year-round.", + "address": "713 Congress Ave, Austin, TX 78701", + "phone": "(512) 472-5470", + "hours": "Box office: Mon-Fri 10am-6pm", + "priceRange": "$$", + "highlights": ["Classic Films", "Live Music", "Comedy Shows", "Historic Venue"], + "tips": [ + "Check the Summer Classic Film Series for discounted screenings.", + "The balcony seats offer the best view of the ornate ceiling.", + "Arrive early to grab a drink at the bar before the show." + ] }, { "id": "4", @@ -44,7 +74,17 @@ "category": "Park", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin4.jpeg", - "description": "Popular park with trails, sports fields, and Barton Springs Pool." + "description": "Popular park with trails, sports fields, and Barton Springs Pool. This 350-acre metropolitan park sits along the south bank of Lady Bird Lake and is home to the Austin City Limits music festival.", + "address": "2100 Barton Springs Rd, Austin, TX 78704", + "phone": "(512) 974-6700", + "hours": "5am-10pm daily", + "priceRange": "Free", + "highlights": ["Barton Springs Pool", "Botanical Garden", "Disc Golf", "ACL Festival"], + "tips": [ + "Barton Springs Pool is spring-fed and stays 68°F year-round.", + "The botanical garden entrance is on the west side of the park.", + "Parking fills up fast on weekends. Take the bus or bike instead." + ] }, { "id": "5", @@ -53,7 +93,16 @@ "category": "Landmark", "location": "Austin", "image": "https://cdn.sunpeak.ai/demo/austin5.jpeg", - "description": "Vibrant street with unique shops, restaurants, and live music." + "description": "Lively street with unique shops, restaurants, and live music. Known locally as SoCo, this stretch south of Lady Bird Lake is the heart of Austin's eclectic culture, with vintage stores, food trucks, and murals.", + "address": "South Congress Ave, Austin, TX 78704", + "hours": "Shops typically 10am-8pm", + "priceRange": "$-$$$", + "highlights": ["Shopping", "Food Trucks", "Street Art", "Live Music"], + "tips": [ + "First Thursday of every month features extended hours and street vendors.", + "The 'I love you so much' mural on the side of Jo's Coffee is an iconic photo spot.", + "Walk south past Oltorf St for less crowded, more local shops." + ] } ] }