diff --git a/resources/css/components.css b/resources/css/components.css index b7b66a8a..6b451f99 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -26,6 +26,7 @@ @import './components/menu.css'; @import './components/fab-menu-item.css'; @import './components/fab-menu.css'; +@import './components/rich-tooltip.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/rich-tooltip.css b/resources/css/components/rich-tooltip.css new file mode 100644 index 00000000..c294b94e --- /dev/null +++ b/resources/css/components/rich-tooltip.css @@ -0,0 +1,109 @@ +/* + * : 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; + } +} diff --git a/resources/js/rich-tooltip.js b/resources/js/rich-tooltip.js index 6d0bc311..81ffd712 100644 --- a/resources/js/rich-tooltip.js +++ b/resources/js/rich-tooltip.js @@ -2,8 +2,10 @@ * `materialRichTooltip`: shows an ``. * * 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 - * actions. Persistent: a press on the trigger opens it as a light-dismiss popover. + * at once on keyboard focus, standing for M3's 1.5s once the pointer or focus leaves (ACT-25) so + * 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 * 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. */ const HOVER_DELAY_MS = 500 -const LEAVE_GRACE_MS = 200 +const LEAVE_GRACE_MS = 1500 document.addEventListener('alpine:init', () => { window.Alpine.data('materialRichTooltip', (persistent = false) => ({ diff --git a/resources/views/components/rich-tooltip.blade.php b/resources/views/components/rich-tooltip.blade.php index b888e298..cfd43c67 100644 --- a/resources/views/components/rich-tooltip.blade.php +++ b/resources/views/components/rich-tooltip.blade.php @@ -16,13 +16,15 @@ 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`: 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. + `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. --}} + 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, @@ -38,7 +40,8 @@ @endphp class('inline-flex') }} + data-md-rich-tooltip + {{ $attributes }} style="anchor-name: {{ $anchor }}" x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})" > @@ -51,24 +54,18 @@ popover="{{ $persistent ? 'auto' : 'manual' }}" role="{{ $persistent ? 'dialog' : 'tooltip' }}" @if ($title) aria-label="{{ $title }}" @endif + data-md-rich-tooltip-bubble + data-md-side="{{ $side }}" 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) - {{ $title }} + {{ $title }} @endif - {{ $text }} + {{ $text }} @isset($actions) - {{ $actions }} + {{ $actions }} @endisset diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 00ecbd50..c5f0646f 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -25,6 +25,7 @@ dataset('action components', [ 'menu', 'fab-menu-item', 'fab-menu', + 'rich-tooltip', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/RichTooltipTest.php b/tests/Feature/Components/RichTooltipTest.php index 03d869fc..860378cc 100644 --- a/tests/Feature/Components/RichTooltipTest.php +++ b/tests/Feature/Components/RichTooltipTest.php @@ -1,5 +1,7 @@ blade(<<<'BLADE' @@ -11,14 +13,28 @@ it('wraps its trigger with a surface bubble anchored to it', function () { expect($anchor)->not->toBeEmpty() ->and($html) + ->toContain('data-md-rich-tooltip') ->toContain('') ->toContain('x-data="materialRichTooltip(false)"') ->toContain('popover="manual"') ->toContain('role="tooltip"') + ->toContain('data-md-rich-tooltip-bubble') + ->toContain('data-md-side="bottom"') ->toContain("position-anchor: {$anchor[1]}") - ->toContain('rounded-corner-md bg-surface-container') - ->toContain('type-title-sm text-on-surface-variant">Expiry') - ->toContain('Recipients lose access after this time.'); + ->toContain('data-md-rich-tooltip-title') + ->toContain('>Expiry') + ->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 () { @@ -26,5 +42,21 @@ it('opens on press and stays when persistent', function () { ->toContain('x-data="materialRichTooltip(true)"') ->toContain('popover="auto"') ->toContain('role="dialog"') + ->toContain('data-md-rich-tooltip-actions') ->toContain(''); }); + +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(''))->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('')) + ->toContain('data-md-side="bottom"'); +});