mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-07-06 14:12:40 -04:00
Refactored tooltips
This commit is contained in:
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export function watchElementContentOffset(
|
||||
): Observable<ElementOffset> {
|
||||
return merge(
|
||||
fromEvent(el, "scroll"),
|
||||
fromEvent(window, "scroll"),
|
||||
fromEvent(window, "resize")
|
||||
)
|
||||
.pipe(
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ keyboard$
|
||||
})
|
||||
|
||||
/* Set up patches */
|
||||
patchEllipsis({ document$ })
|
||||
patchEllipsis({ viewport$, document$ })
|
||||
patchIndeterminate({ document$, tablet$ })
|
||||
patchScrollfix({ document$ })
|
||||
patchScrolllock({ viewport$, tablet$ })
|
||||
|
||||
@@ -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$ }))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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$ }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
361
src/templates/assets/javascripts/components/tooltip2/index.ts
Normal file
361
src/templates/assets/javascripts/components/tooltip2/index.ts
Normal 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$
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
203
src/templates/assets/stylesheets/main/components/_tooltip2.scss
Normal file
203
src/templates/assets/stylesheets/main/components/_tooltip2.scss
Normal file
@@ -0,0 +1,203 @@
|
||||
////
|
||||
/// 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user