Skip to content

Commit 03c8981

Browse files
Merge pull request #2446 from nhsuk/examples-code
Port Nunjucks syntax highlighting from GOV.UK Design System
2 parents 24b32dd + 6943a4c commit 03c8981

33 files changed

+1201
-641
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# NHS digital service manual Changelog
22

3+
## Unreleased
4+
5+
:wrench: **Maintenance**
6+
7+
- Port Nunjucks syntax highlighting from GOV.UK Design System
8+
- Support focus and keyboard left/right scrolling on code blocks
9+
310
## 8.7.2 - 2 March 2026
411

512
:wrench: **Maintenance**

app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ env.addGlobal('getMacroOptions', macroOptions.getMacroOptions)
136136
env.addGlobal('getMacroPageName', macroOptions.getMacroPageName)
137137
env.addFilter('highlight', filters.highlight)
138138
env.addFilter('kebabCase', filters.kebabCase)
139-
env.addFilter('markdown', filters.markdown)
140139
env.addFilter('slugify', filters.slugify)
140+
env.addFilter('unindent', filters.unindent)
141141

142142
// Render standalone design examples
143143
app.get('/design-example/:group/:item/:type', (req, res, next) => {

app/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = {
2626
// Nunjucks search paths
2727
nunjucksPaths: [
2828
join(sourcePath, 'views'),
29+
join(sourcePath, 'views/components'),
2930
join(modulePath, 'nhsuk-frontend/dist/nhsuk/components'),
3031
join(modulePath, 'nhsuk-frontend/dist/nhsuk/macros'),
3132
join(modulePath, 'nhsuk-frontend/dist/nhsuk'),
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { mockResizeObserver } from 'jsdom-testing-mocks'
2+
import { createAll } from 'nhsuk-frontend'
3+
4+
import { Code } from './code.mjs'
5+
6+
const resizeObserverMock = mockResizeObserver()
7+
8+
describe('NHS digital service manual code', () => {
9+
/** @type {HTMLElement} */
10+
let $container
11+
12+
beforeEach(() => {
13+
document.body.innerHTML = `
14+
<pre class="app-code" data-module="app-code">
15+
<code class="app-code__container hljs"></code>
16+
</pre>
17+
`
18+
19+
const [code] = createAll(Code)
20+
$container = code.$container
21+
22+
jest.spyOn($container, 'clientWidth', 'get').mockReturnValue(1024)
23+
jest.spyOn($container, 'clientHeight', 'get').mockReturnValue(768)
24+
25+
jest.spyOn($container, 'scrollWidth', 'get').mockReturnValue(500)
26+
jest.spyOn($container, 'scrollHeight', 'get').mockReturnValue(500)
27+
})
28+
29+
describe('Keyboard focus', () => {
30+
it('should not add tabindex by default', async () => {
31+
expect($container).not.toHaveAttribute('tabindex')
32+
})
33+
34+
it('should add tabindex by code overflows container', async () => {
35+
jest.spyOn($container, 'clientWidth', 'get').mockReturnValue(320)
36+
jest.spyOn($container, 'clientHeight', 'get').mockReturnValue(240)
37+
38+
resizeObserverMock.mockElementSize($container, {
39+
contentBoxSize: { inlineSize: 500, blockSize: 500 }
40+
})
41+
42+
// Trigger resize
43+
resizeObserverMock.resize()
44+
45+
expect($container).toHaveAttribute('tabindex')
46+
})
47+
})
48+
})

app/javascripts/code.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Component, ElementError } from 'nhsuk-frontend'
2+
3+
/**
4+
* Code with scroll container component
5+
*
6+
* @augments {Component<HTMLPreElement>}
7+
*/
8+
export class Code extends Component {
9+
static elementType = HTMLPreElement
10+
11+
/**
12+
* @param {Element | null} $root - HTML element to use for component
13+
*/
14+
constructor($root) {
15+
super($root)
16+
17+
const $container = this.$root.querySelector('.app-code__container')
18+
if (!($container instanceof HTMLElement)) {
19+
throw new ElementError({
20+
component: Code,
21+
element: $container,
22+
identifier: 'Code container (`.app-code__container`)'
23+
})
24+
}
25+
26+
this.$container = $container
27+
this.handleEnableFocus = this.enableFocus.bind(this)
28+
29+
// ResizeObserver isn't supported by Safari < 13.1 so we need to fall back
30+
// to window resize, checking the container width has actually changed
31+
if ('ResizeObserver' in window) {
32+
this.resizeObserver = new window.ResizeObserver(this.handleEnableFocus)
33+
this.resizeObserver.observe(this.$container)
34+
} else {
35+
window.addEventListener('resize', this.handleEnableFocus)
36+
}
37+
}
38+
39+
/**
40+
* Enable container focus
41+
*/
42+
enableFocus() {
43+
if (this.isOverflowing()) {
44+
this.$container.setAttribute('tabindex', '0')
45+
} else {
46+
this.$container.removeAttribute('tabindex')
47+
}
48+
}
49+
50+
/**
51+
* Checks if the container scrollable width or height is greater than the
52+
* width or height the container is being rendered at
53+
*/
54+
isOverflowing() {
55+
return (
56+
this.$container.scrollHeight > this.$container.clientHeight ||
57+
this.$container.scrollWidth > this.$container.clientWidth
58+
)
59+
}
60+
61+
static moduleName = 'app-code'
62+
}

app/javascripts/main.jsdom.test.mjs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@ import { initAll } from 'nhsuk-frontend'
33
jest.mock('nhsuk-frontend')
44

55
describe('NHS digital service manual', () => {
6-
beforeEach(() => {
7-
document.documentElement.innerHTML = ''
8-
document.body.classList.add('nhsuk-frontend-supported')
9-
})
10-
116
describe('NHS.UK frontend', () => {
127
it('should init all components', async () => {
138
await import('./main.mjs')

app/javascripts/main.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createAll, initAll } from 'nhsuk-frontend'
33

44
// NHS digital service manual components
55
import initAccessibleAutocomplete from './accessible-autocomplete.mjs'
6+
import { Code } from './code.mjs'
67
import { DesignExample } from './design-example.mjs'
78
import { inputValue, onConfirm, source, suggestion } from './search.mjs'
89

@@ -22,5 +23,8 @@ initAccessibleAutocomplete({
2223
}
2324
})
2425

26+
// Code highlight blocks
27+
createAll(Code)
28+
2529
// Design examples
2630
createAll(DesignExample)

app/stylesheets/app/_app-reading-width.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@
4040
.app-example,
4141
.nhsuk-table,
4242
.app-colour-list,
43-
.app-example--image,
44-
.app-pre {
43+
.app-example--image {
4544
max-width: 44em;
4645
}
4746

app/stylesheets/app/_code-block.scss

Lines changed: 2 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,13 @@
22

33
@use "../vendor/nhsuk-frontend" as *;
44

5-
/// Code showing deleted and inserted lines:
6-
7-
code del {
8-
background-color: nhsuk-tint(nhsuk-colour("red"), 85%);
9-
color: nhsuk-shade(nhsuk-colour("red"), 10%);
10-
text-decoration: none;
11-
}
12-
13-
code ins {
14-
background-color: nhsuk-tint(nhsuk-colour("green"), 85%);
15-
color: nhsuk-shade(nhsuk-colour("green"), 8%);
16-
text-decoration: none;
17-
}
18-
195
////
206
/// Code block highlighting
217
////
228

23-
code[class*="language-"],
24-
pre[class*="language-"] {
9+
code[class*="language-"] {
2510
color: #000000;
2611
background: none;
27-
overflow: auto;
2812
text-align: left;
2913
white-space: pre;
3014
word-spacing: normal;
@@ -41,116 +25,30 @@ pre[class*="language-"] {
4125
hyphens: none;
4226
}
4327

44-
pre[class*="language-"]::-moz-selection,
45-
pre[class*="language-"] ::-moz-selection,
4628
code[class*="language-"]::-moz-selection,
4729
code[class*="language-"] ::-moz-selection {
4830
text-shadow: none;
4931
background: #b3d4fc;
5032
}
5133

52-
pre[class*="language-"]::selection,
53-
pre[class*="language-"] ::selection,
5434
code[class*="language-"]::selection,
5535
code[class*="language-"] ::selection {
5636
text-shadow: none;
5737
background: #b3d4fc;
5838
}
5939

6040
@media print {
61-
code[class*="language-"],
62-
pre[class*="language-"] {
41+
code[class*="language-"] {
6342
text-shadow: none;
6443
}
6544
}
6645

67-
pre[class*="language-"] {
68-
padding: 1em;
69-
overflow: auto;
70-
71-
@include nhsuk-responsive-margin(4, "bottom");
72-
}
73-
7446
pre code[class*="language-"] {
7547
display: block;
76-
background: #ffffff;
7748
}
7849

7950
:not(pre) > code[class*="language-"] {
8051
padding: 0.1em;
8152
border-radius: 0.3em;
8253
white-space: normal;
8354
}
84-
85-
.token.comment,
86-
.token.prolog,
87-
.token.doctype,
88-
.token.cdata {
89-
color: #708090;
90-
}
91-
92-
.token.punctuation {
93-
color: #999999;
94-
}
95-
96-
.namespace {
97-
opacity: 0.7;
98-
}
99-
100-
.token.property,
101-
.token.tag,
102-
.token.boolean,
103-
.token.number,
104-
.token.constant,
105-
.token.symbol,
106-
.token.deleted {
107-
color: #990055;
108-
}
109-
110-
.token.selector,
111-
.token.attr-name,
112-
.token.string,
113-
.token.char,
114-
.token.builtin,
115-
.token.inserted {
116-
color: #669900;
117-
}
118-
119-
.token.operator,
120-
.token.entity,
121-
.token.url,
122-
.language-css .token.string,
123-
.style .token.string {
124-
color: #9a6e3a;
125-
background: hsla(0deg, 0%, 100%, 0.5);
126-
}
127-
128-
.token.atrule,
129-
.token.attr-value,
130-
.token.keyword {
131-
color: #0077aa;
132-
}
133-
134-
.token.function,
135-
.token.class-name {
136-
color: #dd4a68;
137-
}
138-
139-
.token.regex,
140-
.token.important,
141-
.token.variable {
142-
color: #ee9900;
143-
}
144-
145-
.token.important,
146-
.token.bold {
147-
font-weight: bold;
148-
}
149-
150-
.token.italic {
151-
font-style: italic;
152-
}
153-
154-
.token.entity {
155-
cursor: help;
156-
}

app/stylesheets/app/_code-highlight.scss

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ p code {
2525
}
2626

2727
// Highlight code in a block
28-
.app-pre {
28+
.app-code,
29+
.app-code__container {
30+
font-family: $app-code-font;
31+
}
32+
33+
.app-code {
2934
background: nhsuk-colour("white");
3035
border: 1px solid $nhsuk-border-colour;
31-
margin-top: 0;
32-
overflow: auto;
33-
padding: nhsuk-spacing(3);
36+
margin: 0;
3437
position: relative;
38+
font-size: inherit;
3539

3640
@include nhsuk-responsive-margin(4, "bottom");
3741

@@ -44,29 +48,14 @@ p code {
4448
}
4549
}
4650

47-
.hljs {
48-
background-color: nhsuk-colour("white");
49-
color: nhsuk-colour("black");
50-
display: block;
51-
overflow-x: scroll;
52-
padding: 0;
53-
}
54-
55-
.hljs.css .hljs-selector-class,
56-
.hljs.css .hljs-selector-tag,
57-
.hljs.css .hljs-keyword {
58-
color: nhsuk-colour("purple");
59-
}
60-
61-
.app-tabs__container pre {
62-
-ms-flex-item-align: stretch;
63-
-ms-grid-row-align: stretch;
64-
align-self: stretch;
65-
background-color: nhsuk-colour("white");
66-
padding: 0;
67-
}
51+
.app-code__container {
52+
padding: nhsuk-spacing(3);
53+
overflow-x: auto;
54+
line-height: 1.5;
55+
border: $nhsuk-focus-width solid transparent;
6856

69-
.app-tabs__container pre,
70-
.app-tabs__container code {
71-
font-family: $app-code-font;
57+
&:focus {
58+
border: $nhsuk-focus-width solid $nhsuk-focus-text-colour;
59+
outline: $nhsuk-focus-width solid $nhsuk-focus-colour;
60+
}
7261
}

0 commit comments

Comments
 (0)