From ea6db89dddc898169798116d6e54d8301949ed24 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 20 May 2026 15:50:03 +0200 Subject: [PATCH] feat: Add tooltipText to icon --- .../__snapshots__/documenter.test.ts.snap | 8 + src/icon/__tests__/icon-tooltip-text.test.tsx | 137 ++++++++++++++++++ src/icon/interfaces.ts | 7 + src/icon/internal.tsx | 103 ++++++++----- src/icon/styles.scss | 11 ++ 5 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 src/icon/__tests__/icon-tooltip-text.test.tsx diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index aefe9e0a61..8d343b616f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -14818,6 +14818,14 @@ The icon will be vertically centered based on the height.", "type": "string", "visualRefreshTag": "\`medium\` size", }, + { + "description": "Displays a visible label on hover or focus. Only use this property +if the icon is semantically meaningful or isn't followed by alternative +text.", + "name": "tooltipText", + "optional": true, + "type": "string", + }, { "description": "Specifies the URL of a custom icon. Use this property if the icon you want isn't available, and your custom icon cannot be an SVG. For SVG icons, use the \`svg\` slot instead. diff --git a/src/icon/__tests__/icon-tooltip-text.test.tsx b/src/icon/__tests__/icon-tooltip-text.test.tsx new file mode 100644 index 0000000000..d7499e2694 --- /dev/null +++ b/src/icon/__tests__/icon-tooltip-text.test.tsx @@ -0,0 +1,137 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import Icon from '../../../lib/components/icon'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +function renderIcon(jsx: React.ReactElement) { + const { container } = render(jsx); + const wrapper = createWrapper(container).findIcon()!; + return { container, wrapper, element: wrapper.getElement() }; +} + +describe('Icon tooltipText', () => { + test('does not show a tooltip by default', () => { + const { wrapper } = renderIcon(); + expect(createWrapper().findTooltip()).toBeNull(); + expect(wrapper).not.toBeNull(); + }); + + test('does not make the icon focusable when tooltipText is not provided', () => { + const { element } = renderIcon(); + expect(element).not.toHaveAttribute('tabIndex'); + }); + + test('makes the icon focusable (tabIndex=0) when tooltipText is provided', () => { + const { element } = renderIcon(); + expect(element).toHaveAttribute('tabIndex', '0'); + }); + + test('sets role="img" and aria-label from tooltipText when ariaLabel is not provided', () => { + const { element } = renderIcon(); + expect(element).toHaveAttribute('role', 'img'); + expect(element).toHaveAttribute('aria-label', 'Settings'); + }); + + test('explicit ariaLabel takes precedence over tooltipText for aria-label', () => { + const { element } = renderIcon(); + expect(element).toHaveAttribute('aria-label', 'Open settings'); + }); + + test('shows the tooltip on pointer enter and hides on pointer leave', () => { + const { element } = renderIcon(); + + fireEvent.pointerEnter(element); + let tooltip = createWrapper().findTooltip(); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getElement()).toHaveTextContent('Settings'); + + fireEvent.pointerLeave(element); + tooltip = createWrapper().findTooltip(); + expect(tooltip).toBeNull(); + }); + + test('shows the tooltip on focus and hides on blur', () => { + const { element } = renderIcon(); + + fireEvent.focus(element); + expect(createWrapper().findTooltip()).not.toBeNull(); + + fireEvent.blur(element); + expect(createWrapper().findTooltip()).toBeNull(); + }); + + test('hides the tooltip on Escape key press', () => { + const { element } = renderIcon(); + + fireEvent.pointerEnter(element); + expect(createWrapper().findTooltip()).not.toBeNull(); + + act(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + + expect(createWrapper().findTooltip()).toBeNull(); + }); + + describe('with svg icon', () => { + const svg = ( + + + + ); + + test('shows the tooltip on hover and applies aria-label', () => { + const { element } = renderIcon(); + + expect(element).toHaveAttribute('aria-label', 'Custom'); + expect(element).toHaveAttribute('tabIndex', '0'); + + fireEvent.pointerEnter(element); + const tooltip = createWrapper().findTooltip(); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getElement()).toHaveTextContent('Custom'); + }); + + test('still sets aria-hidden=false when tooltipText provides the accessible name', () => { + const { element } = renderIcon(); + // hasAriaLabel becomes true via tooltipText fallback, so aria-hidden should be false. + expect(element).toHaveAttribute('aria-hidden', 'false'); + }); + }); + + describe('with url icon', () => { + const url = 'data:image/png;base64,aaaa'; + + test('shows the tooltip on hover and uses tooltipText as the img alt fallback', () => { + const { container, element } = renderIcon(); + + // The wrapper span gets the events / tabIndex but does NOT get aria-label + // (the inner ... already provides the accessible name). + expect(element).toHaveAttribute('tabIndex', '0'); + expect(element).not.toHaveAttribute('aria-label'); + + const img = container.querySelector('img'); + expect(img).toHaveAttribute('alt', 'Custom'); + + fireEvent.pointerEnter(element); + const tooltip = createWrapper().findTooltip(); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getElement()).toHaveTextContent('Custom'); + }); + + test('explicit ariaLabel takes precedence over tooltipText for img alt', () => { + const { container } = renderIcon(); + const img = container.querySelector('img'); + expect(img).toHaveAttribute('alt', 'Explicit'); + }); + + test('alt prop is used only when both ariaLabel and tooltipText are absent', () => { + const { container } = renderIcon(); + const img = container.querySelector('img'); + expect(img).toHaveAttribute('alt', 'Just alt'); + }); + }); +}); diff --git a/src/icon/interfaces.ts b/src/icon/interfaces.ts index 5798309e5f..83395518ad 100644 --- a/src/icon/interfaces.ts +++ b/src/icon/interfaces.ts @@ -52,6 +52,13 @@ export interface IconProps extends BaseComponentProps { */ ariaLabel?: string; + /** + * Displays a visible label on hover or focus. Only use this property + * if the icon is semantically meaningful or isn't followed by alternative + * text. + */ + tooltipText?: string; + /** * Specifies the SVG of a custom icon. * diff --git a/src/icon/internal.tsx b/src/icon/internal.tsx index 8683215be6..b1987a6b82 100644 --- a/src/icon/internal.tsx +++ b/src/icon/internal.tsx @@ -10,6 +10,7 @@ import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; +import Tooltip from '../tooltip/internal'; import { IconProps } from './interfaces'; import styles from './styles.css.js'; @@ -47,6 +48,7 @@ const InternalIcon = ({ url, alt, ariaLabel, + tooltipText, svg, badge, nativeAttributes, @@ -59,6 +61,7 @@ const InternalIcon = ({ useVisualRefresh(); const [parentHeight, setParentHeight] = useState(null); const [parentFontSize, setParentFontSize] = useState(null); + const [showTooltip, setShowTooltip] = useState(false); const contextualSize = size === 'inherit'; const iconSize = contextualSize ? iconSizeMap(parentHeight, parentFontSize) : size; const inlineStyles = contextualSize && parentHeight !== null ? { height: `${parentHeight}px` } : {}; @@ -91,8 +94,26 @@ const InternalIcon = ({ }); const mergedRef = useMergeRefs(iconRef, __internalRootRef); - const hasAriaLabel = typeof ariaLabel === 'string'; - const labelAttributes = hasAriaLabel ? { role: 'img', 'aria-label': ariaLabel } : {}; + // When tooltipText is provided, it serves as the accessible name unless an explicit + // ariaLabel is provided. The tooltip itself is purely visual; the aria-label keeps the + // information available to assistive technology. + const effectiveAriaLabel = ariaLabel ?? tooltipText; + const hasAriaLabel = typeof effectiveAriaLabel === 'string'; + const hasTooltipText = typeof tooltipText === 'string'; + const labelAttributes = hasAriaLabel ? { role: 'img', 'aria-label': effectiveAriaLabel } : {}; + // When tooltipText is set, the icon becomes a focusable target. + const tooltipTextAttributes = hasTooltipText + ? { + tabIndex: 0, + onPointerEnter: () => setShowTooltip(true), + onPointerLeave: () => setShowTooltip(false), + onFocus: () => setShowTooltip(true), + onBlur: () => setShowTooltip(false), + } + : {}; + const tooltipElement = hasTooltipText && showTooltip && ( + iconRef.current} onEscape={() => setShowTooltip(false)} /> + ); if (svg) { if (url) { @@ -102,33 +123,41 @@ const InternalIcon = ({ ); } return ( - - {svg} - + <> + + {svg} + + {tooltipElement} + ); } if (url) { return ( - - {ariaLabel - + <> + + {ariaLabel + + {tooltipElement} + ); } @@ -165,17 +194,21 @@ const InternalIcon = ({ } return ( - - {validIcon ? iconMap(name) : undefined} - + <> + + {validIcon ? iconMap(name) : undefined} + + {tooltipElement} + ); }; diff --git a/src/icon/styles.scss b/src/icon/styles.scss index ee42466115..9a5fdafa7e 100644 --- a/src/icon/styles.scss +++ b/src/icon/styles.scss @@ -5,6 +5,7 @@ @use '../internal/styles/tokens' as awsui; @use '../internal/styles' as styles; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @use './mixins' as mixins; .icon { @@ -16,6 +17,16 @@ align-items: center; } + // The icon root only becomes focusable when `tooltipText` is provided. Apply focus-visible + // styling so keyboard users see a clear indicator when the icon receives focus. + &:focus { + outline: none; + } + + @include focus-visible.when-visible { + @include styles.focus-highlight(awsui.$space-button-inline-icon-focus-outline-gutter); + } + /* stylelint-disable-next-line selector-max-type */ > svg { // SVG is focusable by default