From 12dce0d263254859d58cffb0367f688c1fc04fa6 Mon Sep 17 00:00:00 2001 From: "D.A. Kahn" Date: Wed, 7 Apr 2021 11:16:59 -0500 Subject: [PATCH 1/8] fix(Tooltip): add newTooltip --- .../components/Tooltip/next/Tooltip-story.js | 69 +++++++++++++++++++ .../src/components/Tooltip/next/Tooltip.js | 0 2 files changed, 69 insertions(+) create mode 100644 packages/react/src/components/Tooltip/next/Tooltip-story.js create mode 100644 packages/react/src/components/Tooltip/next/Tooltip.js diff --git a/packages/react/src/components/Tooltip/next/Tooltip-story.js b/packages/react/src/components/Tooltip/next/Tooltip-story.js new file mode 100644 index 000000000000..988d6a60af1e --- /dev/null +++ b/packages/react/src/components/Tooltip/next/Tooltip-story.js @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react'; +import { useId } from '../../../internal/useId'; + +function Tooltip({ children, label, description }) { + // throw warning if there's more than one child + const child = React.Children.only(children); + const [visible, setVisible] = useState(false); + const id = useId('tooltip'); + + const triggerProps = { + onFocus: () => setVisible(true), + onBlur: () => setVisible(false), + }; + + if (label) { + triggerProps['aria-labelledby'] = id; + } else { + triggerProps['aria-describedby'] = id; + } + + useEffect(() => { + function listener(event) { + if (event.key === 'Escape') { + return setVisible(false); + } + } + window.addEventListener('keydown', listener); + + return () => { + window.removeEventListener('keydown', listener); + }; + }, []); + + return ( +
setVisible(true)} + onMouseLeave={() => setVisible(false)}> + {React.cloneElement(child, triggerProps)} + + + +
+ ); +} + +export const Description = () => { + return ( + + + + ); +}; + +export const Default = () => { + return ( + + + + ); +}; + +export default { title: 'Experimental/unstable_Tooltip' }; diff --git a/packages/react/src/components/Tooltip/next/Tooltip.js b/packages/react/src/components/Tooltip/next/Tooltip.js new file mode 100644 index 000000000000..e69de29bb2d1 From 0bed65966e8359298c606830655f983746034aff Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 8 Apr 2021 10:05:42 -0500 Subject: [PATCH 2/8] chore: check-in work --- .../src/components/popover/_popover.scss | 17 ++- .../src/components/tooltip/_tooltip.scss | 10 ++ .../react/src/components/Popover/index.js | 15 +- .../components/Tooltip/next/Tooltip-story.js | 82 +++-------- .../src/components/Tooltip/next/Tooltip.js | 138 ++++++++++++++++++ .../useNoInteractiveChildren-test.js | 47 ++++++ .../src/internal/useNoInteractiveChildren.js | 70 +++++++++ 7 files changed, 310 insertions(+), 69 deletions(-) create mode 100644 packages/react/src/internal/__tests__/useNoInteractiveChildren-test.js create mode 100644 packages/react/src/internal/useNoInteractiveChildren.js diff --git a/packages/components/src/components/popover/_popover.scss b/packages/components/src/components/popover/_popover.scss index e92a3403e468..05761c6e8459 100644 --- a/packages/components/src/components/popover/_popover.scss +++ b/packages/components/src/components/popover/_popover.scss @@ -76,7 +76,13 @@ //----------------------------------------------------------------------------- // Top //----------------------------------------------------------------------------- - .#{$prefix}--popover--top, + .#{$prefix}--popover--top { + left: 50%; + bottom: 0; + text-align: center; + transform: translate(-50%, calc(100% + #{$popover-offset})); + } + .#{$prefix}--popover--top-left, .#{$prefix}--popover--top-right { bottom: 0; @@ -121,11 +127,18 @@ //----------------------------------------------------------------------------- // Bottom //----------------------------------------------------------------------------- + .#{$prefix}--popover--bottom { + left: 50%; + top: 0; + text-align: center; + transform: translate(-50%, calc(100% - #{$popover-offset})); + } + .#{$prefix}--popover--bottom, .#{$prefix}--popover--bottom-left, .#{$prefix}--popover--bottom-right { top: 0; - transform: translateY(calc(-100% - #{$popover-offset})); + transform: translate(calc(-100% - #{$popover-offset})); } .#{$prefix}--popover--caret.#{$prefix}--popover--bottom diff --git a/packages/components/src/components/tooltip/_tooltip.scss b/packages/components/src/components/tooltip/_tooltip.scss index 09f38cf8df55..3437f228df54 100644 --- a/packages/components/src/components/tooltip/_tooltip.scss +++ b/packages/components/src/components/tooltip/_tooltip.scss @@ -706,6 +706,16 @@ .#{$prefix}--assistive-text { pointer-events: all; } + + // TODO NEXT + .cds--tooltip { + display: inline-block; + position: relative; + } + + .cds--tooltip-content { + padding: 1rem; + } } @include exports('tooltip') { diff --git a/packages/react/src/components/Popover/index.js b/packages/react/src/components/Popover/index.js index 7025345217af..319abbb4edf6 100644 --- a/packages/react/src/components/Popover/index.js +++ b/packages/react/src/components/Popover/index.js @@ -108,20 +108,19 @@ Popover.propTypes = { relative: PropTypes.bool, }; -function PopoverContent({ - as: BaseComponent = 'div', - className, - children, - ...rest -}) { +const PopoverContent = React.forwardRef(function PopoverContent( + { as: BaseComponent = 'div', className, children, ...rest }, + ref +) { return ( + className={cx(`${prefix}--popover-contents`, className)} + ref={ref}> {children} ); -} +}); PopoverContent.propTypes = { /** diff --git a/packages/react/src/components/Tooltip/next/Tooltip-story.js b/packages/react/src/components/Tooltip/next/Tooltip-story.js index 988d6a60af1e..ee321f0e2613 100644 --- a/packages/react/src/components/Tooltip/next/Tooltip-story.js +++ b/packages/react/src/components/Tooltip/next/Tooltip-story.js @@ -1,69 +1,33 @@ -import React, { useState, useEffect } from 'react'; -import { useId } from '../../../internal/useId'; - -function Tooltip({ children, label, description }) { - // throw warning if there's more than one child - const child = React.Children.only(children); - const [visible, setVisible] = useState(false); - const id = useId('tooltip'); - - const triggerProps = { - onFocus: () => setVisible(true), - onBlur: () => setVisible(false), - }; - - if (label) { - triggerProps['aria-labelledby'] = id; - } else { - triggerProps['aria-describedby'] = id; - } - - useEffect(() => { - function listener(event) { - if (event.key === 'Escape') { - return setVisible(false); - } - } - window.addEventListener('keydown', listener); - - return () => { - window.removeEventListener('keydown', listener); - }; - }, []); - - return ( -
setVisible(true)} - onMouseLeave={() => setVisible(false)}> - {React.cloneElement(child, triggerProps)} - - - -
- ); -} +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Add24 } from '@carbon/icons-react'; +import React from 'react'; +import { Tooltip } from './Tooltip'; + +export default { + title: 'Experimental/unstable_Tooltip', + component: Tooltip, +}; -export const Description = () => { +export const Default = () => { return ( - - + + ); }; -export const Default = () => { +export const Description = () => { return ( - - + + ); }; - -export default { title: 'Experimental/unstable_Tooltip' }; diff --git a/packages/react/src/components/Tooltip/next/Tooltip.js b/packages/react/src/components/Tooltip/next/Tooltip.js index e69de29bb2d1..3fc295b3dd39 100644 --- a/packages/react/src/components/Tooltip/next/Tooltip.js +++ b/packages/react/src/components/Tooltip/next/Tooltip.js @@ -0,0 +1,138 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { settings } from 'carbon-components'; +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useRef, useEffect, useState } from 'react'; +import { Popover, PopoverContent } from '../../Popover'; +import { canUseDOM } from '../../../internal/environment'; +import { useEvent } from '../../../internal/useEvent'; +import { useId } from '../../../internal/useId'; +import { useNoInteractiveChildren } from '../../../internal/useNoInteractiveChildren'; + +const { prefix } = settings; + +function Tooltip({ + className: customClassName, + children, + label, + description, + ...rest +}) { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [open, setOpen] = useState(false); + const id = useId('tooltip'); + const child = React.Children.only(children); + + const triggerProps = { + onFocus: () => setOpen(true), + onBlur: () => setOpen(false), + }; + + if (label) { + triggerProps['aria-labelledby'] = id; + } else { + triggerProps['aria-describedby'] = id; + } + + if (__DEV__) { + useNoInteractiveChildren( + tooltipRef, + 'The Tooltip component must have no interactive content rendered by the' + + '`label` or `description` prop' + ); + } + + if (canUseDOM) { + useEvent(window, 'keydown', () => { + setOpen(false); + }); + } + + return ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + ref={containerRef}> + {React.cloneElement(child, triggerProps)} + {open && } + + + +
+ ); +} + +function MouseArea({ from: fromRef, to: toRef }) { + const [style, setStyle] = useState({}); + + useEffect(() => { + const { current: from } = fromRef; + const { current: to } = toRef; + const fromRect = from.getBoundingClientRect(); + const toRect = to.getBoundingClientRect(); + + const height = toRect.top - fromRect.top; + const width = Math.max(toRect.width, fromRect.width); + + // console.log(fromRect); + // console.log(toRect); + + console.log(width); + console.log(height); + }, []); + + return
; +} + +Tooltip.propTypes = { + /** + * Pass in the child to which the tooltip will be applied + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the container node + */ + className: PropTypes.string, + + /** + * Provide the label to be rendered inside of the Tooltip. The label will use + * `aria-labelledby` and will fully describe the child node that is provided. + * This means that if you have text in the child node, that it will not be + * announced to the screen reader. + * + * Note: if label and description are both provided, description will not be + * used + */ + label: PropTypes.node, + + /** + * Provide the description to be rendered inside of the Tooltip. The + * description will use `aria-describedby` and will describe the child node + * in addition to the text rendered inside of the child. This means that if you + * have text in the child node, that it will be announced alongside the + * description to the screen reader. + * + * Note: if label and description are both provided, label will be used and + * description will not be used + */ + description: PropTypes.node, +}; + +export { Tooltip }; diff --git a/packages/react/src/internal/__tests__/useNoInteractiveChildren-test.js b/packages/react/src/internal/__tests__/useNoInteractiveChildren-test.js new file mode 100644 index 000000000000..4cd3f6c0f908 --- /dev/null +++ b/packages/react/src/internal/__tests__/useNoInteractiveChildren-test.js @@ -0,0 +1,47 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { cleanup, render } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { useNoInteractiveChildren } from '../useNoInteractiveChildren'; + +describe('useNoInteractiveChildren', () => { + afterEach(cleanup); + + it('should render without errors if no interactive content is found', () => { + function TestComponent() { + const ref = useRef(null); + useNoInteractiveChildren(ref); + return Content; + } + + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should throw an error if interactive content is found', () => { + function TestComponent() { + const ref = useRef(null); + useNoInteractiveChildren(ref); + return ( +
+ +
+ ); + } + + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow(); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/packages/react/src/internal/useNoInteractiveChildren.js b/packages/react/src/internal/useNoInteractiveChildren.js new file mode 100644 index 000000000000..6bec45d6df4b --- /dev/null +++ b/packages/react/src/internal/useNoInteractiveChildren.js @@ -0,0 +1,70 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect } from 'react'; + +export function useNoInteractiveChildren(ref, message) { + useEffect(() => { + const node = getInteractiveContent(ref.current); + + if (node) { + throw new Error(`Error: ${message}.\n\nInstead found: ${node.outerHTML}`); + } + }); +} + +/** + * Determines if a given DOM node has interactive content, or is itself + * interactive. It returns the interactive node if one is found + * + * @param {HTMLElement} node + * @returns {HTMLElement} + */ +function getInteractiveContent(node) { + if (isFocusable(node)) { + return node; + } + + for (const childNode of node.childNodes) { + const interactiveNode = getInteractiveContent(childNode); + if (interactiveNode) { + return interactiveNode; + } + } + + return null; +} + +/** + * Determines if the given element is focusable, or not + * + * @param {HTMLElement} element + * @returns {boolean} + * @see https://github.com/w3c/aria-practices/blob/0553bb51588ffa517506e2a1b2ca1422ed438c5f/examples/js/utils.js#L68 + */ +function isFocusable(element) { + if (element.tabIndex < 0) { + return false; + } + + if (element.disabled) { + return false; + } + + switch (element.nodeName) { + case 'A': + return !!element.href && element.rel !== 'ignore'; + case 'INPUT': + return element.type !== 'hidden'; + case 'BUTTON': + case 'SELECT': + case 'TEXTAREA': + return true; + default: + return false; + } +} From b0150f1b408b26971c27668f6251760a290851f5 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 14 Apr 2021 09:08:13 -0500 Subject: [PATCH 3/8] chore: check-in work --- .../src/components/popover/_popover.scss | 25 +++++++++++++++++-- .../src/components/Tooltip/next/Tooltip.js | 23 ----------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/components/src/components/popover/_popover.scss b/packages/components/src/components/popover/_popover.scss index 05761c6e8459..faecc7402541 100644 --- a/packages/components/src/components/popover/_popover.scss +++ b/packages/components/src/components/popover/_popover.scss @@ -24,6 +24,14 @@ display: none; } + // Click area + .#{$prefix}--popover::before { + content: ''; + display: block; + position: absolute; + height: $popover-offset; + } + .#{$prefix}--popover--relative { position: relative; } @@ -124,6 +132,13 @@ transform: translateY(-50%) rotate(45deg); } + .#{$prefix}--popover--top.#{$prefix}--popover::before { + top: 0; + left: 0; + right: 0; + transform: translateY(-100%); + } + //----------------------------------------------------------------------------- // Bottom //----------------------------------------------------------------------------- @@ -131,10 +146,9 @@ left: 50%; top: 0; text-align: center; - transform: translate(-50%, calc(100% - #{$popover-offset})); + transform: translate(-50%, calc(-100% - #{$popover-offset})); } - .#{$prefix}--popover--bottom, .#{$prefix}--popover--bottom-left, .#{$prefix}--popover--bottom-right { top: 0; @@ -168,6 +182,13 @@ transform: translateY(50%) rotate(45deg); } + .#{$prefix}--popover--bottom.#{$prefix}--popover::before { + bottom: 0; + left: 0; + right: 0; + transform: translateY(100%); + } + //----------------------------------------------------------------------------- // Left //----------------------------------------------------------------------------- diff --git a/packages/react/src/components/Tooltip/next/Tooltip.js b/packages/react/src/components/Tooltip/next/Tooltip.js index 3fc295b3dd39..e0c9980af6e9 100644 --- a/packages/react/src/components/Tooltip/next/Tooltip.js +++ b/packages/react/src/components/Tooltip/next/Tooltip.js @@ -63,7 +63,6 @@ function Tooltip({ onMouseLeave={() => setOpen(false)} ref={containerRef}> {React.cloneElement(child, triggerProps)} - {open && }