Current section
Files
Jump to
Current section
Files
src/ui/popover.gleam
//// Popover component with focus trap support.
////
//// Popovers can be modal (focus trapped) or non-modal using `non_modal()`.
////
//// ## Usage with Focus Trap (Modal Popover)
////
//// ```gleam
//// import glizzy/ui/popover
//// import glizzy/ui/focus
//// import lustre/effect.{type Effect, batch}
////
//// type Model {
//// Model(is_open: Bool, focus_scope_id: String)
//// }
////
//// type Msg {
//// Open
//// Close
//// FocusScopeCreated(String)
//// }
////
//// // In init:
//// fn init() -> #(Model, Effect(Msg)) {
//// #(
//// Model(is_open: False, focus_scope_id: ""),
//// focus.create_scope("popover-content", FocusScopeCreated),
//// )
//// }
////
//// // In update:
//// 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()
//// )
//// }
//// }
////
//// // In view (modal popover):
//// fn view(model: Model) {
//// popover.popover([], [
//// popover.underlay([]),
//// popover.content([
//// attribute("id", "popover-content"),
//// attribute("data-state", case model.is_open {
//// True -> "open"
//// False -> "closed"
//// }),
//// ], [
//// // ... popover content
//// ])
//// ])
//// }
////
//// // In view (non-modal popover - no focus trap):
//// fn view(model: Model) {
//// popover.popover([
//// popover.non_modal(),
//// ], [
//// popover.content([], [
//// // ... popover content (no focus trap)
//// ])
//// ])
//// }
//// ```
// MIT License
// Copyright (c) 2026 Koncreate
// See LICENSE for details
import gleam/string
import lustre/attribute.{type Attribute, attribute, class}
import lustre/element.{type Element}
import lustre/element/html
import ui/css
import ui/size as size_helpers
pub type Variant {
Default
Muted
}
pub type Size {
Small
Medium
Large
}
pub type Placement {
Top
Bottom
Left
Right
Start
End
}
pub fn popover(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.div([class("fixed inset-0 z-50"), ..attributes], children)
}
pub fn trigger(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.button(
[
class(
[
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors",
css.focus_ring(),
"disabled:pointer-events-none " <> css.disabled(),
"bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2",
]
|> string.join(" "),
),
attribute("type", "button"),
attribute("aria-haspopup", "dialog"),
..attributes
],
children,
)
}
pub fn underlay(attributes: List(Attribute(a))) -> Element(a) {
html.div(
[
class("fixed inset-0 bg-black/30"),
attribute("data-popover-underlay", "true"),
attribute("aria-hidden", "true"),
..attributes
],
[],
)
}
pub fn content(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.div(
[
class(
[
"absolute z-50 min-w-[12rem] rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md",
"outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
]
|> string.join(" "),
),
attribute("role", "dialog"),
attribute("tabindex", "-1"),
attribute("data-popover-content", "true"),
..attributes
],
children,
)
}
pub fn arrow(attributes: List(Attribute(a))) -> Element(a) {
html.div(
[
class(
"absolute -top-1 left-4 h-2 w-2 rotate-45 border-l border-t border-border bg-popover",
),
attribute("data-popover-arrow", "true"),
..attributes
],
[],
)
}
pub fn variant(v: Variant) -> Attribute(a) {
case v {
Default -> class("bg-popover text-popover-foreground")
Muted -> class("bg-muted text-muted-foreground")
}
}
pub fn size(s: Size) -> Attribute(a) {
case s {
Small ->
class(size_helpers.container_class(size_helpers.Small) <> " p-2 text-xs")
Medium ->
class(size_helpers.container_class(size_helpers.Medium) <> " p-3 text-sm")
Large ->
class(
size_helpers.container_class(size_helpers.Large) <> " p-4 text-base",
)
}
}
pub fn placement(p: Placement) -> Attribute(a) {
case p {
Top -> attribute("data-placement", "top")
Bottom -> attribute("data-placement", "bottom")
Left -> attribute("data-placement", "left")
Right -> attribute("data-placement", "right")
Start -> attribute("data-placement", "start")
End -> attribute("data-placement", "end")
}
}
pub fn expanded(expanded: Bool) -> Attribute(a) {
case expanded {
True -> attribute("aria-expanded", "true")
False -> attribute("aria-expanded", "false")
}
}
pub fn controls(id: String) -> Attribute(a) {
attribute("aria-controls", id)
}
pub fn non_modal() -> Attribute(a) {
attribute("data-non-modal", "true")
}