Hover Card
Shows rich content in a card when a trigger is hovered or focused. A `preview` slot turns it into an inline component preview with a stage and caption; the card opens below or above the trigger.
Preview
Jane Doe @janedoe
Joined December 2021
{{-- A mention in running text: hover or focus the handle to see who it is. The
wrapper is a div, not a <p>: the card panel is block content, and a <p>
would be auto-closed by the parser before it. --}}
<div class="text-sm text-muted-foreground">
<span>{{ __('Created by') }}</span>
<x-ui.hover-card>
<x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
<x-ui.hover-card.content class="w-72">
<div class="flex gap-3">
<x-ui.avatar fallback="JD" size="md" class="shrink-0" />
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
<div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
<p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
{{ __('Joined December 2021') }}
</p>
</div>
</div>
</x-ui.hover-card.content>
</x-ui.hover-card>
<span>{{ __('a few minutes ago.') }}</span>
</div>
Installation
php artisan ui:add hover-card
Note
This component ships an Alpine behavior module at
resources/js/ui/hover-card.js. Import it once from your bundle so it registers on alpine:init:
import './hover-card.js';
Registry contract
php artisan ui:add hover-card
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/hover-card.blade.php -
resources/views/components/ui/hover-card/trigger.blade.php -
resources/views/components/ui/hover-card/content.blade.php -
resources/js/ui/hover-card.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: Hover Card (`hover-card`)
Shows rich content in a card when a trigger is hovered or focused. A `preview` slot turns it into an inline component preview with a stage and caption; the card opens below or above the trigger.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add hover-card
```
## Usage
```blade
{{-- A mention in running text: hover or focus the handle to see who it is. The
wrapper is a div, not a <p>: the card panel is block content, and a <p>
would be auto-closed by the parser before it. --}}
<div class="text-sm text-muted-foreground">
<span>{{ __('Created by') }}</span>
<x-ui.hover-card>
<x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
<x-ui.hover-card.content class="w-72">
<div class="flex gap-3">
<x-ui.avatar fallback="JD" size="md" class="shrink-0" />
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
<div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
<p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
{{ __('Joined December 2021') }}
</p>
</div>
</div>
</x-ui.hover-card.content>
</x-ui.hover-card>
<span>{{ __('a few minutes ago.') }}</span>
</div>
```
## Props
- `group` (string|null, default `null`) — Opt-in coordination key set on the root component: opening a hover card closes any other hover card sharing the same group name. Leave unset for standalone hover cards.
- `label` (string|null, default `null`) — Caption title shown under the preview stage when the preview slot is used.
- `description` (string|null, default `null`) — Caption description shown under the label when the preview slot is used.
- `side` (top|bottom, default `bottom`) — Which side of the trigger the card opens on.
- `name` (string|null, default `null`) — A name the window events hover-card:open, hover-card:close and hover-card:toggle address with { name }, for a keyboard peek (a list row's Space handler). The card opens at once and focus stays where it is.
- `openDelay` (int, default `600`) — Hover delay in milliseconds before the card opens.
- `closeDelay` (int, default `250`) — Delay in milliseconds before the card closes after the pointer leaves.
- `lazy` (bool, default `false`) — Declared by @props in the registry Blade source.
- `flush` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use for lightweight contextual details or actions near a trigger.
- Revealing rich, non-critical content (a profile summary, a component preview) when a trigger is hovered or focused.
- Needing an inline component preview with a stage and caption via the preview slot.
## Avoid when
- Do not use for long content, required reading, or complex interaction flows.
- The content is plain short text; use tooltip instead.
- The content must be reachable without hover or focus, such as on touch-only devices with no equivalent tap path; use popover.
## Anti-patterns
- Required reading in a popover
- Complex forms in fragile anchored overlays
## Rules
- Use the `<brok:hover-card>` tag (or `<x-ui.hover-card>`) 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/hover-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/hover-card.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{--
Two mentions sharing one `group`: focusing or hovering one card closes the
other, so scanning past several mentions never leaves two cards open at
once. `lazy` defers each card's content until it is actually opened; the
nested `x-data` counter proves that lazily-mounted content still
initialises its own Alpine behaviour correctly.
--}}
<div class="text-sm text-muted-foreground">
{{ __('Reviewed by') }}
<x-ui.hover-card group="grouped-demo-mentions">
<x-ui.hover-card.trigger class="font-medium text-foreground">
{{ __('@alex') }}
</x-ui.hover-card.trigger>
<x-ui.hover-card.content lazy id="grouped-demo-card-alex">
<div x-data="{ count: 0 }" class="space-y-2">
<p class="text-sm font-semibold text-popover-foreground">{{ __('Alex Rivera') }}</p>
<div class="text-sm text-muted-foreground">{{ __('Reviews checkout and payments changes.') }}</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.hover-card.content>
</x-ui.hover-card>
{{ __('and') }}
<x-ui.hover-card group="grouped-demo-mentions">
<x-ui.hover-card.trigger class="font-medium text-foreground">
{{ __('@sam') }}
</x-ui.hover-card.trigger>
<x-ui.hover-card.content lazy id="grouped-demo-card-sam">
<div class="space-y-1">
<p class="text-sm font-semibold text-popover-foreground">{{ __('Sam Okoye') }}</p>
<div class="text-sm text-muted-foreground">{{ __('Reviews accessibility and design tokens.') }}</p>
</div>
</x-ui.hover-card.content>
</x-ui.hover-card>
{{ __('a few minutes ago.') }}
</div>
{{-- Keyboard peek: focus a task and press Space. The row dispatches
hover-card:toggle { name }, the named card opens without moving focus,
and Space again or Escape closes it. Hovering still opens it after
the delay. --}}
@php
$tasks = [
['id' => 'ATL-12', 'title' => __('Draft the kickoff agenda'), 'status' => __('In progress'), 'owner' => __('Sam Visser')],
['id' => 'ATL-14', 'title' => __('Review the contract with the client'), 'status' => __('Todo'), 'owner' => __('Noor Bakker')],
];
@endphp
<ul class="w-full max-w-md divide-y divide-border rounded-md border border-border bg-card text-card-foreground" aria-label="{{ __('Tasks') }}">
@foreach ($tasks as $task)
<li class="px-4 py-2 text-sm">
<x-ui.hover-card :name="$task['id']" :open-delay="400" class="block">
<a
href="#{{ $task['id'] }}"
x-on:keydown.space.prevent="$dispatch('hover-card:toggle', { name: @js($task['id']) })"
aria-keyshortcuts="Space"
class="flex min-w-0 items-center gap-3 rounded-sm outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</span>
<span class="min-w-0 truncate font-medium text-foreground">{{ $task['title'] }}</span>
</a>
<x-ui.hover-card.content class="w-72">
<div class="flex min-w-0 flex-col gap-1">
<p class="text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</p>
<p class="text-sm font-medium text-popover-foreground">{{ $task['title'] }}</p>
<p class="text-xs text-muted-foreground">{{ $task['status'] }}, {{ $task['owner'] }}</p>
</div>
</x-ui.hover-card.content>
</x-ui.hover-card>
</li>
@endforeach
</ul>
{{-- Preview mode: fill the `preview` slot and the card renders a stage for it
plus an optional caption — an inline component preview in running text. --}}
<div class="mx-auto max-w-md py-10 text-center text-sm text-foreground">
{{ __('Built entirely with the') }}
<x-ui.hover-card
side="top"
:label="__('Badge')"
:description="__('A small status pill with token-themed variants.')"
>
<span class="font-medium text-primary">badge</span>
<x-slot:preview>
<span class="flex gap-2">
<x-ui.badge>{{ __('Default') }}</x-ui.badge>
<x-ui.badge variant="success">{{ __('Live') }}</x-ui.badge>
</span>
</x-slot:preview>
</x-ui.hover-card>
{{ __('primitive: hover or focus the link to preview it.') }}
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| group | string | null | null | Opt-in coordination key set on the root component: opening a hover card closes any other hover card sharing the same group name. Leave unset for standalone hover cards. |
| label | string | null | null | Caption title shown under the preview stage when the preview slot is used. |
| description | string | null | null | Caption description shown under the label when the preview slot is used. |
| side | top | bottom | bottom | Which side of the trigger the card opens on. |
| name | string | null | null | A name the window events hover-card:open, hover-card:close and hover-card:toggle address with { name }, for a keyboard peek (a list row's Space handler). The card opens at once and focus stays where it is. |
| openDelay | int | 600 | Hover delay in milliseconds before the card opens. |
| closeDelay | int | 250 | Delay in milliseconds before the card closes after the pointer leaves. |
| lazy | bool | false | Declared by @props in the registry Blade source. |
| flush | bool | false | Declared by @props in the registry Blade source. |
Slots
default— Trigger content, or the compositional trigger/content parts when not using preview mode.preview— Fills the card's preview stage and switches the root into preview mode, rendering its own trigger and content automatically.x-ui.hover-card.trigger— Installed subcomponent from the registry item.x-ui.hover-card.content— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Opening and closing are delayed so passing the pointer through the trigger does not flicker the card; the card stays open while hovered.
- Opt-in `group` coordination closes sibling hover cards sharing the same group name, restoring focus to its own trigger if it was stranded inside the closed card.
- Opt-in `lazy` content defers and re-defers slot mounting to the card's open state.
- Peek: window.dispatchEvent(new CustomEvent('hover-card:toggle', { detail: { name: 'ATL-12' } })) opens the named card at once without moving focus, so a focused list row stays the keyboard target; the same event closes it again, and Escape closes an open card.
- openDelay and closeDelay tune the hover delays; the defaults (600 and 250 ms) are unchanged.
- 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.
- Revealing rich, non-critical content (a profile summary, a component preview) when a trigger is hovered or focused.
- Needing an inline component preview with a stage and caption via the preview slot.
Avoid when
- Do not use for long content, required reading, or complex interaction flows.
- The content is plain short text; use tooltip instead.
- The content must be reachable without hover or focus, such as on touch-only devices with no equivalent tap path; use popover.
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
- The trigger is a real button so assistive tech announces it correctly, and focus opens the card the same way hover does.
- Escape dismisses the card while it is focused, satisfying the requirement that hover/focus content be dismissible.
- A peek never moves focus, so arrow keys keep moving through the list; Escape closes it (WCAG 1.4.13). Keep the peek content reachable elsewhere (the row's own page), because a card opened without focus is not announced.
- 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="hover-card-{{ $record->id }}">
{{-- A mention in running text: hover or focus the handle to see who it is. The
wrapper is a div, not a <p>: the card panel is block content, and a <p>
would be auto-closed by the parser before it. --}}
<div class="text-sm text-muted-foreground">
<span>{{ __('Created by') }}</span>
<x-ui.hover-card>
<x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
<x-ui.hover-card.content class="w-72">
<div class="flex gap-3">
<x-ui.avatar fallback="JD" size="md" class="shrink-0" />
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
<div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
<p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
{{ __('Joined December 2021') }}
</p>
</div>
</div>
</x-ui.hover-card.content>
</x-ui.hover-card>
<span>{{ __('a few minutes ago.') }}</span>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:hover-card
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,
// Preview mode (the former component-preview-tooltip): fill the `preview`
// slot and the root renders its own trigger from the default slot plus a
// card with a stage for the preview, an optional `label`/`description`
// caption, opening on `side` (bottom or top). Without a `preview` slot the
// root is the plain compositional wrapper for .trigger and .content.
'label' => null,
'description' => null,
'side' => 'bottom',
// Programmatic open for a keyboard "peek": a name the window events
// `hover-card:open`, `hover-card:close` and `hover-card:toggle` address
// with { name } (a list row's Space handler dispatches them). The card
// opens at once, focus stays where it is, and Escape closes it.
'name' => null,
// Hover delays in milliseconds (defaults unchanged: 600 to open, 250 to close).
'openDelay' => 600,
'closeDelay' => 250,
])
@php
// `group` (opt-in, default null): coordinate multiple hover cards so
// opening one closes any sibling sharing the same group name — see
// popover.blade.php for the identical rationale. Reduces to the original
// behaviour byte-for-byte when omitted.
$options = array_filter([
'name' => filled($name) ? (string) $name : null,
'openDelay' => (int) $openDelay !== 600 ? max(0, (int) $openDelay) : null,
'closeDelay' => (int) $closeDelay !== 250 ? max(0, (int) $closeDelay) : null,
], fn ($value) => $value !== null);
$groupArg = $group !== null && $group !== '' ? json_encode($group) : null;
// The default call stays `uiHoverCard()` so an existing render is unchanged.
$dataCall = match (true) {
$options !== [] => 'uiHoverCard('.($groupArg ?? 'null').', '.\Illuminate\Support\Js::from($options).')',
$groupArg !== null => 'uiHoverCard('.$groupArg.')',
default => 'uiHoverCard()',
};
// component-preview-tooltip renders this component under its own name; the
// browser keeps only the first of two identical attributes, so read the
// override out of the bag and print exactly one.
$slotName = (string) ($attributes->get('data-slot') ?: 'hover-card');
@endphp
{{--
Hover Card. Reveals rich content in a card when its trigger is hovered or
focused. Opening and closing are delayed so passing the pointer through the
trigger does not flicker the card; the card stays open while hovered.
--}}
<div
x-data="{{ $dataCall }}"
data-slot="{{ $slotName }}"
@if (filled($name)) data-name="{{ $name }}" @endif
@mouseenter="openWithDelay()"
@mouseleave="closeWithDelay()"
{{ $attributes->except('data-slot')->merge(['class' => 'relative inline-block']) }}
>
@isset ($preview)
<x-ui.hover-card.trigger>{{ $slot }}</x-ui.hover-card.trigger>
<x-ui.hover-card.content :side="$side" flush>
<div data-slot="hover-card-preview" class="flex items-center justify-center border-b border-border bg-muted/40 p-4">
{{ $preview }}
</div>
@if ($label || $description)
<div data-slot="hover-card-caption" class="p-3">
@if ($label)
<p class="text-xs font-semibold">{{ $label }}</p>
@endif
@if ($description)
<p class="mt-0.5 text-xs text-muted-foreground">{{ $description }}</p>
@endif
</div>
@endif
</x-ui.hover-card.content>
@else
{{ $slot }}
@endisset
</div>
{{--
A <button> rather than an href-less <a>: an anchor without href has no
implicit ARIA role, so assistive tech announced this as plain text while the
underline promised a link. Escape dismisses the card (WCAG 1.4.13, content
on hover or focus must be dismissible).
--}}
<button
type="button"
data-slot="hover-card-trigger"
x-ref="trigger"
@focusin="open = true"
@focusout="closeWithDelay()"
@keydown.escape.prevent="open = false"
{{ $attributes->merge(['class' => 'cursor-pointer rounded-sm text-start underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
{{ $slot }}
</button>
@props([
'lazy' => false,
// Which side of the trigger the card opens on: bottom (default) or top.
'side' => 'bottom',
// Drop the inset so edge-to-edge content (a preview stage, an image) can
// fill the card; the card clips to its own radius.
'flush' => false,
])
@php
// `lazy` (opt-in, default false): see popover/content.blade.php for the
// identical rationale — the slot mounts/unmounts with `open` directly
// rather than staying mounted after first open, so a long session
// hovering through many cards does not accumulate DOM. The
// `@if`/`@else`/`@endif` markup below is deliberately un-indented: an
// indented directive line leaks stray spaces into the plain (non-lazy)
// output and breaks the byte-identical default render — see
// popover/content.blade.php for the full explanation.
$placement = $side === 'top' ? 'bottom-full mb-2 origin-bottom' : 'mt-2 origin-top';
$inset = $flush ? 'overflow-hidden' : 'p-4';
@endphp
<div
data-slot="hover-card-content"
data-side="{{ $side === 'top' ? 'top' : 'bottom' }}"
x-show="open"
x-cloak
@mouseenter="cancelClose()"
@mouseleave="closeWithDelay()"
x-transition:enter="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
x-transition:enter-start="opacity-0 motion-safe:scale-95 motion-safe:-translate-y-1"
x-transition:enter-end="opacity-100 motion-safe:scale-100 motion-safe:translate-y-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 motion-safe:translate-y-0"
x-transition:leave-end="opacity-0 motion-safe:scale-95 motion-safe:-translate-y-1"
{{ $attributes->merge(['class' => 'absolute z-popover w-64 max-w-none rounded-md border border-border bg-popover text-sm text-popover-foreground shadow-md motion-reduce:transition-none '.$placement.' '.$inset]) }}
>
@if ($lazy)
<template x-if="open">
{{ $slot }}
</template>
@else
{{ $slot }}
@endif
</div>
/**
* Hover Card behavior. Opens on hover and keyboard focus, with an open delay
* (so a brief pass-through does not trigger it) and a close delay (so moving
* the pointer from the trigger into the card keeps it open). Self-registering
* Alpine data component.
*
* v1.2.0 adds an opt-in `group` (see hover-card.blade.php), identical in
* spirit to popover's: opening dispatches a `ui-hover-card:opened` window
* event carrying its group name and a per-instance id, and every other hover
* card sharing that group name closes immediately on hearing it. Ungrouped
* hover cards (the default) never attach a listener or dispatch the event.
* The dispatch lives behind an Alpine `$watch('open', ...)` rather than being
* inlined into `openWithDelay()`, because `open` can also flip true directly
* from the trigger's `@focusin="open = true"` (keyboard users skip the hover
* delay entirely) — watching the property catches both paths from one place
* instead of silently missing the keyboard one. Closing this way skips the
* normal delay (there's no pointer to debounce against) and, if focus was
* inside the card being closed, returns it to this card's own trigger rather
* than leaving it stranded on a removed node.
*
* v1.5.0 adds a keyboard "peek": with a `name`, the card listens for the
* window events `hover-card:open`, `hover-card:close` and `hover-card:toggle`
* carrying { name } (a list row's Space handler dispatches them). The card
* opens at once without moving focus, and Escape (a window listener that
* lives only while a peek is open) closes it. `openDelay` / `closeDelay`
* tune the hover delays.
*/
document.addEventListener('alpine:init', () => {
let hoverCardInstanceSeq = 0;
window.Alpine.data('uiHoverCard', (group = null, options = {}) => ({
open: false,
name: options.name ?? null,
openDelay: Number.isFinite(options.openDelay) ? options.openDelay : 600,
closeDelay: Number.isFinite(options.closeDelay) ? options.closeDelay : 250,
_onPeek: null,
_onPeekEscape: null,
openTimer: null,
closeTimer: null,
group,
_instanceId: `hover-card-${++hoverCardInstanceSeq}`,
_onGroupOpen: null,
init() {
this.initPeek();
if (!this.group) {
return;
}
this.$watch('open', (isOpen) => {
if (!isOpen) {
return;
}
window.dispatchEvent(
new CustomEvent('ui-hover-card:opened', {
detail: { group: this.group, id: this._instanceId },
})
);
});
this._onGroupOpen = (event) => {
const detail = event.detail || {};
if (detail.group !== this.group || detail.id === this._instanceId || !this.open) {
return;
}
clearTimeout(this.openTimer);
clearTimeout(this.closeTimer);
const panel = this.$root.querySelector('[data-slot="hover-card-content"]');
const stoleFocus = !!panel && panel.contains(document.activeElement);
this.open = false;
if (stoleFocus) {
this.$refs.trigger?.focus();
}
};
window.addEventListener('ui-hover-card:opened', this._onGroupOpen);
},
initPeek() {
if (!this.name) {
return;
}
this._onPeek = (event) => {
const detail = event.detail || {};
if ((detail.name ?? detail.id) !== this.name) {
return;
}
const action = event.type.split(':')[1];
if (action === 'open' || (action === 'toggle' && !this.open)) {
this.peek();
} else {
this.unpeek();
}
};
['open', 'close', 'toggle'].forEach((action) => window.addEventListener(`hover-card:${action}`, this._onPeek));
this._onPeekEscape = (event) => {
if (event.key !== 'Escape' || !this.open || event.defaultPrevented) {
return;
}
event.preventDefault();
this.unpeek();
};
this.$watch('open', (isOpen) => {
if (!isOpen) {
window.removeEventListener('keydown', this._onPeekEscape, true);
}
});
},
/** Open at once, without moving focus (a keyboard peek). */
peek() {
clearTimeout(this.openTimer);
clearTimeout(this.closeTimer);
this.open = true;
window.removeEventListener('keydown', this._onPeekEscape, true);
window.addEventListener('keydown', this._onPeekEscape, true);
},
unpeek() {
clearTimeout(this.openTimer);
clearTimeout(this.closeTimer);
this.open = false;
},
openWithDelay() {
clearTimeout(this.closeTimer);
this.openTimer = setTimeout(() => (this.open = true), this.openDelay);
},
closeWithDelay() {
clearTimeout(this.openTimer);
this.closeTimer = setTimeout(() => (this.open = false), this.closeDelay);
},
cancelClose() {
clearTimeout(this.closeTimer);
},
destroy() {
clearTimeout(this.openTimer);
clearTimeout(this.closeTimer);
if (this._onPeek) {
['open', 'close', 'toggle'].forEach((action) => window.removeEventListener(`hover-card:${action}`, this._onPeek));
this._onPeek = null;
}
if (this._onPeekEscape) {
window.removeEventListener('keydown', this._onPeekEscape, true);
this._onPeekEscape = null;
}
if (this._onGroupOpen) {
window.removeEventListener('ui-hover-card:opened', this._onGroupOpen);
this._onGroupOpen = null;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.5.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