From ca3cce23e36b9950164de5007d6aaf095df75517 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Wed, 3 Sep 2025 16:26:28 +0100 Subject: [PATCH 01/65] Remove logging from autocomplete JS unit test --- app/assets/javascripts/components/autocomplete.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/components/autocomplete.spec.js b/app/assets/javascripts/components/autocomplete.spec.js index a74ba6a9ef..5791a9adc4 100644 --- a/app/assets/javascripts/components/autocomplete.spec.js +++ b/app/assets/javascripts/components/autocomplete.spec.js @@ -72,7 +72,6 @@ describe("Autocomplete", () => { // Check that matching options are shown const visibleOptions = listbox.querySelectorAll("li"); - console.log(visibleOptions[0].innerHTML.trim()); expect(visibleOptions.length).toEqual(1); // Option display hint text From f9e356fd534262d0f29d1b2650151f729fe70b3a Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Wed, 3 Sep 2025 17:01:44 +0100 Subject: [PATCH 02/65] Add sticky JavaScript component module --- app/assets/javascripts/application.js | 5 + app/assets/javascripts/components/sticky.js | 63 ++++++ .../javascripts/components/sticky.spec.js | 194 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 app/assets/javascripts/components/sticky.js create mode 100644 app/assets/javascripts/components/sticky.spec.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 80b10eefeb..3fe4b5c9e7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,6 +12,7 @@ import { import { Autocomplete } from "./components/autocomplete.js"; import { UpgradedRadios as Radios } from "./components/radios.js"; +import { Sticky } from "./components/sticky.js"; // Configure Turbo Turbo.session.drive = false; @@ -41,6 +42,10 @@ function initialiseComponents() { createAll(Autocomplete); } + if (!isInitialised("app-sticky")) { + createAll(Sticky); + } + if (!isInitialised("nhsuk-button")) { createAll(Button, { preventDoubleClick: true }); } diff --git a/app/assets/javascripts/components/sticky.js b/app/assets/javascripts/components/sticky.js new file mode 100644 index 0000000000..ea1aed4de4 --- /dev/null +++ b/app/assets/javascripts/components/sticky.js @@ -0,0 +1,63 @@ +import { Component } from "nhsuk-frontend"; + +/** + * Sticky component + */ +export class Sticky extends Component { + /** + * @param {Element | null} $root - HTML element to use for component + */ + constructor($root) { + super($root); + + this.stickyElement = $root; + this.stickyElementStyle = null; + this.stickyElementTop = 0; + + this.determineStickyState = this.determineStickyState.bind(this); + this.throttledStickyState = this.throttle(this.determineStickyState, 100); + + this.stickyElementStyle = window.getComputedStyle($root); + this.stickyElementTop = parseInt(this.stickyElementStyle.top, 10); + + window.addEventListener("scroll", this.throttledStickyState); + + this.determineStickyState(); + } + + /** + * Name for the component used when initialising using data-module attributes + */ + static moduleName = "app-sticky"; + + /** + * Determine element’s sticky state + */ + determineStickyState() { + const currentTop = this.stickyElement.getBoundingClientRect().top; + + this.stickyElement.dataset.stuck = String( + currentTop <= this.stickyElementTop, + ); + } + + /** + * Throttle + * + * @param {Function} callback - Function to throttle + * @param {number} limit - Minimum time interval (in milliseconds) + * @returns {Function} Throttled function + */ + throttle(callback, limit) { + let inThrottle; + return function () { + const args = arguments; + const context = this; + if (!inThrottle) { + callback.apply(context, args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; + } +} diff --git a/app/assets/javascripts/components/sticky.spec.js b/app/assets/javascripts/components/sticky.spec.js new file mode 100644 index 0000000000..42bb765885 --- /dev/null +++ b/app/assets/javascripts/components/sticky.spec.js @@ -0,0 +1,194 @@ +import { Sticky } from "./sticky.js"; + +describe("Sticky Component", () => { + let mockElement; + let stickyInstance; + let scrollEventListener; + + beforeEach(() => { + document.body.classList.add("nhsuk-frontend-supported"); + + // Create a mock DOM element + document.body.innerHTML = `
`; + mockElement = document.getElementById("mock-element"); + + // Mock getBoundingClientRect + Object.defineProperty(mockElement, "getBoundingClientRect", { + value: jest.fn(() => ({ top: 0 })), + configurable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, "getComputedStyle", { + value: jest.fn(() => ({ + top: "20px", + })), + writable: true, + }); + + // Mock window.addEventListener and capture scroll listener + const originalAddEventListener = window.addEventListener; + window.addEventListener = jest.fn((event, listener) => { + if (event === "scroll") { + scrollEventListener = listener; + } + originalAddEventListener.call(window, event, listener); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe("Initialization", () => { + test("should initialize with correct properties", () => { + stickyInstance = new Sticky(mockElement); + + expect(stickyInstance.stickyElement).toBe(mockElement); + expect(stickyInstance.stickyElementTop).toBe(20); + expect(window.getComputedStyle).toHaveBeenCalledWith(mockElement); + expect(window.addEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function), + ); + }); + + test("should have correct moduleName", () => { + expect(Sticky.moduleName).toBe("app-sticky"); + }); + + test("should call determineStickyState on initialization", () => { + const spy = jest.spyOn(Sticky.prototype, "determineStickyState"); + stickyInstance = new Sticky(mockElement); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe("determineStickyState method", () => { + beforeEach(() => { + stickyInstance = new Sticky(mockElement); + }); + + test("should set data-stuck to `true` when element is at or above threshold", () => { + // Element is at the top (currentTop = 0, threshold = 20) + mockElement.getBoundingClientRect.mockReturnValue({ top: 0 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `true` when element above threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 10 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `true` when element at threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 20 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + + test("should set data-stuck to `false` when element below threshold", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 30 }); + + stickyInstance.determineStickyState(); + + expect(mockElement.dataset.stuck).toBe("false"); + }); + }); + + describe("Scroll behavior", () => { + beforeEach(() => { + jest.useFakeTimers(); + + // Clear any existing event listeners + window.removeEventListener = jest.fn(); + + stickyInstance = new Sticky(mockElement); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("should respond to scroll events", () => { + mockElement.getBoundingClientRect.mockReturnValue({ top: 10 }); + + // Make sure we have the listener + expect(scrollEventListener).toBeDefined(); + + // Trigger scroll event + scrollEventListener(); + + expect(mockElement.dataset.stuck).toBe("true"); + }); + }); + + describe("Throttle functionality", () => { + beforeEach(() => { + jest.useFakeTimers(); + stickyInstance = new Sticky(mockElement); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("should throttle function calls", () => { + const mockCallback = jest.fn(); + const throttledCallback = stickyInstance.throttle(mockCallback, 100); + + // Call multiple times rapidly + throttledCallback(); + throttledCallback(); + throttledCallback(); + + // Should only be called once + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Fast forward past throttle limit + jest.advanceTimersByTime(100); + + // Now should allow another call + throttledCallback(); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + test("should preserve context and arguments in throttled function", () => { + const mockCallback = jest.fn(); + const throttledCallback = stickyInstance.throttle(mockCallback, 100); + + throttledCallback("arg1", "arg2"); + + expect(mockCallback).toHaveBeenCalledWith("arg1", "arg2"); + }); + }); + + describe("Integration with different CSS top values", () => { + test("should handle different top values correctly", () => { + // Mock different computed style top value + window.getComputedStyle.mockReturnValue({ top: "50px" }); + + stickyInstance = new Sticky(mockElement); + + expect(stickyInstance.stickyElementTop).toBe(50); + + // Test with element above new threshold + mockElement.getBoundingClientRect.mockReturnValue({ top: 30 }); + stickyInstance.determineStickyState(); + expect(mockElement.dataset.stuck).toBe("true"); + + // Test with element below new threshold + mockElement.getBoundingClientRect.mockReturnValue({ top: 60 }); + stickyInstance.determineStickyState(); + expect(mockElement.dataset.stuck).toBe("false"); + }); + }); +}); From 506570b8c5f1bead583f02a694bbe2868e651891 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Wed, 3 Sep 2025 17:02:27 +0100 Subject: [PATCH 03/65] Add missing class name and aria label to AppSecondaryNavigationComponent --- app/components/app_secondary_navigation_component.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/app_secondary_navigation_component.html.erb b/app/components/app_secondary_navigation_component.html.erb index 6001b04a14..c1f2ac7c12 100644 --- a/app/components/app_secondary_navigation_component.html.erb +++ b/app/components/app_secondary_navigation_component.html.erb @@ -1,4 +1,5 @@ -