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 @@
<%= render 'partials/personal_key/key', code: code %>
-
-
- <%= link_to t('forms.backup_code.download'), idv_download_personal_key_path, - class: 'usa-button usa-button--outline' %> -
-
- <%= link_to t('users.personal_key.print'), '#', data: {print: true}, - class: 'usa-button usa-button--outline' %> -
-
- <%= link_to t('links.copy'), '#', data: {"clipboard-text": code}, - class: 'usa-button usa-button--outline clipboard' %> +
+
+ <%= link_to( + t('forms.backup_code.download'), + idv_download_personal_key_path, + class: 'usa-button usa-button--outline', + ) %> +
+
+ <%= button_tag( + t('users.personal_key.print'), + type: :button, + data: { print: true }, + class: 'usa-button usa-button--outline', + ) %> +
+
+ <%= render ClipboardButtonComponent.new(clipboard_text: code, outline: true) do %> + <%= t('links.copy') %> + <% end %> +
-
-
-
<%= image_tag(asset_url('alert/icon-lock-alert-important.svg'), alt: '', size: '80') %>
@@ -43,4 +49,4 @@ 'data-toggle': 'modal' %>

<%= render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path %> -<%== javascript_packs_tag_once 'personal-key-page-controller', 'clipboard' %> +<%== javascript_packs_tag_once 'personal-key-page-controller' %> diff --git a/package.json b/package.json index a935d802403..6a6d6171450 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/sinon": "^9.0.11", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", + "clipboard-polyfill": "^3.0.3", "dirty-chai": "^2.0.1", "eslint": "^8.3.0", "jsdom": "^16.4.0", diff --git a/spec/components/button_component_spec.rb b/spec/components/button_component_spec.rb new file mode 100644 index 00000000000..874ba93976d --- /dev/null +++ b/spec/components/button_component_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe ButtonComponent, type: :component do + let(:type) { nil } + let(:outline) { false } + let(:content) { 'Button' } + let(:options) do + { + outline: outline, + type: type, + }.compact + end + + subject(:rendered) { render_inline ButtonComponent.new(options) { content } } + + it 'renders button content' do + expect(rendered).to have_content(content) + end + + it 'renders as type=button' do + expect(rendered).to have_css('button[type=button]') + end + + it 'renders with design system classes' do + expect(rendered).to have_css('button.usa-button') + end + + context 'with outline' do + let(:outline) { true } + + it 'renders with design system classes' do + expect(rendered).to have_css('button.usa-button.usa-button--outline') + end + end + + context 'with type' do + let(:type) { :submit } + + it 'renders as type' do + expect(rendered).to have_css('button[type=submit]') + end + end +end diff --git a/spec/components/clipboard_button_component_spec.rb b/spec/components/clipboard_button_component_spec.rb new file mode 100644 index 00000000000..102bac24d14 --- /dev/null +++ b/spec/components/clipboard_button_component_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ClipboardButtonComponent, type: :component do + let(:clipboard_text) { 'Copy Text' } + let(:content) { 'Button' } + let(:tag_options) { {} } + + subject(:rendered) do + render_inline ClipboardButtonComponent.new(clipboard_text: clipboard_text, **tag_options) do + content + end + end + + it 'renders button content' do + expect(rendered).to have_content(content) + end + + it 'renders with clipboard text as data-attribute' do + expect(rendered).to have_css("lg-clipboard-button[data-clipboard-text='#{clipboard_text}']") + end + + context 'with tag options' do + let(:tag_options) { { outline: true } } + + it 'renders button given the tag options' do + expect(rendered).to have_css('button.usa-button.usa-button--outline') + end + end +end diff --git a/spec/support/shared_examples_for_personal_keys.rb b/spec/support/shared_examples_for_personal_keys.rb index bba05290f02..d917bfdb27b 100644 --- a/spec/support/shared_examples_for_personal_keys.rb +++ b/spec/support/shared_examples_for_personal_keys.rb @@ -12,4 +12,26 @@ end end end + + context 'with javascript enabled', js: true do + before do + page.driver.browser.execute_cdp( + 'Browser.grantPermissions', + origin: page.server_url, + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + ) + end + + after do + page.driver.browser.execute_cdp('Browser.resetPermissions') + end + + it 'allows a user to copy the code into the confirmation modal' do + click_on t('links.copy') + copied_text = page.evaluate_async_script('navigator.clipboard.readText().then(arguments[0])') + + code = page.all('[data-personal-key]').map(&:text).join('-') + expect(copied_text).to eq(code) + end + end end diff --git a/yarn.lock b/yarn.lock index 5f1884bd4a7..36f761e3d33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2678,6 +2678,11 @@ cleave.js@^1.6.0: resolved "https://registry.yarnpkg.com/cleave.js/-/cleave.js-1.6.0.tgz#0e4e011943bdd70c67c9dcf4ff800ce710529171" integrity sha512-ivqesy3j5hQVG3gywPfwKPbi/7ZSftY/UNp5uphnqjr25yI2CP8FS2ODQPzuLXXnNLi29e2+PgPkkiKUXLs/Nw== +clipboard-polyfill@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/clipboard-polyfill/-/clipboard-polyfill-3.0.3.tgz#159ea0768e20edc7ffda404bd13c54c73de4ff40" + integrity sha512-hts0o01ZkwjA1qHA5gFePzAj/780W7v+eyN3GdaCRyDnapzcPsKRV5aodv77gcr40NDIcyNjNmc+HvfKV+jD0g== + clipboard@^2.0.6: version "2.0.8" resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba"