From 7f666983799c36b6443e801c3633e66c2e8737a4 Mon Sep 17 00:00:00 2001 From: clacina Date: Fri, 1 May 2026 11:20:52 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20mobile-responsive=20l?= =?UTF-8?q?ayout=20shells=20for=20phone=20and=20tablet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the coarse isMobileHorizontal boolean with a useLayoutMode hook that classifies the viewport into phone-portrait, phone-landscape, tablet-portrait, tablet-landscape, or desktop. Each mobile form factor gets a dedicated layout shell component that positions the video player as the dominant element, with gesture swipe navigation via useSwipe. Per-layout CSS custom properties make visual tuning straightforward without touching shared styles. Co-Authored-By: Claude Sonnet 4.6 --- src/App.css | 377 ++++++++++++++++++ src/components/FlashcardSession.jsx | 173 ++++---- .../layouts/PhoneLandscapeLayout.jsx | 29 ++ .../layouts/PhonePortraitLayout.jsx | 27 ++ .../layouts/TabletLandscapeLayout.jsx | 31 ++ .../layouts/TabletPortraitLayout.jsx | 35 ++ src/constants/breakpoints.js | 2 + src/hooks/useLayoutMode.js | 42 ++ src/hooks/useSwipe.js | 26 ++ tests/useLayoutMode.test.js | 126 ++++++ 10 files changed, 795 insertions(+), 73 deletions(-) create mode 100644 src/components/layouts/PhoneLandscapeLayout.jsx create mode 100644 src/components/layouts/PhonePortraitLayout.jsx create mode 100644 src/components/layouts/TabletLandscapeLayout.jsx create mode 100644 src/components/layouts/TabletPortraitLayout.jsx create mode 100644 src/constants/breakpoints.js create mode 100644 src/hooks/useLayoutMode.js create mode 100644 src/hooks/useSwipe.js create mode 100644 tests/useLayoutMode.test.js diff --git a/src/App.css b/src/App.css index 8b8ef07..e5bd035 100644 --- a/src/App.css +++ b/src/App.css @@ -696,6 +696,8 @@ } /* ── Mobile landscape 3-column layout ────────────────── */ +/* DEPRECATED: replaced by .layout-* shells below. + Kept temporarily as a safety net; remove in a follow-up PR. */ .fcs-landscape { display: grid; @@ -764,6 +766,381 @@ width: 100%; } +/* ═══════════════════════════════════════════════════════════ + Layout: Phone Portrait + Video dominates ≥55% of viewport height; term overlays video. + ═══════════════════════════════════════════════════════════ */ + +.layout-phone-portrait { + --lpp-video-height: 55vh; /* video player height */ + --lpp-term-font-size: clamp(18px, 5vw, 28px); /* term overlay text size */ + --lpp-term-overlay-padding: 6px 12px; /* padding on the term overlay badge */ + --lpp-term-overlay-bottom: 12px; /* term overlay distance from video bottom */ + --lpp-nav-btn-min-size: 44px; /* minimum tap target for nav buttons */ + --lpp-nav-font-size: 14px; /* font size inside nav buttons */ + --lpp-nav-gap: 6px; /* gap between nav buttons */ + --lpp-gap: 8px; /* gap between layout regions */ + --lpp-position-font-size: 13px; /* position counter font size */ + --lpp-padding: 8px; /* outer padding */ + + display: flex; + flex-direction: column; + gap: var(--lpp-gap); + padding: var(--lpp-padding); + flex-grow: 1; + min-height: 0; +} + +.layout-phone-portrait__back { + align-self: flex-start; +} + +.layout-phone-portrait__video-wrap { + position: relative; + width: 100%; + height: var(--lpp-video-height); + flex-shrink: 0; +} + +.layout-phone-portrait__video-wrap .flashcard-video { + max-width: none; + width: 100%; + height: 100%; + aspect-ratio: unset; +} + +.layout-phone-portrait__term-overlay { + position: absolute; + left: 50%; + bottom: var(--lpp-term-overlay-bottom); + transform: translateX(-50%); + z-index: 2; + padding: var(--lpp-term-overlay-padding); + font-size: var(--lpp-term-font-size); + font-weight: 700; + border-radius: 8px; + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + box-shadow: var(--shadow); + pointer-events: none; +} + +.layout-phone-portrait__position { + font-size: var(--lpp-position-font-size); + color: var(--text); + text-align: center; + margin: 0; +} + +.layout-phone-portrait__position .flashcard-position { + margin: 0; + font-size: inherit; +} + +.layout-phone-portrait__nav .flashcard-nav { + gap: var(--lpp-nav-gap); + justify-content: center; +} + +.layout-phone-portrait__nav .btn-nav { + min-height: var(--lpp-nav-btn-min-size); + font-size: var(--lpp-nav-font-size); +} + +/* ═══════════════════════════════════════════════════════════ + Layout: Phone Landscape + Video dominates ≥65% of viewport width; sidebar holds nav. + ═══════════════════════════════════════════════════════════ */ + +.layout-phone-landscape { + --lpl-video-width: 65vw; /* video player width */ + --lpl-sidebar-width: 35vw; /* sidebar width */ + --lpl-term-font-size: clamp(13px, 2.5vw, 18px); /* term card text size */ + --lpl-term-padding: 6px 8px; /* term card padding */ + --lpl-nav-btn-min-size: 32px; /* minimum tap target for nav buttons */ + --lpl-nav-font-size: 12px; /* font size inside nav buttons */ + --lpl-nav-gap: 4px; /* gap between nav buttons */ + --lpl-gap: 6px; /* gap between video and sidebar */ + --lpl-sidebar-gap: 6px; /* gap between sidebar regions */ + --lpl-position-font-size: 12px; /* position counter font size */ + --lpl-padding: 6px; /* outer padding */ + + display: flex; + flex-direction: row; + gap: var(--lpl-gap); + padding: var(--lpl-padding); + flex-grow: 1; + min-height: 0; + overflow: hidden; +} + +.layout-phone-landscape__video-wrap { + flex: 0 0 var(--lpl-video-width); + min-width: 0; + display: flex; + align-items: stretch; +} + +.layout-phone-landscape__video-wrap .flashcard-video { + max-width: none; + width: 100%; + height: 100%; + aspect-ratio: unset; + flex: 1 1 0; + min-height: 0; +} + +.layout-phone-landscape__sidebar { + flex: 1 1 var(--lpl-sidebar-width); + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--lpl-sidebar-gap); + overflow: hidden; +} + +.layout-phone-landscape__term { + padding: var(--lpl-term-padding); + font-size: var(--lpl-term-font-size); + font-weight: 700; + border-radius: 8px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layout-phone-landscape__position { + font-size: var(--lpl-position-font-size); + color: var(--text); + text-align: center; + margin: 0; +} + +.layout-phone-landscape__position .flashcard-position { + margin: 0; + font-size: inherit; +} + +.layout-phone-landscape__nav .flashcard-nav { + flex-direction: column; + gap: var(--lpl-nav-gap); +} + +.layout-phone-landscape__nav .btn-nav { + width: 100%; + min-height: var(--lpl-nav-btn-min-size); + font-size: var(--lpl-nav-font-size); + padding: 4px 6px; +} + +.layout-phone-landscape__sidebar .term-select { + flex-grow: 1; + overflow-y: auto; + font-size: 12px; + min-height: 0; +} + +/* ═══════════════════════════════════════════════════════════ + Layout: Tablet Portrait + Video dominates ≥50% of viewport height; term sits below. + ═══════════════════════════════════════════════════════════ */ + +.layout-tablet-portrait { + --ltp-video-height: 50vh; /* video player height */ + --ltp-term-font-size: clamp(20px, 3vw, 32px); /* term card text size */ + --ltp-term-padding: 12px 16px; /* term card padding */ + --ltp-nav-btn-min-size: 44px; /* minimum tap target for nav buttons */ + --ltp-nav-font-size: 15px; /* font size inside nav buttons */ + --ltp-nav-gap: 8px; /* gap between nav buttons */ + --ltp-gap: 12px; /* gap between layout regions */ + --ltp-position-font-size: 14px; /* position counter font size */ + --ltp-padding: 16px; /* outer padding */ + --ltp-termlist-max-height: 200px; /* inline term list panel max height */ + + display: flex; + flex-direction: column; + gap: var(--ltp-gap); + padding: var(--ltp-padding); + flex-grow: 1; + min-height: 0; +} + +.layout-tablet-portrait__back { + align-self: flex-start; +} + +.layout-tablet-portrait__header { + display: flex; + flex-direction: column; + gap: 4px; +} + +.layout-tablet-portrait__title { + margin: 0; + font-size: clamp(20px, 3vw, 28px); + font-weight: 700; + color: var(--text-h); +} + +.layout-tablet-portrait__desc { + margin: 0; + font-size: 14px; + color: var(--text); +} + +.layout-tablet-portrait__video-wrap { + width: 100%; + height: var(--ltp-video-height); + flex-shrink: 0; +} + +.layout-tablet-portrait__video-wrap .flashcard-video { + max-width: none; + width: 100%; + height: 100%; + aspect-ratio: unset; +} + +.layout-tablet-portrait__term { + padding: var(--ltp-term-padding); + font-size: var(--ltp-term-font-size); + font-weight: 700; + border-radius: 12px; + text-align: center; +} + +.layout-tablet-portrait__position { + font-size: var(--ltp-position-font-size); + color: var(--text); + text-align: center; + margin: 0; +} + +.layout-tablet-portrait__position .flashcard-position { + margin: 0; + font-size: inherit; +} + +.layout-tablet-portrait__nav .flashcard-nav { + gap: var(--ltp-nav-gap); + justify-content: center; +} + +.layout-tablet-portrait__nav .btn-nav { + min-height: var(--ltp-nav-btn-min-size); + font-size: var(--ltp-nav-font-size); +} + +.layout-tablet-portrait__termlist .term-select { + width: 100%; + max-height: var(--ltp-termlist-max-height); +} + +/* ═══════════════════════════════════════════════════════════ + Layout: Tablet Landscape + Video dominates ≥55% of viewport width; sidebar on right. + ═══════════════════════════════════════════════════════════ */ + +.layout-tablet-landscape { + --ltl-video-width: 60vw; /* video column width */ + --ltl-sidebar-width: 40vw; /* sidebar width */ + --ltl-term-font-size: clamp(18px, 2.5vw, 26px); /* term card text size */ + --ltl-term-padding: 10px 14px; /* term card padding */ + --ltl-nav-btn-min-size: 40px; /* minimum tap target for nav buttons */ + --ltl-nav-font-size: 14px; /* font size inside nav buttons */ + --ltl-nav-gap: 6px; /* gap between nav buttons */ + --ltl-gap: 12px; /* gap between video col and sidebar */ + --ltl-col-gap: 8px; /* gap between video & term in left col */ + --ltl-sidebar-gap: 8px; /* gap between sidebar regions */ + --ltl-position-font-size: 13px; /* position counter font size */ + --ltl-padding: 12px; /* outer padding */ + + display: flex; + flex-direction: row; + gap: var(--ltl-gap); + padding: var(--ltl-padding); + flex-grow: 1; + min-height: 0; + overflow: hidden; +} + +.layout-tablet-landscape__video-col { + flex: 0 0 var(--ltl-video-width); + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ltl-col-gap); +} + +.layout-tablet-landscape__video-wrap { + flex: 1 1 0; + min-height: 0; + display: flex; + align-items: stretch; +} + +.layout-tablet-landscape__video-wrap .flashcard-video { + max-width: none; + width: 100%; + height: 100%; + aspect-ratio: unset; + flex: 1 1 0; + min-height: 0; +} + +.layout-tablet-landscape__term { + padding: var(--ltl-term-padding); + font-size: var(--ltl-term-font-size); + font-weight: 700; + border-radius: 10px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layout-tablet-landscape__position { + font-size: var(--ltl-position-font-size); + color: var(--text); + text-align: center; + margin: 0; +} + +.layout-tablet-landscape__position .flashcard-position { + margin: 0; + font-size: inherit; +} + +.layout-tablet-landscape__sidebar { + flex: 1 1 var(--ltl-sidebar-width); + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ltl-sidebar-gap); + overflow: hidden; +} + +.layout-tablet-landscape__nav .flashcard-nav { + flex-direction: column; + gap: var(--ltl-nav-gap); +} + +.layout-tablet-landscape__nav .btn-nav { + width: 100%; + min-height: var(--ltl-nav-btn-min-size); + font-size: var(--ltl-nav-font-size); +} + +.layout-tablet-landscape__sidebar .term-select { + flex-grow: 1; + overflow-y: auto; + font-size: 13px; + min-height: 0; +} + /* ── App banner ───────────────────────────────────────── */ .app-banner { diff --git a/src/components/FlashcardSession.jsx b/src/components/FlashcardSession.jsx index 2ee99b9..91a3bd2 100644 --- a/src/components/FlashcardSession.jsx +++ b/src/components/FlashcardSession.jsx @@ -4,13 +4,18 @@ import {shuffle} from "../utils/shuffle"; import toast from "react-hot-toast"; import {FlashcardNav} from "./FlashcardNav"; import {FlashcardPlayer} from "./FlashcardPlayer"; +import {useLayoutMode} from "../hooks/useLayoutMode"; +import {useSwipe} from "../hooks/useSwipe"; +import {PhonePortraitLayout} from "./layouts/PhonePortraitLayout"; +import {PhoneLandscapeLayout} from "./layouts/PhoneLandscapeLayout"; +import {TabletPortraitLayout} from "./layouts/TabletPortraitLayout"; +import {TabletLandscapeLayout} from "./layouts/TabletLandscapeLayout"; export function FlashcardSession({terms, cardColors, onBack, title, description}) { const [currentIndex, setCurrentIndex] = useState(0); const [localTerms, setLocalTerms] = useState(terms); const [localColors, setLocalColors] = useState(cardColors); const [termDrawerOpen, setTermDrawerOpen] = useState(false); - const [isMobileHorizontal, setIsMobileHorizontal] = useState(false); const [autoPlay, setAutoPlay] = useState(false); const [showPlayerControls, setShowPlayerControls] = useState(true); const [playing, setPlaying] = useState(false); @@ -18,20 +23,7 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} const [repeat, setRepeat] = useState(false); const selectRef = useRef(null); - useEffect(() => { - function check() { - setIsMobileHorizontal( - window.innerWidth < 1424 && window.matchMedia('(orientation: landscape)').matches - ); - } - check(); - window.addEventListener('resize', check); - window.addEventListener('orientationchange', check); - return () => { - window.removeEventListener('resize', check); - window.removeEventListener('orientationchange', check); - }; - }, []); + const layoutMode = useLayoutMode(); const goNext = useCallback(() => { setPlaying(false); @@ -43,6 +35,8 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} setCurrentIndex(i => (i - 1 + localTerms.length) % localTerms.length); }, [localTerms.length]); + const swipeHandlers = useSwipe({onSwipeLeft: goNext, onSwipeRight: goPrev}); + function handleShuffle() { setPlaying(false); const indices = shuffle([...localTerms.keys()]); @@ -126,67 +120,101 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} const fg = contrastColor(bg); const playbackUrl = getPlaybackUrl(); - if (isMobileHorizontal) { - return ( -
-
-
- -
- {localTerms[currentIndex].term} -
-

{currentIndex + 1} / {localTerms.length}

- setAutoPlay(p => !p)} - autoPlayActiveLabel="⏸ Auto" - autoPlayInactiveLabel="▶ Auto" - showPlayerControls={showPlayerControls} - onTogglePlayerControls={() => setShowPlayerControls(p => !p)} - playing={playing} - onTogglePlaying={() => setPlaying(p => !p)} - playbackRate={playbackRate} - onTogglePlaybackRate={() => setPlaybackRate(r => r === 1 ? 0.5 : 1)} - repeat={repeat} - onToggleRepeat={() => setRepeat(r => !r)} - /> -
-
- playingStateChanged(PLAYBACK_STATE_START)} - onPause={() => playingStateChanged(PLAYBACK_STATE_PAUSE)} - onEnded={() => playingStateChanged(PLAYBACK_STATE_END)} - onError={playbackError} - /> -
-
- + const isPhonePortrait = layoutMode === 'phone-portrait'; + const isMobileLayout = layoutMode === 'phone-portrait' || layoutMode === 'phone-landscape' + || layoutMode === 'tablet-portrait' || layoutMode === 'tablet-landscape'; + + if (isMobileLayout) { + const videoEl = ( + playingStateChanged(PLAYBACK_STATE_START)} + onPause={() => playingStateChanged(PLAYBACK_STATE_PAUSE)} + onEnded={() => playingStateChanged(PLAYBACK_STATE_END)} + onError={playbackError} + /> + ); + + const navEl = ( + setTermDrawerOpen(true) : undefined} + autoPlay={autoPlay} + onToggleAutoPlay={() => setAutoPlay(p => !p)} + autoPlayActiveLabel="🔁 Auto" + autoPlayInactiveLabel="⏸ Wait" + showPlayerControls={showPlayerControls} + onTogglePlayerControls={() => setShowPlayerControls(p => !p)} + playing={playing} + onTogglePlaying={() => setPlaying(p => !p)} + playbackRate={playbackRate} + onTogglePlaybackRate={() => setPlaybackRate(r => r === 1 ? 0.5 : 1)} + repeat={repeat} + onToggleRepeat={() => setRepeat(r => !r)} + /> + ); + + const termSelectEl = ( + + ); + + const termListEl = isPhonePortrait ? ( +
+
setTermDrawerOpen(false)} /> +
+
+ Select a Term +
+ {termSelectEl}
+ ) : termSelectEl; + + const positionLabelEl = ( +

{currentIndex + 1} / {localTerms.length}

); + + const sharedSlots = { + video: videoEl, + nav: navEl, + termList: termListEl, + positionLabel: positionLabelEl, + termText: localTerms[currentIndex].term, + termBg: bg, + termFg: fg, + onBack, + swipeHandlers, + title, + description, + }; + + switch (layoutMode) { + case 'phone-portrait': return ; + case 'phone-landscape': return ; + case 'tablet-portrait': return ; + case 'tablet-landscape': return ; + default: break; + } } return ( @@ -228,7 +256,6 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} playing={playing} onTogglePlaying={() => setPlaying(p => !p)} playbackRate={playbackRate} - loop={repeat} onTogglePlaybackRate={() => setPlaybackRate(r => r === 1 ? 0.5 : 1)} repeat={repeat} onToggleRepeat={() => setRepeat(r => !r)} diff --git a/src/components/layouts/PhoneLandscapeLayout.jsx b/src/components/layouts/PhoneLandscapeLayout.jsx new file mode 100644 index 0000000..90a5a08 --- /dev/null +++ b/src/components/layouts/PhoneLandscapeLayout.jsx @@ -0,0 +1,29 @@ +export function PhoneLandscapeLayout({ + video, + nav, + termList, + positionLabel, + termText, + termBg, + termFg, + onBack, + swipeHandlers, +}) { + return ( +
+
+ {video} +
+
+ +
{termText}
+
{positionLabel}
+
{nav}
+ {termList} +
+
+ ); +} diff --git a/src/components/layouts/PhonePortraitLayout.jsx b/src/components/layouts/PhonePortraitLayout.jsx new file mode 100644 index 0000000..7ca8d60 --- /dev/null +++ b/src/components/layouts/PhonePortraitLayout.jsx @@ -0,0 +1,27 @@ +export function PhonePortraitLayout({ + video, + nav, + termList, + positionLabel, + termText, + termBg, + termFg, + onBack, + swipeHandlers, +}) { + return ( +
+ +
+ {termText} + {video} +
+
{positionLabel}
+
{nav}
+ {termList} +
+ ); +} diff --git a/src/components/layouts/TabletLandscapeLayout.jsx b/src/components/layouts/TabletLandscapeLayout.jsx new file mode 100644 index 0000000..8e9ace4 --- /dev/null +++ b/src/components/layouts/TabletLandscapeLayout.jsx @@ -0,0 +1,31 @@ +export function TabletLandscapeLayout({ + video, + nav, + termList, + positionLabel, + termText, + termBg, + termFg, + onBack, + swipeHandlers, +}) { + return ( +
+
+
+ {video} +
+
{termText}
+
{positionLabel}
+
+
+ +
{nav}
+
{termList}
+
+
+ ); +} diff --git a/src/components/layouts/TabletPortraitLayout.jsx b/src/components/layouts/TabletPortraitLayout.jsx new file mode 100644 index 0000000..99b0005 --- /dev/null +++ b/src/components/layouts/TabletPortraitLayout.jsx @@ -0,0 +1,35 @@ +export function TabletPortraitLayout({ + video, + nav, + termList, + positionLabel, + termText, + termBg, + termFg, + onBack, + swipeHandlers, + title, + description, +}) { + return ( +
+ + {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} +
+ {video} +
+
{termText}
+
{positionLabel}
+
{nav}
+
{termList}
+
+ ); +} diff --git a/src/constants/breakpoints.js b/src/constants/breakpoints.js new file mode 100644 index 0000000..29a0645 --- /dev/null +++ b/src/constants/breakpoints.js @@ -0,0 +1,2 @@ +export const PHONE_MAX_WIDTH = 767; +export const TABLET_MAX_WIDTH = 1023; diff --git a/src/hooks/useLayoutMode.js b/src/hooks/useLayoutMode.js new file mode 100644 index 0000000..d03fc5a --- /dev/null +++ b/src/hooks/useLayoutMode.js @@ -0,0 +1,42 @@ +import {useState, useEffect} from 'react'; +import {PHONE_MAX_WIDTH, TABLET_MAX_WIDTH} from '../constants/breakpoints'; + +function isLandscapeOrientation(width, height) { + if (typeof screen !== 'undefined' && screen.orientation && typeof screen.orientation.type === 'string') { + const t = screen.orientation.type; + return t === 'landscape-primary' || t === 'landscape-secondary'; + } + return width > height; +} + +export function getLayoutMode() { + const w = typeof window !== 'undefined' ? window.innerWidth : 1024; + const h = typeof window !== 'undefined' ? window.innerHeight : 768; + const landscape = isLandscapeOrientation(w, h); + if (w <= PHONE_MAX_WIDTH) return landscape ? 'phone-landscape' : 'phone-portrait'; + if (w <= TABLET_MAX_WIDTH) return landscape ? 'tablet-landscape' : 'tablet-portrait'; + return 'desktop'; +} + +export function useLayoutMode() { + const [mode, setMode] = useState(getLayoutMode); + + useEffect(() => { + function check() { setMode(getLayoutMode()); } + window.addEventListener('resize', check); + window.addEventListener('orientationchange', check); + const so = typeof screen !== 'undefined' ? screen.orientation : null; + if (so && typeof so.addEventListener === 'function') { + so.addEventListener('change', check); + } + return () => { + window.removeEventListener('resize', check); + window.removeEventListener('orientationchange', check); + if (so && typeof so.removeEventListener === 'function') { + so.removeEventListener('change', check); + } + }; + }, []); + + return mode; +} diff --git a/src/hooks/useSwipe.js b/src/hooks/useSwipe.js new file mode 100644 index 0000000..49fdefd --- /dev/null +++ b/src/hooks/useSwipe.js @@ -0,0 +1,26 @@ +import {useRef} from 'react'; + +export function useSwipe({onSwipeLeft, onSwipeRight, minDistance = 50}) { + const startX = useRef(null); + + function onTouchStart(e) { + const touch = e.changedTouches && e.changedTouches[0]; + startX.current = touch ? touch.clientX : null; + } + + function onTouchEnd(e) { + if (startX.current === null) return; + const touch = e.changedTouches && e.changedTouches[0]; + if (!touch) { startX.current = null; return; } + const deltaX = touch.clientX - startX.current; + startX.current = null; + if (Math.abs(deltaX) < minDistance) return; + if (deltaX < 0) { + if (typeof onSwipeLeft === 'function') onSwipeLeft(); + } else { + if (typeof onSwipeRight === 'function') onSwipeRight(); + } + } + + return {onTouchStart, onTouchEnd}; +} diff --git a/tests/useLayoutMode.test.js b/tests/useLayoutMode.test.js new file mode 100644 index 0000000..f4a9dd9 --- /dev/null +++ b/tests/useLayoutMode.test.js @@ -0,0 +1,126 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import {renderHook, act} from '@testing-library/react'; +import {useLayoutMode, getLayoutMode} from '../src/hooks/useLayoutMode'; + +function setViewport(width, height) { + Object.defineProperty(window, 'innerWidth', {value: width, configurable: true, writable: true}); + Object.defineProperty(window, 'innerHeight', {value: height, configurable: true, writable: true}); +} + +function setScreenOrientation(type) { + if (type === undefined) { + Object.defineProperty(globalThis, 'screen', { + value: {...globalThis.screen, orientation: undefined}, + configurable: true, + }); + return; + } + Object.defineProperty(globalThis, 'screen', { + value: { + ...globalThis.screen, + orientation: { + type, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }, + configurable: true, + }); +} + +const ORIGINAL_SCREEN = globalThis.screen; + +beforeEach(() => { + setScreenOrientation(undefined); +}); + +afterEach(() => { + Object.defineProperty(globalThis, 'screen', {value: ORIGINAL_SCREEN, configurable: true}); +}); + +describe('getLayoutMode', () => { + it('returns phone-portrait for narrow taller-than-wide viewport', () => { + setViewport(375, 667); + expect(getLayoutMode()).toBe('phone-portrait'); + }); + + it('returns phone-landscape for narrow wider-than-tall viewport', () => { + setViewport(667, 375); + expect(getLayoutMode()).toBe('phone-landscape'); + }); + + it('returns tablet-portrait for medium taller-than-wide viewport', () => { + setViewport(768, 1024); + expect(getLayoutMode()).toBe('tablet-portrait'); + }); + + it('returns tablet-landscape for medium wider-than-tall viewport', () => { + setViewport(1023, 768); + expect(getLayoutMode()).toBe('tablet-landscape'); + }); + + it('returns desktop for viewports above the tablet threshold', () => { + setViewport(1440, 900); + expect(getLayoutMode()).toBe('desktop'); + }); + + it('treats 767px wide landscape as phone-landscape (max phone boundary)', () => { + setViewport(767, 430); + expect(getLayoutMode()).toBe('phone-landscape'); + }); + + it('treats 768px wide portrait as tablet-portrait (min tablet boundary)', () => { + setViewport(768, 1024); + expect(getLayoutMode()).toBe('tablet-portrait'); + }); + + it('uses screen.orientation.type when API is available (landscape-primary)', () => { + setScreenOrientation('landscape-primary'); + setViewport(600, 800); // taller than wide, but API says landscape + expect(getLayoutMode()).toBe('phone-landscape'); + }); + + it('uses screen.orientation.type when API is available (portrait-primary)', () => { + setScreenOrientation('portrait-primary'); + setViewport(900, 600); // wider than tall, but API says portrait + expect(getLayoutMode()).toBe('tablet-portrait'); + }); + + it('falls back to innerWidth>innerHeight when screen.orientation is undefined', () => { + setScreenOrientation(undefined); + setViewport(800, 600); + expect(getLayoutMode()).toBe('tablet-landscape'); + }); +}); + +describe('useLayoutMode', () => { + it('returns the initial layout mode on mount', () => { + setViewport(375, 667); + const {result} = renderHook(() => useLayoutMode()); + expect(result.current).toBe('phone-portrait'); + }); + + it('re-evaluates on orientationchange event', () => { + setViewport(375, 667); + const {result} = renderHook(() => useLayoutMode()); + expect(result.current).toBe('phone-portrait'); + + act(() => { + setViewport(667, 375); + window.dispatchEvent(new Event('orientationchange')); + }); + expect(result.current).toBe('phone-landscape'); + }); + + it('re-evaluates on resize event', () => { + setViewport(1440, 900); + const {result} = renderHook(() => useLayoutMode()); + expect(result.current).toBe('desktop'); + + act(() => { + setViewport(375, 667); + window.dispatchEvent(new Event('resize')); + }); + expect(result.current).toBe('phone-portrait'); + }); +});