Skip to content

Commit 7989285

Browse files
committed
feat(Album): integrate Embla carousel and fix critical UX issues
Implements Embla Carousel's Thumbnails pattern for AlbumViewer with proper API-to-API synchronization and fixes three critical UX regressions. ## Embla Carousel Integration ### AlbumViewer - Replace custom photo navigation with Embla carousel - Implement swipe gestures for photo navigation - Add carousel API for synchronization with FilmStrip - Support empty album states (hideWhenEmpty, custom empty content) - Add proper keyboard navigation with Embla API ### FilmStrip - Convert from basic scrolling to Embla carousel with vertical axis - Enable drag-free scrolling for natural thumbnail navigation - Implement API-to-API sync with main photo carousel - Add vertical centering wrapper for better UX ### Responsive Design - Implement 3-breakpoint system (640px tablet, 768px desktop, 1024px desktop-wide) - Progressive padding enhancement across breakpoints - Sidebar visibility aligned with padding breakpoints (640px) ## Bug Fixes ### 1. hideWhenEmpty Effects Issue **Problem**: Body scroll lock and keyboard listeners mounted before early return, freezing page scroll when component returned null. **Solution**: Added isViewerVisible flag to conditionally run effects only when viewer is actually rendered, preventing UX regression in "Hide When Empty" story. ### 2. FilmStrip Thumbnail Clicks **Problem**: Thumbnail clicks no-op when mainApi undefined/null, breaking standalone use and early clicks before API ready. **Solution**: Always call onSelect first, then conditionally scroll mainApi if available. ### 3. Mouse-Wheel Scrolling **Problem**: FilmStrip used Embla with overflow:hidden and no wheel support, removing mouse-wheel scrolling for desktop users. **Solution**: Added embla-carousel-wheel-gestures plugin for proper desktop accessibility. ## Documentation - Updated Album pattern docs with comprehensive responsive behavior section - Documented all three breakpoints and their behaviors - Added feature descriptions mentioning Embla carousel integration Fixes page scroll lock, thumbnail click handling, and desktop accessibility issues.
1 parent 6f60921 commit 7989285

File tree

10 files changed

+583
-89
lines changed

10 files changed

+583
-89
lines changed

packages/ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
},
117117
"dependencies": {
118118
"embla-carousel": "^8.6.0",
119-
"embla-carousel-react": "^8.6.0"
119+
"embla-carousel-react": "^8.6.0",
120+
"embla-carousel-wheel-gestures": "^8.1.0"
120121
}
121122
}

packages/ui/src/components/Album/Album.stories.tsx

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ const sampleAlbums: AlbumType[] = [
163163
},
164164
],
165165
},
166+
{
167+
id: 'empty-album',
168+
title: 'Empty Album',
169+
cover: 'https://persistent.oaistatic.com/pizzaz/pizzaz-1.png',
170+
photos: [],
171+
},
166172
];
167173

168174
const meta: Meta<typeof Album> = {
@@ -181,6 +187,9 @@ const AlbumSystemComponent: React.FC = () => {
181187
const [selectedAlbum, setSelectedAlbum] = useState<AlbumType | null>(null);
182188
const [showViewer, setShowViewer] = useState(false);
183189
const [viewerAlbum, setViewerAlbum] = useState<AlbumType | null>(null);
190+
const [showEmptyDefault, setShowEmptyDefault] = useState(false);
191+
const [showEmptyCustom, setShowEmptyCustom] = useState(false);
192+
const [showEmptyHidden, setShowEmptyHidden] = useState(false);
184193

185194
return (
186195
<div style={{ padding: '24px', maxWidth: '100%' }}>
@@ -463,8 +472,199 @@ const AlbumSystemComponent: React.FC = () => {
463472
>
464473
<p style={{ margin: 0, fontSize: '14px', color: 'var(--ai-color-text-secondary)' }}>
465474
<strong>Viewer Features:</strong> Arrow key navigation, ESC to close, photo counter,
466-
responsive layouts, smooth transitions
475+
responsive layouts, smooth transitions, Embla carousel for touch/swipe navigation
476+
</p>
477+
</div>
478+
</section>
479+
480+
{/* Responsive Behavior */}
481+
<section style={{ marginBottom: '64px' }}>
482+
<header style={{ marginBottom: '24px' }}>
483+
<h2 style={{ marginBottom: '8px' }}>Responsive Behavior</h2>
484+
<p style={{ color: 'var(--ai-color-text-secondary)', margin: 0, fontSize: '14px' }}>
485+
AlbumViewer adapts to different screen sizes with three breakpoints
486+
</p>
487+
</header>
488+
489+
<div
490+
style={{
491+
padding: '16px',
492+
backgroundColor: 'var(--ai-color-bg-secondary)',
493+
borderRadius: '8px',
494+
marginBottom: '16px',
495+
}}
496+
>
497+
<h3 style={{ fontSize: '14px', marginTop: 0, marginBottom: '12px' }}>
498+
Mobile (&lt; 640px)
499+
</h3>
500+
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '14px', color: 'var(--ai-color-text-secondary)' }}>
501+
<li>No thumbnail sidebar</li>
502+
<li>Touch-enabled swipe navigation</li>
503+
<li>Prev/Next navigation buttons visible</li>
504+
<li>Minimal padding (8px horizontal, 8px vertical)</li>
505+
<li>Photo counter centered at bottom</li>
506+
</ul>
507+
</div>
508+
509+
<div
510+
style={{
511+
padding: '16px',
512+
backgroundColor: 'var(--ai-color-bg-secondary)',
513+
borderRadius: '8px',
514+
marginBottom: '16px',
515+
}}
516+
>
517+
<h3 style={{ fontSize: '14px', marginTop: 0, marginBottom: '12px' }}>
518+
Tablet (≥ 640px)
519+
</h3>
520+
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '14px', color: 'var(--ai-color-text-secondary)' }}>
521+
<li>Thumbnail sidebar appears (160px width)</li>
522+
<li>Thumbnails centered vertically in sidebar</li>
523+
<li>Navigation buttons hidden (sidebar provides navigation)</li>
524+
<li>Increased padding (16px horizontal, 16px vertical)</li>
525+
<li>Photo counter offset to center over photo area</li>
526+
<li>Embla carousel synchronization between main view and thumbnails</li>
527+
</ul>
528+
</div>
529+
530+
<div
531+
style={{
532+
padding: '16px',
533+
backgroundColor: 'var(--ai-color-bg-secondary)',
534+
borderRadius: '8px',
535+
}}
536+
>
537+
<h3 style={{ fontSize: '14px', marginTop: 0, marginBottom: '12px' }}>
538+
Desktop-wide (≥ 1024px)
539+
</h3>
540+
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '14px', color: 'var(--ai-color-text-secondary)' }}>
541+
<li>All tablet features maintained</li>
542+
<li>Maximum padding for optimal viewing (24px horizontal, 24px vertical)</li>
543+
<li>Generous breathing room around photos</li>
544+
</ul>
545+
</div>
546+
547+
<div
548+
style={{
549+
padding: '16px',
550+
backgroundColor: 'var(--ai-color-bg-tertiary)',
551+
borderRadius: '8px',
552+
marginTop: '16px',
553+
}}
554+
>
555+
<p style={{ margin: 0, fontSize: '13px', color: 'var(--ai-color-text-secondary)', fontStyle: 'italic' }}>
556+
<strong>Note:</strong> All breakpoints use CSS custom properties from the design system
557+
(--ai-breakpoint-tablet: 640px, --ai-breakpoint-desktop-wide: 1024px)
558+
</p>
559+
</div>
560+
</section>
561+
562+
{/* AlbumViewer Empty States */}
563+
<section style={{ marginBottom: '64px' }}>
564+
<header style={{ marginBottom: '24px' }}>
565+
<h2 style={{ marginBottom: '8px' }}>AlbumViewer Empty States</h2>
566+
<p style={{ color: 'var(--ai-color-text-secondary)', margin: 0, fontSize: '14px' }}>
567+
Handling albums with no photos using default, custom, or hidden empty states
568+
</p>
569+
</header>
570+
571+
<div style={{ marginBottom: '32px' }}>
572+
<h3
573+
style={{
574+
fontSize: '14px',
575+
marginBottom: '12px',
576+
color: 'var(--ai-color-text-secondary)',
577+
}}
578+
>
579+
Default Empty State
580+
</h3>
581+
<p style={{ fontSize: '14px', marginBottom: '12px', color: 'var(--ai-color-text-secondary)' }}>
582+
Shows default "No photos available" message with close button
583+
</p>
584+
<Button onClick={() => setShowEmptyDefault(true)}>
585+
Open Empty Album (Default)
586+
</Button>
587+
{showEmptyDefault && (
588+
<AlbumViewer
589+
album={sampleAlbums[5]}
590+
onClose={() => setShowEmptyDefault(false)}
591+
/>
592+
)}
593+
</div>
594+
595+
<div style={{ marginBottom: '32px' }}>
596+
<h3
597+
style={{
598+
fontSize: '14px',
599+
marginBottom: '12px',
600+
color: 'var(--ai-color-text-secondary)',
601+
}}
602+
>
603+
Custom Empty State Content
604+
</h3>
605+
<p style={{ fontSize: '14px', marginBottom: '12px', color: 'var(--ai-color-text-secondary)' }}>
606+
Provide custom UI via emptyStateContent prop
607+
</p>
608+
<Button onClick={() => setShowEmptyCustom(true)} variant="secondary">
609+
Open Empty Album (Custom)
610+
</Button>
611+
{showEmptyCustom && (
612+
<AlbumViewer
613+
album={sampleAlbums[5]}
614+
onClose={() => setShowEmptyCustom(false)}
615+
emptyStateContent={
616+
<div style={{
617+
display: 'flex',
618+
flexDirection: 'column',
619+
alignItems: 'center',
620+
justifyContent: 'center',
621+
height: '100%',
622+
gap: '16px',
623+
}}>
624+
<div style={{
625+
fontSize: '48px',
626+
}}>
627+
📸
628+
</div>
629+
<h3 style={{ margin: 0, fontSize: '20px' }}>No Photos Yet</h3>
630+
<p style={{ margin: 0, color: 'var(--ai-color-text-secondary)' }}>
631+
This album is waiting for its first photo
632+
</p>
633+
<Button onClick={() => setShowEmptyCustom(false)}>
634+
Close Viewer
635+
</Button>
636+
</div>
637+
}
638+
/>
639+
)}
640+
</div>
641+
642+
<div>
643+
<h3
644+
style={{
645+
fontSize: '14px',
646+
marginBottom: '12px',
647+
color: 'var(--ai-color-text-secondary)',
648+
}}
649+
>
650+
Hide When Empty
651+
</h3>
652+
<p style={{ fontSize: '14px', marginBottom: '12px', color: 'var(--ai-color-text-secondary)' }}>
653+
Returns null when album has no photos (hideWhenEmpty=true)
467654
</p>
655+
<Button onClick={() => setShowEmptyHidden(true)} variant="secondary">
656+
Try Opening Empty Album (Hidden)
657+
</Button>
658+
<p style={{ fontSize: '12px', marginTop: '8px', color: 'var(--ai-color-text-secondary)', fontStyle: 'italic' }}>
659+
Note: Nothing will appear because the viewer returns null when empty
660+
</p>
661+
{showEmptyHidden && (
662+
<AlbumViewer
663+
album={sampleAlbums[5]}
664+
onClose={() => setShowEmptyHidden(false)}
665+
hideWhenEmpty={true}
666+
/>
667+
)}
468668
</div>
469669
</section>
470670

@@ -570,6 +770,44 @@ const AlbumSystemComponent: React.FC = () => {
570770
/>
571771
</section>
572772

773+
<section style={{ marginBottom: '64px' }}>
774+
<header style={{ marginBottom: '24px' }}>
775+
<h2 style={{ marginBottom: '8px' }}>AlbumViewer Props</h2>
776+
<p style={{ color: 'var(--ai-color-text-secondary)', margin: 0, fontSize: '14px' }}>
777+
Fullscreen photo viewer props including empty state handling
778+
</p>
779+
</header>
780+
<PropsTable
781+
hideThemeColumn
782+
rows={[
783+
{
784+
name: 'album',
785+
description: 'Album object to display. Type: Album (required)',
786+
},
787+
{
788+
name: 'initialPhotoIndex',
789+
description: 'Initial photo index to display - default: 0',
790+
},
791+
{
792+
name: 'onClose',
793+
description: 'Callback when viewer is closed: () => void',
794+
},
795+
{
796+
name: 'className',
797+
description: 'Additional CSS class name for the viewer',
798+
},
799+
{
800+
name: 'emptyStateContent',
801+
description: 'Custom content to display when album has no photos. If not provided, shows default empty state with message and close button. Type: React.ReactNode',
802+
},
803+
{
804+
name: 'hideWhenEmpty',
805+
description: 'Hide the viewer completely when album has no photos. When true, returns null instead of showing empty state - default: false',
806+
},
807+
]}
808+
/>
809+
</section>
810+
573811
{/* Related Components */}
574812
<section>
575813
<header style={{ marginBottom: '16px' }}>

0 commit comments

Comments
 (0)