
A searchable users table with role filters, row selection, and bulk role and status actions.
@esuiss
bases/radix/components/blocks/users-table.tsx
"use client"
import * as React from "react"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type NavItem = {
value: string
label: string
icon: React.ReactNode
badge?: React.ReactNode
group?: string
}
function NavSidebar({
items,
value: valueProp,
defaultValue,
onValueChange,
header,
footer,
}: {
items: NavItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
header?: React.ReactNode
footer?: React.ReactNode
}) {
const { isMobile, setOpenMobile } = useSidebar()
const [uncontrolledValue, setUncontrolledValue] = React.useState(
defaultValue ?? items[0]?.value
)
const value = valueProp ?? uncontrolledValue
const groups = [...new Set(items.map((item) => item.group ?? ""))]
return (
<Sidebar collapsible="icon">
{header && (
<SidebarHeader className="group-data-[collapsible=icon]:hidden">
{header}
</SidebarHeader>
)}
<SidebarContent>
{groups.map((group) => (
<SidebarGroup key={group}>
{group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{items
.filter((item) => (item.group ?? "") === group)
.map((item) => (
<SidebarMenuItem key={item.value}>
<SidebarMenuButton
isActive={item.value === value}
tooltip={item.label}
aria-current={item.value === value ? "page" : undefined}
onClick={() => {
if (valueProp === undefined) {
setUncontrolledValue(item.value)
}
onValueChange?.(item.value)
if (isMobile) setOpenMobile(false)
}}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
{item.badge !== undefined && (
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
{footer && (
<SidebarFooter className="group-data-[collapsible=icon]:hidden">
{footer}
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
)
}
const nav: NavItem[] = [
{
value: "users",
label: "Users",
icon: (
<IconPlaceholder
lucide="UsersIcon"
tabler="IconUsers"
hugeicons="UserGroupIcon"
phosphor="UsersIcon"
remixicon="RiGroupLine"
/>
),
},
{
value: "roles",
label: "Roles",
icon: (
<IconPlaceholder
lucide="ShieldIcon"
tabler="IconShield"
hugeicons="Shield01Icon"
phosphor="ShieldIcon"
remixicon="RiShieldLine"
/>
),
},
{
value: "log",
label: "Activity",
icon: (
<IconPlaceholder
lucide="ChartColumnIcon"
tabler="IconChartBar"
hugeicons="ChartColumnIcon"
phosphor="ChartBarIcon"
remixicon="RiBarChartLine"
/>
),
},
{
value: "settings",
label: "Settings",
icon: (
<IconPlaceholder
lucide="SettingsIcon"
tabler="IconSettings"
hugeicons="Settings01Icon"
phosphor="GearIcon"
remixicon="RiSettingsLine"
/>
),
},
]
const roles = ["Admin", "Editor", "Support", "Viewer"]
type Status = "Active" | "Suspended" | "Invited"
type User = {
name: string
email: string
role: string
status: Status
seen: string
hue: number
}
const initialUsers: User[] = [
{
name: "Jamie Rivera",
email: "jamie@example.com",
role: "Admin",
status: "Active",
seen: "2 min ago",
hue: 250,
},
{
name: "Morgan Lee",
email: "morgan@example.com",
role: "Editor",
status: "Active",
seen: "1 hr ago",
hue: 30,
},
{
name: "Riley Chen",
email: "riley@example.com",
role: "Support",
status: "Active",
seen: "Yesterday",
hue: 150,
},
{
name: "Taylor Kim",
email: "taylor@example.com",
role: "Viewer",
status: "Suspended",
seen: "3 days ago",
hue: 200,
},
{
name: "Avery Brooks",
email: "avery@example.com",
role: "Editor",
status: "Invited",
seen: "—",
hue: 330,
},
{
name: "Jordan Park",
email: "jordan@example.com",
role: "Support",
status: "Active",
seen: "5 hr ago",
hue: 100,
},
]
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.join("")
export function UsersTable() {
const [users, setUsers] = React.useState(initialUsers)
const [query, setQuery] = React.useState("")
const [role, setRole] = React.useState("All")
const [selected, setSelected] = React.useState<string[]>([])
const list = users.filter(
(user) =>
(role === "All" || user.role === role) &&
(!query ||
`${user.name} ${user.email}`
.toLowerCase()
.includes(query.toLowerCase()))
)
const toggle = (email: string) =>
setSelected((current) =>
current.includes(email)
? current.filter((item) => item !== email)
: [...current, email]
)
const bulk = (patch: Partial<User>) => {
setUsers((current) =>
current.map((user) =>
selected.includes(user.email) ? { ...user, ...patch } : user
)
)
setSelected([])
}
return (
<SidebarProvider>
<NavSidebar
items={nav}
defaultValue="users"
header={
<p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
<span className="font-bold">suiss</span> Admin
</p>
}
/>
<SidebarInset>
<div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
<div className="flex flex-wrap items-start gap-3">
<SidebarTrigger className="-ms-2 mt-0.5" />
<div className="flex flex-1 flex-col">
<h1 className="text-3xl font-semibold tracking-tight">Users</h1>
<p className="text-sm text-muted-foreground">
{users.length} people
</p>
</div>
<div className="flex w-full items-center gap-2 sm:w-auto">
<InputGroup className="h-8 flex-1 sm:w-48">
<InputGroupAddon>
<IconPlaceholder
lucide="SearchIcon"
tabler="IconSearch"
hugeicons="Search01Icon"
phosphor="MagnifyingGlassIcon"
remixicon="RiSearchLine"
/>
</InputGroupAddon>
<InputGroupInput
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
aria-label="Search users"
className="text-sm"
/>
</InputGroup>
<Button size="sm" className="gap-1">
<IconPlaceholder
lucide="PlusIcon"
tabler="IconPlus"
hugeicons="PlusSignIcon"
phosphor="PlusIcon"
remixicon="RiAddLine"
className="size-3.25"
/>
Invite
</Button>
</div>
</div>
<ToggleGroup
spacing={0}
type="single"
value={role}
onValueChange={(value: string) => {
if (!value) return
setRole(value)
setSelected([])
}}
aria-label="Role"
className="max-w-full overflow-x-auto"
>
{["All", ...roles].map((item) => (
<ToggleGroupItem
key={item}
value={item}
size="sm"
className="px-4 md:px-8"
>
{item}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="overflow-hidden rounded-xl bg-muted">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="ps-4 text-xs font-normal text-muted-foreground">
Name
</TableHead>
<TableHead className="hidden text-xs font-normal text-muted-foreground sm:table-cell">
Role
</TableHead>
<TableHead className="text-xs font-normal text-muted-foreground">
Status
</TableHead>
<TableHead className="hidden pe-4 text-end text-xs font-normal text-muted-foreground md:table-cell">
Last sign-in
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((user) => {
const isSelected = selected.includes(user.email)
return (
<TableRow
key={user.email}
data-selected={isSelected ? "true" : undefined}
onClick={() => toggle(user.email)}
className="group/row cursor-default border-0 select-none not-data-selected:even:bg-background/60 not-data-selected:hover:bg-foreground/5 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50 data-selected:bg-primary data-selected:text-primary-foreground"
>
<TableCell className="ps-4">
<span className="flex items-center gap-2.5">
<Checkbox
checked={isSelected}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggle(user.email)}
aria-label={`Select ${user.name}`}
className="absolute sr-only"
/>
<Avatar className="size-7">
<AvatarFallback
style={{
background: `linear-gradient(160deg, oklch(0.78 0.1 ${user.hue}), oklch(0.6 0.14 ${user.hue + 30}))`,
}}
className="text-xs text-white"
>
{initials(user.name)}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="text-sm font-semibold">
{user.name}
</span>
<span className="truncate text-xs text-muted-foreground group-data-selected/row:text-primary-foreground">
{user.email}
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-sm sm:table-cell">
{user.role}
</TableCell>
<TableCell
data-status={isSelected ? undefined : user.status}
className="text-sm data-[status=Invited]:text-muted-foreground data-[status=Suspended]:text-destructive group-data-selected/row:text-primary-foreground"
>
{user.status}
</TableCell>
<TableCell className="hidden pe-4 text-end text-sm text-muted-foreground group-data-selected/row:text-primary-foreground md:table-cell">
{user.seen}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{list.length === 0 && (
<p className="p-6 text-center text-sm text-muted-foreground">
No matching users.
</p>
)}
<div className="flex min-h-12 flex-wrap items-center gap-2 border-t border-border py-2 ps-4 pe-3">
<span
className="flex-1 text-xs text-muted-foreground"
aria-live="polite"
>
{selected.length
? `${selected.length} selected`
: "Click rows to select"}
</span>
{selected.length > 0 && (
<>
<Button
size="sm"
variant="secondary"
onClick={() => bulk({ role: "Editor" })}
>
Make Editor
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => bulk({ status: "Active" })}
>
Activate
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => bulk({ status: "Suspended" })}
>
Suspend
</Button>
</>
)}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}

























