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