feat(frontend): add FilterChips primitive
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type FilterChipOption = { value: string; label: string };
|
||||||
|
|
||||||
|
type FilterChipsProps = {
|
||||||
|
/** Available chip options. Order is preserved. */
|
||||||
|
options: FilterChipOption[];
|
||||||
|
/** Currently selected value, or null for "all". */
|
||||||
|
value: string | null;
|
||||||
|
/** Receives the new value, or null when "Clear" is pressed. */
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
/** Optional eyebrow label rendered before the chips. */
|
||||||
|
eyebrow?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-select filter chips.
|
||||||
|
* Voice: hairline-bordered pills; the active one fills with the electric
|
||||||
|
* blue accent at low opacity and lifts a hairline brighter than its
|
||||||
|
* peers. A small "Clear" pill appears once a value is set.
|
||||||
|
*/
|
||||||
|
export function FilterChips({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
eyebrow,
|
||||||
|
className,
|
||||||
|
}: FilterChipsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Filter"
|
||||||
|
>
|
||||||
|
{eyebrow ? (
|
||||||
|
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isActive = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
onClick={() => onChange(isActive ? null : opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
|
||||||
|
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
isActive
|
||||||
|
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
|
||||||
|
: "bg-transparent text-muted-foreground ring-border/60 hover:text-foreground hover:ring-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{value !== null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
aria-label="Clear filter"
|
||||||
|
className={cn(
|
||||||
|
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
"text-muted-foreground hover:text-foreground",
|
||||||
|
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user