feat: convert sport filter to multi-select with mobile bottom sheet (#51)

Replaces the single-sport native <select> with a popover (desktop) and
bottom sheet (mobile) checkbox multi-select. No sports selected shows all
participants; selecting one or more narrows the list. Trigger label adapts
to reflect current filter state and a Reset button appears when filters
are active.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-02 00:29:54 -08:00 committed by GitHub
parent 5f23885097
commit da0e0f2be8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 388 additions and 53 deletions

View file

@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
const defaultProps = {
participants: [
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },
{ id: "2", name: "Player B", sport: { id: "s2", name: "NBA" } },
{ id: "3", name: "Player C", sport: { id: "s1", name: "NFL" } },
],
searchQuery: "",
sportFilters: [] as string[],
hideDrafted: false,
hideIneligible: false,
hideCompletedSports: false,
uniqueSports: ["NBA", "NFL"],
draftedParticipantIds: new Set<string>(),
queue: [] as Array<{ id: string; participantId: string }>,
eligibility: null,
canPick: false,
hasTeam: false,
onSearchChange: vi.fn(),
onSportFiltersChange: vi.fn(),
onHideDraftedChange: vi.fn(),
onHideIneligibleChange: vi.fn(),
onHideCompletedSportsChange: vi.fn(),
onMakePick: vi.fn(),
onAddToQueue: vi.fn(),
onRemoveFromQueue: vi.fn(),
};
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom
// (CSS breakpoints are not applied). Click the first one to open the Sheet.
let user: ReturnType<typeof userEvent.setup>;
function clickFirstSportFilterTrigger(name: string | RegExp) {
return user.click(screen.getAllByRole("button", { name })[0]);
}
describe("AvailableParticipantsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
user = userEvent.setup();
});
describe("Rendering", () => {
it("renders all participants by name", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(screen.getAllByText("Player A").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player B").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player C").length).toBeGreaterThan(0);
});
it("renders search input", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(
screen.getByPlaceholderText("Search participants...")
).toBeInTheDocument();
});
it("renders the Show Drafted toggle", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(screen.getByText("Show Drafted")).toBeInTheDocument();
});
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
eligibility={{ eligibleSportIds: new Set(["s1"]), ineligibleReasons: {} }}
/>
);
expect(screen.getByText("Show Ineligible")).toBeInTheDocument();
expect(screen.getByText("Show drafted sports")).toBeInTheDocument();
});
it("does not render Show Ineligible when hasTeam=false", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={false}
eligibility={{ eligibleSportIds: new Set(), ineligibleReasons: {} }}
/>
);
expect(screen.queryByText("Show Ineligible")).not.toBeInTheDocument();
});
it("renders empty state when no participants", () => {
render(
<AvailableParticipantsSection {...defaultProps} participants={[]} />
);
expect(screen.getAllByText("No participants found.").length).toBeGreaterThan(0);
});
});
describe("Sport Filter - trigger label", () => {
it('shows "All Sports" in aria-label when no filters selected', () => {
render(<AvailableParticipantsSection {...defaultProps} />);
// Both mobile and desktop triggers share the same label
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: All Sports",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("shows sport name in aria-label when exactly one filter selected", () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: NFL",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("shows count in aria-label when multiple filters selected", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: 2 sports selected",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("does not render active-filter badges", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
expect(screen.queryByLabelText(/Remove .* filter/)).not.toBeInTheDocument();
});
});
describe("Sport Filter - sheet/popover", () => {
it("opens and shows checkboxes for each sport", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("NBA")).toBeInTheDocument();
expect(within(dialog).getByText("NFL")).toBeInTheDocument();
});
it("calls onSportFiltersChange with sport added when checkbox clicked", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByLabelText("NFL"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NFL"]);
});
it("calls onSportFiltersChange with sport removed when unchecked", async () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
await clickFirstSportFilterTrigger("Filter by sport: 2 sports selected");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByLabelText("NFL"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NBA"]);
});
it('does not show "Reset" when no filters active', async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
expect(within(dialog).queryByText("Reset")).not.toBeInTheDocument();
});
it('shows "Reset" button when filters exist', async () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
await clickFirstSportFilterTrigger("Filter by sport: NFL");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Reset")).toBeInTheDocument();
});
it('calls onSportFiltersChange([]) when "Reset" clicked', async () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
await clickFirstSportFilterTrigger("Filter by sport: NFL");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByText("Reset"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith([]);
});
it("Done button closes the sheet", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Done" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});

View file

@ -1,7 +1,21 @@
import { useMemo } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { ListPlus, ListX } from "lucide-react";
import { Checkbox } from "~/components/ui/checkbox";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/ui/popover";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
SheetFooter,
SheetClose,
} from "~/components/ui/sheet";
import { ListPlus, ListX, ChevronDown } from "lucide-react";
function getParticipantState(
participant: { id: string; sport: { id: string } },
@ -31,7 +45,7 @@ interface AvailableParticipantsSectionProps {
};
}>;
searchQuery: string;
sportFilter: string;
sportFilters: string[];
hideDrafted: boolean;
hideIneligible: boolean;
hideCompletedSports: boolean;
@ -45,7 +59,7 @@ interface AvailableParticipantsSectionProps {
canPick: boolean;
hasTeam: boolean;
onSearchChange: (query: string) => void;
onSportFilterChange: (sport: string) => void;
onSportFiltersChange: (sports: string[]) => void;
onHideDraftedChange: (hide: boolean) => void;
onHideIneligibleChange: (hide: boolean) => void;
onHideCompletedSportsChange: (hide: boolean) => void;
@ -57,7 +71,7 @@ interface AvailableParticipantsSectionProps {
export function AvailableParticipantsSection({
participants,
searchQuery,
sportFilter,
sportFilters,
hideDrafted,
hideIneligible,
hideCompletedSports,
@ -68,7 +82,7 @@ export function AvailableParticipantsSection({
canPick,
hasTeam,
onSearchChange,
onSportFilterChange,
onSportFiltersChange,
onHideDraftedChange,
onHideIneligibleChange,
onHideCompletedSportsChange,
@ -81,15 +95,40 @@ export function AvailableParticipantsSection({
[queue]
);
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
const triggerText =
sportFilters.length === 0
? "All Sports"
: sportFilters.length === 1
? sportFilters[0]
: `${sportFilters.length} sports`;
const triggerAriaLabel =
sportFilters.length === 0
? "Filter by sport: All Sports"
: sportFilters.length === 1
? `Filter by sport: ${sportFilters[0]}`
: `Filter by sport: ${sportFilters.length} sports selected`;
function handleToggleSport(sport: string, checked: boolean | "indeterminate") {
if (checked === true) {
onSportFiltersChange([...sportFilters, sport]);
} else {
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
}
}
const emptyMessage = useMemo(() => {
if (participants.length > 0) return null;
const active: string[] = [];
if (hideDrafted) active.push("drafted players");
if (hideIneligible && eligibility) active.push("ineligible players");
if (hideCompletedSports) active.push("drafted sports");
if (sportFilters.length > 0) active.push("other sports");
if (active.length === 0) return "No participants found.";
return `No participants found. Try showing ${active.join(", ")}.`;
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility]);
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]);
return (
<div className="flex flex-col h-full">
@ -105,19 +144,109 @@ export function AvailableParticipantsSection({
/>
<div className="flex flex-wrap gap-2">
{/* Sport Filter Dropdown */}
<select
value={sportFilter}
onChange={(e) => onSportFilterChange(e.target.value)}
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) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
{/* Sport Filter Multi-Select */}
<div className="flex-1 min-w-[120px]">
{/* Mobile: bottom sheet */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full justify-between text-base font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
<div className="overflow-y-auto flex-1 px-4">
{uniqueSports.map((sport) => {
const checkboxId = `sheet-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`;
return (
<label
key={sport}
htmlFor={checkboxId}
className="flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
>
<Checkbox
id={checkboxId}
checked={sportFilterSet.has(sport)}
onCheckedChange={(checked) => handleToggleSport(sport, checked)}
/>
{sport}
</label>
);
})}
</div>
<SheetFooter className="px-4 pb-8 flex-row gap-2">
{sportFilters.length > 0 && (
<Button
variant="outline"
className="flex-1"
onClick={() => onSportFiltersChange([])}
>
Reset
</Button>
)}
<SheetClose asChild>
<Button className="flex-1">Done</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
{/* Desktop: popover */}
<div className="hidden md:block">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full justify-between text-sm font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
{sportFilters.length > 0 && (
<div className="flex justify-end px-2 pt-2">
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={() => onSportFiltersChange([])}
>
Reset
</Button>
</div>
)}
<div className="max-h-64 overflow-y-auto p-2 space-y-0.5">
{uniqueSports.map((sport) => {
const checkboxId = `popover-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`;
return (
<label
key={sport}
htmlFor={checkboxId}
className="flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm"
>
<Checkbox
id={checkboxId}
checked={sportFilterSet.has(sport)}
onCheckedChange={(checked) => handleToggleSport(sport, checked)}
/>
{sport}
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Show/Hide Drafted Toggle */}
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
@ -156,6 +285,7 @@ export function AvailableParticipantsSection({
</label>
)}
</div>
</div>
</div>

View file

@ -434,7 +434,7 @@ export default function DraftRoom() {
const [hideDrafted, setHideDrafted] = useState(true);
const [hideIneligible, setHideIneligible] = useState(true);
const [hideCompletedSports, setHideCompletedSports] = useState(false);
const [sportFilter, setSportFilter] = useState<string>("all");
const [sportFilters, setSportFilters] = useState<string[]>([]);
const [queue, setQueue] = useState<QueueItem[]>(userQueue);
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
const [isDraftComplete, setIsDraftComplete] = useState(
@ -1275,45 +1275,44 @@ export default function DraftRoom() {
);
// Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(
() =>
availableParticipants.filter((participant: any) => {
// Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
const filteredParticipants = useMemo(() => {
const sportFilterSet = new Set(sportFilters);
return availableParticipants.filter((participant: any) => {
// Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
return false;
}
// Eligibility filter - hide ineligible participants by default (if user has a team)
if (hideIneligible && eligibility) {
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
if (!isEligible) {
return false;
}
// Eligibility filter - hide ineligible participants by default (if user has a team)
if (hideIneligible && eligibility) {
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
if (!isEligible) {
return false;
}
}
// Hide completed sports filter - hide participants from sports already drafted by user's team
if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) {
return false;
}
// Search filter
if (
searchQuery &&
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Sport filter
if (sportFilter !== "all" && participant.sport.name !== sportFilter) {
return false;
}
return true;
}),
[availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilter]
);
}
// Hide completed sports filter - hide participants from sports already drafted by user's team
if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) {
return false;
}
// Search filter
if (
searchQuery &&
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Sport filter
if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) {
return false;
}
return true;
});
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]);
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
const availableParticipantsSectionProps = {
participants: filteredParticipants,
searchQuery,
sportFilter,
sportFilters,
hideDrafted,
hideIneligible,
uniqueSports,
@ -1323,7 +1322,7 @@ export default function DraftRoom() {
canPick,
hasTeam: !!userTeam,
onSearchChange: setSearchQuery,
onSportFilterChange: setSportFilter,
onSportFiltersChange: setSportFilters,
onHideDraftedChange: setHideDrafted,
onHideIneligibleChange: setHideIneligible,
hideCompletedSports,