@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 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.13-alpha",
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",
@@ -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 — always white background with search icon on the left
325
+ // Input field — white background, GOV.UK dark border, search icon on the left
189
326
  .autocomplete__input {
190
327
  width: 100%;
191
- padding: 4px 8px 4px 40px;
192
- border: 2px solid #fff;
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: 10px center;
198
- background-size: 20px 20px;
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: 767px) {
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
+ }
@@ -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;
@@ -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-template--rebranded {
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-template--rebranded > .govuk-width-container {
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="govuk-template__body" />
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-template--rebranded">
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
- <div className="govuk-width-container">
270
- {phaseBanner && (
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
- <main id="main-content" className="govuk-main-wrapper">
282
- {sidebarItems ? (
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
- children
293
- )}
294
- </main>
295
- </div>
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
- <Autocomplete
174
- id="govuk-search"
175
- source={source}
176
- templates={{
177
- inputValue: (result) => (result ? result._label : ''),
178
- suggestion: (result) => {
179
- if (!result) return '';
180
- const title = escapeHtml(result._label);
181
- const context = result._context
182
- ? `<span class="app-search__context">${escapeHtml(result._context)}</span>`
183
- : '';
184
- return `<span class="app-search__title">${title}</span>${context}`;
185
- },
186
- }}
187
- displayMenu="overlay"
188
- minLength={2}
189
- placeholder="Search"
190
- onConfirm={onConfirm}
191
- tNoResults={() => 'No results found'}
192
- tAssistiveHint={() =>
193
- 'When autocomplete results are available use up and down arrows to review and enter to select.'
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
- * Works for both anchor-based (auto-generated) and page-based (manual) sidebars.
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
- * SSR / no-JS: all groups are rendered expanded so content is accessible.
10
- * CSR after hydration:
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
- * State sentinel:
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(window.location.hash.slice(1));
23
+ const update = () => setHash(globalThis.location.hash.slice(1));
25
24
  update();
26
- window.addEventListener('hashchange', update);
27
- return () => window.removeEventListener('hashchange', update);
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
- key={i}
73
- className={`${lCls}__item${active ? ` ${lCls}__item--active` : ''}`}
74
- >
75
- <a href={item.href} className={`${lCls}__link`}>
76
- {item.text}
77
- </a>
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
- key={j}
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
-