suiss/uim · Interaction
Selection List
An edit mode for lists where rows slide in check circles, toggle on press, and a floating bar shows bulk actions.
Selection List is an open-source mobile interaction from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


import { CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
const initialNotes = [
{ id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
{ id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
{ id: "books", title: "Book list", preview: "Three novels for the summer" },
{ id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
{ id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
{ id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]
type Note = (typeof initialNotes)[number]
function NoteRows({
notes,
onOpen,
}: {
notes: Note[]
onOpen: (note: Note) => void
}) {
const { editing } = useSelectionList()
return (
<View className="overflow-hidden rounded-2xl border border-separator">
{notes.map((note, index) => (
<SelectionListItem
key={note.id}
value={note.id}
className={index > 0 ? "border-t border-separator" : undefined}
>
<Pressable
onPress={() => onOpen(note)}
className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:bg-item-pressed"
>
<View className="min-w-0 flex-1">
<Text numberOfLines={1} className="font-semibold">
{note.title}
</Text>
<Text numberOfLines={1} className="text-sm text-label-secondary">
{note.preview}
</Text>
</View>
<Icon
as={CaretRightIcon}
className={
editing
? "size-4 shrink-0 text-label-tertiary opacity-0"
: "size-4 shrink-0 text-label-tertiary"
}
/>
</Pressable>
</SelectionListItem>
))}
</View>
)
}
function SelectAllButton() {
const { selected, selectAll, clear } = useSelectionList()
const all = selected.length > 0
return (
<Button
variant="plain"
size="lg"
className="px-4"
onPress={all ? clear : selectAll}
>
<Text>{all ? "Deselect All" : "Select All"}</Text>
</Button>
)
}
function DeleteButton({ onDelete }: { onDelete: (ids: string[]) => void }) {
const { selected, setEditing } = useSelectionList()
const disabled = selected.length === 0
return (
<Button
variant="plain"
size="lg"
className="px-4"
disabled={disabled}
onPress={() => {
onDelete(selected)
setEditing(false)
}}
>
<Text className={disabled ? "text-label-quaternary" : "text-danger"}>
Delete
</Text>
</Button>
)
}
function useNotes() {
const [notes, setNotes] = React.useState(initialNotes)
const [log, setLog] = React.useState("Tap Select to choose notes.")
return {
notes,
log,
setLog,
remove: (ids: string[]) => {
setNotes((current) => current.filter((note) => !ids.includes(note.id)))
setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
},
reset: () => {
setNotes(initialNotes)
setLog("Tap Select to choose notes.")
},
}
}
function Footer({ log, onReset }: { log: string; onReset: () => void }) {
return (
<View className="flex-row items-center justify-between gap-3">
<Text
role="status"
className="min-w-0 flex-1 text-sm text-label-secondary"
>
{log}
</Text>
<Button size="sm" variant="secondary" onPress={onReset}>
<Text>Reset</Text>
</Button>
</View>
)
}
function NotesExample({
defaultEditing,
defaultValue,
}: {
defaultEditing?: boolean
defaultValue?: string[]
}) {
const { notes, log, setLog, remove, reset } = useNotes()
return (
<SelectionList
defaultEditing={defaultEditing}
defaultValue={defaultValue}
className="w-full gap-3"
>
<View className="flex-row items-center justify-between">
<Text variant="h1">Notes</Text>
<SelectionListTrigger />
</View>
<NoteRows
notes={notes}
onOpen={(note) => setLog(`Opened ${note.title}`)}
/>
<Footer log={log} onReset={reset} />
<SelectionListBar accessibilityLabel="Selection actions">
<SelectAllButton />
<SelectionListCount />
<DeleteButton onDelete={remove} />
</SelectionListBar>
</SelectionList>
)
}
export function SelectionListExample() {
return (
<NotesExample />
)
}import EsuissUI
import PhosphorSwift
import SwiftUI
struct SelectionListNote: Identifiable {
let id: String
let title: String
let preview: String
}
let selectionListInitialNotes = [
SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]
let selectionListIdleLog = "Tap Select to choose notes."
struct SelectionListRows: View {
let notes: [SelectionListNote]
let editing: Bool
let onOpen: (SelectionListNote) -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
VStack(spacing: 0) {
ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
SelectionListItem(value: note.id) {
Button {
onOpen(note)
} label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: note.title)
.textStyle(.base, weight: .semibold)
.foregroundStyle(Color.label)
.lineLimit(1)
Text(verbatim: note.preview)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Ph.caretRight.regular
.frame(width: 16, height: 16)
.foregroundStyle(Color.labelTertiary)
.opacity(editing ? 0 : 1)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(SelectionListRowStyle())
}
.overlay(alignment: .top) {
if index > 0 {
Color.separator.frame(height: 1)
}
}
}
}
.clipShape(shape)
.overlay { shape.strokeBorder(Color.separator) }
}
}
struct SelectionListRowStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? Color.itemPressed : .clear)
}
}
struct SelectionListFooter: View {
let log: String
let onReset: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(verbatim: log)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityAddTraits(.updatesFrequently)
Button("Reset", action: onReset)
.buttonStyle(.variant(.secondary, size: .sm))
}
}
}
struct SelectionListStage<Content: View, Bar: View>: View {
@ViewBuilder let content: () -> Content
@ViewBuilder let bar: () -> Bar
var body: some View {
VStack(alignment: .leading, spacing: 12) {
content()
}
.frame(maxHeight: .infinity, alignment: .top)
.overlay(alignment: .bottom) {
bar()
.padding(.horizontal, -16)
}
.containerRelativeFrame(.vertical) { height, _ in height - 32 }
}
}
struct SelectionListNotes: View {
@State private var editing: Bool
@State private var selection: Set<String>
@State private var notes = selectionListInitialNotes
@State private var log = selectionListIdleLog
init(editing: Bool = false, selection: Set<String> = []) {
_editing = State(initialValue: editing)
_selection = State(initialValue: selection)
}
var body: some View {
SelectionList(isEditing: $editing, selection: $selection) {
SelectionListStage {
HStack {
Text("Notes")
.textVariant(.h1)
Spacer()
SelectionListTrigger()
}
SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
SelectionListFooter(log: log) {
notes = selectionListInitialNotes
log = selectionListIdleLog
}
} bar: {
SelectionListBar { list in
Button(list.count > 0 ? "Deselect All" : "Select All") {
if list.count > 0 { list.clear() } else { list.selectAll() }
}
.buttonStyle(.variant(.plain, size: .lg))
SelectionListCount()
Button {
let ids = selection
notes.removeAll { ids.contains($0.id) }
log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
selection = []
list.setEditing(false)
} label: {
Text("Delete")
.foregroundStyle(list.count == 0 ? Color.labelQuaternary : .danger)
}
.buttonStyle(.variant(.plain, size: .lg))
.disabled(list.count == 0)
}
.accessibilityLabel(Text("Selection actions"))
}
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
class SelectionListNote(val id: String, val title: String, val preview: String)
val selectionListInitialNotes = listOf(
SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
SelectionListNote("books", "Book list", "Three novels for the summer"),
SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)
@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, colors.separator, shape),
) {
notes.forEachIndexed { index, note ->
if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.separator))
SelectionListItem(value = note.id, contentDescription = note.title) {
SelectionListRowContent(note, editing) { onOpen(note) }
}
}
}
}
@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
log,
modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
style = Typography.sm.copy(color = EsuissTheme.colors.labelSecondary),
)
Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
}
}
@Composable
fun SelectionListHeader(title: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, variant = TextVariant.H1)
SelectionListTrigger()
}
}
fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"
@Composable
fun SelectionListNotes(defaultEditing: Boolean = false, defaultValue: List<String> = emptyList()) {
val colors = EsuissTheme.colors
var notes by remember { mutableStateOf(selectionListInitialNotes) }
var log by rememberSaveable { mutableStateOf(SelectionListHint) }
var editing by rememberSaveable { mutableStateOf(defaultEditing) }
var selected by rememberSaveable { mutableStateOf(defaultValue) }
SelectionList(
editing = editing,
onEditingChange = { editing = it },
value = selected,
onValueChange = { selected = it },
modifier = Modifier.fillMaxWidth(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SelectionListHeader("Notes")
SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
SelectionListFooter(log) {
notes = selectionListInitialNotes
log = SelectionListHint
}
}
SelectionListBar {
val any = this@SelectionList.selected.isNotEmpty()
Button(
if (any) "Deselect All" else "Select All",
onClick = { if (any) clear() else selectAll() },
modifier = Modifier.padding(horizontal = 4.dp),
variant = ButtonVariant.Plain,
size = ButtonSize.Lg,
)
SelectionListCount()
Button(
onClick = {
val ids = this@SelectionList.selected
notes = notes.filterNot { it.id in ids }
log = selectionListDeleted(ids.size)
setEditing(false)
},
modifier = Modifier.padding(horizontal = 4.dp),
variant = ButtonVariant.Plain,
size = ButtonSize.Lg,
enabled = any,
) {
Text("Delete", style = Typography.base.copy(color = if (any) colors.danger else colors.labelQuaternary))
}
}
}
}SelectionList switches a list between normal and editing modes. In editing mode each SelectionListItem slides in a check circle at its start, the whole row toggles on press, and selected rows are highlighted.
SelectionListBar floats above the bottom safe area while editing, sliding up into view, for actions such as delete or move. Leaving editing mode clears the selection. On Android the hardware back button also leaves it. Use it for mail, notes or photo lists that need bulk actions.
Installation
$ pnpm dlx @esuiss/uim@latest add selection-list$ pnpm dlx @esuiss/uim@latest add selection-list --platform swift$ pnpm dlx @esuiss/uim@latest add selection-list --platform kotlinThe CLI also adds what it builds on: Button, Icon, Text.
Manual
- Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets-
Add the pieces it builds on: Button, Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/selection-list.tsx:
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"- Update the import paths to match your project.
- Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets-
Add the pieces it builds on: Button, Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/selection-list.tsx:
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"- Update the import paths to match your project.
- Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets-
Add the pieces it builds on: Button, Icon, Text, and the primitives
components/primitives/hooks.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/selection-list.tsx:
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View, type ViewProps } from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
import SwiftUI
import UIKit
struct RGBA: Sendable {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
init(_ hex: UInt32, _ alpha: CGFloat = 1) {
red = CGFloat((hex >> 16) & 0xFF) / 255
green = CGFloat((hex >> 8) & 0xFF) / 255
blue = CGFloat(hex & 0xFF) / 255
self.alpha = alpha
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
import PhosphorSwift
import SwiftUI
struct SelectionModel<Value: Hashable> {
var editing = false
var selected: Set<Value> = []
var order: [Value] = []
mutating func toggle(_ value: Value) {
if selected.contains(value) {
selected.remove(value)
} else {
selected.insert(value)
}
}import SwiftUI
public struct VariantButtonStyle: ButtonStyle {
public enum Variant: CaseIterable, Sendable {
case `default`, outline, secondary, tinted, ghost, plain, destructive, link
}
public enum Size: CaseIterable, Sendable {
case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl
var height: CGFloat {
switch self {
case .xs, .iconXs: 24
case .sm, .iconSm: 32
case .default, .icon: 36
case .lg, .iconLg: 44import SwiftUI
extension EnvironmentValues {
@Entry var buttonGroup: Axis? = nil
@Entry var buttonGroupSize: CGSize = .zero
}
enum ButtonGroupSpace: Hashable {
case group
}
struct ButtonGroupFrame {
let bounds: Anchor<CGRect>
let outlined: Bool
}import PhosphorSwift
import SwiftUI
extension Shadow {
static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}
struct SelectionIndicator<Mark: View>: View {
let checked: Bool
let pressed: Bool
let cornerRadius: CGFloat
let mark: Mark
@Environment(\.isEnabled) private var isEnabled
@Environment(\.isInvalid) private var invalidimport SwiftUI
extension EnvironmentValues {
@Entry public var isInvalid = false
@Entry var fieldInset = false
@Entry var fieldGroupInset = false
@Entry var fieldSetInset = false
}
extension View {
public func invalid(_ isInvalid: Bool = true) -> some View {
environment(\.isInvalid, isInvalid)
}
}
public enum FieldOrientation: CaseIterable, Sendable {import SwiftUI
extension EnvironmentValues {
@Entry public var hapticsEnabled = true
}
extension View {
public func haptics(_ enabled: Bool) -> some View {
environment(\.hapticsEnabled, enabled)
}
func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
}
func hapticFeedback<Trigger: Equatable>(import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}import SwiftUI
public enum TextVariant: CaseIterable, Sendable {
case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted
var style: TextStyle {
switch self {
case .h1: .xl3
case .h2: .xl2
case .h3: .xl
case .h4, .lead, .large: .lg
case .code, .small, .muted: .sm
default: .base
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
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")- Copy the theme files every view uses:
package com.esuiss.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffsetpackage com.esuiss.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Immutable
data class Colors(
val surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
package com.esuiss.ui.interactions
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidthpackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShapepackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutablepackage com.esuiss.ui.components
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val LocalContentStyle = compositionLocalOf { TextStyle.Default }
val LocalContentColor = compositionLocalOf { Color.Unspecified }
val LocalIconSize = compositionLocalOf { 20.dp }package com.esuiss.ui.components
import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Sizepackage com.esuiss.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme
@Composable
fun Icon(
imageVector: ImageVector,package com.esuiss.ui.interactions
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues- Update the
com.esuiss.uipackage names to match your project.
Usage
import { SelectionList, SelectionListTrigger, SelectionListItem, SelectionListBar, SelectionListCount } from "@/components/interactions/selection-list"<NotesExample />Wrap the list in SelectionList, add a SelectionListTrigger that toggles editing (it reads Select and Done by default), and give each SelectionListItem a unique value. Put your buttons and a SelectionListCount in SelectionListBar.
Editing and the selection can be uncontrolled with defaultEditing and defaultValue, or controlled with editing, onEditingChange, value and onValueChange. useSelectionList returns editing, selected, toggle, selectAll and clear for custom actions. Outside editing mode, the row's children receive presses as usual.
Examples
Editing
Starts in editing mode with two notes selected.


import { CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
const initialNotes = [
{ id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
{ id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
{ id: "books", title: "Book list", preview: "Three novels for the summer" },
{ id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
{ id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
{ id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]
type Note = (typeof initialNotes)[number]
function NoteRows({
notes,
onOpen,
}: {
notes: Note[]
onOpen: (note: Note) => void
}) {
const { editing } = useSelectionList()
return (
<View className="overflow-hidden rounded-2xl border border-separator">
{notes.map((note, index) => (
<SelectionListItem
key={note.id}
value={note.id}
className={index > 0 ? "border-t border-separator" : undefined}
>
<Pressable
onPress={() => onOpen(note)}
className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:bg-item-pressed"
>
<View className="min-w-0 flex-1">
<Text numberOfLines={1} className="font-semibold">
{note.title}
</Text>
<Text numberOfLines={1} className="text-sm text-label-secondary">
{note.preview}
</Text>
</View>
<Icon
as={CaretRightIcon}
className={
editing
? "size-4 shrink-0 text-label-tertiary opacity-0"
: "size-4 shrink-0 text-label-tertiary"
}
/>
</Pressable>
</SelectionListItem>
))}
</View>
)
}
function SelectAllButton() {
const { selected, selectAll, clear } = useSelectionList()
const all = selected.length > 0
return (
<Button
variant="plain"
size="lg"
className="px-4"
onPress={all ? clear : selectAll}
>
<Text>{all ? "Deselect All" : "Select All"}</Text>
</Button>
)
}
function DeleteButton({ onDelete }: { onDelete: (ids: string[]) => void }) {
const { selected, setEditing } = useSelectionList()
const disabled = selected.length === 0
return (
<Button
variant="plain"
size="lg"
className="px-4"
disabled={disabled}
onPress={() => {
onDelete(selected)
setEditing(false)
}}
>
<Text className={disabled ? "text-label-quaternary" : "text-danger"}>
Delete
</Text>
</Button>
)
}
function useNotes() {
const [notes, setNotes] = React.useState(initialNotes)
const [log, setLog] = React.useState("Tap Select to choose notes.")
return {
notes,
log,
setLog,
remove: (ids: string[]) => {
setNotes((current) => current.filter((note) => !ids.includes(note.id)))
setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
},
reset: () => {
setNotes(initialNotes)
setLog("Tap Select to choose notes.")
},
}
}
function Footer({ log, onReset }: { log: string; onReset: () => void }) {
return (
<View className="flex-row items-center justify-between gap-3">
<Text
role="status"
className="min-w-0 flex-1 text-sm text-label-secondary"
>
{log}
</Text>
<Button size="sm" variant="secondary" onPress={onReset}>
<Text>Reset</Text>
</Button>
</View>
)
}
function NotesExample({
defaultEditing,
defaultValue,
}: {
defaultEditing?: boolean
defaultValue?: string[]
}) {
const { notes, log, setLog, remove, reset } = useNotes()
return (
<SelectionList
defaultEditing={defaultEditing}
defaultValue={defaultValue}
className="w-full gap-3"
>
<View className="flex-row items-center justify-between">
<Text variant="h1">Notes</Text>
<SelectionListTrigger />
</View>
<NoteRows
notes={notes}
onOpen={(note) => setLog(`Opened ${note.title}`)}
/>
<Footer log={log} onReset={reset} />
<SelectionListBar accessibilityLabel="Selection actions">
<SelectAllButton />
<SelectionListCount />
<DeleteButton onDelete={remove} />
</SelectionListBar>
</SelectionList>
)
}
export function SelectionListEditingExample() {
return (
<NotesExample defaultEditing defaultValue={["trip", "meeting"]} />
)
}import EsuissUI
import PhosphorSwift
import SwiftUI
struct SelectionListEditingExample: View {
var body: some View {
SelectionListNotes(editing: true, selection: ["trip", "meeting"])
}
}
struct SelectionListNote: Identifiable {
let id: String
let title: String
let preview: String
}
let selectionListInitialNotes = [
SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]
let selectionListIdleLog = "Tap Select to choose notes."
struct SelectionListRows: View {
let notes: [SelectionListNote]
let editing: Bool
let onOpen: (SelectionListNote) -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
VStack(spacing: 0) {
ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
SelectionListItem(value: note.id) {
Button {
onOpen(note)
} label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: note.title)
.textStyle(.base, weight: .semibold)
.foregroundStyle(Color.label)
.lineLimit(1)
Text(verbatim: note.preview)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Ph.caretRight.regular
.frame(width: 16, height: 16)
.foregroundStyle(Color.labelTertiary)
.opacity(editing ? 0 : 1)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(SelectionListRowStyle())
}
.overlay(alignment: .top) {
if index > 0 {
Color.separator.frame(height: 1)
}
}
}
}
.clipShape(shape)
.overlay { shape.strokeBorder(Color.separator) }
}
}
struct SelectionListRowStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? Color.itemPressed : .clear)
}
}
struct SelectionListFooter: View {
let log: String
let onReset: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(verbatim: log)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityAddTraits(.updatesFrequently)
Button("Reset", action: onReset)
.buttonStyle(.variant(.secondary, size: .sm))
}
}
}
struct SelectionListStage<Content: View, Bar: View>: View {
@ViewBuilder let content: () -> Content
@ViewBuilder let bar: () -> Bar
var body: some View {
VStack(alignment: .leading, spacing: 12) {
content()
}
.frame(maxHeight: .infinity, alignment: .top)
.overlay(alignment: .bottom) {
bar()
.padding(.horizontal, -16)
}
.containerRelativeFrame(.vertical) { height, _ in height - 32 }
}
}
struct SelectionListNotes: View {
@State private var editing: Bool
@State private var selection: Set<String>
@State private var notes = selectionListInitialNotes
@State private var log = selectionListIdleLog
init(editing: Bool = false, selection: Set<String> = []) {
_editing = State(initialValue: editing)
_selection = State(initialValue: selection)
}
var body: some View {
SelectionList(isEditing: $editing, selection: $selection) {
SelectionListStage {
HStack {
Text("Notes")
.textVariant(.h1)
Spacer()
SelectionListTrigger()
}
SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
SelectionListFooter(log: log) {
notes = selectionListInitialNotes
log = selectionListIdleLog
}
} bar: {
SelectionListBar { list in
Button(list.count > 0 ? "Deselect All" : "Select All") {
if list.count > 0 { list.clear() } else { list.selectAll() }
}
.buttonStyle(.variant(.plain, size: .lg))
SelectionListCount()
Button {
let ids = selection
notes.removeAll { ids.contains($0.id) }
log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
selection = []
list.setEditing(false)
} label: {
Text("Delete")
.foregroundStyle(list.count == 0 ? Color.labelQuaternary : .danger)
}
.buttonStyle(.variant(.plain, size: .lg))
.disabled(list.count == 0)
}
.accessibilityLabel(Text("Selection actions"))
}
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
@Composable
fun SelectionListEditingExample() {
SelectionListNotes(defaultEditing = true, defaultValue = listOf("trip", "meeting"))
}
class SelectionListNote(val id: String, val title: String, val preview: String)
val selectionListInitialNotes = listOf(
SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
SelectionListNote("books", "Book list", "Three novels for the summer"),
SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)
@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, colors.separator, shape),
) {
notes.forEachIndexed { index, note ->
if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.separator))
SelectionListItem(value = note.id, contentDescription = note.title) {
SelectionListRowContent(note, editing) { onOpen(note) }
}
}
}
}
@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
log,
modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
style = Typography.sm.copy(color = EsuissTheme.colors.labelSecondary),
)
Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
}
}
@Composable
fun SelectionListHeader(title: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, variant = TextVariant.H1)
SelectionListTrigger()
}
}
fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"
@Composable
fun SelectionListNotes(defaultEditing: Boolean = false, defaultValue: List<String> = emptyList()) {
val colors = EsuissTheme.colors
var notes by remember { mutableStateOf(selectionListInitialNotes) }
var log by rememberSaveable { mutableStateOf(SelectionListHint) }
var editing by rememberSaveable { mutableStateOf(defaultEditing) }
var selected by rememberSaveable { mutableStateOf(defaultValue) }
SelectionList(
editing = editing,
onEditingChange = { editing = it },
value = selected,
onValueChange = { selected = it },
modifier = Modifier.fillMaxWidth(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SelectionListHeader("Notes")
SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
SelectionListFooter(log) {
notes = selectionListInitialNotes
log = SelectionListHint
}
}
SelectionListBar {
val any = this@SelectionList.selected.isNotEmpty()
Button(
if (any) "Deselect All" else "Select All",
onClick = { if (any) clear() else selectAll() },
modifier = Modifier.padding(horizontal = 4.dp),
variant = ButtonVariant.Plain,
size = ButtonSize.Lg,
)
SelectionListCount()
Button(
onClick = {
val ids = this@SelectionList.selected
notes = notes.filterNot { it.id in ids }
log = selectionListDeleted(ids.size)
setEditing(false)
},
modifier = Modifier.padding(horizontal = 4.dp),
variant = ButtonVariant.Plain,
size = ButtonSize.Lg,
enabled = any,
) {
Text("Delete", style = Typography.base.copy(color = if (any) colors.danger else colors.labelQuaternary))
}
}
}
}Icon actions
A mail list whose bar holds icon-only actions.
import { ArchiveIcon, CaretRightIcon, FolderSimpleIcon, TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
const initialNotes = [
{ id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
{ id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
{ id: "books", title: "Book list", preview: "Three novels for the summer" },
{ id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
{ id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
{ id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]
type Note = (typeof initialNotes)[number]
function NoteRows({
notes,
onOpen,
}: {
notes: Note[]
onOpen: (note: Note) => void
}) {
const { editing } = useSelectionList()
return (
<View className="overflow-hidden rounded-2xl border border-separator">
{notes.map((note, index) => (
<SelectionListItem
key={note.id}
value={note.id}
className={index > 0 ? "border-t border-separator" : undefined}
>
<Pressable
onPress={() => onOpen(note)}
className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:bg-item-pressed"
>
<View className="min-w-0 flex-1">
<Text numberOfLines={1} className="font-semibold">
{note.title}
</Text>
<Text numberOfLines={1} className="text-sm text-label-secondary">
{note.preview}
</Text>
</View>
<Icon
as={CaretRightIcon}
className={
editing
? "size-4 shrink-0 text-label-tertiary opacity-0"
: "size-4 shrink-0 text-label-tertiary"
}
/>
</Pressable>
</SelectionListItem>
))}
</View>
)
}
function useNotes() {
const [notes, setNotes] = React.useState(initialNotes)
const [log, setLog] = React.useState("Tap Select to choose notes.")
return {
notes,
log,
setLog,
remove: (ids: string[]) => {
setNotes((current) => current.filter((note) => !ids.includes(note.id)))
setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
},
reset: () => {
setNotes(initialNotes)
setLog("Tap Select to choose notes.")
},
}
}
function Footer({ log, onReset }: { log: string; onReset: () => void }) {
return (
<View className="flex-row items-center justify-between gap-3">
<Text
role="status"
className="min-w-0 flex-1 text-sm text-label-secondary"
>
{log}
</Text>
<Button size="sm" variant="secondary" onPress={onReset}>
<Text>Reset</Text>
</Button>
</View>
)
}
function MailExample() {
const { notes, log, setLog, remove, reset } = useNotes()
const [selected, setSelected] = React.useState<string[]>([])
const disabled = selected.length === 0
const act = (verb: string) => setLog(`${verb} ${selected.join(", ")}`)
return (
<SelectionList
value={selected}
onValueChange={setSelected}
className="w-full gap-3"
>
<View className="flex-row items-center justify-between">
<Text variant="h1">Inbox</Text>
<SelectionListTrigger />
</View>
<NoteRows
notes={notes}
onOpen={(note) => setLog(`Opened ${note.title}`)}
/>
<Footer log={log} onReset={reset} />
<SelectionListBar accessibilityLabel="Selection actions">
<Button
variant="plain"
size="icon-lg"
accessibilityLabel="Archive"
disabled={disabled}
onPress={() => act("Archived")}
>
<Icon as={ArchiveIcon} className="size-5.5" />
</Button>
<Button
variant="plain"
size="icon-lg"
accessibilityLabel="Move"
disabled={disabled}
onPress={() => act("Moved")}
>
<Icon as={FolderSimpleIcon} className="size-5.5" />
</Button>
<SelectionListCount />
<Button
variant="plain"
size="icon-lg"
accessibilityLabel="Delete"
disabled={disabled}
onPress={() => {
remove(selected)
setSelected([])
}}
>
<Icon
as={TrashIcon}
className={
disabled
? "size-5.5 text-label-quaternary"
: "size-5.5 text-danger"
}
/>
</Button>
</SelectionListBar>
</SelectionList>
)
}
export function SelectionListIconActionsExample() {
return (
<MailExample />
)
}import EsuissUI
import PhosphorSwift
import SwiftUI
struct SelectionListNote: Identifiable {
let id: String
let title: String
let preview: String
}
let selectionListInitialNotes = [
SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]
let selectionListIdleLog = "Tap Select to choose notes."
struct SelectionListRows: View {
let notes: [SelectionListNote]
let editing: Bool
let onOpen: (SelectionListNote) -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
VStack(spacing: 0) {
ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
SelectionListItem(value: note.id) {
Button {
onOpen(note)
} label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: note.title)
.textStyle(.base, weight: .semibold)
.foregroundStyle(Color.label)
.lineLimit(1)
Text(verbatim: note.preview)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Ph.caretRight.regular
.frame(width: 16, height: 16)
.foregroundStyle(Color.labelTertiary)
.opacity(editing ? 0 : 1)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(SelectionListRowStyle())
}
.overlay(alignment: .top) {
if index > 0 {
Color.separator.frame(height: 1)
}
}
}
}
.clipShape(shape)
.overlay { shape.strokeBorder(Color.separator) }
}
}
struct SelectionListRowStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? Color.itemPressed : .clear)
}
}
struct SelectionListFooter: View {
let log: String
let onReset: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(verbatim: log)
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityAddTraits(.updatesFrequently)
Button("Reset", action: onReset)
.buttonStyle(.variant(.secondary, size: .sm))
}
}
}
struct SelectionListStage<Content: View, Bar: View>: View {
@ViewBuilder let content: () -> Content
@ViewBuilder let bar: () -> Bar
var body: some View {
VStack(alignment: .leading, spacing: 12) {
content()
}
.frame(maxHeight: .infinity, alignment: .top)
.overlay(alignment: .bottom) {
bar()
.padding(.horizontal, -16)
}
.containerRelativeFrame(.vertical) { height, _ in height - 32 }
}
}
struct SelectionListMail: View {
@State private var editing = false
@State private var selection: Set<String> = []
@State private var notes = selectionListInitialNotes
@State private var log = selectionListIdleLog
var selectedIDs: String {
notes.map(\.id).filter { selection.contains($0) }.joined(separator: ", ")
}
var body: some View {
let disabled = selection.isEmpty
SelectionList(isEditing: $editing, selection: $selection) {
SelectionListStage {
HStack {
Text("Inbox")
.textVariant(.h1)
Spacer()
SelectionListTrigger()
}
SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
SelectionListFooter(log: log) {
notes = selectionListInitialNotes
log = selectionListIdleLog
}
} bar: {
SelectionListBar {
Button {
log = "Archived \(selectedIDs)"
} label: {
Ph.archive.regular.frame(width: 22, height: 22)
}
.buttonStyle(.variant(.plain, size: .iconLg))
.accessibilityLabel(Text("Archive"))
.disabled(disabled)
Button {
log = "Moved \(selectedIDs)"
} label: {
Ph.folderSimple.regular.frame(width: 22, height: 22)
}
.buttonStyle(.variant(.plain, size: .iconLg))
.accessibilityLabel(Text("Move"))
.disabled(disabled)
SelectionListCount()
Button {
let ids = selection
notes.removeAll { ids.contains($0.id) }
log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
selection = []
} label: {
Ph.trash.regular
.frame(width: 22, height: 22)
.foregroundStyle(disabled ? Color.labelQuaternary : .danger)
}
.buttonStyle(.variant(.plain, size: .iconLg))
.accessibilityLabel(Text("Delete"))
.disabled(disabled)
}
.accessibilityLabel(Text("Selection actions"))
}
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Archive
import com.adamglin.phosphoricons.regular.FolderSimple
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
class SelectionListNote(val id: String, val title: String, val preview: String)
val selectionListInitialNotes = listOf(
SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
SelectionListNote("books", "Book list", "Three novels for the summer"),
SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)
@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, colors.separator, shape),
) {
notes.forEachIndexed { index, note ->
if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.separator))
SelectionListItem(value = note.id, contentDescription = note.title) {
SelectionListRowContent(note, editing) { onOpen(note) }
}
}
}
}
@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
log,
modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
style = Typography.sm.copy(color = EsuissTheme.colors.labelSecondary),
)
Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
}
}
@Composable
fun SelectionListHeader(title: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, variant = TextVariant.H1)
SelectionListTrigger()
}
}
fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"
@Composable
fun SelectionListMail() {
val colors = EsuissTheme.colors
var notes by remember { mutableStateOf(selectionListInitialNotes) }
var log by rememberSaveable { mutableStateOf(SelectionListHint) }
var editing by rememberSaveable { mutableStateOf(false) }
var selected by rememberSaveable { mutableStateOf(emptyList<String>()) }
val enabled = selected.isNotEmpty()
SelectionList(
editing = editing,
onEditingChange = { editing = it },
value = selected,
onValueChange = { selected = it },
modifier = Modifier.fillMaxWidth(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SelectionListHeader("Inbox")
SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
SelectionListFooter(log) {
notes = selectionListInitialNotes
log = SelectionListHint
}
}
SelectionListBar {
SelectionListBarIcon(PhosphorIcons.Regular.Archive, "Archive", enabled) { log = "Archived ${selected.joinToString(", ")}" }
SelectionListBarIcon(PhosphorIcons.Regular.FolderSimple, "Move", enabled) { log = "Moved ${selected.joinToString(", ")}" }
SelectionListCount()
SelectionListBarIcon(
PhosphorIcons.Regular.Trash,
"Delete",
enabled,
tint = if (enabled) colors.danger else colors.labelQuaternary,
) {
val ids = selected
notes = notes.filterNot { it.id in ids }
log = selectionListDeleted(ids.size)
selected = emptyList()
}
}
}
}API Reference
SelectionList
The root that holds the editing state and selection.
ViewProps- About
- Controls editing mode.
- Type
boolean- Default
- —
- About
- Whether editing starts on when uncontrolled.
- Type
boolean- Default
- false
- About
- Called when editing turns on or off.
- Type
(editing: boolean) => void- Default
- —
- About
- Selected item values when controlled.
- Type
string[]- Default
- —
- About
- Initially selected values when uncontrolled.
- Type
string[]- Default
- EMPTY
- About
- Called with the new selection.
- Type
(value: string[]) => void- Default
- —
SelectionListTrigger
A plain button that toggles editing.
React.ComponentProps<typeof Button>SelectionListItem
A row that toggles its value while editing.
React.ComponentProps<typeof Pressable>- About
- The value this row adds to the selection.
- Type
string- Default
- required
- Type
React.ReactNode- Default
- —
SelectionListBar
A floating toolbar shown while editing.
ViewProps- About
- Name of a portal host to render into.
- Type
string- Default
- —
SelectionListCount
Text showing the number of selected items.
React.ComponentProps<typeof Text>Accessibility
- While editing, rows have the
checkboxrole and expose theircheckedstate; otherwise the row wrapper is not focusable and its children are. - The trigger exposes a
selectedstate while editing. - The bar has the
toolbarrole. SelectionListCountis a polite live region, readingSelect itemsorn selectedby default.
Notes
- Selected rows use
bg-item-selected; checked circles usebg-accentwith anon-accentcheck. - The bar uses
bg-surface-raised, orbg-surface-secondaryin dark mode, and is up tomax-w-mdwide. - Animations respect the system reduce motion setting.