-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react): Add TextEllipsis utility component (#1354)
- Loading branch information
Showing
11 changed files
with
363 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
|
||
<Note> | ||
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. | ||
</Note> | ||
|
||
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 | ||
<TextEllipsis>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</TextEllipsis> | ||
``` | ||
|
||
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 | ||
<TextEllipsis as={Link}>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</TextEllipsis> | ||
``` | ||
|
||
<Note> | ||
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. | ||
</Note> | ||
|
||
### Multi-line Ellipsis | ||
|
||
```jsx example | ||
<TextEllipsis maxLines={2}> | ||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. | ||
</TextEllipsis> | ||
``` | ||
|
||
## Props | ||
|
||
<ComponentProps | ||
children={{ | ||
required: true, | ||
type: 'string' | ||
}} | ||
refType="HTMLElement" | ||
props={[ | ||
{ | ||
name: 'maxLines', | ||
type: 'number', | ||
defaultValue: '1', | ||
description: 'Sets the maximum number of display line before truncation.' | ||
}, | ||
{ | ||
name: 'as', | ||
type: ['React.ElementType', 'string'], | ||
description: 'A component to render the TextEllipsis as.', | ||
}, | ||
{ | ||
name: 'tooltipProps', | ||
type: 'object', | ||
description: 'Props to pass and configure the displayed tooltip.' | ||
} | ||
]} | ||
/> | ||
|
||
## Related Components | ||
|
||
- [Tooltip](./Tooltip) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<TextEllipsis>Hello World</TextEllipsis>); | ||
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(<TextEllipsis data-testid="text-ellipsis">Hello World</TextEllipsis>); | ||
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(<TextEllipsis>Hello World</TextEllipsis>); | ||
|
||
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( | ||
<TextEllipsis data-testid="text-ellipsis" maxLines={2}> | ||
Hello World | ||
</TextEllipsis> | ||
); | ||
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(<TextEllipsis maxLines={2}>Hello World</TextEllipsis>); | ||
|
||
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( | ||
<TextEllipsis data-testid="text-ellipsis" className="bananas"> | ||
Hello World | ||
</TextEllipsis> | ||
); | ||
expect(screen.getByTestId('text-ellipsis')).toHaveClass( | ||
'TextEllipsis', | ||
'bananas' | ||
); | ||
}); | ||
|
||
test('should support ref prop', () => { | ||
const ref = createRef<HTMLDivElement>(); | ||
render( | ||
<TextEllipsis data-testid="text-ellipsis" ref={ref}> | ||
Hello World | ||
</TextEllipsis> | ||
); | ||
expect(ref.current).toBeInstanceOf(HTMLDivElement); | ||
expect(screen.getByTestId('text-ellipsis')).toEqual(ref.current); | ||
}); | ||
|
||
test('should support as prop', () => { | ||
const Button = React.forwardRef<HTMLButtonElement, React.PropsWithChildren>( | ||
({ children }, ref) => <button ref={ref}>{children}</button> | ||
); | ||
render(<TextEllipsis as={Button}>Hello World</TextEllipsis>); | ||
expect(screen.getByRole('button')).toBeInTheDocument(); | ||
}); | ||
|
||
test('should return no axe violations', async () => { | ||
render(<TextEllipsis data-testid="text-ellipsis">Hello World</TextEllipsis>); | ||
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(<TextEllipsis>Hello World</TextEllipsis>); | ||
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(<TextEllipsis maxLines={2}>Hello World</TextEllipsis>); | ||
const results = await axe(screen.getByRole('button')); | ||
expect(results).toHaveNoViolations(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLDivElement> { | ||
children: string; | ||
maxLines?: number; | ||
as?: React.ElementType; | ||
refProp?: string; | ||
tooltipProps?: Omit<TooltipProps, 'target' | 'association'>; | ||
} | ||
|
||
const TextEllipsis = React.forwardRef( | ||
( | ||
{ | ||
className, | ||
children, | ||
maxLines, | ||
as, | ||
tooltipProps, | ||
...props | ||
}: TextEllipsisProps, | ||
ref: Ref<HTMLElement> | ||
) => { | ||
let Element: React.ElementType<any> = 'div'; | ||
const sharedRef = useSharedRef<HTMLElement>(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 ( | ||
<> | ||
<Element | ||
className={classnames('TextEllipsis', className, { | ||
'TextEllipsis--multiline': !!maxLines | ||
})} | ||
ref={sharedRef} | ||
{...props} | ||
> | ||
{children} | ||
</Element> | ||
{showTooltip && ( | ||
<Tooltip target={sharedRef} association="none" {...tooltipProps}> | ||
{children} | ||
</Tooltip> | ||
)} | ||
</> | ||
); | ||
} | ||
); | ||
|
||
TextEllipsis.displayName = 'TextEllipsis'; | ||
|
||
export default TextEllipsis; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.