Merged master (tooltip changes)

This commit is contained in:
Brian Vaughn
2019-04-23 08:49:40 -07:00
13 changed files with 1536 additions and 23 deletions
+1 -1
View File
@@ -74,7 +74,7 @@
"@babel/preset-flow": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"@reach/menu-button": "^0.1.12",
"@reach/tooltip": "^0.1.1",
"@reach/tooltip": "file:./vendor/@reach/tooltip",
"adm-zip": "^0.4.7",
"babel-core": "^7.0.0-bridge",
"babel-eslint": "^9.0.0",
+3 -5
View File
@@ -1,10 +1,10 @@
// @flow
import React from 'react';
// TODO (tooltips) import Tooltip from '@reach/tooltip';
import Tooltip from '@reach/tooltip';
import styles from './Button.css';
// TODO (tooltips) import tooltipStyles from './Tooltip.css';
import tooltipStyles from './Tooltip.css';
type Props = {
children: React$Node,
@@ -19,14 +19,13 @@ export default function Button({
...rest
}: Props) {
let button = (
<button className={`${styles.Button} ${className}`} title={title} {...rest}>
<button className={`${styles.Button} ${className}`} {...rest}>
<span className={`${styles.ButtonContent} ${className}`} tabIndex={-1}>
{children}
</span>
</button>
);
/* TODO (tooltips)
if (title) {
button = (
<Tooltip className={tooltipStyles.Tooltip} label={title}>
@@ -34,7 +33,6 @@ export default function Button({
</Tooltip>
);
}
*/
return button;
}
+6 -6
View File
@@ -6,7 +6,7 @@ import React, {
useRef,
useState,
} from 'react';
// TODO (tooltips) import Tooltip from '@reach/tooltip';
import Tooltip from '@reach/tooltip';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
@@ -94,13 +94,13 @@ function ElementsDropdown({
return (
<Menu>
{/* TODO (tooltips) <Tooltip label="Open elements dropdown"> */}
<MenuButton className={styles.MenuButton}>
<span className={styles.MenuButtonContent} tabIndex={-1}>
<ButtonIcon type="more" />
</span>
<Tooltip label="Open elements dropdown">
<span className={styles.MenuButtonContent} tabIndex={-1}>
<ButtonIcon type="more" />
</span>
</Tooltip>
</MenuButton>
{/* TODO (tooltips) </Tooltip> */}
<MenuList className={styles.Modal}>
{ownerStack.map((id, index) => (
<MenuItem
+1 -1
View File
@@ -3,7 +3,7 @@
// Reach styles need to come before any component styles.
// This makes overridding the styles simpler.
import '@reach/menu-button/styles.css';
// TODO (tooltips) import '@reach/tooltip/styles.css';
import '@reach/tooltip/styles.css';
import React, { useEffect, useState } from 'react';
import Store from '../store';
+2 -5
View File
@@ -1,10 +1,10 @@
// @flow
import React, { useCallback } from 'react';
// TODO (tooltips) import Tooltip from '@reach/tooltip';
import Tooltip from '@reach/tooltip';
import styles from './Toggle.css';
// TODO (tooltips) import tooltipStyles from './Tooltip.css';
import tooltipStyles from './Tooltip.css';
type Props = {
children: React$Node,
@@ -42,7 +42,6 @@ export default function Toggle({
className={`${defaultClassName} ${className}`}
disabled={isDisabled}
onClick={handleClick}
title={title}
>
<span className={styles.ToggleContent} tabIndex={-1}>
{children}
@@ -50,7 +49,6 @@ export default function Toggle({
</button>
);
/* TODO (tooltips)
if (title) {
toggle = (
<Tooltip className={tooltipStyles.Tooltip} label={title}>
@@ -58,7 +56,6 @@ export default function Toggle({
</Tooltip>
);
}
*/
return toggle;
}
-1
View File
@@ -8,5 +8,4 @@
/* Make sure this is above the DevTools, which are above the Overlay */
z-index: 10000002;
position: relative;
}
+482
View File
@@ -0,0 +1,482 @@
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; }
////////////////////////////////////////////////////////////////////////////////
// Welcome to @reach/tooltip!
//
// Quick definitions:
//
// - "on rest" or "rested on": describes when the element receives keyboard
// focus or mouse hover after a short delay (and hopefully soon, touch
// longpress).
//
// - "activation": describes a mouse click, keyboard enter, or keyboard space.
//
// Only one tooltip can be visible at a time, so we use a global state chart to
// describe the various states and transitions between states that are
// possible. With the all the timeouts involved with tooltips it's important to
// "make impossible states impossible" with a state machine.
//
// It's also okay to use these module globals because you don't server render
// tooltips. None of the state is changed outside of user events.
//
// There are a few features that are important to understand.
//
// 1. Tooltips don't show up until the user has rested on one, we don't
// want tooltips popupping up as you move your mouse around the page.
//
// 2. Once any tooltip becomes visible, other tooltips nearby should skip
// resting and display immediately.
//
// 3. Tooltips stick around for a little bit after blur/mouseleave.
/* eslint-disable default-case */
import React, { Fragment, cloneElement, Children, useState, useRef, forwardRef, useEffect } from "react";
import { useId } from "@reach/auto-id";
import { wrapEvent } from "@reach/utils";
import Portal from "@reach/portal";
import VisuallyHidden from "@reach/visually-hidden";
import { useRect } from "@reach/rect";
import { node, string, func } from "prop-types";
////////////////////////////////////////////////////////////////////////////////
var chart = {
initial: "idle",
states: {
idle: {
enter: clearContextId,
on: {
mouseenter: "focused",
focus: "focused"
}
},
focused: {
enter: startRestTimer,
leave: clearRestTimer,
on: {
mousemove: "focused",
mouseleave: "idle",
mousedown: "dismissed",
blur: "idle",
rest: "visible"
}
},
visible: {
on: {
mouseleave: "leavingVisible",
blur: "leavingVisible",
mousedown: "dismissed",
selectWithKeyboard: "dismissed"
}
},
leavingVisible: {
enter: startLeavingVisibleTimer,
leave: function leave() {
clearLeavingVisibleTimer();
clearContextId();
},
on: {
mouseenter: "visible",
focus: "visible",
timecomplete: "idle"
}
},
dismissed: {
leave: function leave() {
// allows us to come on back later w/o entering something else first
context.id = null;
},
on: {
mouseleave: "idle",
blur: "idle"
}
}
}
};
// chart context allows us to persist some data around, in Tooltip all we use
// is the id of the current tooltip being interacted with.
var context = { id: null };
var state = chart.initial;
////////////////////////////////////////////////////////////////////////////////
// Finds the next state from the current state + action. If the chart doesn't
// describe that transition, it will throw.
//
// It also manages lifecycles of the machine, (enter/leave hooks on the state
// chart)
function transition(action, newContext) {
var stateDef = chart.states[state];
var nextState = stateDef.on[action];
if (!nextState) {
throw new Error("Unknown state for action \"" + action + "\" from state \"" + state + "\"");
}
if (stateDef.leave) {
stateDef.leave();
}
if (newContext) {
context = newContext;
}
var nextDef = chart.states[nextState];
if (nextDef.enter) {
nextDef.enter();
}
state = nextState;
notify();
}
////////////////////////////////////////////////////////////////////////////////
// Subscriptions:
//
// We could require apps to render a <TooltipProvider> around the app and use
// React context to notify Tooltips of changes to our state machine, instead
// we manage subscriptions ourselves and simplify the Tooltip API.
//
// Maybe if default context could take a hook (instead of just a static value)
// that was rendered at the root for us, that'd be cool! But it doesn't.
var subscriptions = [];
function subscribe(fn) {
subscriptions.push(fn);
return function () {
subscriptions.splice(subscriptions.indexOf(fn), 1);
};
}
function notify() {
subscriptions.forEach(function (fn) {
return fn(state, context);
});
}
////////////////////////////////////////////////////////////////////////////////
// Timeouts:
// Manages when the user "rests" on an element. Keeps the interface from being
// flashing tooltips all the time as the user moves the mouse around the screen.
var restTimeout = void 0;
function startRestTimer() {
clearTimeout(restTimeout);
restTimeout = setTimeout(function () {
return transition("rest");
}, 100);
}
function clearRestTimer() {
clearTimeout(restTimeout);
}
// Manages the delay to hide the tooltip after rest leaves.
var leavingVisibleTimer = void 0;
function startLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
leavingVisibleTimer = setTimeout(function () {
return transition("timecomplete");
}, 500);
}
function clearLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
}
////////////////////////////////////////////////////////////////////////////////
// Rando helpers
// allows us to come on back later w/o entering something else first after the
// user leaves or dismisses
function clearContextId() {
context.id = null;
}
// Avoids focus/mouseEnter from fighting with each other since you can have
// one element focused and another mouseentered at the same time. The first
// one sets the context.id and then the second one is ignored (for a single
// element, most recent focus/mouseenter wins between elements).
function isActive(id) {
return context.id === id;
}
////////////////////////////////////////////////////////////////////////////////
// THE HOOK!
export function useTooltip() {
var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
onMouseEnter = _ref.onMouseEnter,
onMouseMove = _ref.onMouseMove,
onMouseLeave = _ref.onMouseLeave,
onFocus = _ref.onFocus,
onBlur = _ref.onBlur,
onKeyDown = _ref.onKeyDown,
onMouseDown = _ref.onMouseDown,
ref = _ref.ref;
var _useState = useState(state === "visible"),
isVisible = _useState[0],
setIsVisible = _useState[1];
// hopefully they always pass a ref if they ever pass one
var triggerRef = ref || useRef();
var triggerRect = useRect(triggerRef, isVisible);
var id = "tooltip:" + useId();
useEffect(function () {
return subscribe(function () {
if (context.id === id && (state === "visible" || state === "leavingVisible")) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
}, [id]);
var handleMouseEnter = function handleMouseEnter() {
if (isActive(id) &&
// enables leaving then entering again before the times up
state !== "leavingVisible") {
return;
}
switch (state) {
case "idle":
case "leavingVisible":
{
transition("mouseenter", { id: id });
}
}
};
var handleMouseMove = function handleMouseMove() {
switch (state) {
case "focused":
{
transition("mousemove", { id: id });
}
}
};
var handleFocus = function handleFocus() {
if (isActive(id)) return;
switch (state) {
case "idle":
case "leavingVisible":
{
transition("focus", { id: id });
}
}
};
var handleMouseLeave = function handleMouseLeave() {
switch (state) {
case "focused":
case "visible":
case "dismissed":
{
transition("mouseleave");
}
}
};
var handleBlur = function handleBlur() {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible":
case "dismissed":
{
transition("blur");
}
}
};
var handleMouseDown = function handleMouseDown() {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible":
{
transition("mousedown");
}
}
};
var handleKeyDown = function handleKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
switch (state) {
case "visible":
{
transition("selectWithKeyboard");
}
}
}
};
var trigger = {
"aria-labelledby": id,
"data-reach-tooltip-trigger": "",
ref: triggerRef,
onMouseEnter: wrapEvent(onMouseEnter, handleMouseEnter),
onMouseMove: wrapEvent(onMouseMove, handleMouseMove),
onFocus: wrapEvent(onFocus, handleFocus),
onBlur: wrapEvent(onFocus, handleBlur),
onMouseLeave: wrapEvent(onMouseLeave, handleMouseLeave),
onKeyDown: wrapEvent(onKeyDown, handleKeyDown),
onMouseDown: wrapEvent(onMouseDown, handleMouseDown)
};
var tooltip = {
id: id,
triggerRect: triggerRect,
isVisible: isVisible
};
return [trigger, tooltip, isVisible];
}
////////////////////////////////////////////////////////////////////////////////
export default function Tooltip(_ref2) {
var children = _ref2.children,
label = _ref2.label,
ariaLabel = _ref2.ariaLabel,
rest = _objectWithoutProperties(_ref2, ["children", "label", "ariaLabel"]);
var _useTooltip = useTooltip(),
trigger = _useTooltip[0],
tooltip = _useTooltip[1];
return React.createElement(
Fragment,
null,
cloneElement(Children.only(children), trigger),
React.createElement(TooltipPopup, _extends({
label: label,
ariaLabel: ariaLabel
}, tooltip, rest))
);
}
process.env.NODE_ENV !== "production" ? Tooltip.propTypes = {
children: node.isRequired,
label: node.isRequired,
ariaLabel: string
} : void 0;
////////////////////////////////////////////////////////////////////////////////
var TooltipPopup = forwardRef(function TooltipPopup(_ref3, forwardRef) {
var label = _ref3.label,
ariaLabel = _ref3.ariaLabel,
position = _ref3.position,
isVisible = _ref3.isVisible,
id = _ref3.id,
triggerRect = _ref3.triggerRect,
rest = _objectWithoutProperties(_ref3, ["label", "ariaLabel", "position", "isVisible", "id", "triggerRect"]);
return isVisible ? React.createElement(
Portal,
null,
React.createElement(TooltipContent, _extends({
label: label,
ariaLabel: ariaLabel,
position: position,
isVisible: isVisible,
id: id,
triggerRect: triggerRect,
ref: forwardRef
}, rest))
) : null;
});
export { TooltipPopup };
process.env.NODE_ENV !== "production" ? TooltipPopup.propTypes = {
label: node.isRequired,
ariaLabel: string,
position: func
} : void 0;
// Need a separate component so that useRect works inside the portal
var TooltipContent = forwardRef(function TooltipContent(_ref4, forwardRef) {
var label = _ref4.label,
ariaLabel = _ref4.ariaLabel,
_ref4$position = _ref4.position,
position = _ref4$position === undefined ? positionDefault : _ref4$position,
isVisible = _ref4.isVisible,
id = _ref4.id,
triggerRect = _ref4.triggerRect,
style = _ref4.style,
rest = _objectWithoutProperties(_ref4, ["label", "ariaLabel", "position", "isVisible", "id", "triggerRect", "style"]);
var useAriaLabel = ariaLabel != null;
var tooltipRef = useRef();
var tooltipRect = useRect(tooltipRef, isVisible);
return React.createElement(
Fragment,
null,
React.createElement("div", _extends({
"data-reach-tooltip": true,
role: useAriaLabel ? undefined : "tooltip",
id: useAriaLabel ? undefined : id,
children: label,
style: _extends({}, style, getStyles(position, triggerRect, tooltipRect)),
ref: function ref(node) {
tooltipRef.current = node;
if (forwardRef) forwardRef(node);
}
}, rest)),
useAriaLabel && React.createElement(
VisuallyHidden,
{ role: "tooltip", id: id },
ariaLabel
)
);
});
////////////////////////////////////////////////////////////////////////////////
// TODO: research longpress tooltips on Android, iOS, implement here.
// - Probably want to position it by default above, since your thumb
// is below and would cover it
// - I'm thinking after longpress, display the tooltip and cancel any
// click events. Then on touchend, hide the tooltip after a timer
// in case their hand is obstructing it, then can remove and read
// the tooltip
// feels awkward when it's perfectly aligned w/ the trigger
var OFFSET = 8;
var getStyles = function getStyles(position, triggerRect, tooltipRect) {
var haventMeasuredTooltipYet = !tooltipRect;
if (haventMeasuredTooltipYet) {
return { visibility: "hidden" };
}
return position(triggerRect, tooltipRect);
};
var positionDefault = function positionDefault(triggerRect, tooltipRect) {
var styles = {
left: triggerRect.left + window.scrollX + "px",
top: triggerRect.top + triggerRect.height + window.scrollY + "px"
};
var collisions = {
top: triggerRect.top - tooltipRect.height < 0,
right: window.innerWidth < triggerRect.left + tooltipRect.width,
bottom: window.innerHeight < triggerRect.bottom + tooltipRect.height + OFFSET,
left: triggerRect.left - tooltipRect.width < 0
};
var directionRight = collisions.right && !collisions.left;
var directionUp = collisions.bottom && !collisions.top;
return _extends({}, styles, {
left: directionRight ? triggerRect.right + OFFSET / 2 - tooltipRect.width + window.scrollX + "px" : triggerRect.left - OFFSET / 2 + window.scrollX + "px",
top: directionUp ? triggerRect.top - OFFSET - tooltipRect.height + window.scrollY + "px" : triggerRect.top + OFFSET + triggerRect.height + window.scrollY + "px"
});
};
+502
View File
@@ -0,0 +1,502 @@
"use strict";
exports.__esModule = true;
exports.TooltipPopup = undefined;
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
exports.useTooltip = useTooltip;
exports.default = Tooltip;
var _react = require("react");
var _react2 = _interopRequireDefault(_react);
var _autoId = require("@reach/auto-id");
var _utils = require("@reach/utils");
var _portal = require("@reach/portal");
var _portal2 = _interopRequireDefault(_portal);
var _visuallyHidden = require("@reach/visually-hidden");
var _visuallyHidden2 = _interopRequireDefault(_visuallyHidden);
var _rect = require("@reach/rect");
var _propTypes = require("prop-types");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } ////////////////////////////////////////////////////////////////////////////////
// Welcome to @reach/tooltip!
//
// Quick definitions:
//
// - "on rest" or "rested on": describes when the element receives keyboard
// focus or mouse hover after a short delay (and hopefully soon, touch
// longpress).
//
// - "activation": describes a mouse click, keyboard enter, or keyboard space.
//
// Only one tooltip can be visible at a time, so we use a global state chart to
// describe the various states and transitions between states that are
// possible. With the all the timeouts involved with tooltips it's important to
// "make impossible states impossible" with a state machine.
//
// It's also okay to use these module globals because you don't server render
// tooltips. None of the state is changed outside of user events.
//
// There are a few features that are important to understand.
//
// 1. Tooltips don't show up until the user has rested on one, we don't
// want tooltips popupping up as you move your mouse around the page.
//
// 2. Once any tooltip becomes visible, other tooltips nearby should skip
// resting and display immediately.
//
// 3. Tooltips stick around for a little bit after blur/mouseleave.
/* eslint-disable default-case */
////////////////////////////////////////////////////////////////////////////////
var chart = {
initial: "idle",
states: {
idle: {
enter: clearContextId,
on: {
mouseenter: "focused",
focus: "focused"
}
},
focused: {
enter: startRestTimer,
leave: clearRestTimer,
on: {
mousemove: "focused",
mouseleave: "idle",
mousedown: "dismissed",
blur: "idle",
rest: "visible"
}
},
visible: {
on: {
mouseleave: "leavingVisible",
blur: "leavingVisible",
mousedown: "dismissed",
selectWithKeyboard: "dismissed"
}
},
leavingVisible: {
enter: startLeavingVisibleTimer,
leave: function leave() {
clearLeavingVisibleTimer();
clearContextId();
},
on: {
mouseenter: "visible",
focus: "visible",
timecomplete: "idle"
}
},
dismissed: {
leave: function leave() {
// allows us to come on back later w/o entering something else first
context.id = null;
},
on: {
mouseleave: "idle",
blur: "idle"
}
}
}
};
// chart context allows us to persist some data around, in Tooltip all we use
// is the id of the current tooltip being interacted with.
var context = { id: null };
var state = chart.initial;
////////////////////////////////////////////////////////////////////////////////
// Finds the next state from the current state + action. If the chart doesn't
// describe that transition, it will throw.
//
// It also manages lifecycles of the machine, (enter/leave hooks on the state
// chart)
function transition(action, newContext) {
var stateDef = chart.states[state];
var nextState = stateDef.on[action];
if (!nextState) {
throw new Error("Unknown state for action \"" + action + "\" from state \"" + state + "\"");
}
if (stateDef.leave) {
stateDef.leave();
}
if (newContext) {
context = newContext;
}
var nextDef = chart.states[nextState];
if (nextDef.enter) {
nextDef.enter();
}
state = nextState;
notify();
}
////////////////////////////////////////////////////////////////////////////////
// Subscriptions:
//
// We could require apps to render a <TooltipProvider> around the app and use
// React context to notify Tooltips of changes to our state machine, instead
// we manage subscriptions ourselves and simplify the Tooltip API.
//
// Maybe if default context could take a hook (instead of just a static value)
// that was rendered at the root for us, that'd be cool! But it doesn't.
var subscriptions = [];
function subscribe(fn) {
subscriptions.push(fn);
return function () {
subscriptions.splice(subscriptions.indexOf(fn), 1);
};
}
function notify() {
subscriptions.forEach(function (fn) {
return fn(state, context);
});
}
////////////////////////////////////////////////////////////////////////////////
// Timeouts:
// Manages when the user "rests" on an element. Keeps the interface from being
// flashing tooltips all the time as the user moves the mouse around the screen.
var restTimeout = void 0;
function startRestTimer() {
clearTimeout(restTimeout);
restTimeout = setTimeout(function () {
return transition("rest");
}, 100);
}
function clearRestTimer() {
clearTimeout(restTimeout);
}
// Manages the delay to hide the tooltip after rest leaves.
var leavingVisibleTimer = void 0;
function startLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
leavingVisibleTimer = setTimeout(function () {
return transition("timecomplete");
}, 500);
}
function clearLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
}
////////////////////////////////////////////////////////////////////////////////
// Rando helpers
// allows us to come on back later w/o entering something else first after the
// user leaves or dismisses
function clearContextId() {
context.id = null;
}
// Avoids focus/mouseEnter from fighting with each other since you can have
// one element focused and another mouseentered at the same time. The first
// one sets the context.id and then the second one is ignored (for a single
// element, most recent focus/mouseenter wins between elements).
function isActive(id) {
return context.id === id;
}
////////////////////////////////////////////////////////////////////////////////
// THE HOOK!
function useTooltip() {
var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
onMouseEnter = _ref.onMouseEnter,
onMouseMove = _ref.onMouseMove,
onMouseLeave = _ref.onMouseLeave,
onFocus = _ref.onFocus,
onBlur = _ref.onBlur,
onKeyDown = _ref.onKeyDown,
onMouseDown = _ref.onMouseDown,
ref = _ref.ref;
var _useState = (0, _react.useState)(state === "visible"),
isVisible = _useState[0],
setIsVisible = _useState[1];
// hopefully they always pass a ref if they ever pass one
var triggerRef = ref || (0, _react.useRef)();
var triggerRect = (0, _rect.useRect)(triggerRef, isVisible);
var id = "tooltip:" + (0, _autoId.useId)();
(0, _react.useEffect)(function () {
return subscribe(function () {
if (context.id === id && (state === "visible" || state === "leavingVisible")) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
}, [id]);
var handleMouseEnter = function handleMouseEnter() {
if (isActive(id) &&
// enables leaving then entering again before the times up
state !== "leavingVisible") {
return;
}
switch (state) {
case "idle":
case "leavingVisible":
{
transition("mouseenter", { id: id });
}
}
};
var handleMouseMove = function handleMouseMove() {
switch (state) {
case "focused":
{
transition("mousemove", { id: id });
}
}
};
var handleFocus = function handleFocus() {
if (isActive(id)) return;
switch (state) {
case "idle":
case "leavingVisible":
{
transition("focus", { id: id });
}
}
};
var handleMouseLeave = function handleMouseLeave() {
switch (state) {
case "focused":
case "visible":
case "dismissed":
{
transition("mouseleave");
}
}
};
var handleBlur = function handleBlur() {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible":
case "dismissed":
{
transition("blur");
}
}
};
var handleMouseDown = function handleMouseDown() {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible":
{
transition("mousedown");
}
}
};
var handleKeyDown = function handleKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
switch (state) {
case "visible":
{
transition("selectWithKeyboard");
}
}
}
};
var trigger = {
"aria-labelledby": id,
"data-reach-tooltip-trigger": "",
ref: triggerRef,
onMouseEnter: (0, _utils.wrapEvent)(onMouseEnter, handleMouseEnter),
onMouseMove: (0, _utils.wrapEvent)(onMouseMove, handleMouseMove),
onFocus: (0, _utils.wrapEvent)(onFocus, handleFocus),
onBlur: (0, _utils.wrapEvent)(onFocus, handleBlur),
onMouseLeave: (0, _utils.wrapEvent)(onMouseLeave, handleMouseLeave),
onKeyDown: (0, _utils.wrapEvent)(onKeyDown, handleKeyDown),
onMouseDown: (0, _utils.wrapEvent)(onMouseDown, handleMouseDown)
};
var tooltip = {
id: id,
triggerRect: triggerRect,
isVisible: isVisible
};
return [trigger, tooltip, isVisible];
}
////////////////////////////////////////////////////////////////////////////////
function Tooltip(_ref2) {
var children = _ref2.children,
label = _ref2.label,
ariaLabel = _ref2.ariaLabel,
rest = _objectWithoutProperties(_ref2, ["children", "label", "ariaLabel"]);
var _useTooltip = useTooltip(),
trigger = _useTooltip[0],
tooltip = _useTooltip[1];
return _react2.default.createElement(
_react.Fragment,
null,
(0, _react.cloneElement)(_react.Children.only(children), trigger),
_react2.default.createElement(TooltipPopup, _extends({
label: label,
ariaLabel: ariaLabel
}, tooltip, rest))
);
}
process.env.NODE_ENV !== "production" ? Tooltip.propTypes = {
children: _propTypes.node.isRequired,
label: _propTypes.node.isRequired,
ariaLabel: _propTypes.string
} : void 0;
////////////////////////////////////////////////////////////////////////////////
var TooltipPopup = (0, _react.forwardRef)(function TooltipPopup(_ref3, forwardRef) {
var label = _ref3.label,
ariaLabel = _ref3.ariaLabel,
position = _ref3.position,
isVisible = _ref3.isVisible,
id = _ref3.id,
triggerRect = _ref3.triggerRect,
rest = _objectWithoutProperties(_ref3, ["label", "ariaLabel", "position", "isVisible", "id", "triggerRect"]);
return isVisible ? _react2.default.createElement(
_portal2.default,
null,
_react2.default.createElement(TooltipContent, _extends({
label: label,
ariaLabel: ariaLabel,
position: position,
isVisible: isVisible,
id: id,
triggerRect: triggerRect,
ref: forwardRef
}, rest))
) : null;
});
exports.TooltipPopup = TooltipPopup;
process.env.NODE_ENV !== "production" ? TooltipPopup.propTypes = {
label: _propTypes.node.isRequired,
ariaLabel: _propTypes.string,
position: _propTypes.func
} : void 0;
// Need a separate component so that useRect works inside the portal
var TooltipContent = (0, _react.forwardRef)(function TooltipContent(_ref4, forwardRef) {
var label = _ref4.label,
ariaLabel = _ref4.ariaLabel,
_ref4$position = _ref4.position,
position = _ref4$position === undefined ? positionDefault : _ref4$position,
isVisible = _ref4.isVisible,
id = _ref4.id,
triggerRect = _ref4.triggerRect,
style = _ref4.style,
rest = _objectWithoutProperties(_ref4, ["label", "ariaLabel", "position", "isVisible", "id", "triggerRect", "style"]);
var useAriaLabel = ariaLabel != null;
var tooltipRef = (0, _react.useRef)();
var tooltipRect = (0, _rect.useRect)(tooltipRef, isVisible);
return _react2.default.createElement(
_react.Fragment,
null,
_react2.default.createElement("div", _extends({
"data-reach-tooltip": true,
role: useAriaLabel ? undefined : "tooltip",
id: useAriaLabel ? undefined : id,
children: label,
style: _extends({}, style, getStyles(position, triggerRect, tooltipRect)),
ref: function ref(node) {
tooltipRef.current = node;
if (forwardRef) forwardRef(node);
}
}, rest)),
useAriaLabel && _react2.default.createElement(
_visuallyHidden2.default,
{ role: "tooltip", id: id },
ariaLabel
)
);
});
////////////////////////////////////////////////////////////////////////////////
// TODO: research longpress tooltips on Android, iOS, implement here.
// - Probably want to position it by default above, since your thumb
// is below and would cover it
// - I'm thinking after longpress, display the tooltip and cancel any
// click events. Then on touchend, hide the tooltip after a timer
// in case their hand is obstructing it, then can remove and read
// the tooltip
// feels awkward when it's perfectly aligned w/ the trigger
var OFFSET = 8;
var getStyles = function getStyles(position, triggerRect, tooltipRect) {
var haventMeasuredTooltipYet = !tooltipRect;
if (haventMeasuredTooltipYet) {
return { visibility: "hidden" };
}
return position(triggerRect, tooltipRect);
};
var positionDefault = function positionDefault(triggerRect, tooltipRect) {
var styles = {
left: triggerRect.left + window.scrollX + "px",
top: triggerRect.top + triggerRect.height + window.scrollY + "px"
};
var collisions = {
top: triggerRect.top - tooltipRect.height < 0,
right: window.innerWidth < triggerRect.left + tooltipRect.width,
bottom: window.innerHeight < triggerRect.bottom + tooltipRect.height + OFFSET,
left: triggerRect.left - tooltipRect.width < 0
};
var directionRight = collisions.right && !collisions.left;
var directionUp = collisions.bottom && !collisions.top;
return _extends({}, styles, {
left: directionRight ? triggerRect.right + OFFSET / 2 - tooltipRect.width + window.scrollX + "px" : triggerRect.left - OFFSET / 2 + window.scrollX + "px",
top: directionUp ? triggerRect.top - OFFSET - tooltipRect.height + window.scrollY + "px" : triggerRect.top + OFFSET + triggerRect.height + window.scrollY + "px"
});
};
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@reach/tooltip",
"version": "0.1.2",
"description": "Accessible tooltips",
"main": "index.js",
"module": "es/index.js",
"scripts": {
"test": "echo \"Write some tests you bum!\"",
"build": "node ../../shared/build-package",
"lint": "eslint . --max-warnings=0"
},
"dependencies": {
"@reach/auto-id": "0.2.0",
"@reach/portal": "^0.2.0",
"@reach/rect": "^0.2.1",
"@reach/utils": "0.2.0",
"@reach/visually-hidden": "^0.1.3",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"react": "^16.8.0",
"react-dom": "^16.8.0"
},
"devDependencies": {
"react-spring": "^8.0.19"
},
"author": "Ryan Florence",
"license": "MIT",
"files": [
"es",
"src",
"lib",
"index.js",
"styles.css"
]
}
Binary file not shown.
+490
View File
@@ -0,0 +1,490 @@
////////////////////////////////////////////////////////////////////////////////
// Welcome to @reach/tooltip!
//
// Quick definitions:
//
// - "on rest" or "rested on": describes when the element receives keyboard
// focus or mouse hover after a short delay (and hopefully soon, touch
// longpress).
//
// - "activation": describes a mouse click, keyboard enter, or keyboard space.
//
// Only one tooltip can be visible at a time, so we use a global state chart to
// describe the various states and transitions between states that are
// possible. With the all the timeouts involved with tooltips it's important to
// "make impossible states impossible" with a state machine.
//
// It's also okay to use these module globals because you don't server render
// tooltips. None of the state is changed outside of user events.
//
// There are a few features that are important to understand.
//
// 1. Tooltips don't show up until the user has rested on one, we don't
// want tooltips popupping up as you move your mouse around the page.
//
// 2. Once any tooltip becomes visible, other tooltips nearby should skip
// resting and display immediately.
//
// 3. Tooltips stick around for a little bit after blur/mouseleave.
/* eslint-disable default-case */
import React, {
Fragment,
cloneElement,
Children,
useState,
useRef,
forwardRef,
useEffect
} from "react";
import { useId } from "@reach/auto-id";
import { wrapEvent } from "@reach/utils";
import Portal from "@reach/portal";
import VisuallyHidden from "@reach/visually-hidden";
import { useRect } from "@reach/rect";
import { node, string, func } from "prop-types";
////////////////////////////////////////////////////////////////////////////////
const chart = {
initial: "idle",
states: {
idle: {
enter: clearContextId,
on: {
mouseenter: "focused",
focus: "focused"
}
},
focused: {
enter: startRestTimer,
leave: clearRestTimer,
on: {
mousemove: "focused",
mouseleave: "idle",
mousedown: "dismissed",
blur: "idle",
rest: "visible"
}
},
visible: {
on: {
mouseleave: "leavingVisible",
blur: "leavingVisible",
mousedown: "dismissed",
selectWithKeyboard: "dismissed"
}
},
leavingVisible: {
enter: startLeavingVisibleTimer,
leave: () => {
clearLeavingVisibleTimer();
clearContextId();
},
on: {
mouseenter: "visible",
focus: "visible",
timecomplete: "idle"
}
},
dismissed: {
leave: () => {
// allows us to come on back later w/o entering something else first
context.id = null;
},
on: {
mouseleave: "idle",
blur: "idle"
}
}
}
};
// chart context allows us to persist some data around, in Tooltip all we use
// is the id of the current tooltip being interacted with.
let context = { id: null };
let state = chart.initial;
////////////////////////////////////////////////////////////////////////////////
// Finds the next state from the current state + action. If the chart doesn't
// describe that transition, it will throw.
//
// It also manages lifecycles of the machine, (enter/leave hooks on the state
// chart)
function transition(action, newContext) {
const stateDef = chart.states[state];
const nextState = stateDef.on[action];
if (!nextState) {
throw new Error(
`Unknown state for action "${action}" from state "${state}"`
);
}
if (stateDef.leave) {
stateDef.leave();
}
if (newContext) {
context = newContext;
}
const nextDef = chart.states[nextState];
if (nextDef.enter) {
nextDef.enter();
}
state = nextState;
notify();
}
////////////////////////////////////////////////////////////////////////////////
// Subscriptions:
//
// We could require apps to render a <TooltipProvider> around the app and use
// React context to notify Tooltips of changes to our state machine, instead
// we manage subscriptions ourselves and simplify the Tooltip API.
//
// Maybe if default context could take a hook (instead of just a static value)
// that was rendered at the root for us, that'd be cool! But it doesn't.
const subscriptions = [];
function subscribe(fn) {
subscriptions.push(fn);
return () => {
subscriptions.splice(subscriptions.indexOf(fn), 1);
};
}
function notify() {
subscriptions.forEach(fn => fn(state, context));
}
////////////////////////////////////////////////////////////////////////////////
// Timeouts:
// Manages when the user "rests" on an element. Keeps the interface from being
// flashing tooltips all the time as the user moves the mouse around the screen.
let restTimeout;
function startRestTimer() {
clearTimeout(restTimeout);
restTimeout = setTimeout(() => transition("rest"), 100);
}
function clearRestTimer() {
clearTimeout(restTimeout);
}
// Manages the delay to hide the tooltip after rest leaves.
let leavingVisibleTimer;
function startLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
leavingVisibleTimer = setTimeout(() => transition("timecomplete"), 500);
}
function clearLeavingVisibleTimer() {
clearTimeout(leavingVisibleTimer);
}
////////////////////////////////////////////////////////////////////////////////
// Rando helpers
// allows us to come on back later w/o entering something else first after the
// user leaves or dismisses
function clearContextId() {
context.id = null;
}
// Avoids focus/mouseEnter from fighting with each other since you can have
// one element focused and another mouseentered at the same time. The first
// one sets the context.id and then the second one is ignored (for a single
// element, most recent focus/mouseenter wins between elements).
function isActive(id) {
return context.id === id;
}
////////////////////////////////////////////////////////////////////////////////
// THE HOOK!
export function useTooltip({
onMouseEnter,
onMouseMove,
onMouseLeave,
onFocus,
onBlur,
onKeyDown,
onMouseDown,
ref
} = {}) {
const [isVisible, setIsVisible] = useState(state === "visible");
// hopefully they always pass a ref if they ever pass one
const triggerRef = ref || useRef();
const triggerRect = useRect(triggerRef, isVisible);
const id = `tooltip:${useId()}`;
useEffect(() => {
return subscribe(() => {
if (
context.id === id &&
(state === "visible" || state === "leavingVisible")
) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
}, [id]);
const handleMouseEnter = () => {
if (
isActive(id) &&
// enables leaving then entering again before the times up
state !== "leavingVisible"
) {
return;
}
switch (state) {
case "idle":
case "leavingVisible": {
transition("mouseenter", { id });
}
}
};
const handleMouseMove = () => {
switch (state) {
case "focused": {
transition("mousemove", { id });
}
}
};
const handleFocus = () => {
if (isActive(id)) return;
switch (state) {
case "idle":
case "leavingVisible": {
transition("focus", { id });
}
}
};
const handleMouseLeave = () => {
switch (state) {
case "focused":
case "visible":
case "dismissed": {
transition("mouseleave");
}
}
};
const handleBlur = () => {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible":
case "dismissed": {
transition("blur");
}
}
};
const handleMouseDown = () => {
// Allow quick click from one tool to another
if (context.id !== id) return;
switch (state) {
case "focused":
case "visible": {
transition("mousedown");
}
}
};
const handleKeyDown = event => {
if (event.key === "Enter" || event.key === " ") {
switch (state) {
case "visible": {
transition("selectWithKeyboard");
}
}
}
};
const trigger = {
"aria-labelledby": id,
"data-reach-tooltip-trigger": "",
ref: triggerRef,
onMouseEnter: wrapEvent(onMouseEnter, handleMouseEnter),
onMouseMove: wrapEvent(onMouseMove, handleMouseMove),
onFocus: wrapEvent(onFocus, handleFocus),
onBlur: wrapEvent(onFocus, handleBlur),
onMouseLeave: wrapEvent(onMouseLeave, handleMouseLeave),
onKeyDown: wrapEvent(onKeyDown, handleKeyDown),
onMouseDown: wrapEvent(onMouseDown, handleMouseDown)
};
const tooltip = {
id,
triggerRect,
isVisible
};
return [trigger, tooltip, isVisible];
}
////////////////////////////////////////////////////////////////////////////////
export default function Tooltip({ children, label, ariaLabel, ...rest }) {
const [trigger, tooltip] = useTooltip();
return (
<Fragment>
{cloneElement(Children.only(children), trigger)}
<TooltipPopup
label={label}
ariaLabel={ariaLabel}
{...tooltip}
{...rest}
/>
</Fragment>
);
}
Tooltip.propTypes = {
children: node.isRequired,
label: node.isRequired,
ariaLabel: string
};
////////////////////////////////////////////////////////////////////////////////
export const TooltipPopup = forwardRef(function TooltipPopup(
{
// own props
label, // could use children but want to encourage simple strings
ariaLabel,
position,
// hook spread props
isVisible,
id,
triggerRect,
...rest
},
forwardRef
) {
return isVisible ? (
<Portal>
<TooltipContent
label={label}
ariaLabel={ariaLabel}
position={position}
isVisible={isVisible}
id={id}
triggerRect={triggerRect}
ref={forwardRef}
{...rest}
/>
</Portal>
) : null;
});
TooltipPopup.propTypes = {
label: node.isRequired,
ariaLabel: string,
position: func
};
// Need a separate component so that useRect works inside the portal
const TooltipContent = forwardRef(function TooltipContent(
{
label,
ariaLabel,
position = positionDefault,
isVisible,
id,
triggerRect,
style,
...rest
},
forwardRef
) {
const useAriaLabel = ariaLabel != null;
const tooltipRef = useRef();
const tooltipRect = useRect(tooltipRef, isVisible);
return (
<Fragment>
<div
data-reach-tooltip
role={useAriaLabel ? undefined : "tooltip"}
id={useAriaLabel ? undefined : id}
children={label}
style={{
...style,
...getStyles(position, triggerRect, tooltipRect)
}}
ref={node => {
tooltipRef.current = node;
if (forwardRef) forwardRef(node);
}}
{...rest}
/>
{useAriaLabel && (
<VisuallyHidden role="tooltip" id={id}>
{ariaLabel}
</VisuallyHidden>
)}
</Fragment>
);
});
////////////////////////////////////////////////////////////////////////////////
// TODO: research longpress tooltips on Android, iOS, implement here.
// - Probably want to position it by default above, since your thumb
// is below and would cover it
// - I'm thinking after longpress, display the tooltip and cancel any
// click events. Then on touchend, hide the tooltip after a timer
// in case their hand is obstructing it, then can remove and read
// the tooltip
// feels awkward when it's perfectly aligned w/ the trigger
const OFFSET = 8;
const getStyles = (position, triggerRect, tooltipRect) => {
const haventMeasuredTooltipYet = !tooltipRect;
if (haventMeasuredTooltipYet) {
return { visibility: "hidden" };
}
return position(triggerRect, tooltipRect);
};
const positionDefault = (triggerRect, tooltipRect) => {
const styles = {
left: `${triggerRect.left + window.scrollX}px`,
top: `${triggerRect.top + triggerRect.height + window.scrollY}px`
};
const collisions = {
top: triggerRect.top - tooltipRect.height < 0,
right: window.innerWidth < triggerRect.left + tooltipRect.width,
bottom:
window.innerHeight < triggerRect.bottom + tooltipRect.height + OFFSET,
left: triggerRect.left - tooltipRect.width < 0
};
const directionRight = collisions.right && !collisions.left;
const directionUp = collisions.bottom && !collisions.top;
return {
...styles,
left: directionRight
? `${triggerRect.right +
OFFSET / 2 -
tooltipRect.width +
window.scrollX}px`
: `${triggerRect.left - OFFSET / 2 + window.scrollX}px`,
top: directionUp
? `${triggerRect.top - OFFSET - tooltipRect.height + window.scrollY}px`
: `${triggerRect.top + OFFSET + triggerRect.height + window.scrollY}px`
};
};
+11
View File
@@ -0,0 +1,11 @@
[data-reach-tooltip] {
pointer-events: none;
position: absolute;
padding: 0.25em;
box-shadow: 2px 2px 10px hsla(0, 0%, 0%, 0.1);
white-space: nowrap;
font-size: 85%;
background: #f0f0f0;
color: #444;
border: solid 1px #ccc;
}
+2 -4
View File
@@ -1052,10 +1052,8 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
"@reach/tooltip@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@reach/tooltip/-/tooltip-0.1.1.tgz#7f0385624eaffabcd4f075a098ef0fe4b472ce5d"
integrity sha512-EawPeRdeajl5HwtM8Q1rz0aNzOgRKnbs4GNW4tUa/6+F8yN93nZGxfYyefYGQgjzVOd0HdgeatUVqDQMgcldYA==
"@reach/tooltip@file:./vendor/@reach/tooltip":
version "0.1.2"
dependencies:
"@reach/auto-id" "0.2.0"
"@reach/portal" "^0.2.0"