UX audit round 2: Near Me fix, map height, touch targets, iOS dirs, a11y, mobile layout
CRITICAL: - Near Me button: completely rewritten — was silently discarding ALL errors. Now shows toast for denied, timeout, unavailable with actionable copy. Button shows red LocateOff icon when denied so user sees the state at a glance. Raised to 44×44px (Apple minimum). - Map height on mobile: switched to CSS variable system (--header-mobile, --bottom-nav) for pixel-accurate height. Added Layout noPadding prop so the map page gets full bleed without the 80px bottom-padding fighting the map container calc. - Layer toggle pills: raised from 28×28 to 40×40px — was below touch target minimum - Right-side control column: now hides when spot bottom sheet is open (was always visible) iOS/Directions: - SpotDetailPage directions button: now detects Apple platform and opens Apple Maps on iOS/macOS, Google Maps everywhere else (was always Google Maps) - Added aria-label to directions button Accessibility: - LoginDialog: was missing DialogTitle, causing console errors on every login open - CameraCapture: fallback dialog was missing DialogTitle (sr-only) - CommandDialog: added sr-only DialogTitle Layout: - SpotDetailPage: reduced top padding mobile pt-4 (was py-8 = 32px wasted space) - Directions popover: centered in viewport (top-1/2 left-1/2) with zoom-in animation, never clips behind Sheet stacking context CSS: - Added --header-mobile and --bottom-nav CSS variables for consistent calculations - Added .page-fullbleed helper Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
dd85644fca
commit
a1cd936bc4
7 changed files with 106 additions and 30 deletions
|
|
@ -211,6 +211,7 @@ export function CameraCapture({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
|
||||
<DialogTitle className="sr-only">Opening Camera</DialogTitle>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
|
||||
<Camera className="h-8 w-8 text-[#ff0040]" />
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
|||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
/** If true, removes the bottom padding that prevents bottom nav overlap.
|
||||
* Use for full-bleed pages like the map that manage their own height. */
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
// Nav item keys that map to translation keys
|
||||
|
|
@ -332,7 +335,7 @@ function MobileNav({
|
|||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
export function Layout({ children, noPadding }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||
|
|
@ -755,7 +758,8 @@ export function Layout({ children }: LayoutProps) {
|
|||
<BackupReminder />
|
||||
|
||||
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
|
||||
<main className="pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6">
|
||||
{/* noPadding: full-bleed pages (map) manage their own height */}
|
||||
<main className={noPadding ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ declare global {
|
|||
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -232,6 +232,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
|
||||
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
|
||||
<DialogTitle className="text-center font-black uppercase tracking-wide text-xl">
|
||||
Welcome to SkateSpots
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm text-gray-400">
|
||||
Sign up or log in to continue
|
||||
</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from "cmdk"
|
|||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
|
|
@ -27,6 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<DialogTitle className="sr-only">Command Palette</DialogTitle>
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
|
|
|
|||
|
|
@ -348,6 +348,19 @@
|
|||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Bottom navigation height token — used for full-bleed pages (map, etc.) */
|
||||
:root {
|
||||
/* Mobile header: 3.5rem (56px) */
|
||||
--header-mobile: 3.5rem;
|
||||
/* Mobile bottom nav: 58px base + safe area */
|
||||
--bottom-nav: calc(3.625rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Full-bleed page — removes Layout bottom padding so the page can manage its own height */
|
||||
.page-fullbleed > main {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ import {
|
|||
Navigation,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
LocateFixed,
|
||||
LocateOff,
|
||||
} from 'lucide-react';
|
||||
import { CameraCapture } from '@/components/CameraCapture';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
|
|
@ -389,8 +391,8 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label:
|
|||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
|
||||
{/* Menu — fixed so it never clips behind the Sheet stacking context */}
|
||||
<div className="fixed bottom-[30vh] left-1/2 -translate-x-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-72 backdrop-blur">
|
||||
{/* Menu — fixed, centered, never clips behind Sheet stacking context */}
|
||||
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-[min(18rem,calc(100vw-2rem))] backdrop-blur animate-in fade-in zoom-in-95 duration-150">
|
||||
<div className="px-4 py-3 border-b border-white/8">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
|
||||
</div>
|
||||
|
|
@ -582,18 +584,57 @@ export default function MapPage() {
|
|||
// Near Me fly-to state
|
||||
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||||
const [nearMeLoading, setNearMeLoading] = useState(false);
|
||||
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
|
||||
|
||||
const handleNearMe = () => {
|
||||
if (!navigator.geolocation) return;
|
||||
// Clear previous error
|
||||
setNearMeError(null);
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
setNearMeError('unavailable');
|
||||
toast({
|
||||
title: 'Location not available',
|
||||
description: 'Your browser does not support geolocation. Try Chrome or Safari.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setNearMeLoading(true);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 });
|
||||
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 });
|
||||
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
|
||||
setNearMeLoading(false);
|
||||
setNearMeError(null);
|
||||
},
|
||||
() => setNearMeLoading(false),
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
(err) => {
|
||||
setNearMeLoading(false);
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
setNearMeError('denied');
|
||||
toast({
|
||||
title: 'Location access denied',
|
||||
description: 'To find spots near you, allow location access in your browser settings, then try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else if (err.code === err.TIMEOUT) {
|
||||
setNearMeError('timeout');
|
||||
toast({
|
||||
title: 'Location timed out',
|
||||
description: 'Could not get your location in time. Check your signal and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
setNearMeError('unavailable');
|
||||
toast({
|
||||
title: 'Location unavailable',
|
||||
description: 'Your location could not be determined. Make sure location services are enabled.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -842,10 +883,10 @@ export default function MapPage() {
|
|||
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{/* Account for mobile header (56px) + bottom nav (~72px+safe-area) on mobile, desktop header (64px) only on desktop */}
|
||||
{/* Using 100dvh for mobile to handle iOS Safari URL bar correctly */}
|
||||
<div className="h-[calc(100dvh-3.5rem-72px)] md:h-[calc(100vh-4rem)] flex flex-col">
|
||||
<Layout noPadding>
|
||||
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
|
||||
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
|
||||
<div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container px-3 sm:px-4 py-2 sm:py-3">
|
||||
|
|
@ -1401,13 +1442,21 @@ export default function MapPage() {
|
|||
onClick={handleNearMe}
|
||||
disabled={nearMeLoading}
|
||||
size="sm"
|
||||
className="h-10 w-10 rounded-full bg-black/80 hover:bg-black border border-white/20 shadow-lg backdrop-blur p-0"
|
||||
title="Near me"
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-full shadow-lg backdrop-blur p-0 border transition-all active:scale-90 touch-manipulation",
|
||||
nearMeError === 'denied'
|
||||
? "bg-red-900/80 hover:bg-red-800/90 border-red-500/40"
|
||||
: "bg-black/80 hover:bg-black/95 border-white/20"
|
||||
)}
|
||||
title={nearMeError === 'denied' ? 'Location access denied — tap for help' : 'Find spots near me'}
|
||||
aria-label="Find spots near my location"
|
||||
>
|
||||
{nearMeLoading ? (
|
||||
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||
) : nearMeError === 'denied' ? (
|
||||
<LocateOff className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
<Target className="h-4 w-4 text-[#ccff00]" />
|
||||
<LocateFixed className="h-4 w-4 text-[#ccff00]" />
|
||||
)}
|
||||
</Button>
|
||||
{/* Map attribution */}
|
||||
|
|
@ -1424,8 +1473,8 @@ export default function MapPage() {
|
|||
|
||||
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
|
||||
<div className={cn(
|
||||
"absolute bottom-24 right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-opacity duration-150",
|
||||
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
"absolute bottom-20 right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-opacity duration-150",
|
||||
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
)}>
|
||||
{/* Layer filter pills — compact icon-only */}
|
||||
{[
|
||||
|
|
@ -1438,12 +1487,14 @@ export default function MapPage() {
|
|||
key={key}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full text-xs flex items-center justify-center shadow-md border transition-all',
|
||||
// 40×40 ensures 44pt Apple / 48dp Material touch target compliance
|
||||
'h-10 w-10 rounded-full text-base flex items-center justify-center shadow-md border transition-all touch-manipulation active:scale-90',
|
||||
active
|
||||
? `${activeColor} border-white/20 shadow-black/30`
|
||||
: 'bg-black/60 border-white/8 opacity-35'
|
||||
: 'bg-black/60 border-white/8 opacity-40'
|
||||
)}
|
||||
title={key}
|
||||
aria-label={`Toggle ${key}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -165,12 +165,15 @@ export default function SpotDetailPage() {
|
|||
};
|
||||
|
||||
const handleDirections = () => {
|
||||
if (coordinates) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
if (!coordinates) return;
|
||||
const { lat, lng } = coordinates;
|
||||
const label = encodeURIComponent(spot?.title ?? 'Skate Spot');
|
||||
// Use Apple Maps on iOS/macOS, Google Maps everywhere else
|
||||
const isApple = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent);
|
||||
const url = isApple
|
||||
? `https://maps.apple.com/?ll=${lat},${lng}&q=${label}&dirflg=d`
|
||||
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
|
@ -217,11 +220,11 @@ export default function SpotDetailPage() {
|
|||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8 overflow-x-hidden">
|
||||
<div className="container px-4 pt-4 pb-8 sm:py-8 overflow-x-hidden">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/map"
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Map
|
||||
|
|
@ -331,7 +334,7 @@ export default function SpotDetailPage() {
|
|||
<Button variant="outline" size="icon" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleDirections}>
|
||||
<Button variant="outline" size="icon" onClick={handleDirections} aria-label="Get directions">
|
||||
<Navigation className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* More options menu */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue