diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js
index aa85012575..0a6439d9c0 100644
--- a/build-tools/utils/pluralize.js
+++ b/build-tools/utils/pluralize.js
@@ -93,6 +93,7 @@ const pluralizationMap = {
Tooltip: 'Tooltips',
TopNavigation: 'TopNavigations',
TreeView: 'TreeViews',
+ TruncatedText: 'TruncatedTexts',
TutorialPanel: 'TutorialPanels',
Wizard: 'Wizards',
};
diff --git a/pages/truncated-text/permutations.page.tsx b/pages/truncated-text/permutations.page.tsx
new file mode 100644
index 0000000000..9a13598ecb
--- /dev/null
+++ b/pages/truncated-text/permutations.page.tsx
@@ -0,0 +1,87 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import { CopyToClipboard, Link, TruncatedText, TruncatedTextProps } from '~components';
+
+import createPermutations from '../utils/permutations';
+import PermutationsView from '../utils/permutations-view';
+import ScreenshotArea from '../utils/screenshot-area';
+
+const containerStyle: React.CSSProperties = {
+ maxWidth: '320px',
+ padding: '8px 12px',
+ border: '1px solid #d5dbdb',
+ borderRadius: '4px',
+};
+
+const longArn = 'arn:aws:iam::123456789012:role/my-very-long-role-name-that-should-truncate';
+const longSentence = 'The instance has been running for an extended period of time and may be slow.';
+
+const permutations = createPermutations<
+ TruncatedTextProps & { Wrapper: (props: { children: React.ReactNode }) => JSX.Element | null }
+>([
+ {
+ Wrapper: [React.Fragment],
+ children: [
+ 'Short text',
+ longArn,
+ longSentence,
+
+ {longArn}
+ ,
+ ,
+ ],
+ },
+ {
+ Wrapper: [
+ ({ children }) => (
+
+ ),
+ ],
+ children: [
+ 'Short text',
+ longArn,
+ longSentence,
+
+ {longArn}
+ ,
+ ],
+ },
+]);
+
+export default function TruncatedTextPermutations() {
+ return (
+ <>
+
TruncatedText permutations
+
+ (
+
+
+
+
+
+ )}
+ />
+
+ >
+ );
+}
diff --git a/pages/truncated-text/simple.page.tsx b/pages/truncated-text/simple.page.tsx
new file mode 100644
index 0000000000..d28e16436e
--- /dev/null
+++ b/pages/truncated-text/simple.page.tsx
@@ -0,0 +1,110 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+
+import Box from '~components/box';
+import CopyToClipboard from '~components/copy-to-clipboard';
+import Link from '~components/link';
+import SpaceBetween from '~components/space-between';
+import StatusIndicator from '~components/status-indicator';
+import TruncatedText from '~components/truncated-text';
+
+const containerStyle: React.CSSProperties = {
+ maxWidth: '320px',
+ padding: '8px 12px',
+ border: '1px solid #d5dbdb',
+ borderRadius: '4px',
+};
+
+export default function TruncatedTextSimple() {
+ return (
+ <>
+ TruncatedText examples
+
+
+ Truncated plain text
+
+ arn:aws:lambda:us-east-1:123456789012:function:my-function
+
+
+
+
+ Non-truncated plain text
+
+ arn:aws:lambda
+
+
+
+
+ Truncated with interactive child (uses tooltipText)
+
+
+ ResourceName-421492941223_may-be-truncated
+
+
+
+
+
+ Truncated text in a flex container
+
+
Label:
+
+ arn:aws:s3:::my-very-long-bucket-name-with-extra-words
+
+
+
+
+
+ With StatusIndicator (wrapText=true, default)
+
+
+
+ The instance has been running for an extended period of time
+
+
+
+
+
+
+ With StatusIndicator (wrapText=false)
+
+
+
+ The instance has been running for an extended period of time
+
+
+
+
+
+
+ With CopyToClipboard (variant=inline)
+
+
+
+
+
+
+
+
+ With CopyToClipboard, internal truncation
+
+ arn:aws:iam::123456789012:role/my-very-long-role-name}
+ copyButtonAriaLabel="Copy ARN"
+ copySuccessText="ARN copied"
+ copyErrorText="Failed to copy ARN"
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index a9c99cb4b9..aa0ba1aeac 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -32775,6 +32775,51 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
}
`;
+exports[`Components definition for truncated-text matches the snapshot: truncated-text 1`] = `
+{
+ "dashCaseName": "truncated-text",
+ "events": [],
+ "functions": [],
+ "name": "TruncatedText",
+ "properties": [
+ {
+ "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).",
+ "description": "Adds the specified classes to the root element of the component.",
+ "name": "className",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases,
+use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must
+use the \`id\` attribute, consider setting it on a parent element instead.",
+ "description": "Adds the specified ID to the root element of the component.",
+ "name": "id",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "description": "The content of the tooltip shown when the text is truncated. By default, the
+tooltip content is the same as the \`children\` slot. Use only if the \`children\`
+slot may contain interactive elements.",
+ "name": "tooltipText",
+ "optional": true,
+ "type": "string",
+ },
+ ],
+ "regions": [
+ {
+ "description": "The inline text to display. If there isn't enough space to render the text
+in a single line, it is truncated with an ellipsis and the full content is
+shown on pointer hover or keyboard focus.",
+ "isDefault": true,
+ "name": "children",
+ },
+ ],
+ "releaseStatus": "stable",
+}
+`;
+
exports[`Components definition for tutorial-panel matches the snapshot: tutorial-panel 1`] = `
{
"dashCaseName": "tutorial-panel",
@@ -45313,6 +45358,19 @@ Supported options:
],
"name": "TreeViewItemWrapper",
},
+ {
+ "methods": [
+ {
+ "name": "findTooltip",
+ "parameters": [],
+ "returnType": {
+ "isNullable": true,
+ "name": "TooltipWrapper",
+ },
+ },
+ ],
+ "name": "TruncatedTextWrapper",
+ },
{
"methods": [
{
@@ -54066,6 +54124,19 @@ Supported options:
],
"name": "TreeViewItemWrapper",
},
+ {
+ "methods": [
+ {
+ "name": "findTooltip",
+ "parameters": [],
+ "returnType": {
+ "isNullable": false,
+ "name": "TooltipWrapper",
+ },
+ },
+ ],
+ "name": "TruncatedTextWrapper",
+ },
{
"methods": [
{
diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
index 683d4188a0..5e1d0b1e2d 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
@@ -739,6 +739,9 @@ exports[`test-utils selectors 1`] = `
"awsui_root_1js4f",
"awsui_treeitem_1js4f",
],
+ "truncated-text": [
+ "awsui_root_lwmqr",
+ ],
"tutorial-panel": [
"awsui_collapse-button_ig8mp",
"awsui_completed_ig8mp",
diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap
index 4a5cafb198..118ac8d1b2 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap
@@ -100,6 +100,7 @@ import TokenGroupWrapper from './token-group';
import TooltipWrapper from './tooltip';
import TopNavigationWrapper from './top-navigation';
import TreeViewWrapper from './tree-view';
+import TruncatedTextWrapper from './truncated-text';
import TutorialPanelWrapper from './tutorial-panel';
import WizardWrapper from './wizard';
@@ -195,6 +196,7 @@ export { TokenGroupWrapper };
export { TooltipWrapper };
export { TopNavigationWrapper };
export { TreeViewWrapper };
+export { TruncatedTextWrapper };
export { TutorialPanelWrapper };
export { WizardWrapper };
@@ -2749,6 +2751,34 @@ findAllTreeViews(selector?: string): Array;
* @returns {TreeViewWrapper | null}
*/
findClosestTreeView(): TreeViewWrapper | null;
+/**
+ * Returns the wrapper of the first TruncatedText that matches the specified CSS selector.
+ * If no CSS selector is specified, returns the wrapper of the first TruncatedText.
+ * If no matching TruncatedText is found, returns \`null\`.
+ *
+ * @param {string} [selector] CSS Selector
+ * @returns {TruncatedTextWrapper | null}
+ */
+findTruncatedText(selector?: string): TruncatedTextWrapper | null;
+
+/**
+ * Returns an array of TruncatedText wrapper that matches the specified CSS selector.
+ * If no CSS selector is specified, returns all of the TruncatedTexts inside the current wrapper.
+ * If no matching TruncatedText is found, returns an empty array.
+ *
+ * @param {string} [selector] CSS Selector
+ * @returns {Array}
+ */
+findAllTruncatedTexts(selector?: string): Array;
+
+/**
+ * Returns the wrapper of the closest parent TruncatedText for the current element,
+ * or the element itself if it is an instance of TruncatedText.
+ * If no TruncatedText is found, returns \`null\`.
+ *
+ * @returns {TruncatedTextWrapper | null}
+ */
+findClosestTruncatedText(): TruncatedTextWrapper | null;
/**
* Returns the wrapper of the first TutorialPanel that matches the specified CSS selector.
* If no CSS selector is specified, returns the wrapper of the first TutorialPanel.
@@ -3992,6 +4022,19 @@ ElementWrapper.prototype.findTreeView = function(selector) {
ElementWrapper.prototype.findAllTreeViews = function(selector) {
return this.findAllComponents(TreeViewWrapper, selector);
};
+ElementWrapper.prototype.findTruncatedText = function(selector) {
+ let rootSelector = \`.\${TruncatedTextWrapper.rootSelector}\`;
+ if("legacyRootSelector" in TruncatedTextWrapper && TruncatedTextWrapper.legacyRootSelector){
+ rootSelector = \`:is(.\${TruncatedTextWrapper.rootSelector}, .\${TruncatedTextWrapper.legacyRootSelector})\`;
+ }
+ // casting to 'any' is needed to avoid this issue with generics
+ // https://github.com/microsoft/TypeScript/issues/29132
+ return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TruncatedTextWrapper);
+};
+
+ElementWrapper.prototype.findAllTruncatedTexts = function(selector) {
+ return this.findAllComponents(TruncatedTextWrapper, selector);
+};
ElementWrapper.prototype.findTutorialPanel = function(selector) {
let rootSelector = \`.\${TutorialPanelWrapper.rootSelector}\`;
if("legacyRootSelector" in TutorialPanelWrapper && TutorialPanelWrapper.legacyRootSelector){
@@ -4474,6 +4517,11 @@ ElementWrapper.prototype.findClosestTreeView = function() {
// https://github.com/microsoft/TypeScript/issues/29132
return (this as any).findClosestComponent(TreeViewWrapper);
};
+ElementWrapper.prototype.findClosestTruncatedText = function() {
+ // casting to 'any' is needed to avoid this issue with generics
+ // https://github.com/microsoft/TypeScript/issues/29132
+ return (this as any).findClosestComponent(TruncatedTextWrapper);
+};
ElementWrapper.prototype.findClosestTutorialPanel = function() {
// casting to 'any' is needed to avoid this issue with generics
// https://github.com/microsoft/TypeScript/issues/29132
@@ -4594,6 +4642,7 @@ import TokenGroupWrapper from './token-group';
import TooltipWrapper from './tooltip';
import TopNavigationWrapper from './top-navigation';
import TreeViewWrapper from './tree-view';
+import TruncatedTextWrapper from './truncated-text';
import TutorialPanelWrapper from './tutorial-panel';
import WizardWrapper from './wizard';
@@ -4689,6 +4738,7 @@ export { TokenGroupWrapper };
export { TooltipWrapper };
export { TopNavigationWrapper };
export { TreeViewWrapper };
+export { TruncatedTextWrapper };
export { TutorialPanelWrapper };
export { WizardWrapper };
@@ -6242,6 +6292,23 @@ findTreeView(selector?: string): TreeViewWrapper;
* @returns {MultiElementWrapper}
*/
findAllTreeViews(selector?: string): MultiElementWrapper;
+/**
+ * Returns a wrapper that matches the TruncatedTexts with the specified CSS selector.
+ * If no CSS selector is specified, returns a wrapper that matches TruncatedTexts.
+ *
+ * @param {string} [selector] CSS Selector
+ * @returns {TruncatedTextWrapper}
+ */
+findTruncatedText(selector?: string): TruncatedTextWrapper;
+
+/**
+ * Returns a multi-element wrapper that matches TruncatedTexts with the specified CSS selector.
+ * If no CSS selector is specified, returns a multi-element wrapper that matches TruncatedTexts.
+ *
+ * @param {string} [selector] CSS Selector
+ * @returns {MultiElementWrapper}
+ */
+findAllTruncatedTexts(selector?: string): MultiElementWrapper;
/**
* Returns a wrapper that matches the TutorialPanels with the specified CSS selector.
* If no CSS selector is specified, returns a wrapper that matches TutorialPanels.
@@ -7463,6 +7530,19 @@ ElementWrapper.prototype.findTreeView = function(selector) {
ElementWrapper.prototype.findAllTreeViews = function(selector) {
return this.findAllComponents(TreeViewWrapper, selector);
};
+ElementWrapper.prototype.findTruncatedText = function(selector) {
+ let rootSelector = \`.\${TruncatedTextWrapper.rootSelector}\`;
+ if("legacyRootSelector" in TruncatedTextWrapper && TruncatedTextWrapper.legacyRootSelector){
+ rootSelector = \`:is(.\${TruncatedTextWrapper.rootSelector}, .\${TruncatedTextWrapper.legacyRootSelector})\`;
+ }
+ // casting to 'any' is needed to avoid this issue with generics
+ // https://github.com/microsoft/TypeScript/issues/29132
+ return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TruncatedTextWrapper);
+};
+
+ElementWrapper.prototype.findAllTruncatedTexts = function(selector) {
+ return this.findAllComponents(TruncatedTextWrapper, selector);
+};
ElementWrapper.prototype.findTutorialPanel = function(selector) {
let rootSelector = \`.\${TutorialPanelWrapper.rootSelector}\`;
if("legacyRootSelector" in TutorialPanelWrapper && TutorialPanelWrapper.legacyRootSelector){
diff --git a/src/test-utils/dom/truncated-text/index.ts b/src/test-utils/dom/truncated-text/index.ts
new file mode 100644
index 0000000000..e29377c56d
--- /dev/null
+++ b/src/test-utils/dom/truncated-text/index.ts
@@ -0,0 +1,17 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { ComponentWrapper, createWrapper } from '@cloudscape-design/test-utils-core/dom';
+
+import TooltipWrapper from '../tooltip';
+
+import tooltipTestUtilsStyles from '../../../tooltip/test-classes/styles.selectors.js';
+import styles from '../../../truncated-text/test-classes/styles.selectors.js';
+
+export default class TruncatedTextWrapper extends ComponentWrapper {
+ static rootSelector: string = styles.root;
+
+ findTooltip(): TooltipWrapper | null {
+ const tooltipElement = createWrapper().findByClassName(tooltipTestUtilsStyles.root);
+ return tooltipElement && new TooltipWrapper(tooltipElement.getElement());
+ }
+}
diff --git a/src/truncated-text/__tests__/truncated-text.test.tsx b/src/truncated-text/__tests__/truncated-text.test.tsx
new file mode 100644
index 0000000000..993ebe2f6b
--- /dev/null
+++ b/src/truncated-text/__tests__/truncated-text.test.tsx
@@ -0,0 +1,253 @@
+// 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 '../../__a11y__/to-validate-a11y';
+import createWrapper from '../../../lib/components/test-utils/dom';
+import TruncatedText from '../../../lib/components/truncated-text';
+
+import styles from '../../../lib/components/truncated-text/test-classes/styles.css.js';
+
+// Mock ResizeObserver. Multiple components in a single render (e.g. TruncatedText + the
+// Tooltip it renders) can each create their own observer, so we associate the callback
+// with the observed element rather than overwriting a single global slot.
+const observerCallbacks = new Map();
+const mockResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => {
+ let observed: Element | null = null;
+ return {
+ observe: jest.fn((target: Element) => {
+ observed = target;
+ observerCallbacks.set(target, callback);
+ }),
+ unobserve: jest.fn((target: Element) => {
+ if (observerCallbacks.get(target) === callback) {
+ observerCallbacks.delete(target);
+ }
+ }),
+ disconnect: jest.fn(() => {
+ if (observed && observerCallbacks.get(observed) === callback) {
+ observerCallbacks.delete(observed);
+ }
+ }),
+ };
+});
+
+(global as any).ResizeObserver = mockResizeObserver;
+
+beforeEach(() => {
+ mockResizeObserver.mockClear();
+ observerCallbacks.clear();
+});
+
+function setOverflow(element: HTMLElement, isOverflowing: boolean) {
+ Object.defineProperty(element, 'scrollWidth', {
+ configurable: true,
+ value: isOverflowing ? 300 : 100,
+ });
+ Object.defineProperty(element, 'clientWidth', {
+ configurable: true,
+ value: 200,
+ });
+}
+
+function triggerResize(element: HTMLElement) {
+ const callback = observerCallbacks.get(element);
+ if (!callback) {
+ throw new Error('No ResizeObserver callback registered for the given element.');
+ }
+ act(() => {
+ callback(
+ [
+ {
+ target: element,
+ contentRect: { width: 200, height: 20 } as DOMRectReadOnly,
+ borderBoxSize: [{ inlineSize: 200, blockSize: 20 }],
+ contentBoxSize: [{ inlineSize: 200, blockSize: 20 }],
+ devicePixelContentBoxSize: [{ inlineSize: 200, blockSize: 20 }],
+ } as unknown as ResizeObserverEntry,
+ ],
+ {} as ResizeObserver
+ );
+ });
+}
+
+function renderTruncatedText(jsx: React.ReactElement) {
+ const { container } = render(jsx);
+ const wrapper = createWrapper(container).findTruncatedText()!;
+ return { container, wrapper, element: wrapper.getElement() };
+}
+
+describe('TruncatedText', () => {
+ test('renders the children content', () => {
+ const { element } = renderTruncatedText(Some text);
+ expect(element).toHaveTextContent('Some text');
+ });
+
+ test('applies the test-utils root class', () => {
+ const { element } = renderTruncatedText(Some text);
+ expect(element).toHaveClass(styles.root);
+ });
+
+ test('forwards baseComponentProps (className, id, data-*)', () => {
+ const { element } = renderTruncatedText(
+
+ Hello
+
+ );
+ expect(element).toHaveClass('custom-class');
+ expect(element).toHaveAttribute('id', 'my-id');
+ expect(element).toHaveAttribute('data-testid', 'my-test-id');
+ });
+
+ test('does not show a tooltip when text is not truncated', () => {
+ const { element, wrapper } = renderTruncatedText(Short);
+ setOverflow(element, false);
+ triggerResize(element);
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()).toBeNull();
+ });
+
+ test('does not make the element focusable when not truncated', () => {
+ const { element } = renderTruncatedText(Short);
+ setOverflow(element, false);
+ triggerResize(element);
+ expect(element).not.toHaveAttribute('tabIndex');
+ });
+
+ test('shows a tooltip on pointer enter when text is truncated', () => {
+ const { element, wrapper } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.pointerEnter(element);
+
+ const tooltip = wrapper.findTooltip();
+ expect(tooltip).not.toBeNull();
+ expect(tooltip!.getElement()).toHaveTextContent('Very long text content');
+ });
+
+ test('hides the tooltip on pointer leave', () => {
+ const { element, wrapper } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()).not.toBeNull();
+
+ fireEvent.pointerLeave(element);
+ expect(wrapper.findTooltip()).toBeNull();
+ });
+
+ test('shows the tooltip on focus when text is truncated', () => {
+ const { element, wrapper } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.focus(element);
+ expect(wrapper.findTooltip()).not.toBeNull();
+ });
+
+ test('hides the tooltip on blur', () => {
+ const { element, wrapper } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.focus(element);
+ expect(wrapper.findTooltip()).not.toBeNull();
+
+ fireEvent.blur(element);
+ expect(wrapper.findTooltip()).toBeNull();
+ });
+
+ test('hides the tooltip on Escape key press', () => {
+ const { element, wrapper } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()).not.toBeNull();
+
+ act(() => {
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+
+ expect(wrapper.findTooltip()).toBeNull();
+ });
+
+ test('makes the element focusable (tabIndex=0) only when text is truncated', () => {
+ const { element } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+ expect(element).toHaveAttribute('tabIndex', '0');
+ });
+
+ test('uses tooltipText for the tooltip content when provided', () => {
+ const { element, wrapper } = renderTruncatedText(
+
+ Link content
+
+ );
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.pointerEnter(element);
+
+ const tooltip = wrapper.findTooltip();
+ expect(tooltip).not.toBeNull();
+ expect(tooltip!.getElement()).toHaveTextContent('Custom tooltip text');
+ expect(tooltip!.getElement()).not.toHaveTextContent('Link content');
+ });
+
+ test('falls back to children when tooltipText is not provided', () => {
+ const { element, wrapper } = renderTruncatedText(Default text);
+ setOverflow(element, true);
+ triggerResize(element);
+
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Default text');
+ });
+
+ test('renders non-string React children', () => {
+ const { element } = renderTruncatedText(
+
+ ResourceName-12345
+
+ );
+ expect(element.querySelector('a')).not.toBeNull();
+ expect(element.querySelector('a')).toHaveTextContent('ResourceName-12345');
+ });
+
+ test('updates truncation status when the resize callback indicates content fits', () => {
+ const { element, wrapper } = renderTruncatedText(Some text);
+
+ // Start truncated
+ setOverflow(element, true);
+ triggerResize(element);
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()).not.toBeNull();
+ fireEvent.pointerLeave(element);
+ expect(wrapper.findTooltip()).toBeNull();
+
+ // Then no longer truncated
+ setOverflow(element, false);
+ triggerResize(element);
+ expect(element).not.toHaveAttribute('tabIndex');
+ fireEvent.pointerEnter(element);
+ expect(wrapper.findTooltip()).toBeNull();
+ });
+
+ test('passes accessibility validation (non-truncated)', async () => {
+ const { container, element } = renderTruncatedText(Short text);
+ setOverflow(element, false);
+ triggerResize(element);
+ await expect(container).toValidateA11y();
+ });
+
+ test('passes accessibility validation (truncated)', async () => {
+ const { container, element } = renderTruncatedText(Very long text content);
+ setOverflow(element, true);
+ triggerResize(element);
+ await expect(container).toValidateA11y();
+ });
+});
diff --git a/src/truncated-text/index.tsx b/src/truncated-text/index.tsx
new file mode 100644
index 0000000000..4973ce6b6c
--- /dev/null
+++ b/src/truncated-text/index.tsx
@@ -0,0 +1,27 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+'use client';
+import React from 'react';
+
+import useBaseComponent from '../internal/hooks/use-base-component';
+import { applyDisplayName } from '../internal/utils/apply-display-name';
+import { getExternalProps } from '../internal/utils/external-props';
+import { TruncatedTextProps } from './interfaces';
+import InternalTruncatedText from './internal';
+
+export { TruncatedTextProps };
+
+export default function TruncatedText({ children, tooltipText, ...rest }: TruncatedTextProps) {
+ const baseComponentProps = useBaseComponent('TruncatedText', {
+ props: {},
+ metadata: { hasTooltipText: !!tooltipText },
+ });
+ const externalProps = getExternalProps(rest);
+ return (
+
+ {children}
+
+ );
+}
+
+applyDisplayName(TruncatedText, 'TruncatedText');
diff --git a/src/truncated-text/interfaces.ts b/src/truncated-text/interfaces.ts
new file mode 100644
index 0000000000..5add50ae2a
--- /dev/null
+++ b/src/truncated-text/interfaces.ts
@@ -0,0 +1,21 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import { BaseComponentProps } from '../internal/base-component';
+
+export interface TruncatedTextProps extends BaseComponentProps {
+ /**
+ * The inline text to display. If there isn't enough space to render the text
+ * in a single line, it is truncated with an ellipsis and the full content is
+ * shown on pointer hover or keyboard focus.
+ */
+ children?: React.ReactNode;
+
+ /**
+ * The content of the tooltip shown when the text is truncated. By default, the
+ * tooltip content is the same as the `children` slot. Use only if the `children`
+ * slot may contain interactive elements.
+ */
+ tooltipText?: string;
+}
diff --git a/src/truncated-text/internal.tsx b/src/truncated-text/internal.tsx
new file mode 100644
index 0000000000..ba288c85e7
--- /dev/null
+++ b/src/truncated-text/internal.tsx
@@ -0,0 +1,78 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { HTMLAttributes, useEffect, useRef, useState } from 'react';
+import clsx from 'clsx';
+
+import { useMergeRefs, useResizeObserver } from '@cloudscape-design/component-toolkit/internal';
+
+import { getBaseProps } from '../internal/base-component';
+import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
+import Tooltip from '../tooltip/internal';
+import { TruncatedTextProps } from './interfaces';
+
+import styles from './styles.css.js';
+import testUtilStyles from './test-classes/styles.css.js';
+
+type InternalTruncatedTextProps = TruncatedTextProps & InternalBaseComponentProps;
+
+export default function InternalTruncatedText({
+ children,
+ tooltipText,
+ __internalRootRef,
+ ...rest
+}: InternalTruncatedTextProps) {
+ const baseProps = getBaseProps(rest);
+ const containerRef = useRef(null);
+ const [showTooltip, setShowTooltip] = useState(false);
+ const [isTruncated, setIsTruncated] = useState(false);
+
+ useResizeObserver(containerRef, () => {
+ const element = containerRef.current;
+ if (element) {
+ // The element uses CSS ellipsis truncation. When the rendered content overflows the
+ // visible box, the browser sets scrollWidth > clientWidth.
+ setIsTruncated(element.scrollWidth > element.clientWidth);
+ }
+ });
+
+ useEffect(() => {
+ const element = containerRef.current;
+ if (element) {
+ // useResizeObserver fires initially at layoutEffect-time where the initial calculation
+ // is performed, but the calculation isn't always correct at that stage.
+ setTimeout(() => setIsTruncated(element.scrollWidth > element.clientWidth), 1);
+ }
+ }, []);
+
+ const tooltipEnabledProps: HTMLAttributes = isTruncated
+ ? {
+ role: 'group',
+ tabIndex: 0,
+ onPointerEnter: () => setShowTooltip(true),
+ onPointerLeave: () => setShowTooltip(false),
+ // onFocus/onBlur bubble in React, so we only want the wrapper focus to trigger the tooltip.
+ onFocus: event => event.target === event.currentTarget && setShowTooltip(true),
+ onBlur: event => event.target === event.currentTarget && setShowTooltip(false),
+ }
+ : {};
+
+ return (
+ <>
+
+ {children}
+ {isTruncated && showTooltip && (
+ containerRef.current}
+ onEscape={() => setShowTooltip(false)}
+ />
+ )}
+
+ >
+ );
+}
diff --git a/src/truncated-text/styles.scss b/src/truncated-text/styles.scss
new file mode 100644
index 0000000000..a5dc603e11
--- /dev/null
+++ b/src/truncated-text/styles.scss
@@ -0,0 +1,26 @@
+/*
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ SPDX-License-Identifier: Apache-2.0
+*/
+
+@use '../internal/styles' as styles;
+@use '../internal/styles/foundation' as foundation;
+@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
+
+.root {
+ @include styles.text-overflow-ellipsis;
+
+ display: block;
+ inline-size: 100%;
+ max-inline-size: 100%;
+ min-inline-size: 0;
+
+ &:focus {
+ outline: none;
+ }
+ @include focus-visible.when-visible {
+ // Since this component is commonly used in scenarios where the content can be truncated,
+ // let's play it safe by pulling in the outline.
+ @include styles.focus-highlight(calc(-1 * #{foundation.$box-shadow-focused-width}));
+ }
+}
diff --git a/src/truncated-text/test-classes/styles.scss b/src/truncated-text/test-classes/styles.scss
new file mode 100644
index 0000000000..5a54f6dcc3
--- /dev/null
+++ b/src/truncated-text/test-classes/styles.scss
@@ -0,0 +1,8 @@
+/*
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ SPDX-License-Identifier: Apache-2.0
+*/
+
+.root {
+ /* used in test-utils */
+}