# Roles Editor

A role editor that lists roles beside grouped permission switches, with save and revert for unsaved changes.

Roles Editor 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-editor

**React**

**Example**

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

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

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"

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 />
    ),
  },
]

const groups = [
  {
    name: "Content",
    permissions: [
      { name: "Create drafts", description: "Can start new content drafts." },
      { name: "Publish", description: "Can make content public." },
      { name: "Delete", description: "Can permanently delete content." },
    ],
  },
  {
    name: "Customers",
    permissions: [
      {
        name: "Customer data",
        description: "Sees names, emails and order history.",
      },
      { name: "Export", description: "Downloads the customer list as CSV." },
    ],
  },
]

const roles = ["Editor", "Support", "Viewer"] as const

type Role = (typeof roles)[number]

const members: Record<Role, number> = { Editor: 2, Support: 2, Viewer: 1 }

const defaults: Record<Role, boolean[]> = {
  Editor: [true, true, false, true, false],
  Support: [false, false, false, true, false],
  Viewer: [false, false, false, false, false],
}

const clone = (value: Record<Role, boolean[]>) => structuredClone(value)

export function RolesEditor() {
  const idPrefix = React.useId()
  const [role, setRole] = React.useState<Role>("Editor")
  const [state, setState] = React.useState(() => clone(defaults))
  const [base, setBase] = React.useState(() => clone(defaults))
  const dirty = JSON.stringify(state) !== JSON.stringify(base)
  let index = 0

  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 items-center px-5 pt-4 md:hidden">
          <SidebarTrigger />
        </div>
        <SplitView
          defaultColumn="list"
          className="flex-1 rounded-none border-0 max-md:p-4 md:min-h-150"
        >
          <SplitViewList
            title="Roles"
            className="gap-0.5 md:px-4 md:pt-5 [&>h2]:md:not-sr-only [&>h2]:md:px-1 [&>h2]:md:pb-2 [&>h2]:md:text-sm [&>h2]:md:font-semibold [&>h2]:md:text-muted-foreground"
          >
            {roles.map((name) => (
              <SplitViewItem
                key={name}
                isActive={name === role}
                onClick={() => setRole(name)}
                className="group/role py-1.5"
              >
                <span className="text-sm">{name}</span>
                <span className="text-xs text-muted-foreground">
                  {state[name].filter(Boolean).length} permissions
                </span>
              </SplitViewItem>
            ))}
            <Button
              size="sm"
              variant="secondary"
              className="mt-2.5 gap-1 self-start"
            >
              <PlusIcon className="size-3.25" />
              New Role
            </Button>
          </SplitViewList>
          <SplitViewDetail title={role} className="md:p-6 [&>h2]:sr-only">
            <div
              key={role}
              className="flex flex-col gap-4.5 transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
            >
              <div className="flex flex-wrap items-start gap-3">
                <div className="flex flex-1 flex-col">
                  <p className="text-3xl font-semibold tracking-tight">
                    {role}
                  </p>
                  <p className="text-sm text-muted-foreground">
                    {members[role]}{" "}
                    {members[role] === 1 ? "user has" : "users have"} this role
                  </p>
                </div>
                {dirty && (
                  <div className="flex gap-2">
                    <Button
                      size="sm"
                      variant="secondary"
                      onClick={() => setState(clone(base))}
                    >
                      Revert
                    </Button>
                    <Button size="sm" onClick={() => setBase(clone(state))}>
                      Save
                    </Button>
                  </div>
                )}
              </div>
              {groups.map((group) => (
                <section key={group.name} className="flex flex-col gap-2">
                  <h2 className="px-1 text-sm font-semibold">{group.name}</h2>
                  <ItemGroup variant="inset">
                    {group.permissions.map((permission, position) => {
                      const slot = index++
                      const id = `${idPrefix}-${slot}`
                      return (
                        <React.Fragment key={permission.name}>
                          {position > 0 && <ItemSeparator />}
                          <Item size="sm" role="listitem">
                            <ItemContent className="gap-0">
                              <ItemTitle className="font-normal">
                                <label htmlFor={id}>{permission.name}</label>
                              </ItemTitle>
                              <ItemDescription className="text-xs">
                                {permission.description}
                              </ItemDescription>
                            </ItemContent>
                            <ItemActions>
                              <Switch
                                id={id}
                                checked={state[role][slot] ?? false}
                                onCheckedChange={(value) =>
                                  setState((current) => ({
                                    ...current,
                                    [role]: current[role].map((item, i) =>
                                      i === slot ? value : item
                                    ),
                                  }))
                                }
                              />
                            </ItemActions>
                          </Item>
                        </React.Fragment>
                      )
                    })}
                  </ItemGroup>
                </section>
              ))}
            </div>
          </SplitViewDetail>
        </SplitView>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

A role editor with roles and their permission counts on the left and the selected role's permissions on the right as grouped switches. Revert and Save appear only when there are unsaved changes. It combines `Sidebar` navigation with a `SplitView`, so on mobile the role list and editor show one at a time. Use it for any role-based access control screen.

## Installation

### CLI

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

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-editor
```

### Manual

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Item](https://ui.suiss.app/docs/components/item), [Sidebar](https://ui.suiss.app/docs/components/sidebar), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Switch](https://ui.suiss.app/docs/components/switch).

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

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

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

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"

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 />
    ),
  },
]

const groups = [
  {
    name: "Content",
    permissions: [
      { name: "Create drafts", description: "Can start new content drafts." },
      { name: "Publish", description: "Can make content public." },
      { name: "Delete", description: "Can permanently delete content." },
    ],
  },
  {
    name: "Customers",
    permissions: [
      {
        name: "Customer data",
        description: "Sees names, emails and order history.",
      },
      { name: "Export", description: "Downloads the customer list as CSV." },
    ],
  },
]

const roles = ["Editor", "Support", "Viewer"] as const

type Role = (typeof roles)[number]

const members: Record<Role, number> = { Editor: 2, Support: 2, Viewer: 1 }

const defaults: Record<Role, boolean[]> = {
  Editor: [true, true, false, true, false],
  Support: [false, false, false, true, false],
  Viewer: [false, false, false, false, false],
}

const clone = (value: Record<Role, boolean[]>) => structuredClone(value)

export function RolesEditor() {
  const idPrefix = React.useId()
  const [role, setRole] = React.useState<Role>("Editor")
  const [state, setState] = React.useState(() => clone(defaults))
  const [base, setBase] = React.useState(() => clone(defaults))
  const dirty = JSON.stringify(state) !== JSON.stringify(base)
  let index = 0

  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 items-center px-5 pt-4 md:hidden">
          <SidebarTrigger />
        </div>
        <SplitView
          defaultColumn="list"
          className="flex-1 rounded-none border-0 max-md:p-4 md:min-h-150"
        >
          <SplitViewList
            title="Roles"
            className="gap-0.5 md:px-4 md:pt-5 [&>h2]:md:not-sr-only [&>h2]:md:px-1 [&>h2]:md:pb-2 [&>h2]:md:text-sm [&>h2]:md:font-semibold [&>h2]:md:text-muted-foreground"
          >
            {roles.map((name) => (
              <SplitViewItem
                key={name}
                isActive={name === role}
                onClick={() => setRole(name)}
                className="group/role py-1.5"
              >
                <span className="text-sm">{name}</span>
                <span className="text-xs text-muted-foreground">
                  {state[name].filter(Boolean).length} permissions
                </span>
              </SplitViewItem>
            ))}
            <Button
              size="sm"
              variant="secondary"
              className="mt-2.5 gap-1 self-start"
            >
              <PlusIcon className="size-3.25" />
              New Role
            </Button>
          </SplitViewList>
          <SplitViewDetail title={role} className="md:p-6 [&>h2]:sr-only">
            <div
              key={role}
              className="flex flex-col gap-4.5 transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
            >
              <div className="flex flex-wrap items-start gap-3">
                <div className="flex flex-1 flex-col">
                  <p className="text-3xl font-semibold tracking-tight">
                    {role}
                  </p>
                  <p className="text-sm text-muted-foreground">
                    {members[role]}{" "}
                    {members[role] === 1 ? "user has" : "users have"} this role
                  </p>
                </div>
                {dirty && (
                  <div className="flex gap-2">
                    <Button
                      size="sm"
                      variant="secondary"
                      onClick={() => setState(clone(base))}
                    >
                      Revert
                    </Button>
                    <Button size="sm" onClick={() => setBase(clone(state))}>
                      Save
                    </Button>
                  </div>
                )}
              </div>
              {groups.map((group) => (
                <section key={group.name} className="flex flex-col gap-2">
                  <h2 className="px-1 text-sm font-semibold">{group.name}</h2>
                  <ItemGroup variant="inset">
                    {group.permissions.map((permission, position) => {
                      const slot = index++
                      const id = `${idPrefix}-${slot}`
                      return (
                        <React.Fragment key={permission.name}>
                          {position > 0 && <ItemSeparator />}
                          <Item size="sm" role="listitem">
                            <ItemContent className="gap-0">
                              <ItemTitle className="font-normal">
                                <label htmlFor={id}>{permission.name}</label>
                              </ItemTitle>
                              <ItemDescription className="text-xs">
                                {permission.description}
                              </ItemDescription>
                            </ItemContent>
                            <ItemActions>
                              <Switch
                                id={id}
                                checked={state[role][slot] ?? false}
                                onCheckedChange={(value) =>
                                  setState((current) => ({
                                    ...current,
                                    [role]: current[role].map((item, i) =>
                                      i === slot ? value : item
                                    ),
                                  }))
                                }
                              />
                            </ItemActions>
                          </Item>
                        </React.Fragment>
                      )
                    })}
                  </ItemGroup>
                </section>
              ))}
            </div>
          </SplitViewDetail>
        </SplitView>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { RolesEditor } from "@/components/blocks/roles-editor"
```

```tsx
<RolesEditor />
```

Edit `groups` for the permissions, `roles` and `members` for the role list, and `defaults` for each role's starting switches. The `defaults` arrays are positional, mapping to permissions in the order they appear across all groups, so keep them in step with `groups` or switch to keyed data. Save only updates local state, so call your API there, and give New Role a handler.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/roles-editor.tsx"
import {
  ChartBarIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
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 GROUPS = [
  {
    name: "Content",
    permissions: [
      { name: "Create drafts", description: "Can start new content drafts." },
      { name: "Publish", description: "Can make content public." },
      { name: "Delete", description: "Can permanently delete content." },
    ],
  },
  {
    name: "Customers",
    permissions: [
      {
        name: "Customer data",
        description: "Sees names, emails and order history.",
      },
      { name: "Export", description: "Downloads the customer list as CSV." },
    ],
  },
]

const PERMISSIONS = GROUPS.flatMap((group) =>
  group.permissions.map((permission) => permission.name)
)

const ROLES = ["Editor", "Support", "Viewer"] as const

type Role = (typeof ROLES)[number]

type Grants = Record<Role, boolean[]>

const MEMBERS: Record<Role, number> = { Editor: 2, Support: 2, Viewer: 1 }

const DEFAULTS: Grants = {
  Editor: [true, true, false, true, false],
  Support: [false, false, false, true, false],
  Viewer: [false, false, false, false, false],
}

const clone = (value: Grants): Grants => ({
  Editor: [...value.Editor],
  Support: [...value.Support],
  Viewer: [...value.Viewer],
})

const granted = (value: Grants): Record<Role, string[]> => ({
  Editor: PERMISSIONS.filter((_, index) => value.Editor[index]),
  Support: PERMISSIONS.filter((_, index) => value.Support[index]),
  Viewer: PERMISSIONS.filter((_, index) => value.Viewer[index]),
})

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 RolesEditor({
  onTogglePermission,
  onSave,
  onRevert,
  onNewRole,
  onNavigate,
}: {
  onTogglePermission?: (
    role: Role,
    permission: string,
    enabled: boolean
  ) => void
  onSave?: (permissions: Record<Role, string[]>) => void
  onRevert?: () => void
  onNewRole?: () => void
  onNavigate?: (section: string) => void
}) {
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [section, setSection] = React.useState("roles")
  const [role, setRole] = React.useState<Role>("Editor")
  const [state, setState] = React.useState(() => clone(DEFAULTS))
  const [base, setBase] = React.useState(() => clone(DEFAULTS))
  const dirty = JSON.stringify(state) !== JSON.stringify(base)
  let slot = 0

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <SplitView className={wide ? "m-4" : undefined}>
          <SplitViewList
            title="Roles"
            className={wide ? "gap-0.5 px-4 pt-5" : "px-4 pb-8"}
          >
            {wide ? (
              <Text
                role="heading"
                className="px-1 pb-2 text-sm font-semibold text-muted-foreground"
              >
                Roles
              </Text>
            ) : null}
            {ROLES.map((name) => (
              <SplitViewItem
                key={name}
                isActive={name === role}
                onPress={() => setRole(name)}
                className="py-1.5"
              >
                <Text>{name}</Text>
                <Text className="text-xs text-muted-foreground">
                  {`${state[name].filter(Boolean).length} permissions`}
                </Text>
              </SplitViewItem>
            ))}
            <Button
              size="sm"
              variant="secondary"
              onPress={onNewRole}
              className="mt-2.5 self-start"
            >
              <Icon as={PlusIcon} weight="bold" className="size-3.25" />
              <Text>New Role</Text>
            </Button>
          </SplitViewList>
          <SplitViewDetail className={wide ? "p-6" : "px-4 pb-8"}>
            <AnimatedView
              key={role}
              entering={FadeInDown.duration(800).reduceMotion(
                ReduceMotion.System
              )}
              className="gap-4.5"
            >
              <View className="flex-row flex-wrap items-start gap-3">
                <View className="min-w-0 flex-1">
                  <Text role="heading" className="text-3xl font-semibold">
                    {role}
                  </Text>
                  <Text className="text-sm text-muted-foreground">
                    {`${MEMBERS[role]} ${MEMBERS[role] === 1 ? "user has" : "users have"} this role`}
                  </Text>
                </View>
                {dirty ? (
                  <View className="flex-row gap-2">
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onRevert?.()
                        setState(clone(base))
                      }}
                    >
                      <Text>Revert</Text>
                    </Button>
                    <Button
                      size="sm"
                      onPress={() => {
                        onSave?.(granted(state))
                        setBase(clone(state))
                      }}
                    >
                      <Text>Save</Text>
                    </Button>
                  </View>
                ) : null}
              </View>
              {GROUPS.map((group) => (
                <View key={group.name} className="gap-2">
                  <Text role="heading" className="px-1 text-sm font-semibold">
                    {group.name}
                  </Text>
                  <ItemGroup variant="inset">
                    {group.permissions.map((permission, position) => {
                      const index = slot++
                      const id = `roles-editor-${index}`
                      return (
                        <React.Fragment key={permission.name}>
                          {position > 0 ? <ItemSeparator /> : null}
                          <Item size="sm">
                            <ItemContent className="gap-0">
                              <ItemTitle nativeID={id} className="font-normal">
                                {permission.name}
                              </ItemTitle>
                              <ItemDescription className="text-xs">
                                {permission.description}
                              </ItemDescription>
                            </ItemContent>
                            <ItemActions>
                              <Switch
                                checked={state[role][index] ?? false}
                                onCheckedChange={(value) => {
                                  onTogglePermission?.(
                                    role,
                                    permission.name,
                                    value
                                  )
                                  setState((current) => ({
                                    ...current,
                                    [role]: current[role].map((item, i) =>
                                      i === index ? value : item
                                    ),
                                  }))
                                }}
                                accessibilityLabelledBy={id}
                              />
                            </ItemActions>
                          </Item>
                        </React.Fragment>
                      )
                    })}
                  </ItemGroup>
                </View>
              ))}
            </AnimatedView>
          </SplitViewDetail>
        </SplitView>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesEditor }
```

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

public struct RolesEditor: View {
  struct Permission {
    let name: String
    let description: String
  }

  struct PermissionGroup {
    let name: String
    let permissions: [Permission]
  }

  static var groups: [PermissionGroup] {
    [
      PermissionGroup(name: "Content", permissions: [
        Permission(name: "Create drafts", description: "Can start new content drafts."),
        Permission(name: "Publish", description: "Can make content public."),
        Permission(name: "Delete", description: "Can permanently delete content."),
      ]),
      PermissionGroup(name: "Customers", permissions: [
        Permission(name: "Customer data", description: "Sees names, emails and order history."),
        Permission(name: "Export", description: "Downloads the customer list as CSV."),
      ]),
    ]
  }

  static let roles = ["Editor", "Support", "Viewer"]
  static let members = ["Editor": 2, "Support": 2, "Viewer": 1]
  static let defaults: [String: [Bool]] = [
    "Editor": [true, true, false, true, false],
    "Support": [false, false, false, true, false],
    "Viewer": [false, false, false, false, false],
  ]

  static var permissions: [String] {
    groups.flatMap { $0.permissions.map(\.name) }
  }

  private var onNavigate: ((String) -> Void)?
  private var onTogglePermission: ((String, String, Bool) -> Void)?
  private var onSave: (([String: [String]]) -> Void)?
  private var onRevert: (() -> Void)?
  private var onNewRole: (() -> Void)?
  @State private var role: String
  @State private var state: [String: [Bool]]
  @State private var base: [String: [Bool]]
  @State private var column: SplitViewColumn
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onTogglePermission: ((_ role: String, _ permission: String, _ enabled: Bool) -> Void)? = nil,
    onSave: ((_ permissions: [String: [String]]) -> Void)? = nil,
    onRevert: (() -> Void)? = nil,
    onNewRole: (() -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(role: "Editor")
    self.onTogglePermission = onTogglePermission
    self.onSave = onSave
    self.onRevert = onRevert
    self.onNavigate = onNavigate
    self.onNewRole = onNewRole
  }

  init(role: String, column: SplitViewColumn = .list, state: [String: [Bool]]? = nil) {
    _role = State(initialValue: role)
    _column = State(initialValue: column)
    _state = State(initialValue: state ?? Self.defaults)
    _base = State(initialValue: Self.defaults)
  }

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

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

  @ViewBuilder private var page: some View {
    Screen {
      SplitView(column: $column) {
        SplitViewList("Roles") {
          VStack(alignment: .leading, spacing: 0) {
            RolesEditorHeading()
            ForEach(Self.roles, id: \.self) { name in
              SplitViewItem(isActive: name == role, action: { role = name }) {
                Text(LocalizedStringKey(name))
                  .scaledFont(.base)
                Text("\(state[name, default: []].filter { $0 }.count) permissions")
                  .scaledFont(.xs)
                  .foregroundStyle(EsuissColor.mutedForeground)
              }
            }
            Button {
              onNewRole?()
            } label: {
              SwiftUI.Label {
                Text("New Role")
              } icon: {
                Ph.plus.bold.frame(width: 13, height: 13)
              }
            }
            .buttonStyle(.variant(.secondary, size: .sm))
            .padding(.top, 10)
          }
        }
        SplitViewDetail {
          detail
            .id(role)
            .transition(.opacity.combined(with: .offset(y: 6)))
        }
      }
    }
  }

  static func enabled(_ state: [String: [Bool]]) -> [String: [String]] {
    state.mapValues { flags in
      zip(permissions, flags).filter(\.1).map(\.0)
    }
  }

  private var detail: some View {
    let members = Self.members[role] ?? 0
    var offsets: [Int] = []
    var running = 0
    for group in Self.groups {
      offsets.append(running)
      running += group.permissions.count
    }
    return VStack(alignment: .leading, spacing: 18) {
      HStack(alignment: .top, spacing: 12) {
        VStack(alignment: .leading, spacing: 2) {
          Text(LocalizedStringKey(role))
            .scaledTextStyle(.xl3, weight: .semibold)
            .accessibilityAddTraits(.isHeader)
          Text(members == 1 ? "\(members) user has this role" : "\(members) users have this role")
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        if state != base {
          HStack(spacing: 8) {
            Button("Revert") {
              onRevert?()
              withAnimation(animation) { state = base }
            }
            .buttonStyle(.variant(.secondary, size: .sm))
            Button("Save") {
              onSave?(Self.enabled(state))
              withAnimation(animation) { base = state }
            }
            .buttonStyle(.variant(.default, size: .sm))
          }
          .padding(.top, 6)
          .transition(.opacity.combined(with: .scale(scale: 0.9)))
        }
      }
      .padding(.top, 8)
      ForEach(Array(Self.groups.enumerated()), id: \.offset) { groupIndex, group in
        VStack(alignment: .leading, spacing: 8) {
          Text(LocalizedStringKey(group.name))
            .scaledFont(.sm, weight: .semibold)
            .padding(.horizontal, 4)
            .accessibilityAddTraits(.isHeader)
          ItemGroup(.inset) {
            ForEach(Array(group.permissions.enumerated()), id: \.offset) { position, permission in
              if position > 0 {
                ItemSeparator()
              }
              permissionRow(permission, slot: offsets[groupIndex] + position)
            }
          }
        }
      }
    }
    .foregroundStyle(EsuissColor.foreground)
  }

  private func permissionRow(_ permission: Permission, slot: Int) -> some View {
    let current = role
    let binding = Binding<Bool>(
      get: { state[current]?[slot] ?? false },
      set: { value in
        onTogglePermission?(current, permission.name, value)
        withAnimation(animation) { state[current]?[slot] = value }
      }
    )
    return Item(size: .sm) {
      ItemContent {
        ItemTitle { Text(LocalizedStringKey(permission.name)).fontWeight(.regular) }
        ItemDescription(LocalizedStringKey(permission.description))
      }
      ItemActions {
        Toggle(isOn: binding) { Text(LocalizedStringKey(permission.name)) }
          .labelsHidden()
          .toggleStyle(SwitchStyle())
          .accessibilityLabel(Text(LocalizedStringKey(permission.name)))
      }
    }
  }
}

private struct RolesEditorHeading: View {
  @Environment(\.splitView) private var splitView

  var body: some View {
    if splitView?.wide ?? false {
      Text("Roles")
        .scaledFont(.sm, weight: .semibold)
        .foregroundStyle(EsuissColor.mutedForeground)
        .padding(.horizontal, 4)
        .padding(.vertical, 8)
        .accessibilityAddTraits(.isHeader)
    }
  }
}

private struct RolesEditorShell<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/RolesEditor.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
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.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
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.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.Plus
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.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.ItemSeparator
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.Switch
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

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

private class RolePermission(val name: String, val description: String)

private class RolePermissionGroup(val name: String, val permissions: List<RolePermission>)

private val RolePermissionGroups = listOf(
    RolePermissionGroup(
        "Content",
        listOf(
            RolePermission("Create drafts", "Can start new content drafts."),
            RolePermission("Publish", "Can make content public."),
            RolePermission("Delete", "Can permanently delete content."),
        ),
    ),
    RolePermissionGroup(
        "Customers",
        listOf(
            RolePermission("Customer data", "Sees names, emails and order history."),
            RolePermission("Export", "Downloads the customer list as CSV."),
        ),
    ),
)

private val RolePermissionNames = RolePermissionGroups.flatMap { group -> group.permissions.map { it.name } }

private val EditorRoles = listOf("Editor", "Support", "Viewer")

private val RoleMembers = mapOf("Editor" to 2, "Support" to 2, "Viewer" to 1)

private val RoleDefaults = mapOf(
    "Editor" to listOf(true, true, false, true, false),
    "Support" to listOf(false, false, false, true, false),
    "Viewer" to listOf(false, false, false, false, false),
)

@Composable
fun RolesEditor(
    modifier: Modifier = Modifier,
    onTogglePermission: (role: String, permission: String, enabled: Boolean) -> Unit = { _, _, _ -> },
    onSave: (permissions: Map<String, List<String>>) -> Unit = {},
    onRevert: () -> Unit = {},
    onNewRole: () -> Unit = {},
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var role by rememberSaveable { mutableStateOf("Editor") }
    var column by rememberSaveable { mutableStateOf(SplitViewColumn.List) }
    val state = remember { mutableStateMapOf<String, List<Boolean>>().apply { putAll(RoleDefaults) } }
    val base = remember { mutableStateMapOf<String, List<Boolean>>().apply { putAll(RoleDefaults) } }
    val dirty = EditorRoles.any { state[it] != base[it] }

    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("roles", onNavigate) {
            SplitView(column = column, onColumnChange = { column = it }) {
                SplitViewList(title = "Roles") {
                    EditorRoles.forEach { name ->
                        SplitViewItem(isActive = name == role, onClick = { role = name }) {
                            BasicText(name, style = Typography.base.copy(color = colors.foreground))
                            BasicText(
                                "${state.getValue(name).count { it }} permissions",
                                style = Typography.xs.copy(color = colors.mutedForeground),
                            )
                        }
                    }
                    Button(
                        onClick = onNewRole,
                        modifier = Modifier.padding(top = 10.dp),
                        size = ButtonSize.Sm,
                        variant = ButtonVariant.Secondary,
                    ) {
                        Icon(PhosphorIcons.Bold.Plus, contentDescription = null, size = 13.dp)
                        BasicText("New Role", style = LocalContentStyle.current)
                    }
                }
                SplitViewDetail {
                    key(role) {
                        RoleAppear {
                            Column(
                                modifier = Modifier.padding(top = 8.dp),
                                verticalArrangement = Arrangement.spacedBy(18.dp),
                            ) {
                                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
                                    Column(Modifier.weight(1f)) {
                                        BasicText(
                                            role,
                                            modifier = Modifier.semantics { heading() },
                                            style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                        )
                                        val members = RoleMembers.getValue(role)
                                        BasicText(
                                            "$members ${if (members == 1) "user has" else "users have"} this role",
                                            style = Typography.sm.copy(color = colors.mutedForeground),
                                        )
                                    }
                                    if (dirty) {
                                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                                            Button(
                                                text = "Revert",
                                                onClick = {
                                                    state.putAll(base)
                                                    onRevert()
                                                },
                                                size = ButtonSize.Sm,
                                                variant = ButtonVariant.Secondary,
                                            )
                                            Button(
                                                text = "Save",
                                                onClick = {
                                                    base.putAll(state)
                                                    onSave(
                                                        EditorRoles.associateWith { name ->
                                                            RolePermissionNames.filterIndexed { index, _ -> state.getValue(name)[index] }
                                                        },
                                                    )
                                                },
                                                size = ButtonSize.Sm,
                                            )
                                        }
                                    }
                                }
                                RolePermissionGroups.forEach { group ->
                                    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                                        BasicText(
                                            group.name,
                                            modifier = Modifier
                                                .padding(horizontal = 4.dp)
                                                .semantics { heading() },
                                            style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                        )
                                        ItemGroup(variant = ItemGroupVariant.Inset) {
                                            group.permissions.forEachIndexed { position, permission ->
                                                val index = RolePermissionNames.indexOf(permission.name)
                                                if (position > 0) ItemSeparator()
                                                PermissionRow(
                                                    permission = permission,
                                                    checked = state.getValue(role)[index],
                                                ) { enabled ->
                                                    onTogglePermission(role, permission.name, enabled)
                                                    state[role] = state.getValue(role).mapIndexed { i, value -> if (i == index) enabled else value }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun RoleAppear(content: @Composable () -> Unit) {
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(800, easing = RoleRevealEasing)) }
    Box(
        Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 6.dp.toPx()
        },
    ) {
        content()
    }
}

@Composable
private fun PermissionRow(permission: RolePermission, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
    val colors = EsuissTheme.colors
    Item(size = ItemSize.Sm, role = null) {
        ItemContent {
            BasicText(
                permission.name,
                style = Typography.base.copy(color = colors.foreground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            BasicText(permission.description, style = Typography.xs.copy(color = colors.mutedForeground))
        }
        ItemActions {
            Switch(
                checked = checked,
                onCheckedChange = onCheckedChange,
                modifier = Modifier.semantics { contentDescription = permission.name },
            )
        }
    }
}

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)
        }
    }
}
```

A `SplitView` lists the Editor, Support and Viewer roles with their member counts, and the detail pane shows the selected role's permissions grouped into `ItemGroup` lists, each with a `Switch`. Revert and Save buttons appear once a switch changes, and a New Role `Button` sits under the list. An admin `TabBar` switches between Users, Roles, Activity and Settings; sections other than Roles show a placeholder. On wide screens the list and detail sit side by side.

## Installation

**React Native**

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

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-editor
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Switch](https://ui.suiss.app/docs/components/switch), [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 phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Switch](https://ui.suiss.app/docs/components/switch), [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-editor.tsx`:

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

5. Update the import paths to match your project.

**NativeWind**

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: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Switch](https://ui.suiss.app/docs/components/switch), [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-editor.tsx`:

```tsx title="nativewind/components/blocks/roles-editor.tsx"
import {
  ChartBarIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
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 GROUPS = [
  {
    name: "Content",
    permissions: [
      { name: "Create drafts", description: "Can start new content drafts." },
      { name: "Publish", description: "Can make content public." },
      { name: "Delete", description: "Can permanently delete content." },
    ],
  },
  {
    name: "Customers",
    permissions: [
      {
        name: "Customer data",
        description: "Sees names, emails and order history.",
      },
      { name: "Export", description: "Downloads the customer list as CSV." },
    ],
  },
]

const PERMISSIONS = GROUPS.flatMap((group) =>
  group.permissions.map((permission) => permission.name)
)

const ROLES = ["Editor", "Support", "Viewer"] as const

type Role = (typeof ROLES)[number]

type Grants = Record<Role, boolean[]>

const MEMBERS: Record<Role, number> = { Editor: 2, Support: 2, Viewer: 1 }

const DEFAULTS: Grants = {
  Editor: [true, true, false, true, false],
  Support: [false, false, false, true, false],
  Viewer: [false, false, false, false, false],
}

const clone = (value: Grants): Grants => ({
  Editor: [...value.Editor],
  Support: [...value.Support],
  Viewer: [...value.Viewer],
})

const granted = (value: Grants): Record<Role, string[]> => ({
  Editor: PERMISSIONS.filter((_, index) => value.Editor[index]),
  Support: PERMISSIONS.filter((_, index) => value.Support[index]),
  Viewer: PERMISSIONS.filter((_, index) => value.Viewer[index]),
})

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 RolesEditor({
  onTogglePermission,
  onSave,
  onRevert,
  onNewRole,
  onNavigate,
}: {
  onTogglePermission?: (
    role: Role,
    permission: string,
    enabled: boolean
  ) => void
  onSave?: (permissions: Record<Role, string[]>) => void
  onRevert?: () => void
  onNewRole?: () => void
  onNavigate?: (section: string) => void
}) {
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [section, setSection] = React.useState("roles")
  const [role, setRole] = React.useState<Role>("Editor")
  const [state, setState] = React.useState(() => clone(DEFAULTS))
  const [base, setBase] = React.useState(() => clone(DEFAULTS))
  const dirty = JSON.stringify(state) !== JSON.stringify(base)
  let slot = 0

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <SplitView className={wide ? "m-4" : undefined}>
          <SplitViewList
            title="Roles"
            className={wide ? "gap-0.5 px-4 pt-5" : "px-4 pb-8"}
          >
            {wide ? (
              <Text
                role="heading"
                className="px-1 pb-2 text-sm font-semibold text-muted-foreground"
              >
                Roles
              </Text>
            ) : null}
            {ROLES.map((name) => (
              <SplitViewItem
                key={name}
                isActive={name === role}
                onPress={() => setRole(name)}
                className="py-1.5"
              >
                <Text>{name}</Text>
                <Text className="text-xs text-muted-foreground">
                  {`${state[name].filter(Boolean).length} permissions`}
                </Text>
              </SplitViewItem>
            ))}
            <Button
              size="sm"
              variant="secondary"
              onPress={onNewRole}
              className="mt-2.5 self-start"
            >
              <Icon as={PlusIcon} weight="bold" className="size-[0.8125rem]" />
              <Text>New Role</Text>
            </Button>
          </SplitViewList>
          <SplitViewDetail className={wide ? "p-6" : "px-4 pb-8"}>
            <AnimatedView
              key={role}
              entering={FadeInDown.duration(800).reduceMotion(
                ReduceMotion.System
              )}
              className="gap-[1.125rem]"
            >
              <View className="flex-row flex-wrap items-start gap-3">
                <View className="min-w-0 flex-1">
                  <Text role="heading" className="text-3xl font-semibold">
                    {role}
                  </Text>
                  <Text className="text-sm text-muted-foreground">
                    {`${MEMBERS[role]} ${MEMBERS[role] === 1 ? "user has" : "users have"} this role`}
                  </Text>
                </View>
                {dirty ? (
                  <View className="flex-row gap-2">
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onRevert?.()
                        setState(clone(base))
                      }}
                    >
                      <Text>Revert</Text>
                    </Button>
                    <Button
                      size="sm"
                      onPress={() => {
                        onSave?.(granted(state))
                        setBase(clone(state))
                      }}
                    >
                      <Text>Save</Text>
                    </Button>
                  </View>
                ) : null}
              </View>
              {GROUPS.map((group) => (
                <View key={group.name} className="gap-2">
                  <Text role="heading" className="px-1 text-sm font-semibold">
                    {group.name}
                  </Text>
                  <ItemGroup variant="inset">
                    {group.permissions.map((permission, position) => {
                      const index = slot++
                      const id = `roles-editor-${index}`
                      return (
                        <React.Fragment key={permission.name}>
                          {position > 0 ? <ItemSeparator /> : null}
                          <Item size="sm">
                            <ItemContent className="gap-0">
                              <ItemTitle nativeID={id} className="font-normal">
                                {permission.name}
                              </ItemTitle>
                              <ItemDescription className="text-xs">
                                {permission.description}
                              </ItemDescription>
                            </ItemContent>
                            <ItemActions>
                              <Switch
                                checked={state[role][index] ?? false}
                                onCheckedChange={(value) => {
                                  onTogglePermission?.(
                                    role,
                                    permission.name,
                                    value
                                  )
                                  setState((current) => ({
                                    ...current,
                                    [role]: current[role].map((item, i) =>
                                      i === index ? value : item
                                    ),
                                  }))
                                }}
                                accessibilityLabelledBy={id}
                              />
                            </ItemActions>
                          </Item>
                        </React.Fragment>
                      )
                    })}
                  </ItemGroup>
                </View>
              ))}
            </AnimatedView>
          </SplitViewDetail>
        </SplitView>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesEditor }
```

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: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Switch](https://ui.suiss.app/docs/components/switch), [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-editor.tsx`:

```tsx title="stylesheet/components/blocks/roles-editor.tsx"
import {
  ChartBarIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import { fontSize, useTheme } from "@/components/ui/theme"

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 GROUPS = [
  {
    name: "Content",
    permissions: [
      { name: "Create drafts", description: "Can start new content drafts." },
      { name: "Publish", description: "Can make content public." },
      { name: "Delete", description: "Can permanently delete content." },
    ],
  },
  {
    name: "Customers",
    permissions: [
      {
        name: "Customer data",
        description: "Sees names, emails and order history.",
      },
      { name: "Export", description: "Downloads the customer list as CSV." },
    ],
  },
]

const PERMISSIONS = GROUPS.flatMap((group) =>
  group.permissions.map((permission) => permission.name)
)

const ROLES = ["Editor", "Support", "Viewer"] as const

type Role = (typeof ROLES)[number]

type Grants = Record<Role, boolean[]>

const MEMBERS: Record<Role, number> = { Editor: 2, Support: 2, Viewer: 1 }

const DEFAULTS: Grants = {
  Editor: [true, true, false, true, false],
  Support: [false, false, false, true, false],
  Viewer: [false, false, false, false, false],
}

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  wideRoot: { margin: 16 },
  listWide: { gap: 2, paddingHorizontal: 16, paddingTop: 20 },
  compact: { paddingHorizontal: 16, paddingBottom: 32 },
  detailWide: { padding: 24 },
  listHeading: {
    ...fontSize.sm,
    paddingHorizontal: 4,
    paddingBottom: 8,
    fontWeight: "600",
  },
  listItem: { paddingVertical: 6 },
  newRole: { marginTop: 10, alignSelf: "flex-start" },
  detail: { gap: 18 },
  top: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "flex-start",
    gap: 12,
  },
  topText: { minWidth: 0, flex: 1 },
  title: { ...fontSize["3xl"], fontWeight: "600" },
  actions: { flexDirection: "row", gap: 8 },
  group: { gap: 8 },
  groupHeading: { ...fontSize.sm, paddingHorizontal: 4, fontWeight: "600" },
  tight: { gap: 0 },
  regular: { fontWeight: "400" },
})

const clone = (value: Grants): Grants => ({
  Editor: [...value.Editor],
  Support: [...value.Support],
  Viewer: [...value.Viewer],
})

const granted = (value: Grants): Record<Role, string[]> => ({
  Editor: PERMISSIONS.filter((_, index) => value.Editor[index]),
  Support: PERMISSIONS.filter((_, index) => value.Support[index]),
  Viewer: PERMISSIONS.filter((_, index) => value.Viewer[index]),
})

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 RolesEditor({
  onTogglePermission,
  onSave,
  onRevert,
  onNewRole,
  onNavigate,
}: {
  onTogglePermission?: (
    role: Role,
    permission: string,
    enabled: boolean
  ) => void
  onSave?: (permissions: Record<Role, string[]>) => void
  onRevert?: () => void
  onNewRole?: () => void
  onNavigate?: (section: string) => void
}) {
  const { colors } = useTheme()
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [section, setSection] = React.useState("roles")
  const [role, setRole] = React.useState<Role>("Editor")
  const [state, setState] = React.useState(() => clone(DEFAULTS))
  const [base, setBase] = React.useState(() => clone(DEFAULTS))
  const dirty = JSON.stringify(state) !== JSON.stringify(base)
  let slot = 0

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "roles" ? (
        <SplitView style={wide ? styles.wideRoot : undefined}>
          <SplitViewList
            title="Roles"
            style={wide ? styles.listWide : styles.compact}
          >
            {wide ? (
              <Text
                role="heading"
                style={[styles.listHeading, { color: colors.mutedForeground }]}
              >
                Roles
              </Text>
            ) : null}
            {ROLES.map((name) => (
              <SplitViewItem
                key={name}
                isActive={name === role}
                onPress={() => setRole(name)}
                style={styles.listItem}
              >
                <Text>{name}</Text>
                <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
                  {`${state[name].filter(Boolean).length} permissions`}
                </Text>
              </SplitViewItem>
            ))}
            <Button
              size="sm"
              variant="secondary"
              onPress={onNewRole}
              style={styles.newRole}
            >
              <Icon as={PlusIcon} weight="bold" size={13} />
              <Text>New Role</Text>
            </Button>
          </SplitViewList>
          <SplitViewDetail style={wide ? styles.detailWide : styles.compact}>
            <AnimatedView
              key={role}
              entering={FadeInDown.duration(800).reduceMotion(
                ReduceMotion.System
              )}
              style={styles.detail}
            >
              <View style={styles.top}>
                <View style={styles.topText}>
                  <Text role="heading" style={styles.title}>
                    {role}
                  </Text>
                  <Text
                    style={[fontSize.sm, { color: colors.mutedForeground }]}
                  >
                    {`${MEMBERS[role]} ${MEMBERS[role] === 1 ? "user has" : "users have"} this role`}
                  </Text>
                </View>
                {dirty ? (
                  <View style={styles.actions}>
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onRevert?.()
                        setState(clone(base))
                      }}
                    >
                      <Text>Revert</Text>
                    </Button>
                    <Button
                      size="sm"
                      onPress={() => {
                        onSave?.(granted(state))
                        setBase(clone(state))
                      }}
                    >
                      <Text>Save</Text>
                    </Button>
                  </View>
                ) : null}
              </View>
              {GROUPS.map((group) => (
                <View key={group.name} style={styles.group}>
                  <Text role="heading" style={styles.groupHeading}>
                    {group.name}
                  </Text>
                  <ItemGroup variant="inset">
                    {group.permissions.map((permission, position) => {
                      const index = slot++
                      const id = `roles-editor-${index}`
                      return (
                        <React.Fragment key={permission.name}>
                          {position > 0 ? <ItemSeparator /> : null}
                          <Item size="sm">
                            <ItemContent style={styles.tight}>
                              <ItemTitle nativeID={id} style={styles.regular}>
                                {permission.name}
                              </ItemTitle>
                              <ItemDescription style={fontSize.xs}>
                                {permission.description}
                              </ItemDescription>
                            </ItemContent>
                            <ItemActions>
                              <Switch
                                checked={state[role][index] ?? false}
                                onCheckedChange={(value) => {
                                  onTogglePermission?.(
                                    role,
                                    permission.name,
                                    value
                                  )
                                  setState((current) => ({
                                    ...current,
                                    [role]: current[role].map((item, i) =>
                                      i === index ? value : item
                                    ),
                                  }))
                                }}
                                accessibilityLabelledBy={id}
                              />
                            </ItemActions>
                          </Item>
                        </React.Fragment>
                      )
                    })}
                  </ItemGroup>
                </View>
              ))}
            </AnimatedView>
          </SplitViewDetail>
        </SplitView>
      ) : (
        <OtherSection value={section} home="Roles" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { RolesEditor }
```

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/RolesEditor.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/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/Switch.swift`: see [Switch](https://ui.suiss.app/docs/components/switch.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/Patterns/SplitView.swift`: see [Split View](https://ui.suiss.app/docs/patterns/split-view.md)

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/RolesEditor.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/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/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

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

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

- `kotlin/patterns/SplitView.kt`: see [Split View](https://ui.suiss.app/docs/patterns/split-view.md)

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

## Usage

```tsx
import { RolesEditor } from "@/components/blocks/roles-editor"
```

```tsx
<RolesEditor />
```

Replace `GROUPS`, `ROLES`, `MEMBERS` and `DEFAULTS` with your permissions and grants. Use `onTogglePermission`, `onSave`, `onRevert`, `onNewRole` and `onNavigate` to persist changes and route between sections.

