@primer/react
Version:
An implementation of GitHub's Primer Design System using React
227 lines (217 loc) • 11 kB
JavaScript
'use strict';
var React = require('react');
var sx = require('../sx.js');
var useProvidedRefOrCreate = require('../hooks/useProvidedRefOrCreate.js');
var useOnEscapePress = require('../hooks/useOnEscapePress.js');
require('@primer/behaviors/utils');
var behaviors = require('@primer/behaviors');
var useId = require('../hooks/useId.js');
var invariant = require('../utils/invariant.js');
var warning = require('../utils/warning.js');
var styled = require('styled-components');
var constants = require('../constants.js');
var popoverFn = require('../node_modules/@oddbird/popover-polyfill/dist/popover-fn.js');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var React__default = /*#__PURE__*/_interopDefault(React);
var styled__default = /*#__PURE__*/_interopDefault(styled);
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
const animationStyles = `
animation-name: tooltip-appear;
animation-duration: 0.1s;
animation-fill-mode: forwards;
animation-timing-function: ease-in;
animation-delay: 0s;
`;
const StyledTooltip = styled__default.default.span.withConfig({
displayName: "Tooltip__StyledTooltip",
componentId: "sc-e45c7z-0"
})(["display:none;&[popover]{position:absolute;padding:0.5em 0.75em;width:max-content;margin:auto;clip:auto;white-space:normal;font:normal normal 11px/1.5 ", ";-webkit-font-smoothing:subpixel-antialiased;color:", ";text-align:center;word-wrap:break-word;background:", ";border-radius:", ";border:0;opacity:0;max-width:250px;inset:auto;overflow:visible;}&[popover]:popover-open{display:block;}&[popover].\\:popover-open{display:block;}@media (forced-colors:active){outline:1px solid transparent;}&::after{position:absolute;display:block;right:0;left:0;height:var(--overlay-offset,0.25rem);content:'';}&[data-direction='n']::after,&[data-direction='ne']::after,&[data-direction='nw']::after{top:100%;}&[data-direction='s']::after,&[data-direction='se']::after,&[data-direction='sw']::after{bottom:100%;}&[data-direction='w']::after{position:absolute;display:block;height:100%;width:8px;content:'';bottom:0;left:100%;}&[data-direction='e']::after{position:absolute;display:block;height:100%;width:8px;content:'';bottom:0;right:100%;margin-left:-8px;}@keyframes tooltip-appear{from{opacity:0;}to{opacity:1;}}&:popover-open,&:popover-open::before{", "}&.\\:popover-open,&.\\:popover-open::before{", "}", ";"], constants.get('fonts.normal'), constants.get('colors.fg.onEmphasis'), constants.get('colors.neutral.emphasisPlus'), constants.get('radii.2'), animationStyles, animationStyles, sx.default);
// map tooltip direction to anchoredPosition props
const directionToPosition = {
nw: {
side: 'outside-top',
align: 'end'
},
n: {
side: 'outside-top',
align: 'center'
},
ne: {
side: 'outside-top',
align: 'start'
},
e: {
side: 'outside-right',
align: 'center'
},
se: {
side: 'outside-bottom',
align: 'start'
},
s: {
side: 'outside-bottom',
align: 'center'
},
sw: {
side: 'outside-bottom',
align: 'end'
},
w: {
side: 'outside-left',
align: 'center'
}
};
// map anchoredPosition props to tooltip direction
const positionToDirection = {
'outside-top-end': 'nw',
'outside-top-center': 'n',
'outside-top-start': 'ne',
'outside-right-center': 'e',
'outside-bottom-start': 'se',
'outside-bottom-center': 's',
'outside-bottom-end': 'sw',
'outside-left-center': 'w'
};
// The list is from GitHub's custom-axe-rules https://github.com/github/github/blob/master/app/assets/modules/github/axe-custom-rules.ts#L3
const interactiveElements = ['a[href]', 'button:not(:disabled)', 'summary', 'select', 'input:not([type=hidden])', 'textarea'];
const isInteractive = element => {
return interactiveElements.some(selector => element.matches(selector)) || element.hasAttribute('role') && element.getAttribute('role') === 'button';
};
const TooltipContext = /*#__PURE__*/React__default.default.createContext({});
const Tooltip = /*#__PURE__*/React__default.default.forwardRef(({
direction = 's',
text,
type = 'description',
children,
id,
...rest
}, forwardedRef) => {
const tooltipId = useId.useId(id);
const child = React.Children.only(children);
const triggerRef = useProvidedRefOrCreate.useProvidedRefOrCreate(forwardedRef);
const tooltipElRef = React.useRef(null);
const [calculatedDirection, setCalculatedDirection] = React.useState(direction);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const openTooltip = () => {
if (tooltipElRef.current && triggerRef.current && tooltipElRef.current.hasAttribute('popover') && !tooltipElRef.current.matches(':popover-open')) {
const tooltip = tooltipElRef.current;
const trigger = triggerRef.current;
tooltip.showPopover();
setIsPopoverOpen(true);
/*
* TOOLTIP POSITIONING
*/
const settings = {
side: directionToPosition[direction].side,
align: directionToPosition[direction].align
};
const {
top,
left,
anchorAlign,
anchorSide
} = behaviors.getAnchoredPosition(tooltip, trigger, settings);
// This is required to make sure the popover is positioned correctly i.e. when there is not enough space on the specified direction, we set a new direction to position the ::after
const calculatedDirection = positionToDirection[`${anchorSide}-${anchorAlign}`];
setCalculatedDirection(calculatedDirection);
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
};
const closeTooltip = () => {
if (tooltipElRef.current && triggerRef.current && tooltipElRef.current.hasAttribute('popover') && tooltipElRef.current.matches(':popover-open')) {
tooltipElRef.current.hidePopover();
setIsPopoverOpen(false);
}
};
// context value
const value = React.useMemo(() => ({
tooltipId
}), [tooltipId]);
React.useEffect(() => {
if (!tooltipElRef.current || !triggerRef.current) return;
/*
* ACCESSIBILITY CHECKS
*/
// Has trigger element or any of its children interactive elements?
const isTriggerInteractive = isInteractive(triggerRef.current);
const triggerChildren = triggerRef.current.childNodes;
const hasInteractiveChild = Array.from(triggerChildren).some(child => {
return child instanceof HTMLElement && isInteractive(child);
});
!(isTriggerInteractive || hasInteractiveChild) ? process.env.NODE_ENV !== "production" ? invariant.invariant(false, 'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `<button>` or equivalent interactive element instead.') : invariant.invariant(false) : void 0;
// If the tooltip is used for labelling the interactive element, the trigger element or any of its children should not have aria-label
if (type === 'label') {
const hasAriaLabel = triggerRef.current.hasAttribute('aria-label');
const hasAriaLabelInChildren = Array.from(triggerRef.current.childNodes).some(child => child instanceof HTMLElement && child.hasAttribute('aria-label'));
process.env.NODE_ENV !== "production" ? warning.warning(hasAriaLabel || hasAriaLabelInChildren, 'The label type `Tooltip` is going to be used here to label the trigger element. Please remove the aria-label from the trigger element.') : void 0;
}
// SSR safe polyfill apply
if (typeof window !== 'undefined') {
if (!popoverFn.isSupported()) {
popoverFn.apply();
}
}
const tooltip = tooltipElRef.current;
tooltip.setAttribute('popover', 'auto');
}, [tooltipElRef, triggerRef, direction, type]);
useOnEscapePress.useOnEscapePress(event => {
if (isPopoverOpen) {
event.stopImmediatePropagation();
event.preventDefault();
closeTooltip();
}
}, [isPopoverOpen]);
return /*#__PURE__*/React__default.default.createElement(TooltipContext.Provider, {
value: value
}, /*#__PURE__*/React__default.default.createElement(React__default.default.Fragment, null, /*#__PURE__*/React__default.default.isValidElement(child) && /*#__PURE__*/React__default.default.cloneElement(child, {
ref: triggerRef,
// If it is a type description, we use tooltip to describe the trigger
'aria-describedby': type === 'description' ? tooltipId : child.props['aria-describedby'],
// If it is a label type, we use tooltip to label the trigger
'aria-labelledby': type === 'label' ? tooltipId : child.props['aria-labelledby'],
onBlur: event => {
var _child$props$onBlur, _child$props;
closeTooltip();
(_child$props$onBlur = (_child$props = child.props).onBlur) === null || _child$props$onBlur === void 0 ? void 0 : _child$props$onBlur.call(_child$props, event);
},
onFocus: event => {
var _child$props$onFocus, _child$props2;
// only show tooltip on :focus-visible, not on :focus
try {
if (!event.target.matches(':focus-visible')) return;
} catch (error) {
// jsdom (jest) does not support `:focus-visible` yet and would throw an error
// https://github.com/jsdom/jsdom/issues/3426
}
openTooltip();
(_child$props$onFocus = (_child$props2 = child.props).onFocus) === null || _child$props$onFocus === void 0 ? void 0 : _child$props$onFocus.call(_child$props2, event);
},
onMouseEnter: event => {
var _child$props$onMouseE, _child$props3;
openTooltip();
(_child$props$onMouseE = (_child$props3 = child.props).onMouseEnter) === null || _child$props$onMouseE === void 0 ? void 0 : _child$props$onMouseE.call(_child$props3, event);
},
onMouseLeave: event => {
var _child$props$onMouseL, _child$props4;
closeTooltip();
(_child$props$onMouseL = (_child$props4 = child.props).onMouseLeave) === null || _child$props$onMouseL === void 0 ? void 0 : _child$props$onMouseL.call(_child$props4, event);
}
}), /*#__PURE__*/React__default.default.createElement(StyledTooltip, _extends({
ref: tooltipElRef,
"data-direction": calculatedDirection
}, rest, {
// Only need tooltip role if the tooltip is a description for supplementary information
role: type === 'description' ? 'tooltip' : undefined
// stop AT from announcing the tooltip twice when it is a label type because it will be announced with "aria-labelledby"
,
"aria-hidden": type === 'label' ? true : undefined,
id: tooltipId
// mouse leave and enter on the tooltip itself is needed to keep the tooltip open when the mouse is over the tooltip
,
onMouseEnter: openTooltip,
onMouseLeave: closeTooltip
}), text)));
});
exports.Tooltip = Tooltip;
exports.TooltipContext = TooltipContext;