mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-06-14 11:52:32 +03:00
Merge pull request #7045 from squidfunk/refactor/tooltip-positioning
Refactor tooltips
This commit is contained in:
commit
f028004c59
File diff suppressed because one or more lines are too long
18
material/overrides/assets/javascripts/custom.e2e97759.min.js
vendored
Normal file
18
material/overrides/assets/javascripts/custom.e2e97759.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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 %}
|
||||
|
29
material/templates/assets/javascripts/bundle.3220b9d7.min.js
vendored
Normal file
29
material/templates/assets/javascripts/bundle.3220b9d7.min.js
vendored
Normal file
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
File diff suppressed because one or more lines are too long
@ -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 %}
|
||||
|
@ -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";
|
||||
|
||||
|
210
src/templates/assets/stylesheets/main/components/_tooltip2.scss
Normal file
210
src/templates/assets/stylesheets/main/components/_tooltip2.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user