@defra/docusaurus-theme-govuk 0.0.13-alpha → 0.0.15-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +71 -2
- package/package.json +1 -1
- package/src/css/components.scss +175 -20
- package/src/css/prose-scope.scss +13 -13
- package/src/css/theme.scss +13 -3
- package/src/theme/Layout/index.js +60 -13
- package/src/theme/SearchBar/index.js +28 -23
- package/src/theme/SidebarNav/index.js +76 -43
package/index.js
CHANGED
|
@@ -8,6 +8,34 @@ const removeMarkdown = require('remove-markdown');
|
|
|
8
8
|
// handles deduplication automatically (second "Options" → "options-1", etc.).
|
|
9
9
|
const GithubSlugger = require('github-slugger');
|
|
10
10
|
|
|
11
|
+
// Remark plugin: converts `<!-- no-sidebar -->` inline HTML comments inside
|
|
12
|
+
// headings into a `data-no-sidebar` HTML attribute (via mdast hProperties) so
|
|
13
|
+
// the runtime DOM scanner can detect and skip them. MDX/remark-rehype drops
|
|
14
|
+
// raw HTML comment nodes before they reach the browser DOM, so a compile-time
|
|
15
|
+
// transformation is the only reliable way to carry this signal through.
|
|
16
|
+
function remarkNoSidebar() {
|
|
17
|
+
return function (tree) {
|
|
18
|
+
if (!tree || !Array.isArray(tree.children)) return;
|
|
19
|
+
for (const node of tree.children) {
|
|
20
|
+
if (node.type !== 'heading') continue;
|
|
21
|
+
const children = node.children || [];
|
|
22
|
+
const commentIdx = children.findIndex(
|
|
23
|
+
child => child.type === 'html' && child.value?.includes('no-sidebar')
|
|
24
|
+
);
|
|
25
|
+
if (commentIdx === -1) continue;
|
|
26
|
+
// Strip the comment node from the heading children
|
|
27
|
+
node.children = children.filter((_, i) => i !== commentIdx);
|
|
28
|
+
// Trim trailing whitespace in the preceding text node, if any
|
|
29
|
+
const prev = node.children[commentIdx - 1];
|
|
30
|
+
if (prev?.type === 'text') prev.value = prev.value.trimEnd();
|
|
31
|
+
// Attach hProperties so remark-rehype passes data-no-sidebar to the element
|
|
32
|
+
node.data = node.data || {};
|
|
33
|
+
node.data.hProperties = node.data.hProperties || {};
|
|
34
|
+
node.data.hProperties['data-no-sidebar'] = 'true';
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
// Parse markdown content and build a sidebar config from h2/h3 headings.
|
|
12
40
|
// h2 → top-level items; h3 → nested items under the preceding h2.
|
|
13
41
|
// Items include both the display text and an anchor href (basePath + '#' + anchor).
|
|
@@ -32,8 +60,8 @@ function parseHeadingsToSidebar(content, basePath) {
|
|
|
32
60
|
let currentH2 = null;
|
|
33
61
|
|
|
34
62
|
for (const line of lines) {
|
|
35
|
-
const h2 = line.match(/^## (.+)$/);
|
|
36
|
-
const h3 = line.match(/^### (.+)$/);
|
|
63
|
+
const h2 = !line.includes('<!-- no-sidebar -->') && line.match(/^## (.+)$/);
|
|
64
|
+
const h3 = !line.includes('<!-- no-sidebar -->') && line.match(/^### (.+)$/);
|
|
37
65
|
|
|
38
66
|
if (h2) {
|
|
39
67
|
const raw = h2[1].trim();
|
|
@@ -53,6 +81,40 @@ function parseHeadingsToSidebar(content, basePath) {
|
|
|
53
81
|
return items.map(({ _anchor, ...item }) => item);
|
|
54
82
|
}
|
|
55
83
|
|
|
84
|
+
// Mutate the webpack config to inject remarkNoSidebar into the MDX loader.
|
|
85
|
+
// Extracted to keep configureWebpack's cognitive complexity within limits.
|
|
86
|
+
function injectNoSidebarPlugin(config) {
|
|
87
|
+
try {
|
|
88
|
+
injectIntoRules(config.module?.rules || []);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Non-fatal: the build-time parseHeadingsToSidebar still handles static sidebars.
|
|
91
|
+
console.warn('[docusaurus-theme-govuk] Could not inject no-sidebar remark plugin:', e.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normaliseUses(use) {
|
|
96
|
+
if (Array.isArray(use)) return use;
|
|
97
|
+
return use ? [use] : [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function injectIntoUses(uses) {
|
|
101
|
+
for (const use of uses) {
|
|
102
|
+
if (typeof use?.loader === 'string' && use.loader.includes('mdx-loader')) {
|
|
103
|
+
use.options = use.options || {};
|
|
104
|
+
use.options.beforeDefaultRemarkPlugins = use.options.beforeDefaultRemarkPlugins || [];
|
|
105
|
+
use.options.beforeDefaultRemarkPlugins.push(remarkNoSidebar);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function injectIntoRules(rules) {
|
|
111
|
+
for (const rule of rules) {
|
|
112
|
+
if (!rule || typeof rule !== 'object') continue;
|
|
113
|
+
if (Array.isArray(rule.oneOf)) injectIntoRules(rule.oneOf);
|
|
114
|
+
injectIntoUses(normaliseUses(rule.use));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
56
118
|
module.exports = function themeGovuk(context, options) {
|
|
57
119
|
const siteDir = context.siteDir;
|
|
58
120
|
|
|
@@ -144,6 +206,13 @@ module.exports = function themeGovuk(context, options) {
|
|
|
144
206
|
},
|
|
145
207
|
|
|
146
208
|
configureWebpack(config, isServer, utils) {
|
|
209
|
+
// Inject our no-sidebar remark plugin into the MDX loader so that
|
|
210
|
+
// `<!-- no-sidebar -->` comments in headings are converted to
|
|
211
|
+
// data-no-sidebar attributes before the DOM is rendered. We mutate the
|
|
212
|
+
// existing config directly since webpack-merge cannot deep-merge loader
|
|
213
|
+
// options arrays correctly.
|
|
214
|
+
injectNoSidebarPlugin(config);
|
|
215
|
+
|
|
147
216
|
// Helper: resolve a package from the consumer's siteDir.
|
|
148
217
|
// Uses require.resolve with paths so it follows Node's resolution
|
|
149
218
|
// (handles hoisted AND nested node_modules like @docusaurus/core/node_modules/react-router-dom).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/docusaurus-theme-govuk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15-alpha",
|
|
4
4
|
"description": "A Docusaurus theme implementing the GOV.UK Design System for consistent, accessible documentation sites",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"license": "MIT",
|
package/src/css/components.scss
CHANGED
|
@@ -9,9 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
.app-layout-sidebar__nav {
|
|
11
11
|
width: 250px;
|
|
12
|
-
// Padding-left gives room for the nav's active indicator (margin-left: -14px)
|
|
13
|
-
// which would otherwise be clipped by the overflow-y containment.
|
|
14
|
-
padding-left: 15px;
|
|
15
12
|
flex-shrink: 0;
|
|
16
13
|
position: sticky;
|
|
17
14
|
top: 1rem;
|
|
@@ -20,6 +17,16 @@
|
|
|
20
17
|
// Always reserve the scrollbar gutter so expanding subnav items don't
|
|
21
18
|
// cause a horizontal layout shift when the scrollbar appears.
|
|
22
19
|
scrollbar-gutter: stable;
|
|
20
|
+
|
|
21
|
+
// Remove side bar top border
|
|
22
|
+
padding-top: 0;
|
|
23
|
+
border: 0;
|
|
24
|
+
|
|
25
|
+
@media (min-width: 48.125em) {
|
|
26
|
+
overflow: auto !important;
|
|
27
|
+
margin-left: -16px;
|
|
28
|
+
padding-left: 16px;
|
|
29
|
+
}
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
.app-layout-sidebar__content {
|
|
@@ -84,6 +91,126 @@
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
// Navigation menu mobile styling
|
|
95
|
+
.app-layout-sidebar__nav .not-govuk-navigation-menu__list > .not-govuk-navigation-menu__list__item {
|
|
96
|
+
border-bottom: 1px solid #cecece;
|
|
97
|
+
|
|
98
|
+
position: relative;
|
|
99
|
+
padding-top: 5px;
|
|
100
|
+
padding-bottom: 5px;
|
|
101
|
+
margin-bottom: 5px;
|
|
102
|
+
font-size: 1rem;
|
|
103
|
+
padding-left: 0;
|
|
104
|
+
margin-left: 0;
|
|
105
|
+
|
|
106
|
+
@media (min-width: 48.125em) {
|
|
107
|
+
border-bottom: 0;
|
|
108
|
+
|
|
109
|
+
&:after {
|
|
110
|
+
border-bottom: 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.not-govuk-navigation-menu__list__link {
|
|
115
|
+
display: flex;
|
|
116
|
+
padding: 2px 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
&--active {
|
|
120
|
+
border-left: none;
|
|
121
|
+
|
|
122
|
+
@media (min-width: 48.125em) {
|
|
123
|
+
border-left: 4px solid #1d70b8;
|
|
124
|
+
padding-left: 11px;
|
|
125
|
+
margin-left: -15px;
|
|
126
|
+
border-bottom: 0;
|
|
127
|
+
font-weight: bold;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Active indicator on sub-items.
|
|
133
|
+
|
|
134
|
+
.app-layout-sidebar__nav .not-govuk-navigation-menu__list__subitems {
|
|
135
|
+
|
|
136
|
+
.not-govuk-navigation-menu__list__item {
|
|
137
|
+
padding: 5px 0 5px 15px;
|
|
138
|
+
margin-bottom: 5px;
|
|
139
|
+
border-left-width: 0px;
|
|
140
|
+
border-left-style: solid;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.not-govuk-navigation-menu__list__item--active {
|
|
144
|
+
border-left: none;
|
|
145
|
+
|
|
146
|
+
@media (min-width: 48.125em) {
|
|
147
|
+
border-left: 4px solid #1d70b8;
|
|
148
|
+
padding-left: 10px;
|
|
149
|
+
font-weight: bold;
|
|
150
|
+
|
|
151
|
+
.not-govuk-navigation-menu__list__link {
|
|
152
|
+
font-weight: bold;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Remove the '— ' dash from all sub-items.
|
|
158
|
+
.not-govuk-navigation-menu__list__item::before {
|
|
159
|
+
content: none;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
padding-left: 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Sidebar nav section headings and mobile toggle
|
|
166
|
+
// Desktop: headings are non-link spans, visually distinct from link items
|
|
167
|
+
.not-govuk-navigation-menu__list__heading {
|
|
168
|
+
display: block;
|
|
169
|
+
padding: 2px 0;
|
|
170
|
+
color: #0b0c0c;
|
|
171
|
+
font-weight: bold;
|
|
172
|
+
cursor: default;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Mobile: headings become toggle buttons
|
|
176
|
+
.not-govuk-navigation-menu__list__heading-toggle {
|
|
177
|
+
display: inline-flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
padding: 2px 0;
|
|
180
|
+
background: none;
|
|
181
|
+
border: none;
|
|
182
|
+
text-align: left;
|
|
183
|
+
font-family: inherit;
|
|
184
|
+
font-size: inherit;
|
|
185
|
+
font-weight: bold;
|
|
186
|
+
color: #1d70b8;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
|
|
189
|
+
// Chevron indicator on the right, pointing down (collapsed)
|
|
190
|
+
&::after {
|
|
191
|
+
content: '';
|
|
192
|
+
display: inline-block;
|
|
193
|
+
width: 0;
|
|
194
|
+
height: 0;
|
|
195
|
+
border-left: 4px solid transparent;
|
|
196
|
+
border-right: 4px solid transparent;
|
|
197
|
+
border-top: 5px solid currentColor;
|
|
198
|
+
flex-shrink: 0;
|
|
199
|
+
margin-left: 8px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
&[aria-expanded="true"]::after {
|
|
203
|
+
transform: rotate(180deg);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&:focus {
|
|
207
|
+
outline: 3px solid transparent;
|
|
208
|
+
color: #0b0c0c;
|
|
209
|
+
background-color: #fd0;
|
|
210
|
+
box-shadow: 0 -2px #fd0, 0 4px #0b0c0c;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
87
214
|
// Pagination
|
|
88
215
|
.app-pagination__container {
|
|
89
216
|
display: flex;
|
|
@@ -172,6 +299,16 @@
|
|
|
172
299
|
flex-wrap: nowrap;
|
|
173
300
|
}
|
|
174
301
|
|
|
302
|
+
.govuk-header__link--homepage:focus svg g {
|
|
303
|
+
fill: #0b0c0c !important;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Mirror GDS Design System
|
|
307
|
+
.govuk-header__logotype-text {
|
|
308
|
+
font-size: 1.5rem;
|
|
309
|
+
line-height: 1.25;
|
|
310
|
+
}
|
|
311
|
+
|
|
175
312
|
.app-header-search {
|
|
176
313
|
margin-left: auto;
|
|
177
314
|
flex-shrink: 0;
|
|
@@ -185,29 +322,36 @@
|
|
|
185
322
|
width: 300px;
|
|
186
323
|
}
|
|
187
324
|
|
|
188
|
-
// Input field —
|
|
325
|
+
// Input field — white background, GOV.UK dark border, search icon on the left
|
|
189
326
|
.autocomplete__input {
|
|
190
327
|
width: 100%;
|
|
191
|
-
padding
|
|
192
|
-
|
|
328
|
+
// GOV.UK inputs use 5px vertical padding; left padding reserves space for
|
|
329
|
+
// the search icon, right padding keeps text clear of the autocomplete clear button
|
|
330
|
+
padding: 5px 8px 5px 36px;
|
|
331
|
+
// Dark border matches govuk-input — visible on white and meets contrast requirements
|
|
332
|
+
border: 2px solid transparent;
|
|
193
333
|
border-radius: 0;
|
|
194
334
|
background-color: #fff;
|
|
195
335
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230b0c0c' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
|
|
196
336
|
background-repeat: no-repeat;
|
|
197
|
-
background-position:
|
|
198
|
-
background-size:
|
|
337
|
+
background-position: 8px center;
|
|
338
|
+
background-size: 18px 18px;
|
|
199
339
|
color: #0b0c0c;
|
|
200
340
|
font-family: Helvetica, Arial, sans-serif;
|
|
201
|
-
font-size: 0.875rem;
|
|
202
341
|
box-sizing: border-box;
|
|
342
|
+
// Prevent browser-native search-cancel button (replaced by autocomplete's own)
|
|
343
|
+
appearance: none;
|
|
203
344
|
|
|
204
345
|
&::placeholder {
|
|
205
346
|
color: #505a5f;
|
|
206
347
|
}
|
|
207
348
|
|
|
349
|
+
// GOV.UK focus style: yellow outline with dark inner border
|
|
208
350
|
&:focus {
|
|
209
351
|
outline: 3px solid #fd0;
|
|
210
352
|
outline-offset: 0;
|
|
353
|
+
box-shadow: inset 0 0 0 2px #0b0c0c;
|
|
354
|
+
border-color:#0b0c0c;
|
|
211
355
|
}
|
|
212
356
|
}
|
|
213
357
|
|
|
@@ -218,7 +362,6 @@
|
|
|
218
362
|
left: 0;
|
|
219
363
|
right: 0;
|
|
220
364
|
background: #fff;
|
|
221
|
-
border: 2px solid #0b0c0c;
|
|
222
365
|
border-radius: 0;
|
|
223
366
|
margin: 0;
|
|
224
367
|
padding: 0;
|
|
@@ -233,10 +376,13 @@
|
|
|
233
376
|
position: relative;
|
|
234
377
|
}
|
|
235
378
|
|
|
379
|
+
.autocomplete__menu--overlay {
|
|
380
|
+
box-shadow: rgba(11, 12, 12, .256863) 0 5px 5px;
|
|
381
|
+
}
|
|
382
|
+
|
|
236
383
|
.autocomplete__option {
|
|
237
384
|
padding: 8px 12px;
|
|
238
385
|
font-family: Helvetica, Arial, sans-serif;
|
|
239
|
-
font-size: 0.875rem;
|
|
240
386
|
color: #0b0c0c;
|
|
241
387
|
cursor: pointer;
|
|
242
388
|
border-bottom: 1px solid #f3f2f1;
|
|
@@ -264,7 +410,6 @@
|
|
|
264
410
|
|
|
265
411
|
.app-search__context {
|
|
266
412
|
display: block;
|
|
267
|
-
font-size: 0.75rem;
|
|
268
413
|
color: #505a5f;
|
|
269
414
|
margin-top: 2px;
|
|
270
415
|
}
|
|
@@ -272,7 +417,6 @@
|
|
|
272
417
|
.autocomplete__option--no-results {
|
|
273
418
|
padding: 8px 12px;
|
|
274
419
|
font-family: Helvetica, Arial, sans-serif;
|
|
275
|
-
font-size: 0.875rem;
|
|
276
420
|
color: #505a5f;
|
|
277
421
|
}
|
|
278
422
|
|
|
@@ -290,7 +434,23 @@
|
|
|
290
434
|
}
|
|
291
435
|
}
|
|
292
436
|
|
|
293
|
-
@media (max-width:
|
|
437
|
+
@media (max-width: 769px) {
|
|
438
|
+
.govuk-header__container {
|
|
439
|
+
flex-direction: column;
|
|
440
|
+
align-items: flex-start;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.app-header-search {
|
|
444
|
+
margin-left: 0;
|
|
445
|
+
margin-bottom: 10px;
|
|
446
|
+
width: 100%;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.app-header-search div[role="search"],
|
|
450
|
+
.app-header-search .autocomplete__wrapper {
|
|
451
|
+
width: 100%;
|
|
452
|
+
}
|
|
453
|
+
|
|
294
454
|
.app-layout-sidebar {
|
|
295
455
|
flex-direction: column;
|
|
296
456
|
gap: 0;
|
|
@@ -310,9 +470,4 @@
|
|
|
310
470
|
width: 100%;
|
|
311
471
|
min-width: 0;
|
|
312
472
|
}
|
|
313
|
-
|
|
314
|
-
.app-header-search .autocomplete__wrapper {
|
|
315
|
-
width: 180px;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
473
|
+
}
|
package/src/css/prose-scope.scss
CHANGED
|
@@ -4,54 +4,54 @@
|
|
|
4
4
|
|
|
5
5
|
.app-prose-scope {
|
|
6
6
|
// Headings
|
|
7
|
-
h1 {
|
|
7
|
+
h1:not(.app-no-prose *) {
|
|
8
8
|
@extend %govuk-heading-xl;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
h2 {
|
|
11
|
+
h2:not(.app-no-prose *) {
|
|
12
12
|
@extend %govuk-heading-l;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
h3 {
|
|
15
|
+
h3:not(.app-no-prose *) {
|
|
16
16
|
@extend %govuk-heading-m;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
h4 {
|
|
19
|
+
h4:not(.app-no-prose *) {
|
|
20
20
|
@extend %govuk-heading-s;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Body text
|
|
24
|
-
p {
|
|
24
|
+
p:not(.app-no-prose *) {
|
|
25
25
|
@extend %govuk-body-m;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// Bold text
|
|
29
|
-
strong,
|
|
30
|
-
b {
|
|
29
|
+
strong:not(.app-no-prose *),
|
|
30
|
+
b:not(.app-no-prose *) {
|
|
31
31
|
font-weight: 700;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Lists
|
|
35
|
-
ul,
|
|
36
|
-
ol {
|
|
35
|
+
ul:not(.app-no-prose *),
|
|
36
|
+
ol:not(.app-no-prose *) {
|
|
37
37
|
@extend %govuk-list;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
ol {
|
|
40
|
+
ol:not(.app-no-prose *) {
|
|
41
41
|
@extend %govuk-list--number;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
ul {
|
|
44
|
+
ul:not(.app-no-prose *) {
|
|
45
45
|
@extend %govuk-list--bullet;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Links
|
|
49
|
-
a {
|
|
49
|
+
a:not(.app-no-prose *) {
|
|
50
50
|
@extend %govuk-link;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// Section breaks
|
|
54
|
-
hr {
|
|
54
|
+
hr:not(.app-no-prose *) {
|
|
55
55
|
@extend %govuk-section-break;
|
|
56
56
|
@extend %govuk-section-break--visible;
|
|
57
57
|
@extend %govuk-section-break--xl;
|
package/src/css/theme.scss
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
/* Import component styles */
|
|
8
8
|
@import './components.scss';
|
|
9
9
|
|
|
10
|
+
/* GOV.UK grid system */
|
|
11
|
+
@import 'govuk-frontend/dist/govuk/objects/grid';
|
|
12
|
+
|
|
10
13
|
/*
|
|
11
14
|
* Set the default font on the body so that ALL elements
|
|
12
15
|
* (header, footer, service-nav, etc.) inherit the same stack.
|
|
@@ -23,13 +26,20 @@ body {
|
|
|
23
26
|
* and our layout, breaking GOV.UK Frontend's flex sticky-footer chain.
|
|
24
27
|
* Rather than patching the chain, own the full-height context here.
|
|
25
28
|
*/
|
|
26
|
-
.govuk-
|
|
29
|
+
.govuk-template__body-inner {
|
|
30
|
+
background-color: #ffffff;
|
|
27
31
|
display: flex;
|
|
28
32
|
flex-direction: column;
|
|
29
33
|
min-height: 100vh;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
.govuk-
|
|
36
|
+
.govuk-template__body-inner > .govuk-width-container {
|
|
33
37
|
flex: 1 0 auto;
|
|
34
|
-
width: 100
|
|
38
|
+
width: calc(100% - 30px);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (min-width: 40.0625em) {
|
|
42
|
+
.govuk-template__body-inner > .govuk-width-container {
|
|
43
|
+
width: calc(100% - 60px);
|
|
44
|
+
}
|
|
35
45
|
}
|
|
@@ -98,6 +98,41 @@ function getActiveSection(pathname, navigation) {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Initialise the govuk-frontend ServiceNavigation JS for the mobile menu toggle.
|
|
103
|
+
* The @not-govuk React component renders the toggle button with `hidden` by default
|
|
104
|
+
* (progressive enhancement); this hook removes `hidden` on mobile and wires up the
|
|
105
|
+
* click handler exactly as govuk-frontend expects.
|
|
106
|
+
*/
|
|
107
|
+
function useServiceNavigationToggle() {
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
import('govuk-frontend').then(({ ServiceNavigation }) => {
|
|
111
|
+
if (cancelled) return;
|
|
112
|
+
// govuk-frontend checks for this class before initialising any component.
|
|
113
|
+
// Normally added by an inline <script> snippet in the HTML template; we
|
|
114
|
+
// add it here since Docusaurus doesn't use that template pattern.
|
|
115
|
+
document.body.classList.add('govuk-frontend-supported');
|
|
116
|
+
document.querySelectorAll('[data-module="govuk-service_navigation"]').forEach((el) => {
|
|
117
|
+
try {
|
|
118
|
+
const instance = new ServiceNavigation(el);
|
|
119
|
+
el._govukServiceNav = instance;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// May fail if the CSS custom property --govuk-breakpoint-tablet isn't set.
|
|
122
|
+
// Fall back to injecting it directly so the toggle still works.
|
|
123
|
+
if (e.message?.includes('CSS custom property')) {
|
|
124
|
+
document.documentElement.style.setProperty('--govuk-breakpoint-tablet', '40.0625em');
|
|
125
|
+
el._govukServiceNav = new ServiceNavigation(el);
|
|
126
|
+
} else {
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return () => { cancelled = true; };
|
|
133
|
+
}, []);
|
|
134
|
+
}
|
|
135
|
+
|
|
101
136
|
export default function Layout(props) {
|
|
102
137
|
const location = useLocation();
|
|
103
138
|
const {siteConfig} = useDocusaurusContext();
|
|
@@ -109,6 +144,8 @@ export default function Layout(props) {
|
|
|
109
144
|
noFooter,
|
|
110
145
|
} = props;
|
|
111
146
|
|
|
147
|
+
useServiceNavigationToggle();
|
|
148
|
+
|
|
112
149
|
const navigation = govukConfig.navigation || [];
|
|
113
150
|
const header = govukConfig.header || {};
|
|
114
151
|
const phaseBanner = govukConfig.phaseBanner;
|
|
@@ -170,6 +207,10 @@ export default function Layout(props) {
|
|
|
170
207
|
for (const el of headings) {
|
|
171
208
|
const id = el.id;
|
|
172
209
|
if (!id) continue;
|
|
210
|
+
// Skip headings marked with <!-- no-sidebar --> in the source.
|
|
211
|
+
// The remarkNoSidebar plugin (index.js) converts the comment to a
|
|
212
|
+
// data-no-sidebar attribute at compile time so it survives MDX rendering.
|
|
213
|
+
if (el.dataset?.noSidebar) continue;
|
|
173
214
|
const text = el.textContent?.trim() ?? '';
|
|
174
215
|
const href = withBase(`${pathname}#${id}`);
|
|
175
216
|
if (el.tagName === 'H2') {
|
|
@@ -196,14 +237,14 @@ export default function Layout(props) {
|
|
|
196
237
|
return (
|
|
197
238
|
<LayoutProvider>
|
|
198
239
|
<Head>
|
|
199
|
-
<html lang="en-GB" className="govuk-template" />
|
|
200
|
-
<body className=
|
|
240
|
+
<html lang="en-GB" className="govuk-template govuk-template--rebranded" />
|
|
241
|
+
<body className={isHomepage ? 'govuk-template__body app-homepage' : 'govuk-template__body'} />
|
|
201
242
|
<meta name="theme-color" content="#0b0c0c" />
|
|
202
243
|
{title && <title>{title}</title>}
|
|
203
244
|
{description && <meta name="description" content={description} />}
|
|
204
245
|
</Head>
|
|
205
246
|
|
|
206
|
-
<div className="govuk-
|
|
247
|
+
<div className="govuk-template__body-inner">
|
|
207
248
|
<AnnouncementBar />
|
|
208
249
|
|
|
209
250
|
{/* Hidden navbar element for Docusaurus hooks */}
|
|
@@ -266,8 +307,8 @@ export default function Layout(props) {
|
|
|
266
307
|
</div>
|
|
267
308
|
)}
|
|
268
309
|
|
|
269
|
-
|
|
270
|
-
|
|
310
|
+
{phaseBanner && (
|
|
311
|
+
<div className="govuk-width-container">
|
|
271
312
|
<PhaseBanner phase={phaseBanner.phase}>
|
|
272
313
|
{phaseBanner.text}{' '}
|
|
273
314
|
{phaseBanner.feedbackHref && (
|
|
@@ -276,10 +317,12 @@ export default function Layout(props) {
|
|
|
276
317
|
</a>
|
|
277
318
|
)}
|
|
278
319
|
</PhaseBanner>
|
|
279
|
-
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
280
322
|
|
|
281
|
-
|
|
282
|
-
|
|
323
|
+
<main id="main-content" className={isHomepage ? undefined : 'govuk-main-wrapper'}>
|
|
324
|
+
{sidebarItems ? (
|
|
325
|
+
<div className="govuk-width-container">
|
|
283
326
|
<div className="app-layout-sidebar">
|
|
284
327
|
<aside className="app-layout-sidebar__nav">
|
|
285
328
|
<SidebarNav items={sidebarItems} />
|
|
@@ -288,11 +331,15 @@ export default function Layout(props) {
|
|
|
288
331
|
{children}
|
|
289
332
|
</div>
|
|
290
333
|
</div>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
334
|
+
</div>
|
|
335
|
+
) : isHomepage ? (
|
|
336
|
+
children
|
|
337
|
+
) : (
|
|
338
|
+
<div className="govuk-width-container">
|
|
339
|
+
{children}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</main>
|
|
296
343
|
|
|
297
344
|
{!noFooter && (
|
|
298
345
|
<Footer rebrand meta={footer.meta} />
|
|
@@ -170,29 +170,34 @@ function SearchBarInner() {
|
|
|
170
170
|
const Autocomplete = require('accessible-autocomplete/react').default;
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
173
|
+
<div role="search">
|
|
174
|
+
<label htmlFor="govuk-search" className="govuk-visually-hidden">
|
|
175
|
+
Search
|
|
176
|
+
</label>
|
|
177
|
+
<Autocomplete
|
|
178
|
+
id="govuk-search"
|
|
179
|
+
source={source}
|
|
180
|
+
templates={{
|
|
181
|
+
inputValue: (result) => (result ? result._label : ''),
|
|
182
|
+
suggestion: (result) => {
|
|
183
|
+
if (!result) return '';
|
|
184
|
+
const title = escapeHtml(result._label);
|
|
185
|
+
const context = result._context
|
|
186
|
+
? `<span class="app-search__context">${escapeHtml(result._context)}</span>`
|
|
187
|
+
: '';
|
|
188
|
+
return `<span class="app-search__title">${title}</span>${context}`;
|
|
189
|
+
},
|
|
190
|
+
}}
|
|
191
|
+
displayMenu="overlay"
|
|
192
|
+
minLength={2}
|
|
193
|
+
placeholder="Search"
|
|
194
|
+
onConfirm={onConfirm}
|
|
195
|
+
tNoResults={() => 'No results found'}
|
|
196
|
+
tAssistiveHint={() =>
|
|
197
|
+
'When autocomplete results are available use up and down arrows to review and enter to select.'
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
196
201
|
);
|
|
197
202
|
}
|
|
198
203
|
|
|
@@ -4,30 +4,36 @@ import { useLocation } from '@docusaurus/router';
|
|
|
4
4
|
/**
|
|
5
5
|
* Hash- and pathname-aware sidebar navigation.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Desktop (≥770px): section headings are non-interactive spans; all groups
|
|
8
|
+
* are permanently expanded; active items get a left-border marker.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - Anchor hrefs (e.g. /api#constructor): active when pathname AND hash both match
|
|
12
|
-
* - Page hrefs (e.g. /building-a-plugin): active when pathname matches
|
|
13
|
-
* - Groups expand when the group itself or any child is active
|
|
10
|
+
* Mobile (<770px): section headings become toggle buttons; groups collapse
|
|
11
|
+
* and expand. Active groups start expanded. Active marker is hidden.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
* null = not yet hydrated → expand all groups (SSR safe)
|
|
17
|
-
* str = JS loaded (empty string means no hash present)
|
|
13
|
+
* SSR / no-JS: renders in desktop mode (all groups expanded).
|
|
18
14
|
*/
|
|
19
15
|
export default function SidebarNav({ items }) {
|
|
20
16
|
const [hash, setHash] = useState(null);
|
|
17
|
+
// null = not yet hydrated; false = desktop; true = mobile
|
|
18
|
+
const [isMobile, setIsMobile] = useState(null);
|
|
19
|
+
const [openGroups, setOpenGroups] = useState(new Set());
|
|
21
20
|
const location = useLocation();
|
|
22
21
|
|
|
23
22
|
useEffect(() => {
|
|
24
|
-
const update = () => setHash(
|
|
23
|
+
const update = () => setHash(globalThis.location.hash.slice(1));
|
|
25
24
|
update();
|
|
26
|
-
|
|
27
|
-
return () =>
|
|
25
|
+
globalThis.addEventListener('hashchange', update);
|
|
26
|
+
return () => globalThis.removeEventListener('hashchange', update);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const mql = globalThis.matchMedia('(max-width: 769px)');
|
|
31
|
+
const update = () => setIsMobile(mql.matches);
|
|
32
|
+
update();
|
|
33
|
+
mql.addEventListener('change', update);
|
|
34
|
+
return () => mql.removeEventListener('change', update);
|
|
28
35
|
}, []);
|
|
29
36
|
|
|
30
|
-
// Split an href into its path and anchor components.
|
|
31
37
|
function parseHref(href) {
|
|
32
38
|
if (!href) return { path: '', anchor: '' };
|
|
33
39
|
const idx = href.indexOf('#');
|
|
@@ -35,9 +41,6 @@ export default function SidebarNav({ items }) {
|
|
|
35
41
|
return { path: href.slice(0, idx) || '/', anchor: href.slice(idx + 1) };
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
// Return true if the given href matches the current browser location.
|
|
39
|
-
// Anchor hrefs: both pathname and hash must match.
|
|
40
|
-
// Page hrefs: pathname match is sufficient (exact or child path).
|
|
41
44
|
function isActive(href) {
|
|
42
45
|
const { path, anchor } = parseHref(href);
|
|
43
46
|
if (anchor) {
|
|
@@ -49,44 +52,75 @@ export default function SidebarNav({ items }) {
|
|
|
49
52
|
);
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
// When switching to mobile, open any groups that contain the active page.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isMobile || hash === null) return;
|
|
58
|
+
const active = new Set();
|
|
59
|
+
(items || []).forEach((item, i) => {
|
|
60
|
+
if (!Array.isArray(item.items) || !item.items.length) return;
|
|
61
|
+
if (isActive(item.href) || item.items.some(sub => isActive(sub.href))) {
|
|
62
|
+
active.add(i);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
setOpenGroups(active);
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [isMobile, location.pathname, hash]);
|
|
68
|
+
|
|
69
|
+
function toggleGroup(i) {
|
|
70
|
+
setOpenGroups(prev => {
|
|
71
|
+
const next = new Set(prev);
|
|
72
|
+
if (next.has(i)) next.delete(i);
|
|
73
|
+
else next.add(i);
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
const cls = 'not-govuk-navigation-menu';
|
|
53
79
|
const lCls = `${cls}__list`;
|
|
80
|
+
const itemCls = `${lCls}__item`;
|
|
81
|
+
const activeCls = `${itemCls}--active`;
|
|
82
|
+
|
|
83
|
+
function renderHeading(item, i, sublistId) {
|
|
84
|
+
if (isMobile) {
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={`${lCls}__heading-toggle`}
|
|
89
|
+
aria-expanded={openGroups.has(i)}
|
|
90
|
+
aria-controls={sublistId}
|
|
91
|
+
onClick={() => toggleGroup(i)}
|
|
92
|
+
>
|
|
93
|
+
{item.text}
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return <span className={`${lCls}__heading`}>{item.text}</span>;
|
|
98
|
+
}
|
|
54
99
|
|
|
55
100
|
return (
|
|
56
101
|
<nav className={cls}>
|
|
57
102
|
<ul className={lCls}>
|
|
58
|
-
{items.map((item, i) => {
|
|
103
|
+
{(items || []).map((item, i) => {
|
|
59
104
|
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
|
60
|
-
|
|
61
|
-
// Pre-hydration (hash === null): expand everything so content is
|
|
62
|
-
// accessible without JS. After hydration: expand only if this group
|
|
63
|
-
// or one of its children is the active location.
|
|
64
|
-
const expanded =
|
|
65
|
-
hasChildren &&
|
|
66
|
-
(hash === null || isActive(item.href) || item.items.some(sub => isActive(sub.href)));
|
|
67
|
-
|
|
68
105
|
const active = hash !== null && isActive(item.href);
|
|
106
|
+
const showChildren =
|
|
107
|
+
hasChildren && (isMobile === null || !isMobile || openGroups.has(i));
|
|
108
|
+
const liCls = active && !hasChildren ? `${itemCls} ${activeCls}` : itemCls;
|
|
109
|
+
const sublistId = hasChildren ? `sidebar-group-${i}` : undefined;
|
|
69
110
|
|
|
70
111
|
return (
|
|
71
|
-
<li
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
{expanded && (
|
|
79
|
-
<ul className={`${lCls}__subitems`}>
|
|
80
|
-
{item.items.map((sub, j) => {
|
|
112
|
+
<li key={item.href || item.text} className={liCls}>
|
|
113
|
+
{hasChildren ? renderHeading(item, i, sublistId) : (
|
|
114
|
+
<a href={item.href} className={`${lCls}__link`}>{item.text}</a>
|
|
115
|
+
)}
|
|
116
|
+
{showChildren && (
|
|
117
|
+
<ul id={sublistId} className={`${lCls}__subitems`}>
|
|
118
|
+
{item.items.map((sub) => {
|
|
81
119
|
const subActive = hash !== null && isActive(sub.href);
|
|
120
|
+
const subCls = subActive ? `${itemCls} ${activeCls}` : itemCls;
|
|
82
121
|
return (
|
|
83
|
-
<li
|
|
84
|
-
|
|
85
|
-
className={`${lCls}__item${subActive ? ` ${lCls}__item--active` : ''}`}
|
|
86
|
-
>
|
|
87
|
-
<a href={sub.href} className={`${lCls}__link`}>
|
|
88
|
-
{sub.text}
|
|
89
|
-
</a>
|
|
122
|
+
<li key={sub.href || sub.text} className={subCls}>
|
|
123
|
+
<a href={sub.href} className={`${lCls}__link`}>{sub.text}</a>
|
|
90
124
|
</li>
|
|
91
125
|
);
|
|
92
126
|
})}
|
|
@@ -99,4 +133,3 @@ export default function SidebarNav({ items }) {
|
|
|
99
133
|
</nav>
|
|
100
134
|
);
|
|
101
135
|
}
|
|
102
|
-
|