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/simple.page.tsx b/pages/truncated-text/simple.page.tsx new file mode 100644 index 0000000000..c89ebb2ecc --- /dev/null +++ b/pages/truncated-text/simple.page.tsx @@ -0,0 +1,101 @@ +// 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'; + +import ScreenshotArea from '../utils/screenshot-area'; + +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 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 aefe9e0a61..071657cbc5 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -32204,6 +32204,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", @@ -44719,6 +44764,19 @@ Supported options: ], "name": "TreeViewItemWrapper", }, + { + "methods": [ + { + "name": "findTooltip", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "TooltipWrapper", + }, + }, + ], + "name": "TruncatedTextWrapper", + }, { "methods": [ { @@ -53456,6 +53514,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 9a46945f90..c92e542113 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 @@ -737,6 +737,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..9aa95ffccb --- /dev/null +++ b/src/truncated-text/index.tsx @@ -0,0 +1,24 @@ +// 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'); + 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..1e45b77828 --- /dev/null +++ b/src/truncated-text/styles.scss @@ -0,0 +1,25 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles' as styles; +@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 { + // The border is 2px wide, and 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(-2px); + } +} 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 */ +}