Skip to content

Commit a478d8f

Browse files
authored
Merge pull request #4629 from nhsuk/next
Version 4.2.0
2 parents b6a9ad5 + 304541b commit a478d8f

169 files changed

Lines changed: 4071 additions & 1174 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ tests/dist
8787
# These files are autogenerated by the deploy GitHub Action
8888
public/sha
8989
public/ref
90+
91+
# Redis
92+
dump.rdb

.prettierignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ terraform/.terraform
3939
*.tfstate
4040
*.tfstate.backup
4141
scratchpad
42-
spec/fixtures/files/fhir/immunisation-create.json
43-
spec/fixtures/files/fhir/immunisation-update.json
42+
spec/fixtures/files/fhir/immunisation_create.json
43+
spec/fixtures/files/fhir/immunisation_update.json

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,5 @@ group :test do
122122
gem "shoulda-matchers"
123123
gem "simplecov", require: false
124124
gem "webmock"
125+
gem "rack_session_access"
125126
end

Gemfile.lock

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ GEM
113113
ast (2.4.3)
114114
attr_required (1.0.2)
115115
aws-eventstream (1.4.0)
116-
aws-partitions (1.1156.0)
116+
aws-partitions (1.1159.0)
117117
aws-sdk-accessanalyzer (1.78.0)
118118
aws-sdk-core (~> 3, >= 3.231.0)
119119
aws-sigv4 (~> 1.5)
@@ -137,7 +137,7 @@ GEM
137137
aws-sdk-kms (1.112.0)
138138
aws-sdk-core (~> 3, >= 3.231.0)
139139
aws-sigv4 (~> 1.5)
140-
aws-sdk-rds (1.292.0)
140+
aws-sdk-rds (1.293.0)
141141
aws-sdk-core (~> 3, >= 3.231.0)
142142
aws-sigv4 (~> 1.5)
143143
aws-sdk-s3 (1.199.0)
@@ -456,7 +456,7 @@ GEM
456456
public_suffix (6.0.2)
457457
puma (7.0.2)
458458
nio4r (~> 2.0)
459-
pundit (2.5.0)
459+
pundit (2.5.1)
460460
activesupport (>= 3.0.0)
461461
raabro (1.4.0)
462462
racc (1.8.1)
@@ -477,6 +477,9 @@ GEM
477477
rack (>= 3.0.0)
478478
rack-test (2.2.0)
479479
rack (>= 1.3)
480+
rack_session_access (0.2.0)
481+
builder (>= 2.0.0)
482+
rack (>= 1.0.0)
480483
rackup (2.2.1)
481484
rack (>= 3)
482485
rails (8.0.2.1)
@@ -820,6 +823,7 @@ DEPENDENCIES
820823
pry-rails
821824
puma
822825
pundit
826+
rack_session_access
823827
rails (~> 8.0.2)
824828
rails_semantic_logger
825829
rainbow

app/assets/javascripts/application.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212

1313
import { Autocomplete } from "./components/autocomplete.js";
1414
import { UpgradedRadios as Radios } from "./components/radios.js";
15+
import { Sticky } from "./components/sticky.js";
1516

1617
// Configure Turbo
1718
Turbo.session.drive = false;
@@ -41,6 +42,10 @@ function initialiseComponents() {
4142
createAll(Autocomplete);
4243
}
4344

45+
if (!isInitialised("app-sticky")) {
46+
createAll(Sticky);
47+
}
48+
4449
if (!isInitialised("nhsuk-button")) {
4550
createAll(Button, { preventDoubleClick: true });
4651
}

app/assets/javascripts/components/autocomplete.spec.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ describe("Autocomplete", () => {
7272

7373
// Check that matching options are shown
7474
const visibleOptions = listbox.querySelectorAll("li");
75-
console.log(visibleOptions[0].innerHTML.trim());
7675
expect(visibleOptions.length).toEqual(1);
7776

7877
// Option display hint text
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Component } from "nhsuk-frontend";
2+
3+
/**
4+
* Sticky component
5+
*/
6+
export class Sticky extends Component {
7+
/**
8+
* @param {Element | null} $root - HTML element to use for component
9+
*/
10+
constructor($root) {
11+
super($root);
12+
13+
this.stickyElement = $root;
14+
this.stickyElementStyle = null;
15+
this.stickyElementTop = 0;
16+
17+
this.determineStickyState = this.determineStickyState.bind(this);
18+
this.throttledStickyState = this.throttle(this.determineStickyState, 100);
19+
20+
this.stickyElementStyle = window.getComputedStyle($root);
21+
this.stickyElementTop = parseInt(this.stickyElementStyle.top, 10);
22+
23+
window.addEventListener("scroll", this.throttledStickyState);
24+
25+
this.determineStickyState();
26+
}
27+
28+
/**
29+
* Name for the component used when initialising using data-module attributes
30+
*/
31+
static moduleName = "app-sticky";
32+
33+
/**
34+
* Determine element’s sticky state
35+
*/
36+
determineStickyState() {
37+
const currentTop = this.stickyElement.getBoundingClientRect().top;
38+
39+
this.stickyElement.dataset.stuck = String(
40+
currentTop <= this.stickyElementTop,
41+
);
42+
}
43+
44+
/**
45+
* Throttle
46+
*
47+
* @param {Function} callback - Function to throttle
48+
* @param {number} limit - Minimum time interval (in milliseconds)
49+
* @returns {Function} Throttled function
50+
*/
51+
throttle(callback, limit) {
52+
let inThrottle;
53+
return function () {
54+
const args = arguments;
55+
const context = this;
56+
if (!inThrottle) {
57+
callback.apply(context, args);
58+
inThrottle = true;
59+
setTimeout(() => (inThrottle = false), limit);
60+
}
61+
};
62+
}
63+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { Sticky } from "./sticky.js";
2+
3+
describe("Sticky Component", () => {
4+
let mockElement;
5+
let stickyInstance;
6+
let scrollEventListener;
7+
8+
beforeEach(() => {
9+
document.body.classList.add("nhsuk-frontend-supported");
10+
11+
// Create a mock DOM element
12+
document.body.innerHTML = `<div id="mock-element" style="position: sticky; top: 20px;"></div>`;
13+
mockElement = document.getElementById("mock-element");
14+
15+
// Mock getBoundingClientRect
16+
Object.defineProperty(mockElement, "getBoundingClientRect", {
17+
value: jest.fn(() => ({ top: 0 })),
18+
configurable: true,
19+
});
20+
21+
// Mock window.getComputedStyle
22+
Object.defineProperty(window, "getComputedStyle", {
23+
value: jest.fn(() => ({
24+
top: "20px",
25+
})),
26+
writable: true,
27+
});
28+
29+
// Mock window.addEventListener and capture scroll listener
30+
const originalAddEventListener = window.addEventListener;
31+
window.addEventListener = jest.fn((event, listener) => {
32+
if (event === "scroll") {
33+
scrollEventListener = listener;
34+
}
35+
originalAddEventListener.call(window, event, listener);
36+
});
37+
});
38+
39+
afterEach(() => {
40+
jest.clearAllMocks();
41+
jest.restoreAllMocks();
42+
});
43+
44+
describe("Initialization", () => {
45+
test("should initialize with correct properties", () => {
46+
stickyInstance = new Sticky(mockElement);
47+
48+
expect(stickyInstance.stickyElement).toBe(mockElement);
49+
expect(stickyInstance.stickyElementTop).toBe(20);
50+
expect(window.getComputedStyle).toHaveBeenCalledWith(mockElement);
51+
expect(window.addEventListener).toHaveBeenCalledWith(
52+
"scroll",
53+
expect.any(Function),
54+
);
55+
});
56+
57+
test("should have correct moduleName", () => {
58+
expect(Sticky.moduleName).toBe("app-sticky");
59+
});
60+
61+
test("should call determineStickyState on initialization", () => {
62+
const spy = jest.spyOn(Sticky.prototype, "determineStickyState");
63+
stickyInstance = new Sticky(mockElement);
64+
expect(spy).toHaveBeenCalled();
65+
});
66+
});
67+
68+
describe("determineStickyState method", () => {
69+
beforeEach(() => {
70+
stickyInstance = new Sticky(mockElement);
71+
});
72+
73+
test("should set data-stuck to `true` when element is at or above threshold", () => {
74+
// Element is at the top (currentTop = 0, threshold = 20)
75+
mockElement.getBoundingClientRect.mockReturnValue({ top: 0 });
76+
77+
stickyInstance.determineStickyState();
78+
79+
expect(mockElement.dataset.stuck).toBe("true");
80+
});
81+
82+
test("should set data-stuck to `true` when element above threshold", () => {
83+
mockElement.getBoundingClientRect.mockReturnValue({ top: 10 });
84+
85+
stickyInstance.determineStickyState();
86+
87+
expect(mockElement.dataset.stuck).toBe("true");
88+
});
89+
90+
test("should set data-stuck to `true` when element at threshold", () => {
91+
mockElement.getBoundingClientRect.mockReturnValue({ top: 20 });
92+
93+
stickyInstance.determineStickyState();
94+
95+
expect(mockElement.dataset.stuck).toBe("true");
96+
});
97+
98+
test("should set data-stuck to `false` when element below threshold", () => {
99+
mockElement.getBoundingClientRect.mockReturnValue({ top: 30 });
100+
101+
stickyInstance.determineStickyState();
102+
103+
expect(mockElement.dataset.stuck).toBe("false");
104+
});
105+
});
106+
107+
describe("Scroll behavior", () => {
108+
beforeEach(() => {
109+
jest.useFakeTimers();
110+
111+
// Clear any existing event listeners
112+
window.removeEventListener = jest.fn();
113+
114+
stickyInstance = new Sticky(mockElement);
115+
});
116+
117+
afterEach(() => {
118+
jest.useRealTimers();
119+
});
120+
121+
test("should respond to scroll events", () => {
122+
mockElement.getBoundingClientRect.mockReturnValue({ top: 10 });
123+
124+
// Make sure we have the listener
125+
expect(scrollEventListener).toBeDefined();
126+
127+
// Trigger scroll event
128+
scrollEventListener();
129+
130+
expect(mockElement.dataset.stuck).toBe("true");
131+
});
132+
});
133+
134+
describe("Throttle functionality", () => {
135+
beforeEach(() => {
136+
jest.useFakeTimers();
137+
stickyInstance = new Sticky(mockElement);
138+
});
139+
140+
afterEach(() => {
141+
jest.useRealTimers();
142+
});
143+
144+
test("should throttle function calls", () => {
145+
const mockCallback = jest.fn();
146+
const throttledCallback = stickyInstance.throttle(mockCallback, 100);
147+
148+
// Call multiple times rapidly
149+
throttledCallback();
150+
throttledCallback();
151+
throttledCallback();
152+
153+
// Should only be called once
154+
expect(mockCallback).toHaveBeenCalledTimes(1);
155+
156+
// Fast forward past throttle limit
157+
jest.advanceTimersByTime(100);
158+
159+
// Now should allow another call
160+
throttledCallback();
161+
expect(mockCallback).toHaveBeenCalledTimes(2);
162+
});
163+
164+
test("should preserve context and arguments in throttled function", () => {
165+
const mockCallback = jest.fn();
166+
const throttledCallback = stickyInstance.throttle(mockCallback, 100);
167+
168+
throttledCallback("arg1", "arg2");
169+
170+
expect(mockCallback).toHaveBeenCalledWith("arg1", "arg2");
171+
});
172+
});
173+
174+
describe("Integration with different CSS top values", () => {
175+
test("should handle different top values correctly", () => {
176+
// Mock different computed style top value
177+
window.getComputedStyle.mockReturnValue({ top: "50px" });
178+
179+
stickyInstance = new Sticky(mockElement);
180+
181+
expect(stickyInstance.stickyElementTop).toBe(50);
182+
183+
// Test with element above new threshold
184+
mockElement.getBoundingClientRect.mockReturnValue({ top: 30 });
185+
stickyInstance.determineStickyState();
186+
expect(mockElement.dataset.stuck).toBe("true");
187+
188+
// Test with element below new threshold
189+
mockElement.getBoundingClientRect.mockReturnValue({ top: 60 });
190+
stickyInstance.determineStickyState();
191+
expect(mockElement.dataset.stuck).toBe("false");
192+
});
193+
});
194+
});

app/assets/stylesheets/components/_button.scss

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,24 @@ $_secondary-warning-button-hover-colour: color.change(
77
$alpha: 0.1
88
);
99

10+
.button_to {
11+
display: contents;
12+
}
13+
1014
.nhsuk-button {
11-
.button_to &,
12-
.nhsuk-table & {
15+
.button_to & {
1316
margin-bottom: 0;
1417
}
18+
19+
.nhsuk-button-group .button_to & {
20+
$horizontal-gap: nhsuk-spacing(4);
21+
$vertical-gap: nhsuk-spacing(3);
22+
margin-bottom: $vertical-gap + $_button-shadow-size;
23+
24+
@include nhsuk-media-query($from: tablet) {
25+
margin-right: $horizontal-gap;
26+
}
27+
}
1528
}
1629

1730
.app-button--secondary-warning {

app/assets/stylesheets/components/_card.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@
8181
.app-card--compact {
8282
@include nhsuk-responsive-margin(3, "bottom");
8383

84-
.nhsuk-button-group {
85-
margin-top: nhsuk-spacing(-4);
86-
}
87-
8884
.nhsuk-card__heading {
8985
@include nhsuk-responsive-margin(1, "bottom");
9086
}

0 commit comments

Comments
 (0)