# Roles Requests

An access request queue where admins approve or deny permission requests, with an approval length setting.

Roles Requests is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/roles-requests

**React**

**Example**

```tsx title="components/blocks/roles-requests.tsx"
"use client"

import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

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: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

type Decision = "approved" | "denied" | null

const initialRequests = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    hue: 30,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    hue: 150,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    hue: 100,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    hue: 200,
  },
]

export function RolesRequests() {
  const idPrefix = React.useId()
  const [requests, setRequests] = React.useState(() =>
    initialRequests.map((request) => ({
      ...request,
      decision: null as Decision,
    }))
  )
  const [duration, setDuration] = React.useState("Permanent")
  const open = requests.filter((request) => !request.decision).length

  const decide = (name: string, decision: Decision) => {
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    window.setTimeout(
      () =>
        setRequests((current) =>
          current.filter((request) => request.name !== name)
        ),
      900
    )
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="roles"
        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">
                Access requests
              </h1>
              <p className="text-sm text-muted-foreground" aria-live="polite">
                {open} pending {open === 1 ? "request" : "requests"}
              </p>
            </div>
            <div className="flex items-center gap-2">
              <span
                id={`${idPrefix}-duration`}
                className="text-sm text-muted-foreground"
              >
                Approval length
              </span>
              <Select
                value={duration}
                onValueChange={(value) => setDuration(value as string)}
              >
                <SelectTrigger
                  size="sm"
                  aria-labelledby={`${idPrefix}-duration`}
                  className="h-7.5"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {["24 hours", "7 days", "Permanent"].map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
          {requests.length > 0 ? (
            <ul className="overflow-hidden rounded-2xl bg-muted">
              {requests.map((request) => (
                <li
                  key={request.name}
                  data-closing={request.decision ? "" : undefined}
                  className="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] delay-500 duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] not-data-closing:delay-0 data-closing:grid-rows-[0fr] data-closing:opacity-0 motion-reduce:transition-none"
                >
                  <div className="flex min-h-0 items-start gap-3 overflow-hidden ps-4">
                    <Avatar className="mt-3 size-8.5">
                      <AvatarFallback
                        style={{
                          background: `linear-gradient(160deg, oklch(0.78 0.1 ${request.hue}), oklch(0.6 0.14 ${request.hue + 30}))`,
                        }}
                        className="text-white"
                      >
                        {request.name
                          .split(" ")
                          .map((part) => part[0])
                          .join("")}
                      </AvatarFallback>
                    </Avatar>
                    <div className="flex min-w-0 flex-1 flex-col gap-2.5 border-border py-3 pe-4 sm:flex-row sm:items-start sm:gap-3 [li+li_&]:border-t">
                      <div className="flex min-w-0 flex-1 flex-col gap-0.75">
                        <span className="flex items-center gap-2">
                          <span className="text-sm font-semibold">
                            {request.name}
                          </span>
                          <span className="text-xs text-muted-foreground">
                            {request.time}
                          </span>
                        </span>
                        <span className="text-sm">
                          Requests {request.permission}
                        </span>
                        <span className="text-sm text-muted-foreground">
                          {request.reason}
                        </span>
                      </div>
                      {request.decision ? (
                        <span
                          role="status"
                          data-decision={request.decision}
                          className="pt-0.5 text-sm font-semibold text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_45%)] data-[decision=denied]:text-destructive dark:text-green-500 dark:data-[decision=denied]:text-destructive"
                        >
                          {request.decision === "approved"
                            ? "Approved"
                            : "Denied"}
                        </span>
                      ) : (
                        <div className="flex flex-wrap gap-2 sm:justify-end">
                          <Button
                            size="sm"
                            variant="secondary"
                            onClick={() => decide(request.name, "denied")}
                          >
                            Deny
                          </Button>
                          <Button
                            size="sm"
                            onClick={() => decide(request.name, "approved")}
                          >
                            Approve
                          </Button>
                        </div>
                      )}
                    </div>
                  </div>
                </li>
              ))}
            </ul>
          ) : (
            <Empty className="gap-3 p-10 transition-[opacity,translate] duration-800 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">
              <EmptyHeader>
                <EmptyMedia className="mb-0 text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_35%)] dark:text-green-500">
                  <CheckIcon className="size-10" />
                </EmptyMedia>
                <EmptyTitle className="text-xl">
                  No pending requests.
                </EmptyTitle>
              </EmptyHeader>
              <EmptyContent>
                <button
                  type="button"
                  onClick={() =>
                    setRequests(
                      initialRequests.map((request) => ({
                        ...request,
                        decision: null,
                      }))
                    )
                  }
                  className="rounded-xs text-sm text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  Reset the sample ›
                </button>
              </EmptyContent>
            </Empty>
          )}
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

An access request queue where admins approve or deny permission requests, each showing the requester, the permission, a reason and when it was asked. An Approval length menu in the header sets how long approvals last. A decided row shows its outcome briefly and then collapses out of the list, and an empty state appears once the queue is clear. Use it for access reviews, join requests or any approval inbox.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/roles-requests
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks roles-requests
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Select](https://ui.suiss.app/docs/components/select), [Sidebar](https://ui.suiss.app/docs/components/sidebar).

3. Copy the source into `components/blocks/roles-requests.tsx`:

```tsx title="components/blocks/roles-requests.tsx"
"use client"

import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

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: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

type Decision = "approved" | "denied" | null

const initialRequests = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    hue: 30,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    hue: 150,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    hue: 100,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    hue: 200,
  },
]

export function RolesRequests() {
  const idPrefix = React.useId()
  const [requests, setRequests] = React.useState(() =>
    initialRequests.map((request) => ({
      ...request,
      decision: null as Decision,
    }))
  )
  const [duration, setDuration] = React.useState("Permanent")
  const open = requests.filter((request) => !request.decision).length

  const decide = (name: string, decision: Decision) => {
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    window.setTimeout(
      () =>
        setRequests((current) =>
          current.filter((request) => request.name !== name)
        ),
      900
    )
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="roles"
        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">
                Access requests
              </h1>
              <p className="text-sm text-muted-foreground" aria-live="polite">
                {open} pending {open === 1 ? "request" : "requests"}
              </p>
            </div>
            <div className="flex items-center gap-2">
              <span
                id={`${idPrefix}-duration`}
                className="text-sm text-muted-foreground"
              >
                Approval length
              </span>
              <Select
                value={duration}
                onValueChange={(value) => setDuration(value as string)}
              >
                <SelectTrigger
                  size="sm"
                  aria-labelledby={`${idPrefix}-duration`}
                  className="h-7.5"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {["24 hours", "7 days", "Permanent"].map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
          {requests.length > 0 ? (
            <ul className="overflow-hidden rounded-2xl bg-muted">
              {requests.map((request) => (
                <li
                  key={request.name}
                  data-closing={request.decision ? "" : undefined}
                  className="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] delay-500 duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] not-data-closing:delay-0 data-closing:grid-rows-[0fr] data-closing:opacity-0 motion-reduce:transition-none"
                >
                  <div className="flex min-h-0 items-start gap-3 overflow-hidden ps-4">
                    <Avatar className="mt-3 size-8.5">
                      <AvatarFallback
                        style={{
                          background: `linear-gradient(160deg, oklch(0.78 0.1 ${request.hue}), oklch(0.6 0.14 ${request.hue + 30}))`,
                        }}
                        className="text-white"
                      >
                        {request.name
                          .split(" ")
                          .map((part) => part[0])
                          .join("")}
                      </AvatarFallback>
                    </Avatar>
                    <div className="flex min-w-0 flex-1 flex-col gap-2.5 border-border py-3 pe-4 sm:flex-row sm:items-start sm:gap-3 [li+li_&]:border-t">
                      <div className="flex min-w-0 flex-1 flex-col gap-0.75">
                        <span className="flex items-center gap-2">
                          <span className="text-sm font-semibold">
                            {request.name}
                          </span>
                          <span className="text-xs text-muted-foreground">
                            {request.time}
                          </span>
                        </span>
                        <span className="text-sm">
                          Requests {request.permission}
                        </span>
                        <span className="text-sm text-muted-foreground">
                          {request.reason}
                        </span>
                      </div>
                      {request.decision ? (
                        <span
                          role="status"
                          data-decision={request.decision}
                          className="pt-0.5 text-sm font-semibold text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_45%)] data-[decision=denied]:text-destructive dark:text-green-500 dark:data-[decision=denied]:text-destructive"
                        >
                          {request.decision === "approved"
                            ? "Approved"
                            : "Denied"}
                        </span>
                      ) : (
                        <div className="flex flex-wrap gap-2 sm:justify-end">
                          <Button
                            size="sm"
                            variant="secondary"
                            onClick={() => decide(request.name, "denied")}
                          >
                            Deny
                          </Button>
                          <Button
                            size="sm"
                            onClick={() => decide(request.name, "approved")}
                          >
                            Approve
                          </Button>
                        </div>
                      )}
                    </div>
                  </div>
                </li>
              ))}
            </ul>
          ) : (
            <Empty className="gap-3 p-10 transition-[opacity,translate] duration-800 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">
              <EmptyHeader>
                <EmptyMedia className="mb-0 text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_35%)] dark:text-green-500">
                  <CheckIcon className="size-10" />
                </EmptyMedia>
                <EmptyTitle className="text-xl">
                  No pending requests.
                </EmptyTitle>
              </EmptyHeader>
              <EmptyContent>
                <button
                  type="button"
                  onClick={() =>
                    setRequests(
                      initialRequests.map((request) => ({
                        ...request,
                        decision: null,
                      }))
                    )
                  }
                  className="rounded-xs text-sm text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  Reset the sample ›
                </button>
              </EmptyContent>
            </Empty>
          )}
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { RolesRequests } from "@/components/blocks/roles-requests"
```

```tsx
<RolesRequests />
```

Replace `initialRequests` with your pending requests, each with a name, permission, reason, time and avatar hue. Call your API in `decide` and pass the selected `duration`. Requests are matched by name, so switch to a unique id, and remove the Reset the sample link from the empty state.

## Notes

Each decision is announced as it happens.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-roles-requests--default.light.webp)

```tsx title="uniwind/components/blocks/roles-requests.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  FadeInDown,
  FadeOut,
  LinearTransition,
  ReduceMotion,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const DURATIONS = ["24 hours", "7 days", "Permanent"]

type Decision = "approved" | "denied" | null

type Request = {
  name: string
  permission: string
  reason: string
  time: string
  tint: string
  decision: Decision
}

const INITIAL_REQUESTS: Request[] = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    tint: "bg-orange-500",
    decision: null,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    tint: "bg-green-500",
    decision: null,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    tint: "bg-teal-400",
    decision: null,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    tint: "bg-cyan-500",
    decision: null,
  },
]

const EASING_LAYOUT = LinearTransition.duration(400).reduceMotion(
  ReduceMotion.System
)

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-13">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function RequestRow({
  request,
  first,
  onDecide,
}: {
  request: Request
  first: boolean
  onDecide: (decision: "approved" | "denied") => void
}) {
  return (
    <AnimatedView
      layout={EASING_LAYOUT}
      exiting={FadeOut.duration(400).reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent className="bg-muted">
          <View className="flex-row items-start gap-3 ps-4">
            <Avatar className="mt-3 size-8.5">
              <AvatarFallback className={request.tint}>
                {initials(request.name)}
              </AvatarFallback>
            </Avatar>
            <View
              className={cn(
                "min-w-0 flex-1 gap-2.5 py-3 pe-4",
                !first && "border-t border-border"
              )}
            >
              <View className="gap-0.75">
                <View className="flex-row items-center gap-2">
                  <Text className="text-sm font-semibold">{request.name}</Text>
                  <Text className="text-xs text-muted-foreground">
                    {request.time}
                  </Text>
                </View>
                <Text className="text-sm">
                  {`Requests ${request.permission}`}
                </Text>
                <Text className="text-sm text-muted-foreground">
                  {request.reason}
                </Text>
              </View>
              {request.decision ? (
                <Text
                  accessibilityLiveRegion="polite"
                  className={cn(
                    "pt-0.5 text-sm font-semibold",
                    request.decision === "approved"
                      ? "text-green-600 dark:text-green-400"
                      : "text-destructive"
                  )}
                >
                  {request.decision === "approved" ? "Approved" : "Denied"}
                </Text>
              ) : (
                <View className="flex-row flex-wrap gap-2">
                  <Button
                    size="sm"
                    variant="secondary"
                    onPress={() => onDecide("denied")}
                  >
                    <Text>Deny</Text>
                  </Button>
                  <Button size="sm" onPress={() => onDecide("approved")}>
                    <Text>Approve</Text>
                  </Button>
                </View>
              )}
            </View>
          </View>
        </SwipeActionsContent>
        {request.decision ? null : (
          <SwipeActionsActions side="leading">
            <SwipeAction fullSwipe onPress={() => onDecide("approved")}>
              <Icon as={CheckIcon} weight="bold" />
              <Text>Approve</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
        {request.decision ? null : (
          <SwipeActionsActions side="trailing">
            <SwipeAction
              variant="destructive"
              fullSwipe
              onPress={() => onDecide("denied")}
            >
              <Icon as={XIcon} weight="bold" />
              <Text>Deny</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
      </SwipeActions>
    </AnimatedView>
  )
}

function RolesRequests({
  onApprove,
  onDeny,
  onDurationChange,
  onNavigate,
}: {
  onApprove?: (name: string, duration: string) => void
  onDeny?: (name: string) => void
  onDurationChange?: (duration: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("roles")
  const [requests, setRequests] = React.useState(INITIAL_REQUESTS)
  const [duration, setDuration] = React.useState("Permanent")
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const open = requests.filter((request) => !request.decision).length

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const decide = (name: string, decision: "approved" | "denied") => {
    if (decision === "approved") onApprove?.(name, duration)
    else onDeny?.(name)
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    timers.current.push(
      setTimeout(
        () =>
          setRequests((current) =>
            current.filter((request) => request.name !== name)
          ),
        900
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Access requests</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-sm text-muted-foreground"
                >
                  {`${open} pending ${open === 1 ? "request" : "requests"}`}
                </Text>
              }
            >
              Access requests
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row items-center gap-2">
                <Text
                  nativeID="roles-requests-duration"
                  className="flex-1 text-sm text-muted-foreground"
                >
                  Approval length
                </Text>
                <Picker
                  size="sm"
                  value={duration}
                  onValueChange={(value) => {
                    setDuration(value)
                    onDurationChange?.(value)
                  }}
                  accessibilityLabelledBy="roles-requests-duration"
                >
                  {DURATIONS.map((item) => (
                    <PickerItem key={item} value={item}>
                      {item}
                    </PickerItem>
                  ))}
                </Picker>
              </View>
              {requests.length > 0 ? (
                <View className="overflow-hidden rounded-2xl bg-muted">
                  {requests.map((request, index) => (
                    <RequestRow
                      key={request.name}
                      request={request}
                      first={index === 0}
                      onDecide={(decision) => decide(request.name, decision)}
                    />
                  ))}
                </View>
              ) : (
                <AnimatedView
                  entering={FadeInDown.duration(800).reduceMotion(
                    ReduceMotion.System
                  )}
                >
                  <Empty className="gap-3 p-10">
                    <EmptyHeader>
                      <EmptyMedia className="mb-0">
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          className="size-10 text-green-600 dark:text-green-400"
                        />
                      </EmptyMedia>
                      <EmptyTitle className="text-xl">
                        No pending requests.
                      </EmptyTitle>
                    </EmptyHeader>
                    <EmptyContent>
                      <Pressable
                        role="button"
                        hitSlop={8}
                        onPress={() => setRequests(INITIAL_REQUESTS)}
                        className="active:opacity-60"
                      >
                        <Text className="text-sm text-primary">
                          Reset the sample ›
                        </Text>
                      </Pressable>
                    </EmptyContent>
                  </Empty>
                </AnimatedView>
              )}
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesRequests }
```

```swift title="swift/Blocks/RolesRequests.swift"
import PhosphorSwift
import SwiftUI

public struct RolesRequests: View {
  enum Decision {
    case approved, denied
  }

  struct Request: Identifiable, Equatable {
    let name: String
    let permission: String
    let reason: String
    let time: String
    let tint: Color
    var decision: Decision?

    var id: String { name }
  }

  static var initialRequests: [Request] {
    [
      Request(name: "Morgan Lee", permission: "Change prices", reason: "I need to update the campaign prices.", time: "10 min ago", tint: Palette.orange500),
      Request(name: "Riley Chen", permission: "Export customers", reason: "I need the customer list for the monthly report.", time: "1 hr ago", tint: Palette.green500),
      Request(name: "Jordan Park", permission: "Issue refunds", reason: "So I can refund support tickets myself.", time: "Yesterday", tint: Palette.teal400),
      Request(name: "Taylor Kim", permission: "Publish", reason: "I'd like to publish blog posts.", time: "2 days ago", tint: Palette.blue500),
    ]
  }

  static let durations = ["24 hours", "7 days", "Permanent"]

  private var onNavigate: ((String) -> Void)?
  private var onApprove: ((String, String) -> Void)?
  private var onDeny: ((String) -> Void)?
  private var onDurationChange: ((String) -> Void)?
  private let swiped: String?
  @State private var requests: [Request]
  @State private var duration: String?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onApprove: ((_ request: String, _ duration: String) -> Void)? = nil,
    onDeny: ((_ request: String) -> Void)? = nil,
    onDurationChange: ((_ duration: String) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(requests: Self.initialRequests)
    self.onNavigate = onNavigate
    self.onApprove = onApprove
    self.onDeny = onDeny
    self.onDurationChange = onDurationChange
  }

  init(requests: [Request], swiped: String? = nil) {
    _requests = State(initialValue: requests)
    _duration = State(initialValue: "Permanent")
    self.swiped = swiped
  }

  private var animation: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.4)
  }

  public var body: some View {
    RolesRequestsShell(home: "roles", onNavigate: onNavigate) {
      page
    }
  }

  @ViewBuilder private var page: some View {
    let open = requests.filter { $0.decision == nil }.count
    let durationSelection = Binding<String?>(
      get: { duration },
      set: { value in
        guard let value, value != duration else { return }
        onDurationChange?(value)
        duration = value
      }
    )
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Access requests", subtitle: open == 1 ? "1 pending request" : "\(open) pending requests")
        VStack(alignment: .leading, spacing: 20) {
          ItemGroup(.inset) {
            Item(size: .sm) {
              ItemContent {
                ItemTitle { Text("Approval length").fontWeight(.regular) }
              }
              ItemActions {
                OptionPicker(selection: durationSelection, size: .sm) {
                  ForEach(Self.durations, id: \.self) { item in
                    OptionPickerItem(LocalizedStringKey(item), value: item)
                  }
                }
                .fixedSize()
                .accessibilityLabel(Text("Approval length"))
              }
            }
          }
          if requests.isEmpty {
            Empty {
              EmptyHeader {
                EmptyMedia {
                  Ph.check.bold
                    .frame(width: 40, height: 40)
                    .foregroundStyle(Palette.green500)
                }
                EmptyTitle("No pending requests.")
              }
              EmptyContent {
                Button("Reset the sample ›") {
                  withAnimation(animation) { requests = Self.initialRequests }
                }
                .buttonStyle(.variant(.link, size: .sm))
              }
            }
            .padding(.top, 24)
            .transition(.opacity.combined(with: .offset(y: 8)))
          } else {
            VStack(spacing: 0) {
              ForEach(Array(requests.enumerated()), id: \.element.id) { index, request in
                SwipeActions(background: EsuissColor.muted, revealed: swiped == request.name ? 88 : 0) {
                  RolesRequestsRow(
                    request: request,
                    separator: index > 0,
                    onDeny: { decide(request.name, .denied) },
                    onApprove: { decide(request.name, .approved) }
                  )
                } leading: {
                  if request.decision == nil {
                    SwipeAction("Approve", icon: Ph.check.bold, variant: .default, fullSwipe: true) {
                      decide(request.name, .approved)
                    }
                  }
                } trailing: {
                  if request.decision == nil {
                    SwipeAction("Deny", icon: Ph.x.bold, variant: .destructive, fullSwipe: true) {
                      decide(request.name, .denied)
                    }
                  }
                }
                .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
              }
            }
            .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
          }
        }
        .padding(.horizontal, 16)
        .padding(.top, 8)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Access requests")
        }
      }
    }
  }

  private func decide(_ name: String, _ decision: Decision) {
    guard let index = requests.firstIndex(where: { $0.name == name }), requests[index].decision == nil else { return }
    if decision == .approved {
      onApprove?(name, duration ?? "Permanent")
    } else {
      onDeny?(name)
    }
    withAnimation(.easeOut(duration: 0.2)) { requests[index].decision = decision }
    Task {
      try? await Task.sleep(for: .milliseconds(900))
      withAnimation(animation) { requests.removeAll { $0.name == name } }
    }
  }
}

private struct RolesRequestsRow: View {
  let request: RolesRequests.Request
  let separator: Bool
  let onDeny: () -> Void
  let onApprove: () -> Void

  var body: some View {
    HStack(alignment: .top, spacing: 12) {
      Text(verbatim: request.name.split(separator: " ").compactMap(\.first).map(String.init).joined())
        .scaledFont(.xs, weight: .semibold)
        .lineLimit(1)
        .minimumScaleFactor(0.5)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 34, height: 34)
        .background(request.tint, in: Circle())
        .padding(.top, 12)
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 10) {
        VStack(alignment: .leading, spacing: 3) {
          HStack(spacing: 8) {
            Text(verbatim: request.name)
              .scaledFont(.sm, weight: .semibold)
            Text(LocalizedStringKey(request.time))
              .scaledFont(.xs)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          Text("Requests \(request.permission)")
            .scaledFont(.sm)
          Text(verbatim: request.reason)
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
            .fixedSize(horizontal: false, vertical: true)
        }
        .accessibilityElement(children: .combine)
        if let decision = request.decision {
          Text(decision == .approved ? "Approved" : "Denied")
            .scaledFont(.sm, weight: .semibold)
            .foregroundStyle(decision == .approved ? Color.adaptive(light: Palette.green600, dark: Palette.green400) : EsuissColor.destructive)
            .transition(.opacity)
        } else {
          HStack(spacing: 8) {
            Button("Deny", action: onDeny)
              .buttonStyle(.variant(.secondary, size: .sm))
            Button("Approve", action: onApprove)
              .buttonStyle(.variant(.default, size: .sm))
          }
        }
      }
      .padding(.vertical, 12)
      .padding(.trailing, 16)
      .frame(maxWidth: .infinity, alignment: .leading)
      .overlay(alignment: .top) {
        if separator {
          EsuissColor.border.frame(height: 1)
        }
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.leading, 16)
  }
}

private struct RolesRequestsShell<Content: View>: View {
  let home: String
  let onNavigate: ((String) -> Void)?
  let content: Content

  @State private var tab: String

  init(home: String, onNavigate: ((String) -> Void)?, @ViewBuilder content: () -> Content) {
    self.home = home
    self.onNavigate = onNavigate
    self.content = content()
    _tab = State(initialValue: home)
  }

  private static var sections: [(value: String, label: LocalizedStringKey, icon: Image, active: Image)] {
    [
      ("users", "Users", Ph.users.regular, Ph.users.fill),
      ("roles", "Roles", Ph.shield.regular, Ph.shield.fill),
      ("log", "Activity", Ph.chartBar.regular, Ph.chartBar.fill),
      ("settings", "Settings", Ph.gear.regular, Ph.gear.fill),
    ]
  }

  var body: some View {
    let selection = Binding<String>(
      get: { tab },
      set: { value in
        guard value != tab else { return }
        onNavigate?(value)
        tab = value
      }
    )
    Screen {
      ZStack {
        content
          .opacity(tab == home ? 1 : 0)
          .allowsHitTesting(tab == home)
          .accessibilityHidden(tab != home)
        if tab != home {
          Empty {
            EmptyHeader {
              EmptyDescription("This section isn't part of the sample. Choose \(Text(Self.sections.first { $0.value == home }?.label ?? "")).")
            }
          }
          .background(EsuissColor.background)
        }
      }
      .frame(maxHeight: .infinity)
      TabBar(selection: selection) {
        ForEach(Self.sections, id: \.value) { section in
          TabBarItem(section.label, value: section.value, icon: section.icon, activeIcon: section.active)
        }
      }
    }
  }
}
```

```kt title="kotlin/blocks/RolesRequests.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.X
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
import com.adamglin.phosphoricons.fill.Shield as FillShield
import com.adamglin.phosphoricons.fill.Users as FillUsers
import com.adamglin.phosphoricons.regular.ChartBar
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.Shield
import com.adamglin.phosphoricons.regular.Users
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyContent
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.EmptyMedia
import com.esuiss.ui.components.EmptyTitle
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemActions
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.SelectSize
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.interactions.SwipeAction
import com.esuiss.ui.interactions.SwipeActionVariant
import com.esuiss.ui.interactions.SwipeActions
import com.esuiss.ui.patterns.Picker
import com.esuiss.ui.patterns.PickerItemData
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

private val RequestEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private val RequestDurations = listOf("24 hours", "7 days", "Permanent").map { PickerItemData(it, it) }

private class AccessRequest(val name: String, val permission: String, val reason: String, val time: String, val hue: Int) {
    var decision by mutableStateOf<String?>(null)
}

private fun accessRequests() = listOf(
    AccessRequest("Morgan Lee", "Change prices", "I need to update the campaign prices.", "10 min ago", 30),
    AccessRequest("Riley Chen", "Export customers", "I need the customer list for the monthly report.", "1 hr ago", 150),
    AccessRequest("Jordan Park", "Issue refunds", "So I can refund support tickets myself.", "Yesterday", 100),
    AccessRequest("Taylor Kim", "Publish", "I'd like to publish blog posts.", "2 days ago", 200),
)

private fun requestTint(colors: Colors, hue: Int): Color = when (hue) {
    30 -> Palette.orange500
    150 -> Palette.green500
    200 -> Palette.blue500
    else -> Palette.teal500
}

private fun approvedTint(colors: Colors): Color = if (colors.isDark) Palette.green500 else lerp(Palette.green500, colors.foreground, 0.45f)

@Composable
fun RolesRequests(
    modifier: Modifier = Modifier,
    onApprove: (name: String, duration: String) -> Unit = { _, _ -> },
    onDeny: (name: String) -> Unit = {},
    onDurationChange: (duration: String) -> Unit = {},
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    val requests = remember { mutableStateListOf<AccessRequest>().apply { addAll(accessRequests()) } }
    var duration by rememberSaveable { mutableStateOf("Permanent") }
    val open = requests.count { it.decision == null }

    fun decide(request: AccessRequest, decision: String) {
        if (request.decision != null) return
        if (decision == "approved") onApprove(request.name, duration) else onDeny(request.name)
        request.decision = decision
    }

    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("roles", onNavigate) {
            Column(
                Modifier
                    .fillMaxSize()
                    .nestedScroll(header.nestedScrollConnection),
            ) {
                Header(state = header) {
                    HeaderTitle("Access requests")
                    HeaderLargeTitle(
                        "Access requests",
                        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                        subtitle = "$open pending ${if (open == 1) "request" else "requests"}",
                    )
                }
                Column(
                    Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                        .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp),
                    verticalArrangement = Arrangement.spacedBy(20.dp),
                ) {
                    ItemGroup(variant = ItemGroupVariant.Inset) {
                        Item(size = ItemSize.Sm, role = null) {
                            ItemContent {
                                ItemTitle {
                                    BasicText("Approval length", style = Typography.base.copy(color = colors.foreground))
                                }
                            }
                            ItemActions {
                                Picker(
                                    value = duration,
                                    onValueChange = {
                                        onDurationChange(it)
                                        duration = it
                                    },
                                    modifier = Modifier.semantics { contentDescription = "Approval length" },
                                    items = RequestDurations,
                                    size = SelectSize.Sm,
                                )
                            }
                        }
                    }
                    if (requests.isNotEmpty()) {
                        Column(
                            Modifier
                                .fillMaxWidth()
                                .clip(RoundedCornerShape(Radius.xl2))
                                .background(colors.muted),
                        ) {
                            requests.forEachIndexed { index, request ->
                                key(request.name) {
                                    RequestRow(
                                        request = request,
                                        divided = index > 0,
                                        onDecide = { decide(request, it) },
                                        onRemoved = { requests.remove(request) },
                                    )
                                }
                            }
                        }
                    } else {
                        RequestsEmpty {
                            requests.clear()
                            requests.addAll(accessRequests())
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun RequestRow(request: AccessRequest, divided: Boolean, onDecide: (String) -> Unit, onRemoved: () -> Unit) {
    val colors = EsuissTheme.colors
    var closing by remember { mutableStateOf(false) }
    LaunchedEffect(request.decision) {
        if (request.decision == null) return@LaunchedEffect
        delay(500)
        closing = true
        delay(400)
        onRemoved()
    }
    AnimatedVisibility(
        visible = !closing,
        exit = shrinkVertically(tween(400, easing = RequestEasing)) + fadeOut(tween(400)),
    ) {
        SwipeActions(
            enabled = request.decision == null,
            background = colors.muted,
            leading = listOf(
                SwipeAction(
                    label = "Approve",
                    onClick = { onDecide("approved") },
                    icon = PhosphorIcons.Bold.Check,
                    fullSwipe = true,
                ),
            ),
            trailing = listOf(
                SwipeAction(
                    label = "Deny",
                    onClick = { onDecide("denied") },
                    icon = PhosphorIcons.Bold.X,
                    variant = SwipeActionVariant.Destructive,
                    fullSwipe = true,
                ),
            ),
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(colors.muted)
                    .padding(start = 16.dp),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                Box(
                    Modifier
                        .padding(top = 12.dp)
                        .size(34.dp)
                        .background(requestTint(colors, request.hue), CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    BasicText(
                        request.name.split(" ").mapNotNull { it.firstOrNull() }.joinToString(""),
                        style = Typography.xs.copy(color = Palette.white, fontWeight = FontWeight.Medium),
                    )
                }
                Column(Modifier.weight(1f)) {
                    if (divided) {
                        Box(
                            Modifier
                                .fillMaxWidth()
                                .height(1.dp)
                                .background(colors.border),
                        )
                    }
                    Column(
                        modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp),
                        verticalArrangement = Arrangement.spacedBy(10.dp),
                    ) {
                        Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
                            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
                                BasicText(request.name, style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                                BasicText(request.time, style = Typography.xs.copy(color = colors.mutedForeground))
                            }
                            BasicText("Requests ${request.permission}", style = Typography.sm.copy(color = colors.foreground))
                            BasicText(request.reason, style = Typography.sm.copy(color = colors.mutedForeground))
                        }
                        val decision = request.decision
                        if (decision != null) {
                            BasicText(
                                if (decision == "approved") "Approved" else "Denied",
                                modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                                style = Typography.sm.copy(
                                    color = if (decision == "approved") approvedTint(colors) else colors.destructive,
                                    fontWeight = FontWeight.SemiBold,
                                ),
                            )
                        } else {
                            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                                Button(text = "Deny", onClick = { onDecide("denied") }, size = ButtonSize.Sm, variant = ButtonVariant.Secondary)
                                Button(text = "Approve", onClick = { onDecide("approved") }, size = ButtonSize.Sm)
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun RequestsEmpty(onReset: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(800, easing = RequestEasing)) }
    Empty(
        Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 8.dp.toPx()
        },
    ) {
        EmptyHeader {
            EmptyMedia {
                Icon(
                    PhosphorIcons.Bold.Check,
                    contentDescription = null,
                    size = 40.dp,
                    tint = if (colors.isDark) Palette.green500 else lerp(Palette.green500, colors.foreground, 0.35f),
                )
            }
            EmptyTitle("No pending requests.")
        }
        EmptyContent {
            Button(text = "Reset the sample ›", onClick = onReset, size = ButtonSize.Sm, variant = ButtonVariant.Link)
        }
    }
}

private val AdminSections = listOf("users" to "Users", "roles" to "Roles", "log" to "Activity", "settings" to "Settings")

@Composable
private fun AdminTabs(home: String, onNavigate: (String) -> Unit, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    var tab by rememberSaveable { mutableStateOf(home) }
    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(1f)
                .fillMaxWidth(),
        ) {
            if (tab == home) {
                content()
            } else {
                BasicText(
                    "This section isn't part of the sample. Choose ${AdminSections.first { it.first == home }.second}.",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(32.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            }
        }
        TabBar(
            value = tab,
            onValueChange = {
                onNavigate(it)
                tab = it
            },
        ) {
            TabBarItem("users", "Users", PhosphorIcons.Regular.Users, selectedIcon = PhosphorIcons.Fill.FillUsers)
            TabBarItem("roles", "Roles", PhosphorIcons.Regular.Shield, selectedIcon = PhosphorIcons.Fill.FillShield)
            TabBarItem("log", "Activity", PhosphorIcons.Regular.ChartBar, selectedIcon = PhosphorIcons.Fill.FillChartBar)
            TabBarItem("settings", "Settings", PhosphorIcons.Regular.Gear, selectedIcon = PhosphorIcons.Fill.FillGear)
        }
    }
}
```

Each request shows an `Avatar`, the requester, the permission and an optional reason, with Deny and Approve `Button`s. Rows use `SwipeActions`, so swiping from the leading edge approves and from the trailing edge denies, and decided rows collapse out of the list. A `Picker` sets the grant duration, and an `Empty` state with a reset link appears once the queue is clear. It uses a collapsing `Header` and the admin `TabBar`.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/roles-requests
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add roles-requests
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add roles-requests --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add roles-requests --platform kotlin
```

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/roles-requests.tsx`:

- `uniwind/components/blocks/roles-requests.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/roles-requests.tsx`:

```tsx title="nativewind/components/blocks/roles-requests.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  FadeInDown,
  FadeOut,
  LinearTransition,
  ReduceMotion,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const DURATIONS = ["24 hours", "7 days", "Permanent"]

type Decision = "approved" | "denied" | null

type Request = {
  name: string
  permission: string
  reason: string
  time: string
  tint: string
  decision: Decision
}

const INITIAL_REQUESTS: Request[] = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    tint: "bg-orange-500",
    decision: null,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    tint: "bg-green-500",
    decision: null,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    tint: "bg-teal-400",
    decision: null,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    tint: "bg-cyan-500",
    decision: null,
  },
]

const EASING_LAYOUT = LinearTransition.duration(400).reduceMotion(
  ReduceMotion.System
)

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-[3.25rem]">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function RequestRow({
  request,
  first,
  onDecide,
}: {
  request: Request
  first: boolean
  onDecide: (decision: "approved" | "denied") => void
}) {
  return (
    <AnimatedView
      layout={EASING_LAYOUT}
      exiting={FadeOut.duration(400).reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent className="bg-muted">
          <View className="flex-row items-start gap-3 ps-4">
            <Avatar className="mt-3 size-[2.125rem]">
              <AvatarFallback className={request.tint}>
                {initials(request.name)}
              </AvatarFallback>
            </Avatar>
            <View
              className={cn(
                "min-w-0 flex-1 gap-2.5 py-3 pe-4",
                !first && "border-t border-border"
              )}
            >
              <View className="gap-[0.1875rem]">
                <View className="flex-row items-center gap-2">
                  <Text className="text-sm font-semibold">{request.name}</Text>
                  <Text className="text-xs text-muted-foreground">
                    {request.time}
                  </Text>
                </View>
                <Text className="text-sm">
                  {`Requests ${request.permission}`}
                </Text>
                <Text className="text-sm text-muted-foreground">
                  {request.reason}
                </Text>
              </View>
              {request.decision ? (
                <Text
                  accessibilityLiveRegion="polite"
                  className={cn(
                    "pt-0.5 text-sm font-semibold",
                    request.decision === "approved"
                      ? "text-green-600 dark:text-green-400"
                      : "text-destructive"
                  )}
                >
                  {request.decision === "approved" ? "Approved" : "Denied"}
                </Text>
              ) : (
                <View className="flex-row flex-wrap gap-2">
                  <Button
                    size="sm"
                    variant="secondary"
                    onPress={() => onDecide("denied")}
                  >
                    <Text>Deny</Text>
                  </Button>
                  <Button size="sm" onPress={() => onDecide("approved")}>
                    <Text>Approve</Text>
                  </Button>
                </View>
              )}
            </View>
          </View>
        </SwipeActionsContent>
        {request.decision ? null : (
          <SwipeActionsActions side="leading">
            <SwipeAction fullSwipe onPress={() => onDecide("approved")}>
              <Icon as={CheckIcon} weight="bold" />
              <Text>Approve</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
        {request.decision ? null : (
          <SwipeActionsActions side="trailing">
            <SwipeAction
              variant="destructive"
              fullSwipe
              onPress={() => onDecide("denied")}
            >
              <Icon as={XIcon} weight="bold" />
              <Text>Deny</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
      </SwipeActions>
    </AnimatedView>
  )
}

function RolesRequests({
  onApprove,
  onDeny,
  onDurationChange,
  onNavigate,
}: {
  onApprove?: (name: string, duration: string) => void
  onDeny?: (name: string) => void
  onDurationChange?: (duration: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("roles")
  const [requests, setRequests] = React.useState(INITIAL_REQUESTS)
  const [duration, setDuration] = React.useState("Permanent")
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const open = requests.filter((request) => !request.decision).length

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const decide = (name: string, decision: "approved" | "denied") => {
    if (decision === "approved") onApprove?.(name, duration)
    else onDeny?.(name)
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    timers.current.push(
      setTimeout(
        () =>
          setRequests((current) =>
            current.filter((request) => request.name !== name)
          ),
        900
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Access requests</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-sm text-muted-foreground"
                >
                  {`${open} pending ${open === 1 ? "request" : "requests"}`}
                </Text>
              }
            >
              Access requests
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row items-center gap-2">
                <Text
                  nativeID="roles-requests-duration"
                  className="flex-1 text-sm text-muted-foreground"
                >
                  Approval length
                </Text>
                <Picker
                  size="sm"
                  value={duration}
                  onValueChange={(value) => {
                    setDuration(value)
                    onDurationChange?.(value)
                  }}
                  accessibilityLabelledBy="roles-requests-duration"
                >
                  {DURATIONS.map((item) => (
                    <PickerItem key={item} value={item}>
                      {item}
                    </PickerItem>
                  ))}
                </Picker>
              </View>
              {requests.length > 0 ? (
                <View className="overflow-hidden rounded-2xl bg-muted">
                  {requests.map((request, index) => (
                    <RequestRow
                      key={request.name}
                      request={request}
                      first={index === 0}
                      onDecide={(decision) => decide(request.name, decision)}
                    />
                  ))}
                </View>
              ) : (
                <AnimatedView
                  entering={FadeInDown.duration(800).reduceMotion(
                    ReduceMotion.System
                  )}
                >
                  <Empty className="gap-3 p-10">
                    <EmptyHeader>
                      <EmptyMedia className="mb-0">
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          className="size-10 text-green-600 dark:text-green-400"
                        />
                      </EmptyMedia>
                      <EmptyTitle className="text-xl">
                        No pending requests.
                      </EmptyTitle>
                    </EmptyHeader>
                    <EmptyContent>
                      <Pressable
                        role="button"
                        hitSlop={8}
                        onPress={() => setRequests(INITIAL_REQUESTS)}
                        className="active:opacity-60"
                      >
                        <Text className="text-sm text-primary">
                          Reset the sample ›
                        </Text>
                      </Pressable>
                    </EmptyContent>
                  </Empty>
                </AnimatedView>
              )}
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesRequests }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/roles-requests.tsx`:

```tsx title="stylesheet/components/blocks/roles-requests.tsx"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View } from "react-native"
import {
  FadeInDown,
  FadeOut,
  LinearTransition,
  ReduceMotion,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const DURATIONS = ["24 hours", "7 days", "Permanent"]

type Decision = "approved" | "denied" | null

type Tint = Tone

type Request = {
  name: string
  permission: string
  reason: string
  time: string
  tint: Tint
  decision: Decision
}

const INITIAL_REQUESTS: Request[] = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    tint: "orange500",
    decision: null,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    tint: "green500",
    decision: null,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    tint: "teal400",
    decision: null,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    tint: "cyan500",
    decision: null,
  },
]

const EASING_LAYOUT = LinearTransition.duration(400).reduceMotion(
  ReduceMotion.System
)

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  row: {
    flexDirection: "row",
    alignItems: "flex-start",
    gap: 12,
    paddingStart: 16,
  },
  avatar: { marginTop: 12, width: 34, height: 34 },
  content: {
    minWidth: 0,
    flex: 1,
    gap: 10,
    paddingVertical: 12,
    paddingEnd: 16,
  },
  divider: { borderTopWidth: 1 },
  lines: { gap: 3 },
  top: { flexDirection: "row", alignItems: "center", gap: 8 },
  strong: { ...fontSize.sm, fontWeight: "600" },
  decision: { ...fontSize.sm, paddingTop: 2, fontWeight: "600" },
  actions: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
  body: { gap: 20, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 },
  duration: { flexDirection: "row", alignItems: "center", gap: 8 },
  durationLabel: { ...fontSize.sm, flex: 1 },
  group: { overflow: "hidden", borderRadius: radius["2xl"] },
  empty: { gap: 12, padding: 40 },
  emptyMedia: { marginBottom: 0 },
})

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const { colors } = useTheme()
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View style={styles.other}>
      <Text role="heading" style={styles.otherTitle}>
        {label}
      </Text>
      <Text style={[fontSize.lg, { color: colors.mutedForeground }]}>
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function RequestRow({
  request,
  first,
  onDecide,
}: {
  request: Request
  first: boolean
  onDecide: (decision: "approved" | "denied") => void
}) {
  const { colors, isDark } = useTheme()
  const success = isDark ? palette.green400 : palette.green600
  return (
    <AnimatedView
      layout={EASING_LAYOUT}
      exiting={FadeOut.duration(400).reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent style={{ backgroundColor: colors.muted }}>
          <View style={styles.row}>
            <Avatar style={styles.avatar}>
              <AvatarFallback
                style={{ backgroundColor: tone(colors, request.tint) }}
              >
                {initials(request.name)}
              </AvatarFallback>
            </Avatar>
            <View
              style={[
                styles.content,
                !first && [styles.divider, { borderTopColor: colors.border }],
              ]}
            >
              <View style={styles.lines}>
                <View style={styles.top}>
                  <Text style={styles.strong}>{request.name}</Text>
                  <Text
                    style={[fontSize.xs, { color: colors.mutedForeground }]}
                  >
                    {request.time}
                  </Text>
                </View>
                <Text style={fontSize.sm}>
                  {`Requests ${request.permission}`}
                </Text>
                <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
                  {request.reason}
                </Text>
              </View>
              {request.decision ? (
                <Text
                  accessibilityLiveRegion="polite"
                  style={[
                    styles.decision,
                    {
                      color:
                        request.decision === "approved"
                          ? success
                          : colors.destructive,
                    },
                  ]}
                >
                  {request.decision === "approved" ? "Approved" : "Denied"}
                </Text>
              ) : (
                <View style={styles.actions}>
                  <Button
                    size="sm"
                    variant="secondary"
                    onPress={() => onDecide("denied")}
                  >
                    <Text>Deny</Text>
                  </Button>
                  <Button size="sm" onPress={() => onDecide("approved")}>
                    <Text>Approve</Text>
                  </Button>
                </View>
              )}
            </View>
          </View>
        </SwipeActionsContent>
        {request.decision ? null : (
          <SwipeActionsActions side="leading">
            <SwipeAction fullSwipe onPress={() => onDecide("approved")}>
              <Icon as={CheckIcon} weight="bold" />
              <Text>Approve</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
        {request.decision ? null : (
          <SwipeActionsActions side="trailing">
            <SwipeAction
              variant="destructive"
              fullSwipe
              onPress={() => onDecide("denied")}
            >
              <Icon as={XIcon} weight="bold" />
              <Text>Deny</Text>
            </SwipeAction>
          </SwipeActionsActions>
        )}
      </SwipeActions>
    </AnimatedView>
  )
}

function RolesRequests({
  onApprove,
  onDeny,
  onDurationChange,
  onNavigate,
}: {
  onApprove?: (name: string, duration: string) => void
  onDeny?: (name: string) => void
  onDurationChange?: (duration: string) => void
  onNavigate?: (section: string) => void
}) {
  const { colors, isDark } = useTheme()
  const [section, setSection] = React.useState("roles")
  const [requests, setRequests] = React.useState(INITIAL_REQUESTS)
  const [duration, setDuration] = React.useState("Permanent")
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const open = requests.filter((request) => !request.decision).length

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const decide = (name: string, decision: "approved" | "denied") => {
    if (decision === "approved") onApprove?.(name, duration)
    else onDeny?.(name)
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    timers.current.push(
      setTimeout(
        () =>
          setRequests((current) =>
            current.filter((request) => request.name !== name)
          ),
        900
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Access requests</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  style={[fontSize.sm, { color: colors.mutedForeground }]}
                >
                  {`${open} pending ${open === 1 ? "request" : "requests"}`}
                </Text>
              }
            >
              Access requests
            </HeaderLargeTitle>
            <View style={styles.body}>
              <View style={styles.duration}>
                <Text
                  nativeID="roles-requests-duration"
                  style={[
                    styles.durationLabel,
                    { color: colors.mutedForeground },
                  ]}
                >
                  Approval length
                </Text>
                <Picker
                  size="sm"
                  value={duration}
                  onValueChange={(value) => {
                    setDuration(value)
                    onDurationChange?.(value)
                  }}
                  accessibilityLabelledBy="roles-requests-duration"
                >
                  {DURATIONS.map((item) => (
                    <PickerItem key={item} value={item}>
                      {item}
                    </PickerItem>
                  ))}
                </Picker>
              </View>
              {requests.length > 0 ? (
                <View style={[styles.group, { backgroundColor: colors.muted }]}>
                  {requests.map((request, index) => (
                    <RequestRow
                      key={request.name}
                      request={request}
                      first={index === 0}
                      onDecide={(decision) => decide(request.name, decision)}
                    />
                  ))}
                </View>
              ) : (
                <AnimatedView
                  entering={FadeInDown.duration(800).reduceMotion(
                    ReduceMotion.System
                  )}
                >
                  <Empty style={styles.empty}>
                    <EmptyHeader>
                      <EmptyMedia style={styles.emptyMedia}>
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          size={40}
                          color={isDark ? palette.green400 : palette.green600}
                        />
                      </EmptyMedia>
                      <EmptyTitle style={fontSize.xl}>
                        No pending requests.
                      </EmptyTitle>
                    </EmptyHeader>
                    <EmptyContent>
                      <Pressable
                        role="button"
                        hitSlop={8}
                        onPress={() => setRequests(INITIAL_REQUESTS)}
                        style={({ pressed }) => pressed && { opacity: 0.6 }}
                      >
                        <Text style={[fontSize.sm, { color: colors.primary }]}>
                          Reset the sample ›
                        </Text>
                      </Pressable>
                    </EmptyContent>
                  </Empty>
                </AnimatedView>
              )}
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesRequests }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/RolesRequests.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TabBar.swift`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Interactions/SwipeActions.swift`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

- `swift/Patterns/Adaptive.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/Adaptive.swift)

- `swift/Patterns/OptionPicker.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/OptionPicker.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/RolesRequests.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Empty.kt`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Item.kt`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Select.kt`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/TabBar.kt`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `kotlin/interactions/SwipeActions.kt`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

- `kotlin/patterns/Picker.kt`: see [Picker](https://ui.suiss.app/docs/patterns/picker.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { RolesRequests } from "@/components/blocks/roles-requests"
```

```tsx
<RolesRequests />
```

Replace `INITIAL_REQUESTS` and `DURATIONS` with your data. Use `onApprove`, `onDeny`, `onDurationChange` and `onNavigate` to send decisions to your API and route between sections.

