diff --git a/resources/css/components.css b/resources/css/components.css index 93c02802..6e5e555c 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -13,6 +13,7 @@ /* Actions and communication */ @import './components/loading.css'; +@import './components/tooltip.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/tooltip.css b/resources/css/components/tooltip.css new file mode 100644 index 00000000..5c00df6a --- /dev/null +++ b/resources/css/components/tooltip.css @@ -0,0 +1,80 @@ +/* + * : M3's plain tooltip, a short label for a control in the inverse surface. + * + * The bubble is a `popover="manual"` in the top layer, so no `overflow: hidden` parent clips it, + * placed by CSS anchor positioning on its `data-md-side` and flipping to the other side where the + * window has no room. PlainTooltipTokens and TooltipDefaults (androidx Compose Material 3, + * Apache-2.0): `inverse-surface` container with the extra-small corner, `body-small` supporting + * text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px + * from its anchor. It never takes the pointer, so it can stand over the control it names. + * + * It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay` + * alive until the fade out has been seen, and `@starting-style` gives the fade in a start. + * resources/js/tooltip.js shows and hides it. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-tooltip-anchor] { + display: inline-flex; + } + + [data-md-tooltip] { + inset: auto; + margin: 0; + max-inline-size: 200px; + overflow: visible; + border: 0; + border-radius: var(--md-sys-shape-corner-xs); + padding-block: var(--md-sys-measurement-space50); + padding-inline: var(--md-sys-measurement-space100); + background-color: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + font: var(--md-sys-typescale-body-sm); + letter-spacing: var(--md-sys-typescale-body-sm-tracking); + font-variation-settings: normal; + text-align: center; + white-space: normal; + pointer-events: none; + 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-tooltip]:is([data-md-side='top'], [data-md-side='bottom']) { + margin-block: var(--md-sys-measurement-space50); + position-try-fallbacks: flip-block; + } + + [data-md-tooltip]:is([data-md-side='left'], [data-md-side='right']) { + margin-inline: var(--md-sys-measurement-space50); + position-try-fallbacks: flip-inline; + } + + [data-md-tooltip][data-md-side='top'] { + position-area: top; + } + + [data-md-tooltip][data-md-side='bottom'] { + position-area: bottom; + } + + [data-md-tooltip][data-md-side='left'] { + position-area: left; + } + + [data-md-tooltip][data-md-side='right'] { + position-area: right; + } +} diff --git a/resources/views/components/tooltip.blade.php b/resources/views/components/tooltip.blade.php index 480eb5e5..8038f1b8 100644 --- a/resources/views/components/tooltip.blade.php +++ b/resources/views/components/tooltip.blade.php @@ -9,8 +9,9 @@ at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its words. The bubble is a `popover="manual"` in the top layer — never clipped by an - `overflow-hidden` parent, never widening a scroll container — placed by CSS anchor + `overflow: hidden` parent, never widening a scroll container — placed by CSS anchor positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room. + Drawn by resources/css/components/tooltip.css. It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled control would otherwise read them twice. --}} @@ -28,7 +29,7 @@ @endphp @if ($standalone) - + {{ $slot }} @endif @@ -36,15 +37,9 @@ popover="manual" aria-hidden="true" x-data="materialTooltip" + data-md-tooltip + data-md-side="{{ $side }}" style="position-anchor: {{ $anchor }}" - @class([ - 'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [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:top] [position-try-fallbacks:flip-block]' => $side === 'top', - 'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom', - 'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left', - 'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right', - ]) >{{ $text }} @if ($standalone) diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index dce6b285..ca1a0054 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -12,6 +12,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; */ dataset('action components', [ 'loading', + 'tooltip', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { @@ -54,8 +55,8 @@ it('takes its values from the tokens and its breakpoints in px', function (strin // Colours are roles, shadows are elevation levels, type is a type style, motion is a spring. expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i') - ->not->toMatch('/\bbox-shadow:\s*(?!none|var\(--md-)/') - ->not->toMatch('/\bfont:\s*(?!var\(--md-sys-typescale-)/') + ->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/') + ->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/') ->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/'); foreach ($css->mediaQueries() as $query) { diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index 57f3b391..c8228027 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -160,7 +160,7 @@ it('anchors its tooltip to itself', function () { expect($anchor)->not->toBeEmpty() ->and($html)->toContain("position-anchor: {$anchor[1]}") - ->toContain('[position-area:bottom]') + ->toContain('data-md-side="bottom"') ->not->toContain('aria-label="Saves the draft"'); }); diff --git a/tests/Feature/Components/TooltipTest.php b/tests/Feature/Components/TooltipTest.php index 70f71d8d..d51d9f63 100644 --- a/tests/Feature/Components/TooltipTest.php +++ b/tests/Feature/Components/TooltipTest.php @@ -1,5 +1,7 @@ blade(''); @@ -7,22 +9,49 @@ it('wraps a trigger and anchors the bubble to it', function () { expect($anchor)->not->toBeEmpty() ->and($html) - ->toContain('') - ->toContain('popover="manual"') - ->toContain('x-data="materialTooltip"') - ->toContain("position-anchor: {$anchor[1]}") - ->toContain('bg-inverse-surface') - ->toContain('[position-area:top]'); + ->toMatch('/\s*