Skip to content
Brok UI

Loading…

No results

Popover

Open source Core

Non-modal floating panel anchored to a trigger, with an animated entrance, collision-aware placement, click-outside and Escape dismissal.

Version
v1.8.2
Stability
stable
License
MIT
Related
Tooltip
Dropdown Menu
Dialog
Sheet

Preview

Side
Align
previews.components.popover.default.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="w-full flex flex-wrap items-center justify-center gap-3">
    {{-- Bottom (default) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Bottom') }}</x-ui.popover.trigger>
        <x-ui.popover.content>
            <div class="space-y-3">
                <div class="space-y-1">
                    <p class="text-sm font-medium text-foreground">{{ __('Dimensions') }}</p>
                    <p class="text-sm text-muted-foreground">{{ __('Set the dimensions for the layer.') }}</p>
                </div>
                <div class="space-y-2">
                    <x-ui.label for="width">{{ __('Width') }}</x-ui.label>
                    <x-ui.input id="width" value="100%" />
                </div>
            </div>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-start side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Start') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="start" class="!w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-start edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-end side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('End') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="end" class="!w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-end edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>
</div>

Side options

Top Current
Bottom Current

Align options

Start Current
Center Current
End Current

Installation

terminal
php artisan ui:add popover

Note

This component ships an Alpine behavior module at resources/js/ui/popover.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './popover.js';

Registry contract

php artisan ui:add popover writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/popover.blade.php
  • blade resources/views/components/ui/popover/trigger.blade.php
  • blade resources/views/components/ui/popover/content.blade.php
  • js resources/js/ui/popover.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

popover.md
# Brok UI: Popover (`popover`)

Non-modal floating panel anchored to a trigger, with an animated entrance, collision-aware placement, click-outside and Escape dismissal.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add popover
```

## Usage

```blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="w-full flex flex-wrap items-center justify-center gap-3">
    {{-- Bottom (default) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Bottom') }}</x-ui.popover.trigger>
        <x-ui.popover.content>
            <div class="space-y-3">
                <div class="space-y-1">
                    <p class="text-sm font-medium text-foreground">{{ __('Dimensions') }}</p>
                    <p class="text-sm text-muted-foreground">{{ __('Set the dimensions for the layer.') }}</p>
                </div>
                <div class="space-y-2">
                    <x-ui.label for="width">{{ __('Width') }}</x-ui.label>
                    <x-ui.input id="width" value="100%" />
                </div>
            </div>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-start side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Start') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="start" class="!w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-start edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-end side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('End') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="end" class="!w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-end edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>
</div>
```

## Props

- `group` (string|null, default `null`) — Opt-in coordination key set on the root component: opening a popover closes any other popover sharing the same group name. Leave unset for standalone popovers.
- `anchored` (bool, default `false`) — Opt-in prop on the root: teleport the content panel to <body> and place it with position: fixed against the trigger's measured rect, so an overflow-clipping or scrolling ancestor (a table wrapper, an overflow-hidden card) never cuts it off. Side flip, alignment and viewport clamping are preserved.
- `side` (top|bottom|start|end, default `bottom`) — Preferred panel placement. Start/end mirror under RTL.
- `align` (start|center|end, default `start`) — Documented catalog control used by the preview workbench.
- `arrow` (bool, default `false`) — Declared by @props in the registry Blade source.
- `lazy` (bool, default `false`) — Declared by @props in the registry Blade source.

## Use when

- Use for lightweight contextual details or actions near a trigger.
- Showing compact contextual information or controls anchored to a trigger.
- The user should be able to continue interacting with the page while it is open.

## Avoid when

- Do not use for long content, required reading, or complex interaction flows.
- Keep it compact and anchored to its trigger; upgrade to a sheet, dialog, or page if the content grows.
- Focus must be trapped or the task blocks the page; use dialog.
- The content is only a short label; use tooltip.
- The surface contains application navigation on small screens; use sheet.

## Anti-patterns

- Required reading in a popover
- Complex forms in fragile anchored overlays

## Rules

- Use the `<brok:popover>` tag (or `<x-ui.popover>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/popover
- Registry JSON (files, props, contract): https://brokui.dev/r/open/popover.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

form.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="flex items-center justify-center">
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Edit dimensions') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="bottom" align="start" arrow class="w-80">
            <form method="POST" action="#" class="space-y-4">
                @csrf
                <div class="space-y-1">
                    <h3 class="text-sm font-semibold text-foreground">{{ __('Dimensions') }}</h3>
                    <p class="text-sm text-muted-foreground">{{ __('Set the dimensions for the layer.') }}</p>
                </div>
                <div class="grid grid-cols-3 items-center gap-3">
                    <x-ui.label for="popover-form-width">{{ __('Width') }}</x-ui.label>
                    <x-ui.input id="popover-form-width" name="width" value="100%" class="col-span-2 h-8" />
                </div>
                <div class="grid grid-cols-3 items-center gap-3">
                    <x-ui.label for="popover-form-height">{{ __('Height') }}</x-ui.label>
                    <x-ui.input id="popover-form-height" name="height" value="25rem" class="col-span-2 h-8" />
                </div>
                <div class="flex justify-end gap-2 pt-1">
                    <x-ui.button type="button" variant="ghost" size="sm" @click="close()">{{ __('Cancel') }}</x-ui.button>
                    <x-ui.button type="submit" size="sm">{{ __('Save') }}</x-ui.button>
                </div>
            </form>
        </x-ui.popover.content>
    </x-ui.popover>
</div>
grouped.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

{{--
    A table-row pattern: every row's popover shares one `group`, so opening a
    row's panel closes whichever other row panel was open — never two at
    once — and `lazy` defers each row's panel content until it is actually
    opened, so a full table only ever pays the mount cost for the panels a
    user actually looked at.
--}}
<div class="flex flex-wrap items-center justify-center gap-3" role="table" aria-label="{{ __('Orders') }}">
    @foreach ([1, 2, 3] as $row)
        <x-ui.popover group="grouped-demo-orders">
            <x-ui.popover.trigger :class="$triggerClass">{{ __('Row :row', ['row' => $row]) }}</x-ui.popover.trigger>
            <x-ui.popover.content lazy side="bottom" align="start" id="grouped-demo-panel-{{ $row }}" class="w-64">
                <div x-data="{ count: 0 }" class="space-y-2">
                    <p class="text-sm font-medium text-foreground">{{ __('Row :row actions', ['row' => $row]) }}</p>
                    <p class="text-sm text-muted-foreground">{{ __('Mounted on first open only.') }}</p>
                    <button
                        type="button"
                        @click="count++"
                        class="inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-sm text-foreground hover:bg-muted"
                    >
                        {{ __('Clicked') }}: <span x-text="count" class="ms-1 tabular-nums"></span>
                    </button>
                </div>
            </x-ui.popover.content>
        </x-ui.popover>
    @endforeach
</div>
menu.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')
@php($itemClass = 'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-start text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none')

<div class="flex items-center justify-center">
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Actions') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="bottom" align="start" class="w-52 p-1">
            <div role="menu" aria-label="{{ __('Actions') }}" class="flex flex-col">
                <button type="button" role="menuitem" @click="close()" class="{{ $itemClass }}">{{ __('Rename') }}</button>
                <button type="button" role="menuitem" @click="close()" class="{{ $itemClass }}">{{ __('Duplicate') }}</button>
                <button type="button" role="menuitem" @click="close()" class="{{ $itemClass }}">{{ __('Move to folder') }}</button>
                <x-ui.separator class="my-1" />
                <button type="button" role="menuitem" @click="close()" class="{{ $itemClass }} text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive">{{ __('Delete') }}</button>
            </div>
        </x-ui.popover.content>
    </x-ui.popover>
</div>
placements.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="flex flex-wrap items-center justify-center gap-3">
    {{-- Top, centered, with arrow --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Top') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="top" align="center" arrow class="w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens above the trigger, centered, with a pointer arrow.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Bottom, aligned to end edge --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Bottom end') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="bottom" align="end" arrow class="w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens below the trigger, aligned to the inline-end edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-start side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Start') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="start" align="center" arrow class="w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-start edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>

    {{-- Inline-end side (mirrors under RTL) --}}
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('End') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="end" align="center" arrow class="w-56">
            <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-end edge.') }}</p>
        </x-ui.popover.content>
    </x-ui.popover>
</div>
settings.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="flex items-center justify-center">
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('Notifications') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="bottom" align="end" arrow class="w-80">
            <div class="space-y-4">
                <div class="space-y-1">
                    <h3 class="text-sm font-semibold text-foreground">{{ __('Notification settings') }}</h3>
                    <p class="text-sm text-muted-foreground">{{ __('Choose how you want to be notified.') }}</p>
                </div>
                <div class="space-y-3">
                    <div class="flex items-center justify-between gap-4">
                        <span class="text-sm text-foreground">{{ __('Email digest') }}</span>
                        <x-ui.switch name="email_digest" size="sm" checked />
                    </div>
                    <div class="flex items-center justify-between gap-4">
                        <span class="text-sm text-foreground">{{ __('Push notifications') }}</span>
                        <x-ui.switch name="push" size="sm" />
                    </div>
                    <div class="flex items-center justify-between gap-4">
                        <span class="text-sm text-foreground">{{ __('Product updates') }}</span>
                        <x-ui.switch name="product_updates" size="sm" checked />
                    </div>
                </div>
            </div>
        </x-ui.popover.content>
    </x-ui.popover>
</div>
with-header.blade.php Blade
@php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')

<div class="flex items-center justify-center">
    <x-ui.popover>
        <x-ui.popover.trigger :class="$triggerClass">{{ __('About this layer') }}</x-ui.popover.trigger>
        <x-ui.popover.content side="bottom" align="start" arrow class="w-80 p-0">
            <header class="space-y-1 border-b border-border p-4">
                <h3 class="text-sm font-semibold text-foreground">{{ __('Background layer') }}</h3>
                <p class="text-sm text-muted-foreground">{{ __('Details and quick actions for the selected layer.') }}</p>
            </header>
            <div class="space-y-2 p-4 text-sm text-muted-foreground">
                <p>{{ __('Created on 12 June 2026 by the design system team.') }}</p>
                <p>{{ __('This layer is locked to the admin surface and cannot be reordered.') }}</p>
            </div>
        </x-ui.popover.content>
    </x-ui.popover>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
group string | null null Opt-in coordination key set on the root component: opening a popover closes any other popover sharing the same group name. Leave unset for standalone popovers.
anchored bool false Opt-in prop on the root: teleport the content panel to <body> and place it with position: fixed against the trigger's measured rect, so an overflow-clipping or scrolling ancestor (a table wrapper, an overflow-hidden card) never cuts it off. Side flip, alignment and viewport clamping are preserved.
side top | bottom | start | end bottom Preferred panel placement. Start/end mirror under RTL.
align start | center | end start Documented catalog control used by the preview workbench.
arrow bool false Declared by @props in the registry Blade source.
lazy bool false Declared by @props in the registry Blade source.

Slots

  • default — Primary Blade slot rendered by the component.
  • x-ui.popover.trigger — Installed subcomponent from the registry item.
  • x-ui.popover.content — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

popover popover-arrow popover-content popover-trigger

Behavior

  • Dismisses on Escape and outside interaction without trapping focus.
  • Repositions to remain inside the viewport and returns focus predictably: Escape moves focus to the trigger in the same task, before the panel hides, and a close before the open tick cancels the pending focus and listeners, so a quick re-open never races.
  • Opt-in `group` coordination closes sibling popovers sharing the same group name, restoring focus to its own trigger if it was stranded inside the closed panel.
  • Opt-in `lazy` content defers and re-defers slot mounting to the panel's open state.
  • Opt-in `anchored` mode teleports the panel to <body> and positions it from the trigger's measured rect, escaping overflow-clipping ancestors. The panel is measured at its settled size (not mid enter scale), so near a viewport edge it flips or shifts fully inside.
  • External anchor (anchored mode): the component's showFrom(anchor) opens the panel against any element outside the popover, or moves an open panel to it. Placement, the outside-click exemption and the focus return use that element until the panel closes; an anchor with aria-haspopup gets aria-expanded. property-picker opens this way from property-picker-open.
  • Focus return survives a re-render: when the anchor was replaced (a Livewire morph of the row), focus goes to the element with the anchor's id, else its data-anchor-key, else the first focusable element of the nearest ancestor that carried a data-anchor-key, else the own trigger. A replacement that lands after the close is followed the same way while focus sits on <body>.
  • Binds wire:model (Livewire) or x-model (Alpine) to its open state through x-modelable.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Contextual overlay

Show lightweight contextual details or actions.

Use when

  • Use for lightweight contextual details or actions near a trigger.
  • Showing compact contextual information or controls anchored to a trigger.
  • The user should be able to continue interacting with the page while it is open.

Avoid when

  • Do not use for long content, required reading, or complex interaction flows.
  • Keep it compact and anchored to its trigger; upgrade to a sheet, dialog, or page if the content grows.
  • Focus must be trapped or the task blocks the page; use dialog.
  • The content is only a short label; use tooltip.
  • The surface contains application navigation on small screens; use sheet.

Use instead

  • Inline disclosure
  • Sheet or page for longer content

Anti-patterns

  • Required reading in a popover
  • Complex forms in fragile anchored overlays
Anatomy
root trigger content
Theming hooks
popover content popover arrow

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
managed
  • Ensure interactive content is keyboard reachable.
  • Do not place critical confirmation workflows in a non-modal popover.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="popover-{{ $record->id }}">
    @php($triggerClass = 'inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background')
    
    <div class="w-full flex flex-wrap items-center justify-center gap-3">
        {{-- Bottom (default) --}}
        <x-ui.popover>
            <x-ui.popover.trigger :class="$triggerClass">{{ __('Bottom') }}</x-ui.popover.trigger>
            <x-ui.popover.content>
                <div class="space-y-3">
                    <div class="space-y-1">
                        <p class="text-sm font-medium text-foreground">{{ __('Dimensions') }}</p>
                        <p class="text-sm text-muted-foreground">{{ __('Set the dimensions for the layer.') }}</p>
                    </div>
                    <div class="space-y-2">
                        <x-ui.label for="width">{{ __('Width') }}</x-ui.label>
                        <x-ui.input id="width" value="100%" />
                    </div>
                </div>
            </x-ui.popover.content>
        </x-ui.popover>
    
        {{-- Inline-start side (mirrors under RTL) --}}
        <x-ui.popover>
            <x-ui.popover.trigger :class="$triggerClass">{{ __('Start') }}</x-ui.popover.trigger>
            <x-ui.popover.content side="start" class="!w-56">
                <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-start edge.') }}</p>
            </x-ui.popover.content>
        </x-ui.popover>
    
        {{-- Inline-end side (mirrors under RTL) --}}
        <x-ui.popover>
            <x-ui.popover.trigger :class="$triggerClass">{{ __('End') }}</x-ui.popover.trigger>
            <x-ui.popover.content side="end" class="!w-56">
                <p class="text-sm text-muted-foreground">{{ __('Opens toward the inline-end edge.') }}</p>
            </x-ui.popover.content>
        </x-ui.popover>
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:popover
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/popover.blade.php Blade
@props([
    'group' => null,
    'anchored' => false,
])

@php
    // `group` (opt-in, default null): coordinate multiple popovers so opening
    // one closes any sibling sharing the same group name — a table of rows
    // each with its own popover should never show two open panels at once.
    // Ungrouped popovers never dispatch or listen for the coordination event,
    // so this reduces to the original behaviour byte-for-byte when omitted.
    $groupArg = $group !== null && $group !== '' ? json_encode($group) : 'null';
    $dataCall = $anchored ? 'uiPopover('.$groupArg.', true)' : ($groupArg === 'null' ? 'uiPopover()' : 'uiPopover('.$groupArg.')');

    // `anchored` (opt-in, default false): the content panel teleports to
    // <body> and is placed against the trigger's measured rect, so a
    // clipping/scrolling ancestor (a table's overflow wrapper, an
    // `overflow-hidden` card) can never cut it off. The panel is then no
    // longer a descendant of this root, so outside-click detection extends
    // to the teleported `$refs.panel` — otherwise a click inside it would
    // read as "outside" and close the popover instantly. A click on an
    // external anchor (showFrom()) is not outside either, so the element
    // that opened the panel can close it again.
    $outsideClick = $anchored ? 'if (isOutside($event.target)) close()' : 'close()';
@endphp

{{--
    Popover (spec §17 + §19). Non-modal floating panel toggled by its trigger.
    Click-outside and Escape close it; focus returns to the trigger on Escape.
--}}
<div
    x-data="{{ $dataCall }}"
    x-modelable="open"
    x-id="['popover-trigger']"
    data-slot="popover"
    @keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
    @click.outside="{{ $outsideClick }}"
    {{ $attributes->merge(['class' => 'relative inline-block']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/popover/trigger.blade.php Blade
<button
    type="button"
    data-slot="popover-trigger"
    x-ref="trigger"
    @unless ($attributes->has('id')) x-bind:id="$id('popover-trigger')" @endunless
    :aria-expanded="open"
    @click="toggle()"
    {{ $attributes->merge(['class' => 'inline-flex items-center justify-center']) }}
>
    {{ $slot }}
</button>
resources/views/components/ui/popover/content.blade.php Blade
@aware([
    'anchored' => false,
])

@props([
    'side' => 'bottom',
    'align' => 'start',
    'arrow' => false,
    'lazy' => false,
])

@php
    // Logical placement so left/right mirror under RTL: `start` opens toward the
    // inline-start edge, `end` toward the inline-end edge.
    $position = match ($side) {
        'top' => 'bottom-full mb-2',
        'start' => 'end-full me-2 top-0',
        'end' => 'start-full ms-2 top-0',
        default => 'top-full mt-2',
    };

    $isVertical = in_array($side, ['top', 'bottom'], true);

    // Anchor the enter/leave scale to the trigger edge. Tailwind has no logical
    // transform-origin utility, so start/end map to physical edges with an
    // rtl: override so the anchor mirrors under dir="rtl".
    $origin = match ($side) {
        'top' => 'origin-bottom',
        'start' => 'origin-right rtl:origin-left',
        'end' => 'origin-left rtl:origin-right',
        default => 'origin-top',
    };

    // Enter/leave offset: the panel starts nudged toward the trigger and settles
    // into place, so it reads as growing out of it. The offset axis is always
    // perpendicular to the alignment translate axis, so centered alignment
    // (`-translate-x-1/2` / `-translate-y-1/2`) survives the transition.
    $offsetStart = match ($side) {
        'top' => 'motion-safe:translate-y-1',
        'start' => 'motion-safe:translate-x-1 rtl:motion-safe:-translate-x-1',
        'end' => 'motion-safe:-translate-x-1 rtl:motion-safe:translate-x-1',
        default => 'motion-safe:-translate-y-1',
    };
    $offsetEnd = $isVertical ? 'motion-safe:translate-y-0' : 'motion-safe:translate-x-0';

    // For top/bottom popovers, align the panel to the trigger's start/center/end
    // edge along the inline axis. For start/end popovers, align along the block axis.
    $alignment = match (true) {
        $isVertical && $align === 'center' => 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2',
        $isVertical && $align === 'end' => 'end-0',
        $isVertical => 'start-0',
        ! $isVertical && $align === 'center' => 'top-1/2 -translate-y-1/2',
        ! $isVertical && $align === 'end' => 'bottom-0 top-auto',
        default => 'top-0',
    };

    // Arrow: a rotated square sharing the panel's surface + border. It sits on the
    // edge facing the trigger; only the two outward-facing borders show, so it reads
    // as a clean pointer. Logical insets keep it mirrored under RTL.
    $arrowEdge = match ($side) {
        'top' => 'bottom-[-5px] border-b border-e',
        'start' => 'end-[-5px] border-t border-e',
        'end' => 'start-[-5px] border-b border-s',
        default => 'top-[-5px] border-t border-s',
    };
    $arrowAlign = match (true) {
        $isVertical && $align === 'center' => 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2',
        $isVertical && $align === 'end' => 'end-4',
        $isVertical => 'start-4',
        ! $isVertical && $align === 'center' => 'top-1/2 -translate-y-1/2',
        ! $isVertical && $align === 'end' => 'bottom-4',
        default => 'top-4',
    };

    // `lazy` (opt-in, default false): the slot is only stamped into the DOM
    // while `open` is true, tied directly to the same state the panel's own
    // `x-show` uses — not a separate "ever opened" flag. On a table with many
    // rows this keeps at most a handful of panels' worth of content mounted
    // at once instead of accumulating one per row ever opened during a long
    // session; the trade-off is that reopening re-runs the slot's own
    // `init()`/`x-data` and loses any in-panel-only state
    // (typed-but-unsubmitted form fields, scroll position). Content that
    // must survive a close should keep its state outside the panel (e.g. a
    // wire:model-bound property) rather than opting into `lazy`. `x-if` runs
    // inside this component's own `x-data` scope, so `close()` and friends
    // remain reachable from the lazily-mounted slot.
    //
    // The `@if`/`@else`/`@endif` markup below is deliberately un-indented:
    // Blade only strips a directive line's own leading whitespace from the
    // branch it belongs to, so an indented `@else`/`@endif` would leak stray
    // spaces into the plain (non-lazy) output and break the byte-identical
    // default render.
    // One class string, merged through the attribute bag rather than printed by
    // `@class` and then followed by `$attributes->except('class')`. That older
    // shape existed because `@class` emits its own literal `class="..."`, so an
    // unfiltered bag would have produced a second `class` attribute on the same
    // tag — but dodging that by dropping every consumer class meant a caller's
    // width or `max-h` silently did nothing, which is the worse failure.
    // Anchored mode (see popover.blade.php): no inset/alignment utilities —
    // `popover.js`'s `reposition()` sets `position: fixed` top/left from the
    // trigger's measured rect and stamps the resolved `data-side`, which the
    // origin variants read so the enter/leave scale still grows out of the
    // trigger after a flip.
    $panelClasses = $anchored
        ? 'fixed z-popover w-72 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none motion-reduce:transition-none origin-top data-[side=top]:origin-bottom data-[side=start]:origin-right data-[side=end]:origin-left rtl:data-[side=start]:origin-left rtl:data-[side=end]:origin-right'
        : trim(implode(' ', array_filter([
            'absolute z-popover w-72 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none motion-reduce:transition-none',
            $origin,
            $position,
            $alignment,
        ])));
@endphp

@if ($anchored)
<template x-teleport="body">
@endif

<div
    data-slot="popover-content"
    data-side="{{ $side }}"
    data-align="{{ $align }}"
    role="dialog"
    {{-- A dialog needs a name: by default the panel is named by its trigger;
         an explicit aria-label or aria-labelledby on the content wins. --}}
    @unless ($attributes->hasAny(['aria-label', 'aria-labelledby'])) x-bind:aria-labelledby="$refs.trigger?.id || null" @endunless
    x-ref="panel"
    x-show="open"
    x-cloak
    x-transition:enter="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
    x-transition:enter-start="opacity-0 motion-safe:scale-95 {{ $offsetStart }} motion-safe:blur-[2px]"
    x-transition:enter-end="opacity-100 motion-safe:scale-100 {{ $offsetEnd }} motion-safe:blur-0"
    x-transition:leave="motion-safe:transition motion-safe:duration-100 motion-safe:ease-in"
    x-transition:leave-start="opacity-100 motion-safe:scale-100 {{ $offsetEnd }} motion-safe:blur-0"
    x-transition:leave-end="opacity-0 motion-safe:scale-95 {{ $offsetStart }} motion-safe:blur-[2px]"
    {{ $attributes->merge(['class' => $panelClasses]) }}
>
    @if ($arrow)
        <span
            aria-hidden="true"
            data-slot="popover-arrow"
            @class(['pointer-events-none absolute z-[-1] h-2 w-2 rotate-45 rounded-[2px] border-border bg-popover', $arrowEdge, $arrowAlign])
        ></span>
    @endif

@if ($lazy)
        <template x-if="open">
            {{ $slot }}
        </template>
@else
    {{ $slot }}
@endif
</div>
@if ($anchored)
</template>
@endif
resources/js/ui/popover.js JS
/**
 * Popover behavior (spec §19). A lightweight, non-modal floating panel.
 * Opening moves focus to the first focusable element inside the panel (if any)
 * for keyboard users; Escape closes and returns focus to the trigger.
 *
 * v1.1.0 adds collision-aware placement: the `side` declared on the content is
 * the PREFERRED side, but at open time we measure the trigger rect, the panel
 * size and the viewport and FLIP to the opposite side if the preferred side
 * would overflow, then clamp the cross-axis so the panel stays on screen. This
 * is pure vanilla geometry (getBoundingClientRect + viewport math) applied via
 * an inline `left` and the CSS `translate` longhand (see applyTranslate), so it
 * layers on top of the Blade Tailwind positioning without clobbering its
 * `transform`-based centering. RTL is honored
 * by resolving the logical `start`/`end` sides against the document direction.
 *
 * v1.4.0 adds an opt-in `group` (see popover.blade.php): opening a popover
 * dispatches a `ui-popover:opened` window event carrying its group name and a
 * per-instance id; every other popover sharing that group name closes itself
 * on hearing it, so a table of rows can never show two open panels at once.
 * Ungrouped popovers (the default) never attach the listener or dispatch the
 * event. If focus was inside the panel being auto-closed (rather than on the
 * sibling that just opened), it is returned to this popover's own trigger
 * exactly like an Escape dismissal, so it never lands on a removed node.
 *
 * v1.7.0 adds an external anchor: `showFrom(anchor)` opens the panel against
 * any element (a row's property chip) instead of the own trigger. Placement,
 * the outside-click exemption and the focus return all use that element
 * until the panel closes; an anchor with aria-haspopup gets aria-expanded.
 * Anchored placement now measures the panel without its enter scale, so a
 * panel near the viewport edge no longer ends a few pixels past it.
 *
 * v1.8.0 keeps the focus return working across a re-render (a Livewire
 * morph that replaces the row the anchor sat in). When the panel opens from
 * an anchor it records the anchor's identity; on a focus-returning close
 * (Escape, Tab, a pick) focus goes to the first of, in this order:
 *   1. the anchor itself, while it is still in the document;
 *   2. the element with the anchor's `id`;
 *   3. the element with the anchor's `data-anchor-key`;
 *   4. the nearest ancestor of the anchor that carried a `data-anchor-key`
 *      and still exists (found by that key): the element in it with the
 *      anchor's `data-slot` (v1.8.1), else its first tabbable element, else
 *      its first focusable one, else the ancestor itself when focusable;
 *   5. the popover's own trigger.
 * The re-render usually lands after the close (the pick's round trip), so
 * the same lookup runs again whenever the element that received focus
 * leaves the document and focus falls back to <body>. That watch ends when
 * focus moves anywhere else, or after 10 seconds.
 */
document.addEventListener('alpine:init', () => {
    let popoverInstanceSeq = 0;

    window.Alpine.data('uiPopover', (group = null, anchored = false) => ({
        open: false,
        group,
        // Anchored mode: the panel is teleported to <body> and placed with
        // `position: fixed` from the trigger's measured rect (see reposition()).
        anchored,
        _instanceId: `popover-${++popoverInstanceSeq}`,
        // Bumped on every open and close. The deferred open work checks it,
        // so a close (or a close and re-open) before the next tick cancels it.
        _openSeq: 0,
        // Bound listeners so we can add on open and remove on close/destroy.
        _onReposition: null,
        _onKeydown: null,
        _onGroupOpen: null,
        // Anchored mode re-places the panel when its own size changes (a list
        // inside it renders after open, a section unfolds).
        _resizeObserver: null,
        // An element outside the popover the panel opened from (showFrom()).
        // Null: the own trigger. Cleared when the panel closes.
        _anchor: null,
        // How to find the anchor again after a re-render replaced it
        // (see anchorIdentity()). Cleared when the panel closes.
        _anchorIdentity: null,
        _onOutsidePointer: null,
        _rootEl: null,

        init() {
            this._rootEl = this.$el;
            if (!this.group) {
                return;
            }
            this._onGroupOpen = (event) => {
                const detail = event.detail || {};
                if (detail.group !== this.group || detail.id === this._instanceId || !this.open) {
                    return;
                }
                // A sibling in the same group just opened. Close, but only
                // steal focus back to our own trigger if focus was actually
                // stranded inside the panel we are about to hide/unmount —
                // otherwise leave focus on whatever the user just interacted
                // with (typically the sibling's trigger).
                if (this.$refs.panel?.contains(document.activeElement)) {
                    this.closeAndFocus();
                } else {
                    this.close();
                }
            };
            window.addEventListener('ui-popover:opened', this._onGroupOpen);
        },

        toggle() {
            this.open ? this.close() : this.show();
        },

        /**
         * Open against `anchor`, an element outside the popover, or move an
         * open panel to it. Placement, the outside-click exemption and the
         * focus return use the anchor until the panel closes. Without an
         * element this is show().
         */
        showFrom(anchor = null) {
            const next = anchor instanceof Element && anchor.isConnected ? anchor : null;
            if (next !== this._anchor) {
                this.setAnchorExpanded(false);
                this._anchor = next;
                this._anchorIdentity = next ? anchorIdentity(next) : null;
                this.setAnchorExpanded(this.open);
            }
            if (this.open) {
                this.$nextTick(() => this.reposition());

                return;
            }
            this.show();
            this.setAnchorExpanded(true);
            this.watchOutsideClicks(this._openSeq);
        },

        /**
         * Opened from an external anchor, the root can have no size (a picker
         * without its own trigger), and Alpine's click.outside skips a root
         * without size. Listen on the document instead, from the next task on,
         * so the click that opened the panel never closes it.
         */
        watchOutsideClicks(openSeq) {
            setTimeout(() => {
                if (!this.open || openSeq !== this._openSeq || this._onOutsidePointer) return;
                this._onOutsidePointer = (event) => {
                    if (this.open && this.isOutside(event.target) && !this._rootEl?.contains(event.target)) this.close();
                };
                document.addEventListener('click', this._onOutsidePointer);
            }, 0);
        },

        /** The element the panel is placed against and returns focus to. */
        anchorEl() {
            return this._anchor?.isConnected ? this._anchor : (this.$refs.trigger ?? null);
        },

        /** True when a click on `target` is outside the panel and its anchor. */
        isOutside(target) {
            return !this.$refs.panel?.contains(target) && !this._anchor?.contains(target);
        },

        /** An external anchor that announces a popup reports the open state. */
        setAnchorExpanded(expanded) {
            if (this._anchor?.hasAttribute('aria-haspopup')) {
                this._anchor.setAttribute('aria-expanded', expanded ? 'true' : 'false');
            }
        },

        show() {
            // Idempotent: a second show() (hover + click triggers, or a programmatic
            // call while open) must not stack another window keydown listener.
            if (this.open) return;

            // Install Escape handling before `open` changes. The panel becomes
            // visible before Alpine's next tick; waiting until that tick leaves
            // a small window where a fast keyboard dismissal can be missed.
            this._onKeydown = (event) => {
                if (event.key !== 'Escape' || !this.open) return;
                event.preventDefault();
                event.stopPropagation();
                this.closeAndFocus();
            };
            window.addEventListener('keydown', this._onKeydown, true);
            this.open = true;
            const openSeq = ++this._openSeq;
            if (this.group) {
                window.dispatchEvent(
                    new CustomEvent('ui-popover:opened', {
                        detail: { group: this.group, id: this._instanceId },
                    })
                );
            }
            this.$nextTick(() => {
                // Closed (or closed and opened again) before this tick: do not
                // attach listeners to a hidden panel or pull focus into it.
                if (!this.open || openSeq !== this._openSeq) {
                    return;
                }
                this.reposition();

                this._onReposition = () => this.reposition();
                window.addEventListener('resize', this._onReposition, { passive: true });
                window.addEventListener('scroll', this._onReposition, {
                    passive: true,
                    capture: true,
                });
                // A direction switch mirrors the layout (and what start/end
                // mean) under an open panel, so measure again.
                if (typeof MutationObserver !== 'undefined') {
                    this._dirObserver = new MutationObserver(() => this.reposition());
                    this._dirObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] });
                }
                const panel = this.$refs.panel;
                if (this.anchored && panel && typeof ResizeObserver !== 'undefined') {
                    this._resizeObserver = new ResizeObserver(() => this.reposition());
                    this._resizeObserver.observe(panel);
                }
                const focusable = panel?.querySelector(
                    'a[href], button:not([disabled]), textarea, input:not([disabled]), select, [tabindex]:not([tabindex="-1"])'
                );
                focusable?.focus();
            });
        },

        close() {
            this.open = false;
            this._openSeq++;
            this.setAnchorExpanded(false);
            this._anchor = null;
            this._anchorIdentity = null;
            // Keep the nudge through the leave fade: undoing it here moved the
            // panel back past the viewport edge while it faded. reposition()
            // clears it before the next measurement.
            this.teardownReposition();
        },

        /**
         * Anchored placement: put the teleported, `position: fixed` panel on
         * the preferred logical side of the trigger, flip to the opposite side
         * when that would overflow the viewport and the other side has more
         * room, align along the trigger edge (`data-align`) and clamp into the
         * viewport as a last resort. Mirrors `dropdown.js`.
         */
        repositionAnchored(panel, trigger) {
            const isRtl = getComputedStyle(panel).direction === 'rtl';
            // `data-side` is rewritten with the resolved side after a flip, so
            // the declared preference is remembered separately on first use.
            const declared = (panel.dataset.preferredSide ??= panel.getAttribute('data-side') || 'bottom');
            const align = panel.getAttribute('data-align') || 'start';
            const sideMap = { top: 'top', bottom: 'bottom', start: isRtl ? 'right' : 'left', end: isRtl ? 'left' : 'right' };
            let side = sideMap[declared] || 'bottom';
            const vw = document.documentElement.clientWidth;
            const vh = document.documentElement.clientHeight;
            const margin = 8;
            const gap = 8; // matches the in-flow mode's `mt-2` trigger gap

            const t = trigger.getBoundingClientRect();
            // The layout size, not the painted one: during the enter scale the
            // painted box is smaller, and a clamp from it let the settled panel
            // end past the viewport edge.
            const painted = panel.getBoundingClientRect();
            const r = { width: panel.offsetWidth || painted.width, height: panel.offsetHeight || painted.height };
            const alignStart = (align === 'start') !== isRtl;
            let top;
            let left;

            if (side === 'top' || side === 'bottom') {
                if (side === 'bottom' && t.bottom + gap + r.height + margin > vh && t.top > vh - t.bottom) side = 'top';
                else if (side === 'top' && t.top - gap - r.height < margin && vh - t.bottom > t.top) side = 'bottom';
                top = side === 'bottom' ? t.bottom + gap : t.top - r.height - gap;
                left = align === 'center' ? t.left + t.width / 2 - r.width / 2 : alignStart ? t.left : t.right - r.width;
            } else {
                if (side === 'right' && t.right + gap + r.width + margin > vw && t.left > vw - t.right) side = 'left';
                else if (side === 'left' && t.left - gap - r.width < margin && vw - t.right > t.left) side = 'right';
                left = side === 'right' ? t.right + gap : t.left - r.width - gap;
                top = align === 'center' ? t.top + t.height / 2 - r.height / 2 : align === 'start' ? t.top : t.bottom - r.height;
            }

            top = Math.min(Math.max(top, margin), Math.max(margin, vh - r.height - margin));
            left = Math.min(Math.max(left, margin), Math.max(margin, vw - r.width - margin));

            panel.style.position = 'fixed';
            panel.style.top = `${Math.round(top)}px`;
            panel.style.left = `${Math.round(left)}px`;
            const logical = { top: 'top', bottom: 'bottom', left: isRtl ? 'end' : 'start', right: isRtl ? 'start' : 'end' };
            panel.dataset.side = logical[side];
        },

        /**
         * Close and return focus to the trigger. Focus moves synchronously,
         * before the panel hides: a deferred focus left a gap in which a fast
         * key press still reached the closing panel, or a quick re-open ran
         * its own deferred focus in the wrong order.
         */
        closeAndFocus() {
            if (!this.open) {
                return;
            }
            const identity = this._anchorIdentity;
            const target = (this._anchor?.isConnected ? this._anchor : null)
                ?? (identity ? findAnchor(identity) : null)
                ?? this.$refs.trigger
                ?? null;
            target?.focus();
            this.close();
            if (identity && target && document.activeElement === target) {
                watchFocusReturn(target, identity);
            }
        },

        teardownReposition() {
            if (this._onOutsidePointer) {
                document.removeEventListener('click', this._onOutsidePointer);
                this._onOutsidePointer = null;
            }
            if (this._dirObserver) {
                this._dirObserver.disconnect();
                this._dirObserver = null;
            }
            if (this._resizeObserver) {
                this._resizeObserver.disconnect();
                this._resizeObserver = null;
            }
            if (this._onReposition) {
                window.removeEventListener('resize', this._onReposition);
                window.removeEventListener('scroll', this._onReposition, { capture: true });
                this._onReposition = null;
            }
            if (this._onKeydown) {
                window.removeEventListener('keydown', this._onKeydown, true);
                this._onKeydown = null;
            }
        },

        /**
         * Measure the trigger + panel + viewport and nudge/flip the panel so it
         * stays visible. We only apply an inline `left` (horizontal) and
         * `translate` (vertical); the base placement comes from the content's
         * Tailwind classes.
         */
        reposition() {
            const panel = this.$refs.panel;
            const trigger = this.anchored ? this.anchorEl() : this.$refs.trigger;
            if (!panel || !trigger || !this.open) {
                return;
            }

            if (this.anchored) {
                this.repositionAnchored(panel, trigger);
                return;
            }

            // Resolve the preferred (logical) side into a physical side.
            const isRtl = getComputedStyle(panel).direction === 'rtl';
            const declared = panel.getAttribute('data-side') || 'bottom';
            const sideMap = {
                top: 'top',
                bottom: 'bottom',
                start: isRtl ? 'right' : 'left',
                end: isRtl ? 'left' : 'right',
            };
            const side = sideMap[declared] || 'bottom';

            // Reset any prior nudge, then measure once. The panel transitions
            // `translate` and `scale`, so a rect read after setting a new
            // translate (or during the enter scale) is stale: the flip and the
            // clamp are computed from one unscaled layout box instead.
            applyTranslate(panel, 0, 0);

            const vw = document.documentElement.clientWidth;
            const vh = document.documentElement.clientHeight;
            const margin = 8; // keep a small gutter from the viewport edge

            // The un-nudged panel can sit past the viewport edge. Under RTL
            // that overflow is scrollable to the left, and the browser keeps
            // the physical offset, so the page reads as scrolled (scrollX < 0)
            // and every rect shifts. Measure x against the unscrolled page;
            // once the panel fits, the overflow and the scroll go away.
            const sx = window.scrollX || 0;
            const t = shiftX(trigger.getBoundingClientRect(), sx);
            const r = shiftX(layoutRect(panel), sx);

            let dx = 0;
            let dy = 0;

            if (side === 'top' || side === 'bottom') {
                // Flip top<->bottom if the preferred side overflows AND the
                // opposite side has more room.
                const overflows = side === 'bottom' ? r.bottom + margin > vh : r.top - margin < 0;
                if (overflows && side === 'bottom' && t.top > vh - t.bottom) {
                    dy = -(t.height + r.height + (r.top - t.bottom) * 2);
                } else if (overflows && side === 'top' && vh - t.bottom > t.top) {
                    dy = t.height + r.height + (t.top - r.bottom) * 2;
                }
            } else {
                // Flip left<->right if the preferred side overflows AND the
                // opposite side has more room.
                const overflows = side === 'right' ? r.right + margin > vw : r.left - margin < 0;
                if (overflows && side === 'right' && t.left > vw - t.right) {
                    dx = -(t.width + r.width + (r.left - t.right) * 2);
                } else if (overflows && side === 'left' && vw - t.right > t.left) {
                    dx = t.width + r.width + (t.left - r.right) * 2;
                }
            }

            // Clamp both axes into the viewport as a last resort. Clamping only
            // the cross axis left a start/end panel off-screen on a phone, where
            // neither side of the trigger fits the panel; it may then overlap
            // the trigger, as the anchored path already allows.
            if (r.left + dx < margin) {
                dx = margin - r.left;
            } else if (r.right + dx > vw - margin) {
                dx = vw - margin - r.right;
            }
            if (r.top + dy < margin) {
                dy = margin - r.top;
            } else if (r.bottom + dy > vh - margin) {
                dy = vh - margin - r.bottom;
            }

            applyTranslate(panel, dx, dy);
        },

        destroy() {
            this.teardownReposition();
            if (this._onGroupOpen) {
                window.removeEventListener('ui-popover:opened', this._onGroupOpen);
                this._onGroupOpen = null;
            }
        },
    }));

    /**
     * What identifies an anchor across a re-render: its `id`, its
     * `data-anchor-key`, and the keys of its ancestors that carry a
     * `data-anchor-key` (nearest first).
     */
    function anchorIdentity(anchor) {
        const scopes = [];
        for (let node = anchor.parentElement; node; node = node.parentElement) {
            const key = node.getAttribute('data-anchor-key');
            if (key) scopes.push(key);
        }

        return {
            id: anchor.id || null,
            key: anchor.getAttribute('data-anchor-key') || null,
            slot: anchor.getAttribute('data-slot') || null,
            scopes,
        };
    }

    const byKey = (key) => document.querySelector(`[data-anchor-key="${CSS.escape(key)}"]`);
    const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]';
    const rendered = (el) => el.getClientRects().length > 0;

    /**
     * The element to focus for a recorded anchor identity, or null (order in the file header).
     * @returns {HTMLElement | null}
     */
    function findAnchor(identity) {
        const own = /** @type {HTMLElement | null} */ ((identity.id && document.getElementById(identity.id)) || (identity.key && byKey(identity.key)) || null);
        if (own) return own;
        for (const key of identity.scopes) {
            const scope = byKey(key);
            if (!scope) continue;
            const candidates = /** @type {HTMLElement[]} */ ([...scope.querySelectorAll(FOCUSABLE)].filter(rendered));
            const sameSlot = identity.slot
                ? candidates.find((el) => el.getAttribute('data-slot') === identity.slot)
                : null;

            return sameSlot
                ?? candidates.find((el) => el.tabIndex >= 0)
                ?? candidates[0]
                ?? (scope.hasAttribute('tabindex') ? /** @type {HTMLElement} */ (scope) : null);
        }

        return null;
    }

    // One watch at a time: the latest focus return wins.
    let focusWatch = null;

    /**
     * Keep the returned focus through a re-render: when `target` leaves the
     * document while it held focus (focus is now on <body>), focus the
     * element the identity resolves to in the new DOM. Ends when focus moves
     * to anything else, or after 10 seconds.
     */
    function watchFocusReturn(target, identity) {
        focusWatch?.stop();
        if (typeof MutationObserver === 'undefined') return;
        const watch = { target };
        const onFocusIn = (event) => {
            if (event.target !== watch.target) watch.stop();
        };
        const observer = new MutationObserver(() => {
            if (watch.target.isConnected) return;
            const active = document.activeElement;
            if (active && active !== document.body && active !== document.documentElement) {
                watch.stop();

                return;
            }
            const next = findAnchor(identity);
            if (!next) return;
            watch.target = next;
            next.focus();
        });
        const timer = setTimeout(() => watch.stop(), 10000);
        watch.stop = () => {
            observer.disconnect();
            clearTimeout(timer);
            document.removeEventListener('focusin', onFocusIn, true);
            if (focusWatch === watch) focusWatch = null;
        };
        observer.observe(document.body, { childList: true, subtree: true });
        document.addEventListener('focusin', onFocusIn, true);
        focusWatch = watch;
    }

    /**
     * Nudge the panel by (dx, dy). The horizontal nudge moves the layout box
     * with an inline `left`: a translate only moves the painted box, so on a
     * phone the untranslated box past the edge widened the layout viewport
     * while an end/start panel was open. The vertical nudge is an inline
     * translate, which replaces the class one, so a centred panel keeps its
     * -50% (+50% under RTL) in the value. Both leave the transitioned
     * properties so they apply at once instead of sliding the panel.
     */
    function applyTranslate(panel, dx, dy) {
        if (dx === 0 && dy === 0) {
            // Only undo our own nudge, and without animating it back, so the
            // next measurement is not stale; an un-nudged open keeps its motion.
            if (panel.style.translate || panel.style.left) {
                panel.style.transitionProperty = 'opacity, scale, filter';
                panel.style.translate = '';
                panel.style.left = '';
                panel.style.right = '';
            }
            return;
        }
        panel.style.transitionProperty = 'opacity, scale, filter';
        if (dx !== 0) {
            // offsetLeft is the border box against the containing block and
            // ignores transforms; `left` places the margin box.
            const marginLeft = parseFloat(getComputedStyle(panel).marginLeft) || 0;
            panel.style.left = `${panel.offsetLeft - marginLeft + dx}px`;
            panel.style.right = 'auto';
        }
        if (dy !== 0) {
            const centred = panel.getAttribute('data-align') === 'center';
            const vertical = ['top', 'bottom'].includes(panel.getAttribute('data-side') || 'bottom');
            const rtl = getComputedStyle(panel).direction === 'rtl';
            const x = centred && vertical ? (rtl ? '50%' : '-50%') : '0px';
            const y = centred && !vertical ? `calc(-50% + ${dy}px)` : `${dy}px`;
            panel.style.translate = `${x} ${y}`;
        }
    }

    function shiftX(rect, dx) {
        return {
            left: rect.left + dx,
            right: rect.right + dx,
            top: rect.top,
            bottom: rect.bottom,
            width: rect.width,
            height: rect.height,
        };
    }

    /**
     * The panel's border box without the enter/leave `scale`: a rect measured
     * mid-transition is scaled about the transform origin, so undo that. The
     * 4px enter offset is left in: removing it would cancel the open motion
     * for every popover, and it only narrows a last-resort gutter to 4px.
     */
    function layoutRect(panel) {
        const rect = panel.getBoundingClientRect();
        const width = panel.offsetWidth || rect.width;
        const height = panel.offsetHeight || rect.height;
        const sx = width ? rect.width / width : 1;
        const sy = height ? rect.height / height : 1;
        const [ox, oy] = getComputedStyle(panel).transformOrigin.split(' ').map(parseFloat);
        const left = rect.left - (ox || 0) * (1 - sx);
        const top = rect.top - (oy || 0) * (1 - sy);
        return { left, top, width, height, right: left + width, bottom: top + height };
    }
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.8.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0