@defra/docusaurus-theme-govuk 0.0.12-alpha → 0.0.14-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.12-alpha",
3
+ "version": "0.0.14-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;
@@ -84,6 +81,120 @@
84
81
  }
85
82
  }
86
83
 
84
+ // Remove side bar top border
85
+ .app-layout-sidebar__nav {
86
+ overflow: auto;
87
+ padding-top: 0;
88
+ border: 0;
89
+ }
90
+
91
+ // Navigation menu mobile styling
92
+ .app-layout-sidebar__nav .not-govuk-navigation-menu__list > .not-govuk-navigation-menu__list__item {
93
+ border-bottom: 1px solid #cecece;
94
+ padding-top: 5px;
95
+ padding-bottom: 5px;
96
+ margin-bottom: 5px;
97
+ font-size: 1rem;
98
+
99
+ @media (min-width: 40.0625em) {
100
+ border-bottom: 0;
101
+ }
102
+
103
+ .not-govuk-navigation-menu__list__link {
104
+ display: flex;
105
+ padding: 2px 0;
106
+ }
107
+
108
+ &--active {
109
+ border-left: 4px solid #1d70b8;
110
+ padding-left: 11px;
111
+ font-weight: bold;
112
+ }
113
+ }
114
+
115
+ // Active indicator on sub-items.
116
+ // __subitems has padding-left: 15px; margin-left: -15px shifts the item back
117
+ // to the container edge so the 4px border sits flush on the left.
118
+
119
+ .app-layout-sidebar__nav .not-govuk-navigation-menu__list__subitems {
120
+
121
+ .not-govuk-navigation-menu__list__item {
122
+ padding: 5px 0 5px 15px;
123
+ margin-bottom: 5px;
124
+ border-left-width: 0px;
125
+ border-left-style: solid;
126
+ }
127
+
128
+ .not-govuk-navigation-menu__list__item--active {
129
+ border-left-width: 4px;
130
+ padding-left: 11px;
131
+ font-weight: bold;
132
+
133
+ .not-govuk-navigation-menu__list__link {
134
+ font-weight: bold;
135
+ }
136
+ }
137
+
138
+ // Remove the '— ' dash from all sub-items.
139
+ .not-govuk-navigation-menu__list__item::before {
140
+ content: none;
141
+ }
142
+
143
+ padding-left: 0;
144
+ }
145
+
146
+ // Sidebar nav section headings and mobile toggle
147
+ // Desktop: headings are non-link spans, visually distinct from link items
148
+ .not-govuk-navigation-menu__list__heading {
149
+ display: block;
150
+ padding: 2px 0;
151
+ color: #0b0c0c;
152
+ font-weight: bold;
153
+ cursor: default;
154
+ }
155
+
156
+ // Mobile: headings become toggle buttons
157
+ .not-govuk-navigation-menu__list__heading-toggle {
158
+ display: flex;
159
+ align-items: center;
160
+ width: 100%;
161
+ padding: 2px 0;
162
+ background: none;
163
+ border: none;
164
+ text-align: left;
165
+ font-family: inherit;
166
+ font-size: inherit;
167
+ font-weight: bold;
168
+ color: #1d70b8;
169
+ cursor: pointer;
170
+
171
+ // Chevron indicator on the left
172
+ &::before {
173
+ content: '';
174
+ display: inline-block;
175
+ width: 0;
176
+ height: 0;
177
+ border-top: 4px solid transparent;
178
+ border-bottom: 4px solid transparent;
179
+ border-left: 5px solid currentColor;
180
+ flex-shrink: 0;
181
+ margin-right: 8px;
182
+ transition: transform 0.2s ease;
183
+ }
184
+
185
+ &[aria-expanded="true"]::before {
186
+ transform: rotate(90deg);
187
+ }
188
+
189
+ &:focus {
190
+ outline: 3px solid #fd0;
191
+ outline-offset: 0;
192
+ background-color: #fd0;
193
+ color: #0b0c0c;
194
+ box-shadow: 0 -2px #fd0, 0 4px #0b0c0c;
195
+ }
196
+ }
197
+
87
198
  // Pagination
88
199
  .app-pagination__container {
89
200
  display: flex;
@@ -172,6 +283,16 @@
172
283
  flex-wrap: nowrap;
173
284
  }
174
285
 
286
+ .govuk-header__link--homepage:focus svg g {
287
+ fill: #0b0c0c !important;
288
+ }
289
+
290
+ // Mirror GDS Design System
291
+ .govuk-header__logotype-text {
292
+ font-size: 1.5rem;
293
+ line-height: 1.25;
294
+ }
295
+
175
296
  .app-header-search {
176
297
  margin-left: auto;
177
298
  flex-shrink: 0;
@@ -185,29 +306,36 @@
185
306
  width: 300px;
186
307
  }
187
308
 
188
- // Input field — always white background with search icon on the left
309
+ // Input field — white background, GOV.UK dark border, search icon on the left
189
310
  .autocomplete__input {
190
311
  width: 100%;
191
- padding: 4px 8px 4px 40px;
192
- border: 2px solid #fff;
312
+ // GOV.UK inputs use 5px vertical padding; left padding reserves space for
313
+ // the search icon, right padding keeps text clear of the autocomplete clear button
314
+ padding: 5px 8px 5px 36px;
315
+ // Dark border matches govuk-input — visible on white and meets contrast requirements
316
+ border: 2px solid transparent;
193
317
  border-radius: 0;
194
318
  background-color: #fff;
195
319
  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
320
  background-repeat: no-repeat;
197
- background-position: 10px center;
198
- background-size: 20px 20px;
321
+ background-position: 8px center;
322
+ background-size: 18px 18px;
199
323
  color: #0b0c0c;
200
324
  font-family: Helvetica, Arial, sans-serif;
201
- font-size: 0.875rem;
202
325
  box-sizing: border-box;
326
+ // Prevent browser-native search-cancel button (replaced by autocomplete's own)
327
+ appearance: none;
203
328
 
204
329
  &::placeholder {
205
330
  color: #505a5f;
206
331
  }
207
332
 
333
+ // GOV.UK focus style: yellow outline with dark inner border
208
334
  &:focus {
209
335
  outline: 3px solid #fd0;
210
336
  outline-offset: 0;
337
+ box-shadow: inset 0 0 0 2px #0b0c0c;
338
+ border-color:#0b0c0c;
211
339
  }
212
340
  }
213
341
 
@@ -218,7 +346,6 @@
218
346
  left: 0;
219
347
  right: 0;
220
348
  background: #fff;
221
- border: 2px solid #0b0c0c;
222
349
  border-radius: 0;
223
350
  margin: 0;
224
351
  padding: 0;
@@ -233,10 +360,13 @@
233
360
  position: relative;
234
361
  }
235
362
 
363
+ .autocomplete__menu--overlay {
364
+ box-shadow: rgba(11, 12, 12, .256863) 0 5px 5px;
365
+ }
366
+
236
367
  .autocomplete__option {
237
368
  padding: 8px 12px;
238
369
  font-family: Helvetica, Arial, sans-serif;
239
- font-size: 0.875rem;
240
370
  color: #0b0c0c;
241
371
  cursor: pointer;
242
372
  border-bottom: 1px solid #f3f2f1;
@@ -264,7 +394,6 @@
264
394
 
265
395
  .app-search__context {
266
396
  display: block;
267
- font-size: 0.75rem;
268
397
  color: #505a5f;
269
398
  margin-top: 2px;
270
399
  }
@@ -272,7 +401,6 @@
272
401
  .autocomplete__option--no-results {
273
402
  padding: 8px 12px;
274
403
  font-family: Helvetica, Arial, sans-serif;
275
- font-size: 0.875rem;
276
404
  color: #505a5f;
277
405
  }
278
406
 
@@ -291,6 +419,22 @@
291
419
  }
292
420
 
293
421
  @media (max-width: 767px) {
422
+ .govuk-header__container {
423
+ flex-direction: column;
424
+ align-items: flex-start;
425
+ }
426
+
427
+ .app-header-search {
428
+ margin-left: 0;
429
+ margin-bottom: 10px;
430
+ width: 100%;
431
+ }
432
+
433
+ .app-header-search div[role="search"],
434
+ .app-header-search .autocomplete__wrapper {
435
+ width: 100%;
436
+ }
437
+
294
438
  .app-layout-sidebar {
295
439
  flex-direction: column;
296
440
  gap: 0;
@@ -310,9 +454,4 @@
310
454
  width: 100%;
311
455
  min-width: 0;
312
456
  }
313
-
314
- .app-header-search .autocomplete__wrapper {
315
- width: 180px;
316
- }
317
- }
318
-
457
+ }
@@ -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,37 @@ 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: section headings (items with children) are rendered as plain text,
8
+ * all groups are permanently expanded, only sub-items are links.
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: section headings become toggle buttons; groups collapse and expand.
11
+ * Active groups start expanded. Items without children are always links.
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) so content is
14
+ * accessible without JavaScript.
18
15
  */
19
16
  export default function SidebarNav({ items }) {
20
17
  const [hash, setHash] = useState(null);
18
+ // null = not yet hydrated; false = desktop; true = mobile
19
+ const [isMobile, setIsMobile] = useState(null);
20
+ const [openGroups, setOpenGroups] = useState(new Set());
21
21
  const location = useLocation();
22
22
 
23
23
  useEffect(() => {
24
- const update = () => setHash(window.location.hash.slice(1));
24
+ const update = () => setHash(globalThis.location.hash.slice(1));
25
25
  update();
26
- window.addEventListener('hashchange', update);
27
- return () => window.removeEventListener('hashchange', update);
26
+ globalThis.addEventListener('hashchange', update);
27
+ return () => globalThis.removeEventListener('hashchange', update);
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ const mql = globalThis.matchMedia('(max-width: 767px)');
32
+ const update = () => setIsMobile(mql.matches);
33
+ update();
34
+ mql.addEventListener('change', update);
35
+ return () => mql.removeEventListener('change', update);
28
36
  }, []);
29
37
 
30
- // Split an href into its path and anchor components.
31
38
  function parseHref(href) {
32
39
  if (!href) return { path: '', anchor: '' };
33
40
  const idx = href.indexOf('#');
@@ -35,9 +42,6 @@ export default function SidebarNav({ items }) {
35
42
  return { path: href.slice(0, idx) || '/', anchor: href.slice(idx + 1) };
36
43
  }
37
44
 
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
45
  function isActive(href) {
42
46
  const { path, anchor } = parseHref(href);
43
47
  if (anchor) {
@@ -49,44 +53,75 @@ export default function SidebarNav({ items }) {
49
53
  );
50
54
  }
51
55
 
56
+ // When switching to mobile, open any groups that contain the active page.
57
+ useEffect(() => {
58
+ if (!isMobile || hash === null) return;
59
+ const active = new Set();
60
+ (items || []).forEach((item, i) => {
61
+ if (!Array.isArray(item.items) || !item.items.length) return;
62
+ if (isActive(item.href) || item.items.some(sub => isActive(sub.href))) {
63
+ active.add(i);
64
+ }
65
+ });
66
+ setOpenGroups(active);
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [isMobile, location.pathname, hash]);
69
+
70
+ function toggleGroup(i) {
71
+ setOpenGroups(prev => {
72
+ const next = new Set(prev);
73
+ if (next.has(i)) next.delete(i);
74
+ else next.add(i);
75
+ return next;
76
+ });
77
+ }
78
+
52
79
  const cls = 'not-govuk-navigation-menu';
53
80
  const lCls = `${cls}__list`;
81
+ const itemCls = `${lCls}__item`;
82
+ const activeCls = `${itemCls}--active`;
83
+
84
+ function renderHeading(item, i, sublistId) {
85
+ if (isMobile) {
86
+ return (
87
+ <button
88
+ type="button"
89
+ className={`${lCls}__heading-toggle`}
90
+ aria-expanded={openGroups.has(i)}
91
+ aria-controls={sublistId}
92
+ onClick={() => toggleGroup(i)}
93
+ >
94
+ {item.text}
95
+ </button>
96
+ );
97
+ }
98
+ return <span className={`${lCls}__heading`}>{item.text}</span>;
99
+ }
54
100
 
55
101
  return (
56
102
  <nav className={cls}>
57
103
  <ul className={lCls}>
58
- {items.map((item, i) => {
104
+ {(items || []).map((item, i) => {
59
105
  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
106
  const active = hash !== null && isActive(item.href);
107
+ const showChildren =
108
+ hasChildren && (isMobile === null || !isMobile || openGroups.has(i));
109
+ const liCls = active && !hasChildren ? `${itemCls} ${activeCls}` : itemCls;
110
+ const sublistId = hasChildren ? `sidebar-group-${i}` : undefined;
69
111
 
70
112
  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) => {
113
+ <li key={item.href || item.text} className={liCls}>
114
+ {hasChildren ? renderHeading(item, i, sublistId) : (
115
+ <a href={item.href} className={`${lCls}__link`}>{item.text}</a>
116
+ )}
117
+ {showChildren && (
118
+ <ul id={sublistId} className={`${lCls}__subitems`}>
119
+ {item.items.map((sub) => {
81
120
  const subActive = hash !== null && isActive(sub.href);
121
+ const subCls = subActive ? `${itemCls} ${activeCls}` : itemCls;
82
122
  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>
123
+ <li key={sub.href || sub.text} className={subCls}>
124
+ <a href={sub.href} className={`${lCls}__link`}>{sub.text}</a>
90
125
  </li>
91
126
  );
92
127
  })}
@@ -99,4 +134,3 @@ export default function SidebarNav({ items }) {
99
134
  </nav>
100
135
  );
101
136
  }
102
-