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 (
-
-
-
+ <>
+
+
+
+ {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