fix: prevent iOS Safari viewport zoom on draft room form inputs (#45)

Add text-base md:text-sm to all native <input> and <select> elements
in the draft room (including the ShadCN SelectTrigger) so iOS Safari
doesn't auto-zoom when focused.

Also extract the two near-duplicate participant selection dialogs into
a shared ParticipantSelectionDialog component (-236 lines), and replace
O(n) queue.find() scans with a memoized Map in AvailableParticipantsSection.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-27 23:46:09 -08:00 committed by GitHub
parent 4e447c30ab
commit 2df47658fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 228 additions and 276 deletions

View file

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { ListPlus, ListX } from "lucide-react";
@ -5,14 +6,14 @@ import { ListPlus, ListX } from "lucide-react";
function getParticipantState(
participant: { id: string; sport: { id: string } },
draftedParticipantIds: Set<string>,
queue: Array<{ participantId: string }>,
queueMap: Map<string, string>,
eligibility: {
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, string>;
} | null
) {
const isDrafted = draftedParticipantIds.has(participant.id);
const isInQueue = queue.some((item) => item.participantId === participant.id);
const isInQueue = queueMap.has(participant.id);
const isEligible = eligibility
? eligibility.eligibleSportIds.has(participant.sport.id)
: true;
@ -71,6 +72,11 @@ export function AvailableParticipantsSection({
onAddToQueue,
onRemoveFromQueue,
}: AvailableParticipantsSectionProps) {
const queueMap = useMemo(
() => new Map(queue.map((item) => [item.participantId, item.id])),
[queue]
);
return (
<div className="flex flex-col h-full">
<div className="px-4 pt-4 pb-2 flex-shrink-0">
@ -81,7 +87,7 @@ export function AvailableParticipantsSection({
placeholder="Search participants..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-sm bg-background text-foreground"
className="w-full px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
/>
<div className="flex flex-wrap gap-2">
@ -89,7 +95,7 @@ export function AvailableParticipantsSection({
<select
value={sportFilter}
onChange={(e) => onSportFilterChange(e.target.value)}
className="flex-1 min-w-[120px] px-3 py-2 border rounded-md text-sm bg-background text-foreground"
className="flex-1 min-w-[120px] px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
@ -135,7 +141,7 @@ export function AvailableParticipantsSection({
) : (
participants.map((participant) => {
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queue, eligibility);
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
return (
<div
@ -198,12 +204,8 @@ export function AvailableParticipantsSection({
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => {
const queueItem = queue.find(
(item) => item.participantId === participant.id
);
if (queueItem) {
onRemoveFromQueue(queueItem.id);
}
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>
@ -260,7 +262,7 @@ export function AvailableParticipantsSection({
) : (
participants.map((participant) => {
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
getParticipantState(participant, draftedParticipantIds, queue, eligibility);
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
return (
<tr
@ -332,13 +334,8 @@ export function AvailableParticipantsSection({
variant="ghost"
size="sm"
onClick={() => {
const queueItem = queue.find(
(item) =>
item.participantId === participant.id
);
if (queueItem) {
onRemoveFromQueue(queueItem.id);
}
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
title="Remove from queue"
>

View file

@ -0,0 +1,188 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
interface ParticipantSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
participants: Array<{
id: string;
name: string;
sport: { id: string; name: string };
}>;
draftedParticipantIds: Set<string>;
/** Pass the old participant's ID to keep them in the list (replace-pick dialog) */
allowParticipantId?: string;
eligibility: {
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, string>;
} | null;
/** Marks a row with "Current" badge and "Keep" button label */
currentParticipantId?: string;
uniqueSports: string[];
onSelect: (participantId: string) => void;
}
export function ParticipantSelectionDialog({
open,
onOpenChange,
title,
description,
participants,
draftedParticipantIds,
allowParticipantId,
eligibility,
currentParticipantId,
uniqueSports,
onSelect,
}: ParticipantSelectionDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [sportFilter, setSportFilter] = useState("all");
// Reset filters each time the dialog opens
useEffect(() => {
if (open) {
setSearchQuery("");
setSportFilter("all");
}
}, [open]);
const filtered = useMemo(
() =>
participants.filter((p) => {
if (p.id !== allowParticipantId && draftedParticipantIds.has(p.id))
return false;
if (
searchQuery &&
!p.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return false;
if (sportFilter !== "all" && p.sport.name !== sportFilter) return false;
return true;
}),
[participants, draftedParticipantIds, allowParticipantId, searchQuery, sportFilter]
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="p-6 pb-4">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
value={sportFilter}
onChange={(e) => setSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{filtered.map((participant) => {
const isCurrentPick =
participant.id === currentParticipantId;
const isEligible = eligibility
? eligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason =
eligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible
? "hover:bg-muted/50"
: "bg-destructive/10 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
{isCurrentPick && (
<Badge variant="outline" className="text-xs">
Current
</Badge>
)}
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() => onSelect(participant.id)}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
{isCurrentPick ? "Keep" : "Draft"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DialogFooter className="px-6 pb-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -19,6 +19,7 @@ import { AvailableParticipantsSection } from "~/components/draft/AvailablePartic
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getTeamForPick } from "~/lib/draft-order";
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
@ -374,9 +375,6 @@ export default function DraftRoom() {
pickNumber: number;
teamId: string;
} | null>(null);
const [dialogSearchQuery, setDialogSearchQuery] = useState("");
const [dialogSportFilter, setDialogSportFilter] = useState<string>("all");
// Replace pick dialog state
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
const [replacePickSlot, setReplacePickSlot] = useState<{
@ -897,8 +895,6 @@ export default function DraftRoom() {
return;
}
setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id });
setDialogSearchQuery("");
setDialogSportFilter("all");
setReplacePickDialogOpen(true);
};
@ -1012,8 +1008,6 @@ export default function DraftRoom() {
const handleForceManualPickOpen = (pickNumber: number, teamId: string) => {
setSelectedPickSlot({ pickNumber, teamId });
setDialogSearchQuery("");
setDialogSportFilter("all");
setForcePickDialogOpen(true);
};
@ -1083,41 +1077,6 @@ export default function DraftRoom() {
[availableParticipants]
);
// Participants filtered for the force pick dialog (always excludes drafted, uses dialog-local filters)
const dialogFilteredParticipants = useMemo(
() =>
availableParticipants.filter((p: any) => {
if (draftedParticipantIds.has(p.id)) return false;
if (
dialogSearchQuery &&
!p.name.toLowerCase().includes(dialogSearchQuery.toLowerCase())
)
return false;
if (dialogSportFilter !== "all" && p.sport.name !== dialogSportFilter)
return false;
return true;
}),
[availableParticipants, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]
);
// Participants for the replace pick dialog — excludes drafted except the old pick (which is freed)
const replaceDialogFilteredParticipants = useMemo(() => {
if (!replacePickSlot) return [];
return availableParticipants.filter((p: any) => {
// Exclude drafted players, but allow the old participant through since they'll be freed
if (p.id !== replacePickSlot.oldParticipantId && draftedParticipantIds.has(p.id))
return false;
if (
dialogSearchQuery &&
!p.name.toLowerCase().includes(dialogSearchQuery.toLowerCase())
)
return false;
if (dialogSportFilter !== "all" && p.sport.name !== dialogSportFilter)
return false;
return true;
});
}, [availableParticipants, replacePickSlot, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]);
// Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(
() =>
@ -1524,229 +1483,37 @@ export default function DraftRoom() {
})}
</nav>
{/* Force Manual Pick Dialog */}
<Dialog
<ParticipantSelectionDialog
open={forcePickDialogOpen && !!selectedPickSlot}
onOpenChange={(open) => {
setForcePickDialogOpen(open);
if (!open) setSelectedPickSlot(null);
}}
>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Force Manual Pick</DialogTitle>
<DialogDescription>
Select a participant to draft for Pick #{selectedPickSlot?.pickNumber}
</DialogDescription>
</DialogHeader>
title="Force Manual Pick"
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
participants={availableParticipants}
draftedParticipantIds={draftedParticipantIds}
eligibility={forcePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleForceManualPick}
/>
<div className="px-6 pb-4">
{/* Search and Filter */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground"
value={dialogSearchQuery}
onChange={(e) => setDialogSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md bg-background text-foreground"
value={dialogSportFilter}
onChange={(e) => setDialogSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
{/* Participant List */}
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{dialogFilteredParticipants.map((participant: any) => {
const isEligible = forcePickEligibility
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible ? "hover:bg-muted/50" : "bg-destructive/10 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() => handleForceManualPick(participant.id)}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
Draft
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DialogFooter className="px-6 pb-6">
<Button variant="outline" onClick={() => setForcePickDialogOpen(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Replace Pick Dialog */}
<Dialog
<ParticipantSelectionDialog
open={replacePickDialogOpen && !!replacePickSlot}
onOpenChange={(open) => {
setReplacePickDialogOpen(open);
if (!open) setReplacePickSlot(null);
}}
>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Replace Pick</DialogTitle>
<DialogDescription>
Select a participant to replace the current pick at slot #{replacePickSlot?.pickNumber}
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground"
value={dialogSearchQuery}
onChange={(e) => setDialogSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md bg-background text-foreground"
value={dialogSportFilter}
onChange={(e) => setDialogSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{replaceDialogFilteredParticipants.map((participant: any) => {
const isCurrentPick = participant.id === replacePickSlot?.oldParticipantId;
const isEligible = replacePickEligibility
? replacePickEligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = replacePickEligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible ? "hover:bg-muted/50" : "bg-destructive/10 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{isCurrentPick && (
<Badge variant="outline" className="text-xs">
Current
</Badge>
)}
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() => handleReplacePick(participant.id)}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
{isCurrentPick ? "Keep" : "Draft"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DialogFooter className="px-6 pb-6">
<Button variant="outline" onClick={() => setReplacePickDialogOpen(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
title="Replace Pick"
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
participants={availableParticipants}
draftedParticipantIds={draftedParticipantIds}
allowParticipantId={replacePickSlot?.oldParticipantId}
currentParticipantId={replacePickSlot?.oldParticipantId}
eligibility={replacePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleReplacePick}
/>
{/* Rollback Confirmation Dialog */}
<Dialog
@ -1828,7 +1595,7 @@ export default function DraftRoom() {
step="any"
value={timeBankAmount}
onChange={(e) => setTimeBankAmount(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-sm"
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
placeholder="Amount"
/>
<select
@ -1836,7 +1603,7 @@ export default function DraftRoom() {
onChange={(e) =>
setTimeBankUnit(e.target.value as "seconds" | "minutes" | "hours")
}
className="px-3 py-2 border rounded-md bg-background text-foreground text-sm"
className="px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
>
<option value="seconds">Seconds</option>
<option value="minutes">Minutes</option>