Compare commits

...

2 Commits

Author SHA1 Message Date
4e91944956 Fix code review findings: sort dropdown, overlay ref, CalendarPage
- C-01: Simplify EntityTable sort dropdown to toggle-based (select
  column, re-select to flip direction), add aria-label
- W-01: Convert CalendarPage mobile overlay to MobileDetailOverlay
- W-02: Use ref for onClose in MobileDetailOverlay to prevent
  listener churn from inline arrow functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:46:40 +08:00
a737f06e85 Action deferred QA items: shared overlay, sort, touch, a11y
- S-01/W-06/S-02/S-04: Extract MobileDetailOverlay shared component
  with Escape key, body scroll lock, and ARIA dialog attributes.
  Refactored Todos, Reminders, People, Locations, ProjectDetail.
- W-02: Add specificity contract comment to mobile-scale CSS
- W-03: Enforce 10px floor for text-[9px] on mobile
- W-05: Add sort dropdown to EntityTable mobile card view
- S-03: Export MOBILE/DESKTOP breakpoint constants from useMediaQuery,
  updated all 8 consumer files to use constants
- S-06: Bump KanbanBoard TouchSensor tolerance from 5 to 8
- S-07: Hover state audit — no action needed, hoverOnlyWhenSupported
  in Tailwind config already handles touch devices correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:43:25 +08:00
12 changed files with 180 additions and 117 deletions

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -20,6 +20,7 @@ import { Select } from '@/components/ui/select';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import CalendarSidebar from './CalendarSidebar';
import EventDetailPanel from './EventDetailPanel';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
import type { CreateDefaults } from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@ -164,7 +165,7 @@ export default function CalendarPage() {
const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Continuously resize calendar during panel open/close CSS transition
@ -639,14 +640,7 @@ export default function CalendarPage() {
{/* Mobile detail panel overlay */}
{panelOpen && !isDesktop && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<MobileDetailOverlay open onClose={handlePanelClose} className="sm:max-w-[400px]">
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
@ -657,8 +651,7 @@ export default function CalendarPage() {
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/>
</div>
</div>
</MobileDetailOverlay>
)}
</div>
);

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -17,10 +17,11 @@ import {
import { useTableVisibility } from '@/hooks/useTableVisibility';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import LocationForm from './LocationForm';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
export default function LocationsPage() {
const queryClient = useQueryClient();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
@ -387,17 +388,9 @@ export default function LocationsPage() {
{/* Mobile detail panel overlay */}
{panelOpen && selectedLocation && !isDesktop && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedLocationId(null)}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<MobileDetailOverlay open={true} onClose={() => setSelectedLocationId(null)}>
{renderPanel()}
</div>
</div>
</MobileDetailOverlay>
)}
{showForm && (

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@ -27,6 +27,7 @@ import PersonForm from './PersonForm';
import ConnectionSearch from '@/components/connections/ConnectionSearch';
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
import { useConnections } from '@/hooks/useConnections';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
// ---------------------------------------------------------------------------
// StatCounter — inline helper
@ -215,7 +216,7 @@ const panelFields: PanelField[] = [
export default function PeoplePage() {
const queryClient = useQueryClient();
const tableContainerRef = useRef<HTMLDivElement>(null);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
@ -776,17 +777,9 @@ export default function PeoplePage() {
{/* Mobile detail panel overlay */}
{panelOpen && selectedPerson && !isDesktop && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedPersonId(null)}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<MobileDetailOverlay open={true} onClose={() => setSelectedPersonId(null)}>
{renderPanel()}
</div>
</div>
</MobileDetailOverlay>
)}
{showForm && (

View File

@ -154,7 +154,7 @@ export default function KanbanBoard({
}: KanbanBoardProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ,
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);
// Subtask view is driven by kanbanParentTask (decoupled from selected task)

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -39,6 +39,7 @@ import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
type SortMode = 'manual' | 'priority' | 'due_date';
type ViewMode = 'list' | 'kanban';
@ -258,7 +259,7 @@ export default function ProjectDetail() {
}
}, [topLevelTasks, sortMode, sortSubtasks]);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
const selectedTask = useMemo(() => {
if (!selectedTaskId) return null;
@ -653,8 +654,7 @@ export default function ProjectDetail() {
{/* Mobile: show detail panel as overlay when task selected on small screens */}
{selectedTaskId && selectedTask && !isDesktop && (
<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">
<MobileDetailOverlay open={true} onClose={() => setSelectedTaskId(null)}>
<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>
<Button
@ -675,8 +675,7 @@ export default function ProjectDetail() {
onSelectTask={setSelectedTaskId}
/>
</div>
</div>
</div>
</MobileDetailOverlay>
)}
{showTaskForm && (

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@ -13,6 +13,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import ReminderList from './ReminderList';
import ReminderDetailPanel from './ReminderDetailPanel';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
const statusFilters = [
{ value: 'active', label: 'Active' },
@ -25,7 +26,7 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() {
const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
@ -235,22 +236,14 @@ export default function RemindersPage() {
{/* Mobile detail panel overlay */}
{panelOpen && !isDesktop && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
</MobileDetailOverlay>
)}
</div>
);

View File

@ -1,7 +1,7 @@
import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronsUpDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery';
export interface ColumnDef<T> {
key: string;
@ -134,11 +134,30 @@ export function EntityTable<T extends { id: number }>({
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0;
const isMobile = useMediaQuery('(max-width: 767px)');
const isMobile = useMediaQuery(MOBILE);
const sortableColumns = columns.filter((col) => col.sortable);
if (isMobile && mobileCardRender) {
return (
<div className="space-y-2">
{sortableColumns.length > 0 && !loading && (
<div className="flex items-center gap-2 justify-end">
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
<select
value={sortKey}
onChange={(e) => onSort(e.target.value)}
aria-label="Sort by"
className="h-7 rounded-md border border-border bg-card px-2 text-xs text-foreground"
>
{sortableColumns.map((col) => (
<option key={col.key} value={col.key}>
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</option>
))}
</select>
</div>
)}
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />

View File

@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
interface MobileDetailOverlayProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
/**
* Full-screen overlay for mobile detail panels.
* - Backdrop click closes the overlay
* - Escape key closes the overlay
* - Body scroll is locked while open
*/
export default function MobileDetailOverlay({
open,
onClose,
children,
className,
}: MobileDetailOverlayProps) {
// Stable ref to avoid re-registering listener on every render
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Escape key handler
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open]);
// Body scroll lock
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
className={cn(
'absolute right-0 top-0 h-full w-full sm:max-w-md bg-card border-l border-border shadow-xl overflow-y-auto',
className,
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@ -11,6 +11,7 @@ import { Select } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import TodoList from './TodoList';
import TodoDetailPanel from './TodoDetailPanel';
@ -26,7 +27,7 @@ const priorityFilters = [
export default function TodosPage() {
const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery(DESKTOP);
// Panel state
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
@ -270,22 +271,14 @@ export default function TodosPage() {
{/* Mobile detail panel overlay */}
{panelOpen && !isDesktop && (
<div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
</MobileDetailOverlay>
)}
</div>
);

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { createPortal } from 'react-dom';
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery';
// ── Browser detection (stable — checked once at module load) ──
@ -128,7 +128,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
const isMobile = useMediaQuery('(max-width: 767px)');
const isMobile = useMediaQuery(MOBILE);
React.useImperativeHandle(ref, () => triggerRef.current!);

View File

@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
export const MOBILE = '(max-width: 767px)';
export const DESKTOP = '(min-width: 1024px)';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false

View File

@ -292,6 +292,12 @@ form[data-submitted] input:invalid + button {
}
/*
* Mobile font scaling overrides Tailwind text utilities below 768px.
* Applied via .mobile-scale class on <main> in AppLayout.tsx.
* These selectors rely on cascade order (loaded after Tailwind utilities).
* Arbitrary sizes like text-[9px] are NOT captured see W-03 override below.
*/
/* ── Global mobile content scaling ── */
/* Scales down all page content text on mobile. Navbar and UMBRA title are excluded
because they live outside .mobile-scale and use their own sizing. */
@ -323,6 +329,10 @@ form[data-submitted] input:invalid + button {
.mobile-scale .text-3xl {
font-size: 1.375rem; /* 22px */
}
/* W-03: Enforce 10px floor for arbitrary small sizes */
.mobile-scale [class*="text-\[9px\]"] {
font-size: 10px !important;
}
}
/* ── Mobile touch optimisation ── */