From d2a1ed35f30851de6d4745cbc5261f4cc495d537 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 1 Oct 2026 15:18:25 +0200 Subject: [PATCH] feat: replace the users page trait selectors with cycling pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each trait filter is now one pill that cycles any, has it, and does not have it on click (Shift-click goes back), with a / + − badge and gray, green, and red tints. A small legend sits under the pills, and Clear all moves next to them. The filter values in the URL are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/routes/users/index.tsx | 208 +++++++++++++++++++++++++------------ 1 file changed, 139 insertions(+), 69 deletions(-) diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index d5c6be3..44587e4 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -1,6 +1,17 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { ChevronLeft, ChevronRight, Info, Search, Send, Users, X } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + Info, + Minus, + Plus, + Search, + Send, + Slash, + Users, + X, +} from "lucide-react"; import { cn } from "cn"; import { type UserListPage, @@ -40,59 +51,103 @@ function formatDate(value: string) { return new Date(value).toLocaleDateString(undefined, { dateStyle: "medium" }); } -/** Any, only people who have it, or only people who do not. */ +type Presence = "yes" | "no" | undefined; + +/** The order a trait pill steps through on each click. */ +const PRESENCE_CYCLE = [undefined, "yes", "no"] as const; + +const PRESENCE_STATES = { + any: { + Glyph: Slash, + label: "any", + short: "any", + pill: "border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground", + glyph: "bg-muted text-muted-foreground", + }, + yes: { + Glyph: Plus, + label: "has it", + short: "with", + pill: "border-emerald-600/40 bg-emerald-600/10 text-emerald-700 dark:border-emerald-400/40 dark:text-emerald-300", + glyph: "bg-emerald-600 text-white dark:bg-emerald-500", + }, + no: { + Glyph: Minus, + label: "does not have it", + short: "without", + pill: "border-destructive/40 bg-destructive/10 text-destructive dark:border-red-400/40 dark:text-red-300", + glyph: "bg-destructive text-white", + }, +} as const; + +const presenceState = (value: Presence) => PRESENCE_STATES[value ?? "any"]; + +/** Steps forward through any, has it, and does not have it; backward with Shift. */ +function cyclePresence(value: Presence, backward: boolean): Presence { + const index = PRESENCE_CYCLE.indexOf(value); + const step = backward ? PRESENCE_CYCLE.length - 1 : 1; + return PRESENCE_CYCLE[(index + step) % PRESENCE_CYCLE.length]; +} + +/** A pill that cycles through any, only people who have it, or only people who do not. */ function TraitFilter({ trait, value, onChange, }: { trait: (typeof USER_TRAITS)[number]; - value: "yes" | "no" | undefined; - onChange: (value: "yes" | "no" | undefined) => void; + value: Presence; + onChange: (value: Presence) => void; }) { const Icon = TRAIT_ICONS[trait.key]; - const options = [ - { value: undefined, label: "Any" }, - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ] as const; + const state = presenceState(value); + const next = presenceState(cyclePresence(value, false)); return ( -
-

-

-
onChange(cyclePresence(value, event.shiftKey))} + aria-label={`${trait.label}: ${state.label}`} + title={`${trait.description}. Click for “${next.label}”, Shift-click to go back.`} + className={cn( + "inline-flex h-7 items-center gap-1.5 rounded-full border py-0 pr-2.5 pl-1 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + state.pill, + )} + > +
-
+ + +