1
0
mirror of https://github.com/squidfunk/mkdocs-material.git synced 2024-11-30 18:24:35 +01:00

Merge pull request #7045 from squidfunk/refactor/tooltip-positioning

Refactor tooltips
This commit is contained in:
Martin Donath 2024-04-16 10:21:04 +07:00 committed by GitHub
commit f028004c59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 786 additions and 104 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.129bd6ad.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.e2e97759.min.js' | url }}"></script>
{% endblock %}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -44,7 +44,7 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.8b0efcb2.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.66ac8b77.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.06af60db.min.css' | url }}">
@ -249,7 +249,7 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.ae821067.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.3220b9d7.min.js' | url }}"></script>
{% for script in config.extra_javascript %}
{{ script | script_tag }}
{% endfor %}

View File

@ -22,12 +22,14 @@
import {
Observable,
debounceTime,
debounce,
defer,
fromEvent,
identity,
map,
merge,
startWith
startWith,
timer
} from "rxjs"
/* ----------------------------------------------------------------------------
@ -37,20 +39,26 @@ import {
/**
* Watch element hover
*
* The second parameter allows to specify a timeout in milliseconds after which
* the hover state will be reset to `false`. This is useful for tooltips which
* should disappear after a certain amount of time, in order to allow the user
* to move the cursor from the host to the tooltip.
*
* @param el - Element
* @param duration - Debounce duration
* @param timeout - Timeout
*
* @returns Element hover observable
*/
export function watchElementHover(
el: HTMLElement, duration?: number
el: HTMLElement, timeout?: number
): Observable<boolean> {
return merge(
return defer(() => merge(
fromEvent(el, "mouseenter").pipe(map(() => true)),
fromEvent(el, "mouseleave").pipe(map(() => false))
)
.pipe(
duration ? debounceTime(duration) : identity,
startWith(false)
timeout ? debounce(active => timer(+!active * timeout)) : identity,
startWith(el.matches(":hover"))
)
)
}

View File

@ -30,6 +30,8 @@ import {
startWith
} from "rxjs"
import { watchElementSize } from "../../size"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
@ -62,6 +64,23 @@ export function getElementOffset(
}
}
/**
* Retrieve absolute element offset
*
* @param el - Element
*
* @returns Element offset
*/
export function getElementOffsetAbsolute(
el: HTMLElement
): ElementOffset {
const rect = el.getBoundingClientRect()
return {
x: rect.x + window.scrollX,
y: rect.y + window.scrollY
}
}
/* ------------------------------------------------------------------------- */
/**
@ -84,3 +103,23 @@ export function watchElementOffset(
startWith(getElementOffset(el))
)
}
/**
* Watch absolute element offset
*
* @param el - Element
*
* @returns Element offset observable
*/
export function watchElementOffsetAbsolute(
el: HTMLElement
): Observable<ElementOffset> {
return merge(
watchElementOffset(el),
watchElementSize(document.body) // @todo find a better way for this
)
.pipe(
map(() => getElementOffsetAbsolute(el)),
startWith(getElementOffsetAbsolute(el))
)
}

View File

@ -66,6 +66,7 @@ export function watchElementContentOffset(
): Observable<ElementOffset> {
return merge(
fromEvent(el, "scroll"),
fromEvent(window, "scroll"),
fromEvent(window, "resize")
)
.pipe(

View File

@ -80,15 +80,12 @@ const observer$ = defer(() => (
: of(undefined)
))
.pipe(
map(() => new ResizeObserver(entries => {
for (const entry of entries)
entry$.next(entry)
})),
switchMap(observer => merge(NEVER, of(observer))
.pipe(
finalize(() => observer.disconnect())
)
),
map(() => new ResizeObserver(entries => (
entries.forEach(entry => entry$.next(entry))
))),
switchMap(observer => merge(NEVER, of(observer)).pipe(
finalize(() => observer.disconnect())
)),
shareReplay(1)
)
@ -136,16 +133,27 @@ export function getElementSize(
export function watchElementSize(
el: HTMLElement
): Observable<ElementSize> {
return observer$
.pipe(
tap(observer => observer.observe(el)),
switchMap(observer => entry$
.pipe(
filter(({ target }) => target === el),
finalize(() => observer.unobserve(el)),
map(() => getElementSize(el))
)
),
startWith(getElementSize(el))
)
// Compute target element - since inline elements cannot be observed by the
// current `ResizeObserver` implementation as provided by browsers, we need
// to determine the first containing parent element and use that one as a
// target, while we always compute the actual size from the element.
let target = el
while (target.clientWidth === 0)
if (target.parentElement)
target = target.parentElement
else
break
// Observe target element and recompute element size on resize - as described
// above, the target element is not necessarily the element of interest
return observer$.pipe(
tap(observer => observer.observe(target)),
switchMap(observer => entry$.pipe(
filter(entry => entry.target === target),
finalize(() => observer.unobserve(target))
)),
map(() => getElementSize(el)),
startWith(getElementSize(el))
)
}

View File

@ -65,3 +65,40 @@ export function getElementContainer(
/* Return overflowing container */
return parent ? el : undefined
}
/**
* Retrieve all overflowing containers of an element, if any
*
* Note that this function has a slightly different behavior, so we should at
* some point consider refactoring how overflowing containers are handled.
*
* @param el - Element
*
* @returns Overflowing containers
*/
export function getElementContainers(
el: HTMLElement
): HTMLElement[] {
const containers: HTMLElement[] = []
// Walk up the DOM tree until we find an overflowing container
let parent = el.parentElement
while (parent) {
if (
el.clientWidth > parent.clientWidth ||
el.clientHeight > parent.clientHeight
)
containers.push(parent)
// Continue with parent element
parent = (el = parent).parentElement
}
// If the page is short, the body might not be overflowing and there might be
// no other containers, which is why we need to make sure the body is present
if (containers.length === 0)
containers.push(document.documentElement)
// Return overflowing containers
return containers
}

View File

@ -200,7 +200,7 @@ keyboard$
})
/* Set up patches */
patchEllipsis({ document$ })
patchEllipsis({ viewport$, document$ })
patchIndeterminate({ document$, tablet$ })
patchScrollfix({ document$ })
patchScrolllock({ viewport$, tablet$ })

View File

@ -28,8 +28,8 @@ import { Viewport, getElements } from "~/browser"
import { Component } from "../../_"
import {
Tooltip,
mountTooltip
} from "../../tooltip"
mountInlineTooltip2
} from "../../tooltip2"
import {
Annotation,
mountAnnotationBlock
@ -131,6 +131,6 @@ export function mountContent(
/* Tooltips */
...getElements("[title]", el)
.filter(() => feature("content.tooltips"))
.map(child => mountTooltip(child))
.map(child => mountInlineTooltip2(child, { viewport$ }))
)
}

View File

@ -46,13 +46,13 @@ import {
watchElementSize,
watchElementVisibility
} from "~/browser"
import {
Tooltip,
mountInlineTooltip2
} from "~/components/tooltip2"
import { renderClipboardButton } from "~/templates"
import { Component } from "../../../_"
import {
Tooltip,
mountTooltip
} from "../../../tooltip"
import {
Annotation,
mountAnnotationList
@ -200,7 +200,7 @@ export function mountCodeBlock(
const button = renderClipboardButton(parent.id)
parent.insertBefore(button, el)
if (feature("content.tooltips"))
content$.push(mountTooltip(button))
content$.push(mountInlineTooltip2(button, { viewport$ }))
}
}

View File

@ -0,0 +1,361 @@
/*
* Copyright (c) 2016-2024 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
BehaviorSubject,
EMPTY,
Observable,
Subject,
animationFrameScheduler,
combineLatest,
debounce,
defer,
distinctUntilChanged,
endWith,
filter,
finalize,
first,
ignoreElements,
map,
mergeMap,
observeOn,
queueScheduler,
share,
startWith,
switchMap,
tap,
throttleTime,
timer,
withLatestFrom
} from "rxjs"
import {
ElementOffset,
Viewport,
getElement,
getElementContainers,
getElementOffsetAbsolute,
getElementSize,
watchElementContentOffset,
watchElementFocus,
watchElementHover
} from "~/browser"
import { renderInlineTooltip2 } from "~/templates"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Tooltip
*/
export interface Tooltip {
active: boolean // Tooltip is active
offset: ElementOffset // Tooltip offset
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Dependencies
*/
interface Dependencies {
content$: Observable<HTMLElement> // Tooltip content observable
viewport$: Observable<Viewport> // Viewport observable
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for tooltips
*/
let sequence = 0
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch tooltip
*
* This function tracks the tooltip host element, and deduces the active state
* and offset of the tooltip from it. The active state is determined by whether
* the host element is focused or hovered, and the offset is determined by the
* host element's absolute position in the document.
*
* @param el - Tooltip host element
*
* @returns Tooltip observable
*/
export function watchTooltip2(
el: HTMLElement
): Observable<Tooltip> {
// Compute whether tooltip should be shown - we need to watch both focus and
// hover events on the host element and emit if one of them is active. In case
// of a hover event, we keep the element visible for a short amount of time
// after the pointer left the host element for a better user experience.
const active$ =
combineLatest([
watchElementFocus(el),
watchElementHover(el)
])
.pipe(
map(([focus, hover]) => focus || hover),
distinctUntilChanged()
)
// We need to determine all parent elements of the host element that are
// currently scrollable, as they might affect the position of the tooltip
// depending on their horizontal of vertical offset. We must track all of
// them and recompute the position of the tooltip if they change.
const offset$ =
defer(() => getElementContainers(el)).pipe(
mergeMap(watchElementContentOffset),
throttleTime(1),
map(() => getElementOffsetAbsolute(el))
)
// Only track parent elements and compute offset of the tooltip host if the
// tooltip should be shown - we defer the computation of the offset until the
// tooltip becomes active for the first time. This is necessary, because we
// must also keep the tooltip active as long as it is focused or hovered.
return active$.pipe(
first(active => active),
switchMap(() => combineLatest([active$, offset$])),
map(([active, offset]) => ({ active, offset })),
share()
)
}
/**
* Mount tooltip
*
* This function renders a tooltip with the content from the provided `content$`
* observable as passed via the dependencies. If the returned element has a role
* of type `dialog`, the tooltip is considered to be interactive, and rendered
* either above or below the host element, depending on the available space.
*
* If the returned element has a role of type `tooltip`, the tooltip is always
* rendered below the host element and considered to be non-interactive. This
* allows us to reuse the same positioning logic for both interactive and
* non-interactive tooltips, as it is largely the same.
*
* @param el - Tooltip host element
* @param dependencies - Dependencies
*
* @returns Tooltip component observable
*/
export function mountTooltip2(
el: HTMLElement, dependencies: Dependencies
): Observable<Component<Tooltip>> {
const { content$, viewport$ } = dependencies
// Compute unique tooltip id - this is necessary to associate the tooltip host
// element with the tooltip element for ARIA purposes
const id = `__tooltip2_${sequence++}`
// Create component on subscription
return defer(() => {
const push$ = new Subject<Tooltip>()
// Create subject to track tooltip presence and visibility - we use another
// purely internal subject to track the tooltip's presence and visibility,
// as the tooltip should be visible if the host element or tooltip itself
// is focused or hovered to allow for smooth pointer migration
const show$ = new BehaviorSubject(false)
push$.pipe(ignoreElements(), endWith(false))
.subscribe(show$)
// Create observable controlling tooltip element - we create and attach the
// tooltip only if it is actually present, in order to keep the number of
// elements low. We need to keep the tooltip visible for a short time after
// the pointer left the host element or tooltip itself. For this, we use an
// inner subscription to the tooltip observable, which we terminate when the
// tooltip should not be shown, automatically removing the element. Moreover
// we use the queue scheduler, which will schedule synchronously in case the
// tooltip should be shown, and asynchronously if it should be hidden.
const node$ = show$.pipe(
debounce(active => timer(+!active * 250, queueScheduler)),
distinctUntilChanged(),
switchMap(active => active ? content$ : EMPTY),
tap(node => node.id = id),
share()
)
// Compute tooltip presence and visibility - the tooltip should be shown if
// the host element or the tooltip itself is focused or hovered
combineLatest([
push$.pipe(map(({ active }) => active)),
node$.pipe(
switchMap(node => watchElementHover(node, 250)),
startWith(false)
)
])
.pipe(map(states => states.some(active => active)))
.subscribe(show$)
// Compute tooltip origin - we need to compute the tooltip origin depending
// on the position of the host element, the viewport size, as well as the
// actual size of the tooltip, if positioned above. The tooltip must about
// to be rendered for this to be correct, which is why we do it here.
const origin$ = show$.pipe(
filter(active => active),
withLatestFrom(node$, viewport$),
map(([_, node, { size }]) => {
const host = el.getBoundingClientRect()
const x = host.width / 2
// If the tooltip is non-interactive, we always render it below the
// actual element because all operating systems do it that way
if (node.role === "tooltip") {
return { x, y: 8 + host.height }
// Otherwise, we determine where there is more space, and render the
// tooltip either above or below the host element
} else if (host.y >= size.height / 2) {
const { height } = getElementSize(node)
return { x, y: -16 - height }
} else {
return { x, y: +16 + host.height }
}
})
)
// Update tooltip position - we always need to update the position of the
// tooltip, as it might change depending on the viewport offset of the host
combineLatest([node$, push$, origin$])
.subscribe(([node, { offset }, origin]) => {
node.style.setProperty("--md-tooltip-host-x", `${offset.x}px`)
node.style.setProperty("--md-tooltip-host-y", `${offset.y}px`)
// Update tooltip origin - this is mainly set to determine the position
// of the tooltip tail, to show the direction it is originating from
node.style.setProperty("--md-tooltip-x", `${origin.x}px`)
node.style.setProperty("--md-tooltip-y", `${origin.y}px`)
// Update tooltip render location, i.e., whether the tooltip is shown
// above or below the host element, depending on the available space
node.classList.toggle("md-tooltip2--top", origin.y < 0)
node.classList.toggle("md-tooltip2--bottom", origin.y >= 0)
})
// Update tooltip width - we only explicitly set the width of the tooltip
// if it is non-interactive, in case it should always be rendered centered
show$.pipe(
filter(active => active),
withLatestFrom(node$, (_, node) => node),
filter(node => node.role === "tooltip")
)
.subscribe(node => {
const size = getElementSize(getElement(":scope > *", node))
// Set tooltip width and remove tail by setting it to a width of zero -
// if authors want to keep the tail, we can move this to CSS later
node.style.setProperty("--md-tooltip-width", `${size.width}px`)
node.style.setProperty("--md-tooltip-tail", `${0}px`)
})
// Update tooltip visibility - we defer to the next animation frame, because
// the tooltip must first be added to the document before we make it appear,
// or it will appear instantly without delay. Additionally, we need to keep
// the tooltip visible for a short time after the pointer left the host.
show$.pipe(
distinctUntilChanged(),
observeOn(animationFrameScheduler),
withLatestFrom(node$)
)
.subscribe(([active, node]) => {
node.classList.toggle("md-tooltip2--active", active)
})
// Set up ARIA attributes when tooltip is visible
combineLatest([
show$.pipe(filter(active => active)),
node$
])
.subscribe(([_, node]) => {
if (node.role === "dialog") {
el.setAttribute("aria-controls", id)
el.setAttribute("aria-haspopup", "dialog")
} else {
el.setAttribute("aria-describedby", id)
}
})
// Remove ARIA attributes when tooltip is hidden
show$.pipe(filter(active => !active))
.subscribe(() => {
el.removeAttribute("aria-controls")
el.removeAttribute("aria-describedby")
el.removeAttribute("aria-haspopup")
})
// Create and return component
return watchTooltip2(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}
// ----------------------------------------------------------------------------
/**
* Mount inline tooltip
*
* @todo refactor this function
*
* @param el - Tooltip host element
* @param dependencies - Dependencies
* @param container - Container
*
* @returns Tooltip component observable
*/
export function mountInlineTooltip2(
el: HTMLElement, { viewport$ }: { viewport$: Observable<Viewport> },
container = document.body
): Observable<Component<Tooltip>> {
return mountTooltip2(el, {
content$: new Observable<HTMLElement>(observer => {
const title = el.title
const node = renderInlineTooltip2(title)
observer.next(node)
el.removeAttribute("title")
// Append tooltip and remove on unsubscription
container.append(node)
return () => {
node.remove()
el.setAttribute("title", title)
}
}),
viewport$
})
}

View File

@ -33,10 +33,11 @@ import {
} from "rxjs"
import {
Viewport,
getElements,
watchElementVisibility
} from "~/browser"
import { mountTooltip } from "~/components"
import { mountInlineTooltip2 } from "~/components/tooltip2"
/* ----------------------------------------------------------------------------
* Helper types
@ -47,6 +48,7 @@ import { mountTooltip } from "~/components"
*/
interface PatchOptions {
document$: Observable<Document> /* Document observable */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
@ -64,7 +66,7 @@ interface PatchOptions {
* @param options - Options
*/
export function patchEllipsis(
{ document$ }: PatchOptions
{ document$, viewport$ }: PatchOptions
): void {
document$
.pipe(
@ -84,7 +86,7 @@ export function patchEllipsis(
host.title = text
/* Mount tooltip */
return mountTooltip(host)
return mountInlineTooltip2(host, { viewport$ })
.pipe(
takeUntil(document$.pipe(skip(1))),
finalize(() => host.removeAttribute("title"))
@ -97,7 +99,7 @@ export function patchEllipsis(
document$
.pipe(
switchMap(() => getElements(".md-status")),
mergeMap(el => mountTooltip(el))
mergeMap(el => mountInlineTooltip2(el, { viewport$ }))
)
.subscribe()
}

View File

@ -20,6 +20,8 @@
* IN THE SOFTWARE.
*/
import { ComponentChild } from "preact"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
@ -61,3 +63,16 @@ export function renderTooltip(
)
}
}
// @todo: rename
export function renderInlineTooltip2(
...children: ComponentChild[]
): HTMLElement {
return (
<div class="md-tooltip2" role="tooltip">
<div class="md-tooltip2__inner md-typeset">
{children}
</div>
</div>
)
}

View File

@ -66,6 +66,7 @@
@import "main/components/tabs";
@import "main/components/tag";
@import "main/components/tooltip";
@import "main/components/tooltip2";
@import "main/components/top";
@import "main/components/version";

View File

@ -0,0 +1,210 @@
////
/// Copyright (c) 2016-2024 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Tooltip variables
:root {
--md-tooltip-width: #{px2rem(400px)};
--md-tooltip-tail: #{px2rem(6px)};
}
// ----------------------------------------------------------------------------
// Tooltip
.md-tooltip2 {
position: absolute;
// Note that the top offset is computed from the host element offset plus the
// tooltip offset, which is always measured relative to the host element
top:
calc(
var(--md-tooltip-host-y) +
var(--md-tooltip-y)
);
inline-size: 100%;
// Hack: set an explicit `z-index` so we can transition it to ensure that any
// following elements are not overlaying the tooltip during the transition.
z-index: 0;
font-family: var(--md-text-font-family);
color: var(--md-default-fg-color);
pointer-events: none;
opacity: 0;
transition:
transform 0ms 250ms,
opacity 250ms,
z-index 250ms;
transform: translateY(px2rem(-8px));
// We explicitly set the origin to the tooltip tail, allowing the author to
// easily add further transforms to the tooltip, customizing the transition
transform-origin:
calc(
var(--md-tooltip-host-x) +
var(--md-tooltip-x)
)
0;
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
// Tooltip tail
&::before {
position: absolute;
// The offset of the tooltip tail is computed from the host element offset,
// plus the tooltip offset, which equals the center of the host element,
// and minus the half width of the tooltip tail to center it. Then, on both
// sides, the tooltip tail is padded with 150% of the inset area.
left:
clamp(
1.5 * #{px2rem(16px)},
calc(
var(--md-tooltip-host-x) +
var(--md-tooltip-x) -
var(--md-tooltip-tail)
),
calc(
100vw -
2 * var(--md-tooltip-tail) -
1.5 * #{px2rem(16px)}
)
);
z-index: 1;
display: block;
content: "";
border-inline: var(--md-tooltip-tail) solid transparent;
}
// Tooltip tail if rendered above target
&--top::before {
bottom: calc(-1 * var(--md-tooltip-tail) + px2rem(0.5px));
filter: drop-shadow(0 1px 0 hsla(0, 0%, 0%, 0.05));
border-top: var(--md-tooltip-tail) solid var(--md-default-bg-color);
}
// Tooltip tail if rendered below target
&--bottom::before {
top: calc(-1 * var(--md-tooltip-tail) + px2rem(0.5px));
filter: drop-shadow(0 -1px 0 hsla(0, 0%, 0%, 0.05));
border-bottom: var(--md-tooltip-tail) solid var(--md-default-bg-color);
}
// Tooltip is visible
&--active {
z-index: 2;
opacity: 1;
transition:
transform 400ms cubic-bezier(0, 1, 0.5, 1),
opacity 250ms,
z-index 0ms;
transform: translateY(0);
}
// Tooltip wrapper
&__inner {
position: relative;
// The tooltip is slightly moved to the left, so it nicely aligns with the
// content of the tooltip set by the padding of this element. On both sides,
// the tooltip is padded with the inset area, so it never touches the edge
// of the window for a better user experience.
left:
clamp(
#{px2rem(16px)},
calc(
var(--md-tooltip-host-x) -
#{px2rem(16px)}
),
calc(
100vw -
var(--md-tooltip-width) -
#{px2rem(16px)}
)
);
max-width: calc(100vw - 2 * #{px2rem(16px)});
max-height: 40vh;
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px);
box-shadow: var(--md-shadow-z2);
scrollbar-width: thin;
scrollbar-gutter: stable;
// Webkit scrollbar
&::-webkit-scrollbar {
width: px2rem(4px);
height: px2rem(4px);
}
// Webkit scrollbar thumb
&::-webkit-scrollbar-thumb {
background-color: var(--md-default-fg-color--lighter);
// Webkit scrollbar thumb on hover
&:hover {
background-color: var(--md-accent-fg-color);
}
}
// Tooltip is non-interactive - this role should be set if the tooltip has
// only informational and non-interactive content, e.g., an actual tooltip.
// It has no explicitl width set, uses a smaller font, and is centered,
// other than a tooltip with typesetted content.
[role="tooltip"] > & {
left:
clamp(
#{px2rem(16px)},
calc(
var(--md-tooltip-host-x) +
var(--md-tooltip-x) -
var(--md-tooltip-width) / 2
),
calc(
100vw -
var(--md-tooltip-width) -
#{px2rem(16px)}
)
);
width: fit-content;
// @todo refactor - this is currently a hack to fix overly long tooltips,
// but should be refactored in the future to be more flexible
max-width:
min(
calc(100vw - 2 * #{px2rem(16px)}),
400px
);
padding: px2rem(4px) px2rem(8px);
font-size: px2rem(10px);
font-weight: 700;
// If the author wishes to keep the tooltip visible upon hover and make
// the text selectable, this property can be set to `initial`
user-select: none;
}
// Adjust spacing on first child
&.md-typeset > :first-child {
margin-top: 0;
}
// Adjust spacing on last child
&.md-typeset > :last-child {
margin-bottom: 0;
}
}
}