Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 137 additions & 0 deletions src/icon/__tests__/icon-tooltip-text.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Icon name="settings" tooltipText="Settings" />);
expect(createWrapper().findTooltip()).toBeNull();
expect(wrapper).not.toBeNull();
});

test('does not make the icon focusable when tooltipText is not provided', () => {
const { element } = renderIcon(<Icon name="settings" />);
expect(element).not.toHaveAttribute('tabIndex');
});

test('makes the icon focusable (tabIndex=0) when tooltipText is provided', () => {
const { element } = renderIcon(<Icon name="settings" tooltipText="Settings" />);
expect(element).toHaveAttribute('tabIndex', '0');
});

test('sets role="img" and aria-label from tooltipText when ariaLabel is not provided', () => {
const { element } = renderIcon(<Icon name="settings" tooltipText="Settings" />);
expect(element).toHaveAttribute('role', 'img');
expect(element).toHaveAttribute('aria-label', 'Settings');
});

test('explicit ariaLabel takes precedence over tooltipText for aria-label', () => {
const { element } = renderIcon(<Icon name="settings" ariaLabel="Open settings" tooltipText="Visible tooltip" />);
expect(element).toHaveAttribute('aria-label', 'Open settings');
});

test('shows the tooltip on pointer enter and hides on pointer leave', () => {
const { element } = renderIcon(<Icon name="settings" tooltipText="Settings" />);

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(<Icon name="settings" tooltipText="Settings" />);

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(<Icon name="settings" tooltipText="Settings" />);

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 = (
<svg className="test-svg" focusable="false">
<circle cx="8" cy="8" r="7" />
</svg>
);

test('shows the tooltip on hover and applies aria-label', () => {
const { element } = renderIcon(<Icon svg={svg} tooltipText="Custom" />);

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(<Icon svg={svg} tooltipText="Custom" />);
// 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(<Icon url={url} tooltipText="Custom" />);

// The wrapper span gets the events / tabIndex but does NOT get aria-label
// (the inner <img alt="..."> 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(<Icon url={url} ariaLabel="Explicit" tooltipText="Visible" />);
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(<Icon url={url} alt="Just alt" />);
const img = container.querySelector('img');
expect(img).toHaveAttribute('alt', 'Just alt');
});
});
});
7 changes: 7 additions & 0 deletions src/icon/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
103 changes: 68 additions & 35 deletions src/icon/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ const InternalIcon = ({
url,
alt,
ariaLabel,
tooltipText,
svg,
badge,
nativeAttributes,
Expand All @@ -59,6 +61,7 @@ const InternalIcon = ({
useVisualRefresh();
const [parentHeight, setParentHeight] = useState<number | null>(null);
const [parentFontSize, setParentFontSize] = useState<number | null>(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` } : {};
Expand Down Expand Up @@ -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 && (
<Tooltip content={tooltipText!} getTrack={() => iconRef.current} onEscape={() => setShowTooltip(false)} />
);

if (svg) {
if (url) {
Expand All @@ -102,33 +123,41 @@ const InternalIcon = ({
);
}
return (
<WithNativeAttributes
{...baseProps}
{...labelAttributes}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
aria-hidden={!hasAriaLabel}
style={inlineStyles}
>
{svg}
</WithNativeAttributes>
<>
<WithNativeAttributes
{...baseProps}
{...labelAttributes}
{...tooltipTextAttributes}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
aria-hidden={!hasAriaLabel}
style={inlineStyles}
>
{svg}
</WithNativeAttributes>
{tooltipElement}
</>
);
}

if (url) {
return (
<WithNativeAttributes
{...baseProps}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
style={inlineStyles}
>
<img src={url} alt={ariaLabel ?? alt} />
</WithNativeAttributes>
<>
<WithNativeAttributes
{...baseProps}
{...tooltipTextAttributes}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
style={inlineStyles}
>
<img src={url} alt={ariaLabel ?? tooltipText ?? alt} />
</WithNativeAttributes>
{tooltipElement}
</>
);
}

Expand Down Expand Up @@ -165,17 +194,21 @@ const InternalIcon = ({
}

return (
<WithNativeAttributes
{...baseProps}
{...labelAttributes}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
style={inlineStyles}
>
{validIcon ? iconMap(name) : undefined}
</WithNativeAttributes>
<>
<WithNativeAttributes
{...baseProps}
{...labelAttributes}
{...tooltipTextAttributes}
tag="span"
componentName="Icon"
nativeAttributes={nativeAttributes}
ref={mergedRef}
style={inlineStyles}
>
{validIcon ? iconMap(name) : undefined}
</WithNativeAttributes>
{tooltipElement}
</>
);
};

Expand Down
11 changes: 11 additions & 0 deletions src/icon/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading