Floating Window

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJSApache-2.0
Installation mix mishka.ui.gen.headless floating_window
Source View Source

Overview of the Phoenix and LiveView Headless Floating Window Component

The Mishka Chelekom Headless Floating Window is a Phoenix function component for a draggable panel that sits inside a positioned container. It renders a div with role="dialog" and two parts, a handle and a body. The FloatingWindow JS hook moves the panel when the handle is dragged, or when the handle has focus and an arrow key is pressed. It keeps the panel inside its container and mirrors the position into data-x and data-y.

The window is not modal and does not trap focus. A LiveView can follow it through on_move, which pushes the new coordinates when a drag ends and on every key move. Style the root with class, the title bar with handle_class and the content with body_class.

Base UI Floating Window example - draggable panel (Phoenix headless)

Base UI Floating Window example - draggable panel (Phoenix headless)

heex
<div class="relative h-56 w-full overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 bg-[image:radial-gradient(rgba(120,120,120,0.25)_1px,transparent_0)] bg-[length:16px_16px] dark:border-neutral-800 dark:bg-neutral-900">
  <.floating_window
    id="baseui-floating-window"
    x={24}
    y={24}
    label="Window"
    class="absolute w-52 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
    handle_class="cursor-grab rounded-t-lg border-b border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm font-medium text-neutral-700 active:cursor-grabbing dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
    body_class="p-3 text-sm text-neutral-500 dark:text-neutral-400"
  >
    <:handle>Drag me</:handle>
    Grab the title bar to move this panel within the box.
  </.floating_window>
</div>

Attributes and slots

id is required because it carries the hook. x and y are the starting offsets in pixels from the top left corner of the container, and both default to 0. label is the accessible name of the dialog. handle_label names the drag handle and defaults to "Move window".

The handle slot holds the title bar content. The default slot holds the window body and is required.

Events and controls in the handle

on_move names an event that receives %{"x" => x, "y" => y} as whole pixels when a drag ends and after each arrow key. If you track the window on the server, store the coordinates from this event and pass them back as x and y, so a re-render keeps the window where it was.

Buttons, links, inputs, textareas and selects inside the handle do not start a drag, so a close button in the title bar keeps working. Add data-no-drag to any other element that should not start one.

heex
<.floating_window
  id="notes-window"
  x={@pos.x}
  y={@pos.y}
  on_move="moved"
  label="Notes"
  class="absolute w-56 rounded-lg border bg-white shadow-lg"
  handle_class="flex cursor-grab items-center justify-between px-3 py-1.5"
>
  <:handle>
    <span>Notes</span>
    <button type="button" phx-click={JS.hide(to: "#notes-window")} aria-label="Close">
      Close
    </button>
  </:handle>
  Window content.
</.floating_window>
elixir
def handle_event("moved", %{"x" => x, "y" => y}, socket) do
  {:noreply, assign(socket, :pos, %{x: x, y: y})}
end

Parts & data attributes

The root is marked data-part="window" and carries data-x, data-y and, during a drag, data-dragging. The other parts are handle and body, with the structural classes chelekom-floating-window__handle and chelekom-floating-window__body. The hook writes the position to the inline left and top of the root.

Accessibility

The handle is a focusable element with role="button", aria-label and aria-roledescription="draggable window handle". With the handle focused, the arrow keys move the window by 10 pixels, or by 1 pixel with Shift held. This gives keyboard users an alternative to dragging, as WCAG 2.5.7 asks. There is no formal ARIA pattern for a movable non-modal window, so the root uses the dialog role without a focus trap.