Working tooltips!

This commit is contained in:
Melvin Valster
2019-07-26 18:20:08 +02:00
parent 90fdc8d0e9
commit 4f72889e68
18 changed files with 476 additions and 127 deletions
+8
View File
@@ -64,4 +64,12 @@ footer {
padding: 2em;
font-size: 12px;
text-align: center;
}
#tooltip-root {
position: absolute;
z-index: $z-index-tooltips;
top: 0;
left: 0;
pointer-events: none;
}
+13 -7
View File
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'
import { classByName } from '../data/classes'
import { Icon } from './Icon'
import classNames from 'classnames'
import { Controller, Trigger, Tooltip } from './Tooltip';
interface Props {
/** Name of the selected class, lowercase */
@@ -31,13 +32,18 @@ export class ClassPicker extends React.PureComponent<Props> {
return (
<ul className={cn}>
{Object.values(classByName).map((c) =>
<li key={c.id} className={classNameForItem(c, selected)}>
<Link to={`/${c.name.toLowerCase()}`} title={c.name}>
<Icon
name={c.icon}
golden={selected === c.name.toLowerCase()}
/>
</Link>
<li key={c.id} className={classNameForItem(c, selected)}>
<Controller>
<Trigger>
<Link to={`/${c.name.toLowerCase()}`}>
<Icon
name={c.icon}
golden={selected === c.name.toLowerCase()}
/>
</Link>
</Trigger>
<Tooltip>{c.name}</Tooltip>
</Controller>
</li>
)}
</ul>
+5 -5
View File
@@ -1,4 +1,4 @@
import React, { FC, useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import classNames from 'classnames'
import './Icon.scss'
@@ -11,8 +11,8 @@ interface Props {
const NOT_FOUND_ICON = 'inv_misc_questionmark'
export const Icon: FC<Props> = (props) => {
const { name: defaultName, size = 'medium', golden = false, children } = props
export const Icon = React.forwardRef<HTMLDivElement, Props>((props, ref) => {
const { name: defaultName, size = 'medium', golden = false, children, ...rest } = props
const [hasLoadedImage, setLoadedImage] = useState(false)
const [fadeIn, setFadeIn] = useState(false)
const [name, setName] = useState(defaultName)
@@ -43,7 +43,7 @@ export const Icon: FC<Props> = (props) => {
})
return (
<div className={className}>
<div className={className} ref={ref} {...rest}>
{url &&
<div className="icon__bg" style={{ backgroundImage: `url(${url})` }} />
}
@@ -51,4 +51,4 @@ export const Icon: FC<Props> = (props) => {
{children}
</div>
)
}
})
+16 -14
View File
@@ -1,21 +1,23 @@
import React, { FC } from 'react'
import { Tooltip } from './Tooltip'
import React from 'react'
import { Tooltip, Props as BaseProps } from './Tooltip'
import spells from '../data/spells.json'
interface Props {
interface Props extends BaseProps {
id: number
}
export const SpellTooltip: FC<Props> = ({ id }) => {
const spell: SpellData = spells[id.toString()]
if (!spell) {
return <Tooltip fixed>Spell not found :(</Tooltip>
}
return <Tooltip fixed title={spell.name} icon={spell.icon}>
{spell.rank &&
<p className="tight">Rank {spell.rank}</p>
export class SpellTooltip extends React.PureComponent<Props> {
render() {
const spell: SpellData = spells[this.props.id.toString()]
if (!spell) {
return <Tooltip fixed {...this.props}>Spell not found :(</Tooltip>
}
<p className="yellow">{spell.description}</p>
</Tooltip>
return <Tooltip fixed title={spell.name} icon={spell.icon} {...this.props}>
{spell.rank &&
<p className="tight">Rank {spell.rank}</p>
}
<p className="yellow">{spell.description}</p>
</Tooltip>
}
}
+59 -48
View File
@@ -1,66 +1,77 @@
import './Talent.scss'
import React, { FC } from 'react'
import React from 'react'
import { Icon } from './Icon'
import classNames from 'classnames'
import { Controller, Trigger } from './Tooltip'
import { TalentTooltip } from './TalentTooltip'
interface Props {
talent: TalentData
specId?: number
points?: number
disabled?: boolean
errors?: string
onClick?: (talentId: number) => void
onRightClick?: (talentId: number) => void
}
const defaultProps: Partial<Props> = {
points: 0,
disabled: false,
onClick: () => undefined,
onRightClick: () => undefined
}
export class Talent extends React.PureComponent<Props> {
static whyDidYouRender = true
export const Talent: FC<Props> = (props) => {
const { talent, points, disabled } = props
const showPoints = !disabled || points > 0
const containerClassNames = classNames('talent', {
'talent--disabled': disabled && points === 0,
'talent--available': !disabled && points < talent.ranks.length,
'talent--disabled-with-points': points >= talent.ranks.length || (points > 0 && disabled)
})
const pointsClassNames = classNames('point-label', {
'point-label--enabled': !disabled
})
const handleContextMenu = (e) => {
if (props.onRightClick) props.onRightClick(talent.id)
e.preventDefault()
return false
static defaultProps = {
points: 0,
disabled: false,
onClick: () => undefined,
onRightClick: () => undefined
}
return (
<div
className={containerClassNames}
title={talent.ranks[0].toString()}
data-row={talent.row}
data-col={talent.col}
onClick={!disabled ? () => props.onClick(talent.id) : () => {}}
onContextMenu={handleContextMenu}
>
<div className="talent__status" />
<Icon name={talent.icon} size="medium" />
{showPoints &&
<div className={pointsClassNames}>
{points}
/{talent.ranks.length}
</div>
}
</div>
)
render() {
const { talent, points, errors, disabled } = this.props
const showPoints = !disabled || points > 0
const containerClassNames = classNames('talent', {
'talent--disabled': disabled && points === 0,
'talent--available': !disabled && points < talent.ranks.length,
'talent--disabled-with-points': points >= talent.ranks.length || (points > 0 && disabled)
})
const pointsClassNames = classNames('point-label', {
'point-label--enabled': !disabled
})
const handleContextMenu = (e) => {
if (this.props.onRightClick) this.props.onRightClick(talent.id)
e.preventDefault()
return false
}
return (
<Controller>
<Trigger>
<div
className={containerClassNames}
data-row={talent.row}
data-col={talent.col}
onClick={!disabled ? () => this.props.onClick(talent.id) : () => {}}
onContextMenu={handleContextMenu}
>
<div className="talent__status" />
<Icon name={talent.icon} size="medium" />
{showPoints &&
<div className={pointsClassNames}>
{points}/{talent.ranks.length}
</div>
}
</div>
</Trigger>
<TalentTooltip talent={talent} points={points} errors={errors}>
{!disabled && points < talent.ranks.length && <p className="green tight">Click to learn</p>}
{points > 0 && <p className="green">Right-click to unlearn</p>}
</TalentTooltip>
</Controller>
)
}
}
Talent.defaultProps = defaultProps
;(Talent as any).whyDidYouRender = true
// ;(Talent as any).whyDidYouRender = true
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import { Props as BaseProps, Tooltip } from './Tooltip'
import spells from '../data/spells.json'
interface Props extends BaseProps {
talent: TalentData
points: number
errors?: string
currentSpellId?: number
nextSpellId?: number
}
export class TalentTooltip extends React.PureComponent<Props> {
render() {
const { talent, points, errors } = this.props
const currentSpellId = talent.ranks[Math.max(0, points - 1)]
const nextSpellId = points > 0 && points < talent.ranks.length && talent.ranks[Math.max(0, points)]
const currentSpell: SpellData = spells[currentSpellId.toString()]
const nextSpell: SpellData = spells[nextSpellId.toString()]
const title = (currentSpell || nextSpell).name
const rank = (currentSpell || nextSpell).rank
const description = (currentSpell || nextSpell).description
return <Tooltip fixed title={title} icon={false}>
{rank &&
<p className="tight">Rank {points}/{talent.ranks.length}</p>
}
{errors && errors.split('_').map((err, index) =>
<p key={index} className="tight" style={{ color: 'red' }}>{err}</p>
)}
<p className="yellow">{description}</p>
{nextSpell && <React.Fragment>
<p className="tight">Next rank:</p>
<p className="yellow">{nextSpell.description}</p>
</React.Fragment>}
{this.props.children}
</Tooltip>
}
}
+5 -2
View File
@@ -2,7 +2,7 @@ import './TalentTree.scss'
import React, { useCallback } from 'react'
import { Map } from 'immutable'
import { Talent } from './Talent';
import { getPointsInSpec, canLearnTalent, SORT_TALENTS_DESC } from '../lib/tree';
import { getPointsInSpec, canLearnTalent, SORT_TALENTS_DESC, getUnmetRequirements } from '../lib/tree';
import { talentsBySpec, specNames, talentsById } from '../data/talents'
import { Arrow } from './Arrow'
@@ -15,6 +15,7 @@ interface Props {
export const TalentTree: React.FC<Props> = ({ specId, knownTalents, availablePoints, onTalentPress }) => {
const talents = Object.values(talentsBySpec[specId]).sort(SORT_TALENTS_DESC)
const pointsInSpec = getPointsInSpec(specId, knownTalents)
const handleClick = useCallback(
(talentId) => onTalentPress(specId, talentId, 1),
@@ -28,18 +29,20 @@ export const TalentTree: React.FC<Props> = ({ specId, knownTalents, availablePoi
return (
<div className="tree">
<div className="tree__header">
<h3>{specNames[specId]} ({getPointsInSpec(specId, knownTalents)})</h3>
<h3>{specNames[specId]} ({pointsInSpec})</h3>
</div>
<div className="tree__body" style={{ backgroundImage: `url(${require(`../images/specs/${specId}.jpg`)})` }}>
{talents.map((talent) => {
const points = knownTalents.get(talent.id, 0)
const canLearn = canLearnTalent(knownTalents, talent)
const unmetRequirements = getUnmetRequirements(talent, knownTalents, pointsInSpec, specNames[specId])
return <React.Fragment key={talent.id}>
<Talent
talent={talent}
points={points}
errors={unmetRequirements}
onClick={handleClick}
onRightClick={handleRightClick}
disabled={availablePoints === 0 || !canLearn}
-45
View File
@@ -1,45 +0,0 @@
import './Tooltip.scss'
import React, { FC } from 'react'
import classNames from 'classnames'
import { Icon } from './Icon'
interface Props {
title?: string
/** Override width of tooltip. Needs `fixed` to be true to have effect. */
width?: string
/** Fixed width */
fixed?: boolean
/** Display tooltip inline */
inline?: boolean
/** Icon to show next to tooltip */
icon?: string
}
export const Tooltip: FC<Props> = (props) => {
const { title, children } = props
const cn = classNames('tooltip', {
'tooltip--fixed': props.fixed,
'tooltip--inline': props.inline,
})
const style = {
width: props.width
}
return <div className={cn}>
{props.icon &&
<Icon className="tooltip__icon" name={props.icon} />
}
<div className="tooltip__inner" style={style}>
<div className="tooltip__top">
<div className="tooltip__body">
{title && <div className="tooltip__title tight">{title}</div>}
{children}
</div>
</div>
<div className="tooltip__footer" />
</div>
</div>
}
+161
View File
@@ -0,0 +1,161 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { createPortal } from 'react-dom'
import { Map } from 'immutable'
const TOOLTIP_ROOT = document.getElementById('tooltip-root')
interface Props {
position?: TooltipPosition
}
export class Controller extends React.PureComponent<Props> {
static defaultProps = {
position: 'top-right'
}
tooltip = React.createRef<HTMLSpanElement>()
trigger = React.createRef<React.ReactInstance>()
state = {
isVisible: false,
tooltipDimensions: Map({
width: 0,
height: 0
}),
style: Map({
position: 'absolute' as 'absolute',
left: 0,
top: 0,
}),
triggerRect: Map({
left: 0,
top: 0,
width: 0,
height: 0
})
}
handleMouseEnter = () => {
this.setState({
isVisible: true,
style: this.state.style.merge(this.getPosition())
})
}
handleMouseLeave = () => {
this.setState({ isVisible: false })
}
componentDidUpdate(prevProps, prevState) {
if (this.state.isVisible) {
const { width, height } = (ReactDOM.findDOMNode(this.tooltip.current) as HTMLElement).getBoundingClientRect()
const { style, tooltipDimensions } = this.state
this.setState({
tooltipDimensions: tooltipDimensions.merge({ width, height }),
style: style.merge(this.getPosition())
})
}
}
getPosition = (): { top: number, left: number } => {
const { tooltipDimensions, triggerRect } = this.state
return calculatePosition(this.props.position, triggerRect, tooltipDimensions)
}
updateTriggerRect = (triggerRect: { left: number, top: number, width: number, height: number }) => {
this.setState({
triggerRect: this.state.triggerRect.merge(triggerRect)
})
}
render() {
const { children } = this.props
const { isVisible, style } = this.state
return React.Children.map(children, (child: React.ReactElement) => {
const name = (child.type as any).name
if (name === 'Trigger') {
return React.cloneElement(child, {
ref: this.trigger,
resize: this.updateTriggerRect,
onMouseEnter: this.handleMouseEnter,
onMouseLeave: this.handleMouseLeave,
})
} else {
// Tooltip
return isVisible && createPortal(
<span style={style.toJS()} ref={this.tooltip}>
{React.cloneElement(child)}
</span>,
TOOLTIP_ROOT
)
}
})
}
}
const TOOLTIP_BOUNDING_PADDING = 10
const calculatePosition = (pos: TooltipPosition, trigger: Map<string, number>, tooltip: Map<string, number>) => {
const { innerWidth: windowWidth } = window
let top = 0
let left = 0
const triggerTop = trigger.get('top') + window.pageYOffset
// Top
switch (pos) {
case 'top-left':
case 'top-right': {
top = triggerTop - (tooltip.get('height') + 5)
break
}
case 'bottom-left':
case 'bottom-right': {
top = triggerTop + trigger.get('height') + 5
break
}
}
// Left
switch (pos) {
case 'left':
case 'bottom-left':
case 'top-left': {
left = (trigger.get('left')) - tooltip.get('width')
break
}
case 'right':
case 'bottom-right':
case 'top-right': {
left = (trigger.get('left')) + trigger.get('width')
break
}
}
const overflowsRight = left + tooltip.get('width') + TOOLTIP_BOUNDING_PADDING > windowWidth
const overflowsTop = top - (window.scrollY + TOOLTIP_BOUNDING_PADDING) < 0
// Validate
switch (pos) {
case 'top-right': {
if (overflowsRight && !overflowsTop) return calculatePosition('top-left', trigger, tooltip)
if (!overflowsRight && overflowsTop) return calculatePosition('bottom-right', trigger, tooltip)
if (overflowsRight && overflowsTop) return calculatePosition('bottom-left', trigger, tooltip)
break
}
case 'top-left': {
break
}
}
return { top, left }
}
@@ -15,7 +15,7 @@
&:after {
content: '';
padding: 3px;
background: url('../images/tooltip-background.png');
background: url('../../images/tooltip-background.png');
background-position: top right;
}
}
@@ -23,7 +23,7 @@
&__body {
min-height: 2px;
padding: 8px 3px 2px 9px;
background: url('../images/tooltip-background.png');
background: url('../../images/tooltip-background.png');
background-position: top left;
}
@@ -35,14 +35,14 @@
content: '';
flex: 1;
padding: 3px;
background: url('../images/tooltip-background.png');
background: url('../../images/tooltip-background.png');
background-position: bottom left;
}
&:after {
content: '';
padding: 3px;
background: url('../images/tooltip-background.png');
background: url('../../images/tooltip-background.png');
background-position: bottom right;
}
}
@@ -57,7 +57,6 @@
}
&--fixed {
.tooltip__inner {
width: 100%;
width: 320px;
+58
View File
@@ -0,0 +1,58 @@
import './Tooltip.scss'
import React from 'react'
import classNames from 'classnames'
import { Icon } from '../Icon'
export interface Props {
title?: string
/** Override width of tooltip. Needs `fixed` to be true to have effect. */
width?: string
/** Fixed width */
fixed?: boolean
/** Display tooltip inline */
inline?: boolean
/** Icon to show next to tooltip */
icon?: string | false
anchor?: HTMLElement
style?: any
}
export class Tooltip extends React.PureComponent<Props> {
static defaultProps = {
style: {}
}
render() {
const { title, icon, children } = this.props
const cn = classNames('tooltip', {
'tooltip--fixed': this.props.fixed,
'tooltip--inline': this.props.inline,
})
const innerStyle = {
width: this.props.width
}
const style = {
opacity: 1,
...this.props.style,
}
return <div className={cn} style={style}>
{icon &&
<Icon className="tooltip__icon" name={icon} />
}
<div className="tooltip__inner" style={innerStyle}>
<div className="tooltip__top">
<div className="tooltip__body">
{title && <div className="tooltip__title tight">{title}</div>}
{children}
</div>
</div>
<div className="tooltip__footer" />
</div>
</div>
}
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { debounce } from '../../lib/helpers'
interface Props {
resize?: (rect: { left: number, top: number, width: number, height: number }) => void
children: React.ReactElement
}
export class Trigger extends React.PureComponent<Props> {
trigger = React.createRef<HTMLElement>()
get boundingRect() {
if (!this.trigger.current) {
throw new Error('Trigger does not have reference to itself')
}
const { width, height, top, left } = (ReactDOM.findDOMNode(this.trigger.current) as HTMLElement).getBoundingClientRect()
return { width, height, top, left }
}
resize = debounce(() => this.props.resize(this.boundingRect), 250)
componentDidMount() {
this.resize()
window.addEventListener('scroll', this.resize)
window.addEventListener('resize', this.resize)
}
componentWillUnmount(){
window.removeEventListener('scroll', this.resize)
window.removeEventListener('resize', this.resize)
}
componentDidUpdate() {
this.resize()
}
render() {
const { resize, children, ...props } = this.props
return React.cloneElement(children, {
...props,
ref: this.trigger
})
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './Tooltip'
export { Controller } from './Controller'
export { Trigger } from './Trigger'
+23
View File
@@ -0,0 +1,23 @@
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds. If `immediate` is passed, trigger the function on the
* leading edge, instead of the trailing.
*
* @see https://davidwalsh.name/javascript-debounce-function
*/
export function debounce(func: (...args: any[]) => void, wait: number, immediate: boolean = false) {
let timeout = null
return function () {
const context = this, args = arguments
const later = function () {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
+28 -1
View File
@@ -2,9 +2,11 @@ import { Map } from 'immutable'
import {
talentsBySpec,
talentToSpec,
talentsBySpecArray
talentsBySpecArray,
talentsById
} from '../data/talents';
import { classByName } from '../data/classes'
import spells from '../data/spells.json'
export const MAX_POINTS = 51
export const MAX_ROWS = 7
@@ -59,6 +61,31 @@ export function calcMeetsRequirements(talent: TalentData, known: Map<number, num
}, true)
}
export function getNameForTalent(talentId: number): string {
const spell: SpellData = spells[talentsById[talentId].ranks[0]]
if (spell) return spell.name
return 'Unknown'
}
export function getUnmetRequirements(talent: TalentData, known: Map<number, number>, pointsInSpec: number, specName: string): string {
const missing = []
const dependency = talent.requires[0]
if (dependency && known.get(dependency.id, 0) < dependency.qty) {
missing.push(
`Requires ${dependency.qty} point${dependency.qty !== 1 ? 's' : ''} in ${getNameForTalent(dependency.id)}`
)
}
if (talent.row * 5 > pointsInSpec) {
missing.push(`Requires ${talent.row * 5} points in ${specName}`)
}
// Hackfix: Returning an Array will cause the prop to change everytime, causing re-renders on all
// of the components. Returning a string makes the shallow compare easier and prevents re-renders.
// Could add Memoization to this function, but not sure of another "better" more "React" way..
return missing.join('_')
}
export const canLearnTalent = (known: Map<number, number>, talent: TalentData): boolean => {
// Reached the max rank?
if (known.get(talent.id, 0) >= talent.ranks.length) {
+2
View File
@@ -7,6 +7,8 @@ $col-offset: 44px;
$col-gutter: 16px;
$col-distance: $icon-size + $col-gutter;
$z-index-tooltips: 5;
// Item quality colours
$quality-0: #9d9d9d;
$quality-1: #fff;
+2
View File
@@ -50,3 +50,5 @@ interface Talent {
}
type TalentClickHandler = (specId: number, talentId: number, modifier: 1 | -1) => void
type TooltipPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'left' | 'right'