Tutorial Filters
Filter controls for browsing tutorials by category or difficulty.
Filter controls for tutorial listings.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/tutorial-filters.jsonbash
Code
"use client";
import { memo } from "react";
import { Badge } from "../badge";
export type TutorialFiltersLabels = {
activeFilters: string;
clear: string;
clearAll: string;
difficulty: Record<string, string>;
difficultyLabel: string;
searchFilter: string;
searchLabel: string;
searchPlaceholder: string;
tagsLabel: string;
};
export type FilterUpdates = {
difficulty?: string;
search?: string;
tags?: string[];
};
export type TutorialFiltersProps = {
currentDifficulty: string;
currentTags: string[];
difficultyOptions?: string[];
isPending?: boolean;
labels: TutorialFiltersLabels;
onFilterChange: (updates: FilterUpdates) => void;
searchQuery: string;
tags: string[];
};
function SearchInput({
isPending,
labels,
onSearchChange,
searchQuery,
}: {
isPending: boolean;
labels: TutorialFiltersLabels;
onSearchChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
searchQuery: string;
}): React.ReactNode {
return (
<div>
<label className="sr-only" htmlFor="tutorial-search">
{labels.searchLabel}
</label>
<input
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
defaultValue={searchQuery}
disabled={isPending}
id="tutorial-search"
onChange={onSearchChange}
placeholder={labels.searchPlaceholder}
type="text"
/>
</div>
);
}
function DifficultyFilter({
activeDifficulty,
difficultyOptions,
isPending,
labels,
onDifficultyChange,
}: {
activeDifficulty: string;
difficultyOptions: string[];
isPending: boolean;
labels: TutorialFiltersLabels;
onDifficultyChange: (difficulty: string) => void;
}): React.ReactNode {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium">{labels.difficultyLabel}</span>
</div>
<div className="flex flex-wrap gap-2">
{difficultyOptions.map((difficulty) => {
const isActive = difficulty === activeDifficulty;
return (
<button
className={`px-3 py-1 text-sm rounded-lg border transition-colors ${
isActive
? "bg-primary text-primary-foreground border-transparent"
: "bg-background text-foreground border-border hover:bg-muted"
}`}
disabled={isPending}
key={difficulty}
onClick={() => {
onDifficultyChange(difficulty);
}}
type="button"
>
<span className="capitalize">
{labels.difficulty[difficulty] || difficulty}
</span>
</button>
);
})}
</div>
</div>
);
}
function TagFilter({
currentTags,
labels,
onClearTags,
onTagToggle,
tags,
}: {
currentTags: string[];
labels: TutorialFiltersLabels;
onClearTags: () => void;
onTagToggle: (tag: string) => void;
tags: string[];
}): React.ReactNode {
if (tags.length === 0) {
return null;
}
return (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium">{labels.tagsLabel}</span>
{currentTags.length > 0 ? (
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onClearTags}
type="button"
>
{labels.clear}
</button>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isActive = currentTags.includes(tag);
return (
<Badge
className={`cursor-pointer transition-all ${
isActive
? "bg-primary text-primary-foreground border-transparent"
: "hover:bg-muted"
}`}
key={tag}
onClick={() => {
onTagToggle(tag);
}}
variant={isActive ? "default" : "outline"}
>
{tag}
</Badge>
);
})}
</div>
</div>
);
}
function ActiveFiltersSummary({
currentDifficulty,
currentTags,
labels,
onClearAll,
searchQuery,
}: {
currentDifficulty: string;
currentTags: string[];
labels: TutorialFiltersLabels;
onClearAll: () => void;
searchQuery: string;
}): React.ReactNode {
if (!currentDifficulty && currentTags.length === 0 && !searchQuery) {
return null;
}
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{labels.activeFilters}</span>
{currentDifficulty ? (
<Badge className="capitalize" variant="secondary">
{labels.difficulty[currentDifficulty] || currentDifficulty}
</Badge>
) : null}
{currentTags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
{searchQuery ? (
<Badge variant="secondary">
{labels.searchFilter} "{searchQuery}"
</Badge>
) : null}
<button
className="text-xs hover:underline"
onClick={onClearAll}
type="button"
>
{labels.clearAll}
</button>
</div>
);
}
const DEFAULT_DIFFICULTY_OPTIONS = [
"all",
"beginner",
"intermediate",
"advanced",
];
function TutorialFiltersImpl({
currentDifficulty,
currentTags,
difficultyOptions = DEFAULT_DIFFICULTY_OPTIONS,
isPending = false,
labels,
onFilterChange,
searchQuery,
tags,
}: TutorialFiltersProps): React.ReactNode {
const activeDifficulty = currentDifficulty || "all";
const handleDifficultyChange = (difficulty: string): void => {
onFilterChange({ difficulty });
};
const handleSearchChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
onFilterChange({ search: event.target.value });
};
const handleTagToggle = (tag: string): void => {
const newTags = currentTags.includes(tag)
? currentTags.filter((t) => t !== tag)
: [...currentTags, tag];
onFilterChange({ tags: newTags });
};
const handleClearAll = (): void => {
onFilterChange({ difficulty: "all", search: "", tags: [] });
const input = document.querySelector<HTMLInputElement>("#tutorial-search");
if (input) input.value = "";
};
return (
<div className="space-y-4 mb-8">
<SearchInput
isPending={isPending}
labels={labels}
onSearchChange={handleSearchChange}
searchQuery={searchQuery}
/>
<DifficultyFilter
activeDifficulty={activeDifficulty}
difficultyOptions={difficultyOptions}
isPending={isPending}
labels={labels}
onDifficultyChange={handleDifficultyChange}
/>
<TagFilter
currentTags={currentTags}
labels={labels}
onClearTags={() => {
onFilterChange({ tags: [] });
}}
onTagToggle={handleTagToggle}
tags={tags}
/>
<ActiveFiltersSummary
currentDifficulty={currentDifficulty}
currentTags={currentTags}
labels={labels}
onClearAll={handleClearAll}
searchQuery={searchQuery}
/>
</div>
);
}
export const TutorialFilters = memo(TutorialFiltersImpl);
TutorialFilters.displayName = "TutorialFilters";
typescript