From 60cf9550d5076ab27f790758bd8d56da3b03874e Mon Sep 17 00:00:00 2001 From: Ben Cox <1038350+ind1go@users.noreply.github.com> Date: Mon, 22 Mar 2021 16:59:19 +0000 Subject: [PATCH 1/7] feat(UI icons): add 'football' alias for 'soccer' (#8126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the soccer icon more accessible to the rest of the world... 😉 Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/icons/icons.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/icons/icons.yml b/packages/icons/icons.yml index 41a76219134a..c2993cdd318f 100644 --- a/packages/icons/icons.yml +++ b/packages/icons/icons.yml @@ -13010,6 +13010,7 @@ aliases: - soccer - sports + - football sizes: - 32 - name: soil-moisture From 5efe02801f673579d1213909c3713a44870d46ca Mon Sep 17 00:00:00 2001 From: emyarod Date: Mon, 22 Mar 2021 12:27:20 -0500 Subject: [PATCH 2/7] fix(treeview): update text and color tokens to match spec (#8075) * docs(TreeView): set treeview width * fix(treeview): update icon color token * fix(treeview): update enabled hover text tokens * fix(treeview): update selected state blue side bar token * fix(treeview): update selected and hover icon color tokens * fix(treeview): scope icon hover styles to current hovered node * fix(treeview): update disablednode pointer and focus styles Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../src/components/treeview/_treeview.scss | 50 +++++++++++++++---- .../src/components/TreeView/TreeView-story.js | 1 + .../react/src/components/TreeView/story.scss | 10 ++++ 3 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 packages/react/src/components/TreeView/story.scss diff --git a/packages/components/src/components/treeview/_treeview.scss b/packages/components/src/components/treeview/_treeview.scss index 604ea373079d..92cb936d34c6 100644 --- a/packages/components/src/components/treeview/_treeview.scss +++ b/packages/components/src/components/treeview/_treeview.scss @@ -35,24 +35,34 @@ @include focus-outline('outline'); } - .#{$prefix}--tree-node--disabled { - color: $disabled-02; - background-color: $disabled-01; - pointer-events: none; + .#{$prefix}--tree-node--disabled:focus > .#{$prefix}--tree-node__label { + outline: none; } - .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__label:hover { + .#{$prefix}--tree-node--disabled, + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__label:hover, + .#{$prefix}--tree-node--disabled + .#{$prefix}--tree-node__label:hover + .#{$prefix}--tree-node__label__details { + color: $disabled-02; background-color: $disabled-01; } .#{$prefix}--tree-node--disabled .#{$prefix}--tree-parent-node__toggle-icon, - .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__icon { + .#{$prefix}--tree-node--disabled .#{$prefix}--tree-node__icon, + .#{$prefix}--tree-node--disabled + .#{$prefix}--tree-node__label:hover + .#{$prefix}--tree-parent-node__toggle-icon, + .#{$prefix}--tree-node--disabled + .#{$prefix}--tree-node__label:hover + .#{$prefix}--tree-node__icon { fill: $disabled-02; } + .#{$prefix}--tree-node--disabled, .#{$prefix}--tree-node--disabled .#{$prefix}--tree-parent-node__toggle-icon:hover { - cursor: default; + cursor: not-allowed; } .#{$prefix}--tree-node__label { @@ -62,10 +72,21 @@ min-height: rem(32px); &:hover { + color: $text-01; background-color: $hover-ui; } } + .#{$prefix}--tree-node__label:hover .#{$prefix}--tree-node__label__details { + color: $text-01; + } + + .#{$prefix}--tree-node__label:hover + .#{$prefix}--tree-parent-node__toggle-icon, + .#{$prefix}--tree-node__label:hover .#{$prefix}--tree-node__icon { + fill: $icon-01; + } + .#{$prefix}--tree-leaf-node { display: flex; padding-left: $spacing-08; @@ -101,7 +122,7 @@ .#{$prefix}--tree-parent-node__toggle-icon { transform: rotate(-90deg); transition: all $duration--fast-02 motion(standard, productive); - fill: $icon-01; + fill: $icon-02; } .#{$prefix}--tree-parent-node__toggle-icon--expanded { @@ -110,7 +131,7 @@ .#{$prefix}--tree-node__icon { margin-right: $spacing-03; - fill: $icon-01; + fill: $icon-02; } .#{$prefix}--tree-node--selected > .#{$prefix}--tree-node__label { @@ -122,6 +143,15 @@ } } + .#{$prefix}--tree-node--selected + > .#{$prefix}--tree-node__label + .#{$prefix}--tree-parent-node__toggle-icon, + .#{$prefix}--tree-node--selected + > .#{$prefix}--tree-node__label + .#{$prefix}--tree-node__icon { + fill: $icon-01; + } + .#{$prefix}--tree-node--active > .#{$prefix}--tree-node__label { position: relative; @@ -131,7 +161,7 @@ left: 0; width: rem(4px); height: 100%; - background-color: $interactive-01; + background-color: $interactive-04; content: ''; } } diff --git a/packages/react/src/components/TreeView/TreeView-story.js b/packages/react/src/components/TreeView/TreeView-story.js index 509a9beb4d22..3bedf17d6a26 100644 --- a/packages/react/src/components/TreeView/TreeView-story.js +++ b/packages/react/src/components/TreeView/TreeView-story.js @@ -17,6 +17,7 @@ import { } from '@storybook/addon-knobs'; import { InlineNotification } from '../Notification'; import TreeView, { TreeNode } from '../TreeView'; +import './story.scss'; const sizes = { default: 'default', diff --git a/packages/react/src/components/TreeView/story.scss b/packages/react/src/components/TreeView/story.scss new file mode 100644 index 000000000000..2c5e6b9c2144 --- /dev/null +++ b/packages/react/src/components/TreeView/story.scss @@ -0,0 +1,10 @@ +// +// 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. +// + +.bx--tree { + width: 16rem; +} From 8eb7ecdb174e3d301c30f09ade54fc372f960ff7 Mon Sep 17 00:00:00 2001 From: TJ Egan Date: Mon, 22 Mar 2021 14:17:18 -0700 Subject: [PATCH 3/7] fix(Button): close Icon only button tooltip, TooltipIcon when another button/tooltip is focused or hovered (#8022) * fix(Button): prevent button tooltips from overlapping * fix(Button): don't lose focus on mouseout * fix(Button): remove focused tooltip if another tooltip is hovered * fix(TooltipIcon): sync icon-only button tooltip logic in TooltipIcon * test(snapshot): update snapshot tests * style(UIShell): prevent mouseout when hovering svg inside tooltip button * fix(Button): get list of tooltips on mouseenter not on render * fix(a11y): updates to meet WCAG 2.1 - 1.4.13 compliance * fix(tooltip): don't close tooltip is element still has focus * style(svg): remove pointer-events: none from previous commit * test(snapshot): update snapshot tests * chore(storybook): remove test stories --- .../src/components/button/_button.scss | 6 + .../src/components/tooltip/_tooltip.scss | 6 + .../components/src/globals/scss/_tooltip.scss | 33 ++++++ .../__snapshots__/PublicAPI-test.js.snap | 18 +++ .../react/src/components/Button/Button.js | 110 +++++++++++++++++- .../__snapshots__/DataTable-test.js.snap | 40 +++++++ .../TableBatchAction-test.js.snap | 4 + .../TableBatchActions-test.js.snap | 8 ++ .../__snapshots__/ModalWrapper-test.js.snap | 12 ++ .../src/components/TooltipIcon/TooltipIcon.js | 81 ++++++++++++- .../__snapshots__/TooltipIcon-test.js.snap | 6 + .../HeaderGlobalAction-test.js.snap | 9 +- 12 files changed, 324 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/button/_button.scss b/packages/components/src/components/button/_button.scss index 5ed90d6e1db8..5fc7a719772c 100644 --- a/packages/components/src/components/button/_button.scss +++ b/packages/components/src/components/button/_button.scss @@ -162,6 +162,12 @@ } } + // Allow pointer events on tooltip when tooltip is visible + .#{$prefix}--btn.#{$prefix}--btn--icon-only:not(.#{$prefix}--tooltip--hidden) + .#{$prefix}--assistive-text { + pointer-events: all; + } + .#{$prefix}--btn.#{$prefix}--btn--icon-only.#{$prefix}--tooltip__trigger:focus { border-color: $focus; diff --git a/packages/components/src/components/tooltip/_tooltip.scss b/packages/components/src/components/tooltip/_tooltip.scss index 2103474de494..09f38cf8df55 100644 --- a/packages/components/src/components/tooltip/_tooltip.scss +++ b/packages/components/src/components/tooltip/_tooltip.scss @@ -700,6 +700,12 @@ @include tooltip--placement('icon', 'left', 'end'); } } + + // Allow pointer events on tooltip when tooltip is visible + .#{$prefix}--tooltip__trigger:not(.#{$prefix}--tooltip--hidden) + .#{$prefix}--assistive-text { + pointer-events: all; + } } @include exports('tooltip') { diff --git a/packages/components/src/globals/scss/_tooltip.scss b/packages/components/src/globals/scss/_tooltip.scss index d4e41ce3dbda..4dabb826b393 100644 --- a/packages/components/src/globals/scss/_tooltip.scss +++ b/packages/components/src/globals/scss/_tooltip.scss @@ -224,6 +224,39 @@ $caret-width: rem(8px); $body-spacing: $caret-spacing + $caret-height; + // Use pseudo element to create invisible hover area to keep tooltip open on hover + .#{$prefix}--assistive-text::after { + position: absolute; + display: block; + content: ''; + // clip-path: polygon(50% 100%, 0 0, 100% 0); + + @if ($position == 'top' or $position == 'bottom') { + left: 0; + width: 100%; + height: rem(12px); + } + + @if ($position == 'left' or $position == 'right') { + top: 0; + width: rem(12px); + height: 100%; + } + + @if ($position == 'top') { + bottom: rem(-12px); + } + @if ($position == 'right') { + left: rem(-12px); + } + @if ($position == 'bottom') { + top: rem(-12px); + } + @if ($position == 'left') { + right: rem(-12px); + } + } + // @todo Simplify CSS selectors on next major release &::before, &::after, diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 6d9b62cf41ad..33d423097682 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -207,6 +207,18 @@ Map { "isRequired": true, "type": "oneOf", }, + "onBlur": Object { + "type": "func", + }, + "onFocus": Object { + "type": "func", + }, + "onMouseEnter": Object { + "type": "func", + }, + "onMouseLeave": Object { + "type": "func", + }, "renderIcon": Object { "args": Array [ Array [ @@ -6351,12 +6363,18 @@ Map { "id": Object { "type": "string", }, + "onBlur": Object { + "type": "func", + }, "onFocus": Object { "type": "func", }, "onMouseEnter": Object { "type": "func", }, + "onMouseLeave": Object { + "type": "func", + }, "tooltipText": Object { "isRequired": true, "type": "string", diff --git a/packages/react/src/components/Button/Button.js b/packages/react/src/components/Button/Button.js index c6185f323a61..fceccecad93a 100644 --- a/packages/react/src/components/Button/Button.js +++ b/packages/react/src/components/Button/Button.js @@ -6,11 +6,14 @@ */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { settings } from 'carbon-components'; import { ButtonKinds } from '../../prop-types/types'; import deprecate from '../../prop-types/deprecate'; +import { composeEventHandlers } from '../../tools/events'; +import { keys, matches } from '../../internal/keyboard'; +import toggleClass from '../../tools/toggleClass'; const { prefix } = settings; const Button = React.forwardRef(function Button( @@ -31,10 +34,78 @@ const Button = React.forwardRef(function Button( hasIconOnly, tooltipPosition, tooltipAlignment, + onBlur, + onFocus, + onMouseEnter, + onMouseLeave, ...other }, ref ) { + const [allowTooltipVisibility, setAllowTooltipVisibility] = useState(true); + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const tooltipRef = useRef(null); + const tooltipTimeout = useRef(null); + + const closeTooltips = (evt) => { + const tooltipNode = document?.querySelectorAll(`.${prefix}--tooltip--a11y`); + [...tooltipNode].map((node) => { + toggleClass( + node, + `${prefix}--tooltip--hidden`, + node !== evt.currentTarget + ); + }); + }; + + const handleFocus = (evt) => { + closeTooltips(evt); + setIsHovered(!isHovered); + setIsFocused(true); + setAllowTooltipVisibility(true); + }; + + const handleBlur = () => { + setIsHovered(false); + setIsFocused(false); + setAllowTooltipVisibility(false); + }; + + const handleMouseEnter = (evt) => { + setIsHovered(true); + tooltipTimeout.current && clearTimeout(tooltipTimeout.current); + + if (evt.target === tooltipRef.current) { + setAllowTooltipVisibility(true); + return; + } + + closeTooltips(evt); + + setAllowTooltipVisibility(true); + }; + + const handleMouseLeave = () => { + if (!isFocused) { + tooltipTimeout.current = setTimeout(() => { + setAllowTooltipVisibility(false); + setIsHovered(false); + }, 100); + } + }; + + useEffect(() => { + const handleEscKeyDown = (event) => { + if (matches(event, [keys.Escape])) { + setAllowTooltipVisibility(false); + setIsHovered(false); + } + }; + document.addEventListener('keydown', handleEscKeyDown); + return () => document.removeEventListener('keydown', handleEscKeyDown); + }, []); + const buttonClasses = classNames(className, { [`${prefix}--btn`]: true, [`${prefix}--btn--field`]: size === 'field', @@ -43,6 +114,8 @@ const Button = React.forwardRef(function Button( [`${prefix}--btn--xl`]: size === 'xl', [`${prefix}--btn--${kind}`]: kind, [`${prefix}--btn--disabled`]: disabled, + [`${prefix}--tooltip--hidden`]: hasIconOnly && !allowTooltipVisibility, + [`${prefix}--tooltip--visible`]: isHovered, [`${prefix}--btn--icon-only`]: hasIconOnly, [`${prefix}--btn--selected`]: hasIconOnly && isSelected && kind === 'ghost', [`${prefix}--tooltip__trigger`]: hasIconOnly, @@ -76,7 +149,12 @@ const Button = React.forwardRef(function Button( href, }; const assistiveText = hasIconOnly ? ( - {iconDescription} +
+ {iconDescription} +
) : null; if (as) { component = as; @@ -91,6 +169,10 @@ const Button = React.forwardRef(function Button( return React.createElement( component, { + onMouseEnter: composeEventHandlers([onMouseEnter, handleMouseEnter]), + onMouseLeave: composeEventHandlers([onMouseLeave, handleMouseLeave]), + onFocus: composeEventHandlers([onFocus, handleFocus]), + onBlur: composeEventHandlers([onBlur, handleBlur]), ...other, ...commonProps, ...otherProps, @@ -161,6 +243,30 @@ Button.propTypes = { */ kind: PropTypes.oneOf(ButtonKinds).isRequired, + /** + * Provide an optional function to be called when the button element + * loses focus + */ + onBlur: PropTypes.func, + + /** + * Provide an optional function to be called when the button element + * receives focus + */ + onFocus: PropTypes.func, + + /** + * Provide an optional function to be called when the mouse + * enters the button element + */ + onMouseEnter: PropTypes.func, + + /** + * Provide an optional function to be called when the mouse + * leaves the button element + */ + onMouseLeave: PropTypes.func, + /** * Optional prop to allow overriding the icon rendering. * Can be a React component class diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap index f917629852bf..538983cbb103 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap @@ -2063,7 +2063,11 @@ exports[`DataTable should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -2136,7 +2140,11 @@ exports[`DataTable should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -2209,7 +2217,11 @@ exports[`DataTable should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -2268,7 +2280,11 @@ exports[`DataTable should render 1`] = ` aria-pressed={null} className="bx--batch-summary__cancel bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={-1} type="button" > @@ -2522,7 +2538,11 @@ exports[`DataTable should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--sm bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -3074,7 +3094,11 @@ exports[`DataTable sticky header should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -3147,7 +3171,11 @@ exports[`DataTable sticky header should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -3220,7 +3248,11 @@ exports[`DataTable sticky header should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -3279,7 +3311,11 @@ exports[`DataTable sticky header should render 1`] = ` aria-pressed={null} className="bx--batch-summary__cancel bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={-1} type="button" > @@ -3533,7 +3569,11 @@ exports[`DataTable sticky header should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--sm bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchAction-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchAction-test.js.snap index 4d398c882fea..bdfd58f405c2 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchAction-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchAction-test.js.snap @@ -32,6 +32,10 @@ exports[`DataTable.TableBatchAction should render 1`] = ` aria-pressed={null} className="custom-class bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchActions-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchActions-test.js.snap index 8d58bca90557..2791ba24bb6c 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchActions-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/TableBatchActions-test.js.snap @@ -42,7 +42,11 @@ exports[`DataTable.TableBatchActions should render 1`] = ` aria-pressed={null} className="bx--batch-summary__cancel bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={-1} type="button" > @@ -97,7 +101,11 @@ exports[`DataTable.TableBatchActions should render 2`] = ` aria-pressed={null} className="bx--batch-summary__cancel bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[MockFunction]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > diff --git a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap index 6c191edfa692..ded1f508d498 100644 --- a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap +++ b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap @@ -39,7 +39,11 @@ exports[`ModalWrapper should render 1`] = ` aria-pressed={null} className="btn-trigger bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -175,7 +179,11 @@ exports[`ModalWrapper should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--secondary" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > @@ -197,7 +205,11 @@ exports[`ModalWrapper should render 1`] = ` aria-pressed={null} className="bx--btn bx--btn--primary" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} tabIndex={0} type="button" > diff --git a/packages/react/src/components/TooltipIcon/TooltipIcon.js b/packages/react/src/components/TooltipIcon/TooltipIcon.js index c0958afd88cf..80d14a92dc1f 100644 --- a/packages/react/src/components/TooltipIcon/TooltipIcon.js +++ b/packages/react/src/components/TooltipIcon/TooltipIcon.js @@ -6,12 +6,13 @@ */ import cx from 'classnames'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { settings } from 'carbon-components'; import setupGetInstanceId from '../../tools/setupGetInstanceId'; import { composeEventHandlers } from '../../tools/events'; import { keys, matches } from '../../internal/keyboard'; +import toggleClass from '../../tools/toggleClass'; const { prefix } = settings; const getInstanceId = setupGetInstanceId(); @@ -21,12 +22,18 @@ const TooltipIcon = ({ children, direction, align, + onBlur, onFocus, onMouseEnter, + onMouseLeave, tooltipText, ...rest }) => { const [allowTooltipVisibility, setAllowTooltipVisibility] = useState(true); + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const tooltipRef = useRef(null); + const tooltipTimeout = useRef(null); const tooltipId = id || `icon-tooltip-${getInstanceId()}`; const tooltipTriggerClasses = cx( `${prefix}--tooltip__trigger`, @@ -36,14 +43,62 @@ const TooltipIcon = ({ [`${prefix}--tooltip--${direction}`]: direction, [`${prefix}--tooltip--align-${align}`]: align, [`${prefix}--tooltip--hidden`]: !allowTooltipVisibility, + [`${prefix}--tooltip--visible`]: isHovered, } ); - const handleFocus = () => setAllowTooltipVisibility(true); - const handleMouseEnter = () => setAllowTooltipVisibility(true); + + const closeTooltips = (evt) => { + const tooltipNode = document?.querySelectorAll(`.${prefix}--tooltip--a11y`); + [...tooltipNode].map((node) => { + toggleClass( + node, + `${prefix}--tooltip--hidden`, + node !== evt.currentTarget + ); + }); + }; + + const handleFocus = (evt) => { + closeTooltips(evt); + setIsHovered(!isHovered); + setIsFocused(true); + setAllowTooltipVisibility(true); + }; + + const handleBlur = () => { + setIsHovered(false); + setIsFocused(false); + setAllowTooltipVisibility(false); + }; + + const handleMouseEnter = (evt) => { + setIsHovered(true); + tooltipTimeout.current && clearTimeout(tooltipTimeout.current); + + if (evt.target === tooltipRef.current) { + setAllowTooltipVisibility(true); + return; + } + + closeTooltips(evt); + + setAllowTooltipVisibility(true); + }; + + const handleMouseLeave = () => { + if (!isFocused) { + tooltipTimeout.current = setTimeout(() => { + setAllowTooltipVisibility(false); + setIsHovered(false); + }, 100); + } + }; + useEffect(() => { const handleEscKeyDown = (event) => { if (matches(event, [keys.Escape])) { setAllowTooltipVisibility(false); + setIsHovered(false); } }; document.addEventListener('keydown', handleEscKeyDown); @@ -57,8 +112,14 @@ const TooltipIcon = ({ className={tooltipTriggerClasses} aria-describedby={tooltipId} onMouseEnter={composeEventHandlers([onMouseEnter, handleMouseEnter])} - onFocus={composeEventHandlers([onFocus, handleFocus])}> - + onMouseLeave={composeEventHandlers([onMouseLeave, handleMouseLeave])} + onFocus={composeEventHandlers([onFocus, handleFocus])} + onBlur={composeEventHandlers([onBlur, handleBlur])}> + {tooltipText} {children} @@ -95,6 +156,11 @@ TooltipIcon.propTypes = { */ id: PropTypes.string, + /** + * The event handler for the `blur` event. + */ + onBlur: PropTypes.func, + /** * The event handler for the `focus` event. */ @@ -105,6 +171,11 @@ TooltipIcon.propTypes = { */ onMouseEnter: PropTypes.func, + /** + * The event handler for the `mouseleave` event. + */ + onMouseLeave: PropTypes.func, + /** * Provide the ARIA label for the tooltip. * TODO: rename this prop (will be a breaking change) diff --git a/packages/react/src/components/TooltipIcon/__snapshots__/TooltipIcon-test.js.snap b/packages/react/src/components/TooltipIcon/__snapshots__/TooltipIcon-test.js.snap index 351021445b7e..7f102fddb30b 100644 --- a/packages/react/src/components/TooltipIcon/__snapshots__/TooltipIcon-test.js.snap +++ b/packages/react/src/components/TooltipIcon/__snapshots__/TooltipIcon-test.js.snap @@ -10,13 +10,16 @@ exports[`TooltipIcon should allow the user to specify the direction 1`] = ` - +

Content for third tab goes here.

Content for fourth tab goes here.

@@ -152,23 +153,24 @@ _Default.story = { export const Playground = () => (
- +

Content for first tab goes here.

- +

Content for second tab goes here.

- +

Content for third tab goes here.

@@ -193,13 +195,14 @@ export const Playground = () => ( export const Container = () => ( - +

Content for first tab goes here.

- +

Content for second tab goes here.

Content for third tab goes here.

@@ -219,16 +222,17 @@ export const Skeleton = () => { ) : ( - +

Content for first tab goes here.

- +

Content for second tab goes here.

- +

Content for third tab goes here.

Content for fourth tab goes here.

From 635e0cbb37542f8ecc574b449e4250bd2442116b Mon Sep 17 00:00:00 2001 From: emyarod Date: Mon, 22 Mar 2021 18:10:41 -0500 Subject: [PATCH 6/7] fix(Dropdown): prevent unnecessary duplicate VO readings (#7768) * fix(ListBoxMenuItem): add title tooltip only when content overflows * docs(ListBoxMenu): update children proptype * docs(Dropdown): reduce item width * chore: update snapshots * fix(ListBoxMenuItem): add title tooltip only when content overflows * refactor(ComboBox): convert to functional component to allow hooks * fix(ListBoxMenuItem): forward ref for class based listbox components * test(ListBox): update assertions for forwarded ref in listbox menu items * chore: update snapshots Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../src/components/ComboBox/ComboBox-test.js | 21 +- .../react/src/components/ComboBox/ComboBox.js | 900 +++++++++--------- .../src/components/Dropdown/Dropdown-story.js | 3 +- .../src/components/Dropdown/Dropdown-test.js | 5 +- .../react/src/components/Dropdown/Dropdown.js | 12 +- .../__snapshots__/Dropdown-test.js.snap | 32 +- .../src/components/ListBox/ListBoxMenu.js | 1 + .../src/components/ListBox/ListBoxMenuItem.js | 42 +- .../__snapshots__/ListBoxMenu-test.js.snap | 4 +- .../ListBoxMenuItem-test.js.snap | 12 +- .../__tests__/FilterableMultiSelect-test.js | 4 +- .../MultiSelect/__tests__/MultiSelect-test.js | 2 +- 12 files changed, 525 insertions(+), 513 deletions(-) diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js index bacc66c3fb87..06b5ee247864 100644 --- a/packages/react/src/components/ComboBox/ComboBox-test.js +++ b/packages/react/src/components/ComboBox/ComboBox-test.js @@ -10,7 +10,6 @@ import { mount } from 'enzyme'; import { findListBoxNode, findMenuNode, - findMenuItemNode, assertMenuOpen, assertMenuClosed, generateItems, @@ -20,13 +19,7 @@ import ComboBox from '../ComboBox'; import { settings } from 'carbon-components'; const { prefix } = settings; - const findInputNode = (wrapper) => wrapper.find(`.${prefix}--text-input`); -const downshiftActions = { - setHighlightedIndex: jest.fn(), -}; -const clearInput = (wrapper) => - wrapper.instance().handleOnStateChange({ inputValue: '' }, downshiftActions); const openMenu = (wrapper) => { wrapper.find(`[role="combobox"]`).simulate('click'); }; @@ -64,9 +57,8 @@ describe('ComboBox', () => { expect(mockProps.onChange).not.toHaveBeenCalled(); for (let i = 0; i < mockProps.items.length; i++) { - clearInput(wrapper); openMenu(wrapper); - findMenuItemNode(wrapper, i).simulate('click'); + wrapper.find('ForwardRef(ListBoxMenuItem)').at(i).simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(i + 1); expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItem: mockProps.items[i], @@ -100,7 +92,7 @@ describe('ComboBox', () => { it('should let the user select an option by clicking on the option node', () => { const wrapper = mount(); openMenu(wrapper); - findMenuItemNode(wrapper, 0).simulate('click'); + wrapper.find('ForwardRef(ListBoxMenuItem)').at(0).simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItem: mockProps.items[0], @@ -110,7 +102,7 @@ describe('ComboBox', () => { mockProps.onChange.mockClear(); openMenu(wrapper); - findMenuItemNode(wrapper, 1).simulate('click'); + wrapper.find('ForwardRef(ListBoxMenuItem)').at(1).simulate('click'); expect(mockProps.onChange).toHaveBeenCalledTimes(1); expect(mockProps.onChange).toHaveBeenCalledWith({ selectedItem: mockProps.items[1], @@ -207,12 +199,7 @@ describe('ComboBox', () => { it('should set `inputValue` to an empty string if a falsey-y value is given', () => { const wrapper = mount(); - - wrapper.instance().handleOnInputValueChange('foo', downshiftActions); - expect(wrapper.state('inputValue')).toBe('foo'); - - wrapper.instance().handleOnInputValueChange(null, downshiftActions); - expect(wrapper.state('inputValue')).toBe(''); + expect(wrapper.find('input').instance().value).toBe(''); }); }); }); diff --git a/packages/react/src/components/ComboBox/ComboBox.js b/packages/react/src/components/ComboBox/ComboBox.js index e3ba0b2dd378..ab8a584a3600 100644 --- a/packages/react/src/components/ComboBox/ComboBox.js +++ b/packages/react/src/components/ComboBox/ComboBox.js @@ -8,7 +8,7 @@ import cx from 'classnames'; import Downshift from 'downshift'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { settings } from 'carbon-components'; import { Checkmark16, @@ -34,16 +34,21 @@ const defaultItemToString = (item) => { const defaultShouldFilterItem = () => true; -const getInputValue = (props, state) => { - if (props.selectedItem) { - return props.itemToString(props.selectedItem); +const getInputValue = ({ + initialSelectedItem, + inputValue, + itemToString, + selectedItem, +}) => { + if (selectedItem) { + return itemToString(selectedItem); } // TODO: consistent `initialSelectedItem` behavior with other listbox components in v11 - if (props.initialSelectedItem) { - return props.itemToString(props.initialSelectedItem); + if (initialSelectedItem) { + return itemToString(initialSelectedItem); } - return state.inputValue || ''; + return inputValue || ''; }; const findHighlightedIndex = ({ items, itemToString }, inputValue) => { @@ -65,242 +70,103 @@ const findHighlightedIndex = ({ items, itemToString }, inputValue) => { const getInstanceId = setupGetInstanceId(); -export default class ComboBox extends React.Component { - static propTypes = { - /** - * 'aria-label' of the ListBox component. - */ - ariaLabel: PropTypes.string, - - /** - * An optional className to add to the container node - */ - className: PropTypes.string, - - /** - * Specify the direction of the combobox dropdown. Can be either top or bottom. - */ - direction: PropTypes.oneOf(['top', 'bottom']), - - /** - * Specify if the control should be disabled, or not - */ - disabled: PropTypes.bool, - - /** - * Additional props passed to Downshift - */ - downshiftProps: PropTypes.shape(Downshift.propTypes), - - /** - * Provide helper text that is used alongside the control label for - * additional help - */ - helperText: PropTypes.string, - - /** - * Specify a custom `id` for the input - */ - id: PropTypes.string.isRequired, - - /** - * Allow users to pass in an arbitrary item or a string (in case their items are an array of strings) - * from their collection that are pre-selected - */ - initialSelectedItem: PropTypes.oneOfType([ - PropTypes.object, - PropTypes.string, - ]), - - /** - * Specify if the currently selected value is invalid. - */ - invalid: PropTypes.bool, - - /** - * Message which is displayed if the value is invalid. - */ - invalidText: PropTypes.node, - - /** - * Optional function to render items as custom components instead of strings. - * Defaults to null and is overriden by a getter - */ - itemToElement: PropTypes.func, - - /** - * Helper function passed to downshift that allows the library to render a - * given item to a string label. By default, it extracts the `label` field - * from a given item to serve as the item label in the list - */ - itemToString: PropTypes.func, - - /** - * We try to stay as generic as possible here to allow individuals to pass - * in a collection of whatever kind of data structure they prefer - */ - items: PropTypes.array.isRequired, - - /** - * should use "light theme" (white background)? - */ - light: PropTypes.bool, - - /** - * `onChange` is a utility for this controlled component to communicate to a - * consuming component when a specific dropdown item is selected. - * @param {{ selectedItem }} - */ - onChange: PropTypes.func.isRequired, - - /** - * Callback function to notify consumer when the text input changes. - * This provides support to change available items based on the text. - * @param {string} inputText - */ - onInputChange: PropTypes.func, - - /** - * Callback function that fires when the combobox menu toggle is clicked - * @param {MouseEvent} event - */ - onToggleClick: PropTypes.func, - - /** - * Used to provide a placeholder text node before a user enters any input. - * This is only present if the control has no items selected - */ - placeholder: PropTypes.string.isRequired, - - /** - * For full control of the selection - */ - selectedItem: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), - - /** - * Specify your own filtering logic by passing in a `shouldFilterItem` - * function that takes in the current input and an item and passes back - * whether or not the item should be filtered. - */ - shouldFilterItem: PropTypes.func, - - /** - * Specify the size of the ListBox. Currently supports either `sm`, `lg` or `xl` as an option. - */ - size: ListBoxPropTypes.ListBoxSize, - - /** - * Provide text to be used in a `
+ ); + }} + + ); +}; + +ComboBox.propTypes = { + /** + * 'aria-label' of the ListBox component. + */ + ariaLabel: PropTypes.string, + + /** + * An optional className to add to the container node + */ + className: PropTypes.string, + + /** + * Specify the direction of the combobox dropdown. Can be either top or bottom. + */ + direction: PropTypes.oneOf(['top', 'bottom']), + + /** + * Specify if the control should be disabled, or not + */ + disabled: PropTypes.bool, + + /** + * Additional props passed to Downshift + */ + downshiftProps: PropTypes.shape(Downshift.propTypes), + + /** + * Provide helper text that is used alongside the control label for + * additional help + */ + helperText: PropTypes.string, + + /** + * Specify a custom `id` for the input + */ + id: PropTypes.string.isRequired, + + /** + * Allow users to pass in an arbitrary item or a string (in case their items are an array of strings) + * from their collection that are pre-selected + */ + initialSelectedItem: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + ]), + + /** + * Specify if the currently selected value is invalid. + */ + invalid: PropTypes.bool, + + /** + * Message which is displayed if the value is invalid. + */ + invalidText: PropTypes.node, + + /** + * Optional function to render items as custom components instead of strings. + * Defaults to null and is overriden by a getter + */ + itemToElement: PropTypes.func, + + /** + * Helper function passed to downshift that allows the library to render a + * given item to a string label. By default, it extracts the `label` field + * from a given item to serve as the item label in the list + */ + itemToString: PropTypes.func, + + /** + * We try to stay as generic as possible here to allow individuals to pass + * in a collection of whatever kind of data structure they prefer + */ + items: PropTypes.array.isRequired, + + /** + * should use "light theme" (white background)? + */ + light: PropTypes.bool, + + /** + * `onChange` is a utility for this controlled component to communicate to a + * consuming component when a specific dropdown item is selected. + * @param {{ selectedItem }} + */ + onChange: PropTypes.func.isRequired, + + /** + * Callback function to notify consumer when the text input changes. + * This provides support to change available items based on the text. + * @param {string} inputText + */ + onInputChange: PropTypes.func, + + /** + * Callback function that fires when the combobox menu toggle is clicked + * @param {MouseEvent} event + */ + onToggleClick: PropTypes.func, + + /** + * Used to provide a placeholder text node before a user enters any input. + * This is only present if the control has no items selected + */ + placeholder: PropTypes.string.isRequired, + + /** + * For full control of the selection + */ + selectedItem: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + + /** + * Specify your own filtering logic by passing in a `shouldFilterItem` + * function that takes in the current input and an item and passes back + * whether or not the item should be filtered. + */ + shouldFilterItem: PropTypes.func, + + /** + * Specify the size of the ListBox. Currently supports either `sm`, `lg` or `xl` as an option. + */ + size: ListBoxPropTypes.ListBoxSize, + + /** + * Provide text to be used in a `
- + `; exports[`ListBoxMenuItem should render 2`] = ` - @@ -35,11 +35,11 @@ exports[`ListBoxMenuItem should render 2`] = ` - + `; exports[`ListBoxMenuItem should render 3`] = ` - @@ -54,5 +54,5 @@ exports[`ListBoxMenuItem should render 3`] = ` - + `; diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js index 94e3a0ac1b27..978a23ba03e7 100644 --- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js @@ -17,7 +17,7 @@ import { generateGenericItem, } from '../../ListBox/test-helpers'; -const listItemName = 'ListBoxMenuItem'; +const listItemName = 'ForwardRef(ListBoxMenuItem)'; describe('MultiSelect.Filterable', () => { let mockProps; @@ -124,7 +124,7 @@ describe('MultiSelect.Filterable', () => { }); }); - it('should let items stay at thier position after selecting', () => { + it('should let items stay at their position after selecting', () => { const wrapper = mount( ); diff --git a/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js index 6d290f73fb5c..69274c14bc32 100644 --- a/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js @@ -400,7 +400,7 @@ describe('MultiSelect', () => { ); // the first option in the list to the the former third option in the list - expect(optionsArray[0].title).toBe('Item 2'); + expect(optionsArray[0].getAttribute('aria-label')).toBe('Item 2'); }); it('should accept a `ref` for the underlying button element', () => { From 8756867329370541d771aaea0c070f2d04a98a22 Mon Sep 17 00:00:00 2001 From: emyarod Date: Mon, 22 Mar 2021 18:37:02 -0500 Subject: [PATCH 7/7] docs(UIShell-story): add missing prop to HeaderMenu (#8132) Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/react/src/components/UIShell/UIShell-story.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/UIShell/UIShell-story.js b/packages/react/src/components/UIShell/UIShell-story.js index 2e66ce7984ea..44a2baee8f0b 100644 --- a/packages/react/src/components/UIShell/UIShell-story.js +++ b/packages/react/src/components/UIShell/UIShell-story.js @@ -815,7 +815,7 @@ export const SideNavRailWHeader = withReadme(readme, () => ( Link 1 Link 2 Link 3 - + Sub-link 1 Sub-link 2 Sub-link 3