Fix QA findings: dual panel mount, touch-action, font floor, a11y
- Replace CSS-only panel hiding with isDesktop media query guard in Todos, Reminders, People, Locations, ProjectDetail (W-01) - Add touch-action: manipulation for mobile interactive elements (W-04) - Bump FullCalendar more-link from 0.55rem to 0.625rem (W-07) - Add aria-label on admin portal tab NavLinks (S-05) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
98ad83ae5f
commit
89f72895c1
@ -35,6 +35,7 @@ export default function AdminPortal() {
|
|||||||
key={path}
|
key={path}
|
||||||
to={path}
|
to={path}
|
||||||
title={label}
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
'flex items-center justify-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
|
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -19,6 +20,7 @@ import LocationForm from './LocationForm';
|
|||||||
|
|
||||||
export default function LocationsPage() {
|
export default function LocationsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -373,20 +375,20 @@ export default function LocationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail panel (desktop) */}
|
{/* Detail panel (desktop) */}
|
||||||
|
{panelOpen && isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
|
||||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{renderPanel()}
|
{renderPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && selectedLocation && (
|
{panelOpen && selectedLocation && !isDesktop && (
|
||||||
<div
|
<div
|
||||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
onClick={() => setSelectedLocationId(null)}
|
onClick={() => setSelectedLocationId(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@ -214,6 +215,7 @@ const panelFields: PanelField[] = [
|
|||||||
export default function PeoplePage() {
|
export default function PeoplePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
|
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -762,20 +764,20 @@ export default function PeoplePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail panel (desktop) */}
|
{/* Detail panel (desktop) */}
|
||||||
|
{panelOpen && isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
|
||||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{renderPanel()}
|
{renderPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && selectedPerson && (
|
{panelOpen && selectedPerson && !isDesktop && (
|
||||||
<div
|
<div
|
||||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
onClick={() => setSelectedPersonId(null)}
|
onClick={() => setSelectedPersonId(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -257,6 +258,8 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}, [topLevelTasks, sortMode, sortSubtasks]);
|
}, [topLevelTasks, sortMode, sortSubtasks]);
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const selectedTask = useMemo(() => {
|
const selectedTask = useMemo(() => {
|
||||||
if (!selectedTaskId) return null;
|
if (!selectedTaskId) return null;
|
||||||
// Search top-level and subtasks
|
// Search top-level and subtasks
|
||||||
@ -628,11 +631,10 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: task detail (hidden on small screens) */}
|
{/* Right panel: task detail (desktop only) */}
|
||||||
|
{selectedTaskId && isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card flex w-[45%]"
|
||||||
selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden min-w-[360px]">
|
<div className="flex-1 overflow-hidden min-w-[360px]">
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
@ -645,12 +647,13 @@ export default function ProjectDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
||||||
{selectedTaskId && selectedTask && (
|
{selectedTaskId && selectedTask && !isDesktop && (
|
||||||
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||||
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@ -24,6 +25,8 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
|
|||||||
export default function RemindersPage() {
|
export default function RemindersPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
// Panel state
|
// Panel state
|
||||||
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
||||||
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||||
@ -216,10 +219,9 @@ export default function RemindersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail panel (desktop) */}
|
{/* Detail panel (desktop) */}
|
||||||
|
{panelOpen && isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
|
||||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<ReminderDetailPanel
|
<ReminderDetailPanel
|
||||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||||
@ -228,12 +230,13 @@ export default function RemindersPage() {
|
|||||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && (
|
{panelOpen && !isDesktop && (
|
||||||
<div
|
<div
|
||||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
onClick={handlePanelClose}
|
onClick={handlePanelClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@ -25,6 +26,8 @@ const priorityFilters = [
|
|||||||
export default function TodosPage() {
|
export default function TodosPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
// Panel state
|
// Panel state
|
||||||
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
||||||
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
||||||
@ -251,10 +254,9 @@ export default function TodosPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail panel (desktop) */}
|
{/* Detail panel (desktop) */}
|
||||||
|
{panelOpen && isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
|
||||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<TodoDetailPanel
|
<TodoDetailPanel
|
||||||
todo={panelMode === 'view' ? selectedTodo : null}
|
todo={panelMode === 'view' ? selectedTodo : null}
|
||||||
@ -263,12 +265,13 @@ export default function TodosPage() {
|
|||||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && (
|
{panelOpen && !isDesktop && (
|
||||||
<div
|
<div
|
||||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
onClick={handlePanelClose}
|
onClick={handlePanelClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -325,6 +325,13 @@ form[data-submitted] input:invalid + button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile touch optimisation ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
button, a, [role="button"], input, select, textarea {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── FullCalendar mobile overrides ── */
|
/* ── FullCalendar mobile overrides ── */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.fc .fc-daygrid-event {
|
.fc .fc-daygrid-event {
|
||||||
@ -361,7 +368,7 @@ form[data-submitted] input:invalid + button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-daygrid-more-link {
|
.fc .fc-daygrid-more-link {
|
||||||
font-size: 0.55rem;
|
font-size: 0.625rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ── Ambient background animations ── */
|
/* ── Ambient background animations ── */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user