@pnx-mixtape/mxds 0.1.0-rc.5 → 0.1.0-rc.6

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.
Files changed (77) hide show
  1. package/DESIGN.md +1 -1
  2. package/README.md +1 -1
  3. package/dist/build/accordion.entry.js +1 -1
  4. package/dist/build/base.css +1 -1
  5. package/dist/build/chunks/{Accordion-D2wp8hBG.js → Accordion-Bqe8vTNR.js} +1 -1
  6. package/dist/build/chunks/{AccordionMobile-D3sTeiM5.js → AccordionMobile-D517oXYZ.js} +1 -1
  7. package/dist/build/chunks/{ClosableAlert-yy84gae6.js → ClosableAlert-CZ114JsL.js} +1 -1
  8. package/dist/build/chunks/{Dialog-DWoXW-7Z.js → Dialog-DoYcGqhW.js} +1 -1
  9. package/dist/build/chunks/{FeedbackForm-BochXEDx.js → FeedbackForm-BsL7fv8s.js} +1 -1
  10. package/dist/build/chunks/{Gallery-Rkfv7Mbb.js → Gallery-DqJPujX5.js} +2 -2
  11. package/dist/build/chunks/{GlobalToggle-CzlkpLpc.js → GlobalToggle-7H3F1Wvc.js} +1 -1
  12. package/dist/build/chunks/{InPageNavigation-D1Kk-rCm.js → InPageNavigation-D7m3IN89.js} +1 -1
  13. package/dist/build/chunks/{Navigation-CDQnN61h.js → Navigation-CRModT40.js} +13 -18
  14. package/dist/build/chunks/{Sticky-DoVndENm.js → Sticky-C1H8MNqK.js} +1 -1
  15. package/dist/build/chunks/{Tabs-BDlxCDNh.js → Tabs-BJD3kABL.js} +1 -1
  16. package/dist/build/chunks/{Translate-bmM38j6z.js → Translate-yAgySwet.js} +1 -1
  17. package/dist/build/chunks/{utilities-Dd-08SW6.js → utilities-B9mNRs7V.js} +1 -1
  18. package/dist/build/dialog.entry.js +1 -1
  19. package/dist/build/elements.js +13 -13
  20. package/dist/build/feedback-form.entry.js +1 -1
  21. package/dist/build/filters.entry.js +3 -3
  22. package/dist/build/form.css +1 -1
  23. package/dist/build/gallery.entry.js +1 -1
  24. package/dist/build/global-alert.entry.js +1 -1
  25. package/dist/build/header.entry.js +1 -1
  26. package/dist/build/in-page-navigation.entry.js +1 -1
  27. package/dist/build/navigation.css +1 -1
  28. package/dist/build/navigation.entry.js +1 -1
  29. package/dist/build/react.js +230 -209
  30. package/dist/build/side-navigation.css +1 -1
  31. package/dist/build/sidebar.css +1 -1
  32. package/dist/build/sticky.entry.js +1 -1
  33. package/dist/build/tabs.entry.js +1 -1
  34. package/dist/build/translate.entry.js +1 -1
  35. package/dist/types/Component/Navigation/Elements/Navigation.d.ts +2 -3
  36. package/dist/types/Component/Navigation/Elements/Navigation.d.ts.map +1 -1
  37. package/dist/types/Component/SideNavigation/Components/SideNavigationTree.d.ts +3 -3
  38. package/dist/types/Component/SideNavigation/Components/SideNavigationTree.d.ts.map +1 -1
  39. package/dist/types/Component/SideNavigation/SideNavigation.d.ts +3 -1
  40. package/dist/types/Component/SideNavigation/SideNavigation.d.ts.map +1 -1
  41. package/dist/types/tokens.d.ts +1 -0
  42. package/dist/types/tokens.d.ts.map +1 -1
  43. package/docs/CSSGuidelines.md +2 -0
  44. package/package.json +5 -5
  45. package/src/Atom/Link/_links.css +1 -0
  46. package/src/Component/Navigation/Elements/Navigation.ts +25 -29
  47. package/src/Component/Navigation/README.md +11 -10
  48. package/src/Component/Navigation/__snapshots__/Navigation.stories.ts.snap +8 -8
  49. package/src/Component/Navigation/_navigation-dropdown.css +9 -11
  50. package/src/Component/Navigation/_navigation-mega.css +5 -11
  51. package/src/Component/Navigation/_navigation.css +5 -3
  52. package/src/Component/Navigation/menu.twig +1 -1
  53. package/src/Component/Navigation/navigation.twig +0 -1
  54. package/src/Component/SideNavigation/Components/SideNavigationTree.tsx +68 -24
  55. package/src/Component/SideNavigation/README.md +78 -32
  56. package/src/Component/SideNavigation/SideNavigation.stories.ts +41 -2
  57. package/src/Component/SideNavigation/SideNavigation.stories.tsx +31 -0
  58. package/src/Component/SideNavigation/SideNavigation.tsx +13 -3
  59. package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.ts.snap +87 -3
  60. package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.tsx.snap +2 -0
  61. package/src/Component/SideNavigation/side-navigation.css +3 -3
  62. package/src/Component/SideNavigation/side-navigation.twig +10 -3
  63. package/src/Component/Translate/README.md +4 -2
  64. package/src/Component/Translate/Translate.stories.ts +2 -2
  65. package/src/Component/Translate/Translate.stories.tsx +2 -2
  66. package/src/Component/Translate/Translate.tsx +1 -1
  67. package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +1 -1
  68. package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +1 -1
  69. package/src/Component/Translate/translate.twig +1 -1
  70. package/src/Form/form.css +17 -19
  71. package/src/Introduction.mdx +1 -1
  72. package/src/Layout/Footer/__snapshots__/Footer.stories.ts.snap +3 -3
  73. package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +6 -6
  74. package/src/Layout/Sidebar/sidebar.css +10 -3
  75. package/src/Utility/Elements/google-translate-widget.ts +1 -1
  76. package/src/_custom-media.css +2 -0
  77. package/src/tokens.js +1 -0
@@ -2,18 +2,26 @@
2
2
 
3
3
  A vertical, multi-level navigation rendered as a `<nav>` landmark with a parent page link at the top and a nested list of sibling/child pages below. Designed to live in the aside of content layouts to expose a section's local page tree. Extends the base `.mx-nav` styles from the Navigation component.
4
4
 
5
+ By default every level is rendered open. Set `collapsible` to wrap the nav in `<mx-nav openActive>`, which turns each sub-list into a click-to-expand disclosure and opens the branch containing the current page.
6
+
5
7
  ## CSS Import
6
8
 
7
9
  ```css
8
10
  @import "@pnx-mixtape/mxds/src/Component/SideNavigation/side-navigation.css";
9
11
  ```
10
12
 
11
- Depends on the Navigation component's base styles (for `.mx-nav` and sub-list level classes):
13
+ Depends on the Navigation component's base styles (for `.mx-nav`, sub-list level classes and, when collapsible, the `.mx-nav--collapsible` toggle/transition styles):
12
14
 
13
15
  ```css
14
16
  @import "@pnx-mixtape/mxds/src/Component/Navigation/navigation.css";
15
17
  ```
16
18
 
19
+ ## JS Import (collapsible only)
20
+
21
+ ```js
22
+ import "@pnx-mixtape/mxds/src/Component/Navigation/Elements/Navigation"
23
+ ```
24
+
17
25
  ---
18
26
 
19
27
  ## Twig
@@ -24,6 +32,7 @@ Depends on the Navigation component's base styles (for `.mx-nav` and sub-list le
24
32
  title: 'Section navigation',
25
33
  parent: parent_link,
26
34
  items: menu_tree,
35
+ collapsible: true,
27
36
  } %}
28
37
  ```
29
38
 
@@ -31,13 +40,14 @@ Depends on the Navigation component's base styles (for `.mx-nav` and sub-list le
31
40
 
32
41
  **side-navigation.twig props** (`SideNavigation` type from `ids-shape`):
33
42
 
34
- | Prop | Type | Description |
35
- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
36
- | `id` | `string` | Written to `.mx-side-nav__parent` and used as the `<nav>`'s `aria-labelledby` fallback when `title` is empty |
37
- | `title` | `string` | `aria-label` for the `<nav>` landmark. Provide a meaningful value so assistive tech can distinguish this nav from the site's primary nav |
38
- | `parent` | `Link` | Pre-rendered Link atom representing the current section's parent page, shown above the list |
39
- | `items` | `MenuTree[]` | Recursive menu tree — see shape below |
40
- | `attributes` | `Attribute` | Optional Drupal-style attributes object; the template merges `mx-nav mx-side-nav` onto its classes |
43
+ | Prop | Type | Description |
44
+ | ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `id` | `string` | Written to `.mx-side-nav__parent` and used as the `<nav>`'s `aria-labelledby` fallback when `title` is empty |
46
+ | `title` | `string` | `aria-label` for the `<nav>` landmark. Provide a meaningful value so assistive tech can distinguish this nav from the site's primary nav |
47
+ | `parent` | `Link` | Pre-rendered Link atom representing the current section's parent page, shown above the list |
48
+ | `items` | `MenuTree[]` | Recursive menu tree — see shape below |
49
+ | `collapsible` | `boolean` | Optional. Wraps the `<nav>` in `<mx-nav openActive>` and adds `mx-nav--collapsible` so sub-lists collapse behind their toggle buttons. Not part of the `ids-shape` `SideNavigation` type |
50
+ | `attributes` | `Attribute` | Optional Drupal-style attributes object; the template merges `mx-nav mx-side-nav` onto its classes |
41
51
 
42
52
  **`MenuTree` shape** (from `@pnx-mixtape/ids-shape`):
43
53
 
@@ -98,13 +108,43 @@ Rules for the markup:
98
108
  - The top list is `mx-nav__level-1`, children are `mx-nav__level-2`, grandchildren are `mx-nav__level-3`. Each level adds more inline-start indent.
99
109
  - Any `<li>` that contains a nested `<ul>` must carry `mx-nav__has-subnav`.
100
110
  - The current page's `<a>` carries `aria-current="page"` — CSS uses this to bold the label and apply the active background.
101
- - Sub-lists are **not** collapsed or inert — unlike the dropdown/collapsible navigation, every level is rendered open.
111
+ - Sub-lists are **not** collapsed or inert by default — every level is rendered open.
112
+
113
+ ### Collapsible
114
+
115
+ ```html
116
+ <mx-nav openActive>
117
+ <nav class="mx-nav mx-side-nav mx-nav--collapsible" aria-label="Section navigation">
118
+ <div class="mx-side-nav__parent" id="section-nav">
119
+ <a href="/about">About</a>
120
+ </div>
121
+ <ul class="mx-nav__level-1">
122
+ <li class="mx-nav__has-subnav">
123
+ <a href="/about/what-we-do">What we do</a>
124
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">
125
+ Sub-navigation
126
+ </button>
127
+ <ul class="mx-nav__level-2">
128
+ <li>
129
+ <a href="/about/what-we-do/events" aria-current="page">Events</a>
130
+ </li>
131
+ </ul>
132
+ </li>
133
+ </ul>
134
+ </nav>
135
+ </mx-nav>
136
+ ```
137
+
138
+ - Sub-lists are rendered open in the markup (no `inert`), so the full tree is available without JS.
139
+ - On connect, `<mx-nav>` wires each `.mx-nav__toggle` to its sibling `<ul>` (`aria-controls`, `aria-expanded`, `inert`), labels each sub-list by its parent link, then (because of `openActive`) re-opens any sub-list whose parent link or descendants carry `aria-current="page"` or `.is-active`.
140
+ - Sub-lists not on the current page's trail start collapsed.
141
+ - Toggles are hidden until `<mx-nav>` is defined (`mx-nav:not(:defined) .mx-nav__toggle`).
102
142
 
103
143
  ---
104
144
 
105
145
  ## React
106
146
 
107
- Uses a composition pattern — always use `SideNavigationTree` inside `SideNavigation`.
147
+ Uses a composition pattern — always use `SideNavigationTree` inside `SideNavigation`. `SideNavigationTree` reads `collapsible` from `SideNavigation` via context, so it is only set once.
108
148
 
109
149
  ```tsx
110
150
  import { SideNavigation, SideNavigationTree, Link } from "@pnx-mixtape/mxds/react"
@@ -126,6 +166,7 @@ const items: MenuTree[] = [
126
166
  id="section-nav"
127
167
  title="Section navigation"
128
168
  parent={<Link to="/about">About</Link>}
169
+ collapsible
129
170
  >
130
171
  <SideNavigationTree items={items} />
131
172
  </SideNavigation>
@@ -133,18 +174,20 @@ const items: MenuTree[] = [
133
174
 
134
175
  **`SideNavigation` Props:**
135
176
 
136
- | Prop | Type | Required | Description |
137
- | ----------- | ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
138
- | `parent` | `ReactNode` | No | Link/element shown above the list inside `.mx-side-nav__parent` |
139
- | `title` | `string` | No | `aria-label` for the `<nav>` landmark. When omitted, falls back to `aria-labelledby` pointing at the parent wrapper |
140
- | `id` | `string` | No | `id` on `.mx-side-nav__parent`, also used as the `aria-labelledby` target when `title` is not set. Auto-generated if omitted |
141
- | `className` | `string` | No | Extra class on the `<nav>` |
177
+ | Prop | Type | Required | Description |
178
+ | ------------- | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
179
+ | `parent` | `ReactNode` | No | Link/element shown above the list inside `.mx-side-nav__parent` |
180
+ | `title` | `string` | No | `aria-label` for the `<nav>` landmark. When omitted, falls back to `aria-labelledby` pointing at the parent wrapper |
181
+ | `id` | `string` | No | `id` on `.mx-side-nav__parent`, also used as the `aria-labelledby` target when `title` is not set. Auto-generated if omitted |
182
+ | `collapsible` | `boolean` | No | Adds `mx-nav--collapsible` and makes every nested `SideNavigationTree` sub-list a disclosure (`aria-expanded`/`aria-controls` on the toggle, `inert` when closed). Branches containing the current item start open. Default `false` |
183
+ | `className` | `string` | No | Extra class on the `<nav>` |
142
184
 
143
185
  **`SideNavigationTree` Props:**
144
186
 
145
- | Prop | Type | Required | Description |
146
- | ------- | ------------ | -------- | ----------------------------------------------------------------------------- |
147
- | `items` | `MenuTree[]` | Yes | Recursive menu tree — rendered as nested `<ul class="mx-nav__level-N">` lists |
187
+ | Prop | Type | Required | Description |
188
+ | ---------- | --------------- | -------- | ----------------------------------------------------------------------------- |
189
+ | `items` | `MenuTree[]` | Yes | Recursive menu tree — rendered as nested `<ul class="mx-nav__level-N">` lists |
190
+ | `...props` | `ul` attributes | No | Spread onto the `<ul>` |
148
191
 
149
192
  **`MenuTree` shape** (from `@pnx-mixtape/ids-shape`):
150
193
 
@@ -159,17 +202,18 @@ const items: MenuTree[] = [
159
202
 
160
203
  ## Modifiers
161
204
 
162
- SideNavigation uses the base `.mx-nav` class for list reset and the component-specific classes below. There are no variant modifiers — the single layout covers all cases.
205
+ SideNavigation uses the base `.mx-nav` class for list reset and the component-specific classes below. The only variant is `mx-nav--collapsible`.
163
206
 
164
- | Class | Applied by | Visual intent |
165
- | --------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
166
- | `mx-nav` | Always (shared with Navigation) | Base list reset — no bullets, vertical layout |
167
- | `mx-side-nav` | Always | Side-nav specific styling: per-item dividers, indent ramp across levels, current-page background |
168
- | `mx-side-nav__parent` | Always | Parent-page wrapper above the list — bold label, thicker bottom border |
169
- | `mx-nav__level-1` / `mx-nav__level-2` / `mx-nav__level-3` | Structural | Sub-list nesting level — each level increases `--indent` |
170
- | `mx-nav__has-subnav` | Structural | Applied to any `<li>` that contains a child `<ul>` |
171
- | `mx-nav__toggle` | Structural (inherited from Navigation) | Hidden in side-nav (`display: none`) — the template emits it for markup parity with dropdown nav, but every level is always expanded |
172
- | `is-active` | Authoring | Alternative to `aria-current="page"` for marking the current item — styled identically |
207
+ | Class | Applied by | Visual intent |
208
+ | --------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
209
+ | `mx-nav` | Always (shared with Navigation) | Base list reset — no bullets, vertical layout |
210
+ | `mx-side-nav` | Always | Side-nav specific styling: per-item dividers, indent ramp across levels, current-page background |
211
+ | `mx-side-nav__parent` | Always | Parent-page wrapper above the list — bold label, thicker bottom border |
212
+ | `mx-nav--collapsible` | Twig `collapsible: true` | Shows the sub-list toggles and applies the collapsible nav's `inert`/transition styles |
213
+ | `mx-nav__level-1` / `mx-nav__level-2` / `mx-nav__level-3` | Structural | Sub-list nesting level — each level increases `--indent` |
214
+ | `mx-nav__has-subnav` | Structural | Applied to any `<li>` that contains a child `<ul>` |
215
+ | `mx-nav__toggle` | Structural (inherited from Navigation) | Hidden (`display: none`) unless the nav is `mx-nav--collapsible`, where it opens and closes its sibling sub-list |
216
+ | `is-active` | Authoring | Alternative to `aria-current="page"` for marking the current item — styled identically |
173
217
 
174
218
  ---
175
219
 
@@ -180,8 +224,10 @@ SideNavigation uses the base `.mx-nav` class for list reset and the component-sp
180
224
  - Uses semantic nested `<ul>` / `<li>` lists, so assistive tech announces list depth and item count at each level.
181
225
  - The current page is marked with `aria-current="page"` on the `<a>`. Style-only bolding is a visual reinforcement — the ARIA attribute is what assistive tech announces. Avoid relying on the background/weight alone.
182
226
  - Active-trail ancestors (parent items on the path to the current page) are not given `aria-current` — only the exact current page. Apply the `is-active` class if you want to visually mark ancestors with the same active styling.
183
- - Keyboard navigation: every item is a native `<a>`, so tab order follows source order and no custom key handling is needed. Sub-lists are always visible, so there is no expand/collapse interaction to manage.
184
- - The `.mx-nav__toggle` button is emitted in the Twig markup for structural parity with the dropdown nav but is hidden via CSS — do not rely on it for any interactive behaviour in this component at this time (future iterations may enable it).
227
+ - Keyboard navigation: every item is a native `<a>`, so tab order follows source order. In the default variant sub-lists are always visible, so there is no expand/collapse interaction to manage.
228
+ - The `.mx-nav__toggle` button is hidden via CSS in the default variant.
229
+ - Collapsible (Twig and React): each toggle is a native `<button>` with `aria-expanded` and `aria-controls`; collapsed sub-lists are `inert`, so they drop out of the tab order and the accessibility tree. In Twig, `<mx-nav>` also adds the shared `Keyboard` arrow-key handling between links; React has no equivalent. `Escape` does not close sub-lists in either — that is only wired up for `flyout` navs.
230
+ - Collapsible: every toggle's accessible name is "Sub-navigation". Screen-reader users get the context from the preceding link, but the name alone doesn't say which section it opens.
185
231
 
186
232
  ---
187
233
 
@@ -205,7 +251,7 @@ SideNavigation uses the base `.mx-nav` class for list reset and the component-sp
205
251
  }
206
252
  ```
207
253
 
208
- Level 2 and level 3 sub-lists override `--indent` to step the text further in — at narrow widths with `--spacing-s` / `--spacing-m`, and at the `--global-nav-up` breakpoint with `--spacing-m` / `--spacing-l`.
254
+ Level 2 and level 3 sub-lists override `--indent` to step the text further in — at narrow widths with `--spacing-s` / `--spacing-m`, and at the `--sidebar-up` breakpoint with `--spacing-m` / `--spacing-l`.
209
255
 
210
256
  ---
211
257
 
@@ -1,11 +1,17 @@
1
1
  import { Meta, StoryObj } from "@storybook/html-vite"
2
+ import { expect } from "storybook/test"
2
3
  import Component from "./side-navigation.twig"
3
4
  import Link from "../../Atom/Link/link.twig"
4
5
  import "../Navigation/navigation.css"
5
6
  import "./side-navigation.css"
7
+ import "../Navigation/Elements/Navigation"
6
8
  import { SideNavigation as NavigationType } from "@pnx-mixtape/ids-shape"
7
9
 
8
- const meta: Meta<NavigationType> = {
10
+ type SideNavigationType = NavigationType & {
11
+ collapsible?: boolean
12
+ }
13
+
14
+ const meta: Meta<SideNavigationType> = {
9
15
  tags: ["autodocs", "ids-mvp"],
10
16
  component: Component,
11
17
  args: {
@@ -128,10 +134,43 @@ const meta: Meta<NavigationType> = {
128
134
  required: true,
129
135
  },
130
136
  },
137
+ collapsible: {
138
+ type: "boolean",
139
+ },
131
140
  },
132
141
  }
133
142
 
134
143
  export default meta
135
- type Story = StoryObj<NavigationType>
144
+ type Story = StoryObj<SideNavigationType>
136
145
 
137
146
  export const SideNavigation: Story = {}
147
+
148
+ export const Collapsible: Story = {
149
+ args: {
150
+ collapsible: true,
151
+ },
152
+ play: async ({ canvas, userEvent, step }) => {
153
+ const item = canvas.getByRole("link", { name: "What we do" }).closest("li")
154
+ const trigger = item.querySelector(":scope > .mx-nav__toggle")
155
+ const list = item.querySelector(":scope > ul")
156
+
157
+ await step("Opens the active trail", async () => {
158
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
159
+ await expect(trigger).toHaveAttribute("aria-controls", list.id)
160
+ await expect(list).not.toHaveAttribute("inert")
161
+ })
162
+
163
+ await step("Closes on click", async () => {
164
+ await userEvent.click(trigger)
165
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
166
+ await expect(list).toHaveAttribute("inert")
167
+ })
168
+
169
+ await step("Reopens on click", async () => {
170
+ await userEvent.click(trigger)
171
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
172
+ await expect(list).not.toHaveAttribute("inert")
173
+ ;(trigger as HTMLElement).blur()
174
+ })
175
+ },
176
+ }
@@ -1,4 +1,5 @@
1
1
  import { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { expect } from "storybook/test"
2
3
  import Component from "./SideNavigation"
3
4
  import SideNavigationTree from "./Components/SideNavigationTree"
4
5
  import "../Navigation/navigation.css"
@@ -66,3 +67,33 @@ const meta: Meta<typeof Component> = {
66
67
  export default meta
67
68
 
68
69
  export const SideNavigation: StoryObj<typeof meta> = {}
70
+
71
+ export const Collapsible: StoryObj<typeof meta> = {
72
+ args: {
73
+ collapsible: true,
74
+ },
75
+ play: async ({ canvas, userEvent, step }) => {
76
+ const item = canvas.getByRole("link", { name: "What we do" }).closest("li")
77
+ const trigger = item.querySelector(":scope > .mx-nav__toggle")
78
+ const list = item.querySelector(":scope > ul")
79
+
80
+ await step("Opens the active trail", async () => {
81
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
82
+ await expect(trigger).toHaveAttribute("aria-controls", list.id)
83
+ await expect(list).not.toHaveAttribute("inert")
84
+ })
85
+
86
+ await step("Closes on click", async () => {
87
+ await userEvent.click(trigger)
88
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
89
+ await expect(list).toHaveAttribute("inert")
90
+ })
91
+
92
+ await step("Reopens on click", async () => {
93
+ await userEvent.click(trigger)
94
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
95
+ await expect(list).not.toHaveAttribute("inert")
96
+ ;(trigger as HTMLElement).blur()
97
+ })
98
+ },
99
+ }
@@ -1,18 +1,23 @@
1
- import { JSX, ReactNode, PropsWithChildren, useId } from "react"
1
+ import { JSX, ReactNode, PropsWithChildren, createContext, useId } from "react"
2
2
  import classNames from "classnames"
3
3
 
4
4
  type SideNavigationProps = PropsWithChildren & {
5
5
  parent?: ReactNode
6
6
  title?: string
7
7
  id?: string
8
+ collapsible?: boolean
8
9
  className?: string
9
10
  }
10
11
 
12
+ // Whether sub-lists in a nested SideNavigationTree can be collapsed.
13
+ export const SideNavigationContext = createContext<boolean>(false)
14
+
11
15
  const SideNavigation = ({
12
16
  children,
13
17
  parent,
14
18
  title,
15
19
  id,
20
+ collapsible = false,
16
21
  className,
17
22
  }: SideNavigationProps): JSX.Element => {
18
23
  const autoId = useId()
@@ -20,13 +25,18 @@ const SideNavigation = ({
20
25
 
21
26
  return (
22
27
  <nav
23
- className={classNames("mx-nav", "mx-side-nav", className)}
28
+ className={classNames(
29
+ "mx-nav",
30
+ "mx-side-nav",
31
+ { "mx-nav--collapsible": collapsible },
32
+ className,
33
+ )}
24
34
  {...(title ? { "aria-label": title } : { "aria-labelledby": navId })}
25
35
  >
26
36
  <div className="mx-side-nav__parent" id={navId}>
27
37
  {parent}
28
38
  </div>
29
- {children}
39
+ <SideNavigationContext value={collapsible}>{children}</SideNavigationContext>
30
40
  </nav>
31
41
  )
32
42
  }
@@ -1,9 +1,93 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`Collapsible 1`] = `
4
+ "
5
+
6
+ <mx-nav openactive="">
7
+ <nav class="mx-nav mx-side-nav mx-nav--collapsible" aria-label="Sidebar nav">
8
+ <div class="mx-side-nav__parent" id="sidenav-story">
9
+
10
+ <a href="#">
11
+
12
+ About
13
+ </a>
14
+
15
+ </div>
16
+ <ul class="mx-nav__level-1">
17
+ <li>
18
+ <a href="#">
19
+ News
20
+ </a>
21
+ </li>
22
+ <li class="mx-nav__has-subnav">
23
+ <a href="#" id="unique-2">
24
+ What we do
25
+ </a>
26
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button" aria-expanded="true" aria-controls="unique-0">Sub-navigation</button>
27
+ <ul class="mx-nav__level-2" id="unique-0" aria-labelledby="unique-1">
28
+ <li>
29
+ <a href="#">
30
+ Events
31
+ </a>
32
+ </li>
33
+ <li class="mx-nav__has-subnav">
34
+ <a href="#" id="unique-3">
35
+ Who we are
36
+ </a>
37
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button" aria-expanded="true" aria-controls="unique-2">Sub-navigation</button>
38
+ <ul class="mx-nav__level-3" id="unique-1" aria-labelledby="unique-3">
39
+ <li>
40
+ <a href="#">
41
+ Our team
42
+ </a>
43
+ </li>
44
+ <li>
45
+ <a href="#" aria-current="page">
46
+ Our philosophy
47
+ </a>
48
+ </li>
49
+ <li>
50
+ <a href="#">
51
+ Why music?
52
+ </a>
53
+ </li>
54
+ </ul>
55
+
56
+ </li>
57
+ <li>
58
+ <a href="#">
59
+ Join us
60
+ </a>
61
+ </li>
62
+ <li>
63
+ <a href="#">
64
+ Careers
65
+ </a>
66
+ </li>
67
+ </ul>
68
+
69
+ </li>
70
+ <li>
71
+ <a href="#">
72
+ Contact
73
+ </a>
74
+ </li>
75
+ <li>
76
+ <a href="#">
77
+ Resources
78
+ </a>
79
+ </li>
80
+ </ul>
81
+
82
+ </nav>
83
+ </mx-nav>
84
+ "
85
+ `;
86
+
3
87
  exports[`Side Navigation 1`] = `
4
88
  "
5
89
 
6
- <nav class="mx-nav mx-side-nav" aria-label="Sidebar nav" "="">
90
+ <nav class="mx-nav mx-side-nav" aria-label="Sidebar nav">
7
91
  <div class="mx-side-nav__parent" id="sidenav-story">
8
92
 
9
93
  <a href="#">
@@ -22,7 +106,7 @@ exports[`Side Navigation 1`] = `
22
106
  <a href="#">
23
107
  What we do
24
108
  </a>
25
- <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only">Sub-navigation</button>
109
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">Sub-navigation</button>
26
110
  <ul class="mx-nav__level-2">
27
111
  <li>
28
112
  <a href="#">
@@ -33,7 +117,7 @@ exports[`Side Navigation 1`] = `
33
117
  <a href="#">
34
118
  Who we are
35
119
  </a>
36
- <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only">Sub-navigation</button>
120
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">Sub-navigation</button>
37
121
  <ul class="mx-nav__level-3">
38
122
  <li>
39
123
  <a href="#">
@@ -1,3 +1,5 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`Collapsible 1`] = `"<nav class="mx-nav mx-side-nav mx-nav--collapsible" aria-label="Sidebar nav"><div class="mx-side-nav__parent" id="sidenav-story"><a class="mx-link" href="#">About</a></div><ul class="mx-nav__level-1"><li><a href="#" class="mx-link">News</a></li><li class="mx-nav__has-subnav"><a href="#" class="mx-link" id="_r_17_">What we do</a><button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button" aria-expanded="true" aria-controls="_r_16_">Sub-navigation</button><ul class="mx-nav__level-2" id="_r_16_" aria-labelledby="_r_17_"><li><a href="#" class="mx-link">Events</a></li><li class="mx-nav__has-subnav"><a href="#" class="mx-link" id="_r_1d_">Who we are</a><button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button" aria-expanded="true" aria-controls="_r_1c_">Sub-navigation</button><ul class="mx-nav__level-3" id="_r_1c_" aria-labelledby="_r_1d_"><li><a href="#" class="mx-link">Our team</a></li><li><a href="#" class="mx-link" aria-current="page">Our philosophy</a></li><li><a href="#" class="mx-link">Why music?</a></li></ul></li><li><a href="#" class="mx-link">Join us</a></li><li><a href="#" class="mx-link">Careers</a></li></ul></li><li><a href="#" class="mx-link">Contact</a></li><li><a href="#" class="mx-link">Resources</a></li></ul></nav>"`;
4
+
3
5
  exports[`Side Navigation 1`] = `"<nav class="mx-nav mx-side-nav" aria-label="Sidebar nav"><div class="mx-side-nav__parent" id="sidenav-story"><a class="mx-link" href="#">About</a></div><ul class="mx-nav__level-1"><li><a href="#" class="mx-link">News</a></li><li class="mx-nav__has-subnav"><a href="#" class="mx-link">What we do</a><button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">Sub-navigation</button><ul class="mx-nav__level-2"><li><a href="#" class="mx-link">Events</a></li><li class="mx-nav__has-subnav"><a href="#" class="mx-link">Who we are</a><button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">Sub-navigation</button><ul class="mx-nav__level-3"><li><a href="#" class="mx-link">Our team</a></li><li><a href="#" class="mx-link" aria-current="page">Our philosophy</a></li><li><a href="#" class="mx-link">Why music?</a></li></ul></li><li><a href="#" class="mx-link">Join us</a></li><li><a href="#" class="mx-link">Careers</a></li></ul></li><li><a href="#" class="mx-link">Contact</a></li><li><a href="#" class="mx-link">Resources</a></li></ul></nav>"`;
@@ -41,7 +41,7 @@
41
41
  & .mx-nav__level-2 > li > a {
42
42
  --indent: var(--spacing-s);
43
43
 
44
- @media (--global-nav-up) {
44
+ @media (--sidebar-up) {
45
45
  --indent: var(--spacing-m);
46
46
  }
47
47
  }
@@ -49,7 +49,7 @@
49
49
  & .mx-nav__level-3 > li > a {
50
50
  --indent: var(--spacing-m);
51
51
 
52
- @media (--global-nav-up) {
52
+ @media (--sidebar-up) {
53
53
  --indent: var(--spacing-l);
54
54
  }
55
55
  }
@@ -62,7 +62,7 @@
62
62
  color: var(--nav-active-foreground, inherit);
63
63
  }
64
64
 
65
- & .mx-nav__toggle {
65
+ &:not(.mx-nav--collapsible) .mx-nav__toggle {
66
66
  display: none;
67
67
  }
68
68
  }
@@ -2,7 +2,8 @@
2
2
  {% set baseClass = 'mx-nav' %}
3
3
  {% set classes = [
4
4
  baseClass,
5
- 'mx-side-nav'
5
+ 'mx-side-nav',
6
+ collapsible ? 'mx-nav--collapsible' : null,
6
7
  ] %}
7
8
  {% set attributes = (attributes ?? create_attribute()).addClass(classes) %}
8
9
 
@@ -19,7 +20,7 @@
19
20
  {{ item.link.title }}
20
21
  {% endif %}
21
22
  {% if item.items is not empty %}
22
- <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only">Sub-navigation</button>
23
+ <button class="mx-nav__toggle mx-icon mx-icon--chevron-down mx-icon--only" type="button">Sub-navigation</button>
23
24
  {{ nav.menutree(item.items, level + 1, setInert, baseClass) }}
24
25
  {% endif %}
25
26
  </li>
@@ -27,9 +28,15 @@
27
28
  </ul>
28
29
  {% endmacro %}
29
30
 
30
- <nav{{ attributes }}{% if title is not empty %} aria-label="{{ title }}"{% else %} aria-labelledby="{{ id }}{% endif %}">
31
+ {% if collapsible %}
32
+ <mx-nav openActive>
33
+ {% endif %}
34
+ <nav{{ attributes }}{% if title is not empty %} aria-label="{{ title }}"{% else %} aria-labelledby="{{ id }}"{% endif %}>
31
35
  <div class="mx-side-nav__parent" id="{{ id }}">
32
36
  {{ parent }}
33
37
  </div>
34
38
  {{ nav.menutree(items, 0, false, baseClass) }}
35
39
  </nav>
40
+ {% if collapsible %}
41
+ </mx-nav>
42
+ {% endif %}
@@ -81,7 +81,7 @@ import "@pnx-mixtape/mxds/src/Component/Translate/Elements/Translate"
81
81
  popovertarget="translate"
82
82
  style="anchor-name: --translate"
83
83
  data-label="Language"
84
- aria-label="Language"
84
+ aria-label="Language, EN"
85
85
  >
86
86
  <span class="mx-icon mx-icon--globe"></span>
87
87
  <span class="mx-translate__code">EN</span>
@@ -230,8 +230,10 @@ No BEM modifier classes.
230
230
  ## Accessibility
231
231
 
232
232
  - By default, the trigger's accessible name (`aria-label`) updates to reflect the active language
233
- (e.g. `"Language: Français"`) rather than staying static — pass `updateTriggerLabel={false}` to
233
+ (e.g. `"Language: Français, FR"`) rather than staying static — pass `updateTriggerLabel={false}` to
234
234
  keep it static instead, which some sites prefer.
235
+ - The accessible name always includes the visible language code (e.g. `EN`) so it satisfies WCAG
236
+ 2.5.3 Label in Name — speech-input users can activate the trigger by saying what they see.
235
237
  - Each language link gets `lang`/`hreflang` set to its own code, so screen readers pronounce each
236
238
  language name using that language's own phonetics.
237
239
  - The active language gets `aria-current="true"`, cleared from the previously active one.
@@ -44,7 +44,7 @@ export const Translate: Story = {
44
44
  // (and a fresh view of the story) always starts from the real default state.
45
45
  window.localStorage.removeItem("googtrans")
46
46
 
47
- const trigger = canvas.getByRole("button", { name: "Language" })
47
+ const trigger = canvas.getByRole("button", { name: /^Language\b/ })
48
48
  const menu = canvasElement.querySelector("#translate") as HTMLElement
49
49
  const french = await canvas.findByRole("menuitem", { name: "Français", hidden: true })
50
50
  // The notice is created via JS on first use (not pre-rendered) and inserted at the
@@ -71,7 +71,7 @@ export const Translate: Story = {
71
71
  async () => {
72
72
  await userEvent.click(french)
73
73
  await waitFor(() => expect(french).toHaveAttribute("aria-current", "true"))
74
- await expect(trigger).toHaveAttribute("aria-label", "Language: Français")
74
+ await expect(trigger).toHaveAttribute("aria-label", "Language: Français, FR")
75
75
  await waitFor(() => expect(findNotice()).not.toHaveAttribute("hidden"))
76
76
  },
77
77
  )
@@ -35,7 +35,7 @@ export const Translate: StoryObj<typeof meta> = {
35
35
  // (and a fresh view of the story) always starts from the real default state.
36
36
  window.localStorage.removeItem("googtrans")
37
37
 
38
- const trigger = canvas.getByRole("button", { name: "Language" })
38
+ const trigger = canvas.getByRole("button", { name: /^Language\b/ })
39
39
  const menu = canvasElement.querySelector("#translate") as HTMLElement
40
40
  const french = await canvas.findByRole("menuitem", { name: "Français", hidden: true })
41
41
  // The notice is created via JS on first use (not pre-rendered) and inserted at the
@@ -62,7 +62,7 @@ export const Translate: StoryObj<typeof meta> = {
62
62
  async () => {
63
63
  await userEvent.click(french)
64
64
  await waitFor(() => expect(french).toHaveAttribute("aria-current", "true"))
65
- await expect(trigger).toHaveAttribute("aria-label", "Language: Français")
65
+ await expect(trigger).toHaveAttribute("aria-label", "Language: Français, FR")
66
66
  await waitFor(() => expect(findNotice()).not.toHaveAttribute("hidden"))
67
67
  },
68
68
  )
@@ -186,7 +186,7 @@ const Translate = ({
186
186
  popoverTarget={rootId}
187
187
  style={triggerStyle}
188
188
  data-label={label}
189
- aria-label={label}
189
+ aria-label={`${label}, ${defaultLanguage.toUpperCase()}`}
190
190
  >
191
191
  <Icon icon={Icons.GLOBE} />
192
192
  <span className="mx-translate__code">{defaultLanguage.toUpperCase()}</span>
@@ -3,7 +3,7 @@
3
3
  exports[`Translate 1`] = `
4
4
  "
5
5
  <mx-translate class="mx-translate" closeonclick="" data-default-language="en" data-update-trigger-label="true" data-notice-content="Our website uses an automatic service to translate content. These translations should be used as a guide only.">
6
- <button id="unique-0" type="button" class="mx-button mx-button--light mx-button--small mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" style="anchor-name: --translate" data-label="Language" aria-label="Language: English">
6
+ <button id="unique-0" type="button" class="mx-button mx-button--light mx-button--small mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" style="anchor-name: --translate" data-label="Language" aria-label="Language: English, EN">
7
7
 
8
8
  <span class="mx-icon mx-icon--globe"></span>
9
9
  <span class="mx-translate__code">EN</span>
@@ -1,3 +1,3 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`Translate 1`] = `"<div class="mx-translate"><button type="button" id="translate-target" class="mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" data-label="Language" aria-label="Language: English" style="anchor-name: --translate;"><span class="mx-icon mx-icon--globe"></span><span class="mx-translate__code">EN</span></button><div id="translate" class="mx-popover mx-translate__menu notranslate" aria-describedby="translate-target" aria-labelledby="translate-target" data-placement="bottom-start" popover="" role="menu" style="position-anchor: --translate;"><span data-focus-scope-start="true" hidden=""></span><a class="mx-translate__item" href="?hl=en" lang="en" hreflang="en" role="menuitem" data-language="en" aria-current="true">English</a><a class="mx-translate__item" href="?hl=fr" lang="fr" hreflang="fr" role="menuitem" data-language="fr">Français</a><a class="mx-translate__item" href="?hl=es" lang="es" hreflang="es" role="menuitem" data-language="es">Español</a><span data-focus-scope-end="true" hidden=""></span></div></div>"`;
3
+ exports[`Translate 1`] = `"<div class="mx-translate"><button type="button" id="translate-target" class="mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" data-label="Language" aria-label="Language: English, EN" style="anchor-name: --translate;"><span class="mx-icon mx-icon--globe"></span><span class="mx-translate__code">EN</span></button><div id="translate" class="mx-popover mx-translate__menu notranslate" aria-describedby="translate-target" aria-labelledby="translate-target" data-placement="bottom-start" popover="" role="menu" style="position-anchor: --translate;"><span data-focus-scope-start="true" hidden=""></span><a class="mx-translate__item" href="?hl=en" lang="en" hreflang="en" role="menuitem" data-language="en" aria-current="true">English</a><a class="mx-translate__item" href="?hl=fr" lang="fr" hreflang="fr" role="menuitem" data-language="fr">Français</a><a class="mx-translate__item" href="?hl=es" lang="es" hreflang="es" role="menuitem" data-language="es">Español</a><span data-focus-scope-end="true" hidden=""></span></div></div>"`;