Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 232 additions & 67 deletions apps/web/src/components/files/FilterControls.tsx
Original file line number Diff line number Diff line change
@@ -1,115 +1,280 @@
/**
* The Files filter vocabulary (spec: "Files views"): Type (kind or device), Repo, PR state.
* Each view composes the controls it needs; values live in the URL.
* The Files filter vocabulary (spec: "Files views"): Type, Device, Repo/Project,
* PR state, Merged only. Every view shows one bar: optional search, one
* Filters button (with a count) that opens a popover of sections, optional
* trailing controls, and the active filters as removable chips below.
* Values live in the URL.
*/
import { Select } from "@uploads/ui";
import { Checkbox } from "@uploads/ui/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@uploads/ui/components/ui/popover";
import { SlidersHorizontalIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
import { FILE_SURFACES, type FileSurface, type FileTypeClass } from "@uploads/comment-render/scope";
import {
FILE_SURFACE_LABELS,
FILE_TYPE_LABELS,
parseFileTypeParam,
parsePrStateParam,
parseSurfaceParam,
type PrStateFilter,
} from "../../lib/files-view-state";

const SELECT_CLASS = "ul-select--sm min-h-9 max-w-full text-base sm:text-[13px] box-border";
const FILE_TYPE_ORDER: FileTypeClass[] = ["screenshot", "video", "other"];
const PR_STATE_LABELS: Record<PrStateFilter, string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};

export interface TypeFilter {
type: FileTypeClass | null;
surface: FileSurface | null;
}

/**
* One select for what a file is: its kind (by extension) or the device it
* shows (the classifier's `ai.surface`). Picking one clears the other, so the
* bar keeps a single control; the URL keeps `type` and `surface` apart.
*/
export function TypeSelect({
export interface FilterChip {
key: string;
label: string;
onRemove: () => void;
}

export function FilterBar({
search,
trailing,
chips,
children,
}: {
search?: ReactNode;
trailing?: ReactNode;
/** Active filters; their count badges the Filters button. */
chips: FilterChip[];
/** Popover body: FilterSection blocks. */
children: ReactNode;
}) {
return (
<div className="wsp-filter grid gap-2">
<div className="flex items-stretch gap-2">
{search}
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label={chips.length > 0 ? `Filters, ${chips.length} active` : "Filters"}
className={`flex min-h-9 flex-none items-center gap-2 rounded-[6px] border px-3 text-[13px] cursor-pointer box-border hover:text-fg ${chips.length > 0 ? "border-primary/50 bg-primary/10 text-fg" : "border-line bg-transparent text-muted-foreground"}`}
/>
}
>
<SlidersHorizontalIcon className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Filters</span>
{chips.length > 0 && (
<span className="rounded-full bg-primary px-1.5 text-[11px] leading-[18px] text-primary-foreground tabular-nums">
{chips.length}
</span>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(18rem,calc(100vw-2rem))] gap-3 p-3">
{children}
</PopoverContent>
</Popover>
{trailing}
</div>
{chips.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5" aria-label="Active filters">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className="flex h-[26px] items-center gap-1 rounded-full border border-line bg-panel px-2.5 text-[12px] text-fg cursor-pointer hover:border-fg/40"
aria-label={`Remove filter: ${chip.label}`}
onClick={chip.onRemove}
>
{chip.label}
<XIcon className="size-3 text-muted-foreground" aria-hidden="true" />
</button>
))}
{chips.length > 1 && (
<button
type="button"
className="text-btn ml-1 text-[12px]"
onClick={() => {
for (const chip of chips) chip.onRemove();
}}
>
Clear
</button>
)}
</div>
)}
</div>
);
}

export function FilterSection({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid gap-1.5">
<div className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">{label}</div>
{children}
</div>
);
}

/** Single choice as pills; pressing the active pill clears it. */
function ChoicePills<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly { value: T; label: string }[];
value: T | null;
onChange: (next: T | null) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
className="rounded-full border border-line px-2.5 py-1 text-[13px] text-muted-foreground cursor-pointer hover:text-fg aria-pressed:border-primary aria-pressed:bg-primary/10 aria-pressed:text-fg"
onClick={() => onChange(value === option.value ? null : option.value)}
>
{option.label}
</button>
))}
</div>
);
}

const TYPE_OPTIONS = FILE_TYPE_ORDER.map((type) => ({
value: type,
label: FILE_TYPE_LABELS[type],
}));
const SURFACE_OPTIONS = FILE_SURFACES.map((surface) => ({
value: surface,
label: FILE_SURFACE_LABELS[surface],
}));
const STATE_OPTIONS = (["open", "merged", "closed"] as const).map((state) => ({
value: state,
label: PR_STATE_LABELS[state],
}));

/** Type (by extension) and Device (the classifier's `ai.surface`); they combine. */
export function TypeSections({
value,
onChange,
}: {
value: TypeFilter;
onChange: (next: TypeFilter) => void;
}) {
const current = value.surface ? `surface:${value.surface}` : (value.type ?? "");
return (
<Select
className={`${SELECT_CLASS} flex-[0_1_10rem] min-w-[9rem]`}
aria-label="Filter by type"
value={current}
onChange={(event) => {
const raw = event.target.value;
onChange(
raw.startsWith("surface:")
? { type: null, surface: parseSurfaceParam(raw.slice("surface:".length)) }
: { type: parseFileTypeParam(raw), surface: null },
);
}}
>
<option value="">All types</option>
{FILE_TYPE_ORDER.map((type) => (
<option key={type} value={type}>
{FILE_TYPE_LABELS[type]}
</option>
))}
<optgroup label="Device">
{FILE_SURFACES.map((surface) => (
<option key={surface} value={`surface:${surface}`}>
{FILE_SURFACE_LABELS[surface]}
</option>
))}
</optgroup>
</Select>
<>
<FilterSection label="Type">
<ChoicePills
label="Filter by type"
options={TYPE_OPTIONS}
value={value.type}
onChange={(type) => onChange({ ...value, type })}
/>
</FilterSection>
<FilterSection label="Device">
<ChoicePills
label="Filter by device"
options={SURFACE_OPTIONS}
value={value.surface}
onChange={(surface) => onChange({ ...value, surface })}
/>
</FilterSection>
</>
);
}

export function RepoSelect({
export function typeChips(value: TypeFilter, onChange: (next: TypeFilter) => void): FilterChip[] {
const chips: FilterChip[] = [];
if (value.type) {
chips.push({
key: "type",
label: FILE_TYPE_LABELS[value.type],
onRemove: () => onChange({ ...value, type: null }),
});
}
if (value.surface) {
chips.push({
key: "surface",
label: FILE_SURFACE_LABELS[value.surface],
onRemove: () => onChange({ ...value, surface: null }),
});
}
return chips;
}

/** Repo (PR view) or project (By page) picker; "" means all. */
export function ScopeSelect({
label,
allLabel,
value,
options,
onChange,
}: {
label: string;
allLabel: string;
value: string;
options: string[];
onChange: (repo: string) => void;
onChange: (next: string) => void;
}) {
const all = value && !options.includes(value) ? [...options, value] : options;
return (
<Select
className={`${SELECT_CLASS} flex-[0_1_16rem] min-w-[12rem]`}
aria-label="Filter by repo"
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">All repos</option>
{all.map((repo) => (
<option key={repo} value={repo}>
{repo}
</option>
))}
</Select>
<FilterSection label={label}>
<Select
className="ul-select--sm min-h-9 w-full text-base sm:text-[13px] box-border"
aria-label={`Filter by ${label.toLowerCase()}`}
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{allLabel}</option>
{all.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</FilterSection>
);
}

export function PrStateSelect({
export function PrStateSection({
value,
onChange,
}: {
value: PrStateFilter | null;
onChange: (state: PrStateFilter | null) => void;
}) {
return (
<Select
className={`${SELECT_CLASS} flex-[0_1_9rem] min-w-[8rem]`}
aria-label="Filter by pull request state"
value={value ?? ""}
onChange={(event) => onChange(parsePrStateParam(event.target.value))}
>
<option value="">Any state</option>
<option value="open">Open</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
</Select>
<FilterSection label="State">
<ChoicePills
label="Filter by pull request state"
options={STATE_OPTIONS}
value={value}
onChange={onChange}
/>
</FilterSection>
);
}

export function prStateLabel(state: PrStateFilter): string {
return PR_STATE_LABELS[state];
}

export function MergedOnlyCheckbox({
checked,
onChange,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 text-[13px] cursor-pointer">
<Checkbox checked={checked} onCheckedChange={(next) => onChange(next === true)} />
Merged only
</label>
);
}
Loading
Loading