Structured filter bar for any list or table: a + Filter popover that slides property → operator → value (type-ahead fields, per-type operators, checkbox listbox, number range, and a range-selecting month calendar with presets), spring-in chips you edit in place part by part, an and/or toggle, saved views that crossfade chip sets with inline Save view, Clear all, and a result count rolling on Tally drums. Controlled or uncontrolled, keyboard-first (F, arrows, Backspace), with a pure applyFilters() helper to drive your rows.
@crucible
registry/default/app/sift/sift.tsx
"use client";
import { AnimatePresence, LayoutGroup, motion, useInView, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Tally } from "@/components/crucible/tally";
/* ================================================================ types */
/** How a field's values are compared and edited. */
export type SiftFieldType = "text" | "select" | "number" | "date" | "boolean";
/** One choice of a `select` field. */
export interface SiftOption {
/** Stored / compared value. */
value: string;
/** Visible label. @default value */
label?: string;
/** Optional status dot color (any CSS color). */
color?: string;
}
/** A filterable property of your rows. */
export interface SiftField {
/** Row key read by `applyFilters` (`row[key]`). */
key: string;
/** Visible name in the property list and on chips. */
label: string;
/** Decides the operators and the value editor. */
type: SiftFieldType;
/** Choices for `select` fields (strings or `{ value, label, color }`). */
options?: (string | SiftOption)[];
/** 14px glyph (uses `currentColor`). A type glyph is used when omitted. */
icon?: React.ReactNode;
}
/**
* Comparison operator. Per field type:
* text `contains | is | is-not | starts-with`; select `any-of | none-of`;
* number `eq | neq | gt | lt | between`; date `is | before | after | between`;
* boolean `is-true | is-false`.
*/
export type SiftOperator =
| "contains"
| "is"
| "is-not"
| "starts-with"
| "any-of"
| "none-of"
| "eq"
| "neq"
| "gt"
| "lt"
| "between"
| "before"
| "after"
| "is-true"
| "is-false";
/** Relative date ranges, resolved against "today" when filtering. */
export type SiftDatePreset = "today" | "last-7" | "last-30" | "this-month";
/**
* A filter's operand: `string` (text, or a `YYYY-MM-DD` date), `string[]`
* (select), `number`, `[min, max]` (number between), `[from, to]` date keys
* (date between) or `null` (boolean / preset).
*/
export type SiftFilterValue = string | number | boolean | string[] | [number, number] | [string, string] | null;
/** One applied filter (one chip). */
export interface SiftFilter {
/** Stable id (chip key). */
id: string;
/** `SiftField.key` this filter targets. */
field: string;
operator: SiftOperator;
value: SiftFilterValue;
/** Date fields only: a relative range that overrides `value`. */
preset?: SiftDatePreset;
}
/** How chips combine: every filter must match (`and`) or any (`or`). */
export type SiftConjunction = "and" | "or";
/** A named, saved chip set. */
export interface SiftView {
id: string;
label: string;
filters: SiftFilter[];
/** @default "and" */
conjunction?: SiftConjunction;
}
/** Imperative verbs exposed through the `ref` prop. */
export interface SiftHandle {
/** Open the filter popover — at the operator step when `fieldKey` is given. */
open: (fieldKey?: string) => void;
/** Close the popover (and views menu) if open. */
close: () => void;
/** Remove every filter. */
clear: () => void;
/** Replay the intro. */
replay: () => void;
}
export interface SiftProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/**
* Filterable properties.
* @default issue-tracker sample fields (Status, Priority, Assignee, Title, Estimate, Updated, Failing checks)
*/
fields?: SiftField[];
/** Controlled filters. Omit for uncontrolled use. */
value?: SiftFilter[];
/**
* Initial filters (uncontrolled).
* @default the "Active work" sample view when `fields` is omitted, otherwise none
*/
defaultValue?: SiftFilter[];
/** Fires with the full next filter list on every add, edit, removal, clear or view switch. */
onChange?: (filters: SiftFilter[]) => void;
/** Controlled conjunction. */
conjunction?: SiftConjunction;
/** Initial conjunction (uncontrolled). @default "and" */
defaultConjunction?: SiftConjunction;
/** Fires when the and / or toggle between chips flips. */
onConjunctionChange?: (conjunction: SiftConjunction) => void;
/**
* Saved views in the views menu. Pass `[]` to hide the menu.
* @default sample views (All issues, Active work, My open, Failing this week, Urgent & high) when `fields` is omitted
*/
views?: SiftView[];
/** Id of the view selected initially. @default "active" for the sample views, else the first view */
defaultView?: string;
/** Fires when a view is picked from the menu. */
onViewChange?: (view: SiftView) => void;
/**
* Called when a view is saved from "Save view". Return a Promise to show a
* pending state; rejecting keeps the name field open with the error. The
* view is added to the menu on success. Omit to hide "Save view".
*/
onSaveView?: (view: SiftView) => void | Promise<unknown>;
/** Result count shown at the right; rolls on Tally drums when it changes. */
count?: number;
/** Optional total, shown as "24 of 40". */
total?: number;
/** Result noun. @default { one: "result", other: "results" } */
noun?: { one: string; other: string };
/** "Today" for date presets and the calendar (YYYY-MM-DD or Date). @default the viewer's local today */
today?: string | Date;
/** Accent for focus rings, the active chip, values and the Apply button. @default "#e4e4e7" */
accentColor?: string;
/** Imperative handle (`open(fieldKey?)`, `close()`, `clear()`, `replay()`). React 19 ref-as-prop. */
ref?: React.Ref<SiftHandle>;
/** Extra classes for the root. */
className?: string;
}
/* ================================================================ constants */
const DEFAULT_ACCENT = "#e4e4e7";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 };
const SLIDE: Transition = { type: "spring", stiffness: 480, damping: 40 };
const FADE: Transition = { duration: 0.18, ease: "easeOut" };
/** Reduced motion: geometry snaps, only opacity fades. */
const REDUCED_T: Transition = { duration: 0, opacity: { duration: 0.18, ease: "easeOut" } };
const POP_W = 304;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MONTHS_LONG = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const PRESETS: { id: SiftDatePreset; label: string }[] = [
{ id: "today", label: "Today" },
{ id: "last-7", label: "Last 7 days" },
{ id: "last-30", label: "Last 30 days" },
{ id: "this-month", label: "This month" },
];
const PRESET_LABEL: Record<SiftDatePreset, string> = {
today: "Today",
"last-7": "Last 7 days",
"last-30": "Last 30 days",
"this-month": "This month",
};
interface OpDef {
id: SiftOperator;
label: string;
hint?: string;
}
const OPS: Record<SiftFieldType, OpDef[]> = {
text: [
{ id: "contains", label: "contains" },
{ id: "is", label: "is" },
{ id: "is-not", label: "is not" },
{ id: "starts-with", label: "starts with" },
],
select: [
{ id: "any-of", label: "is any of" },
{ id: "none-of", label: "is none of" },
],
number: [
{ id: "eq", label: "=", hint: "equals" },
{ id: "neq", label: "≠", hint: "not equal" },
{ id: "gt", label: ">", hint: "greater than" },
{ id: "lt", label: "<", hint: "less than" },
{ id: "between", label: "between", hint: "inclusive" },
],
date: [
{ id: "is", label: "is" },
{ id: "before", label: "before" },
{ id: "after", label: "after" },
{ id: "between", label: "between" },
],
boolean: [
{ id: "is-true", label: "is true" },
{ id: "is-false", label: "is false" },
],
};
function opLabel(type: SiftFieldType, op: SiftOperator): string {
return OPS[type].find((o) => o.id === op)?.label ?? op;
}
/* ================================================================ icons */
const svg = (d: React.ReactNode) => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 shrink-0"
aria-hidden
>
{d}
</svg>
);
const Icon = {
plus: svg(<path d="M8 3.5v9M3.5 8h9" />),
x: svg(<path d="m4.5 4.5 7 7m0-7-7 7" />),
back: svg(<path d="M9.5 3.5 5 8l4.5 4.5" />),
chevron: svg(<path d="m4.5 6.5 3.5 3.5 3.5-3.5" />),
chevL: svg(<path d="M9.5 4 5.5 8l4 4" />),
chevR: svg(<path d="m6.5 4 4 4-4 4" />),
check: svg(<path d="m3.5 8.5 3 3 6-7" />),
search: svg(
<>
<circle cx="7" cy="7" r="4.25" />
<path d="m10.25 10.25 3 3" />
</>
),
views: svg(
<>
<path d="M2.5 5 8 2.5 13.5 5 8 7.5z" />
<path d="m2.5 8 5.5 2.5L13.5 8" />
<path d="m2.5 11 5.5 2.5 5.5-2.5" />
</>
),
funnel: svg(<path d="M2.5 3.5h11l-4.25 5v4l-2.5 1.25V8.5z" />),
text: svg(<path d="M3 12.5 6 3.5l3 9M4 9.5h4M10.5 8.5c.4-.6 1-.9 1.75-.9 1 0 1.75.6 1.75 1.6v3.3M14 10.5c-2.2 0-3.5.4-3.5 1.2 0 .6.5 1 1.2 1 1.2 0 2.3-.8 2.3-2.2" />),
select: svg(
<>
<circle cx="8" cy="8" r="5.25" />
<circle cx="8" cy="8" r="1.75" fill="currentColor" stroke="none" />
</>
),
number: svg(<path d="M6 2.5 4.75 13.5M11.25 2.5 10 13.5M3 6h10.5M2.5 10H13" />),
date: svg(
<>
<rect x="2.5" y="3.5" width="11" height="10" rx="2" />
<path d="M2.5 7h11M5.5 2v3M10.5 2v3" />
</>
),
boolean: svg(
<>
<rect x="1.75" y="4.5" width="12.5" height="7" rx="3.5" />
<circle cx="10.75" cy="8" r="1.9" fill="currentColor" stroke="none" />
</>
),
status: svg(
<>
<circle cx="8" cy="8" r="5.25" strokeDasharray="2.2 1.6" />
<path d="M8 4.75a3.25 3.25 0 0 1 0 6.5z" fill="currentColor" stroke="none" />
</>
),
priority: svg(<path d="M3.5 12.5v-3M8 12.5v-6M12.5 12.5v-9" strokeWidth="1.75" />),
person: svg(
<>
<circle cx="8" cy="5.75" r="2.5" />
<path d="M3 13.5c.6-2.4 2.6-3.75 5-3.75s4.4 1.35 5 3.75" />
</>
),
estimate: svg(
<>
<path d="M8 2.5 13.5 5.5v5L8 13.5 2.5 10.5v-5z" />
<path d="M2.5 5.5 8 8.5l5.5-3M8 8.5v5" />
</>
),
clock: svg(
<>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 5v3l2 1.5" />
</>
),
alert: svg(
<>
<path d="M8 2.5 14 13H2z" />
<path d="M8 6.75v2.75M8 11.25v.01" />
</>
),
};
const TYPE_ICON: Record<SiftFieldType, React.ReactNode> = {
text: Icon.text,
select: Icon.select,
number: Icon.number,
date: Icon.date,
boolean: Icon.boolean,
};
/* ================================================================ sample data */
/** Sample issue-tracker fields used when `fields` is omitted. */
export const defaultSiftFields: SiftField[] = [
{
key: "status",
label: "Status",
type: "select",
icon: Icon.status,
options: [
{ value: "Backlog", color: "#71717a" },
{ value: "Todo", color: "#d4d4d8" },
{ value: "In progress", color: "#fbbf24" },
{ value: "In review", color: "#818cf8" },
{ value: "Done", color: "#4ade80" },
{ value: "Canceled", color: "#52525b" },
],
},
{
key: "priority",
label: "Priority",
type: "select",
icon: Icon.priority,
options: [
{ value: "Urgent", color: "#f87171" },
{ value: "High", color: "#fb923c" },
{ value: "Medium", color: "#facc15" },
{ value: "Low", color: "#a1a1aa" },
{ value: "None", color: "#52525b" },
],
},
{
key: "assignee",
label: "Assignee",
type: "select",
icon: Icon.person,
options: ["Mara Quill", "Jonah Reyes", "Ines Varga", "Kofi Mensah", "Priya Raman", "Unassigned"],
},
{ key: "title", label: "Title", type: "text", icon: Icon.text },
{ key: "estimate", label: "Estimate", type: "number", icon: Icon.estimate },
{ key: "updated", label: "Updated", type: "date", icon: Icon.clock },
{ key: "failing", label: "Failing checks", type: "boolean", icon: Icon.alert },
];
/** Sample saved views for `defaultSiftFields`. */
export const defaultSiftViews: SiftView[] = [
{ id: "all", label: "All issues", filters: [] },
{
id: "active",
label: "Active work",
filters: [
{ id: "v-active-status", field: "status", operator: "any-of", value: ["Todo", "In progress", "In review"] },
{ id: "v-active-est", field: "estimate", operator: "gt", value: 1 },
],
},
{
id: "mine",
label: "My open",
filters: [
{ id: "v-mine-who", field: "assignee", operator: "any-of", value: ["Mara Quill"] },
{ id: "v-mine-status", field: "status", operator: "none-of", value: ["Done", "Canceled"] },
],
},
{
id: "failing",
label: "Failing this week",
filters: [
{ id: "v-fail-bool", field: "failing", operator: "is-true", value: null },
{ id: "v-fail-date", field: "updated", operator: "is", value: null, preset: "last-7" },
],
},
{
id: "urgent",
label: "Urgent & high",
filters: [{ id: "v-urgent", field: "priority", operator: "any-of", value: ["Urgent", "High"] }],
},
];
/* ================================================================ date helpers */
const pad = (n: number) => String(n).padStart(2, "0");
function dkey(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fromKey(k: string): Date {
const [y, m, d] = k.split("-").map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
function addDays(k: string, n: number): string {
const d = fromKey(k);
d.setDate(d.getDate() + n);
return dkey(d);
}
function addMonths(month: string, n: number): string {
const [y, m] = month.split("-").map(Number);
const d = new Date(y, m - 1 + n, 1);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
}
/** Coerces a row value (Date, epoch ms, ISO string) to a local `YYYY-MM-DD` key. */
function toKey(v: unknown): string | null {
if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : dkey(v);
if (typeof v === "number") return Number.isFinite(v) ? dkey(new Date(v)) : null;
if (typeof v === "string") {
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : dkey(d);
}
return null;
}
function presetRange(p: SiftDatePreset, today: string): [string, string] {
if (p === "today") return [today, today];
if (p === "last-7") return [addDays(today, -6), today];
if (p === "last-30") return [addDays(today, -29), today];
const first = `${today.slice(0, 7)}-01`;
return [first, addDays(`${addMonths(today.slice(0, 7), 1)}-01`, -1)];
}
function fmtDay(k: string): string {
const d = fromKey(k);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
const subscribeNoop = () => () => {};
/** Local today as `YYYY-MM-DD`; null on the server / during hydration. */
function useTodayKey(override?: string | Date): string | null {
const live = React.useSyncExternalStore(
subscribeNoop,
() => dkey(new Date()),
() => null
);
if (override) return toKey(override);
return live;
}
/* ================================================================ pure filtering */
function optionsOf(field: SiftField | undefined): Required<SiftOption>[] {
return (field?.options ?? []).map((o) =>
typeof o === "string"
? { value: o, label: o, color: "" }
: { value: o.value, label: o.label ?? o.value, color: o.color ?? "" }
);
}
function matches(row: Record<string, unknown>, f: SiftFilter, field: SiftField, today: string): boolean {
const raw = row[field.key];
switch (field.type) {
case "text": {
const hay = String(raw ?? "").toLowerCase();
const needle = String(f.value ?? "").toLowerCase();
if (!needle) return true;
if (f.operator === "is") return hay === needle;
if (f.operator === "is-not") return hay !== needle;
if (f.operator === "starts-with") return hay.startsWith(needle);
return hay.includes(needle);
}
case "select": {
const sel = Array.isArray(f.value) ? f.value.map(String) : f.value == null ? [] : [String(f.value)];
if (!sel.length) return true;
const vals = Array.isArray(raw) ? raw.map(String) : raw == null ? [] : [String(raw)];
const hit = vals.some((v) => sel.includes(v));
return f.operator === "none-of" ? !hit : hit;
}
case "number": {
const n = typeof raw === "number" ? raw : Number(raw);
if (raw == null || Number.isNaN(n)) return false;
if (f.operator === "between" && Array.isArray(f.value)) {
const [a, b] = (f.value as number[]).map(Number);
return n >= Math.min(a, b) && n <= Math.max(a, b);
}
const v = Number(f.value);
if (f.operator === "neq") return n !== v;
if (f.operator === "gt") return n > v;
if (f.operator === "lt") return n < v;
return n === v;
}
case "date": {
const k = toKey(raw);
if (!k) return false;
if (f.preset) {
const [a, b] = presetRange(f.preset, today);
return k >= a && k <= b;
}
if (f.operator === "between" && Array.isArray(f.value)) {
const [a, b] = (f.value as string[]).map(String).sort();
return k >= a && k <= b;
}
const v = String(f.value ?? "");
if (!v) return true;
if (f.operator === "before") return k < v;
if (f.operator === "after") return k > v;
return k === v;
}
case "boolean":
return Boolean(raw) === (f.operator === "is-true");
}
}
/**
* Pure helper: returns the rows that satisfy `filters` (AND by default, or OR),
* reading `row[field.key]`. Filters for unknown fields are ignored. Date
* values may be `Date`s, epoch ms or ISO strings; presets resolve against
* `options.now` (default: the current date).
*/
export function applyFilters<T extends object>(
rows: readonly T[],
filters: readonly SiftFilter[],
fields: readonly SiftField[] = defaultSiftFields,
options: { conjunction?: SiftConjunction; now?: Date | string } = {}
): T[] {
const active = filters
.map((f) => ({ f, field: fields.find((x) => x.key === f.field) }))
.filter((x): x is { f: SiftFilter; field: SiftField } => !!x.field);
if (!active.length) return rows.slice();
const today = toKey(options.now ?? new Date()) ?? dkey(new Date());
const any = options.conjunction === "or";
return rows.filter((row) => {
const r = row as Record<string, unknown>;
return any ? active.some((x) => matches(r, x.f, x.field, today)) : active.every((x) => matches(r, x.f, x.field, today));
});
}
/* ================================================================ misc helpers */
function inkFor(hex: string): string {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return "#0a0a0a";
const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
const [r, g, b] = [0, 2, 4].map((i) => {
const c = parseInt(h.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return L > 0.36 ? "#0a0a0a" : "#ffffff";
}
function sig(filters: readonly SiftFilter[], conj: SiftConjunction): string {
return JSON.stringify([conj, filters.map((f) => [f.field, f.operator, f.value, f.preset ?? null])]);
}
function summarize(field: SiftField, f: SiftFilter): string {
switch (field.type) {
case "text":
return `“${String(f.value ?? "")}”`;
case "select": {
const opts = optionsOf(field);
const labels = (Array.isArray(f.value) ? f.value : []).map(
(v) => opts.find((o) => o.value === v)?.label ?? String(v)
);
return labels.length > 2 ? `${labels.slice(0, 2).join(", ")} +${labels.length - 2}` : labels.join(", ");
}
case "number":
return Array.isArray(f.value) ? `${f.value[0]} – ${f.value[1]}` : String(f.value ?? "");
case "date":
if (f.preset) return PRESET_LABEL[f.preset];
if (Array.isArray(f.value)) {
const [a, b] = (f.value as string[]).map(String).sort();
return a === b ? fmtDay(a) : `${fmtDay(a)} – ${fmtDay(b)}`;
}
return f.value ? fmtDay(String(f.value)) : "";
case "boolean":
return "";
}
}
function describe(field: SiftField, f: SiftFilter): string {
const v = summarize(field, f);
return `${field.label} ${opLabel(field.type, f.operator)}${v ? ` ${v}` : ""}`;
}
/* ================================================================ draft */
interface Draft {
editingId: string | null;
field: string | null;
operator: SiftOperator | null;
text: string;
sel: string[];
numA: string;
numB: string;
dateA: string | null;
dateB: string | null;
preset: SiftDatePreset | null;
}
const EMPTY_DRAFT: Draft = {
editingId: null,
field: null,
operator: null,
text: "",
sel: [],
numA: "",
numB: "",
dateA: null,
dateB: null,
preset: null,
};
function draftFrom(f: SiftFilter): Draft {
const d: Draft = { ...EMPTY_DRAFT, editingId: f.id, field: f.field, operator: f.operator };
const v = f.value;
if (typeof v === "string") {
d.text = v;
d.dateA = /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null;
}
if (Array.isArray(v)) {
if (typeof v[0] === "number") {
d.numA = String(v[0]);
d.numB = String(v[1] ?? "");
} else if (f.operator === "between" && v.length === 2 && /^\d{4}-/.test(String(v[0]))) {
d.dateA = String(v[0]);
d.dateB = String(v[1]);
} else d.sel = v.map(String);
}
if (typeof v === "number") d.numA = String(v);
d.preset = f.preset ?? null;
return d;
}
const isNum = (s: string) => s.trim() !== "" && Number.isFinite(Number(s));
function buildFilter(d: Draft, field: SiftField, id: string): SiftFilter | null {
const op = d.operator;
if (!op) return null;
const base = { id, field: field.key, operator: op };
switch (field.type) {
case "text":
return d.text.trim() ? { ...base, value: d.text.trim() } : null;
case "select":
return d.sel.length ? { ...base, value: d.sel } : null;
case "number":
if (op === "between")
return isNum(d.numA) && isNum(d.numB)
? { ...base, value: [Number(d.numA), Number(d.numB)].sort((a, b) => a - b) as [number, number] }
: null;
return isNum(d.numA) ? { ...base, value: Number(d.numA) } : null;
case "date":
if (d.preset) return { ...base, operator: "is", value: null, preset: d.preset };
if (!d.dateA) return null;
if (op === "between") {
const pair = [d.dateA, d.dateB ?? d.dateA].sort() as [string, string];
return { ...base, value: pair };
}
return { ...base, value: d.dateA };
case "boolean":
return op === "is-true" || op === "is-false" ? { ...base, value: null } : null;
}
}
/* ================================================================ small pieces */
type Step = "field" | "operator" | "value";
const STEP_INDEX: Record<Step, number> = { field: 0, operator: 1, value: 2 };
function Dot({ color }: { color: string }) {
if (!color) return null;
return <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: color }} />;
}
const FOCUS =
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--sift-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-neutral-900";
/** Keyboard model shared by the listboxes: Up/Down/Home/End move, Enter picks. */
function listKeys(
e: React.KeyboardEvent,
count: number,
active: number,
setActive: (n: number) => void,
pick: (n: number) => void
) {
if (!count) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((active + 1) % count);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((active - 1 + count) % count);
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(count - 1);
} else if (e.key === "Enter") {
e.preventDefault();
pick(active);
}
}
function useFocusOnMount<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
React.useEffect(() => {
ref.current?.focus({ preventScroll: true });
}, []);
return ref;
}
/* ---------------------------------------------------------------- field step */
function FieldStep({
uid,
fields,
selected,
onPick,
}: {
uid: string;
fields: SiftField[];
selected: string | null;
onPick: (key: string) => void;
}) {
const [q, setQ] = React.useState("");
const [active, setActive] = React.useState(() => Math.max(0, fields.findIndex((f) => f.key === selected)));
const input = useFocusOnMount<HTMLInputElement>();
const shown = React.useMemo(() => {
const s = q.trim().toLowerCase();
return s ? fields.filter((f) => f.label.toLowerCase().includes(s)) : fields;
}, [fields, q]);
const act = Math.min(active, shown.length - 1);
const listId = `${uid}-fields`;
return (
<div className="p-1.5">
<label className="relative flex items-center">
<span className="pointer-events-none absolute left-2.5 text-white/35">{Icon.search}</span>
<input
ref={input}
value={q}
onChange={(e) => {
setQ(e.target.value);
setActive(0);
}}
onKeyDown={(e) => listKeys(e, shown.length, act, setActive, (i) => shown[i] && onPick(shown[i].key))}
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={shown[act] ? `${listId}-${shown[act].key}` : undefined}
aria-label="Filter by property"
placeholder="Filter by…"
className="h-9 w-full rounded-lg border border-white/[0.08] bg-white/[0.03] pl-8 pr-2.5 text-[13px] text-white placeholder:text-white/35 outline-none transition-colors focus:border-[color-mix(in_srgb,var(--sift-accent)_45%,transparent)]"
/>
</label>
<ul id={listId} role="listbox" aria-label="Properties" className="mt-1.5 max-h-64 overflow-y-auto">
{shown.map((f, i) => (
<li
key={f.key}
id={`${listId}-${f.key}`}
role="option"
aria-selected={i === act}
onPointerMove={() => setActive(i)}
onClick={() => onPick(f.key)}
className={cn(
"flex h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-[13px] text-white/75",
i === act && "bg-white/[0.06] text-white"
)}
>
<span className="text-white/45">{f.icon ?? TYPE_ICON[f.type]}</span>
<span className="flex-1 truncate">{f.label}</span>
{f.key === selected && <span className="text-[var(--sift-accent)]">{Icon.check}</span>}
</li>
))}
{!shown.length && <li className="px-2.5 py-3 text-[12.5px] text-white/40">No matching properties</li>}
</ul>
</div>
);
}
/* ---------------------------------------------------------------- operator step */
function OperatorStep({
uid,
type,
selected,
onPick,
}: {
uid: string;
type: SiftFieldType;
selected: SiftOperator | null;
onPick: (op: SiftOperator) => void;
}) {
const ops = OPS[type];
const [active, setActive] = React.useState(() => Math.max(0, ops.findIndex((o) => o.id === selected)));
const list = useFocusOnMount<HTMLUListElement>();
const listId = `${uid}-ops`;
return (
<div className="p-1.5">
<ul
ref={list}
id={listId}
role="listbox"
tabIndex={0}
aria-label="Operator"
aria-activedescendant={`${listId}-${ops[active]?.id}`}
onKeyDown={(e) => listKeys(e, ops.length, active, setActive, (i) => onPick(ops[i].id))}
className="rounded-lg outline-none focus-visible:ring-1 focus-visible:ring-white/10"
>
{ops.map((o, i) => (
<li
key={o.id}
id={`${listId}-${o.id}`}
role="option"
aria-selected={i === active}
onPointerMove={() => setActive(i)}
onClick={() => onPick(o.id)}
className={cn(
"flex h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-[13px] text-white/75",
i === active && "bg-white/[0.06] text-white"
)}
>
<span className={cn("min-w-0 flex-1 truncate", o.label.length === 1 && "font-mono text-[13px]")}>
{o.label}
{o.hint && <span className="ml-2 font-sans text-[12px] text-white/35">{o.hint}</span>}
</span>
{o.id === selected && <span className="text-[var(--sift-accent)]">{Icon.check}</span>}
</li>
))}
</ul>
</div>
);
}
/* ---------------------------------------------------------------- value editors */
const INPUT =
"h-9 w-full min-w-0 rounded-lg border border-white/[0.08] bg-white/[0.03] px-2.5 text-[13px] text-white tabular-nums placeholder:text-white/30 outline-none transition-colors focus:border-[color-mix(in_srgb,var(--sift-accent)_45%,transparent)]";
function TextEditor({ draft, set, submit }: { draft: Draft; set: (p: Partial<Draft>) => void; submit: () => void }) {
const ref = useFocusOnMount<HTMLInputElement>();
return (
<div className="p-1.5">
<input
ref={ref}
value={draft.text}
onChange={(e) => set({ text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
aria-label="Value"
placeholder="Type a value…"
className={INPUT}
/>
</div>
);
}
function SelectEditor({
uid,
field,
draft,
set,
submit,
}: {
uid: string;
field: SiftField;
draft: Draft;
set: (p: Partial<Draft>) => void;
submit: () => void;
}) {
const opts = React.useMemo(() => optionsOf(field), [field]);
const [q, setQ] = React.useState("");
const [active, setActive] = React.useState(0);
const input = useFocusOnMount<HTMLInputElement>();
const shown = React.useMemo(() => {
const s = q.trim().toLowerCase();
return s ? opts.filter((o) => o.label.toLowerCase().includes(s)) : opts;
}, [opts, q]);
const act = Math.min(active, shown.length - 1);
const toggle = (v: string) =>
set({ sel: draft.sel.includes(v) ? draft.sel.filter((x) => x !== v) : [...draft.sel, v] });
const listId = `${uid}-opts`;
return (
<div className="p-1.5">
<label className="relative flex items-center">
<span className="pointer-events-none absolute left-2.5 text-white/35">{Icon.search}</span>
<input
ref={input}
value={q}
onChange={(e) => {
setQ(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === " " && !q) {
e.preventDefault();
if (shown[act]) toggle(shown[act].value);
return;
}
if (e.key === "Enter") {
e.preventDefault();
// Enter toggles while searching an unselected option; otherwise applies.
if (q && shown[act] && !draft.sel.includes(shown[act].value)) {
toggle(shown[act].value);
setQ("");
} else submit();
return;
}
listKeys(e, shown.length, act, setActive, () => {});
}}
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={shown[act] ? `${listId}-${act}` : undefined}
aria-label={`Search ${field.label.toLowerCase()} (Space toggles, Enter applies)`}
placeholder={`Search ${field.label.toLowerCase()}…`}
className={cn(INPUT, "pl-8")}
/>
</label>
<ul
id={listId}
role="listbox"
aria-multiselectable
aria-label={field.label}
className="mt-1.5 max-h-56 overflow-y-auto"
>
{shown.map((o, i) => {
const on = draft.sel.includes(o.value);
return (
<li
key={o.value}
id={`${listId}-${i}`}
role="option"
aria-selected={on}
onPointerMove={() => setActive(i)}
onClick={() => toggle(o.value)}
className={cn(
"flex h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-[13px] text-white/75",
i === act && "bg-white/[0.06] text-white"
)}
>
<span
aria-hidden
className={cn(
"grid size-4 shrink-0 place-items-center rounded-[5px] border transition-colors duration-150",
on ? "border-transparent" : "border-white/20 bg-white/[0.02]"
)}
style={on ? { background: "var(--sift-accent)", color: "var(--sift-ink)" } : undefined}
>
{on && (
<svg viewBox="0 0 16 16" className="size-3" fill="none" stroke="currentColor" strokeWidth="2.25" aria-hidden>
<path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<Dot color={o.color} />
<span className="flex-1 truncate">{o.label}</span>
</li>
);
})}
{!shown.length && <li className="px-2.5 py-3 text-[12.5px] text-white/40">No matches</li>}
</ul>
</div>
);
}
function NumberEditor({ draft, set, submit }: { draft: Draft; set: (p: Partial<Draft>) => void; submit: () => void }) {
const ref = useFocusOnMount<HTMLInputElement>();
const between = draft.operator === "between";
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
};
const bad = (s: string) => s.trim() !== "" && !isNum(s);
return (
<div className="flex items-center gap-2 p-1.5">
<input
ref={ref}
inputMode="decimal"
value={draft.numA}
onChange={(e) => set({ numA: e.target.value })}
onKeyDown={onKey}
aria-label={between ? "Minimum" : "Value"}
aria-invalid={bad(draft.numA) || undefined}
placeholder={between ? "Min" : "0"}
className={cn(INPUT, bad(draft.numA) && "border-red-400/50")}
/>
{between && (
<>
<span className="shrink-0 text-[12px] text-white/40">and</span>
<input
inputMode="decimal"
value={draft.numB}
onChange={(e) => set({ numB: e.target.value })}
onKeyDown={onKey}
aria-label="Maximum"
aria-invalid={bad(draft.numB) || undefined}
placeholder="Max"
className={cn(INPUT, bad(draft.numB) && "border-red-400/50")}
/>
</>
)}
</div>
);
}
function monthGrid(month: string): string[] {
const first = fromKey(`${month}-01`);
const offset = (first.getDay() + 6) % 7;
const start = addDays(`${month}-01`, -offset);
return Array.from({ length: 42 }, (_, i) => addDays(start, i));
}
function Calendar({
range,
a,
b,
today,
onSelect,
submit,
}: {
range: boolean;
a: string | null;
b: string | null;
today: string;
onSelect: (a: string, b: string | null) => void;
submit: () => void;
}) {
const init = a ?? today;
const [month, setMonth] = React.useState(init.slice(0, 7));
const [focusKey, setFocusKey] = React.useState(init);
const [hover, setHover] = React.useState<string | null>(null);
const [kbd, setKbd] = React.useState(0);
const grid = React.useRef<HTMLDivElement>(null);
const days = React.useMemo(() => monthGrid(month), [month]);
const [y, m] = month.split("-").map(Number);
React.useEffect(() => {
if (!kbd) return;
grid.current?.querySelector<HTMLButtonElement>(`[data-day="${focusKey}"]`)?.focus({ preventScroll: true });
}, [kbd, focusKey]);
const lo = a && range ? [a, b ?? hover ?? a].sort()[0] : a;
const hi = a && range ? [a, b ?? hover ?? a].sort()[1] : a;
const pick = (k: string) => {
if (!range) return onSelect(k, null);
if (!a || b) return onSelect(k, null);
return onSelect(k < a ? k : a, k < a ? a : k);
};
const move = (n: number) => {
const next = addDays(focusKey, n);
setFocusKey(next);
if (next.slice(0, 7) !== month) setMonth(next.slice(0, 7));
setKbd((x) => x + 1);
};
return (
<div className="px-2 pb-1 pt-0.5">
<div className="flex items-center justify-between px-0.5 pb-1">
<span className="text-[12.5px] font-medium text-white/85" aria-live="polite">
{MONTHS_LONG[m - 1]} {y}
</span>
<div className="flex gap-0.5">
{[
{ d: -1, icon: Icon.chevL, label: "Previous month" },
{ d: 1, icon: Icon.chevR, label: "Next month" },
].map((x) => (
<button
key={x.d}
type="button"
aria-label={x.label}
onClick={() => {
const nm = addMonths(month, x.d);
setMonth(nm);
setFocusKey(`${nm}-01`);
}}
className={cn("grid size-7 place-items-center rounded-md text-white/50 hover:bg-white/[0.06] hover:text-white", FOCUS)}
>
{x.icon}
</button>
))}
</div>
</div>
<div
ref={grid}
role="grid"
aria-label={`${MONTHS_LONG[m - 1]} ${y}${range ? ", pick a start and end date" : ""}`}
onPointerLeave={() => setHover(null)}
onKeyDown={(e) => {
const map: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
if (map[e.key] !== undefined) {
e.preventDefault();
move(map[e.key]);
} else if (e.key === "PageUp" || e.key === "PageDown") {
e.preventDefault();
move(e.key === "PageUp" ? -28 : 28);
} else if (e.key === "Enter" && a && (!range || b) && focusKey === (range ? b : a)) {
e.preventDefault();
submit();
}
}}
>
<div role="row" className="grid grid-cols-7">
{WEEKDAYS.map((w) => (
<span key={w} role="columnheader" className="grid h-7 place-items-center text-[11px] text-white/35">
{w}
</span>
))}
</div>
{[0, 1, 2, 3, 4, 5].map((r) => (
<div role="row" key={r} className="grid grid-cols-7">
{days.slice(r * 7, r * 7 + 7).map((k) => {
const out = k.slice(0, 7) !== month;
const isEnd = k === lo || k === hi;
const inside = !!lo && !!hi && k > lo && k < hi;
const d = fromKey(k);
return (
<div role="gridcell" key={k} aria-selected={isEnd || inside} className="relative h-8 p-px">
{(inside || (isEnd && lo !== hi)) && (
<span
aria-hidden
className={cn(
"absolute inset-y-px bg-[color-mix(in_srgb,var(--sift-accent)_12%,transparent)]",
k === lo ? "left-1/2 right-0" : k === hi ? "left-0 right-1/2" : "inset-x-0"
)}
/>
)}
<button
type="button"
data-day={k}
tabIndex={k === focusKey ? 0 : -1}
aria-label={`${MONTHS_LONG[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}${k === today ? " (today)" : ""}`}
onClick={() => {
setFocusKey(k);
pick(k);
}}
onPointerEnter={() => setHover(k)}
className={cn(
"relative grid h-full w-full place-items-center rounded-md text-[12.5px] tabular-nums transition-colors",
FOCUS,
out ? "text-white/25" : "text-white/75",
!isEnd && "hover:bg-white/[0.07] hover:text-white",
isEnd && "font-semibold"
)}
style={isEnd ? { background: "var(--sift-accent)", color: "var(--sift-ink)" } : undefined}
>
{d.getDate()}
{k === today && (
<span
aria-hidden
className="absolute bottom-1 left-1/2 size-[3px] -translate-x-1/2 rounded-full"
style={{ background: isEnd ? "var(--sift-ink)" : "var(--sift-accent)" }}
/>
)}
</button>
</div>
);
})}
</div>
))}
</div>
</div>
);
}
function DateEditor({
draft,
set,
today,
submit,
}: {
draft: Draft;
set: (p: Partial<Draft>) => void;
today: string;
submit: () => void;
}) {
const first = useFocusOnMount<HTMLDivElement>();
return (
<div>
<div ref={first} tabIndex={-1} className="flex flex-wrap gap-1 px-2 pb-1.5 pt-1.5 outline-none" role="group" aria-label="Presets">
{PRESETS.map((p) => {
const on = draft.preset === p.id;
return (
<button
key={p.id}
type="button"
aria-pressed={on}
onClick={() => set({ preset: on ? null : p.id, dateA: null, dateB: null })}
className={cn(
"h-7 rounded-md border px-2 text-[12px] transition-colors",
FOCUS,
on
? "border-[color-mix(in_srgb,var(--sift-accent)_45%,transparent)] bg-[color-mix(in_srgb,var(--sift-accent)_14%,transparent)] text-white"
: "border-white/[0.08] text-white/60 hover:border-white/15 hover:text-white"
)}
>
{p.label}
</button>
);
})}
</div>
<div className="mx-2 h-px bg-white/[0.06]" />
<Calendar
range={draft.operator === "between"}
a={draft.preset ? null : draft.dateA}
b={draft.preset ? null : draft.dateB}
today={today}
onSelect={(a, b) => set({ dateA: a, dateB: b, preset: null })}
submit={submit}
/>
</div>
);
}
/* ================================================================ views menu */
function ViewsMenu({
uid,
views,
activeId,
edited,
open,
setOpen,
onPick,
}: {
uid: string;
views: SiftView[];
activeId: string | null;
edited: boolean;
open: boolean;
setOpen: (o: boolean) => void;
onPick: (v: SiftView) => void;
}) {
const btn = React.useRef<HTMLButtonElement>(null);
const menu = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const active = views.find((v) => v.id === activeId);
const menuId = `${uid}-views`;
React.useEffect(() => {
if (!open) return;
const idx = Math.max(0, views.findIndex((v) => v.id === activeId));
const id = requestAnimationFrame(() =>
menu.current?.querySelectorAll<HTMLElement>("[role=menuitemradio]")[idx]?.focus({ preventScroll: true })
);
const onDown = (e: PointerEvent) => {
if (!menu.current?.contains(e.target as Node) && !btn.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => {
cancelAnimationFrame(id);
document.removeEventListener("pointerdown", onDown);
};
}, [open, views, activeId, setOpen]);
const close = () => {
setOpen(false);
btn.current?.focus({ preventScroll: true });
};
return (
<div className="relative min-w-0">
<button
ref={btn}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen(!open)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && !open) {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"flex h-8 min-w-0 max-w-full items-center gap-2 rounded-lg px-2 text-[13px] font-medium text-white/90 transition-colors hover:bg-white/[0.05]",
open && "bg-white/[0.05]",
FOCUS
)}
>
<span className="text-white/45">{Icon.views}</span>
<span className="truncate">{active?.label ?? "Custom view"}</span>
{edited && (
<span className="shrink-0 rounded-[5px] border border-white/10 px-1 py-px text-[10.5px] font-normal leading-tight text-white/50">
Edited
</span>
)}
<span className={cn("text-white/40 transition-transform duration-200", open && "rotate-180")}>{Icon.chevron}</span>
</button>
<AnimatePresence>
{open && (
<motion.div
ref={menu}
id={menuId}
role="menu"
aria-label="Saved views"
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
transition={reduced ? FADE : { duration: 0.16, ease: EASE }}
style={{ transformOrigin: "top left" }}
onKeyDown={(e) => {
const items = [...(menu.current?.querySelectorAll<HTMLElement>("[role=menuitemradio]") ?? [])];
const i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const n = (i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[n]?.focus({ preventScroll: true });
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
items[e.key === "Home" ? 0 : items.length - 1]?.focus({ preventScroll: true });
} else if (e.key === "Escape" || e.key === "Tab") {
e.preventDefault();
close();
}
}}
className="absolute left-0 top-[calc(100%+6px)] z-40 w-60 max-w-[calc(100cqw-1rem)] rounded-xl border border-white/10 bg-[#141416] p-1 shadow-[0_24px_48px_-12px_rgba(0,0,0,0.75),0_0_0_1px_rgba(0,0,0,0.4)]"
>
<p className="px-2.5 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-white/35">Views</p>
{views.map((v) => {
const on = v.id === activeId;
return (
<div
key={v.id}
role="menuitemradio"
aria-checked={on}
tabIndex={-1}
onClick={() => {
onPick(v);
close();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick(v);
close();
}
}}
className="flex h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-[13px] text-white/75 outline-none hover:bg-white/[0.06] hover:text-white focus-visible:bg-white/[0.06] focus-visible:text-white"
>
<span className="grid w-3.5 place-items-center text-[var(--sift-accent)]">{on && Icon.check}</span>
<span className="flex-1 truncate">{v.label}</span>
<span className="text-[11.5px] tabular-nums text-white/30">{v.filters.length || "—"}</span>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/* ================================================================ main */
/**
* Sift — a structured filter builder for any list or table. "+ Filter" opens
* one popover that slides property → operator → value; applied filters become
* editable chips joined by an and/or toggle; saved views swap chip sets with a
* crossfade. Pair it with the exported `applyFilters(rows, filters, fields)`.
*/
export function Sift({
fields: fieldsProp,
value,
defaultValue,
onChange,
conjunction: conjProp,
defaultConjunction = "and",
onConjunctionChange,
views: viewsProp,
defaultView,
onViewChange,
onSaveView,
count,
total,
noun = { one: "result", other: "results" },
today: todayProp,
accentColor = DEFAULT_ACCENT,
ref,
className,
style,
onKeyDown,
...rest
}: SiftProps) {
const reduced = useReducedMotion();
const uid = React.useId().replace(/:/g, "");
const sample = !fieldsProp;
const fields = fieldsProp ?? defaultSiftFields;
const baseViews = React.useMemo(() => viewsProp ?? (sample ? defaultSiftViews : []), [viewsProp, sample]);
const initialViewId = defaultView ?? (sample && !viewsProp ? "active" : baseViews[0]?.id) ?? null;
/* ----- state */
const [innerFilters, setInnerFilters] = React.useState<SiftFilter[]>(
() => defaultValue ?? (sample ? (baseViews.find((v) => v.id === initialViewId)?.filters ?? []) : [])
);
const filters = value ?? innerFilters;
const [innerConj, setInnerConj] = React.useState<SiftConjunction>(defaultConjunction);
const conj = conjProp ?? innerConj;
const [saved, setSaved] = React.useState<SiftView[]>([]);
const views = React.useMemo(() => [...baseViews, ...saved], [baseViews, saved]);
const [activeViewId, setActiveViewId] = React.useState<string | null>(initialViewId);
const activeView = views.find((v) => v.id === activeViewId);
const edited = activeView
? sig(activeView.filters, activeView.conjunction ?? "and") !== sig(filters, conj)
: filters.length > 0;
const [epoch, setEpoch] = React.useState(0);
const [announce, setAnnounce] = React.useState("");
const idSeq = React.useRef(0);
const freshId = (prefix: string, taken: string[]) => {
let id = "";
do id = `${prefix}-${uid}-${(idSeq.current++).toString(36)}`;
while (taken.includes(id));
return id;
};
const todayKey = useTodayKey(todayProp) ?? "2026-01-01";
const [pop, setPop] = React.useState<{ anchor: string; step: Step; dir: 1 | -1 } | null>(null);
const [popPos, setPopPos] = React.useState<{ left: number; top: number; width: number } | null>(null);
const [draft, setDraft] = React.useState<Draft>(EMPTY_DRAFT);
const [viewsOpen, setViewsOpen] = React.useState(false);
const [saving, setSaving] = React.useState<null | { name: string; status: "idle" | "pending" | "error"; error?: string }>(
null
);
const rootRef = React.useRef<HTMLDivElement>(null);
const addRef = React.useRef<HTMLButtonElement>(null);
const popRef = React.useRef<HTMLDivElement>(null);
const anchorEl = React.useRef<HTMLElement | null>(null);
const restoreEl = React.useRef<HTMLElement | null>(null);
const saveInput = React.useRef<HTMLInputElement>(null);
/* ----- intro (once in view; falls back on a timer so it never stays hidden) */
const inView = useInView(rootRef, { once: true, amount: 0.2 });
const [forced, setForced] = React.useState(false);
const [introKey, setIntroKey] = React.useState(0);
React.useEffect(() => {
const t = window.setTimeout(() => setForced(true), 1200);
return () => window.clearTimeout(t);
}, []);
const play = reduced || inView || forced;
const [introDone, setIntroDone] = React.useState(false);
React.useEffect(() => {
if (!play || introDone) return;
const t = window.setTimeout(() => setIntroDone(true), 900);
return () => window.clearTimeout(t);
}, [play, introDone, introKey]);
/* ----- commits */
const commit = React.useCallback(
(next: SiftFilter[], msg?: string, keepView = true) => {
if (value === undefined) setInnerFilters(next);
onChange?.(next);
if (msg) setAnnounce(msg);
if (!keepView) setActiveViewId(null);
},
[value, onChange]
);
const setConj = (c: SiftConjunction) => {
if (conjProp === undefined) setInnerConj(c);
onConjunctionChange?.(c);
setAnnounce(c === "and" ? "Matching all filters" : "Matching any filter");
};
const fieldOf = (key: string | null) => fields.find((f) => f.key === key);
/* ----- popover */
const place = React.useCallback(() => {
const root = rootRef.current;
const a = anchorEl.current;
if (!root || !a) return;
const r = root.getBoundingClientRect();
const ar = a.getBoundingClientRect();
const width = Math.min(POP_W, r.width - 12);
const left = Math.max(6, Math.min(ar.left - r.left, r.width - width - 6));
setPopPos({ left, top: ar.bottom - r.top + 6, width });
}, []);
const openAt = (anchor: string, el: HTMLElement | null, step: Step, d: Draft) => {
setViewsOpen(false);
setSaving(null);
anchorEl.current = el;
restoreEl.current = (document.activeElement as HTMLElement) ?? el;
setDraft(d);
setPop({ anchor, step, dir: 1 });
place();
};
const openAdd = (fieldKey?: string) => {
const f = fieldKey ? fieldOf(fieldKey) : undefined;
openAt("add", addRef.current, f ? "operator" : "field", { ...EMPTY_DRAFT, field: f?.key ?? null });
};
const openEdit = (filter: SiftFilter, step: Step, el: HTMLElement | null) => {
if (pop?.anchor === filter.id && pop.step === step) return closePop(true);
openAt(filter.id, el, step, draftFrom(filter));
};
const closePop = (restore: boolean) => {
setPop(null);
if (restore) {
const el = restoreEl.current;
requestAnimationFrame(() => (el?.isConnected ? el : addRef.current)?.focus({ preventScroll: true }));
}
};
const go = (step: Step) => setPop((p) => (p ? { ...p, step, dir: STEP_INDEX[step] >= STEP_INDEX[p.step] ? 1 : -1 } : p));
const apply = (d: Draft = draft) => {
const field = fieldOf(d.field);
if (!field) return;
const id = d.editingId ?? freshId("f", filters.map((x) => x.id));
const f = buildFilter(d, field, id);
if (!f) return;
if (d.editingId) {
commit(
filters.map((x) => (x.id === d.editingId ? f : x)),
`Filter updated: ${describe(field, f)}`
);
} else {
commit([...filters, f], `Filter added: ${describe(field, f)}`);
restoreEl.current = addRef.current;
}
closePop(true);
};
const pickField = (key: string) => {
const nf = fieldOf(key);
if (!nf) return;
const prev = fieldOf(draft.field);
const keep = prev && prev.type === nf.type && prev.key === nf.key;
const sameType = prev && prev.type === nf.type;
setDraft((d) => ({
...(keep ? d : sameType && nf.type !== "select" ? d : { ...EMPTY_DRAFT, editingId: d.editingId }),
field: key,
}));
go("operator");
};
const pickOperator = (op: SiftOperator) => {
const field = fieldOf(draft.field);
if (!field) return;
let d: Draft = { ...draft, operator: op };
if (field.type === "date" && op !== "is") d = { ...d, preset: null };
if (field.type === "date" && op !== "between") d = { ...d, dateB: null };
if (field.type === "boolean") return apply(d);
setDraft(d);
// Editing in place: an operator swap that keeps a valid value applies at once.
if (draft.editingId && buildFilter(d, field, draft.editingId) && pop?.step === "operator" && draft.operator) {
const prevBetween = draft.operator === "between";
if (prevBetween === (op === "between")) return apply(d);
}
go("value");
};
React.useEffect(() => {
if (!pop) return;
const root = rootRef.current;
const ro = new ResizeObserver(() => place());
if (root) ro.observe(root);
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (popRef.current?.contains(t) || anchorEl.current?.contains(t)) return;
setPop(null);
};
document.addEventListener("pointerdown", onDown);
return () => {
ro.disconnect();
document.removeEventListener("pointerdown", onDown);
};
}, [pop, place]);
/* ----- chip ops */
const removeChip = (id: string, focusAfter = false) => {
const i = filters.findIndex((x) => x.id === id);
if (i < 0) return;
const f = filters[i];
const field = fieldOf(f.field);
const next = filters.filter((x) => x.id !== id);
commit(next, field ? `Removed ${describe(field, f)}` : "Filter removed");
if (pop?.anchor === id) setPop(null);
if (focusAfter) {
const target = next[Math.max(0, i - 1)]?.id;
requestAnimationFrame(() => {
const el = target
? rootRef.current?.querySelector<HTMLElement>(`[data-chip="${target}"] [data-part="field"]`)
: addRef.current;
el?.focus({ preventScroll: true });
});
}
};
const clearAll = () => {
if (!filters.length) return;
commit([], "All filters cleared");
setPop(null);
requestAnimationFrame(() => addRef.current?.focus({ preventScroll: true }));
};
const pickView = (v: SiftView) => {
setActiveViewId(v.id);
setEpoch((e) => e + 1);
commit(v.filters, `View: ${v.label}, ${v.filters.length} filter${v.filters.length === 1 ? "" : "s"}`);
const c = v.conjunction ?? "and";
if (c !== conj) {
if (conjProp === undefined) setInnerConj(c);
onConjunctionChange?.(c);
}
setPop(null);
onViewChange?.(v);
};
const startSave = () => {
setPop(null);
setViewsOpen(false);
setSaving({ name: "", status: "idle" });
requestAnimationFrame(() => saveInput.current?.focus({ preventScroll: true }));
};
const submitSave = async () => {
if (!saving || saving.status === "pending") return;
const name = saving.name.trim();
if (!name) {
setSaving({ ...saving, status: "error", error: "Name the view" });
return;
}
const view: SiftView = {
id: freshId("view", views.map((v) => v.id)),
label: name,
filters: filters.map((f) => ({ ...f })),
conjunction: conj,
};
try {
const r = onSaveView?.(view);
if (r && typeof (r as Promise<unknown>).then === "function") {
setSaving({ name, status: "pending" });
await r;
}
setSaved((s) => [...s, view]);
setActiveViewId(view.id);
setSaving(null);
setAnnounce(`View saved: ${name}`);
} catch (err) {
setSaving({ name, status: "error", error: err instanceof Error ? err.message : "Couldn't save view" });
requestAnimationFrame(() => saveInput.current?.focus({ preventScroll: true }));
}
};
/* ----- handle */
React.useImperativeHandle(
ref,
() => ({
open: (k?: string) => openAdd(k),
close: () => {
setPop(null);
setViewsOpen(false);
},
clear: () => commit([], "All filters cleared"),
replay: () => {
setIntroDone(false);
setIntroKey((k) => k + 1);
},
})
);
/* ----- keyboard */
const onRootKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
const t = e.target as HTMLElement;
const typing = t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable;
if (e.key === "Escape" && pop) {
e.preventDefault();
closePop(true);
return;
}
if (pop && e.key === "Tab" && popRef.current?.contains(t)) {
// Trap focus within the popover.
const items = [
...popRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), input, [tabindex="0"]'
),
].filter((el) => el.offsetParent !== null);
if (items.length) {
const i = items.indexOf(t);
const n = e.shiftKey ? (i <= 0 ? items.length - 1 : i - 1) : i === items.length - 1 ? 0 : i + 1;
e.preventDefault();
items[n].focus({ preventScroll: true });
}
return;
}
if (!typing && !pop && e.key.toLowerCase() === "f" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
addRef.current?.focus({ preventScroll: true });
}
};
const onChipKey = (e: React.KeyboardEvent, i: number, id: string) => {
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
e.stopPropagation();
removeChip(id, true);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const n = i + (e.key === "ArrowRight" ? 1 : -1);
if (n < 0) return;
const target = filters[n]?.id;
const el = target
? rootRef.current?.querySelector<HTMLElement>(`[data-chip="${target}"] [data-part="field"]`)
: addRef.current;
el?.focus({ preventScroll: true });
}
};
/* ----- derived render bits */
const accentStyle = {
"--sift-accent": accentColor,
"--sift-ink": inkFor(accentColor),
...style,
} as React.CSSProperties;
const popField = fieldOf(draft.field);
const popOp = draft.operator && popField ? opLabel(popField.type, draft.operator) : null;
const canApply = !!(popField && buildFilter(draft, popField, "x"));
const showViews = views.length > 0;
const prevCount = usePrev(count);
const layoutOn = !reduced;
const chipTransition = (i: number): Transition =>
reduced ? REDUCED_T : { ...SPRING, delay: introDone ? 0 : 0.14 + i * 0.05 };
/* ----- step body */
let body: React.ReactNode = null;
if (pop) {
if (pop.step === "field") body = <FieldStep uid={uid} fields={fields} selected={draft.field} onPick={pickField} />;
else if (pop.step === "operator" && popField)
body = <OperatorStep uid={uid} type={popField.type} selected={draft.operator} onPick={pickOperator} />;
else if (pop.step === "value" && popField) {
const set = (p: Partial<Draft>) => setDraft((d) => ({ ...d, ...p }));
const submit = () => apply();
body =
popField.type === "text" ? (
<TextEditor draft={draft} set={set} submit={submit} />
) : popField.type === "select" ? (
<SelectEditor uid={uid} field={popField} draft={draft} set={set} submit={submit} />
) : popField.type === "number" ? (
<NumberEditor draft={draft} set={set} submit={submit} />
) : popField.type === "date" ? (
<DateEditor draft={draft} set={set} today={todayKey} submit={submit} />
) : null;
}
}
return (
<div
ref={rootRef}
data-crucible="sift"
className={cn("@container relative w-full text-[13px] text-white", className)}
style={accentStyle}
onKeyDown={onRootKey}
{...rest}
>
<motion.div
key={introKey}
initial={{ opacity: 0, y: 8 }}
animate={play ? { opacity: 1, y: 0 } : undefined}
transition={reduced ? { duration: 0.15, ease: "easeOut" } : { duration: 0.45, ease: EASE }}
className="rounded-2xl border border-white/[0.08] bg-[#111113]/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_24px_60px_-28px_rgba(0,0,0,0.8)] backdrop-blur-sm"
>
{/* header: views · count · save */}
<div className="flex min-h-12 flex-wrap items-center gap-x-2 gap-y-1 border-b border-white/[0.06] px-2 py-1.5">
{showViews ? (
<ViewsMenu
uid={uid}
views={views}
activeId={activeViewId}
edited={edited}
open={viewsOpen}
setOpen={(o) => {
if (o) setPop(null);
setViewsOpen(o);
}}
onPick={pickView}
/>
) : (
<span className="flex h-8 items-center gap-2 px-2 text-[13px] font-medium text-white/85">
<span className="text-white/45">{Icon.funnel}</span>Filters
</span>
)}
<div className="ml-auto flex min-w-0 items-center gap-2.5">
{count !== undefined && (
<span className="flex items-baseline gap-1 whitespace-nowrap px-1 text-[12.5px] text-white/45" aria-live="polite">
{reduced ? (
<span className="font-medium tabular-nums text-white">{count}</span>
) : (
<Tally
key={count}
value={count}
start={prevCount ?? count}
duration={0.55}
stagger={0.03}
glow={false}
glint={false}
className="font-medium text-white"
/>
)}
{total !== undefined && <span className="tabular-nums">of {total}</span>}
<span>{count === 1 ? noun.one : noun.other}</span>
</span>
)}
{onSaveView && (
<AnimatePresence mode="popLayout" initial={false}>
{saving ? (
<motion.form
key="save-form"
initial={reduced ? { opacity: 0 } : { opacity: 0, width: 96 }}
animate={{ opacity: 1, width: "auto" }}
exit={{ opacity: 0 }}
transition={reduced ? FADE : { duration: 0.22, ease: EASE }}
onSubmit={(e) => {
e.preventDefault();
void submitSave();
}}
className="flex items-center gap-1"
>
<motion.input
ref={saveInput}
value={saving.name}
disabled={saving.status === "pending"}
onChange={(e) => setSaving({ name: e.target.value, status: "idle" })}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setSaving(null);
}
}}
onBlur={(e) => {
if (!saving.name.trim() && !e.currentTarget.form?.contains(e.relatedTarget as Node)) setSaving(null);
}}
animate={saving.status === "error" && !reduced ? { x: [0, -4, 4, -2, 0] } : { x: 0 }}
transition={{ duration: 0.3 }}
aria-label="View name"
aria-invalid={saving.status === "error" || undefined}
title={saving.error}
placeholder={saving.error ?? "View name"}
maxLength={40}
className={cn(
"h-8 w-[min(11rem,40cqw)] rounded-lg border bg-white/[0.04] px-2.5 text-[13px] text-white placeholder:text-white/35 outline-none transition-colors focus:border-[color-mix(in_srgb,var(--sift-accent)_50%,transparent)] disabled:opacity-60",
saving.status === "error" ? "border-red-400/50 placeholder:text-red-300/70" : "border-white/10"
)}
/>
<button
type="submit"
aria-label="Save view"
disabled={saving.status === "pending"}
className={cn(
"grid size-8 shrink-0 place-items-center rounded-lg transition-[filter] hover:brightness-110 disabled:cursor-progress",
FOCUS
)}
style={{ background: "var(--sift-accent)", color: "var(--sift-ink)" }}
>
{saving.status === "pending" ? (
<motion.span
aria-hidden
className="block size-3.5 rounded-full border-2 border-current border-t-transparent"
animate={reduced ? undefined : { rotate: 360 }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
) : (
Icon.check
)}
</button>
</motion.form>
) : (
<motion.button
key="save-btn"
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: !filters.length || !edited ? 0.35 : 1 }}
exit={{ opacity: 0 }}
transition={FADE}
onClick={startSave}
disabled={!filters.length || !edited}
className={cn(
"h-8 whitespace-nowrap rounded-lg border border-white/10 px-2.5 text-[12.5px] font-medium text-white/75 transition-colors hover:border-white/15 hover:bg-white/[0.04] hover:text-white disabled:pointer-events-none",
FOCUS
)}
>
Save view
</motion.button>
)}
</AnimatePresence>
)}
</div>
</div>
{/* chips row — views crossfade by stacking epochs in one grid cell */}
<div className="grid p-2">
<AnimatePresence initial={false}>
<motion.div
key={epoch}
className="relative col-start-1 row-start-1"
initial={{ opacity: 0, filter: reduced ? "blur(0px)" : "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: reduced ? "blur(0px)" : "blur(3px)" }}
transition={{ duration: reduced ? 0.15 : 0.24, ease: "easeOut" }}
>
<LayoutGroup id={`${uid}-${epoch}`}>
<div role="group" aria-label="Applied filters" className="relative flex flex-wrap items-center gap-1.5">
<AnimatePresence mode="popLayout" initial={true}>
{filters.map((f, i) => {
const field = fieldOf(f.field);
if (!field) return null;
const val = summarize(field, f);
const on = pop?.anchor === f.id;
const opts = field.type === "select" ? optionsOf(field) : [];
const firstColor =
field.type === "select" && Array.isArray(f.value) && f.value.length === 1
? (opts.find((o) => o.value === (f.value as string[])[0])?.color ?? "")
: "";
return (
<motion.div
key={f.id}
layout={layoutOn ? "position" : false}
className="flex items-center gap-1.5"
initial={{ opacity: 0, scale: 0.86, y: 3 }}
animate={play ? { opacity: 1, scale: 1, y: 0 } : undefined}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.86, filter: "blur(2px)" }}
transition={chipTransition(i)}
>
{i > 0 && (
<button
type="button"
onClick={() => setConj(conj === "and" ? "or" : "and")}
aria-label={`Combine filters with ${conj}. Switch to ${conj === "and" ? "or" : "and"}`}
className={cn(
"h-6 min-w-8 rounded-md px-1.5 text-[11.5px] font-medium uppercase tracking-[0.06em] text-white/40 transition-colors hover:bg-white/[0.05] hover:text-white/80",
FOCUS
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={conj}
className="inline-block"
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.16, ease: EASE }}
>
{conj}
</motion.span>
</AnimatePresence>
</button>
)}
<div
data-chip={f.id}
role="group"
aria-label={`Filter: ${describe(field, f)}. Backspace removes, arrow keys move between filters`}
onKeyDown={(e) => onChipKey(e, i, f.id)}
className={cn(
"flex h-8 max-w-full items-stretch overflow-hidden rounded-[9px] border bg-white/[0.035] transition-[border-color,box-shadow,background-color] duration-200",
on
? "border-[color-mix(in_srgb,var(--sift-accent)_55%,transparent)] bg-[color-mix(in_srgb,var(--sift-accent)_7%,transparent)] shadow-[0_0_0_3px_color-mix(in_srgb,var(--sift-accent)_12%,transparent)]"
: "border-white/[0.09] hover:border-white/[0.14]"
)}
>
<ChipPart
part="field"
label={`Change property (${field.label})`}
onClick={(e) => openEdit(f, "field", e.currentTarget.closest("[data-chip]"))}
className="gap-1.5 pl-2.5 text-white/60"
>
<span className="text-white/40">{field.icon ?? TYPE_ICON[field.type]}</span>
<span className="hidden whitespace-nowrap @[18rem]:inline">{field.label}</span>
</ChipPart>
<ChipPart
part="op"
label={`Change operator (${opLabel(field.type, f.operator)})`}
onClick={(e) => openEdit(f, "operator", e.currentTarget.closest("[data-chip]"))}
className={cn("text-white/40", opLabel(field.type, f.operator).length === 1 && "font-mono")}
>
<span className="whitespace-nowrap">{opLabel(field.type, f.operator)}</span>
</ChipPart>
{val && (
<ChipPart
part="value"
label={`Change value (${val})`}
onClick={(e) => openEdit(f, "value", e.currentTarget.closest("[data-chip]"))}
className="min-w-0 gap-1.5 font-medium text-[var(--sift-accent)]"
>
<Dot color={firstColor} />
<span className="truncate tabular-nums">{val}</span>
</ChipPart>
)}
<button
type="button"
aria-label={`Remove filter ${describe(field, f)}`}
onClick={() => removeChip(f.id, true)}
className="grid w-7 shrink-0 place-items-center border-l border-white/[0.06] text-white/35 transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:bg-white/[0.08] focus-visible:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sift-accent)]"
>
{Icon.x}
</button>
</div>
</motion.div>
);
})}
{/* Add + Clear all wrap together, so "Clear all" never lands alone on a row. */}
<motion.div
key="tail"
layout={layoutOn ? "position" : false}
className={cn(
"flex flex-1 items-center gap-1.5",
// nowrap → the pair's min-content is both buttons, so the row
// breaks before them rather than between them.
filters.length ? "flex-nowrap" : "min-w-0 flex-wrap"
)}
>
<motion.button
ref={addRef}
type="button"
aria-haspopup="dialog"
aria-expanded={pop?.anchor === "add"}
aria-keyshortcuts="F"
onClick={() => (pop?.anchor === "add" ? closePop(true) : openAdd())}
initial={{ opacity: 0, y: 3 }}
animate={play ? { opacity: 1, y: 0 } : undefined}
transition={reduced ? REDUCED_T : { ...SPRING, delay: introDone ? 0 : 0.14 + filters.length * 0.05 }}
className={cn(
"flex h-8 items-center gap-1.5 rounded-[9px] border border-dashed px-2.5 text-[12.5px] font-medium transition-colors",
pop?.anchor === "add"
? "border-[color-mix(in_srgb,var(--sift-accent)_55%,transparent)] bg-white/[0.05] text-white"
: "border-white/15 text-white/60 hover:border-white/25 hover:bg-white/[0.03] hover:text-white",
FOCUS
)}
>
{Icon.plus}
Filter
<kbd className="ml-0.5 hidden rounded border border-white/10 px-1 font-sans text-[10px] leading-[14px] text-white/35 @[30rem]:inline">
F
</kbd>
</motion.button>
<AnimatePresence initial={false} mode="popLayout">
{!filters.length && (
<motion.span
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={FADE}
className="px-1 text-[12.5px] text-white/35"
>
No filters — showing everything
</motion.span>
)}
{filters.length > 0 && (
<motion.button
key="clear"
type="button"
onClick={clearAll}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={FADE}
className={cn(
"ml-auto h-8 whitespace-nowrap rounded-lg px-2 text-[12.5px] text-white/45 underline-offset-4 transition-colors hover:text-white hover:underline",
FOCUS
)}
>
Clear all
</motion.button>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
</div>
</LayoutGroup>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
{/* popover */}
<AnimatePresence>
{pop && popPos && (
<motion.div
ref={popRef}
role="dialog"
aria-label={
pop.step === "field"
? "Choose a property"
: pop.step === "operator"
? `${popField?.label ?? ""}: choose an operator`
: `${popField?.label ?? ""} ${popOp ?? ""}: choose a value`
}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
transition={reduced ? FADE : { duration: 0.18, ease: EASE }}
style={{ left: popPos.left, top: popPos.top, width: popPos.width, transformOrigin: "top left" }}
className="absolute z-50 overflow-hidden rounded-xl border border-white/10 bg-[#141416] shadow-[0_28px_60px_-16px_rgba(0,0,0,0.85),0_0_0_1px_rgba(0,0,0,0.45)]"
>
{/* step header */}
<div className="flex h-10 items-center gap-1.5 border-b border-white/[0.06] px-1.5">
{pop.step !== "field" ? (
<button
type="button"
aria-label="Back"
onClick={() => go(pop.step === "value" ? "operator" : "field")}
className={cn("grid size-7 place-items-center rounded-md text-white/50 hover:bg-white/[0.06] hover:text-white", FOCUS)}
>
{Icon.back}
</button>
) : (
<span className="grid size-7 place-items-center text-white/40">{Icon.funnel}</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-[12.5px]">
{pop.step === "field" ? (
<span className="font-medium text-white/80">{draft.editingId ? "Change property" : "Add filter"}</span>
) : (
<>
<span className="text-white/40">{popField?.icon ?? (popField && TYPE_ICON[popField.type])}</span>
<span className="truncate font-medium text-white/85">{popField?.label}</span>
{pop.step === "value" && popOp && (
<span className={cn("shrink-0 text-white/40", popOp?.length === 1 && "font-mono")}>{popOp}</span>
)}
</>
)}
</div>
<StepDots step={pop.step} />
</div>
<PopBody stepKey={pop.step} dir={pop.dir} reduced={reduced}>
{body}
</PopBody>
{pop.step === "value" && popField && popField.type !== "boolean" && (
<div className="flex items-center gap-2 border-t border-white/[0.06] px-2 py-2">
<span className="min-w-0 flex-1 truncate pl-1 text-[12px] text-white/40">
{popField.type === "select"
? `${draft.sel.length} selected`
: popField.type === "date"
? draft.preset
? PRESET_LABEL[draft.preset]
: draft.dateA
? draft.operator === "between"
? `${fmtDay(draft.dateA)} – ${draft.dateB ? fmtDay(draft.dateB) : "…"}`
: fmtDay(draft.dateA)
: "Pick a date"
: "Enter to apply"}
</span>
<button
type="button"
onClick={() => apply()}
disabled={!canApply}
className={cn(
"h-8 rounded-lg px-3.5 text-[12.5px] font-semibold transition-[filter,opacity,transform] duration-150 hover:brightness-110 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-35",
"outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#141416]"
)}
style={{ background: "var(--sift-accent)", color: "var(--sift-ink)" }}
>
{draft.editingId ? "Update" : "Apply"}
</button>
</div>
)}
</motion.div>
)}
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
</div>
);
}
/* ================================================================ internals */
function usePrev<T>(value: T): T | undefined {
const [state, setState] = React.useState<{ current: T; previous: T | undefined }>({ current: value, previous: undefined });
if (!Object.is(state.current, value)) setState({ current: value, previous: state.current });
return state.previous;
}
function ChipPart({
part,
label,
onClick,
className,
children,
}: {
part: string;
label: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
className?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
data-part={part}
aria-label={label}
onClick={onClick}
className={cn(
"flex min-w-0 items-center px-2 text-[12.5px] transition-colors hover:bg-white/[0.05] focus-visible:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sift-accent)]",
part !== "field" && "border-l border-white/[0.06]",
className
)}
>
{children}
</button>
);
}
function StepDots({ step }: { step: Step }) {
const n = STEP_INDEX[step];
return (
<span aria-hidden className="flex items-center gap-1 pr-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1 rounded-full transition-all duration-300"
style={{
width: i === n ? 12 : 4,
background: i <= n ? "var(--sift-accent)" : "rgba(255,255,255,0.15)",
opacity: i === n ? 1 : i < n ? 0.5 : 1,
}}
/>
))}
</span>
);
}
/** Slides step content horizontally and springs the popover height to fit. */
function PopBody({
stepKey,
dir,
reduced,
children,
}: {
stepKey: string;
dir: 1 | -1;
reduced: boolean;
children: React.ReactNode;
}) {
const inner = React.useRef<HTMLDivElement>(null);
const [h, setH] = React.useState<number | "auto">("auto");
React.useEffect(() => {
const el = inner.current;
if (!el) return;
const ro = new ResizeObserver(() => setH(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div
animate={{ height: h }}
initial={false}
transition={reduced ? { duration: 0 } : SLIDE}
className="relative overflow-hidden"
>
<div ref={inner} className="relative">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.div
key={stepKey}
custom={dir}
variants={{
enter: (d: number) => (reduced ? { opacity: 0 } : { opacity: 0, x: d * 28 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => (reduced ? { opacity: 0 } : { opacity: 0, x: d * -28 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={reduced ? FADE : { ...SLIDE, opacity: { duration: 0.16 } }}
className="w-full"
>
{children}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
);
}

























