Merge pull request #6869 from vinzscam/copy-text-button-clipboard-api

copy-text-button: remove hidden textarea
This commit is contained in:
Johan Haals
2021-08-20 13:52:33 +02:00
committed by GitHub
7 changed files with 66 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix accessibility issue in `<CopyTextButton />`. The component doesn't render anymore an hidden `textarea` containing the text to be copied.
@@ -15,8 +15,6 @@
*/
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { CodeSnippet } from './CodeSnippet';
@@ -56,19 +54,4 @@ describe('<CodeSnippet />', () => {
expect(getByText('2')).toBeInTheDocument();
expect(getByText('3')).toBeInTheDocument();
});
it('copy code using button', async () => {
jest.useFakeTimers();
document.execCommand = jest.fn();
const { getByTitle } = await renderInTestApp(
<CodeSnippet {...minProps} showCopyCodeButton />,
);
const button = getByTitle('Text copied to clipboard');
fireEvent.click(button);
act(() => {
jest.runAllTimers();
});
expect(document.execCommand).toHaveBeenCalled();
jest.useRealTimers();
});
});
@@ -19,8 +19,9 @@ import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { CopyTextButton } from './CopyTextButton';
import { ApiRegistry, ApiProvider } from '@backstage/core-app-api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { errorApiRef, ErrorApi } from '@backstage/core-plugin-api';
import { useCopyToClipboard } from 'react-use';
jest.mock('popper.js', () => {
const PopperJS = jest.requireActual('popper.js');
@@ -33,6 +34,17 @@ jest.mock('popper.js', () => {
};
});
jest.mock('react-use', () => {
const original = jest.requireActual('react-use');
return {
...original,
useCopyToClipboard: jest
.fn()
.mockImplementation(original.useCopyToClipboard),
};
});
const props = {
text: 'mockText',
tooltipDelay: 2,
@@ -43,9 +55,7 @@ const apiRegistry = ApiRegistry.from([
[
errorApiRef,
{
post(error) {
throw error;
},
post: jest.fn(),
error$: jest.fn(),
} as ErrorApi,
],
@@ -53,17 +63,22 @@ const apiRegistry = ApiRegistry.from([
describe('<CopyTextButton />', () => {
it('renders without exploding', async () => {
const { getByDisplayValue } = await renderInTestApp(
const { getByTitle, queryByText } = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
);
getByDisplayValue('mockText');
expect(getByTitle('mockTooltip')).toBeInTheDocument();
expect(queryByText('mockTooltip')).not.toBeInTheDocument();
});
it('displays tooltip on click', async () => {
it('displays tooltip and copy the text on click', async () => {
jest.useFakeTimers();
document.execCommand = jest.fn();
const spy = useCopyToClipboard as jest.Mock;
const copy = jest.fn();
spy.mockReturnValue([{}, copy]);
const rendered = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
@@ -74,8 +89,22 @@ describe('<CopyTextButton />', () => {
act(() => {
jest.runAllTimers();
});
expect(document.execCommand).toHaveBeenCalled();
expect(copy).toHaveBeenCalledWith('mockText');
rendered.getByText('mockTooltip');
jest.useRealTimers();
});
it('reports copy errors', async () => {
const spy = useCopyToClipboard as jest.Mock;
const error = new Error('just an error');
spy.mockReturnValue([{ error }, jest.fn()]);
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
);
expect(apiRegistry.get(errorApiRef)?.post).toHaveBeenCalledWith(error);
});
});
@@ -18,7 +18,8 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { IconButton, Tooltip } from '@material-ui/core';
import CopyIcon from '@material-ui/icons/FileCopy';
import PropTypes from 'prop-types';
import React, { MouseEventHandler, useRef, useState } from 'react';
import React, { MouseEventHandler, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
/**
* Copy text button with visual feedback in the form of
@@ -52,30 +53,23 @@ export const CopyTextButton = (props: Props) => {
...props,
};
const errorApi = useApi(errorApiRef);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [open, setOpen] = useState(false);
const [{ error }, copyToClipboard] = useCopyToClipboard();
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
const handleCopyClick: MouseEventHandler = e => {
e.stopPropagation();
setOpen(true);
try {
if (inputRef.current) {
inputRef.current.select();
document.execCommand('copy');
}
} catch (error) {
errorApi.post(error);
}
copyToClipboard(text);
};
return (
<>
<textarea
ref={inputRef}
style={{ position: 'absolute', top: -9999, left: -9999 }}
defaultValue={text}
/>
<Tooltip
id="copy-test-tooltip"
title={tooltipText}
@@ -111,7 +111,7 @@ paths:
},
};
const { getByText } = await renderInTestApp(
const { getByText, getAllByText } = await renderInTestApp(
<Wrapper>
<EntityProvider entity={apiEntity}>
<ApiDefinitionCard />
@@ -121,6 +121,10 @@ paths:
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
expect(
getAllByText(
(_text, element) => element?.textContent === 'Custom Definition',
).length,
).toBeGreaterThan(0);
});
});
@@ -20,9 +20,13 @@ import { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
describe('<PlainApiDefinitionWidget />', () => {
it('renders plain text', async () => {
const { getByText } = await renderInTestApp(
const { getAllByText } = await renderInTestApp(
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
);
expect(getByText(/Hello World/i)).toBeInTheDocument();
expect(
getAllByText((_text, element) => element?.textContent === 'Hello World')
.length,
).toBeGreaterThan(0);
});
});
@@ -55,7 +55,7 @@ describe('EntityBadgesDialog', () => {
);
await expect(
rendered.findByText('![test: badge](http://127.0.0.1/catalog/...)'),
rendered.findByText('test: badge'),
).resolves.toBeInTheDocument();
});
});