diff --git a/.jshintrc b/.jshintrc index 368bcf310..50b63c35f 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1 +1 @@ -{"esversion": 6, "esnext": false} +{"esversion": 8, "esnext": false} diff --git a/app/assets/javascripts/copyToClipboard.js b/app/assets/javascripts/copyToClipboard.js index a8126f09f..344ee6716 100644 --- a/app/assets/javascripts/copyToClipboard.js +++ b/app/assets/javascripts/copyToClipboard.js @@ -1,94 +1,70 @@ (function(window) { "use strict"; - if (!document.queryCommandSupported('copy')) return; + // Only initialize if modern Clipboard API is available + if (!navigator.clipboard) return; window.NotifyModules['copy-to-clipboard'] = function() { - const states = { - 'valueVisible': (options) => ` - ${options.valueLabel ? '' + options.thing + ': ' : ''}${options.value} - - ${options.onload ? '' : options.thing + ' returned to page, press button to copy to clipboard'} - - - `, - 'valueCopied': (options) => ` - - ${options.thing} Copied to clipboard, press button to show in page - - - ` - }; - - this.getRangeFromElement = function (copyableElement) { - const range = document.createRange(); - const childNodes = Array.prototype.slice.call(copyableElement.childNodes); - let prefixIndex = -1; - - childNodes.forEach((el, idx) => { - if ((el.nodeType === 1) && el.classList.contains('usa-sr-only')) { - prefixIndex = idx; - } - }); - - range.selectNodeContents(copyableElement); - if (prefixIndex !== -1) { range.setStart(copyableElement, prefixIndex + 1); } - - return range; - }; - - this.copyValueToClipboard = function(copyableElement, callback) { - var selection = window.getSelection ? window.getSelection() : document.selection, - range = this.getRangeFromElement(copyableElement); - - selection.removeAllRanges(); - selection.addRange(range); - document.execCommand('copy'); - selection.removeAllRanges(); - callback(); + /** + * Copy text to clipboard using modern Clipboard API + * @param {string} text - The text to copy + * @param {Function} callback - Called after successful copy + */ + this.copyValueToClipboard = async function(text, callback) { + try { + await navigator.clipboard.writeText(text); + callback(); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } }; this.start = function(component) { - const $component = $(component), - stateOptions = { - value: $component.data('value'), - thing: $component.data('thing') - }, - name = $component.data('name'); + const value = component.dataset.value; + const thing = component.dataset.thing; + const name = component.dataset.name; - // if the name is distinct from the thing: - // - it will be used in the rendering - // - the value won't be identified by a heading so needs its own label - if (name !== stateOptions.thing) { - stateOptions.name = name; - stateOptions.valueLabel = true; - } + // Determine button label + const isMultiple = name !== thing; + const buttonLabel = isMultiple + ? `Copy ${thing}` + : `Copy ${thing} to clipboard`; + const srSuffix = isMultiple ? ` for ${name}` : ''; - $component - .addClass('copy-to-clipboard') - .css('min-height', $component.height()) - .html(states.valueVisible($.extend({ 'onload': true }, stateOptions))) - .on( - 'click', '.copy-to-clipboard__button--copy', () => - this.copyValueToClipboard( - $('.copy-to-clipboard__value', component)[0], () => - $component - .html(states.valueCopied(stateOptions)) - .find('.usa-button').focus() - ) - ) - .on( - 'click', '.copy-to-clipboard__button--show', () => - $component - .html(states.valueVisible(stateOptions)) - .find('.usa-button').focus() - ); + // Create simple HTML structure + component.classList.add('copy-to-clipboard'); + component.innerHTML = ` +
${value}
+ + + `; + + const button = component.querySelector('.copy-to-clipboard__button'); + const srAnnouncement = component.querySelector('[aria-live]'); + + // Handle copy button click + button.addEventListener('click', () => { + this.copyValueToClipboard(value, () => { + // Change button text to "Copied!" + const originalText = button.innerHTML; + button.innerHTML = `Copied!${srSuffix}`; + button.disabled = true; + + // Announce to screen readers + srAnnouncement.textContent = `${thing} copied to clipboard`; + + // Reset button after 2 seconds + setTimeout(() => { + button.innerHTML = originalText; + button.disabled = false; + srAnnouncement.textContent = ''; + }, 2000); + }); + }); if ('stickAtBottomWhenScrolling' in window.NotifyModules) { window.NotifyModules.stickAtBottomWhenScrolling.recalculate(); diff --git a/app/assets/javascripts/errorTracking.js b/app/assets/javascripts/errorTracking.js index d69620f60..fcb8da669 100644 --- a/app/assets/javascripts/errorTracking.js +++ b/app/assets/javascripts/errorTracking.js @@ -4,15 +4,13 @@ window.NotifyModules['track-error'] = function() { this.start = function(element) { - var component = $(element); - // Track error to analytics if available if (window.NotifyModules && window.NotifyModules.analytics && window.NotifyModules.analytics.trackEvent) { window.NotifyModules.analytics.trackEvent( 'Error', - component.data('error-type'), + element.dataset.errorType, { - 'label': component.data('error-label') + 'label': element.dataset.errorLabel } ); } diff --git a/app/assets/javascripts/index.js b/app/assets/javascripts/index.js index f451de399..41933efbb 100644 --- a/app/assets/javascripts/index.js +++ b/app/assets/javascripts/index.js @@ -74,7 +74,6 @@ import './fileUpload.js'; import './errorTracking.js'; import './templateFolderForm.js'; import './collapsibleCheckboxes.js'; -import './radioSlider.js'; import './updateStatus.js'; import './main.js'; import './listEntry.js'; diff --git a/app/assets/javascripts/modules/uswds-modules.js b/app/assets/javascripts/modules/uswds-modules.js index cd10ec27b..0274eee41 100644 --- a/app/assets/javascripts/modules/uswds-modules.js +++ b/app/assets/javascripts/modules/uswds-modules.js @@ -1,19 +1,23 @@ (function (window) { 'use strict'; - var $ = window.jQuery; - window.NotifyModules.moduleSystem = { find: function (container) { - container = container || $('body'); + container = container || document.body; var modules; var moduleSelector = '[data-module]'; - modules = container.find(moduleSelector); + // If container is already an element, use it directly + if (container instanceof Element) { + modules = Array.from(container.querySelectorAll(moduleSelector)); - if (container.is(moduleSelector)) { - modules = modules.add(container); + // If the container itself is a module, include it + if (container.matches && container.matches(moduleSelector)) { + modules.unshift(container); + } + } else { + modules = Array.from(document.querySelectorAll(moduleSelector)); } return modules; @@ -25,16 +29,16 @@ for (var i = 0, l = modules.length; i < l; i++) { try { var module; - var element = $(modules[i]); - var type = this.camelCaseAndCapitalise(element.data('module')); - var started = element.data('module-started'); + var element = modules[i]; + var type = this.camelCaseAndCapitalise(element.dataset.module); + var started = element.dataset.moduleStarted; if (typeof window.NotifyModules[type] === 'function' && !started) { module = new window.NotifyModules[type](); if (module.start) { module.start(element); } - element.data('module-started', true); + element.dataset.moduleStarted = 'true'; } } catch (error) { console.error('Failed to initialize module:', type || 'unknown', error); diff --git a/app/assets/javascripts/radioSlider.js b/app/assets/javascripts/radioSlider.js deleted file mode 100644 index a1d28aa9c..000000000 --- a/app/assets/javascripts/radioSlider.js +++ /dev/null @@ -1,28 +0,0 @@ -(function(window) { - - "use strict"; - - window.NotifyModules['radio-slider'] = function() { - - this.start = function(component) { - - $(component) - .on('click', function() { - - valuesInLabel = $(this).find(':checked').next('label').text().split('/'); - - if (valuesInLabel.length === 2) { - leftValue = valuesInLabel[0]; - rightValue = valuesInLabel[1]; - $(this).find('.radio-slider-left-value').text(leftValue); - $(this).find('.radio-slider-right-value').text(rightValue); - } - - }) - .trigger('click'); - - }; - - }; - -})(window); diff --git a/gulpfile.js b/gulpfile.js index 53d701469..6737950c7 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -58,7 +58,6 @@ const javascripts = () => { const notifyModules2 = src([ paths.src + 'javascripts/collapsibleCheckboxes.js', - paths.src + 'javascripts/radioSlider.js', paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/timeoutPopup.js', paths.src + 'javascripts/main.js', diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py index 976d4eecc..f8365cd74 100644 --- a/tests/end_to_end/conftest.py +++ b/tests/end_to_end/conftest.py @@ -153,7 +153,9 @@ def _cleanup_test_service(page: Page, service_name: str): print(f"Successfully cleaned up test service: {service_name}") # noqa: T201 else: - print(f"Service '{service_name}' not found, may have been deleted already") # noqa: T201 + print( # noqa: T201 + f"Service '{service_name}' not found, may have been deleted already" + ) except Exception as e: raise Exception(f"Failed to cleanup service '{service_name}': {str(e)}") diff --git a/tests/javascripts/copyToClipboard.test.js b/tests/javascripts/copyToClipboard.test.js index 76bad7a7e..db573e5a2 100644 --- a/tests/javascripts/copyToClipboard.test.js +++ b/tests/javascripts/copyToClipboard.test.js @@ -5,45 +5,30 @@ afterAll(() => { require('./support/teardown.js'); // clear up methods in the global space - document.queryCommandSupported = undefined; + navigator.clipboard = undefined; }); describe('copy to clipboard', () => { let apiKey = '00000000-0000-0000-0000-000000000000'; - let thing; let component; - let selectionMock; - let rangeMock; - let screenMock; + let clipboardWriteTextMock; const setUpDOM = function (options) { // set up DOM document.body.innerHTML =` -

- ${options.thing} -

-
- ${(options.name === options.thing) ? '' + options.thing + ': ' : ''}${apiKey} - -
`; +
`; }; beforeEach(() => { - // mock objects used to manipulate the page selection - selectionMock = new helpers.SelectionMock(jest); - rangeMock = new helpers.RangeMock(jest); - - // plug gaps in JSDOM's API for manipulation of selections - window.getSelection = jest.fn(() => selectionMock); - document.createRange = jest.fn(() => rangeMock); - - // plug JSDOM not having execCommand - document.execCommand = jest.fn(() => {}); + // Reset the clipboard mock for each test + if (clipboardWriteTextMock) { + clipboardWriteTextMock.mockClear(); + } // mock sticky JS window.NotifyModules.stickAtBottomWhenScrolling = { @@ -52,10 +37,10 @@ describe('copy to clipboard', () => { }); - test("If copy command isn't available, nothing should happen", () => { + test("If Clipboard API isn't available, nothing should happen", () => { - // fake support for the copy command not being available - document.queryCommandSupported = jest.fn(command => false); + // Remove clipboard API + navigator.clipboard = undefined; require('../../app/assets/javascripts/copyToClipboard.js'); @@ -70,14 +55,15 @@ describe('copy to clipboard', () => { }); - describe("If copy command is available", () => { - - let componentHeightOnLoad; + describe("If Clipboard API is available", () => { beforeAll(() => { - // assume copy command is available - document.queryCommandSupported = jest.fn(command => command === 'copy'); + // Mock modern Clipboard API BEFORE loading the module + clipboardWriteTextMock = jest.fn(() => Promise.resolve()); + navigator.clipboard = { + writeText: clipboardWriteTextMock + }; // force module require to not come from cache jest.resetModules(); @@ -86,10 +72,6 @@ describe('copy to clipboard', () => { }); - afterEach(() => { - screenMock.reset(); - }); - describe("On page load", () => { describe("For all variations of the initial HTML", () => { @@ -100,26 +82,6 @@ describe('copy to clipboard', () => { component = document.querySelector('[data-module=copy-to-clipboard]'); - // set default style for component height (queried by jQuery before checking DOM APIs) - const stylesheet = document.createElement('style'); - stylesheet.innerHTML = '[data-module=copy-to-clipboard] { height: auto; }'; // set to browser default - document.getElementsByTagName('head')[0].appendChild(stylesheet); - - componentHeightOnLoad = 50; - - // mock the DOM APIs called for the position & dimension of the component - screenMock = new helpers.ScreenMock(jest); - screenMock.setWindow({ - width: 1990, - height: 940, - scrollTop: 0 - }); - screenMock.mockPositionAndDimension('component', component, { - offsetHeight: componentHeightOnLoad, - offsetWidth: 641, - offsetTop: 0 - }); - // start the module window.NotifyModules.start(); @@ -128,6 +90,7 @@ describe('copy to clipboard', () => { test("It should add a button for copying the thing to the clipboard", () => { expect(component.querySelector('button')).not.toBeNull(); + expect(component.querySelector('button').textContent.trim()).toContain('Copy Some Thing'); }); @@ -139,21 +102,23 @@ describe('copy to clipboard', () => { test("It should tell any sticky JS present the page has changed", () => { - // recalculate forces the sticky JS to recalculate any stored DOM position/dimensions expect(window.NotifyModules.stickAtBottomWhenScrolling.recalculate).toHaveBeenCalled(); }); - test("It should set the component's minimum height based on its height when the page loads", () => { + test("It should display the value", () => { - // to prevent the position of the button moving when the state changes - expect(window.getComputedStyle(component)['min-height']).toEqual(`${componentHeightOnLoad}px`); + expect(component.querySelector('.copy-to-clipboard__value')).not.toBeNull(); + expect(component.querySelector('.copy-to-clipboard__value').textContent).toBe(apiKey); }); - test("It should render the 'thing' without extra whitespace", () => { + test("It should have an aria-live region for screen reader announcements", () => { - expect(component.querySelector('.copy-to-clipboard__value').textContent).toBe('00000000-0000-0000-0000-000000000000'); + const liveRegion = component.querySelector('[aria-live]'); + expect(liveRegion).not.toBeNull(); + expect(liveRegion.getAttribute('aria-live')).toBe('polite'); + expect(liveRegion.getAttribute('aria-atomic')).toBe('true'); }); @@ -163,8 +128,6 @@ describe('copy to clipboard', () => { beforeEach(() => { - // If 'thing' (what the id is) and 'name' (its specific idenifier on the page) are - // different, it will be one of others called the same 'thing'. setUpDOM({ 'thing': 'ID', 'name': 'Default' }); component = document.querySelector('[data-module=copy-to-clipboard]'); @@ -174,19 +137,12 @@ describe('copy to clipboard', () => { }); - // Because it is not the only 'thing' on the page, the id will not have a heading - // and so needs some prefix text to label it - test("The id should have a hidden prefix to label what it is", () => { + test("the button should have shorter label and hidden suffix", () => { - const value = component.querySelector('.copy-to-clipboard__value .usa-sr-only'); - expect(value).not.toBeNull(); - expect(value.textContent).toEqual('ID: '); + const button = component.querySelector('button'); + expect(button.textContent).toContain('Copy ID'); - }); - - test("the button should have a hidden suffix naming the id it is for", () => { - - const buttonSuffix = component.querySelector('button .usa-sr-only'); + const buttonSuffix = button.querySelector('.usa-sr-only'); expect(buttonSuffix).not.toBeNull(); expect(buttonSuffix.textContent).toEqual(' for Default'); @@ -198,9 +154,6 @@ describe('copy to clipboard', () => { beforeEach(() => { - // The heading is added if 'thing' (what the id is) has the same value as 'name' - // (its specific identifier on the page) because this means it can assume it is - // the only one of its type there setUpDOM({ 'thing': 'Some Thing', 'name': 'Some Thing' }); component = document.querySelector('[data-module=copy-to-clipboard]'); @@ -210,12 +163,11 @@ describe('copy to clipboard', () => { }); - test("Its button and id shouldn't have extra hidden text to identify them", () => { + test("the button should have full label without extra suffix", () => { - const value = component.querySelector('.copy-to-clipboard__value .usa-sr-only'); - const buttonSuffix = component.querySelector('button .usa-sr-only'); - expect(value).toBeNull(); - expect(buttonSuffix).toBeNull(); + const button = component.querySelector('button'); + expect(button.textContent).toContain('Copy Some Thing to clipboard'); + expect(button.textContent).not.toContain(' for '); }) @@ -223,187 +175,84 @@ describe('copy to clipboard', () => { }); - describe("If you click the 'Copy Some Thing to clipboard' button", () => { + describe("If you click the 'Copy' button", () => { - describe("For all variations of the initial HTML", () => { + beforeEach(async () => { - let keyEl; + jest.useFakeTimers(); - beforeEach(() => { + setUpDOM({ 'thing': 'API key', 'name': 'API key' }); - setUpDOM({ 'thing': 'Some Thing', 'name': 'Some Thing' }); + component = document.querySelector('[data-module=copy-to-clipboard]'); - // start the module - window.NotifyModules.start(); + // start the module + window.NotifyModules.start(); - component = document.querySelector('[data-module=copy-to-clipboard]'); - keyEl = component.querySelector('.copy-to-clipboard__value'); + const button = component.querySelector('button'); + helpers.triggerEvent(button, 'click'); - helpers.triggerEvent(component.querySelector('button'), 'click'); - - }); - - test("The live-region should be shown and its text should confirm the copy action", () => { - - const liveRegion = component.querySelector('.copy-to-clipboard__notice'); - - expect(liveRegion.classList.contains('usa-sr-only')).toBe(false); - expect(liveRegion.textContent.trim()).toEqual( - expect.stringContaining('Copied to clipboard') - ); - - }); - - // The button also says this but its text after being changed is not announced due to being - // lower priority than the live-region - test("The live-region should contain some hidden text giving context to the statement shown", () => { - - const liveRegionHiddenText = component.querySelectorAll('.copy-to-clipboard__notice .usa-sr-only'); - - expect(liveRegionHiddenText.length).toEqual(2); - expect(liveRegionHiddenText[0].textContent).toEqual('Some Thing '); - expect(liveRegionHiddenText[1].textContent).toEqual(', press button to show in page'); - - }); - - test("It should swap the button for one to show the Some Thing", () => { - - expect(component.querySelector('button').textContent.trim()).toEqual( - expect.stringContaining('Show Some Thing') - ); - - }); - - test("It should remove the id from the page", () => { - - expect(component.querySelector('.copy-to-clipboard__value')).toBeNull(); - - }); - - test("It should copy the thing to the clipboard", () => { - - // it should make a selection (a range) from the contents of the element containing the Some Thing - expect(rangeMock.selectNodeContents.mock.calls[0]).toEqual([keyEl]); - - // that selection (a range) should be added to that for the page (a selection) - expect(selectionMock.addRange.mock.calls[0]).toEqual([rangeMock]); - - expect(document.execCommand).toHaveBeenCalled(); - expect(document.execCommand.mock.calls[0]).toEqual(['copy']); - - // reset any methods in the global space - window.queryCommandSupported = undefined; - window.getSelection = undefined; - document.createRange = undefined; - - }); - - describe("If you then click the 'Show Some Thing'", () => { - - beforeEach(() => { - - helpers.triggerEvent(component.querySelector('button'), 'click'); - - }); - - test("It should change the text to show the Some Thing", () => { - - expect(component.querySelector('.copy-to-clipboard__value')).not.toBeNull(); - - }); - - test("It should swap the button for one to copy the thing to the clipboard", () => { - - expect(component.querySelector('button').textContent.trim()).toEqual( - expect.stringContaining('Copy Some Thing to clipboard') - ); - - }) - - }); + // Wait for async clipboard operation + await Promise.resolve(); }); - describe("If it's one of many in the page", () => { + afterEach(() => { + jest.useRealTimers(); + }); - beforeEach(() => { + test("The value should still be visible", () => { - // If 'thing' (what the id is) and 'name' (its specific idenifier on the page) are - // different, it will be one of others called the same 'thing'. - setUpDOM({ 'thing': 'ID', 'name': 'Default' }); - - // start the module - window.NotifyModules.start(); - - component = document.querySelector('[data-module=copy-to-clipboard]'); - - helpers.triggerEvent(component.querySelector('button'), 'click'); - - }); - - test("the button should have a hidden suffix naming the id it is for", () => { - - const buttonSuffix = component.querySelector('button .usa-sr-only'); - expect(buttonSuffix).not.toBeNull(); - expect(buttonSuffix.textContent).toEqual(' for Default'); - - }); - - test("the copied selection (range) should start after visually hidden prefix", () => { - - // that selection (a range) should have a startOffset of 1: - // index 0: the visually hidden prefix node, for example "Template ID: " or "API key: " - // index 1: the value node - expect(rangeMock.setStart).toHaveBeenCalled(); - expect(rangeMock.setStart.mock.calls[0][1]).toEqual(1); - - // reset any methods in the global space - window.queryCommandSupported = undefined; - window.getSelection = undefined; - document.createRange = undefined; - - }); + expect(component.querySelector('.copy-to-clipboard__value')).not.toBeNull(); + expect(component.querySelector('.copy-to-clipboard__value').textContent).toBe(apiKey); }); - describe("If it's the only one on the page", () => { + test("The button text should change to 'Copied!'", () => { - beforeEach(() => { + const button = component.querySelector('button'); + expect(button.textContent).toContain('Copied!'); - // The heading is added if 'thing' (what the id is) has the same value as 'name' - // (its specific identifier on the page) because this means it can assume it is - // the only one of its type there - setUpDOM({ 'thing': 'Some Thing', 'name': 'Some Thing' }); + }); - // start the module - window.NotifyModules.start(); + test("The button should be disabled", () => { - component = document.querySelector('[data-module=copy-to-clipboard]'); + const button = component.querySelector('button'); + expect(button.disabled).toBe(true); - helpers.triggerEvent(component.querySelector('button'), 'click'); + }); - }); + test("Screen reader announcement should confirm the copy", () => { - test("Its button and id shouldn't have extra hidden text to identify them", () => { + const liveRegion = component.querySelector('[aria-live]'); + expect(liveRegion.textContent).toBe('API key copied to clipboard'); - const prefix = component.querySelector('.copy-to-clipboard__value .usa-sr-only'); - const buttonSuffix = component.querySelector('button .usa-sr-only'); - expect(prefix).toBeNull(); - expect(buttonSuffix).toBeNull(); + }); - }) + test("It should copy the value to clipboard using Clipboard API", () => { - test("the copied selection (range) should start at the default position", () => { + expect(clipboardWriteTextMock).toHaveBeenCalledWith(apiKey); - // that selection (a range) shouldn't call setStart to avoid the prefix: - expect(rangeMock.setStart).not.toHaveBeenCalled(); + }); - // reset any methods in the global space - window.queryCommandSupported = undefined; - window.getSelection = undefined; - document.createRange = undefined; + test("After 2 seconds, button should reset to original text", () => { - }); + const button = component.querySelector('button'); + + jest.advanceTimersByTime(2000); + + expect(button.textContent).toContain('Copy API key'); + expect(button.textContent).not.toContain('Copied!'); + expect(button.disabled).toBe(false); + + }); + + test("After 2 seconds, screen reader announcement should clear", () => { + + const liveRegion = component.querySelector('[aria-live]'); + + jest.advanceTimersByTime(2000); + + expect(liveRegion.textContent).toBe(''); }); diff --git a/tests/javascripts/jest.config.js b/tests/javascripts/jest.config.js index a3be0bb1d..d56f4c146 100644 --- a/tests/javascripts/jest.config.js +++ b/tests/javascripts/jest.config.js @@ -2,7 +2,8 @@ module.exports = { collectCoverage: true, coverageDirectory: './coverage', coveragePathIgnorePatterns: [ - 'support/polyfills.js' + 'support/polyfills.js', + 'support/helpers/' ], coverageThreshold: { global: {