Improved tooltips

This commit is contained in:
Eduard Urbach 2019-07-06 13:01:26 +09:00
parent 79d04cf55a
commit c281f87335
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
5 changed files with 168 additions and 187 deletions

View File

@ -14,6 +14,7 @@ import ServerEvents from "./ServerEvents"
import { displayAiringDate, displayDate, displayTime } from "./DateView" import { displayAiringDate, displayDate, displayTime } from "./DateView"
import { findAll, supportsWebP, requestIdleCallback, swapElements, delay, findAllInside } from "./Utils" import { findAll, supportsWebP, requestIdleCallback, swapElements, delay, findAllInside } from "./Utils"
import * as actions from "./Actions" import * as actions from "./Actions"
import ToolTip from "./Elements/tool-tip/tool-tip"
export default class AnimeNotifier { export default class AnimeNotifier {
app: Application app: Application
@ -39,6 +40,7 @@ export default class AnimeNotifier {
lastReloadContentPath: string lastReloadContentPath: string
currentMediaId: string currentMediaId: string
serverEvents: ServerEvents serverEvents: ServerEvents
tip: ToolTip
constructor(app: Application) { constructor(app: Application) {
this.app = app this.app = app
@ -53,31 +55,6 @@ export default class AnimeNotifier {
// Never remove src property on diffs // Never remove src property on diffs
Diff.persistentAttributes.add("src") Diff.persistentAttributes.add("src")
// Is intersection observer supported?
if("IntersectionObserver" in window) {
// Enable lazy load
this.visibilityObserver = new IntersectionObserver(
entries => {
for(let entry of entries) {
if(entry.isIntersecting) {
entry.target["became visible"]()
this.visibilityObserver.unobserve(entry.target)
}
}
},
{}
)
} else {
// Disable lazy load feature
this.visibilityObserver = {
disconnect: () => {},
observe: (elem: HTMLElement) => {
elem["became visible"]()
},
unobserve: (_: HTMLElement) => {}
} as IntersectionObserver
}
} }
init() { init() {
@ -124,6 +101,39 @@ export default class AnimeNotifier {
} }
} }
// Web components
this.registerWebComponents()
// Tooltip
this.tip = new ToolTip()
document.body.appendChild(this.tip)
document.addEventListener("linkclicked", () => this.tip.classList.add("fade-out"))
// Intersection observer
if("IntersectionObserver" in window) {
// Enable lazy load
this.visibilityObserver = new IntersectionObserver(
entries => {
for(let entry of entries) {
if(entry.isIntersecting) {
entry.target["became visible"]()
this.visibilityObserver.unobserve(entry.target)
}
}
},
{}
)
} else {
// Disable lazy load feature
this.visibilityObserver = {
disconnect: () => {},
observe: (elem: HTMLElement) => {
elem["became visible"]()
},
unobserve: (_: HTMLElement) => {}
} as IntersectionObserver
}
// Status message // Status message
this.statusMessage = new StatusMessage( this.statusMessage = new StatusMessage(
document.getElementById("status-message") as HTMLElement, document.getElementById("status-message") as HTMLElement,
@ -183,7 +193,7 @@ export default class AnimeNotifier {
Promise.resolve().then(() => this.dragAndDrop()), Promise.resolve().then(() => this.dragAndDrop()),
Promise.resolve().then(() => this.colorBoxes()), Promise.resolve().then(() => this.colorBoxes()),
Promise.resolve().then(() => this.loadCharacterRanking()), Promise.resolve().then(() => this.loadCharacterRanking()),
Promise.resolve().then(() => this.assignTooltipOffsets()), Promise.resolve().then(() => this.prepareTooltips()),
Promise.resolve().then(() => this.countUp()) Promise.resolve().then(() => this.countUp())
]) ])
@ -200,6 +210,23 @@ export default class AnimeNotifier {
} }
} }
registerWebComponents() {
if(!("customElements" in window)) {
console.warn("Web components not supported in your current browser")
return
}
// Custom element names must have a dash in their name
const elements = new Map<string, Function>([
["tool-tip", ToolTip]
])
// Register all custom elements
for(const [tag, definition] of elements.entries()) {
window.customElements.define(tag, definition)
}
}
applyPageTitle() { applyPageTitle() {
let headers = document.getElementsByTagName("h1") let headers = document.getElementsByTagName("h1")
@ -313,80 +340,24 @@ export default class AnimeNotifier {
} }
} }
assignTooltipOffsets(elements?: IterableIterator<HTMLElement>) { prepareTooltips(elements?: IterableIterator<HTMLElement>) {
requestIdleCallback(() => {
const distanceToBorder = 5
let contentRect: ClientRect
if(!elements) { if(!elements) {
elements = findAll("tip") elements = findAll("tip")
} }
this.tip.setAttribute("active", "false")
// Assign mouse enter event handler // Assign mouse enter event handler
for(let element of elements) { for(let element of elements) {
Diff.mutations.queue(() => {
element.classList.add("tip-active")
})
element.onmouseenter = () => { element.onmouseenter = () => {
Diff.mutations.queue(() => { this.tip.classList.remove("fade-out")
if(!contentRect) { this.tip.show(element)
contentRect = this.app.content.getBoundingClientRect()
} }
// Dynamic label assignment to prevent label texts overflowing element.onmouseleave = () => {
// and taking horizontal space at page load. this.tip.hide()
let label = element.getAttribute("aria-label")
if(!label) {
console.error("Tooltip without a label:", element)
return
}
element.dataset.label = label
// This is the most expensive call in this whole function,
// it consumes about 2-4 ms every time you call it.
let rect = element.getBoundingClientRect()
// Calculate offsets
let tipStyle = window.getComputedStyle(element, ":before")
if(!tipStyle.width || !tipStyle.paddingLeft) {
console.error("Tooltip with incorrect computed style:", element)
return
}
let tipWidth = parseInt(tipStyle.width) + parseInt(tipStyle.paddingLeft) * 2
let tipStartX = rect.left + rect.width / 2 - tipWidth / 2 - contentRect.left
let tipEndX = tipStartX + tipWidth
let leftOffset = 0
if(tipStartX < distanceToBorder) {
leftOffset = -tipStartX + distanceToBorder
} else if(tipEndX > contentRect.width - distanceToBorder) {
leftOffset = -(tipEndX - contentRect.width + distanceToBorder)
}
if(leftOffset !== 0) {
element.classList.remove("tip-active")
element.classList.add("tip-offset-root")
let tipChild = document.createElement("div")
tipChild.classList.add("tip-offset-child")
tipChild.setAttribute("data-label", element.dataset.label)
tipChild.style.left = Math.round(leftOffset) + "px"
tipChild.style.width = rect.width + "px"
tipChild.style.height = rect.height + "px"
element.appendChild(tipChild)
}
// Unassign event listener
element.onmouseenter = null
})
} }
} }
})
} }
dragAndDrop() { dragAndDrop() {
@ -1272,7 +1243,7 @@ export default class AnimeNotifier {
this.app.ajaxify(element.getElementsByTagName("a")) this.app.ajaxify(element.getElementsByTagName("a"))
this.lazyLoad(findAllInside("lazy", element)) this.lazyLoad(findAllInside("lazy", element))
this.mountMountables(findAllInside("mountable", element)) this.mountMountables(findAllInside("mountable", element))
this.assignTooltipOffsets(findAllInside("tip", element)) this.prepareTooltips(findAllInside("tip", element))
this.textAreaFocus() this.textAreaFocus()
} }

View File

@ -237,6 +237,7 @@ export default class Application {
e.preventDefault() e.preventDefault()
// Prevent loading the same page
let url = (this as HTMLAnchorElement).getAttribute("href") let url = (this as HTMLAnchorElement).getAttribute("href")
if(!url || url === self.currentPath) { if(!url || url === self.currentPath) {
@ -245,6 +246,7 @@ export default class Application {
// Load requested page // Load requested page
self.load(url) self.load(url)
self.emit("linkclicked")
} }
} }
} }

View File

@ -0,0 +1,38 @@
const tip-opacity = 1
const tip-transform-hidden = translateY(20%)
const tip-transform-visible = translateY(0)
const tip-offset = -0.6rem
tool-tip
position absolute
z-index 100000
opacity 0
transform tip-transform-hidden
transition opacity transition-speed ease, transform transition-speed ease
pointer-events none
margin-top tip-offset
[active="true"]
opacity tip-opacity
transform tip-transform-visible
.box
color text-color
text-shadow none
padding 0.6rem 0.8rem
background tip-bg-color
font-size 0.92rem
border 1px solid ui-border-color
border-radius ui-element-border-radius
box-shadow shadow-light
white-space nowrap
.arrow
position relative
width 6px
height 6px
margin-top -1px
transform translateX(-50%)
border-style solid
border-width 6px 6px 0 6px
border-color tip-bg-color transparent transparent transparent

View File

@ -0,0 +1,60 @@
import Diff from "scripts/Diff"
export default class ToolTip extends HTMLElement {
anchor: HTMLElement
box: HTMLElement
arrow: HTMLElement
connectedCallback() {
this.box = document.createElement("div")
this.box.classList.add("box")
this.appendChild(this.box)
this.arrow = document.createElement("div")
this.arrow.classList.add("arrow")
this.appendChild(this.arrow)
}
hide() {
this.setAttribute("active", "false")
}
show(anchor: HTMLElement) {
const distanceToBorder = 5
this.anchor = anchor
this.box.textContent = this.anchor.getAttribute("aria-label")
let anchorRect = this.anchor.getBoundingClientRect()
let boxRect = this.box.getBoundingClientRect()
let finalX = anchorRect.left + anchorRect.width / 2 - boxRect.width / 2
let finalY = anchorRect.top - boxRect.height
let contentRect = {
left: distanceToBorder,
top: distanceToBorder,
right: document.body.clientWidth - distanceToBorder,
bottom: document.body.clientHeight - distanceToBorder
}
let offsetX = 0
if(finalX < contentRect.left) {
offsetX = contentRect.left - finalX
finalX += offsetX
} else if(finalX + boxRect.width > contentRect.right) {
offsetX = contentRect.right - (finalX + boxRect.width)
finalX += offsetX
}
let arrowX = boxRect.width / 2 - offsetX
Diff.mutations.queue(() => {
this.style.left = finalX + "px"
this.style.top = finalY + "px"
this.arrow.style.left = arrowX + "px"
this.setAttribute("active", "true")
})
}
}

View File

@ -1,90 +0,0 @@
const tip-opacity = 0.97
const tip-transform-hidden = translateX(-50%) translateY(-80%)
const tip-transform-visible = translateX(-50%) translateY(-100%)
mixin tip-before
content attr(data-label)
position absolute
top -10px
left 50%
z-index 100000
pointer-events none
opacity 0
transform tip-transform-hidden
font-size 0.92rem
color text-color
text-shadow none
padding 0.2rem 0.7rem
background tip-bg-color
border 1px solid ui-border-color
border-radius ui-element-border-radius
box-shadow shadow-light
white-space nowrap
default-transition
mixin tip-after
content ""
position absolute
top -4px
left 50%
z-index 100001
pointer-events none
opacity 0
width 0
height 0
border-style solid
border-width 8px 8px 0 8px
border-color tip-bg-color transparent transparent transparent
transform tip-transform-hidden
default-transition
.tip-offset-child
position absolute !important
left 0
top 0
pointer-events none
// Tooltips are not activated by default.
// This helps loading times as the browser
// doesn't need to create pseudo elements on page load.
.tip
// ...
// When the browser is idle, we give all tips the "tip-active" class.
.tip-active
position relative
:before
tip-before
:after
tip-after
:hover
:before
opacity tip-opacity
transform tip-transform-visible
:after
opacity tip-opacity
transform tip-transform-visible
.tip-offset-root
position relative
:after
tip-after
.tip-offset-child
:before
tip-before
:hover
:after
opacity tip-opacity
transform tip-transform-visible
.tip-offset-child
:before
opacity tip-opacity
transform tip-transform-visible