A component library by Mendy Landa
Mendy UI
My collection of React components. Table, Filters, and Sheet: the components I use across projects, with the small interactions already taken care of.
Get startednpm install @mendylanda/uiReact 19 · TypeScript · Tailwind optional
Table + Filters
Live components. Try sorting, filtering, or selecting cells.UI-042
Restore focus to the trigger after Escape
In progress
UI-041
Show an empty state when no rows match
Todo
UI-040
Add keyboard navigation to the option list
In progress
UI-039
Write the installation guide
Done
UI-038
Fix chip hover contrast in dark mode
Todo
UI-037
Wrap filter chips on narrow screens
In progress
UI-036
Label edit and remove buttons for screen readers
Done
UI-035
Save text filters with Enter
Todo
8 of 8 issuesDrag to select cells. Copy with ⌘C or Ctrl+C.
View source
"use client";
import { Circle, CircleCheck, Clock3, Flag, Users } from "lucide-react";
import { DataTable, defineColumns, useDataTable } from "@mendylanda/ui/table";
import { NuqsAdapter } from "nuqs/adapters/react";
import { defineFilters, filter } from "@mendylanda/ui/filters";
import { useUrlFilters } from "@mendylanda/ui/filters/nuqs";
const issues = [
{
id: "UI-042",
title: "Restore focus to the trigger after Escape",
status: "in-progress",
priority: "high",
assignee: "Mendy",
},
{
id: "UI-041",
title: "Show an empty state when no rows match",
status: "todo",
priority: "medium",
assignee: "Alex",
},
{
id: "UI-040",
title: "Add keyboard navigation to the option list",
status: "in-progress",
priority: "high",
assignee: "Sam",
},
{
id: "UI-039",
title: "Write the installation guide",
status: "done",
priority: "medium",
assignee: "Mendy",
},
{
id: "UI-038",
title: "Fix chip hover contrast in dark mode",
status: "todo",
priority: "low",
assignee: "Jordan",
},
{
id: "UI-037",
title: "Wrap filter chips on narrow screens",
status: "in-progress",
priority: "high",
assignee: "Alex",
},
{
id: "UI-036",
title: "Label edit and remove buttons for screen readers",
status: "done",
priority: "high",
assignee: "Sam",
},
{
id: "UI-035",
title: "Save text filters with Enter",
status: "todo",
priority: "medium",
assignee: "Mendy",
},
];
const statuses = [
{ value: "todo", label: "Todo" },
{ value: "in-progress", label: "In progress" },
{ value: "done", label: "Done" },
];
const priorities = [
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
const people = ["Mendy", "Alex", "Sam", "Jordan"].map((name) => ({ value: name, label: name }));
function StatusIcon({ status }: { status: string }) {
if (status === "done")
return (
<CircleCheck className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
);
if (status === "in-progress")
return <Clock3 className="size-3.5 text-amber-600 dark:text-amber-400" aria-hidden="true" />;
return <Circle className="size-3.5 text-muted-foreground" aria-hidden="true" />;
}
const definitions = defineFilters({
issueId: filter.tokens({
label: "Issue ID",
menu: false,
recognize: (token) => (/^UI-\d+$/i.test(token) ? [token.toUpperCase()] : undefined),
}),
status: filter.select({
label: "Status",
searchable: false,
options: statuses,
suggestion: { value: "todo" },
}),
priority: filter.select({
label: "Priority",
searchable: false,
icon: <Flag />,
options: priorities,
}),
assignee: filter.multiSelect({
label: "Assignee",
icon: <Users />,
options: people,
searchLabel: "Search assignees",
editorLabel: "Choose assignees",
suggestion: { value: ["Mendy"] },
}),
title: filter.text({
label: "Title",
searchLabel: "Title contains",
validate: (value) => (value ? undefined : "Enter a title to search for."),
}),
});
const columns = defineColumns<(typeof issues)[number]>((column) => [
column.accessor("id", { label: "ID", size: 110, grow: false, pin: "start" }),
column.accessor("title", { label: "Issue", size: 380, grow: 2 }),
column.accessor("status", {
label: "Status",
size: 150,
render: ({ value }) => (
<span className="flex items-center gap-2">
<StatusIcon status={value} />
{statuses.find((item) => item.value === value)?.label}
</span>
),
copy: (value) => statuses.find((item) => item.value === value)?.label ?? value,
}),
column.accessor("priority", {
label: "Priority",
size: 110,
render: ({ value }) => <span className="capitalize">{value}</span>,
}),
column.accessor("assignee", { label: "Assignee", size: 130 }),
]);
export function FiltersDemo() {
return (
<NuqsAdapter>
<IssueFilters />
</NuqsAdapter>
);
}
function IssueFilters() {
const filters = useUrlFilters(definitions, { scope: "issues" });
const { status, priority, assignee, title, issueId } = filters.values;
const visible = issues.filter(
(issue) =>
`${issue.id} ${issue.title}`.toLowerCase().includes(filters.search.trim().toLowerCase()) &&
(!issueId?.length || issueId.includes(issue.id)) &&
(!status || issue.status === status) &&
(!priority || issue.priority === priority) &&
(!assignee?.length || assignee.includes(issue.assignee)) &&
(!title || issue.title.toLowerCase().includes(title.toLowerCase())),
);
const table = useDataTable({
rows: visible,
columns,
getRowId: (row) => row.id,
enableRowSelection: true,
});
return (
<section aria-label="Interactive issue filters" className="min-w-0">
<DataTable
table={table}
filters={filters}
label="Issues"
height={397}
filterBar={{
searchLabel: "Search issues",
searchPlaceholder: "Search or paste an issue ID",
}}
footer={
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span role="status">
{visible.length} of {issues.length} issues
</span>
<span>Drag to select cells. Copy with ⌘C or Ctrl+C.</span>
</div>
}
/>
</section>
);
}
Table
Typed columns, virtual scrolling, cell selection and copying. Start with rows and columns; connect your queries when you need to.
Filters
Search, menus, editable chips, and typed state. Use it with a table, a list, or anywhere your application needs filters.
Sheet
Side panels with automatic stacking, pinning, and protection for unsaved changes.