From 3e6e4cd8aa661f3b81bcdddbd538f77706f23283 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 14 Mar 2024 10:06:12 -0500 Subject: [PATCH] feat(react): Add TextEllipsis utility component (#1354) --- .eslintrc.js | 17 +-- docs/pages/components/TextEllipsis.mdx | 83 +++++++++++ docs/pages/components/Tooltip.mdx | 2 +- packages/react/.eslintrc.js | 11 +- .../TextEllipsis/TextEllipsis.test.tsx | 138 ++++++++++++++++++ .../src/components/TextEllipsis/index.tsx | 98 +++++++++++++ .../src/components/Tooltip/Tooltip.test.tsx | 7 +- .../react/src/components/Tooltip/index.tsx | 13 +- packages/react/src/index.ts | 4 +- packages/styles/index.css | 1 + packages/styles/text-ellipsis.css | 17 +++ 11 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 docs/pages/components/TextEllipsis.mdx create mode 100644 packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx create mode 100644 packages/react/src/components/TextEllipsis/index.tsx create mode 100644 packages/styles/text-ellipsis.css diff --git a/.eslintrc.js b/.eslintrc.js index 9349f0db4..473a9554b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,20 +33,5 @@ module.exports = { react: { version: '16' } - }, - overrides: [ - { - files: '**/*/__tests__/**/*.js', - globals: { - jest: true, - test: true, - expect: true, - afterEach: true, - afterAll: true, - beforeEach: true, - beforeAll: true - } - }, - { files: '*.js', rules: { '@typescript-eslint/no-var-requires': 'off' } } - ] + } }; diff --git a/docs/pages/components/TextEllipsis.mdx b/docs/pages/components/TextEllipsis.mdx new file mode 100644 index 000000000..16d7905fc --- /dev/null +++ b/docs/pages/components/TextEllipsis.mdx @@ -0,0 +1,83 @@ +--- +title: TextEllipsis +description: A utility component to truncate long text and provide an alternative means of accessing hidden text +source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/TextEllipsis/index.tsx +--- + +import { TextEllipsis, Link } from '@deque/cauldron-react' + +```js +import { TextEllipsis } from '@deque/cauldron-react' +``` + +`TextEllipsis` is a utility component to provide an accessible means of preventing overflow for text that does not fit within a constrained area. + + + This component should be used sparingly and only when absolutely necessary. While this component addresses specific accessibility issues ([1.4.10 Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html), [1.4.12 Text Spacing](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html)) that may arise from overflowing text, ellipsizing text can still present usability issues to users as additional interaction is needed to show the full text content. + + +Some good examples of where it's appropriate to use this component: + +- Long URL links +- Long user provided content or names +- Links that point to a page that contains non-truncated text + +Truncation should **not** be used on headers, labels, error messages, or notifications. + +## Examples + +### One-line Ellipsis + +```jsx example +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +``` + +If your component is a Button, Link, or some other kind of interactive element with a `tabIndex`, you _must_ provide your component as a Polymorphic component using the `as` prop to avoid nesting interactive elements: + +```jsx example +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +``` + + + When using the `as` property it is expected the element getting passed in is an interactive element. Passing an element that is not interactive will result in accessibility issues. + + +### Multi-line Ellipsis + +```jsx example + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +``` + +## Props + + + +## Related Components + +- [Tooltip](./Tooltip) \ No newline at end of file diff --git a/docs/pages/components/Tooltip.mdx b/docs/pages/components/Tooltip.mdx index 14d1ad736..5fa214819 100644 --- a/docs/pages/components/Tooltip.mdx +++ b/docs/pages/components/Tooltip.mdx @@ -183,7 +183,7 @@ function BigTooltipExample() { }, { name: 'association', - type: ['aria-labelledby', 'aria-describedby'], + type: ['aria-labelledby', 'aria-describedby', 'none'], description: 'Sets the aria relationship for the targeted element.', defaultValue: 'aria-describedby' }, diff --git a/packages/react/.eslintrc.js b/packages/react/.eslintrc.js index c9dc0182b..9894ae398 100644 --- a/packages/react/.eslintrc.js +++ b/packages/react/.eslintrc.js @@ -3,12 +3,19 @@ module.exports = { plugins: ['ssr-friendly'], overrides: [ { - files: ['__tests__/**/*', '**/*.test.[j|t]sx?', 'src/setupTests.ts'], + files: ['__tests__/**/*', '**/*/*.test.{j,t}sx', 'src/setupTests.ts'], rules: { 'ssr-friendly/no-dom-globals-in-module-scope': 'off', 'ssr-friendly/no-dom-globals-in-constructor': 'off', 'ssr-friendly/no-dom-globals-in-react-cc-render': 'off', - 'ssr-friendly/no-dom-globals-in-react-fc': 'off' + 'ssr-friendly/no-dom-globals-in-react-fc': 'off', + 'react/display-name': 'off' + } + }, + { + files: ['*.js'], + rules: { + '@typescript-eslint/no-var-requires': 'off' } } ] diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx new file mode 100644 index 000000000..92b0eb8bf --- /dev/null +++ b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx @@ -0,0 +1,138 @@ +import React, { createRef } from 'react'; +import { render, screen, act } from '@testing-library/react'; +import axe from '../../axe'; +import { createSandbox } from 'sinon'; +import TextEllipsis from './'; + +const sandbox = createSandbox(); + +beforeEach(() => { + global.ResizeObserver = global.ResizeObserver || (() => null); + sandbox.stub(global, 'ResizeObserver').callsFake((callback) => { + callback(); + return { + observe: sandbox.stub(), + disconnect: sandbox.stub() + }; + }); + sandbox.stub(global, 'requestAnimationFrame').callsFake((callback) => { + callback(1); + return 1; + }); +}); + +afterEach(() => { + sandbox.restore(); +}); + +test('should render children', () => { + render(Hello World); + expect(screen.getByText('Hello World')).toBeInTheDocument(); +}); + +test('should not display tooltip with no overflow', () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientWidth').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollWidth').value(100); + render(Hello World); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByTestId('text-ellipsis')).not.toHaveAttribute('tabindex'); +}); + +test('should display tooltip with overflow', async () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientWidth').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollWidth').value(200); + render(Hello World); + + const button = screen.queryByRole('button') as HTMLButtonElement; + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('tabindex', '0'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + act(() => { + button.focus(); + }); + expect(screen.queryByRole('tooltip')).toBeInTheDocument(); +}); + +test('should not display tooltip with no multiline overflow', () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientHeight').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollHeight').value(100); + render( + + Hello World + + ); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByTestId('text-ellipsis')).not.toHaveAttribute('tabindex'); +}); + +test('should display tooltip with multiline overflow', () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientHeight').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollHeight').value(200); + render(Hello World); + + const button = screen.queryByRole('button') as HTMLButtonElement; + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('tabindex', '0'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + act(() => { + button.focus(); + }); + expect(screen.queryByRole('tooltip')).toBeInTheDocument(); +}); + +test('should support className prop', () => { + render( + + Hello World + + ); + expect(screen.getByTestId('text-ellipsis')).toHaveClass( + 'TextEllipsis', + 'bananas' + ); +}); + +test('should support ref prop', () => { + const ref = createRef(); + render( + + Hello World + + ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(screen.getByTestId('text-ellipsis')).toEqual(ref.current); +}); + +test('should support as prop', () => { + const Button = React.forwardRef( + ({ children }, ref) => + ); + render(Hello World); + expect(screen.getByRole('button')).toBeInTheDocument(); +}); + +test('should return no axe violations', async () => { + render(Hello World); + const results = await axe(screen.getByTestId('text-ellipsis')); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations when text has ellipsis', async () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientWidth').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollWidth').value(200); + render(Hello World); + const results = await axe(screen.getByRole('button')); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations when text has multiline ellipsis', async () => { + sandbox.stub(global.HTMLDivElement.prototype, 'clientHeight').value(100); + sandbox.stub(global.HTMLDivElement.prototype, 'scrollHeight').value(200); + render(Hello World); + const results = await axe(screen.getByRole('button')); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/react/src/components/TextEllipsis/index.tsx b/packages/react/src/components/TextEllipsis/index.tsx new file mode 100644 index 000000000..769d35662 --- /dev/null +++ b/packages/react/src/components/TextEllipsis/index.tsx @@ -0,0 +1,98 @@ +import React, { Ref, useEffect, useState } from 'react'; +import classnames from 'classnames'; +import useSharedRef from '../../utils/useSharedRef'; +import Tooltip, { type TooltipProps } from '../Tooltip'; + +interface TextEllipsisProps extends React.HTMLAttributes { + children: string; + maxLines?: number; + as?: React.ElementType; + refProp?: string; + tooltipProps?: Omit; +} + +const TextEllipsis = React.forwardRef( + ( + { + className, + children, + maxLines, + as, + tooltipProps, + ...props + }: TextEllipsisProps, + ref: Ref + ) => { + let Element: React.ElementType = 'div'; + const sharedRef = useSharedRef(ref); + const [showTooltip, setShowTooltip] = useState(false); + + if (as) { + Element = as; + } else if (showTooltip) { + props = Object.assign( + { + role: 'button', + 'aria-disabled': true, + tabIndex: 0 + }, + props + ); + } + + if (typeof maxLines === 'number') { + props.style = { + WebkitLineClamp: maxLines || 2, + ...props.style + }; + } + + useEffect(() => { + const listener: ResizeObserverCallback = () => { + requestAnimationFrame(() => { + const { current: overflowElement } = sharedRef; + if (!overflowElement) { + return; + } + + const hasOverflow = + typeof maxLines === 'number' + ? overflowElement.clientHeight < overflowElement.scrollHeight + : overflowElement.clientWidth < overflowElement.scrollWidth; + + setShowTooltip(hasOverflow); + }); + }; + + const observer = new ResizeObserver(listener); + observer.observe(sharedRef.current); + + return () => { + observer?.disconnect(); + }; + }, []); + + return ( + <> + + {children} + + {showTooltip && ( + + {children} + + )} + + ); + } +); + +TextEllipsis.displayName = 'TextEllipsis'; + +export default TextEllipsis; diff --git a/packages/react/src/components/Tooltip/Tooltip.test.tsx b/packages/react/src/components/Tooltip/Tooltip.test.tsx index 77214b9c1..d7d0709f3 100644 --- a/packages/react/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/react/src/components/Tooltip/Tooltip.test.tsx @@ -94,7 +94,6 @@ test('should hide tooltip on target element blur', async () => { }); test('should hide tooltip on escape keypress', async () => { - const user = userEvent.setup(); // @ts-expect-error force show to override existing show value renderTooltip({ tooltipProps: { show: null } }); expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); @@ -145,6 +144,12 @@ test('should support association prop', () => { expect(screen.queryByRole('button')).toHaveAccessibleName('Hello Tooltip'); }); +test('should not add association when association is set to "none"', () => { + renderTooltip({ tooltipProps: { association: 'none' } }); + expect(screen.queryByRole('button')).not.toHaveProperty('aria-describedby'); + expect(screen.queryByRole('button')).not.toHaveProperty('aria-labelledby'); +}); + test('should clean up association when tooltip is no longer rendered', () => { const ShowTooltip = ({ show = true }: { show?: boolean }) => { const ref = createRef(); diff --git a/packages/react/src/components/Tooltip/index.tsx b/packages/react/src/components/Tooltip/index.tsx index 1dee880f3..eabb35bb8 100644 --- a/packages/react/src/components/Tooltip/index.tsx +++ b/packages/react/src/components/Tooltip/index.tsx @@ -14,7 +14,7 @@ export interface TooltipProps extends React.HTMLAttributes { className?: string; target: React.RefObject | HTMLElement; variant?: 'text' | 'info' | 'big'; - association?: 'aria-labelledby' | 'aria-describedby'; + association?: 'aria-labelledby' | 'aria-describedby' | 'none'; show?: boolean | undefined; placement?: Placement; portal?: React.RefObject | HTMLElement; @@ -59,6 +59,7 @@ export default function Tooltip({ null ); const [arrowElement, setArrowElement] = useState(null); + const hasAriaAssociation = association !== 'none'; const { styles, attributes, update } = usePopper( targetElement, @@ -173,13 +174,15 @@ export default function Tooltip({ // Keep the target's id in sync useEffect(() => { - const idRefs = targetElement?.getAttribute(association); - if (!hasIdRef(idRefs, id)) { - targetElement?.setAttribute(association, addIdRef(idRefs, id)); + if (hasAriaAssociation) { + const idRefs = targetElement?.getAttribute(association); + if (!hasIdRef(idRefs, id)) { + targetElement?.setAttribute(association, addIdRef(idRefs, id)); + } } return () => { - if (targetElement) { + if (targetElement && hasAriaAssociation) { const idRefs = targetElement.getAttribute(association); targetElement.setAttribute(association, removeIdRef(idRefs, id)); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5f42c1700..a4892d9cf 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -114,7 +114,6 @@ export { ColumnList } from './components/TwoColumnPanel'; export { default as Notice } from './components/Notice'; - export { default as Listbox, ListboxOption, @@ -125,10 +124,9 @@ export { ComboboxOption, ComboboxGroup } from './components/Combobox'; - export { default as Popover } from './components/Popover'; - export { default as Timeline, TimelineItem } from './components/Timeline'; +export { default as TextEllipsis } from './components/TextEllipsis'; /** * Helpers / Utils diff --git a/packages/styles/index.css b/packages/styles/index.css index 3d3f156a3..28d4a3cec 100644 --- a/packages/styles/index.css +++ b/packages/styles/index.css @@ -44,3 +44,4 @@ @import './combobox.css'; @import './timeline.css'; @import './search-field.css'; +@import './text-ellipsis.css'; diff --git a/packages/styles/text-ellipsis.css b/packages/styles/text-ellipsis.css new file mode 100644 index 000000000..c262e9e29 --- /dev/null +++ b/packages/styles/text-ellipsis.css @@ -0,0 +1,17 @@ +.TextEllipsis { + display: block !important; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.TextEllipsis:where([role='button']) { + text-align: inherit; + user-select: inherit; +} + +.TextEllipsis--multiline { + display: -webkit-box !important; + -webkit-box-orient: vertical; + white-space: normal; +}