# Input Accessory Bar

A toolbar above the keyboard with previous, next and done buttons for moving through form fields.

Input Accessory Bar is an open-source component from suiss/ui for 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/components/input-accessory-bar

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-input-accessory-bar--default.light.webp)

```tsx
import { View } from "react-native"
import { InputAccessoryBar, InputAccessoryBarInput } from "@/components/ui/input-accessory-bar"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"

function Field({ label, autoFocus }: { label: string; autoFocus?: boolean }) {
  return (
    <View className="gap-1.5">
      <Text variant="small">{label}</Text>
      <InputAccessoryBarInput placeholder={label} autoFocus={autoFocus} />
    </View>
  )
}

function Form({ autoFocus = false }: { autoFocus?: boolean }) {
  return (
    <KeyboardScrollView className="-m-4" contentContainerClassName="gap-4 p-4">
      <Field label="Name" autoFocus={autoFocus} />
      <Field label="Email" />
      <Field label="Phone" />
      <Field label="City" />
    </KeyboardScrollView>
  )
}

export function InputAccessoryBarExample() {
  return (
    <InputAccessoryBar>
      <Form />
    </InputAccessoryBar>
  )
}
```

```swift
import EsuissUI
import SwiftUI

enum InputAccessoryBarField: Hashable {
  case name, email, phone, city, subject, message
}

struct InputAccessoryBarRow: View {
  let label: LocalizedStringKey
  let field: InputAccessoryBarField
  let focus: FocusState<InputAccessoryBarField?>.Binding

  @State private var text = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Text(label)
        .textVariant(.small)
        .accessibilityHidden(true)
      TextField(label, text: $text)
        .textFieldStyle(.input)
        .focused(focus, equals: field)
        .submitLabel(.next)
    }
  }
}

struct InputAccessoryBarForm: View {
  var autoFocus = false
  var previousLabel: LocalizedStringKey = "Previous"
  var nextLabel: LocalizedStringKey = "Next"
  var doneLabel: LocalizedStringKey = "Done"

  @FocusState private var focus: InputAccessoryBarField?

  var body: some View {
    KeyboardScrollView {
      VStack(alignment: .leading, spacing: 16) {
        InputAccessoryBarRow(label: "Name", field: .name, focus: $focus)
        InputAccessoryBarRow(label: "Email", field: .email, focus: $focus)
        InputAccessoryBarRow(label: "Phone", field: .phone, focus: $focus)
        InputAccessoryBarRow(label: "City", field: .city, focus: $focus)
      }
      .padding(16)
    }
    .inputAccessoryBar(
      focus: $focus,
      order: [.name, .email, .phone, .city],
      previousLabel: previousLabel,
      nextLabel: nextLabel,
      doneLabel: doneLabel
    )
    .onAppear {
      if autoFocus { focus = .name }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.InputAccessoryBar
import com.esuiss.ui.components.InputAccessoryState
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.components.inputAccessory
import com.esuiss.ui.components.rememberInputAccessoryState

@Composable
fun InputAccessoryBarExample() {
    InputAccessoryBarForm(autoFocus = false)
}

@Composable
fun InputAccessoryBarField(
    label: String,
    state: InputAccessoryState,
    index: Int,
    autoFocus: Boolean = false,
) {
    var value by rememberSaveable(label) { mutableStateOf("") }
    val requester = remember { FocusRequester() }
    if (autoFocus) {
        LaunchedEffect(Unit) { requester.requestFocus() }
    }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
        Text(label, variant = TextVariant.Small)
        Input(
            value,
            { value = it },
            Modifier.focusRequester(requester).inputAccessory(state, index),
            placeholder = label,
        )
    }
}

@Composable
fun InputAccessoryBarForm(
    autoFocus: Boolean,
    previousLabel: String = "Previous",
    nextLabel: String = "Next",
    doneLabel: String = "Done",
) {
    val state = rememberInputAccessoryState()
    Screen {
        KeyboardScrollView(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            InputAccessoryBarField("Name", state, 0, autoFocus)
            InputAccessoryBarField("Email", state, 1)
            InputAccessoryBarField("Phone", state, 2)
            InputAccessoryBarField("City", state, 3)
        }
        InputAccessoryBar(state, previousLabel = previousLabel, nextLabel = nextLabel, doneLabel = doneLabel)
    }
}
```

`InputAccessoryBar` connects a set of inputs and shows a bar above the keyboard while one of them is focused. The up and down arrows move focus to the previous or next input, and the done button dismisses the keyboard.

On iOS the bar is a native `InputAccessoryView`, attached to the keyboard. On Android it is rendered through a portal and positioned just above the keyboard while an input is focused.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/input-accessory-bar
```

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 input-accessory-bar
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add input-accessory-bar --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add input-accessory-bar --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), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`.

3. Copy the source into `components/ui/input-accessory-bar.tsx`:

```tsx title="uniwind/components/ui/input-accessory-bar.tsx"
import { cn } from "cn"
import { CaretDownIcon, CaretUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  InputAccessoryView,
  Keyboard,
  Platform,
  type TextInput,
  type TextInputProps,
  View,
} from "react-native"
import { useComposedRefs } from "@/components/primitives/hooks"
import {
  setKeyboardAccessoryHeight,
  useKeyboard,
} from "@/components/primitives/keyboard"
import { Portal } from "@/components/primitives/portal"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollViewContext } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"

type InputRef = React.RefObject<React.ComponentRef<typeof TextInput> | null>

type InputEntry = { ref: InputRef; order: number | undefined }

type InputPosition = { x: number; y: number }

type FocusEvent = Parameters<NonNullable<TextInputProps["onFocus"]>>[0]

type BlurEvent = Parameters<NonNullable<TextInputProps["onBlur"]>>[0]

type SubmitEvent = Parameters<NonNullable<TextInputProps["onSubmitEditing"]>>[0]

type InputAccessoryBarContextValue = {
  nativeID: string
  inputs: readonly InputRef[]
  focused: InputRef | null
  register: (input: InputRef, order?: number) => () => void
  setFocused: React.Dispatch<React.SetStateAction<InputRef | null>>
  measure: () => void
  focusAt: (index: number) => void
  done: () => void
}

const InputAccessoryBarContext =
  React.createContext<InputAccessoryBarContextValue | null>(null)

function useInputAccessoryBarContext() {
  const context = React.useContext(InputAccessoryBarContext)
  if (!context) {
    throw new Error(
      "useInputAccessoryBarInput must be used within an <InputAccessoryBar />"
    )
  }
  return context
}

const BAR_HEIGHT = 44

function sortInputs(
  entries: readonly InputEntry[],
  positions: ReadonlyMap<InputRef, InputPosition>
) {
  return entries
    .map((entry, index) => ({ entry, index }))
    .sort((a, b) => {
      const aOrder = a.entry.order
      const bOrder = b.entry.order
      if (aOrder !== undefined && bOrder !== undefined && aOrder !== bOrder) {
        return aOrder - bOrder
      }
      const aPosition = positions.get(a.entry.ref)
      const bPosition = positions.get(b.entry.ref)
      if (aPosition && bPosition) {
        if (Math.abs(aPosition.y - bPosition.y) > 1) {
          return aPosition.y - bPosition.y
        }
        if (aPosition.x !== bPosition.x) {
          return I18nManager.isRTL
            ? bPosition.x - aPosition.x
            : aPosition.x - bPosition.x
        }
      }
      return a.index - b.index
    })
    .map(({ entry }) => entry.ref)
}

function InputAccessoryBarView({
  previousLabel,
  nextLabel,
  doneLabel,
  className,
}: {
  previousLabel: string
  nextLabel: string
  doneLabel: React.ReactNode
  className?: string
}) {
  const { inputs, focused, focusAt, done } = useInputAccessoryBarContext()
  const index = focused ? inputs.indexOf(focused) : -1
  return (
    <View
      className={cn(
        "h-11 flex-row items-center border-t border-border bg-card px-1 dark:bg-muted",
        className
      )}
    >
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={previousLabel}
        disabled={index <= 0}
        onPress={() => focusAt(index - 1)}
      >
        <Icon as={CaretUpIcon} className="size-5" />
      </Button>
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={nextLabel}
        disabled={index < 0 || index >= inputs.length - 1}
        onPress={() => focusAt(index + 1)}
      >
        <Icon as={CaretDownIcon} className="size-5" />
      </Button>
      <View className="flex-1" />
      <Button variant="plain" onPress={done}>
        {typeof doneLabel === "string" || typeof doneLabel === "number" ? (
          <Text className="font-semibold">{doneLabel}</Text>
        ) : (
          doneLabel
        )}
      </Button>
    </View>
  )
}

function InputAccessoryBar({
  previousLabel = "Previous",
  nextLabel = "Next",
  doneLabel = "Done",
  onDone,
  portalHost,
  className,
  children,
}: {
  previousLabel?: string
  nextLabel?: string
  doneLabel?: React.ReactNode
  onDone?: () => void
  portalHost?: string
  className?: string
  children?: React.ReactNode
}) {
  const nativeID = `input-accessory-bar-${React.useId()}`
  const [entries, setEntries] = React.useState<readonly InputEntry[]>([])
  const [positions, setPositions] = React.useState<
    ReadonlyMap<InputRef, InputPosition>
  >(() => new Map())
  const entriesRef = React.useRef(entries)
  entriesRef.current = entries
  const inputs = React.useMemo(
    () => sortInputs(entries, positions),
    [entries, positions]
  )
  const [focused, setFocused] = React.useState<InputRef | null>(null)
  const keyboard = useKeyboard()
  const visible =
    Platform.OS !== "ios" && focused !== null && keyboard.height > 0

  const register = React.useCallback((input: InputRef, order?: number) => {
    setEntries((current) => [...current, { ref: input, order }])
    return () => {
      setEntries((current) => current.filter((entry) => entry.ref !== input))
      setFocused((current) => (current === input ? null : current))
    }
  }, [])

  const measure = React.useCallback(() => {
    const list = entriesRef.current
    Promise.all(
      list.map(
        (entry) =>
          new Promise<[InputRef, InputPosition | null]>((resolve) => {
            const node = entry.ref.current
            if (!node) {
              resolve([entry.ref, null])
              return
            }
            node.measureInWindow((x, y) => resolve([entry.ref, { x, y }]))
          })
      )
    ).then((results) => {
      const next = new Map<InputRef, InputPosition>()
      for (const [ref, position] of results) {
        if (position) next.set(ref, position)
      }
      setPositions(next)
    })
  }, [])

  const focusAt = React.useCallback(
    (index: number) => {
      inputs[index]?.current?.focus()
    },
    [inputs]
  )

  const done = React.useCallback(() => {
    Keyboard.dismiss()
    onDone?.()
  }, [onDone])

  React.useEffect(() => {
    if (Platform.OS === "ios") return
    setKeyboardAccessoryHeight(visible ? BAR_HEIGHT : 0)
  }, [visible])

  React.useEffect(
    () => () => {
      if (Platform.OS !== "ios") setKeyboardAccessoryHeight(0)
    },
    []
  )

  const context = React.useMemo(
    () => ({
      nativeID,
      inputs,
      focused,
      register,
      setFocused,
      measure,
      focusAt,
      done,
    }),
    [nativeID, inputs, focused, register, measure, focusAt, done]
  )

  const bar = (
    <InputAccessoryBarView
      previousLabel={previousLabel}
      nextLabel={nextLabel}
      doneLabel={doneLabel}
      className={className}
    />
  )

  return (
    <InputAccessoryBarContext.Provider value={context}>
      {children}
      {Platform.OS === "ios" ? (
        <InputAccessoryView nativeID={nativeID}>{bar}</InputAccessoryView>
      ) : (
        <Portal name={nativeID} hostName={portalHost}>
          {visible ? (
            <InputAccessoryBarContext.Provider value={context}>
              <View
                className="absolute inset-x-0 z-50"
                style={{ bottom: keyboard.height - keyboard.accessoryHeight }}
              >
                {bar}
              </View>
            </InputAccessoryBarContext.Provider>
          ) : null}
        </Portal>
      )}
    </InputAccessoryBarContext.Provider>
  )
}

function useInputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
}: {
  ref?: React.Ref<React.ComponentRef<typeof TextInput>>
  order?: number
  onFocus?: TextInputProps["onFocus"]
  onBlur?: TextInputProps["onBlur"]
  onSubmitEditing?: TextInputProps["onSubmitEditing"]
} = {}) {
  const { nativeID, inputs, register, setFocused, measure, focusAt, done } =
    useInputAccessoryBarContext()
  const scrollView = React.useContext(KeyboardScrollViewContext)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const composedRef = useComposedRefs(ref, inputRef)

  React.useEffect(() => register(inputRef, order), [register, order])

  const index = inputs.indexOf(inputRef)
  const last = index === inputs.length - 1

  return {
    ref: composedRef,
    inputAccessoryViewID: nativeID,
    returnKeyType: last ? "done" : "next",
    submitBehavior: last ? "blurAndSubmit" : "submit",
    onFocus: (event: FocusEvent) => {
      setFocused(inputRef)
      measure()
      if (scrollView) setTimeout(scrollView.scrollToFocused, 50)
      onFocus?.(event)
    },
    onBlur: (event: BlurEvent) => {
      setFocused((current) => (current === inputRef ? null : current))
      onBlur?.(event)
    },
    onSubmitEditing: (event: SubmitEvent) => {
      if (last) done()
      else focusAt(index + 1)
      onSubmitEditing?.(event)
    },
  } satisfies TextInputProps & {
    ref: React.Ref<React.ComponentRef<typeof TextInput>>
  }
}

function InputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
  ...props
}: React.ComponentProps<typeof Input> & { order?: number }) {
  const accessory = useInputAccessoryBarInput({
    ref,
    order,
    onFocus,
    onBlur,
    onSubmitEditing,
  })
  return (
    <Input
      returnKeyType={accessory.returnKeyType}
      submitBehavior={accessory.submitBehavior}
      {...props}
      ref={accessory.ref}
      inputAccessoryViewID={accessory.inputAccessoryViewID}
      onFocus={accessory.onFocus}
      onBlur={accessory.onBlur}
      onSubmitEditing={accessory.onSubmitEditing}
    />
  )
}

export { InputAccessoryBar, InputAccessoryBarInput, useInputAccessoryBarInput }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`.

3. Copy the source into `components/ui/input-accessory-bar.tsx`:

```tsx title="nativewind/components/ui/input-accessory-bar.tsx"
import { cn } from "cn"
import { CaretDownIcon, CaretUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  InputAccessoryView,
  Keyboard,
  Platform,
  type TextInput,
  type TextInputProps,
  View,
} from "react-native"
import { useComposedRefs } from "@/components/primitives/hooks"
import {
  setKeyboardAccessoryHeight,
  useKeyboard,
} from "@/components/primitives/keyboard"
import { Portal } from "@/components/primitives/portal"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollViewContext } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"

type InputRef = React.RefObject<React.ComponentRef<typeof TextInput> | null>

type InputEntry = { ref: InputRef; order: number | undefined }

type InputPosition = { x: number; y: number }

type FocusEvent = Parameters<NonNullable<TextInputProps["onFocus"]>>[0]

type BlurEvent = Parameters<NonNullable<TextInputProps["onBlur"]>>[0]

type SubmitEvent = Parameters<NonNullable<TextInputProps["onSubmitEditing"]>>[0]

type InputAccessoryBarContextValue = {
  nativeID: string
  inputs: readonly InputRef[]
  focused: InputRef | null
  register: (input: InputRef, order?: number) => () => void
  setFocused: React.Dispatch<React.SetStateAction<InputRef | null>>
  measure: () => void
  focusAt: (index: number) => void
  done: () => void
}

const InputAccessoryBarContext =
  React.createContext<InputAccessoryBarContextValue | null>(null)

function useInputAccessoryBarContext() {
  const context = React.useContext(InputAccessoryBarContext)
  if (!context) {
    throw new Error(
      "useInputAccessoryBarInput must be used within an <InputAccessoryBar />"
    )
  }
  return context
}

const BAR_HEIGHT = 44

function sortInputs(
  entries: readonly InputEntry[],
  positions: ReadonlyMap<InputRef, InputPosition>
) {
  return entries
    .map((entry, index) => ({ entry, index }))
    .sort((a, b) => {
      const aOrder = a.entry.order
      const bOrder = b.entry.order
      if (aOrder !== undefined && bOrder !== undefined && aOrder !== bOrder) {
        return aOrder - bOrder
      }
      const aPosition = positions.get(a.entry.ref)
      const bPosition = positions.get(b.entry.ref)
      if (aPosition && bPosition) {
        if (Math.abs(aPosition.y - bPosition.y) > 1) {
          return aPosition.y - bPosition.y
        }
        if (aPosition.x !== bPosition.x) {
          return I18nManager.isRTL
            ? bPosition.x - aPosition.x
            : aPosition.x - bPosition.x
        }
      }
      return a.index - b.index
    })
    .map(({ entry }) => entry.ref)
}

function InputAccessoryBarView({
  previousLabel,
  nextLabel,
  doneLabel,
  className,
}: {
  previousLabel: string
  nextLabel: string
  doneLabel: React.ReactNode
  className?: string
}) {
  const { inputs, focused, focusAt, done } = useInputAccessoryBarContext()
  const index = focused ? inputs.indexOf(focused) : -1
  return (
    <View
      className={cn(
        "h-11 flex-row items-center border-t border-border bg-card px-1 dark:bg-muted",
        className
      )}
    >
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={previousLabel}
        disabled={index <= 0}
        onPress={() => focusAt(index - 1)}
      >
        <Icon as={CaretUpIcon} className="size-5" />
      </Button>
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={nextLabel}
        disabled={index < 0 || index >= inputs.length - 1}
        onPress={() => focusAt(index + 1)}
      >
        <Icon as={CaretDownIcon} className="size-5" />
      </Button>
      <View className="flex-1" />
      <Button variant="plain" onPress={done}>
        {typeof doneLabel === "string" || typeof doneLabel === "number" ? (
          <Text className="font-semibold">{doneLabel}</Text>
        ) : (
          doneLabel
        )}
      </Button>
    </View>
  )
}

function InputAccessoryBar({
  previousLabel = "Previous",
  nextLabel = "Next",
  doneLabel = "Done",
  onDone,
  portalHost,
  className,
  children,
}: {
  previousLabel?: string
  nextLabel?: string
  doneLabel?: React.ReactNode
  onDone?: () => void
  portalHost?: string
  className?: string
  children?: React.ReactNode
}) {
  const nativeID = `input-accessory-bar-${React.useId()}`
  const [entries, setEntries] = React.useState<readonly InputEntry[]>([])
  const [positions, setPositions] = React.useState<
    ReadonlyMap<InputRef, InputPosition>
  >(() => new Map())
  const entriesRef = React.useRef(entries)
  entriesRef.current = entries
  const inputs = React.useMemo(
    () => sortInputs(entries, positions),
    [entries, positions]
  )
  const [focused, setFocused] = React.useState<InputRef | null>(null)
  const keyboard = useKeyboard()
  const visible =
    Platform.OS !== "ios" && focused !== null && keyboard.height > 0

  const register = React.useCallback((input: InputRef, order?: number) => {
    setEntries((current) => [...current, { ref: input, order }])
    return () => {
      setEntries((current) => current.filter((entry) => entry.ref !== input))
      setFocused((current) => (current === input ? null : current))
    }
  }, [])

  const measure = React.useCallback(() => {
    const list = entriesRef.current
    Promise.all(
      list.map(
        (entry) =>
          new Promise<[InputRef, InputPosition | null]>((resolve) => {
            const node = entry.ref.current
            if (!node) {
              resolve([entry.ref, null])
              return
            }
            node.measureInWindow((x, y) => resolve([entry.ref, { x, y }]))
          })
      )
    ).then((results) => {
      const next = new Map<InputRef, InputPosition>()
      for (const [ref, position] of results) {
        if (position) next.set(ref, position)
      }
      setPositions(next)
    })
  }, [])

  const focusAt = React.useCallback(
    (index: number) => {
      inputs[index]?.current?.focus()
    },
    [inputs]
  )

  const done = React.useCallback(() => {
    Keyboard.dismiss()
    onDone?.()
  }, [onDone])

  React.useEffect(() => {
    if (Platform.OS === "ios") return
    setKeyboardAccessoryHeight(visible ? BAR_HEIGHT : 0)
  }, [visible])

  React.useEffect(
    () => () => {
      if (Platform.OS !== "ios") setKeyboardAccessoryHeight(0)
    },
    []
  )

  const context = React.useMemo(
    () => ({
      nativeID,
      inputs,
      focused,
      register,
      setFocused,
      measure,
      focusAt,
      done,
    }),
    [nativeID, inputs, focused, register, measure, focusAt, done]
  )

  const bar = (
    <InputAccessoryBarView
      previousLabel={previousLabel}
      nextLabel={nextLabel}
      doneLabel={doneLabel}
      className={className}
    />
  )

  return (
    <InputAccessoryBarContext.Provider value={context}>
      {children}
      {Platform.OS === "ios" ? (
        <InputAccessoryView nativeID={nativeID}>{bar}</InputAccessoryView>
      ) : (
        <Portal name={nativeID} hostName={portalHost}>
          {visible ? (
            <InputAccessoryBarContext.Provider value={context}>
              <View
                className="absolute inset-x-0 z-50"
                style={{ bottom: keyboard.height - keyboard.accessoryHeight }}
              >
                {bar}
              </View>
            </InputAccessoryBarContext.Provider>
          ) : null}
        </Portal>
      )}
    </InputAccessoryBarContext.Provider>
  )
}

function useInputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
}: {
  ref?: React.Ref<React.ComponentRef<typeof TextInput>>
  order?: number
  onFocus?: TextInputProps["onFocus"]
  onBlur?: TextInputProps["onBlur"]
  onSubmitEditing?: TextInputProps["onSubmitEditing"]
} = {}) {
  const { nativeID, inputs, register, setFocused, measure, focusAt, done } =
    useInputAccessoryBarContext()
  const scrollView = React.useContext(KeyboardScrollViewContext)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const composedRef = useComposedRefs(ref, inputRef)

  React.useEffect(() => register(inputRef, order), [register, order])

  const index = inputs.indexOf(inputRef)
  const last = index === inputs.length - 1

  return {
    ref: composedRef,
    inputAccessoryViewID: nativeID,
    returnKeyType: last ? "done" : "next",
    submitBehavior: last ? "blurAndSubmit" : "submit",
    onFocus: (event: FocusEvent) => {
      setFocused(inputRef)
      measure()
      if (scrollView) setTimeout(scrollView.scrollToFocused, 50)
      onFocus?.(event)
    },
    onBlur: (event: BlurEvent) => {
      setFocused((current) => (current === inputRef ? null : current))
      onBlur?.(event)
    },
    onSubmitEditing: (event: SubmitEvent) => {
      if (last) done()
      else focusAt(index + 1)
      onSubmitEditing?.(event)
    },
  } satisfies TextInputProps & {
    ref: React.Ref<React.ComponentRef<typeof TextInput>>
  }
}

function InputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
  ...props
}: React.ComponentProps<typeof Input> & { order?: number }) {
  const accessory = useInputAccessoryBarInput({
    ref,
    order,
    onFocus,
    onBlur,
    onSubmitEditing,
  })
  return (
    <Input
      returnKeyType={accessory.returnKeyType}
      submitBehavior={accessory.submitBehavior}
      {...props}
      ref={accessory.ref}
      inputAccessoryViewID={accessory.inputAccessoryViewID}
      onFocus={accessory.onFocus}
      onBlur={accessory.onBlur}
      onSubmitEditing={accessory.onSubmitEditing}
    />
  )
}

export { InputAccessoryBar, InputAccessoryBarInput, useInputAccessoryBarInput }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`.

3. Copy the source into `components/ui/input-accessory-bar.tsx`:

```tsx title="stylesheet/components/ui/input-accessory-bar.tsx"
import { CaretDownIcon, CaretUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  InputAccessoryView,
  Keyboard,
  Platform,
  type StyleProp,
  StyleSheet,
  type TextInput,
  type TextInputProps,
  View,
  type ViewStyle,
} from "react-native"
import { useComposedRefs } from "@/components/primitives/hooks"
import {
  setKeyboardAccessoryHeight,
  useKeyboard,
} from "@/components/primitives/keyboard"
import { Portal } from "@/components/primitives/portal"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { KeyboardScrollViewContext } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"
import { useTheme } from "@/components/ui/theme"

type InputRef = React.RefObject<React.ComponentRef<typeof TextInput> | null>

type InputEntry = { ref: InputRef; order: number | undefined }

type InputPosition = { x: number; y: number }

type FocusEvent = Parameters<NonNullable<TextInputProps["onFocus"]>>[0]

type BlurEvent = Parameters<NonNullable<TextInputProps["onBlur"]>>[0]

type SubmitEvent = Parameters<NonNullable<TextInputProps["onSubmitEditing"]>>[0]

type InputAccessoryBarContextValue = {
  nativeID: string
  inputs: readonly InputRef[]
  focused: InputRef | null
  register: (input: InputRef, order?: number) => () => void
  setFocused: React.Dispatch<React.SetStateAction<InputRef | null>>
  measure: () => void
  focusAt: (index: number) => void
  done: () => void
}

const InputAccessoryBarContext =
  React.createContext<InputAccessoryBarContextValue | null>(null)

function useInputAccessoryBarContext() {
  const context = React.useContext(InputAccessoryBarContext)
  if (!context) {
    throw new Error(
      "useInputAccessoryBarInput must be used within an <InputAccessoryBar />"
    )
  }
  return context
}

const BAR_HEIGHT = 44

function sortInputs(
  entries: readonly InputEntry[],
  positions: ReadonlyMap<InputRef, InputPosition>
) {
  return entries
    .map((entry, index) => ({ entry, index }))
    .sort((a, b) => {
      const aOrder = a.entry.order
      const bOrder = b.entry.order
      if (aOrder !== undefined && bOrder !== undefined && aOrder !== bOrder) {
        return aOrder - bOrder
      }
      const aPosition = positions.get(a.entry.ref)
      const bPosition = positions.get(b.entry.ref)
      if (aPosition && bPosition) {
        if (Math.abs(aPosition.y - bPosition.y) > 1) {
          return aPosition.y - bPosition.y
        }
        if (aPosition.x !== bPosition.x) {
          return I18nManager.isRTL
            ? bPosition.x - aPosition.x
            : aPosition.x - bPosition.x
        }
      }
      return a.index - b.index
    })
    .map(({ entry }) => entry.ref)
}

const styles = StyleSheet.create({
  bar: {
    height: BAR_HEIGHT,
    flexDirection: "row",
    alignItems: "center",
    borderTopWidth: 1,
    paddingHorizontal: 4,
  },
  spacer: { flex: 1 },
  done: { fontWeight: "600" },
  overlay: { position: "absolute", left: 0, right: 0, zIndex: 50 },
})

function InputAccessoryBarView({
  previousLabel,
  nextLabel,
  doneLabel,
  style,
}: {
  previousLabel: string
  nextLabel: string
  doneLabel: React.ReactNode
  style?: StyleProp<ViewStyle>
}) {
  const { colors, isDark } = useTheme()
  const { inputs, focused, focusAt, done } = useInputAccessoryBarContext()
  const index = focused ? inputs.indexOf(focused) : -1
  return (
    <View
      style={[
        styles.bar,
        {
          borderColor: colors.border,
          backgroundColor: isDark ? colors.muted : colors.card,
        },
        style,
      ]}
    >
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={previousLabel}
        disabled={index <= 0}
        onPress={() => focusAt(index - 1)}
      >
        <Icon as={CaretUpIcon} size={20} />
      </Button>
      <Button
        variant="ghost"
        size="icon"
        accessibilityLabel={nextLabel}
        disabled={index < 0 || index >= inputs.length - 1}
        onPress={() => focusAt(index + 1)}
      >
        <Icon as={CaretDownIcon} size={20} />
      </Button>
      <View style={styles.spacer} />
      <Button variant="plain" onPress={done}>
        {typeof doneLabel === "string" || typeof doneLabel === "number" ? (
          <Text style={styles.done}>{doneLabel}</Text>
        ) : (
          doneLabel
        )}
      </Button>
    </View>
  )
}

function InputAccessoryBar({
  previousLabel = "Previous",
  nextLabel = "Next",
  doneLabel = "Done",
  onDone,
  portalHost,
  style,
  children,
}: {
  previousLabel?: string
  nextLabel?: string
  doneLabel?: React.ReactNode
  onDone?: () => void
  portalHost?: string
  style?: StyleProp<ViewStyle>
  children?: React.ReactNode
}) {
  const nativeID = `input-accessory-bar-${React.useId()}`
  const [entries, setEntries] = React.useState<readonly InputEntry[]>([])
  const [positions, setPositions] = React.useState<
    ReadonlyMap<InputRef, InputPosition>
  >(() => new Map())
  const entriesRef = React.useRef(entries)
  entriesRef.current = entries
  const inputs = React.useMemo(
    () => sortInputs(entries, positions),
    [entries, positions]
  )
  const [focused, setFocused] = React.useState<InputRef | null>(null)
  const keyboard = useKeyboard()
  const visible =
    Platform.OS !== "ios" && focused !== null && keyboard.height > 0

  const register = React.useCallback((input: InputRef, order?: number) => {
    setEntries((current) => [...current, { ref: input, order }])
    return () => {
      setEntries((current) => current.filter((entry) => entry.ref !== input))
      setFocused((current) => (current === input ? null : current))
    }
  }, [])

  const measure = React.useCallback(() => {
    const list = entriesRef.current
    Promise.all(
      list.map(
        (entry) =>
          new Promise<[InputRef, InputPosition | null]>((resolve) => {
            const node = entry.ref.current
            if (!node) {
              resolve([entry.ref, null])
              return
            }
            node.measureInWindow((x, y) => resolve([entry.ref, { x, y }]))
          })
      )
    ).then((results) => {
      const next = new Map<InputRef, InputPosition>()
      for (const [ref, position] of results) {
        if (position) next.set(ref, position)
      }
      setPositions(next)
    })
  }, [])

  const focusAt = React.useCallback(
    (index: number) => {
      inputs[index]?.current?.focus()
    },
    [inputs]
  )

  const done = React.useCallback(() => {
    Keyboard.dismiss()
    onDone?.()
  }, [onDone])

  React.useEffect(() => {
    if (Platform.OS === "ios") return
    setKeyboardAccessoryHeight(visible ? BAR_HEIGHT : 0)
  }, [visible])

  React.useEffect(
    () => () => {
      if (Platform.OS !== "ios") setKeyboardAccessoryHeight(0)
    },
    []
  )

  const context = React.useMemo(
    () => ({
      nativeID,
      inputs,
      focused,
      register,
      setFocused,
      measure,
      focusAt,
      done,
    }),
    [nativeID, inputs, focused, register, measure, focusAt, done]
  )

  const bar = (
    <InputAccessoryBarView
      previousLabel={previousLabel}
      nextLabel={nextLabel}
      doneLabel={doneLabel}
      style={style}
    />
  )

  return (
    <InputAccessoryBarContext.Provider value={context}>
      {children}
      {Platform.OS === "ios" ? (
        <InputAccessoryView nativeID={nativeID}>{bar}</InputAccessoryView>
      ) : (
        <Portal name={nativeID} hostName={portalHost}>
          {visible ? (
            <InputAccessoryBarContext.Provider value={context}>
              <View
                style={[
                  styles.overlay,
                  { bottom: keyboard.height - keyboard.accessoryHeight },
                ]}
              >
                {bar}
              </View>
            </InputAccessoryBarContext.Provider>
          ) : null}
        </Portal>
      )}
    </InputAccessoryBarContext.Provider>
  )
}

function useInputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
}: {
  ref?: React.Ref<React.ComponentRef<typeof TextInput>>
  order?: number
  onFocus?: TextInputProps["onFocus"]
  onBlur?: TextInputProps["onBlur"]
  onSubmitEditing?: TextInputProps["onSubmitEditing"]
} = {}) {
  const { nativeID, inputs, register, setFocused, measure, focusAt, done } =
    useInputAccessoryBarContext()
  const scrollView = React.useContext(KeyboardScrollViewContext)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const composedRef = useComposedRefs(ref, inputRef)

  React.useEffect(() => register(inputRef, order), [register, order])

  const index = inputs.indexOf(inputRef)
  const last = index === inputs.length - 1

  return {
    ref: composedRef,
    inputAccessoryViewID: nativeID,
    returnKeyType: last ? "done" : "next",
    submitBehavior: last ? "blurAndSubmit" : "submit",
    onFocus: (event: FocusEvent) => {
      setFocused(inputRef)
      measure()
      if (scrollView) setTimeout(scrollView.scrollToFocused, 50)
      onFocus?.(event)
    },
    onBlur: (event: BlurEvent) => {
      setFocused((current) => (current === inputRef ? null : current))
      onBlur?.(event)
    },
    onSubmitEditing: (event: SubmitEvent) => {
      if (last) done()
      else focusAt(index + 1)
      onSubmitEditing?.(event)
    },
  } satisfies TextInputProps & {
    ref: React.Ref<React.ComponentRef<typeof TextInput>>
  }
}

function InputAccessoryBarInput({
  ref,
  order,
  onFocus,
  onBlur,
  onSubmitEditing,
  ...props
}: React.ComponentProps<typeof Input> & { order?: number }) {
  const accessory = useInputAccessoryBarInput({
    ref,
    order,
    onFocus,
    onBlur,
    onSubmitEditing,
  })
  return (
    <Input
      returnKeyType={accessory.returnKeyType}
      submitBehavior={accessory.submitBehavior}
      {...props}
      ref={accessory.ref}
      inputAccessoryViewID={accessory.inputAccessoryViewID}
      onFocus={accessory.onFocus}
      onBlur={accessory.onBlur}
      onSubmitEditing={accessory.onSubmitEditing}
    />
  )
}

export { InputAccessoryBar, InputAccessoryBarInput, useInputAccessoryBarInput }
```

4. 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 title="swift/Components/InputAccessoryBar.swift"
import PhosphorSwift
import SwiftUI

struct FocusChain<Value: Hashable> {
  let order: [Value]

  func next(after value: Value?) -> Value? {
    guard let value, let index = order.firstIndex(of: value) else { return order.first }
    let target = order.index(after: index)
    return target < order.endIndex ? order[target] : nil
  }

  func previous(before value: Value?) -> Value? {
    guard let value, let index = order.firstIndex(of: value) else { return nil }
    return index > order.startIndex ? order[order.index(before: index)] : nil
  }
}

public struct InputAccessoryBar: View {
  let previousLabel: LocalizedStringKey
  let nextLabel: LocalizedStringKey
  let doneLabel: LocalizedStringKey
  let onPrevious: (() -> Void)?
  let onNext: (() -> Void)?
  let onDone: () -> Void

  public init(
    previousLabel: LocalizedStringKey = "Previous",
    nextLabel: LocalizedStringKey = "Next",
    doneLabel: LocalizedStringKey = "Done",
    onPrevious: (() -> Void)? = nil,
    onNext: (() -> Void)? = nil,
    onDone: @escaping () -> Void
  ) {
    self.previousLabel = previousLabel
    self.nextLabel = nextLabel
    self.doneLabel = doneLabel
    self.onPrevious = onPrevious
    self.onNext = onNext
    self.onDone = onDone
  }

  public var body: some View {
    HStack(spacing: 0) {
      arrow(Ph.caretUp.bold, label: previousLabel, action: onPrevious)
      arrow(Ph.caretDown.bold, label: nextLabel, action: onNext)
      Spacer(minLength: 0)
      Button(action: onDone) {
        Text(doneLabel)
          .scaledFont(.base, weight: .semibold)
          .foregroundStyle(EsuissColor.primary)
          .padding(.horizontal, 16)
          .frame(minHeight: 44)
          .contentShape(Rectangle())
      }
      .buttonStyle(.plain)
    }
    .padding(.horizontal, 4)
    .frame(height: 44)
    .frame(maxWidth: .infinity)
    .background(EsuissColor.muted)
    .overlay(alignment: .top) {
      EsuissColor.border.frame(height: 1)
    }
    .accessibilityElement(children: .contain)
  }

  private func arrow(_ icon: Image, label: LocalizedStringKey, action: (() -> Void)?) -> some View {
    Button {
      action?()
    } label: {
      EmptyView()
    }
    .buttonStyle(PressReader { pressed in
      icon
        .frame(width: 18, height: 18)
        .foregroundStyle(EsuissColor.primary)
        .opacity(action == nil ? 0.5 : (pressed ? 0.8 : 1))
        .frame(width: 44, height: 44)
        .contentShape(Rectangle())
    })
    .disabled(action == nil)
    .accessibilityLabel(Text(label))
  }
}

private struct InputAccessoryBarModifier<Value: Hashable>: ViewModifier {
  let focus: FocusState<Value?>.Binding
  let chain: FocusChain<Value>
  let previousLabel: LocalizedStringKey
  let nextLabel: LocalizedStringKey
  let doneLabel: LocalizedStringKey

  func body(content: Content) -> some View {
    let current = focus.wrappedValue
    let previous = chain.previous(before: current)
    let next = chain.next(after: current)
    content
      .onSubmit {
        focus.wrappedValue = chain.next(after: focus.wrappedValue)
      }
      .safeAreaInset(edge: .bottom, spacing: 0) {
        if let current, chain.order.contains(current) {
          InputAccessoryBar(
            previousLabel: previousLabel,
            nextLabel: nextLabel,
            doneLabel: doneLabel,
            onPrevious: previous.map { target in { focus.wrappedValue = target } },
            onNext: next.map { target in { focus.wrappedValue = target } },
            onDone: { focus.wrappedValue = nil }
          )
          .transition(.opacity)
        }
      }
  }
}

extension View {
  public func inputAccessoryBar<Value: Hashable>(
    focus: FocusState<Value?>.Binding,
    order: [Value],
    previousLabel: LocalizedStringKey = "Previous",
    nextLabel: LocalizedStringKey = "Next",
    doneLabel: LocalizedStringKey = "Done"
  ) -> some View {
    modifier(
      InputAccessoryBarModifier(
        focus: focus,
        chain: FocusChain(order: order),
        previousLabel: previousLabel,
        nextLabel: nextLabel,
        doneLabel: doneLabel
      )
    )
  }
}
```

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

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

5. Add the files to your app target.

**Jetpack Compose**

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

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

2. Copy the theme files every view uses:

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

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

3. Copy the source and every file it uses:

```kt title="kotlin/components/InputAccessoryBar.kt"
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretDown
import com.adamglin.phosphoricons.regular.CaretUp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

private val InputAccessoryBarHeight = 44.dp

@Stable
class InputAccessoryState {
    private val requesters = mutableStateMapOf<Int, FocusRequester>()
    internal var focused by mutableStateOf<Int?>(null)
    internal var barHeight by mutableIntStateOf(0)

    internal fun register(index: Int, requester: FocusRequester) {
        requesters[index] = requester
    }

    internal fun unregister(index: Int, requester: FocusRequester) {
        if (requesters[index] == requester) requesters.remove(index)
        if (focused == index) focused = null
    }

    internal fun onFocus(index: Int, isFocused: Boolean) {
        if (isFocused) {
            focused = index
        } else if (focused == index) {
            focused = null
        }
    }

    private val order: List<Int>
        get() = requesters.keys.sorted()

    val hasPrevious: Boolean
        get() = focused?.let { current -> order.any { it < current } } ?: false

    val hasNext: Boolean
        get() = focused?.let { current -> order.any { it > current } } ?: false

    fun previous() {
        val current = focused ?: return
        order.lastOrNull { it < current }?.let { requesters[it]?.requestFocus() }
    }

    fun next() {
        val current = focused ?: return
        order.firstOrNull { it > current }?.let { requesters[it]?.requestFocus() }
    }
}

@Composable
fun rememberInputAccessoryState(): InputAccessoryState = remember { InputAccessoryState() }

fun Modifier.inputAccessory(state: InputAccessoryState, index: Int): Modifier = composed {
    val requester = remember { FocusRequester() }
    val relocation = remember { BringIntoViewRequester() }
    val density = LocalDensity.current
    val keyboard = WindowInsets.ime.getBottom(density)
    var size by remember { mutableStateOf(IntSize.Zero) }
    val focused = state.focused == index
    DisposableEffect(state, index, requester) {
        state.register(index, requester)
        onDispose { state.unregister(index, requester) }
    }
    LaunchedEffect(focused, keyboard > 0, state.barHeight, size) {
        if (!focused || keyboard == 0 || size == IntSize.Zero) return@LaunchedEffect
        val margin = state.barHeight + with(density) { 8.dp.toPx() }
        relocation.bringIntoView(Rect(0f, 0f, size.width.toFloat(), size.height + margin))
    }
    this
        .onSizeChanged { size = it }
        .bringIntoViewRequester(relocation)
        .focusRequester(requester)
        .onFocusChanged { state.onFocus(index, it.isFocused || it.hasFocus) }
}

private class InputAccessoryPosition(private val top: Int) : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize,
    ): IntOffset = IntOffset(0, top - popupContentSize.height)
}

@Composable
fun InputAccessoryBar(
    state: InputAccessoryState,
    modifier: Modifier = Modifier,
    previousLabel: String = "Previous",
    nextLabel: String = "Next",
    doneLabel: String = "Done",
    onDone: (() -> Unit)? = null,
) {
    val density = LocalDensity.current
    val keyboard = WindowInsets.ime.getBottom(density)
    val container = LocalWindowInfo.current.containerSize
    val focus = LocalFocusManager.current
    val inset = LocalKeyboardAccessory.current
    if (state.focused == null || keyboard == 0 || container.height == 0) {
        SideEffect {
            state.barHeight = 0
            inset?.height = 0
        }
        return
    }
    DisposableEffect(inset) {
        onDispose { inset?.height = 0 }
    }
    val colors = EsuissTheme.colors
    val top = container.height - keyboard
    val provider = remember(top) { InputAccessoryPosition(top) }
    Popup(
        popupPositionProvider = provider,
        properties = PopupProperties(focusable = false, dismissOnBackPress = false, dismissOnClickOutside = false),
    ) {
        Row(
            modifier = modifier
                .width(with(density) { container.width.toDp() })
                .height(InputAccessoryBarHeight)
                .onSizeChanged {
                    state.barHeight = it.height
                    inset?.height = it.height
                }
                .background(colors.muted)
                .drawBehind { drawRect(colors.border, Offset.Zero, Size(size.width, 1.dp.toPx())) }
                .padding(horizontal = 4.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(0.dp),
        ) {
            Button(
                onClick = { state.previous() },
                variant = ButtonVariant.Plain,
                size = ButtonSize.Icon,
                enabled = state.hasPrevious,
            ) {
                Icon(PhosphorIcons.Regular.CaretUp, contentDescription = previousLabel, size = 20.dp)
            }
            Button(
                onClick = { state.next() },
                variant = ButtonVariant.Plain,
                size = ButtonSize.Icon,
                enabled = state.hasNext,
            ) {
                Icon(PhosphorIcons.Regular.CaretDown, contentDescription = nextLabel, size = 20.dp)
            }
            Spacer(Modifier.weight(1f))
            Button(
                onClick = {
                    onDone?.invoke()
                    focus.clearFocus()
                },
                variant = ButtonVariant.Plain,
            ) {
                BasicText(
                    doneLabel,
                    style = Typography.base.copy(color = colors.primary, fontWeight = FontWeight.SemiBold),
                    maxLines = 1,
                )
            }
        }
    }
}
```

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

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

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

## Usage

```tsx
import { InputAccessoryBar, InputAccessoryBarInput } from "@/components/ui/input-accessory-bar"
```

```tsx
<InputAccessoryBar>
  <Form />
</InputAccessoryBar>
```

Wrap the form in `InputAccessoryBar` and use `InputAccessoryBarInput` in place of `Input`. Inputs are ordered by the order they mount. The return key reads "next" on every input but the last, which reads "done" and dismisses the keyboard.

For other text inputs, such as a `Textarea`, call `useInputAccessoryBarInput()` and spread the returned props onto the input. Inside a Keyboard Scroll View, the focused input is scrolled into view.

Use `previousLabel`, `nextLabel` and `doneLabel` to localize the buttons, and `onDone` to react when done is pressed.

## Examples

### Focused

The first input focused, so the bar is visible.

![Preview](https://ui.suiss.app/previews/components-input-accessory-bar--focused.light.webp)

```tsx
import { View } from "react-native"
import { InputAccessoryBar, InputAccessoryBarInput } from "@/components/ui/input-accessory-bar"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"

function Field({ label, autoFocus }: { label: string; autoFocus?: boolean }) {
  return (
    <View className="gap-1.5">
      <Text variant="small">{label}</Text>
      <InputAccessoryBarInput placeholder={label} autoFocus={autoFocus} />
    </View>
  )
}

function Form({ autoFocus = false }: { autoFocus?: boolean }) {
  return (
    <KeyboardScrollView className="-m-4" contentContainerClassName="gap-4 p-4">
      <Field label="Name" autoFocus={autoFocus} />
      <Field label="Email" />
      <Field label="Phone" />
      <Field label="City" />
    </KeyboardScrollView>
  )
}

export function InputAccessoryBarFocusedExample() {
  return (
    <InputAccessoryBar>
      <Form autoFocus />
    </InputAccessoryBar>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct InputAccessoryBarFocusedExample: View {
  var body: some View {
    InputAccessoryBarForm(autoFocus: true)
  }
}

enum InputAccessoryBarField: Hashable {
  case name, email, phone, city, subject, message
}

struct InputAccessoryBarRow: View {
  let label: LocalizedStringKey
  let field: InputAccessoryBarField
  let focus: FocusState<InputAccessoryBarField?>.Binding

  @State private var text = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Text(label)
        .textVariant(.small)
        .accessibilityHidden(true)
      TextField(label, text: $text)
        .textFieldStyle(.input)
        .focused(focus, equals: field)
        .submitLabel(.next)
    }
  }
}

struct InputAccessoryBarForm: View {
  var autoFocus = false
  var previousLabel: LocalizedStringKey = "Previous"
  var nextLabel: LocalizedStringKey = "Next"
  var doneLabel: LocalizedStringKey = "Done"

  @FocusState private var focus: InputAccessoryBarField?

  var body: some View {
    KeyboardScrollView {
      VStack(alignment: .leading, spacing: 16) {
        InputAccessoryBarRow(label: "Name", field: .name, focus: $focus)
        InputAccessoryBarRow(label: "Email", field: .email, focus: $focus)
        InputAccessoryBarRow(label: "Phone", field: .phone, focus: $focus)
        InputAccessoryBarRow(label: "City", field: .city, focus: $focus)
      }
      .padding(16)
    }
    .inputAccessoryBar(
      focus: $focus,
      order: [.name, .email, .phone, .city],
      previousLabel: previousLabel,
      nextLabel: nextLabel,
      doneLabel: doneLabel
    )
    .onAppear {
      if autoFocus { focus = .name }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.InputAccessoryBar
import com.esuiss.ui.components.InputAccessoryState
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.components.inputAccessory
import com.esuiss.ui.components.rememberInputAccessoryState

@Composable
fun InputAccessoryBarFocusedExample() {
    InputAccessoryBarForm(autoFocus = true)
}

@Composable
fun InputAccessoryBarField(
    label: String,
    state: InputAccessoryState,
    index: Int,
    autoFocus: Boolean = false,
) {
    var value by rememberSaveable(label) { mutableStateOf("") }
    val requester = remember { FocusRequester() }
    if (autoFocus) {
        LaunchedEffect(Unit) { requester.requestFocus() }
    }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
        Text(label, variant = TextVariant.Small)
        Input(
            value,
            { value = it },
            Modifier.focusRequester(requester).inputAccessory(state, index),
            placeholder = label,
        )
    }
}

@Composable
fun InputAccessoryBarForm(
    autoFocus: Boolean,
    previousLabel: String = "Previous",
    nextLabel: String = "Next",
    doneLabel: String = "Done",
) {
    val state = rememberInputAccessoryState()
    Screen {
        KeyboardScrollView(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            InputAccessoryBarField("Name", state, 0, autoFocus)
            InputAccessoryBarField("Email", state, 1)
            InputAccessoryBarField("Phone", state, 2)
            InputAccessoryBarField("City", state, 3)
        }
        InputAccessoryBar(state, previousLabel = previousLabel, nextLabel = nextLabel, doneLabel = doneLabel)
    }
}
```

### Custom labels

Custom labels for the previous, next and done buttons.

![Preview](https://ui.suiss.app/previews/components-input-accessory-bar--custom-labels.light.webp)

```tsx
import { View } from "react-native"
import { InputAccessoryBar, InputAccessoryBarInput } from "@/components/ui/input-accessory-bar"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"

function Field({ label, autoFocus }: { label: string; autoFocus?: boolean }) {
  return (
    <View className="gap-1.5">
      <Text variant="small">{label}</Text>
      <InputAccessoryBarInput placeholder={label} autoFocus={autoFocus} />
    </View>
  )
}

function Form({ autoFocus = false }: { autoFocus?: boolean }) {
  return (
    <KeyboardScrollView className="-m-4" contentContainerClassName="gap-4 p-4">
      <Field label="Name" autoFocus={autoFocus} />
      <Field label="Email" />
      <Field label="Phone" />
      <Field label="City" />
    </KeyboardScrollView>
  )
}

export function InputAccessoryBarCustomLabelsExample() {
  return (
    <InputAccessoryBar
      previousLabel="Back"
      nextLabel="Forward"
      doneLabel="Close"
    >
      <Form autoFocus />
    </InputAccessoryBar>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct InputAccessoryBarCustomLabelsExample: View {
  var body: some View {
    InputAccessoryBarForm(autoFocus: true, previousLabel: "Back", nextLabel: "Forward", doneLabel: "Close")
  }
}

enum InputAccessoryBarField: Hashable {
  case name, email, phone, city, subject, message
}

struct InputAccessoryBarRow: View {
  let label: LocalizedStringKey
  let field: InputAccessoryBarField
  let focus: FocusState<InputAccessoryBarField?>.Binding

  @State private var text = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Text(label)
        .textVariant(.small)
        .accessibilityHidden(true)
      TextField(label, text: $text)
        .textFieldStyle(.input)
        .focused(focus, equals: field)
        .submitLabel(.next)
    }
  }
}

struct InputAccessoryBarForm: View {
  var autoFocus = false
  var previousLabel: LocalizedStringKey = "Previous"
  var nextLabel: LocalizedStringKey = "Next"
  var doneLabel: LocalizedStringKey = "Done"

  @FocusState private var focus: InputAccessoryBarField?

  var body: some View {
    KeyboardScrollView {
      VStack(alignment: .leading, spacing: 16) {
        InputAccessoryBarRow(label: "Name", field: .name, focus: $focus)
        InputAccessoryBarRow(label: "Email", field: .email, focus: $focus)
        InputAccessoryBarRow(label: "Phone", field: .phone, focus: $focus)
        InputAccessoryBarRow(label: "City", field: .city, focus: $focus)
      }
      .padding(16)
    }
    .inputAccessoryBar(
      focus: $focus,
      order: [.name, .email, .phone, .city],
      previousLabel: previousLabel,
      nextLabel: nextLabel,
      doneLabel: doneLabel
    )
    .onAppear {
      if autoFocus { focus = .name }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.InputAccessoryBar
import com.esuiss.ui.components.InputAccessoryState
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.components.inputAccessory
import com.esuiss.ui.components.rememberInputAccessoryState

@Composable
fun InputAccessoryBarCustomLabelsExample() {
    InputAccessoryBarForm(autoFocus = true, previousLabel = "Back", nextLabel = "Forward", doneLabel = "Close")
}

@Composable
fun InputAccessoryBarField(
    label: String,
    state: InputAccessoryState,
    index: Int,
    autoFocus: Boolean = false,
) {
    var value by rememberSaveable(label) { mutableStateOf("") }
    val requester = remember { FocusRequester() }
    if (autoFocus) {
        LaunchedEffect(Unit) { requester.requestFocus() }
    }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
        Text(label, variant = TextVariant.Small)
        Input(
            value,
            { value = it },
            Modifier.focusRequester(requester).inputAccessory(state, index),
            placeholder = label,
        )
    }
}

@Composable
fun InputAccessoryBarForm(
    autoFocus: Boolean,
    previousLabel: String = "Previous",
    nextLabel: String = "Next",
    doneLabel: String = "Done",
) {
    val state = rememberInputAccessoryState()
    Screen {
        KeyboardScrollView(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            InputAccessoryBarField("Name", state, 0, autoFocus)
            InputAccessoryBarField("Email", state, 1)
            InputAccessoryBarField("Phone", state, 2)
            InputAccessoryBarField("City", state, 3)
        }
        InputAccessoryBar(state, previousLabel = previousLabel, nextLabel = nextLabel, doneLabel = doneLabel)
    }
}
```

### With textarea

A textarea connected through `useInputAccessoryBarInput`.

![Preview](https://ui.suiss.app/previews/components-input-accessory-bar--with-textarea.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { InputAccessoryBar, InputAccessoryBarInput, useInputAccessoryBarInput } from "@/components/ui/input-accessory-bar"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"

function Field({ label, autoFocus }: { label: string; autoFocus?: boolean }) {
  return (
    <View className="gap-1.5">
      <Text variant="small">{label}</Text>
      <InputAccessoryBarInput placeholder={label} autoFocus={autoFocus} />
    </View>
  )
}

function MessageField() {
  const accessory = useInputAccessoryBarInput()
  return (
    <View className="gap-1.5">
      <Text variant="small">Message</Text>
      <Textarea
        placeholder="Message"
        ref={accessory.ref}
        inputAccessoryViewID={accessory.inputAccessoryViewID}
        onFocus={accessory.onFocus}
        onBlur={accessory.onBlur}
      />
    </View>
  )
}

function DoneLogDemo() {
  const [log, setLog] = React.useState("Tap Done.")
  return (
    <InputAccessoryBar onDone={() => setLog("Done pressed")}>
      <KeyboardScrollView
        className="-m-4"
        contentContainerClassName="gap-4 p-4"
      >
        <Field label="Subject" autoFocus />
        <MessageField />
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      </KeyboardScrollView>
    </InputAccessoryBar>
  )
}

export function InputAccessoryBarWithTextareaExample() {
  return (
    <DoneLogDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

enum InputAccessoryBarField: Hashable {
  case name, email, phone, city, subject, message
}

struct InputAccessoryBarRow: View {
  let label: LocalizedStringKey
  let field: InputAccessoryBarField
  let focus: FocusState<InputAccessoryBarField?>.Binding

  @State private var text = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Text(label)
        .textVariant(.small)
        .accessibilityHidden(true)
      TextField(label, text: $text)
        .textFieldStyle(.input)
        .focused(focus, equals: field)
        .submitLabel(.next)
    }
  }
}

struct InputAccessoryBarTextareaDemo: View {
  @FocusState private var focus: InputAccessoryBarField?
  @State private var message = ""
  @State private var log = "Tap Done."

  var body: some View {
    KeyboardScrollView {
      VStack(alignment: .leading, spacing: 16) {
        InputAccessoryBarRow(label: "Subject", field: .subject, focus: $focus)
        VStack(alignment: .leading, spacing: 6) {
          Text("Message")
            .textVariant(.small)
            .accessibilityHidden(true)
          TextField("Message", text: $message, axis: .vertical)
            .textFieldStyle(.textarea)
            .focused($focus, equals: .message)
        }
        Text(log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .padding(16)
    }
    .safeAreaInset(edge: .bottom, spacing: 0) {
      if let current = focus {
        InputAccessoryBar(
          onPrevious: current == .message ? { focus = .subject } : nil,
          onNext: current == .subject ? { focus = .message } : nil,
          onDone: {
            log = "Done pressed"
            focus = nil
          }
        )
      }
    }
    .onSubmit {
      if focus == .subject { focus = .message }
    }
    .onAppear { focus = .subject }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.Input
import com.esuiss.ui.components.InputAccessoryBar
import com.esuiss.ui.components.InputAccessoryState
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.components.Textarea
import com.esuiss.ui.components.inputAccessory
import com.esuiss.ui.components.rememberInputAccessoryState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun InputAccessoryBarField(
    label: String,
    state: InputAccessoryState,
    index: Int,
    autoFocus: Boolean = false,
) {
    var value by rememberSaveable(label) { mutableStateOf("") }
    val requester = remember { FocusRequester() }
    if (autoFocus) {
        LaunchedEffect(Unit) { requester.requestFocus() }
    }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
        Text(label, variant = TextVariant.Small)
        Input(
            value,
            { value = it },
            Modifier.focusRequester(requester).inputAccessory(state, index),
            placeholder = label,
        )
    }
}

@Composable
fun InputAccessoryBarDoneLog() {
    val state = rememberInputAccessoryState()
    var log by rememberSaveable { mutableStateOf("Tap Done.") }
    var message by rememberSaveable { mutableStateOf("") }
    Screen {
        KeyboardScrollView(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            InputAccessoryBarField("Subject", state, 0, autoFocus = true)
            Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
                Text("Message", variant = TextVariant.Small)
                Textarea(
                    message,
                    { message = it },
                    Modifier.inputAccessory(state, 1),
                    placeholder = "Message",
                )
            }
            Text(
                log,
                modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            )
        }
        InputAccessoryBar(state, onDone = { log = "Done pressed" })
    }
}
```

## API Reference

### InputAccessoryBar

Provider that tracks the inputs and renders the bar.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `previousLabel` | `string` | `"Previous"` | Label for the previous button. Defaults to `"Previous"`. |
| `nextLabel` | `string` | `"Next"` | Label for the next button. Defaults to `"Next"`. |
| `doneLabel` | `React.ReactNode` | `"Done"` | Text or node for the done button. Defaults to `"Done"`. |
| `onDone` | `() => void` | - | Called after the keyboard is dismissed with done. |
| `portalHost` | `string` | - | Name of the portal host on Android. |
| `className` | `string` | - |  |
| `children` | `React.ReactNode` | - |  |

### InputAccessoryBarInput

`Input` connected to the bar.

Also accepts the props of `React.ComponentProps<typeof Input>`.

## Accessibility

- The arrow buttons are labelled with `previousLabel` and `nextLabel` and are disabled at the first and last input.
- The done button is a text button that dismisses the keyboard.

## Notes

- iOS uses the native input accessory view; Android uses a portal and reserves the bar height so keyboard-aware layouts stay above it.
- Uses `bg-card` (`bg-muted` in dark mode) and `border-border` tokens.

