Files
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:23 +02:00

73 lines
3.3 KiB
PHP

{{-- An M3 rich tooltip: a few lines of context for a control, with an optional subhead and actions.
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest allowed.">
<x-button icon="help" aria-label="About expiry" />
<x-slot:actions><x-button label="Learn more" link="/help/expiry" /></x-slot:actions>
</x-rich-tooltip>
It wraps its trigger. By default it shows on hover and keyboard focus like a plain tooltip;
`persistent` makes it open on press instead and stay until a press elsewhere or Escape the
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
lets a Livewire morph patch it in place, so an open bubble stays open — through its own
action's `wire:click` too and resources/js/rich-tooltip.js keeps showing and hiding the
element on the page rather than one the morph took away.
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded` (ACT-22): the
explanation is the whole point of a rich tooltip, and without the association a screen reader
reads only the trigger's own label. The trigger arrives in a slot, so only script can find the
element in it that takes the focus. It stands for 1.5s after the pointer or focus leaves,
M3's transient tooltip timing (ACT-25), unless `persistent` keeps it open until dismissed.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. Drawn by
resources/css/components/rich-tooltip.css from `data-md-rich-tooltip` and `data-md-side`. --}}
@props([
'title' => null,
'text' => null,
'side' => 'bottom',
'persistent' => false,
])
@php
$side = in_array($side, ['top', 'bottom', 'left', 'right'], true) ? $side : 'bottom';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-rich-tooltip-{$key}";
@endphp
<span
data-md-rich-tooltip
{{ $attributes }}
style="anchor-name: {{ $anchor }}"
x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})"
>
{{ $trigger ?? $slot }}
<span
x-ref="bubble"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
id="material-rich-tooltip-{{ $key }}"
popover="{{ $persistent ? 'auto' : 'manual' }}"
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@if ($title) aria-label="{{ $title }}" @endif
data-md-rich-tooltip-bubble
data-md-popover-exit
data-md-side="{{ $side }}"
style="position-anchor: {{ $anchor }}"
>
@if ($title)
<span data-md-rich-tooltip-title>{{ $title }}</span>
@endif
<span data-md-rich-tooltip-text>{{ $text }}</span>
@isset($actions)
<span data-md-rich-tooltip-actions>{{ $actions }}</span>
@endisset
</span>
</span>