diff --git a/app/assets/stylesheets/components/_step-indicator.scss b/app/assets/stylesheets/components/_step-indicator.scss index d002d1cf2c9..1e5a1efb012 100644 --- a/app/assets/stylesheets/components/_step-indicator.scss +++ b/app/assets/stylesheets/components/_step-indicator.scss @@ -2,7 +2,8 @@ $step-indicator-current-step-border-width: 3px; $step-indicator-line-height: 4px; $step-indicator-pending-color: #a8b6c6; -.step-indicator { +lg-step-indicator { + display: block; border-bottom: 1px solid color('primary-light'); box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); margin-bottom: units(4); diff --git a/spec/javascripts/packages/step-indicator/index-spec.js b/app/javascript/packages/step-indicator/index.spec.ts similarity index 76% rename from spec/javascripts/packages/step-indicator/index-spec.js rename to app/javascript/packages/step-indicator/index.spec.ts index 1b8278d27c9..45a64266345 100644 --- a/spec/javascripts/packages/step-indicator/index-spec.js +++ b/app/javascript/packages/step-indicator/index.spec.ts @@ -1,15 +1,21 @@ -import StepIndicator from '@18f/identity-step-indicator'; +import sinon from 'sinon'; import userEvent from '@testing-library/user-event'; -import { useSandbox } from '../../support/sinon'; -import useDefineProperty from '../../support/define-property'; +import { useDefineProperty } from '@18f/identity-test-helpers'; +import StepIndicator from './index'; describe('StepIndicator', () => { - const sandbox = useSandbox(); + const sandbox = sinon.createSandbox(); const defineProperty = useDefineProperty(); + before(() => { + if (!customElements.get('lg-step-indicator')) { + customElements.define('lg-step-indicator', StepIndicator); + } + }); + function initialize({ currentStepIndex = 0 } = {}) { document.body.innerHTML = ` -
+
    ${Array.from(Array(5)) .map( @@ -25,12 +31,20 @@ describe('StepIndicator', () => { ) .join('')}
-
`; - const stepIndicator = new StepIndicator(document.body.firstElementChild); - stepIndicator.bind(); - return stepIndicator; + `; + return document.querySelector('lg-step-indicator') as StepIndicator; } + it('cleans up event listeners', () => { + window.resizeTo(1024, 768); + initialize(); + sandbox.spy(HTMLElement.prototype, 'setAttribute'); + document.body.innerHTML = ''; + window.resizeTo(340, 600); + + expect(HTMLElement.prototype.setAttribute).not.to.have.been.called(); + }); + context('small viewport', () => { beforeEach(() => { window.resizeTo(340, 600); @@ -43,9 +57,12 @@ describe('StepIndicator', () => { }); it('scrolls to current item', () => { - sandbox.stub(window, 'getComputedStyle').callsFake((element) => ({ - paddingLeft: element.classList.contains('step-indicator__scroller') ? '24px' : '0', - })); + sandbox.stub(window, 'getComputedStyle').callsFake( + (element) => + ({ + paddingLeft: element.classList.contains('step-indicator__scroller') ? '24px' : '0', + } as CSSStyleDeclaration), + ); defineProperty(window.Element.prototype, 'scrollWidth', { get() { return this.classList.contains('step-indicator__scroller') ? 593 : 0; diff --git a/app/javascript/packages/step-indicator/index.js b/app/javascript/packages/step-indicator/index.ts similarity index 71% rename from app/javascript/packages/step-indicator/index.js rename to app/javascript/packages/step-indicator/index.ts index 22245c43059..c75e9dc08d0 100644 --- a/app/javascript/packages/step-indicator/index.js +++ b/app/javascript/packages/step-indicator/index.ts @@ -1,36 +1,42 @@ const SMALL_VIEWPORT_MEDIA_QUERY = '(max-width: 639px)'; -class StepIndicator { - /** - * @param {HTMLElement} wrapper - */ - constructor(wrapper) { - this.elements = { - wrapper, - scroller: /** @type {HTMLElement} */ (wrapper.querySelector('.step-indicator__scroller')), - currentStep: /** @type {HTMLElement?} */ ( - wrapper.querySelector('.step-indicator__step--current') - ), - }; - } +interface StepIndicatorElements { + scroller: HTMLElement; + + currentStep: HTMLElement | null; +} + +class StepIndicator extends HTMLElement { + elements: StepIndicatorElements; + + mediaQueryList: MediaQueryList | null; get isSmallViewport() { return this.mediaQueryList ? this.mediaQueryList.matches : false; } - bind() { + connectedCallback() { + this.elements = { + scroller: this.querySelector('.step-indicator__scroller')!, + currentStep: this.querySelector('.step-indicator__step--current'), + }; + this.mediaQueryList = window.matchMedia(SMALL_VIEWPORT_MEDIA_QUERY); - this.mediaQueryList.addListener(() => this.onBreakpointMatchChange()); + this.mediaQueryList.addListener(this.onBreakpointMatchChange); this.onBreakpointMatchChange(); if (this.isSmallViewport) { this.setScrollOffset(); } } - onBreakpointMatchChange() { - this.toggleWrapperFocusable(); + disconnectedCallback() { + this.mediaQueryList?.removeListener(this.onBreakpointMatchChange); } + onBreakpointMatchChange = () => { + this.toggleWrapperFocusable(); + }; + setScrollOffset() { const { currentStep, scroller } = this.elements; if (currentStep) { diff --git a/app/javascript/packages/test-helpers/README.md b/app/javascript/packages/test-helpers/README.md new file mode 100644 index 00000000000..76b55e0fb40 --- /dev/null +++ b/app/javascript/packages/test-helpers/README.md @@ -0,0 +1,3 @@ +# `@18f/identity-test-helpers` + +Convenient utilities and Mocha lifecycle helpers for common test implementations. diff --git a/app/javascript/packages/test-helpers/index.ts b/app/javascript/packages/test-helpers/index.ts index 0182e8170ff..c835adac397 100644 --- a/app/javascript/packages/test-helpers/index.ts +++ b/app/javascript/packages/test-helpers/index.ts @@ -1 +1,2 @@ +export { default as useDefineProperty } from './use-define-property'; export { default as usePropertyValue } from './use-property-value'; diff --git a/app/javascript/packages/test-helpers/use-define-property.spec.ts b/app/javascript/packages/test-helpers/use-define-property.spec.ts new file mode 100644 index 00000000000..92740e42bc3 --- /dev/null +++ b/app/javascript/packages/test-helpers/use-define-property.spec.ts @@ -0,0 +1,19 @@ +import useDefineProperty from './use-define-property'; + +describe('useDefineProperty', () => { + const defineProperty = useDefineProperty(); + + before(() => { + (global as any).useDefineProperty = 10; + defineProperty(global, 'useDefineProperty', { get: () => 20 }); + }); + + after(() => { + expect((global as any).useDefineProperty).to.equal(10); + delete (global as any).useDefineProperty; + }); + + it('has property descriptor during spec', () => { + expect((global as any).useDefineProperty).to.equal(20); + }); +}); diff --git a/spec/javascripts/support/define-property.js b/app/javascript/packages/test-helpers/use-define-property.ts similarity index 59% rename from spec/javascripts/support/define-property.js rename to app/javascript/packages/test-helpers/use-define-property.ts index 0b6de51756c..db60a6eceac 100644 --- a/spec/javascripts/support/define-property.js +++ b/app/javascript/packages/test-helpers/use-define-property.ts @@ -1,11 +1,11 @@ +type RedefinedProperty = [any, PropertyKey, PropertyDescriptor | undefined]; + /** * A proxy to Object.defineProperty to use in redefining an existing object and reverting that * definition to its original value after the test has completed. - * - * @return {ObjectConstructor['defineProperty']} */ -export default function useDefineProperty() { - let redefined = []; +function useDefineProperty(): ObjectConstructor['defineProperty'] { + let redefined: Array = []; afterEach(() => { redefined.forEach(([object, property, originalDescriptor]) => { @@ -18,9 +18,15 @@ export default function useDefineProperty() { redefined = []; }); - return function defineProperty(object, property, descriptor) { + return function defineProperty( + object: O, + property: PropertyKey, + descriptor: PropertyDescriptor, + ) { const originalDescriptor = Object.getOwnPropertyDescriptor(object, property); redefined.push([object, property, originalDescriptor]); - Object.defineProperty(object, property, descriptor); + return Object.defineProperty(object, property, descriptor); }; } + +export default useDefineProperty; diff --git a/app/javascript/packages/test-helpers/use-property-value.spec.ts b/app/javascript/packages/test-helpers/use-property-value.spec.ts index 820f3395673..173bc970dbf 100644 --- a/app/javascript/packages/test-helpers/use-property-value.spec.ts +++ b/app/javascript/packages/test-helpers/use-property-value.spec.ts @@ -1,15 +1,15 @@ import usePropertyValue from './use-property-value'; describe('usePropertyValue', () => { - (global as any).foo = 10; - usePropertyValue(global as any, 'foo', 20); + (global as any).usePropertyValue = 10; + usePropertyValue(global as any, 'usePropertyValue', 20); after(() => { - expect((global as any).foo).to.equal(10); - delete (global as any).foo; + expect((global as any).usePropertyValue).to.equal(10); + delete (global as any).usePropertyValue; }); it('has value during spec', () => { - expect((global as any).foo).to.equal(20); + expect((global as any).usePropertyValue).to.equal(20); }); }); diff --git a/app/javascript/packs/step-indicator.js b/app/javascript/packs/step-indicator.js index d30b1ff2aaf..48ca98bfb75 100644 --- a/app/javascript/packs/step-indicator.js +++ b/app/javascript/packs/step-indicator.js @@ -1,4 +1,3 @@ import StepIndicator from '@18f/identity-step-indicator'; -const wrappers = Array.from(document.querySelectorAll('.step-indicator')); -wrappers.forEach((wrapper) => new StepIndicator(/** @type {HTMLElement} */ (wrapper)).bind()); +customElements.define('lg-step-indicator', StepIndicator); diff --git a/app/views/shared/_step_indicator.html.erb b/app/views/shared/_step_indicator.html.erb index c61615645c9..57257457882 100644 --- a/app/views/shared/_step_indicator.html.erb +++ b/app/views/shared/_step_indicator.html.erb @@ -5,9 +5,7 @@ locals: * locale_scope: Scope under which to find title translations for steps given as names. * current_step: Current step. %> -<% classes = ['step-indicator'] - classes << local_assigns[:class] if local_assigns[:class] - normalized_steps = steps.map do |step| +<% normalized_steps = steps.map do |step| if local_assigns[:locale_scope] { title: t(step[:name], scope: [:step_indicator, :flows, locale_scope]) }.merge(step) else @@ -16,10 +14,11 @@ locals: end current_step_index = normalized_steps.index { |step| step[:name] == local_assigns[:current_step] } %> -<%= tag.div( +<%= content_tag( + :'lg-step-indicator', role: 'region', aria: { label: t('step_indicator.accessible_label') }, - class: classes, + class: local_assigns[:class], ) do %>
    <% normalized_steps.each_with_index do |step, index| %> diff --git a/spec/javascripts/packs/form-steps-wait-spec.js b/spec/javascripts/packs/form-steps-wait-spec.js index e495438ddc6..0175a857274 100644 --- a/spec/javascripts/packs/form-steps-wait-spec.js +++ b/spec/javascripts/packs/form-steps-wait-spec.js @@ -1,6 +1,6 @@ import { fireEvent, findByRole } from '@testing-library/dom'; +import { useDefineProperty } from '@18f/identity-test-helpers'; import { useSandbox } from '../support/sinon'; -import useDefineProperty from '../support/define-property'; import { FormStepsWait, getDOMFromHTML, diff --git a/spec/javascripts/packs/webauthn-setup-spec.js b/spec/javascripts/packs/webauthn-setup-spec.js index df52adb6f2d..3ed12e62b7d 100644 --- a/spec/javascripts/packs/webauthn-setup-spec.js +++ b/spec/javascripts/packs/webauthn-setup-spec.js @@ -1,5 +1,5 @@ +import { useDefineProperty } from '@18f/identity-test-helpers'; import { useSandbox } from '../support/sinon'; -import useDefineProperty from '../support/define-property'; import { reloadWithError } from '../../../app/javascript/packs/webauthn-setup'; describe('webauthn-setup', () => { diff --git a/spec/javascripts/packs/webauthn-unhide-spec.js b/spec/javascripts/packs/webauthn-unhide-spec.js index d1619e344fa..aec71ed128b 100644 --- a/spec/javascripts/packs/webauthn-unhide-spec.js +++ b/spec/javascripts/packs/webauthn-unhide-spec.js @@ -1,6 +1,6 @@ import { screen } from '@testing-library/dom'; +import { useDefineProperty } from '@18f/identity-test-helpers'; import { useSandbox } from '../support/sinon'; -import useDefineProperty from '../support/define-property'; import { unhideWebauthn } from '../../../app/javascript/packs/webauthn-unhide'; describe('webauthn-unhide', () => { diff --git a/spec/views/shared/_step_indicator.html.erb_spec.rb b/spec/views/shared/_step_indicator.html.erb_spec.rb index 8ad93d62e1b..264276098b1 100644 --- a/spec/views/shared/_step_indicator.html.erb_spec.rb +++ b/spec/views/shared/_step_indicator.html.erb_spec.rb @@ -44,8 +44,8 @@ context 'without custom classes given' do let(:classes) { nil } - it 'renders with default classes' do - expect(rendered).to have_selector('.step-indicator') + it 'renders with default tag' do + expect(rendered).to have_selector('lg-step-indicator') end end @@ -53,7 +53,7 @@ let(:classes) { 'my-custom-class' } it 'renders with additional custom classes' do - expect(rendered).to have_selector('.step-indicator.my-custom-class') + expect(rendered).to have_selector('lg-step-indicator.my-custom-class') end end end