Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Focus Management Example

Live Demo

Overview

The focus example demonstrates keyboard focus management patterns in vgui. It shows:

  • Dialog with focus trap and focus restore on close.
  • Radiogroup with roving tabindex (Tab reaches only the checked radio).
  • on:resize / ResizeEvent for tracking window size changes.
  • Tab / Shift+Tab cycling within the dialog’s focus trap.
  • Arrow-key navigation between radio options.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (dialog_open, set_dialog_open) = create_signal(false);
    let (radio_val, set_radio) = create_signal(0i32);
    let (field1, set_field1) = create_signal(String::new());
    let (field2, set_field2) = create_signal(String::new());
    let (size_sig, set_size) = create_signal((0f64, 0f64));

    // Individual setters for radio on:change closures (each needs its own
    // WriteSignal clone with a 'static lifetime).
    let sr0 = set_radio.clone();
    let sr1 = set_radio.clone();
    let sr2 = set_radio.clone();
    let set_dialog_open_btn = set_dialog_open.clone();
    let set_dialog_close = set_dialog_open.clone();
    let set_dialog_close_btn = set_dialog_open.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#505050] w-[600px] h-[500px] text-white"
            on:resize={move |ev: &ResizeEvent, _w, _cx| { set_size.update(_cx, |_| (ev.width, ev.height)); }}
        >
            <span class="text-sm text-[#0f0]">{format!("{:.0} x {:.0}", size_sig.get().0, size_sig.get().1)}</span>

            // ── Dialog with focus trap + restore ──────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">
                    {"Click the button, then Tab/Shift+Tab to cycle within the dialog. Escape or click-outside closes it and restores focus."}
                </span>
                <button
                    class="px-3 py-2 bg-[#0066cc] hover:bg-[#004499] rounded text-sm"
                    on:click={click(move |cx| set_dialog_open_btn.set(cx, true))}
                >
                    {"Open Dialog"}
                </button>
            </div>

            // ── Radio group with roving tabindex ──────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">
                    {"Tab reaches only the checked radio. Arrow keys move between radios."}
                </span>
                <radiogroup>
                    <div class="flex flex-row gap-4 items-center">
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 0} on:change={move |_v: bool, cx: &mut App| sr0.set(cx, 0)} />
                            <span class="text-sm">{"Option A"}</span>
                        </div>
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 1} on:change={move |_v: bool, cx: &mut App| sr1.set(cx, 1)} />
                            <span class="text-sm">{"Option B"}</span>
                        </div>
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 2} on:change={move |_v: bool, cx: &mut App| sr2.set(cx, 2)} />
                            <span class="text-sm">{"Option C"}</span>
                        </div>
                    </div>
                </radiogroup>
                <span class="text-sm text-[#0f0]">{format!("selected: {}", radio_val.get())}</span>
            </div>

            // ── Dialog content ────────────────────────────────────────
            <dialog open={dialog_open.get()} on:close={move |cx| set_dialog_close.set(cx, false)}>
                <div class="bg-white text-black p-5 rounded-lg flex flex-col gap-3 w-[350px]">
                    <h3 class="font-bold">{"Dialog with Focus Trap"}</h3>
                    <div class="flex flex-col gap-1">
                        <span class="text-sm text-[#666]">{"Field 1 (type text)"}</span>
                        <input
                            type="text"
                            placeholder="First field"
                            value={field1.get()}
                            on:input={move |v: &str, cx: &mut App| set_field1.set(cx, v.to_string())}
                        />
                    </div>
                    <label class="flex flex-col gap-1">
                        <span class="text-sm text-[#666]">{"Field 2 (type text)"}</span>
                        <input
                            type="text"
                            placeholder="Second field"
                            value={field2.get()}
                            on:input={move |v: &str, cx: &mut App| set_field2.set(cx, v.to_string())}
                        />
                    </label>
                    <div class="flex flex-row gap-2 justify-end">
                        <button
                            class="px-3 py-2 bg-[#ccc] hover:bg-[#aaa] rounded text-sm"
                            on:click={click(move |cx| set_dialog_close_btn.set(cx, false))}
                        >
                            {"Close"}
                        </button>
                    </div>
                </div>
            </dialog>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Focus trap via dialog()

The <dialog open={...}> element renders on a deferred overlay layer with a backdrop. While open, it traps keyboard focus: Tab and Shift+Tab cycle only among the focusable elements inside the dialog (the two text inputs and the Close button). Focus cannot escape to the underlying page until the dialog is dismissed.

Focus restore on close

When the dialog closes — via the Close button, the Escape key, or clicking outside the dialog — focus is automatically restored to the element that had focus before the dialog opened (the “Open Dialog” button). The on:close handler resets the dialog_open signal to false, which unmounts the dialog content and triggers the restore.

Roving tabindex via radiogroup

The <radiogroup> wrapper implements the WAI-ARIA roving tabindex pattern. Only the currently checked radio is in the tab order (tabindex=0); the others are removed (tabindex=-1). Tab moves into the group at the checked radio, then Tab again leaves the group. Arrow keys move the selection between options without leaving the group. Each radio’s on:change handler updates the shared radio_val signal, which reactively updates which radio is checked.

ResizeEvent for window size tracking

The root <div> carries on:resize={move |ev: &ResizeEvent, _w, _cx| ...}. The ResizeEvent provides .width and .height (in device pixels). The handler stores these into the size_sig signal, and a span at the top of the view displays the current dimensions. Resizing the window updates the display in real time.

Running

Native:

cargo run -p vgui-focus

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-focus --release
wasm-bindgen --target web --out-dir examples/focus/dist \
    --no-typescript target/wasm32-unknown-unknown/release/focus.wasm
python3 scripts/serve_plain.py 8080 examples/focus