Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): Add TextEllipsis utility component #1354

Merged
merged 17 commits into from
Mar 14, 2024
Merged
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' } }
]
}
};
79 changes: 79 additions & 0 deletions docs/pages/components/TextEllipsis.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
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>
```

### 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'>;
}
scurker marked this conversation as resolved.
Show resolved Hide resolved

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) {
scurker marked this conversation as resolved.
Show resolved Hide resolved
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
Loading