Current section
Files
Jump to
Current section
Files
src/ui/focus.gleam
//// Focus management utilities for Glizzy components
////
//// Provides effect-based focus management and focus trapping for overlays.
//// Use this for dialogs, modals, popovers, and other overlay components.
////
//// ## Usage
////
//// ```gleam
//// import lustre/effect.{type Effect, batch}
//// import glizzy/ui/focus
////
//// type Model {
//// Model(
//// is_open: Bool,
//// focus_scope_id: String,
//// )
//// }
////
//// type Msg {
//// Open
//// Close
//// FocusScopeCreated(String)
//// }
////
//// fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
//// case msg {
//// Open -> #(
//// Model(..model, is_open: True),
//// batch([
//// focus.trap_focus(model.focus_scope_id, True),
//// focus.focus_first(model.focus_scope_id),
//// ])
//// )
//// Close -> #(
//// Model(..model, is_open: False),
//// focus.trap_focus(model.focus_scope_id, False)
//// )
//// FocusScopeCreated(scope_id) -> #(
//// Model(..model, focus_scope_id: scope_id),
//// effect.none()
//// )
//// }
//// }
//// ```
// MIT License
// Copyright (c) 2026 Koncreate
// See LICENSE for details
import gleam/option.{type Option}
import lustre/effect.{type Effect, after_paint, from}
// ============================================================================
// Types
// ============================================================================
/// A unique identifier for a focus scope.
pub type FocusScopeId =
String
// ============================================================================
// Focus Scope Management
// ============================================================================
/// Create a focus scope for the given container element.
/// Returns an effect that creates the scope and dispatches the scope ID.
///
/// Use this in your component's `init` function to set up focus management.
///
/// ## Example
///
/// ```gleam
/// fn init() -> #(Model, Effect(Msg)) {
/// #(
/// Model(is_open: False, focus_scope_id: ""),
/// focus.create_scope("dialog-content", FocusScopeCreated),
/// )
/// }
/// ```
pub fn create_scope(
container_id: String,
on_created: fn(FocusScopeId) -> msg,
) -> Effect(msg) {
from(fn(dispatch) {
let scope_id = create_focus_scope_js(container_id)
dispatch(on_created(scope_id))
})
}
/// Destroy a focus scope and clean up resources.
/// Returns an effect that destroys the scope.
///
/// Use this when your component unmounts to prevent memory leaks.
pub fn destroy_scope(scope_id: FocusScopeId) -> Effect(msg) {
from(fn(_) { destroy_focus_scope_js(scope_id) })
}
/// Focus the first focusable element within the scope.
/// Returns an effect that focuses the first element after paint.
///
/// Use this when opening an overlay to move focus inside.
pub fn focus_first(scope_id: FocusScopeId) -> Effect(msg) {
after_paint(fn(_, _) { focus_scope_focus_first_js(scope_id) })
}
/// Focus the last focusable element within the scope.
/// Returns an effect that focuses the last element after paint.
///
/// Use this for reverse focus navigation or when closing from the first element.
pub fn focus_last(scope_id: FocusScopeId) -> Effect(msg) {
after_paint(fn(_, _) { focus_scope_focus_last_js(scope_id) })
}
/// Enable or disable focus trapping within the scope.
/// When enabled, Tab key cycles through focusable elements within the scope.
///
/// Returns an effect that enables/disables the trap after paint.
///
/// ## Example
///
/// ```gleam
/// // Enable focus trap when opening dialog
/// focus.trap_focus(scope_id, True)
///
/// // Disable focus trap when closing dialog
/// focus.trap_focus(scope_id, False)
/// ```
pub fn trap_focus(scope_id: FocusScopeId, enabled: Bool) -> Effect(msg) {
after_paint(fn(_, _) { focus_scope_trap_focus_js(scope_id, enabled) })
}
// ============================================================================
// Standalone Focus Trap
// ============================================================================
/// Enable focus trap on a container element by ID.
/// This is a simpler alternative to using focus scopes.
///
/// When enabled, Tab key cycles through focusable elements within the container.
/// Shift+Tab reverses the cycle.
///
/// Returns an effect that enables the trap after paint.
pub fn enable_trap(container_id: String) -> Effect(msg) {
after_paint(fn(_, _) { enable_focus_trap_js(container_id) })
}
/// Disable focus trap on a container element by ID.
/// Returns an effect that disables the trap after paint.
pub fn disable_trap(container_id: String) -> Effect(msg) {
after_paint(fn(_, _) { disable_focus_trap_js(container_id) })
}
// ============================================================================
// Focus Helpers
// ============================================================================
/// Focus an element by its ID.
/// Returns an effect that focuses the element after paint.
///
/// Use this to restore focus to a trigger element when closing an overlay.
pub fn focus_by_id(element_id: String) -> Effect(msg) {
after_paint(fn(_, _) { focus_element_js(element_id) })
}
/// Get the currently focused element's ID.
/// Returns an effect that dispatches the focused element ID.
///
/// Use this to save the currently focused element before opening an overlay,
/// so you can restore focus when closing.
pub fn get_focused_id(on_got_id: fn(Option(String)) -> msg) -> Effect(msg) {
from(fn(dispatch) {
let id = get_focused_element_id_js()
dispatch(on_got_id(id))
})
}
// ============================================================================
// FFI Bindings
// ============================================================================
@external(javascript, "./focus.ffi.mjs", "createFocusScope")
fn create_focus_scope_js(container_id: String) -> FocusScopeId
@external(javascript, "./focus.ffi.mjs", "destroyFocusScope")
fn destroy_focus_scope_js(scope_id: FocusScopeId) -> Nil
@external(javascript, "./focus.ffi.mjs", "focusScopeFocusFirst")
fn focus_scope_focus_first_js(scope_id: FocusScopeId) -> Nil
@external(javascript, "./focus.ffi.mjs", "focusScopeFocusLast")
fn focus_scope_focus_last_js(scope_id: FocusScopeId) -> Nil
@external(javascript, "./focus.ffi.mjs", "focusScopeTrapFocus")
fn focus_scope_trap_focus_js(scope_id: FocusScopeId, enabled: Bool) -> Nil
@external(javascript, "./focus.ffi.mjs", "enableFocusTrap")
fn enable_focus_trap_js(container_id: String) -> Nil
@external(javascript, "./focus.ffi.mjs", "disableFocusTrap")
fn disable_focus_trap_js(container_id: String) -> Nil
@external(javascript, "./focus.ffi.mjs", "focusElementById")
fn focus_element_js(element_id: String) -> Nil
@external(javascript, "./focus.ffi.mjs", "getFocusedElementId")
fn get_focused_element_id_js() -> Option(String)