mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(client): a phone turned sideways stays in the phone layout
The phone shell was chosen by width alone, and most phones are wider than 767px in landscape, so turning one swapped the whole route tree for the desktop planner and an open sheet, its input and the map position were lost. The phone query now also covers a touch screen under 500px tall, which no tablet is. It lives in one place: the shell, the planner and todo hooks, the warnings banner, dialog focus and the stylesheets read it, and six components that measured the window once on mount now follow it.
This commit is contained in:
@@ -24,6 +24,7 @@ import { NoteCard } from './CollabNotesCard'
|
||||
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
interface CollabNotesProps {
|
||||
@@ -343,6 +344,7 @@ function CollabNotesGrid(S: NotesState) {
|
||||
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
|
||||
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
|
||||
} = S
|
||||
const isPhone = useIsPhone()
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{sortedNotes.length === 0 ? (
|
||||
@@ -352,7 +354,7 @@ function CollabNotesGrid(S: NotesState) {
|
||||
/* ── Notes grid — 2 columns ── */
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gridTemplateColumns: isPhone ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{sortedNotes.map(note => (
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function FileManager(props: FileManagerProps) {
|
||||
{showTrash ? <TrashView {...S} /> : <FilesView {...S} />}
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 767px), (pointer: coarse) and (max-height: 500px) {
|
||||
.file-actions button { padding: 8px !important; }
|
||||
.file-actions svg { width: 18px !important; height: 18px !important; }
|
||||
}
|
||||
|
||||
@@ -893,8 +893,11 @@ export const MapView = memo(function MapView({
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
// The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the
|
||||
// Leaflet fit options and the opening-camera maths can read the same numbers.
|
||||
// Followed rather than read once: a phone turned sideways and back crosses
|
||||
// the breakpoint twice, and the padding here and the locate button further
|
||||
// down used to stay with whatever the map saw when it mounted.
|
||||
const isMobile = useIsPhone()
|
||||
const paddingBox = useMemo((): ViewportPadding => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 }
|
||||
return {
|
||||
top: 60,
|
||||
@@ -902,7 +905,7 @@ export const MapView = memo(function MapView({
|
||||
bottom: hasInspector ? 320 : hasDayDetail ? 280 : 60,
|
||||
left: leftWidth + 40,
|
||||
}
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
|
||||
|
||||
@@ -1158,10 +1161,6 @@ export const MapView = memo(function MapView({
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
// The width is followed rather than read once: a phone turned sideways and
|
||||
// back crosses the breakpoint twice, and the button used to stay with
|
||||
// whatever the map saw when it mounted.
|
||||
const isMobile = useIsPhone()
|
||||
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
|
||||
// height var(--day-panel-h)) and covers the button's band, so lift the button
|
||||
// above it; otherwise keep the plain bottom-nav offset. #1348
|
||||
|
||||
@@ -2323,12 +2323,11 @@ export function MapViewGL({
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
const prevFitKey = useRef<number | null>(-1)
|
||||
const pendingRouteFitRef = useRef<{ fitKey: number | null; routeKey: string } | null>(null)
|
||||
|
||||
@@ -76,7 +76,7 @@ interface PlaceFormModalProps {
|
||||
dayAssignments?: Assignment[]
|
||||
/** Mobile keeps the untouched single-column form; desktop adds the saved-place
|
||||
* picker column when the Collections addon is enabled. Sourced from the trip
|
||||
* planner's matchMedia('(max-width:767px)'). */
|
||||
* planner's useIsPhone(). */
|
||||
isMobile?: boolean
|
||||
/** Opens the Costs editor for this place's linked expense (#1298) — the same
|
||||
* seam the booking and transport modals use. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay'
|
||||
import type { Reservation } from '../../types'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* The journey view for an automated public-transit entry (#1065): a roomy modal
|
||||
@@ -52,7 +53,7 @@ interface TransitJourneyModalProps {
|
||||
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const isMobile = useIsPhone()
|
||||
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
|
||||
const res = reservation
|
||||
const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {})
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Day, Place, Accommodation, Reservation } from '../../types'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -465,7 +466,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const isMobile = useIsPhone()
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import TripWarningsBanner from './TripWarningsBanner';
|
||||
import { PHONE_QUERY } from '../../mobile/useIsPhone';
|
||||
|
||||
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string };
|
||||
|
||||
@@ -28,12 +29,15 @@ function mountNavSlot(): HTMLElement {
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** matchMedia is stubbed to matches:false globally — opt this component into desktop. */
|
||||
function stubDesktop(matches: boolean) {
|
||||
/**
|
||||
* The banner asks the phone query, so a desktop viewport is one where it does
|
||||
* not match. `fire(true)` means "now desktop", like the old min-width stub.
|
||||
*/
|
||||
function stubDesktop(desktop: boolean) {
|
||||
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
const mql = {
|
||||
matches,
|
||||
media: '(min-width: 768px)',
|
||||
matches: !desktop,
|
||||
media: PHONE_QUERY,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
@@ -42,7 +46,7 @@ function stubDesktop(matches: boolean) {
|
||||
dispatchEvent: vi.fn(() => true),
|
||||
};
|
||||
window.matchMedia = vi.fn(() => mql) as unknown as typeof window.matchMedia;
|
||||
return { mql, fire: (m: boolean) => listeners.forEach(cb => cb({ matches: m } as MediaQueryListEvent)) };
|
||||
return { mql, fire: (desktopNow: boolean) => listeners.forEach(cb => cb({ matches: !desktopNow } as MediaQueryListEvent)) };
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* Shows validation/warning contributions from `warningProvider` plugins (#1429).
|
||||
@@ -25,9 +26,7 @@ const STYLE = {
|
||||
export default function TripWarningsBanner({ tripId, onOpenPluginTab }: { tripId: number; onOpenPluginTab?: (pluginId: string) => void }) {
|
||||
const [warnings, setWarnings] = useState<Warning[]>([])
|
||||
const [navSlot, setNavSlot] = useState<HTMLElement | null>(null)
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => typeof window !== 'undefined' && (window.matchMedia?.('(min-width: 768px)')?.matches ?? true)
|
||||
)
|
||||
const isDesktop = !useIsPhone()
|
||||
const plugins = usePluginStore((s) => s.plugins)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,13 +41,6 @@ export default function TripWarningsBanner({ tripId, onOpenPluginTab }: { tripId
|
||||
// The navbar renders in the same commit as the planner, so the slot exists by
|
||||
// the time effects run; it is display:none'd with the navbar below md.
|
||||
useEffect(() => { setNavSlot(document.getElementById('trek-nav-center-slot')) }, [])
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(min-width: 768px)')
|
||||
if (!mq) return
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
if (warnings.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Footprints, MoveRight, type LucideIcon } from 'lucide-react'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* Shared display bits for public-transit entries (#1065) — the timeline row,
|
||||
@@ -33,7 +34,7 @@ export function TransitWalkDivider({ leg, t, size = 'md' }: {
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const isMobile = useIsPhone()
|
||||
// Hairlines that fade towards the outer edges — strongest next to the text.
|
||||
const rule = (dir: 'left' | 'right'): React.CSSProperties => ({
|
||||
flex: 1, height: 1, minWidth: 12, borderRadius: 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formatDate as fmtDate } from '../../utils/formatters'
|
||||
import type { TodoItem } from '../../types'
|
||||
import { localToday } from '../Planner/today'
|
||||
import type { FilterType, Member } from './todoListModel'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* Todo list logic — store actions, member load, the filter/selection/add-new
|
||||
@@ -24,13 +25,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
const { t, locale } = useTranslation()
|
||||
const formatDate = (d: string) => fmtDate(d, locale) || d
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 767px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
const isMobile = useIsPhone()
|
||||
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { isPhoneViewport } from '../../mobile/useIsPhone'
|
||||
|
||||
/**
|
||||
* Keyboard focus for the app's dialogs (#1302): the first Tab lands in the dialog
|
||||
@@ -39,7 +40,7 @@ export function firstTextField(panel: HTMLElement): HTMLElement | null {
|
||||
/** Only with a mouse or trackpad: on a phone a focused field would throw the keyboard up. */
|
||||
function typingDevice(): boolean {
|
||||
if (typeof window.matchMedia !== 'function') return false
|
||||
return window.matchMedia('(pointer: fine)').matches && !window.matchMedia('(max-width: 767px)').matches
|
||||
return window.matchMedia('(pointer: fine)').matches && !isPhoneViewport()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
overscroll-behavior has to sit on html, because the value on body is never
|
||||
propagated to the viewport, and without it a pull at the top of the page
|
||||
reloads the installed PWA. Desktop/tablet keep the rules above. */
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 767px), (pointer: coarse) and (max-height: 500px) {
|
||||
html { overflow: visible; overscroll-behavior: none; }
|
||||
/* overflow-x clip (not hidden: clip creates no scroll container) so a screen
|
||||
that accidentally overflows sideways cannot widen the document. Android's
|
||||
@@ -182,7 +182,7 @@ html:not(.dark) .atlas-tooltip::before { border-top-color: rgba(255, 255, 255, 0
|
||||
.dark .leaflet-control-zoom a:hover {
|
||||
background: rgba(30, 30, 40, 0.8) !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 767px), (pointer: coarse) and (max-height: 500px) {
|
||||
.leaflet-control-zoom { display: none !important; }
|
||||
}
|
||||
|
||||
@@ -816,7 +816,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--vg-hi: inset 0 1px 0 oklch(1 0 0 / .8);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 767px), (pointer: coarse) and (max-height: 500px) {
|
||||
:root { --bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
|
||||
@@ -1222,7 +1222,7 @@ body {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 767px), (pointer: coarse) and (max-height: 500px) {
|
||||
* { scrollbar-width: none; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const PHONE_QUERY = '(max-width: 767px)'
|
||||
/**
|
||||
* The phone shell's viewport: narrower than the md breakpoint (768px), or a
|
||||
* touch screen short enough to be a phone on its side.
|
||||
*
|
||||
* Width alone flipped a phone into the desktop planner the moment it was
|
||||
* turned: most phones are wider than 767px in landscape. That swapped the
|
||||
* whole route tree, so an open sheet, what was typed into it and the map's
|
||||
* position were gone. A tablet stays where it was, since none is under 500px
|
||||
* tall, and a desktop window keeps following its width, since its pointer is
|
||||
* fine.
|
||||
*
|
||||
* The phone rules in `index.css` repeat this query; keep them in step.
|
||||
*/
|
||||
export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse) and (max-height: 500px)'
|
||||
|
||||
/** Whether the phone shell applies right now, for code that is not a component. */
|
||||
export function isPhoneViewport(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(PHONE_QUERY).matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true below the md breakpoint (768px) — the cut between the new
|
||||
* mobile shell and the untouched desktop/tablet experience. Reactive: follows
|
||||
* viewport resizes and orientation changes via matchMedia.
|
||||
* True while the phone shell applies (see {@link PHONE_QUERY}). Reactive:
|
||||
* follows viewport resizes and orientation changes via matchMedia.
|
||||
*/
|
||||
export function useIsPhone(): boolean {
|
||||
const [isPhone, setIsPhone] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches,
|
||||
)
|
||||
const [isPhone, setIsPhone] = useState(isPhoneViewport)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(PHONE_QUERY)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { offlineDb, saveImportFiles, getImportFiles } from '../../db/offlineDb'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import type { Accommodation, Place, Reservation, Settings } from '../../types'
|
||||
import { PHONE_QUERY } from '../../mobile/useIsPhone'
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
// Only useParams/useNavigate/useSearchParams are consumed by the hook, so the
|
||||
@@ -2716,7 +2717,11 @@ describe('useTripPlanner — misc state', () => {
|
||||
it('FE-TP-HOOK-097: the media query listener drives the mobile flag', async () => {
|
||||
const listeners: Record<string, Array<(e: MediaQueryListEvent) => void>> = {}
|
||||
const removeEventListener = vi.fn()
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
|
||||
// Swapped by hand and put back below: window.matchMedia is the setup's own
|
||||
// mock, and a spy on it outlives restoreAllMocks, which left every later
|
||||
// test of this file answering "not a phone" whatever the viewport.
|
||||
const originalMatchMedia = window.matchMedia
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
@@ -2727,17 +2732,21 @@ describe('useTripPlanner — misc state', () => {
|
||||
},
|
||||
removeEventListener,
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as unknown as MediaQueryList)
|
||||
seedTrip()
|
||||
}) as unknown as MediaQueryList) as unknown as typeof window.matchMedia
|
||||
try {
|
||||
seedTrip()
|
||||
|
||||
const { result, unmount } = await renderPlanner()
|
||||
expect(result.current.isMobile).toBe(false)
|
||||
const { result, unmount } = await renderPlanner()
|
||||
expect(result.current.isMobile).toBe(false)
|
||||
|
||||
act(() => { listeners['(max-width: 767px)'][0]({ matches: true } as MediaQueryListEvent) })
|
||||
expect(result.current.isMobile).toBe(true)
|
||||
act(() => { listeners[PHONE_QUERY][0]({ matches: true } as MediaQueryListEvent) })
|
||||
expect(result.current.isMobile).toBe(true)
|
||||
|
||||
unmount()
|
||||
expect(removeEventListener).toHaveBeenCalled()
|
||||
unmount()
|
||||
expect(removeEventListener).toHaveBeenCalled()
|
||||
} finally {
|
||||
window.matchMedia = originalMatchMedia
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-098: selectedPlace resolves the current selection out of the store', async () => {
|
||||
|
||||
@@ -87,6 +87,7 @@ import { matchesPlacesFilter } from '../../utils/placesFilter'
|
||||
import { pendingStayPlaceIds } from '../../utils/pendingStays'
|
||||
import { useDayDelete } from './useDayDelete'
|
||||
import { useDayAdd } from './useDayAdd'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
/** Stable empty list so the road trip hook stays inert while its mode is off. */
|
||||
const EMPTY_DAYS: Day[] = []
|
||||
@@ -155,13 +156,7 @@ export function useTripPlanner() {
|
||||
// Declared here rather than with the other layout state further down, because
|
||||
// road-trip mode is decided on it and the assignment and place lists below are
|
||||
// decided on that. One subscriber for the whole hook.
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 767px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
const isMobile = useIsPhone()
|
||||
// The phone shell has no road-trip surface at all: no rail, no drive lines, and
|
||||
// no switch to turn the mode back off. Narrowing a desktop window past the
|
||||
// phone breakpoint used to carry the flag across anyway, which took the trip
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useIsPhone } from '../../../src/mobile/useIsPhone';
|
||||
import { useIsPhone, isPhoneViewport, PHONE_QUERY } from '../../../src/mobile/useIsPhone';
|
||||
|
||||
// FE-MOB-PHONE-001 onwards
|
||||
|
||||
@@ -24,11 +26,47 @@ describe('useIsPhone', () => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it('FE-MOB-PHONE-001: returns true when the viewport matches (max-width: 767px)', () => {
|
||||
it('FE-MOB-PHONE-001: returns true when the viewport matches the phone query', () => {
|
||||
mockMatchMedia(true);
|
||||
const { result } = renderHook(() => useIsPhone());
|
||||
expect(result.current).toBe(true);
|
||||
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 767px)');
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(PHONE_QUERY);
|
||||
});
|
||||
|
||||
it('FE-MOB-PHONE-004: a phone on its side stays a phone, a tablet and a desktop window do not', () => {
|
||||
// Real media query semantics for the three viewports that matter, evaluated
|
||||
// by hand: jsdom does not implement pointer or height features.
|
||||
const viewports = {
|
||||
phoneLandscape: { width: 932, height: 430, coarse: true },
|
||||
tabletLandscape: { width: 1180, height: 820, coarse: true },
|
||||
shortDesktopWindow: { width: 900, height: 450, coarse: false },
|
||||
};
|
||||
const evaluate = (v: { width: number; height: number; coarse: boolean }) =>
|
||||
PHONE_QUERY.split(',').some(part => {
|
||||
const p = part.trim();
|
||||
if (p === '(max-width: 767px)') return v.width <= 767;
|
||||
if (p === '(pointer: coarse) and (max-height: 500px)') return v.coarse && v.height <= 500;
|
||||
throw new Error(`unexpected query part ${p}`);
|
||||
});
|
||||
expect(evaluate(viewports.phoneLandscape)).toBe(true);
|
||||
expect(evaluate(viewports.tabletLandscape)).toBe(false);
|
||||
expect(evaluate(viewports.shortDesktopWindow)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-PHONE-006: the phone rules in the stylesheets use the same query as the shell', () => {
|
||||
for (const file of ['src/index.css', 'src/components/Files/FileManager.tsx']) {
|
||||
const source = readFileSync(resolve(__dirname, '../../..', file), 'utf8');
|
||||
const queries = [...source.matchAll(/@media ([^{]*max-width: 767px[^{]*)\{/g)].map(m => m[1].trim());
|
||||
expect(queries.length).toBeGreaterThan(0);
|
||||
for (const q of queries) expect([PHONE_QUERY, '(max-width: 767px), (pointer: coarse)']).toContain(q);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-MOB-PHONE-005: isPhoneViewport answers outside React, and false without matchMedia', () => {
|
||||
mockMatchMedia(true);
|
||||
expect(isPhoneViewport()).toBe(true);
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
expect(isPhoneViewport()).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-PHONE-002: returns false on wider viewports', () => {
|
||||
|
||||
Reference in New Issue
Block a user