Draw the rich tooltip without Tailwind

Plan step 36 (actions). <x-rich-tooltip> renders data-md-rich-tooltip
with the bubble's data-md-rich-tooltip-bubble and data-md-side, and
data-md-rich-tooltip-title/-text/-actions on its plain text; no class
list. rich-tooltip.css draws RichTooltipTokens' surface-container,
medium corner, elevation 2, 312px bubble, its corner-to-corner anchor
positioning and its fade, the same shape as tooltip.css.

rich-tooltip.js's leave grace goes from 200ms to M3's 1.5s (ACT-25),
matching tooltip.js. RichTooltipTest is rewritten on the hooks and
ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:35:36 +02:00
co-authored by Claude Opus 5
parent 3680808ae8
commit 8cb8727f75
6 changed files with 165 additions and 23 deletions
+1
View File
@@ -26,6 +26,7 @@
@import './components/menu.css'; @import './components/menu.css';
@import './components/fab-menu-item.css'; @import './components/fab-menu-item.css';
@import './components/fab-menu.css'; @import './components/fab-menu.css';
@import './components/rich-tooltip.css';
/* Inputs, selection and data */ /* Inputs, selection and data */
@import './components/form.css'; @import './components/form.css';
+109
View File
@@ -0,0 +1,109 @@
/*
* <x-rich-tooltip>: an M3 rich tooltip, a few lines of context for a control, with an optional
* subhead and actions.
*
* RichTooltipTokens (androidx Compose Material 3, Apache-2.0): surface-container, the medium
* corner, elevation 2, 312px wide at most, 16px either side, 12px above the subhead (or the text
* when there is none) and 8px below it. Title-small subhead and body-medium text in
* on-surface-variant, label-large actions in primary — the actions row pulls itself back to the
* text's edge with a negative margin, the trick every text-button row in this package uses
* (chip.css, fab-menu.css).
*
* Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has
* room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring,
* `allow-discrete` keeping `display` and `overlay` alive for the fade out and `@starting-style`
* giving the fade in a start.
*
* resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with
* `aria-describedby` — and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) — none of
* which this stylesheet draws.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-rich-tooltip] {
display: inline-flex;
}
[data-md-rich-tooltip-bubble] {
inset: auto;
margin: 0;
overflow: visible;
width: max-content;
max-width: 312px;
border-width: 0;
border-radius: var(--md-sys-shape-corner-md);
padding-inline: var(--md-sys-measurement-space200);
padding-block: 12px var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
text-align: start;
white-space: normal;
opacity: 0;
transition-property: opacity, display, overlay;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
opacity: 1;
@starting-style {
opacity: 0;
}
}
}
[data-md-rich-tooltip-bubble]:is([data-md-side='top'], [data-md-side='bottom']) {
margin-block: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-block, flip-inline;
}
[data-md-rich-tooltip-bubble]:is([data-md-side='left'], [data-md-side='right']) {
margin-inline: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-inline, flip-block;
}
[data-md-rich-tooltip-bubble][data-md-side='top'] {
position-area: top span-right;
}
[data-md-rich-tooltip-bubble][data-md-side='bottom'] {
position-area: bottom span-right;
}
[data-md-rich-tooltip-bubble][data-md-side='left'] {
position-area: left span-bottom;
}
[data-md-rich-tooltip-bubble][data-md-side='right'] {
position-area: right span-bottom;
}
[data-md-rich-tooltip-title] {
display: block;
margin-block-end: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
font-variation-settings: normal;
}
[data-md-rich-tooltip-text] {
display: block;
padding-block-end: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-rich-tooltip-actions] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
margin-inline-start: -12px;
}
}
+5 -3
View File
@@ -2,8 +2,10 @@
* `materialRichTooltip`: shows an `<x-rich-tooltip>`. * `materialRichTooltip`: shows an `<x-rich-tooltip>`.
* *
* Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover, * Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover,
* at once on keyboard focus, and it stays while the pointer moves onto the bubble to reach its * at once on keyboard focus, standing for M3's 1.5s once the pointer or focus leaves (ACT-25) so
* actions. Persistent: a press on the trigger opens it as a light-dismiss popover. * the words can still be read while it moves on, and it stays while the pointer moves onto the
* bubble to reach its actions. Persistent: a press on the trigger opens it as a light-dismiss
* popover.
* *
* The trigger is pointed at the bubble with `aria-describedby`, so the explanation — the whole * The trigger is pointed at the bubble with `aria-describedby`, so the explanation — the whole
* point of a rich tooltip — is read out with the control rather than never at all; a persistent * point of a rich tooltip — is read out with the control rather than never at all; a persistent
@@ -13,7 +15,7 @@
* they are written again whenever the trigger is reached. * they are written again whenever the trigger is reached.
*/ */
const HOVER_DELAY_MS = 500 const HOVER_DELAY_MS = 500
const LEAVE_GRACE_MS = 200 const LEAVE_GRACE_MS = 1500
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
window.Alpine.data('materialRichTooltip', (persistent = false) => ({ window.Alpine.data('materialRichTooltip', (persistent = false) => ({
@@ -16,13 +16,15 @@
element on the page rather than one the morph took away. 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 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`: the explanation is `persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded` (ACT-22): the
the whole point of a rich tooltip, and without the association a screen reader reads only the explanation is the whole point of a rich tooltip, and without the association a screen reader
trigger's own label. The trigger arrives in a slot, so only script can find the element in it reads only the trigger's own label. The trigger arrives in a slot, so only script can find the
that takes the focus. 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 RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}} 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([ @props([
'title' => null, 'title' => null,
@@ -38,7 +40,8 @@
@endphp @endphp
<span <span
{{ $attributes->class('inline-flex') }} data-md-rich-tooltip
{{ $attributes }}
style="anchor-name: {{ $anchor }}" style="anchor-name: {{ $anchor }}"
x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})" x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})"
> >
@@ -51,24 +54,18 @@
popover="{{ $persistent ? 'auto' : 'manual' }}" popover="{{ $persistent ? 'auto' : 'manual' }}"
role="{{ $persistent ? 'dialog' : 'tooltip' }}" role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@if ($title) aria-label="{{ $title }}" @endif @if ($title) aria-label="{{ $title }}" @endif
data-md-rich-tooltip-bubble
data-md-side="{{ $side }}"
style="position-anchor: {{ $anchor }}" style="position-anchor: {{ $anchor }}"
@class([
'm-0 w-max max-w-78 overflow-visible border-0 rounded-corner-md bg-surface-container px-4 pt-3 pb-2 text-start whitespace-normal shadow-elevation-2 [inset:auto]',
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'my-1 [position-area:bottom_span-right] [position-try-fallbacks:flip-block,flip-inline]' => $side === 'bottom',
'my-1 [position-area:top_span-right] [position-try-fallbacks:flip-block,flip-inline]' => $side === 'top',
'mx-1 [position-area:left_span-bottom] [position-try-fallbacks:flip-inline,flip-block]' => $side === 'left',
'mx-1 [position-area:right_span-bottom] [position-try-fallbacks:flip-inline,flip-block]' => $side === 'right',
])
> >
@if ($title) @if ($title)
<span class="mb-1 block type-title-sm text-on-surface-variant">{{ $title }}</span> <span data-md-rich-tooltip-title>{{ $title }}</span>
@endif @endif
<span class="block pb-2 type-body-md text-on-surface-variant">{{ $text }}</span> <span data-md-rich-tooltip-text>{{ $text }}</span>
@isset($actions) @isset($actions)
<span class="-ms-3 flex flex-wrap gap-2">{{ $actions }}</span> <span data-md-rich-tooltip-actions>{{ $actions }}</span>
@endisset @endisset
</span> </span>
</span> </span>
@@ -25,6 +25,7 @@ dataset('action components', [
'menu', 'menu',
'fab-menu-item', 'fab-menu-item',
'fab-menu', 'fab-menu',
'rich-tooltip',
]); ]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
+35 -3
View File
@@ -1,5 +1,7 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('wraps its trigger with a surface bubble anchored to it', function () { it('wraps its trigger with a surface bubble anchored to it', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time."> <x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
@@ -11,14 +13,28 @@ it('wraps its trigger with a surface bubble anchored to it', function () {
expect($anchor)->not->toBeEmpty() expect($anchor)->not->toBeEmpty()
->and($html) ->and($html)
->toContain('data-md-rich-tooltip')
->toContain('<button>?</button>') ->toContain('<button>?</button>')
->toContain('x-data="materialRichTooltip(false)"') ->toContain('x-data="materialRichTooltip(false)"')
->toContain('popover="manual"') ->toContain('popover="manual"')
->toContain('role="tooltip"') ->toContain('role="tooltip"')
->toContain('data-md-rich-tooltip-bubble')
->toContain('data-md-side="bottom"')
->toContain("position-anchor: {$anchor[1]}") ->toContain("position-anchor: {$anchor[1]}")
->toContain('rounded-corner-md bg-surface-container') ->toContain('data-md-rich-tooltip-title')
->toContain('type-title-sm text-on-surface-variant">Expiry') ->toContain('>Expiry')
->toContain('Recipients lose access after this time.'); ->toContain('data-md-rich-tooltip-text')
->toContain('Recipients lose access after this time.')
->and(ComponentStylesheet::read('rich-tooltip')->declarations('[data-md-rich-tooltip-bubble]'))->toMatchArray([
'border-radius' => 'var(--md-sys-shape-corner-md)',
'background-color' => 'var(--md-sys-color-surface-container)',
'box-shadow' => 'var(--md-sys-elevation-2)',
'max-width' => '312px',
])
->and(ComponentStylesheet::read('rich-tooltip')->declarations('[data-md-rich-tooltip-title]'))->toMatchArray([
'font' => 'var(--md-sys-typescale-title-sm)',
'color' => 'var(--md-sys-color-on-surface-variant)',
]);
}); });
it('opens on press and stays when persistent', function () { it('opens on press and stays when persistent', function () {
@@ -26,5 +42,21 @@ it('opens on press and stays when persistent', function () {
->toContain('x-data="materialRichTooltip(true)"') ->toContain('x-data="materialRichTooltip(true)"')
->toContain('popover="auto"') ->toContain('popover="auto"')
->toContain('role="dialog"') ->toContain('role="dialog"')
->toContain('data-md-rich-tooltip-actions')
->toContain('<button>More</button>'); ->toContain('<button>More</button>');
}); });
it('places the bubble by the side asked for, on the anchor positioning fallback that suits it', function () {
$css = ComponentStylesheet::read('rich-tooltip');
expect((string) $this->blade('<x-rich-tooltip text="Details" side="left"><button>i</button></x-rich-tooltip>'))->toContain('data-md-side="left"')
->and($css->declarations("[data-md-rich-tooltip-bubble][data-md-side='left']"))->toBe(['position-area' => 'left span-bottom'])
->and($css->declarations("[data-md-rich-tooltip-bubble]:is([data-md-side='left'], [data-md-side='right'])"))->toMatchArray([
'position-try-fallbacks' => 'flip-inline, flip-block',
]);
});
it('falls back to bottom for an unknown side', function () {
expect((string) $this->blade('<x-rich-tooltip text="Details" side="diagonal"><button>i</button></x-rich-tooltip>'))
->toContain('data-md-side="bottom"');
});