Popover
Non-modal floating panel anchored to a trigger, with an animated entrance, collision-aware placement, click-outside and Escape dismissal.
Preview
Dimensions
Set the dimensions for the layer.
Opens toward the inline-start edge.
Opens toward the inline-end edge.
@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
Align options
Installation
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:
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.
-
resources/views/components/ui/popover.blade.php -
resources/views/components/ui/popover/trigger.blade.php -
resources/views/components/ui/popover/content.blade.php -
resources/js/ui/popover.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
@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>
@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>
@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
@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>
@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
@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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
<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>
@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
/**
* 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