diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb new file mode 100644 index 00000000000..762ac4b710b --- /dev/null +++ b/app/components/button_component.html.erb @@ -0,0 +1 @@ +<%= button_tag(content, **tag_options, type: tag_type, class: css_class) %> diff --git a/app/components/button_component.rb b/app/components/button_component.rb new file mode 100644 index 00000000000..ad6ea90ac22 --- /dev/null +++ b/app/components/button_component.rb @@ -0,0 +1,20 @@ +class ButtonComponent < BaseComponent + attr_reader :type, :outline, :tag_options + + DEFAULT_BUTTON_TYPE = :button + + def initialize(outline: false, **tag_options) + @outline = outline + @tag_options = tag_options + end + + def css_class + classes = ['usa-button', *tag_options[:class]] + classes << 'usa-button--outline' if outline + classes + end + + def tag_type + tag_options.fetch(:type, DEFAULT_BUTTON_TYPE) + end +end diff --git a/app/components/clipboard_button_component.js b/app/components/clipboard_button_component.js new file mode 100644 index 00000000000..1a05087c2bb --- /dev/null +++ b/app/components/clipboard_button_component.js @@ -0,0 +1,5 @@ +import { loadPolyfills } from '@18f/identity-polyfill'; + +loadPolyfills(['custom-elements', 'clipboard']) + .then(() => import('@18f/identity-clipboard-button')) + .then(({ ClipboardButton }) => customElements.define('lg-clipboard-button', ClipboardButton)); diff --git a/app/components/clipboard_button_component.rb b/app/components/clipboard_button_component.rb new file mode 100644 index 00000000000..0e7eb19b705 --- /dev/null +++ b/app/components/clipboard_button_component.rb @@ -0,0 +1,14 @@ +class ClipboardButtonComponent < ButtonComponent + attr_reader :clipboard_text, :tag_options + + def initialize(clipboard_text:, **tag_options) + super(**tag_options) + + @clipboard_text = clipboard_text + @tag_options = tag_options + end + + def call + content_tag(:'lg-clipboard-button', super, data: { clipboard_text: clipboard_text }) + end +end diff --git a/app/javascript/packages/clipboard-button/index.js b/app/javascript/packages/clipboard-button/index.js new file mode 100644 index 00000000000..03e27bbebfb --- /dev/null +++ b/app/javascript/packages/clipboard-button/index.js @@ -0,0 +1,24 @@ +export class ClipboardButton extends HTMLElement { + connectedCallback() { + /** @type {HTMLButtonElement?} */ + this.button = this.querySelector('button'); + + this.button?.addEventListener('click', () => this.writeToClipboard()); + } + + /** + * Returns the text to be copied to the clipboard. + * + * @return {string} + */ + get clipboardText() { + return this.dataset.clipboardText || ''; + } + + /** + * Writes the element's clipboard text to the clipboard. + */ + writeToClipboard() { + navigator.clipboard.writeText(this.clipboardText); + } +} diff --git a/app/javascript/packages/clipboard-button/index.spec.js b/app/javascript/packages/clipboard-button/index.spec.js new file mode 100644 index 00000000000..362333dadd4 --- /dev/null +++ b/app/javascript/packages/clipboard-button/index.spec.js @@ -0,0 +1,66 @@ +import sinon from 'sinon'; +import { getByRole } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { loadPolyfills } from '@18f/identity-polyfill'; +import { ClipboardButton } from './index.js'; + +describe('ClipboardButton', () => { + before(async () => { + // Necessary until: https://github.com/jsdom/jsdom/issues/1568 + await loadPolyfills(['clipboard']); + + if (!customElements.get('lg-clipboard-button')) { + customElements.define('lg-clipboard-button', ClipboardButton); + } + }); + + beforeEach(() => { + sinon.spy(navigator.clipboard, 'writeText'); + }); + + afterEach(() => { + navigator.clipboard.writeText.restore(); + }); + + function createAndConnectElement({ clipboardText = '' } = {}) { + const element = document.createElement('lg-clipboard-button'); + element.setAttribute('data-clipboard-text', clipboardText); + element.innerHTML = ''; + document.body.appendChild(element); + return element; + } + + it('copies text to clipboard when clicking its button', () => { + const clipboardText = 'example'; + const element = createAndConnectElement({ clipboardText }); + const button = getByRole(element, 'button'); + + userEvent.click(button); + + expect(navigator.clipboard.writeText).to.have.been.calledWith(clipboardText); + }); + + it('copies the latest clipboard attribute value after initialization', () => { + const clipboardText = 'example'; + const element = createAndConnectElement({ clipboardText }); + const changedClipbordText = 'example2'; + element.setAttribute('data-clipboard-text', changedClipbordText); + + const button = getByRole(element, 'button'); + + userEvent.click(button); + + expect(navigator.clipboard.writeText).to.have.been.calledWith(changedClipbordText); + }); + + context('with nothing to copy', () => { + it('does writes an empty string to the clipboard', () => { + const element = createAndConnectElement(); + const button = getByRole(element, 'button'); + + userEvent.click(button); + + expect(navigator.clipboard.writeText).to.have.been.calledWith(''); + }); + }); +}); diff --git a/app/javascript/packages/clipboard-button/package.json b/app/javascript/packages/clipboard-button/package.json new file mode 100644 index 00000000000..60a3066ed34 --- /dev/null +++ b/app/javascript/packages/clipboard-button/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-clipboard-button", + "version": "1.0.0", + "private": true +} diff --git a/app/javascript/packages/polyfill/index.js b/app/javascript/packages/polyfill/index.js index 018535b792c..6e5c76a1d3a 100644 --- a/app/javascript/packages/polyfill/index.js +++ b/app/javascript/packages/polyfill/index.js @@ -8,7 +8,7 @@ import isSafe from './is-safe'; */ /** - * @typedef {"fetch"|"classlist"|"crypto"|"custom-elements"|"custom-event"|"url"} SupportedPolyfills + * @typedef {"fetch"|"classlist"|"clipboard"|"crypto"|"custom-elements"|"custom-event"|"url"} SupportedPolyfills */ /** @@ -23,6 +23,11 @@ const POLYFILLS = { test: () => 'classList' in Element.prototype, load: () => import(/* webpackChunkName: "classlist-polyfill" */ 'classlist-polyfill'), }, + clipboard: { + test: () => 'clipboard' in navigator, + load: () => + import(/* webpackChunkName: "clipboard-polyfill" */ 'clipboard-polyfill/overwrite-globals'), + }, crypto: { test: () => 'crypto' in window, load: () => import(/* webpackChunkName: "webcrypto-shim" */ 'webcrypto-shim'), diff --git a/app/javascript/packages/polyfill/package.json b/app/javascript/packages/polyfill/package.json index 10d039c36dc..0cef662a82e 100644 --- a/app/javascript/packages/polyfill/package.json +++ b/app/javascript/packages/polyfill/package.json @@ -5,6 +5,7 @@ "dependencies": { "@webcomponents/custom-elements": "^1.5.0", "classlist-polyfill": "^1.2.0", + "clipboard-polyfill": "^3.0.3", "custom-event-polyfill": "^1.0.7", "js-polyfills": "^0.1.43", "webcrypto-shim": "^0.1.6", diff --git a/app/views/shared/_personal_key.html.erb b/app/views/shared/_personal_key.html.erb index aa4eb9ee2dd..cc55cfb5a3a 100644 --- a/app/views/shared/_personal_key.html.erb +++ b/app/views/shared/_personal_key.html.erb @@ -8,22 +8,28 @@