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:
parent
b5a6741d45
commit
7b491d86d2
3 changed files with 182 additions and 87 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue