Skip to content

Commit

Permalink
feat(react): Add TextEllipsis utility component (#1354)
Browse files Browse the repository at this point in the history
  • Loading branch information
scurker authored Mar 14, 2024
1 parent 4083f45 commit 3e6e4cd
Show file tree
Hide file tree
Showing 11 changed files with 363 additions and 28 deletions.
17 changes: 1 addition & 16 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }
]
}
};
83 changes: 83 additions & 0 deletions docs/pages/components/TextEllipsis.mdx
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)
2 changes: 1 addition & 1 deletion docs/pages/components/Tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand Down
11 changes: 9 additions & 2 deletions packages/react/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
]
Expand Down
138 changes: 138 additions & 0 deletions packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx
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();
});
98 changes: 98 additions & 0 deletions packages/react/src/components/TextEllipsis/index.tsx
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;
7 changes: 6 additions & 1 deletion packages/react/src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<HTMLButtonElement>();
Expand Down
Loading

0 comments on commit 3e6e4cd

Please sign in to comment.