Skip to content

Focus Management

Only one element at a time can be focused — the one that receives keyboard input. Inputs and selects need focus to be typed into or navigated. Vue TermUI gives you two composables over OpenTUI's focus system: useCurrentFocusedElement to read which element is focused, and useFocusManager for an app-wide view.

No automatic Tab cycling

Unlike the web, OpenTUI does not cycle focus on Tab for you. You decide the order and which key advances it — see Building Tab navigation below. This is more code, but it gives you full control over how navigation feels.

Making an element focusable

Any element joins the focus system just by being focusable. Interactive components (<Input>, <Select>) already are; opt a container in with <Box :focusable="true" />. Once focusable, an element can be focused imperatively through its backing renderable — grab it with a template ref and call focus() / blur():

vue
<script setup lang="ts">
import { Box, Text, computed, shallowRef, useCurrentFocusedElement } from 'vue-termui'

// The backing OpenTUI renderable. Bound to a host `<tui-box>` the ref receives the
// renderable directly; bound to the `<Box>` component, unwrap its `$el`.
const el = shallowRef<any>(null)
const currentFocused = useCurrentFocusedElement()
const focused = computed(() => !!el.value && currentFocused.value === el.value)

function setRef(instance: any) {
  el.value = instance?.$el ?? instance ?? null
}
</script>

<template>
  <Box :ref="setRef" focusable border :borderColor="focused ? '#42b883' : '#666666'" :padding="1">
    <Text>{{ focused ? 'focused' : 'not focused' }}</Text>
  </Box>
</template>

el.value.focus() gives the element focus and el.value.blur() removes it. focused stays in sync because useCurrentFocusedElement is reactive.

A common pattern is a reusable focusable item that exposes its controls to a parent:

vue
<!-- MenuItem.vue -->
<script setup lang="ts">
import { Box, Text, computed, shallowRef, useCurrentFocusedElement } from 'vue-termui'

defineProps<{ label: string }>()

const el = shallowRef<any>(null)
const currentFocused = useCurrentFocusedElement()
const focused = computed(() => !!el.value && currentFocused.value === el.value)

// Let the parent drive focus and read state.
defineExpose({ focus: () => el.value?.focus(), focused })
</script>

<template>
  <Box
    :ref="(c) => (el = c?.$el ?? c ?? null)"
    focusable
    :backgroundColor="focused ? '#42b883' : undefined"
    :paddingX="1"
  >
    <Text :fg="focused ? '#0b0b0b' : '#cccccc'">{{ focused ? '' : ' ' }} {{ label }}</Text>
  </Box>
</template>

useCurrentFocusedElement

useCurrentFocusedElement returns a reactive ShallowRef of the element that currently holds focus, or null when nothing is focused. It updates whenever focus moves.

vue
<script setup lang="ts">
import { useCurrentFocusedElement } from 'vue-termui'

const focused = useCurrentFocusedElement()
// focused.value is the currently focused renderable, or null
</script>

Compare it against your own element (see above) to derive a per-element focused boolean.

useFocusManager

useFocusManager gives you app-wide Tab navigation: the currently focused element (reactive, same as useCurrentFocusedElement), plus focusNext() / focusPrevious() that cycle through every focusable element in render-tree order (wrapping around), and blur().

vue
<script setup lang="ts">
import { onKeyDown, useFocusManager } from 'vue-termui'

const { focused, focusNext, focusPrevious } = useFocusManager()

// One handler, anywhere near the root, wires Tab across the whole app.
onKeyDown((key) => {
  if (key.name !== 'tab') return
  key.preventDefault()
  key.shift ? focusPrevious() : focusNext()
})
</script>
PropertyTypeDescription
focusedShallowRef<Renderable>The currently focused element, or null
focusNext()() => voidFocus the next focusable in tree order, wrapping (first if none)
focusPrevious()() => voidFocus the previous focusable in tree order, wrapping (last if none)
blur()() => voidClear focus from whatever element has it

Elements join the cycle just by being focusable: interactive components (<Input>, <Select>) already are, and you opt a container in with <Box :focusable="true" />. The order is the render tree's depth-first order.

Scoped navigation within a component

useFocusManager cycles the whole app. When you instead want arrow keys to move only within one list — a sidebar, a menu, a tab bar — keep an ordered list of that component's focusable children, track which is focused, and move on a key press. Here a parent collects child refs and moves focus with the arrow keys, committing on Enter:

vue
<script setup lang="ts">
import { Box, onKeyDown, onMounted, nextTick, ref } from 'vue-termui'
import MenuItem from './MenuItem.vue'

const items = ['New file', 'Open', 'Save', 'Quit']

// Public instances of each MenuItem, collected via function refs.
const links = ref<Array<{ focus: () => void; focused: boolean } | null>>([])

function focusedIndex() {
  return links.value.findIndex((l) => l?.focused)
}
function focusAt(i: number) {
  const n = items.length
  links.value[((i % n) + n) % n]?.focus() // wraps around
}

onMounted(async () => {
  await nextTick() // wait for children to mount + register
  focusAt(0)
})

onKeyDown((key) => {
  const current = focusedIndex()
  if (current < 0) return // focus is elsewhere; ignore
  if (key.name === 'down') focusAt(current + 1)
  else if (key.name === 'up') focusAt(current - 1)
  else if (key.name === 'return') run(items[current])
})

function run(item: string) {
  /* ... */
}
</script>

<template>
  <Box flexDirection="column">
    <MenuItem
      v-for="(label, i) in items"
      :key="label"
      :ref="(el) => (links[i] = el as any)"
      :label="label"
    />
  </Box>
</template>

The same idea scales to a sidebar, a tab bar, or a form: an ordered list, an index, and a key that advances it. A nice touch is binding Esc to pull focus back to your main navigation from a focused <Input> or <Select>.

Built-in focusable components

<Input> and <Select> are focusable out of the box and accept a focus prop to grab focus on mount:

template
<Input v-model="name" focus />
<Select v-model="index" :options="options" focus />

While focused, they handle the relevant keys themselves (typing, cursor movement, list navigation). See <Input> and <Select>.

Released under the MIT License.