refactor: integrate sport filter dropdown with drafted sports toggle (#53)

- Move "Show drafted sports" into the sport filter dropdown (sheet/popover)
  instead of a standalone checkbox in the filter bar
- When unchecked, drafted sports are hidden from the dropdown list entirely;
  when checked, they appear in alphabetical order with muted text
- Auto-deselect drafted sport filters when hiding drafted sports
- Reset button also restores "Show drafted sports" to checked
- Extract SportFilterContent component to eliminate sheet/popover duplication
- Make userDraftedSportNames required; add useCallback for handlers;
  memoize triggerText/triggerAriaLabel; unify all checkboxes to ShadCN Checkbox;
  replace empty div divider with hr

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-02 10:13:51 -08:00 committed by GitHub
parent b5a6741d45
commit 7b491d86d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 182 additions and 87 deletions

View file

@ -15,6 +15,7 @@ const defaultProps = {
hideIneligible: false,
hideCompletedSports: false,
uniqueSports: ["NBA", "NFL"],
userDraftedSportNames: new Set<string>(),
draftedParticipantIds: new Set<string>(),
queue: [] as Array<{ id: string; participantId: string }>,
eligibility: null,
@ -64,7 +65,7 @@ describe("AvailableParticipantsSection", () => {
expect(screen.getByText("Show Drafted")).toBeInTheDocument();
});
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => {
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", async () => {
render(
<AvailableParticipantsSection
{...defaultProps}
@ -73,7 +74,10 @@ describe("AvailableParticipantsSection", () => {
/>
);
expect(screen.getByText("Show Ineligible")).toBeInTheDocument();
expect(screen.getByText("Show drafted sports")).toBeInTheDocument();
// Show drafted sports lives inside the sport filter dropdown
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Show drafted sports")).toBeInTheDocument();
});
it("does not render Show Ineligible when hasTeam=false", () => {
@ -185,7 +189,7 @@ describe("AvailableParticipantsSection", () => {
expect(within(dialog).getByText("Reset")).toBeInTheDocument();
});
it('calls onSportFiltersChange([]) when "Reset" clicked', async () => {
it('calls onSportFiltersChange([]) and onHideCompletedSportsChange(false) when "Reset" clicked', async () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
@ -193,6 +197,7 @@ describe("AvailableParticipantsSection", () => {
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByText("Reset"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith([]);
expect(defaultProps.onHideCompletedSportsChange).toHaveBeenCalledWith(false);
});
it("Done button closes the sheet", async () => {

View file

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
@ -35,6 +35,92 @@ function getParticipantState(
return { isDrafted, isInQueue, isEligible, ineligibleReason };
}
interface SportFilterContentProps {
sportsForDropdown: Array<{ name: string; isDrafted: boolean }>;
sportFilterSet: Set<string>;
hideCompletedSports: boolean;
hasTeam: boolean;
variant: "sheet" | "popover";
onToggleSport: (sport: string, checked: boolean | "indeterminate") => void;
onHideCompletedSportsChange: (hide: boolean) => void;
}
function SportFilterContent({
sportsForDropdown,
sportFilterSet,
hideCompletedSports,
hasTeam,
variant,
onToggleSport,
onHideCompletedSportsChange,
}: SportFilterContentProps) {
const isSheet = variant === "sheet";
const idPrefix = isSheet ? "sheet-sport-filter-" : "popover-sport-filter-";
const listClass = isSheet
? "overflow-y-auto flex-1 px-4"
: "max-h-64 overflow-y-auto p-2 space-y-0.5";
const itemBaseClass = isSheet
? "flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
: "flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm";
const toggleLabelClass = isSheet
? "flex items-center gap-4 py-2 cursor-pointer text-base"
: "flex items-center gap-2 px-4 py-3 cursor-pointer text-sm";
return (
<>
<div className={listClass}>
{sportsForDropdown
.filter(({ isDrafted }) => !isDrafted || !hideCompletedSports)
.map(({ name, isDrafted }) => {
const checkboxId = `${idPrefix}${name.replace(/\s+/g, "-").toLowerCase()}`;
return (
<label
key={name}
htmlFor={checkboxId}
className={`${itemBaseClass}${isDrafted ? " text-muted-foreground" : ""}`}
>
<Checkbox
id={checkboxId}
checked={sportFilterSet.has(name)}
onCheckedChange={(checked) => onToggleSport(name, checked)}
/>
{name}
</label>
);
})}
</div>
{hasTeam && (
isSheet ? (
<div className="px-4 border-t pt-3">
<label className={toggleLabelClass}>
<Checkbox
checked={!hideCompletedSports}
onCheckedChange={(checked) =>
onHideCompletedSportsChange(checked === false)
}
/>
<span>Show drafted sports</span>
</label>
</div>
) : (
<>
<hr className="mx-2 border-border" />
<label className={toggleLabelClass}>
<Checkbox
checked={!hideCompletedSports}
onCheckedChange={(checked) =>
onHideCompletedSportsChange(checked === false)
}
/>
<span>Show drafted sports</span>
</label>
</>
)
)}
</>
);
}
interface AvailableParticipantsSectionProps {
participants: Array<{
id: string;
@ -49,6 +135,7 @@ interface AvailableParticipantsSectionProps {
hideDrafted: boolean;
hideIneligible: boolean;
hideCompletedSports: boolean;
userDraftedSportNames: Set<string>;
uniqueSports: string[];
draftedParticipantIds: Set<string>;
queue: Array<{ id: string; participantId: string }>;
@ -75,6 +162,7 @@ export function AvailableParticipantsSection({
hideDrafted,
hideIneligible,
hideCompletedSports,
userDraftedSportNames,
uniqueSports,
draftedParticipantIds,
queue,
@ -97,27 +185,48 @@ export function AvailableParticipantsSection({
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
const triggerText =
sportFilters.length === 0
? "All Sports"
: sportFilters.length === 1
? sportFilters[0]
: `${sportFilters.length} sports`;
const sportsForDropdown = useMemo(() => {
return uniqueSports.map((s) => ({
name: s,
isDrafted: hasTeam && userDraftedSportNames.has(s),
}));
}, [uniqueSports, userDraftedSportNames, hasTeam]);
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`;
const triggerText = useMemo(
() =>
sportFilters.length === 0
? "All Sports"
: sportFilters.length === 1
? sportFilters[0]
: `${sportFilters.length} sports`,
[sportFilters]
);
function handleToggleSport(sport: string, checked: boolean | "indeterminate") {
if (checked === true) {
onSportFiltersChange([...sportFilters, sport]);
} else {
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
}
}
const triggerAriaLabel = useMemo(
() =>
sportFilters.length === 0
? "Filter by sport: All Sports"
: sportFilters.length === 1
? `Filter by sport: ${sportFilters[0]}`
: `Filter by sport: ${sportFilters.length} sports selected`,
[sportFilters]
);
const handleToggleSport = useCallback(
(sport: string, checked: boolean | "indeterminate") => {
if (checked === true) {
onSportFiltersChange([...sportFilters, sport]);
} else {
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
}
},
[sportFilters, onSportFiltersChange]
);
const handleReset = useCallback(() => {
onSportFiltersChange([]);
onHideCompletedSportsChange(false);
}, [onSportFiltersChange, onHideCompletedSportsChange]);
const emptyMessage = useMemo(() => {
if (participants.length > 0) return null;
@ -130,6 +239,8 @@ export function AvailableParticipantsSection({
return `No participants found. Try showing ${active.join(", ")}.`;
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]);
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
return (
<div className="flex flex-col h-full">
<div className="px-4 pt-4 pb-2 flex-shrink-0">
@ -161,31 +272,21 @@ export function AvailableParticipantsSection({
</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>
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="sheet"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
<SheetFooter className="px-4 pb-8 flex-row gap-2">
{sportFilters.length > 0 && (
{hasActiveFilters && (
<Button
variant="outline"
className="flex-1"
onClick={() => onSportFiltersChange([])}
onClick={handleReset}
>
Reset
</Button>
@ -212,37 +313,27 @@ export function AvailableParticipantsSection({
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
{sportFilters.length > 0 && (
{hasActiveFilters && (
<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([])}
onClick={handleReset}
>
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>
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="popover"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
</PopoverContent>
</Popover>
</div>
@ -250,11 +341,9 @@ export function AvailableParticipantsSection({
{/* Show/Hide Drafted Toggle */}
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<input
type="checkbox"
<Checkbox
checked={!hideDrafted}
onChange={(e) => onHideDraftedChange(!e.target.checked)}
className="rounded"
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
/>
<span>Show Drafted</span>
</label>
@ -262,28 +351,13 @@ export function AvailableParticipantsSection({
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
{hasTeam && eligibility && (
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<input
type="checkbox"
<Checkbox
checked={!hideIneligible}
onChange={(e) => onHideIneligibleChange(!e.target.checked)}
className="rounded"
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
/>
<span>Show Ineligible</span>
</label>
)}
{/* Show drafted sports - only show if user has a team */}
{hasTeam && (
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<input
type="checkbox"
checked={!hideCompletedSports}
onChange={(e) => onHideCompletedSportsChange(!e.target.checked)}
className="rounded"
/>
<span>Show drafted sports</span>
</label>
)}
</div>
</div>

View file

@ -1265,6 +1265,16 @@ export default function DraftRoom() {
);
}, [picks, userTeam]);
// Sport names where the current user's team has already made at least one pick
const userDraftedSportNames = useMemo(() => {
if (!userTeam) return new Set<string>();
return new Set(
picks
.filter((p: any) => p.team.id === userTeam.id)
.map((p: any) => p.sport.name)
);
}, [picks, userTeam]);
// Get unique sports for filter dropdown
const uniqueSports = useMemo(
() =>
@ -1326,7 +1336,13 @@ export default function DraftRoom() {
onHideDraftedChange: setHideDrafted,
onHideIneligibleChange: setHideIneligible,
hideCompletedSports,
onHideCompletedSportsChange: setHideCompletedSports,
userDraftedSportNames,
onHideCompletedSportsChange: (hide: boolean) => {
setHideCompletedSports(hide);
if (hide) {
setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s)));
}
},
onMakePick: handleMakePick,
onAddToQueue: handleAddToQueue,
onRemoveFromQueue: handleRemoveFromQueue,