Add ClipboardController to allow copying to the clipboard (Stimulus)

pull/11348/head
the-r3aper7 2023-12-07 12:06:45 +05:30 zatwierdzone przez LB (Ben Johnston)
rodzic d585fa48dc
commit bb33e7c508
3 zmienionych plików z 270 dodań i 0 usunięć

Wyświetl plik

@ -0,0 +1,207 @@
import { Application } from '@hotwired/stimulus';
import { ClipboardController } from './ClipboardController';
jest.useFakeTimers();
describe('ClipboardController', () => {
const handleEvent = jest.fn();
document.addEventListener('w-clipboard:copy', handleEvent);
document.addEventListener('w-clipboard:copied', handleEvent);
document.addEventListener('w-clipboard:error', handleEvent);
let app;
const writeText = jest.fn(() => Promise.resolve());
const setup = async (
html = `
<div id="container" data-controller="w-clipboard" data-action="some-event->w-clipboard#copy">
<input type="text id="content" data-w-clipboard-target="value" value="copy me to the clipboard" />
<button type="button" data-action="w-clipboard#copy">Copy</button>
</div>
`,
) => {
document.body.innerHTML = `<main>${html}</main>`;
app = Application.start();
app.register('w-clipboard', ClipboardController);
await Promise.resolve();
};
afterEach(() => {
app?.stop();
jest.clearAllMocks();
});
describe('when the clipboard is not available', () => {
it('should not have the clipboard available', () => {
expect(window.navigator.clipboard).toBeUndefined();
});
it('should send an error event if the copy method is called', async () => {
await setup();
expect(handleEvent).not.toHaveBeenCalled();
document.querySelector('button').click();
await jest.runAllTimersAsync();
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'w-clipboard:error',
detail: { clear: true, type: 'error' },
}),
);
});
});
describe('when the clipboard is available', () => {
beforeAll(() => {
Object.defineProperty(window.navigator, 'clipboard', {
writable: true,
value: { writeText },
});
});
it('should have the clipboard available', () => {
expect(window.navigator.clipboard).toBeTruthy();
});
it('should copy the content from the value target with the copy method', async () => {
await setup();
expect(handleEvent).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
document.querySelector('button').click();
expect(handleEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'w-clipboard:copy' }),
);
await jest.runAllTimersAsync();
expect(writeText).toHaveBeenCalledWith('copy me to the clipboard');
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'w-clipboard:copied',
detail: { clear: true, type: 'success' },
}),
);
});
it('should support a way to block the copy method with event listeners', async () => {
document.addEventListener(
'w-clipboard:copy',
(event) => event.preventDefault(),
{ once: true },
);
await setup();
expect(handleEvent).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
document.querySelector('button').click();
expect(handleEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'w-clipboard:copy' }),
);
await jest.runAllTimersAsync();
expect(writeText).not.toHaveBeenCalled();
expect(handleEvent).toHaveBeenCalledTimes(1);
});
it('should support a custom dispatched event with the value to copy', async () => {
expect(handleEvent).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
await setup();
document.getElementById('container').dispatchEvent(
new CustomEvent('some-event', {
detail: { value: 'copy me from the event' },
}),
);
expect(handleEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'w-clipboard:copy' }),
);
await jest.runAllTimersAsync();
expect(writeText).toHaveBeenCalledWith('copy me from the event');
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'w-clipboard:copied',
detail: { clear: true, type: 'success' },
}),
);
});
it('should support a custom action params to provide the value to copy', async () => {
expect(handleEvent).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
await setup(`
<div id="container" data-controller="w-clipboard">
<button type="button" data-action="w-clipboard#copy" data-w-clipboard-value-param="Copy me instead!">Copy</button>
</div>
`);
document.querySelector('button').click();
expect(handleEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'w-clipboard:copy' }),
);
await jest.runAllTimersAsync();
expect(writeText).toHaveBeenCalledWith('Copy me instead!');
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'w-clipboard:copied',
detail: { clear: true, type: 'success' },
}),
);
});
it('should support falling back to the controlled element value if no other value is provided', async () => {
expect(handleEvent).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
await setup(`
<textarea id="container" data-controller="w-clipboard" data-action="custom-event->w-clipboard#copy">
Copy the content inside the controlled element.
</textarea>
`);
document
.querySelector('textarea')
.dispatchEvent(new CustomEvent('custom-event'));
expect(handleEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'w-clipboard:copy' }),
);
await jest.runAllTimersAsync();
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining(
'Copy the content inside the controlled element.',
),
);
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: 'w-clipboard:copied',
detail: { clear: true, type: 'success' },
}),
);
});
});
});

Wyświetl plik

@ -0,0 +1,61 @@
import { Controller } from '@hotwired/stimulus';
type CopyOptions = {
/** Custom supplied value to copy to the clipboard. */
value?: string;
};
/**
* Adds the ability for an element to copy the value from a target to the clipboard.
*
* @example
* ```html
* <div data-controller="w-clipboard">
* <input type="text" value="Hello World" data-w-clipboard-target="value" />
* <button type="button" data-action="w-clipboard#copy">Copy</button>
* </div>
* ```
*/
export class ClipboardController extends Controller<HTMLElement> {
static targets = ['value'];
declare readonly hasValueTarget: boolean;
declare readonly valueTarget:
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
/**
* Copies the value from either the Custom Event detail, Stimulus action params or
* the value target to the clipboard. If no value is found, nothing happens.
* If the clipboard is not available an error event is dispatched and it will
* intentionally fail silently.
*/
copy(event: CustomEvent<CopyOptions> & { params?: CopyOptions }) {
const {
value = this.hasValueTarget
? this.valueTarget.value
: (this.element as HTMLInputElement).value || null,
} = { ...event.detail, ...event.params };
if (!value) return;
const copyEvent = this.dispatch('copy');
if (copyEvent.defaultPrevented) return;
new Promise((resolve, reject) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(value).then(resolve, reject);
} else {
reject();
}
})
.then(() =>
this.dispatch('copied', { detail: { clear: true, type: 'success' } }),
)
.catch(() =>
this.dispatch('error', { detail: { clear: true, type: 'error' } }),
);
}
}

Wyświetl plik

@ -4,6 +4,7 @@ import type { Definition } from '@hotwired/stimulus';
import { ActionController } from './ActionController';
import { AutosizeController } from './AutosizeController';
import { BulkController } from './BulkController';
import { ClipboardController } from './ClipboardController';
import { CloneController } from './CloneController';
import { CountController } from './CountController';
import { DialogController } from './DialogController';
@ -30,6 +31,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: ActionController, identifier: 'w-action' },
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
{ controllerConstructor: CloneController, identifier: 'w-clone' },
{ controllerConstructor: CloneController, identifier: 'w-messages' },
{ controllerConstructor: CountController, identifier: 'w-count' },