@statistikzh/leu 0.27.0 → 0.28.0

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 (202) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/.storybook/main.ts +1 -1
  3. package/.storybook/preview.ts +6 -4
  4. package/.storybook/static/global.css +5 -0
  5. package/CHANGELOG.md +23 -0
  6. package/dist/{Accordion-DLsqXcK8.js → Accordion-D9kLsiBW.js} +1 -1
  7. package/dist/Accordion.d.ts +1 -1
  8. package/dist/Accordion.js +2 -2
  9. package/dist/{Button-BgNUxmo_.d.ts → Button-DcuvEVkC.d.ts} +14 -12
  10. package/dist/{Button-BSyDL_cV.js → Button-DyNVOHCd.js} +90 -82
  11. package/dist/Button.d.ts +1 -1
  12. package/dist/Button.js +4 -4
  13. package/dist/{ButtonGroup-BmSvl-Oc.js → ButtonGroup-MEh4vb5a.js} +2 -2
  14. package/dist/ButtonGroup.d.ts +1 -1
  15. package/dist/ButtonGroup.js +5 -5
  16. package/dist/{ChartWrapper-CvDvQsd5.js → ChartWrapper-DAl91BIN.js} +2 -2
  17. package/dist/ChartWrapper.d.ts +3 -3
  18. package/dist/ChartWrapper.js +3 -3
  19. package/dist/{Checkbox-Cl_X6gBJ.js → Checkbox-CGGyUW9U.js} +2 -2
  20. package/dist/Checkbox.d.ts +3 -3
  21. package/dist/Checkbox.js +3 -3
  22. package/dist/{CheckboxGroup-BKhOmZYX.js → CheckboxGroup-DXt5iMdj.js} +2 -2
  23. package/dist/CheckboxGroup.d.ts +1 -1
  24. package/dist/CheckboxGroup.js +4 -4
  25. package/dist/{Chip-McVP3N_x.js → Chip-BGs71WGH.js} +1 -1
  26. package/dist/{Chip-DLKM9P7v.d.ts → Chip-DVGjEPJE.d.ts} +1 -1
  27. package/dist/Chip.d.ts +1 -1
  28. package/dist/Chip.js +2 -2
  29. package/dist/{ChipGroup-Ta8Ht4jc.d.ts → ChipGroup-BK5BYM0X.d.ts} +2 -2
  30. package/dist/{ChipGroup-DUGavZeU.js → ChipGroup-BcGyusP-.js} +1 -1
  31. package/dist/ChipGroup.d.ts +1 -1
  32. package/dist/ChipGroup.js +3 -3
  33. package/dist/{ChipLink-BAxyQO2M.d.ts → ChipLink-BdG2o-nk.d.ts} +1 -1
  34. package/dist/ChipLink.d.ts +1 -1
  35. package/dist/ChipLink.js +2 -2
  36. package/dist/{ChipRemovable-DBjwt0CH.d.ts → ChipRemovable-CCwSQTAL.d.ts} +2 -2
  37. package/dist/ChipRemovable.d.ts +1 -1
  38. package/dist/ChipRemovable.js +3 -3
  39. package/dist/{ChipSelectable-CMJNcE4U.d.ts → ChipSelectable-BQ3VLVi5.d.ts} +1 -1
  40. package/dist/ChipSelectable.d.ts +1 -1
  41. package/dist/ChipSelectable.js +2 -2
  42. package/dist/{Dialog-BlDd4T2u.js → Dialog-BzuyL1U3.js} +2 -2
  43. package/dist/Dialog.d.ts +3 -3
  44. package/dist/Dialog.js +3 -3
  45. package/dist/{Dropdown-BLxSIe6p.js → Dropdown-plyBTM15.js} +5 -5
  46. package/dist/Dropdown.d.ts +6 -6
  47. package/dist/Dropdown.js +8 -8
  48. package/dist/{FileInput-DntYrpZ-.js → FileInput-BT3Fe-0J.js} +4 -4
  49. package/dist/FileInput.d.ts +5 -5
  50. package/dist/FileInput.js +6 -6
  51. package/dist/{Icon-Op80LrrO.d.ts → Icon-CUfh3eb3.d.ts} +1 -1
  52. package/dist/{Icon-CbZXpyHU.js → Icon-D83qesg5.js} +1 -1
  53. package/dist/Icon.d.ts +1 -1
  54. package/dist/Icon.js +2 -2
  55. package/dist/{Input-DBXX7ev8.js → Input-D7zS50oz.js} +2 -2
  56. package/dist/{Input-CeaAOB4p.d.ts → Input-fEiQvGDF.d.ts} +3 -3
  57. package/dist/Input.d.ts +1 -1
  58. package/dist/Input.js +3 -3
  59. package/dist/{LeuElement-k4RjIeoG.js → LeuElement-DQI8cqZV.js} +1 -1
  60. package/dist/{Menu-Cu8eIF1T.js → Menu-DRU1LiMM.js} +2 -2
  61. package/dist/{Menu-CQdx1ef3.d.ts → Menu-Z2b7dsB5.d.ts} +2 -2
  62. package/dist/Menu.d.ts +1 -1
  63. package/dist/Menu.js +4 -4
  64. package/dist/{MenuItem-Cs3KFhJm.js → MenuItem-DCttylRO.js} +2 -2
  65. package/dist/{MenuItem-QcgnRk_7.d.ts → MenuItem-LY4TRIho.d.ts} +2 -2
  66. package/dist/MenuItem.d.ts +1 -1
  67. package/dist/MenuItem.js +3 -3
  68. package/dist/{Message-C6Zlk_2p.js → Message-0NxnKEqw.js} +2 -2
  69. package/dist/Message.d.ts +2 -2
  70. package/dist/Message.js +3 -3
  71. package/dist/{Pagination-CqkHh-Vd.d.ts → Pagination-9eZ8WMvR.d.ts} +4 -4
  72. package/dist/{Pagination-CB2eVlXk.js → Pagination-CIy7YvWE.js} +4 -4
  73. package/dist/Pagination.d.ts +1 -1
  74. package/dist/Pagination.js +6 -6
  75. package/dist/{Placeholder-DHMexMhK.js → Placeholder-Dol_X5Hp.js} +1 -1
  76. package/dist/Placeholder.d.ts +1 -1
  77. package/dist/Placeholder.js +2 -2
  78. package/dist/{Popup-Btgm2a3D.d.ts → Popup-B5iDSLIO.d.ts} +1 -1
  79. package/dist/{Popup-8jhVy8gB.js → Popup-nJrJHGSy.js} +1 -1
  80. package/dist/Popup.d.ts +1 -1
  81. package/dist/Popup.js +2 -2
  82. package/dist/{ProgressBar-CG0_lHfS.js → ProgressBar-Dmq9veqU.js} +1 -1
  83. package/dist/ProgressBar.d.ts +1 -1
  84. package/dist/ProgressBar.js +2 -2
  85. package/dist/{Radio-DG3xqP3s.js → Radio-W5ck_IJI.js} +1 -1
  86. package/dist/Radio.d.ts +1 -1
  87. package/dist/Radio.js +2 -2
  88. package/dist/{RadioGroup-BKCp9ICX.js → RadioGroup-De5x2YCO.js} +2 -2
  89. package/dist/RadioGroup.d.ts +1 -1
  90. package/dist/RadioGroup.js +3 -3
  91. package/dist/{Range-7LrESv4K.js → Range-NCdfDkeD.js} +1 -1
  92. package/dist/Range.d.ts +1 -1
  93. package/dist/Range.js +2 -2
  94. package/dist/{ScrollTop-CJJsfniA.js → ScrollTop-DwcNIKmN.js} +3 -3
  95. package/dist/ScrollTop.d.ts +3 -3
  96. package/dist/ScrollTop.js +5 -5
  97. package/dist/{Select-CxEDXIBn.js → Select-Bpicra9q.js} +7 -8
  98. package/dist/Select.d.ts +9 -9
  99. package/dist/Select.js +9 -9
  100. package/dist/{Spinner-VhKfzI3Q.js → Spinner-BBiVZxFH.js} +1 -1
  101. package/dist/{Spinner-CrM1enM0.d.ts → Spinner-DHYaX6-Y.d.ts} +1 -1
  102. package/dist/Spinner.d.ts +1 -1
  103. package/dist/Spinner.js +2 -2
  104. package/dist/Tab-CN97q0nj.d.ts +30 -0
  105. package/dist/Tab-Ce9nrDok.js +117 -0
  106. package/dist/Tab.d.ts +2 -0
  107. package/dist/Tab.js +3 -0
  108. package/dist/TabGroup-C-cd4Wcx.js +248 -0
  109. package/dist/TabGroup.d.ts +64 -0
  110. package/dist/TabGroup.js +5 -0
  111. package/dist/TabPanel-BW1ydVBT.js +65 -0
  112. package/dist/TabPanel-DQgWP7LO.d.ts +26 -0
  113. package/dist/TabPanel.d.ts +2 -0
  114. package/dist/TabPanel.js +3 -0
  115. package/dist/{Table-rg_JCtsA.js → Table-DiYqIzBu.js} +3 -3
  116. package/dist/Table.d.ts +3 -3
  117. package/dist/Table.js +7 -7
  118. package/dist/{Tag-BROUaDAZ.js → Tag-Ct8Hhv7W.js} +1 -1
  119. package/dist/Tag.d.ts +1 -1
  120. package/dist/Tag.js +2 -2
  121. package/dist/{VisuallyHidden-OeQvhxYn.d.ts → VisuallyHidden-CB7aRJzF.d.ts} +1 -1
  122. package/dist/{VisuallyHidden-Co_txzxB.js → VisuallyHidden-CpYXyuC7.js} +1 -1
  123. package/dist/VisuallyHidden.d.ts +1 -1
  124. package/dist/VisuallyHidden.js +2 -2
  125. package/dist/index.d.ts +14 -14
  126. package/dist/index.js +30 -30
  127. package/dist/leu-accordion.js +2 -2
  128. package/dist/leu-button-group.js +5 -5
  129. package/dist/leu-button.d.ts +1 -1
  130. package/dist/leu-button.js +4 -4
  131. package/dist/leu-chart-wrapper.js +3 -3
  132. package/dist/leu-checkbox-group.js +4 -4
  133. package/dist/leu-checkbox.js +3 -3
  134. package/dist/leu-chip-group.d.ts +1 -1
  135. package/dist/leu-chip-group.js +3 -3
  136. package/dist/leu-chip-link.d.ts +1 -1
  137. package/dist/leu-chip-link.js +2 -2
  138. package/dist/leu-chip-removable.d.ts +1 -1
  139. package/dist/leu-chip-removable.js +3 -3
  140. package/dist/leu-chip-selectable.d.ts +1 -1
  141. package/dist/leu-chip-selectable.js +2 -2
  142. package/dist/leu-dialog.js +3 -3
  143. package/dist/leu-dropdown.js +8 -8
  144. package/dist/leu-file-input.js +6 -6
  145. package/dist/leu-icon.d.ts +1 -1
  146. package/dist/leu-icon.js +2 -2
  147. package/dist/leu-input.d.ts +1 -1
  148. package/dist/leu-input.js +3 -3
  149. package/dist/leu-menu-item.d.ts +1 -1
  150. package/dist/leu-menu-item.js +3 -3
  151. package/dist/leu-menu.d.ts +1 -1
  152. package/dist/leu-menu.js +4 -4
  153. package/dist/leu-message.js +3 -3
  154. package/dist/leu-pagination.d.ts +1 -1
  155. package/dist/leu-pagination.js +6 -6
  156. package/dist/leu-placeholder.js +2 -2
  157. package/dist/leu-popup.d.ts +1 -1
  158. package/dist/leu-popup.js +2 -2
  159. package/dist/leu-progress-bar.js +2 -2
  160. package/dist/leu-radio-group.js +3 -3
  161. package/dist/leu-radio.js +2 -2
  162. package/dist/leu-range.js +2 -2
  163. package/dist/leu-scroll-top.js +5 -5
  164. package/dist/leu-select.js +9 -9
  165. package/dist/leu-spinner.d.ts +1 -1
  166. package/dist/leu-spinner.js +2 -2
  167. package/dist/leu-tab-group.d.ts +10 -0
  168. package/dist/leu-tab-group.js +8 -0
  169. package/dist/leu-tab-panel.d.ts +10 -0
  170. package/dist/leu-tab-panel.js +6 -0
  171. package/dist/leu-tab.d.ts +10 -0
  172. package/dist/leu-tab.js +6 -0
  173. package/dist/leu-table.js +7 -7
  174. package/dist/leu-tag.js +2 -2
  175. package/dist/leu-visually-hidden.d.ts +1 -1
  176. package/dist/leu-visually-hidden.js +2 -2
  177. package/dist/vscode.html-custom-data.json +76 -8
  178. package/dist/vue/index.d.ts +73 -5
  179. package/dist/web-types.json +142 -17
  180. package/package.json +1 -2
  181. package/src/components/button/Button.ts +45 -30
  182. package/src/components/button/button.css +55 -54
  183. package/src/components/button/stories/button.stories.ts +17 -20
  184. package/src/components/button/test/button.test.ts +46 -0
  185. package/src/components/select/Select.ts +0 -1
  186. package/src/components/tab/Tab.ts +72 -0
  187. package/src/components/tab/TabGroup.ts +267 -0
  188. package/src/components/tab/TabPanel.ts +59 -0
  189. package/src/components/tab/leu-tab-group.ts +11 -0
  190. package/src/components/tab/leu-tab-panel.ts +11 -0
  191. package/src/components/tab/leu-tab.ts +11 -0
  192. package/src/components/tab/stories/tab.stories.ts +97 -0
  193. package/src/components/tab/tab-group.css +63 -0
  194. package/src/components/tab/tab-panel.css +10 -0
  195. package/src/components/tab/tab.css +54 -0
  196. package/src/components/tab/test/tab-group.test.ts +426 -0
  197. package/src/components/tab/test/tab-panel.test.ts +102 -0
  198. package/src/components/tab/test/tab.test.ts +139 -0
  199. package/tsconfig.json +1 -0
  200. /package/dist/{FormAssociatedMixin-Cc74LjbC.d.ts → FormAssociatedMixin-Cw7LsSUE.d.ts} +0 -0
  201. /package/dist/{LeuElement-pJFU18Xm.d.ts → LeuElement-DK1jntuu.d.ts} +0 -0
  202. /package/dist/{hasSlotController-DWPyZ52b.d.ts → hasSlotController-BjKyhJm-.d.ts} +0 -0
@@ -0,0 +1,426 @@
1
+ import { html } from "lit"
2
+ import { ifDefined } from "lit/directives/if-defined.js"
3
+ import { fixture, expect, oneEvent } from "@open-wc/testing"
4
+ import { sendKeys } from "@web/test-runner-commands"
5
+ import { spy } from "sinon"
6
+
7
+ import "../leu-tab-group.js"
8
+ import "../leu-tab.js"
9
+ import "../leu-tab-panel.js"
10
+ import type { LeuTabGroup } from "../TabGroup.js"
11
+ import type { LeuTab } from "../Tab.js"
12
+ import type { LeuTabPanel } from "../TabPanel.js"
13
+
14
+ type TestArgs = {
15
+ active?: string
16
+ label?: string
17
+ }
18
+
19
+ async function defaultFixture(args: TestArgs = {}) {
20
+ return fixture<LeuTabGroup>(html`
21
+ <leu-tab-group
22
+ active=${ifDefined(args.active)}
23
+ label=${ifDefined(args.label)}
24
+ >
25
+ <leu-tab slot="tabs" name="online">Online</leu-tab>
26
+ <leu-tab-panel slot="panels" name="online"><p>Online</p></leu-tab-panel>
27
+
28
+ <leu-tab slot="tabs" name="vor-ort">Vor Ort</leu-tab>
29
+ <leu-tab-panel slot="panels" name="vor-ort"><p>Vor Ort</p></leu-tab-panel>
30
+
31
+ <leu-tab slot="tabs" name="per-post">Per Post</leu-tab>
32
+ <leu-tab-panel slot="panels" name="per-post">
33
+ <p>Per Post</p>
34
+ </leu-tab-panel>
35
+ </leu-tab-group>
36
+ `)
37
+ }
38
+
39
+ function getTabs(el: LeuTabGroup): LeuTab[] {
40
+ return Array.from(el.querySelectorAll<LeuTab>("leu-tab"))
41
+ }
42
+
43
+ function getPanels(el: LeuTabGroup): LeuTabPanel[] {
44
+ return Array.from(el.querySelectorAll<LeuTabPanel>("leu-tab-panel"))
45
+ }
46
+
47
+ // ─── Element registration ─────────────────────────────────────────────────────
48
+
49
+ describe("LeuTabGroup – element registration", () => {
50
+ it("leu-tab-group is a defined element", () => {
51
+ expect(customElements.get("leu-tab-group")).not.to.be.undefined
52
+ })
53
+ })
54
+
55
+ // ─── Accessibility ────────────────────────────────────────────────────────────
56
+
57
+ describe("LeuTabGroup – a11y", () => {
58
+ it("passes the a11y audit", async () => {
59
+ const el = await defaultFixture({ label: "Navigation" })
60
+ await expect(el).shadowDom.to.be.accessible()
61
+ })
62
+
63
+ it("sets aria-label on the tablist from the label attribute", async () => {
64
+ const el = await defaultFixture({ label: "Kanal wählen" })
65
+ const tablist = el.shadowRoot!.querySelector("[role='tablist']")
66
+ expect(tablist!.getAttribute("aria-label")).to.equal("Kanal wählen")
67
+ })
68
+ })
69
+
70
+ // ─── Active fallback ─────────────────────────────────────────────────────────
71
+
72
+ describe("LeuTabGroup – active fallback", () => {
73
+ it("activates the first tab when no active prop is set", async () => {
74
+ const el = await defaultFixture()
75
+ expect(el.active).to.equal("online")
76
+ })
77
+
78
+ it("activates the first tab when the active prop doesn't match any tab", async () => {
79
+ const el = await defaultFixture({ active: "nonexistent" })
80
+ expect(el.active).to.equal("online")
81
+ })
82
+
83
+ it("preserves a valid pre-set active value", async () => {
84
+ const el = await defaultFixture({ active: "vor-ort" })
85
+ expect(el.active).to.equal("vor-ort")
86
+ })
87
+
88
+ it("the active tab and panel are in sync", async () => {
89
+ const el = await defaultFixture()
90
+ const panels = getPanels(el)
91
+ const activePanel = panels.find((p) => p.active)
92
+ expect(activePanel?.getAttribute("name") ?? activePanel?.name).to.equal(
93
+ el.active,
94
+ )
95
+ })
96
+ })
97
+
98
+ // ─── Tab/panel state ──────────────────────────────────────────────────────────
99
+
100
+ describe("LeuTabGroup – tab and panel state", () => {
101
+ it("only one tab is active at a time", async () => {
102
+ const el = await defaultFixture()
103
+ const activeTabs = getTabs(el).filter((t) => t.active)
104
+ expect(activeTabs).to.have.length(1)
105
+ })
106
+
107
+ it("only one panel is active at a time", async () => {
108
+ const el = await defaultFixture()
109
+ const activePanels = getPanels(el).filter((p) => p.active)
110
+ expect(activePanels).to.have.length(1)
111
+ })
112
+
113
+ it("inactive tabs have ariaSelected=false", async () => {
114
+ const el = await defaultFixture()
115
+ const inactiveTabs = getTabs(el).filter((t) => !t.active)
116
+ for (const tab of inactiveTabs) {
117
+ expect(tab.ariaSelected).to.equal("false")
118
+ }
119
+ })
120
+
121
+ it("the active tab has ariaSelected=true", async () => {
122
+ const el = await defaultFixture()
123
+ const activeTab = getTabs(el).find((t) => t.active)!
124
+ expect(activeTab.ariaSelected).to.equal("true")
125
+ })
126
+ })
127
+
128
+ // ─── Linking tabs and panels ──────────────────────────────────────────────────
129
+
130
+ describe("LeuTabGroup – aria linking", () => {
131
+ it("each tab's aria-controls points to its panel's id", async () => {
132
+ const el = await defaultFixture()
133
+ const tabs = getTabs(el)
134
+ const panels = getPanels(el)
135
+
136
+ for (const tab of tabs) {
137
+ const panel = panels.find((p) => p.name === tab.name)!
138
+ expect(tab.getAttribute("aria-controls")).to.equal(panel.id)
139
+ }
140
+ })
141
+
142
+ it("each panel's aria-labelledby points to its tab's id", async () => {
143
+ const el = await defaultFixture()
144
+ const tabs = getTabs(el)
145
+ const panels = getPanels(el)
146
+
147
+ for (const panel of panels) {
148
+ const tab = tabs.find((t) => t.name === panel.name)!
149
+ expect(panel.getAttribute("aria-labelledby")).to.equal(tab.id)
150
+ }
151
+ })
152
+
153
+ it("uses custom ids provided by the user for aria linking", async () => {
154
+ const el = await fixture<LeuTabGroup>(html`
155
+ <leu-tab-group>
156
+ <leu-tab id="tab-custom" slot="tabs" name="a">A</leu-tab>
157
+ <leu-tab-panel id="panel-custom" slot="panels" name="a"
158
+ >A</leu-tab-panel
159
+ >
160
+ </leu-tab-group>
161
+ `)
162
+ const tab = el.querySelector<LeuTab>("leu-tab")!
163
+ const panel = el.querySelector<LeuTabPanel>("leu-tab-panel")!
164
+ expect(tab.getAttribute("aria-controls")).to.equal("panel-custom")
165
+ expect(panel.getAttribute("aria-labelledby")).to.equal("tab-custom")
166
+ })
167
+
168
+ it("re-links tabs and panels when a tab's id changes", async () => {
169
+ const el = await defaultFixture()
170
+ const tab = getTabs(el)[0]
171
+ const panel = getPanels(el)[0]
172
+
173
+ // Simulate id being set (e.g., late server render)
174
+ tab.id = "new-tab-id"
175
+
176
+ // MutationObserver fires asynchronously
177
+ await new Promise((r) => setTimeout(r, 0))
178
+ await el.updateComplete
179
+
180
+ expect(panel.getAttribute("aria-labelledby")).to.equal("new-tab-id")
181
+ })
182
+ })
183
+
184
+ // ─── Click selection ──────────────────────────────────────────────────────────
185
+
186
+ describe("LeuTabGroup – click selection", () => {
187
+ it("clicking an inactive tab makes it the active tab", async () => {
188
+ const el = await defaultFixture()
189
+ const [, secondTab] = getTabs(el)
190
+
191
+ secondTab.click()
192
+ await el.updateComplete
193
+
194
+ expect(el.active).to.equal("vor-ort")
195
+ })
196
+
197
+ it("clicking an inactive tab deactivates all other tabs", async () => {
198
+ const el = await defaultFixture()
199
+ const tabs = getTabs(el)
200
+
201
+ tabs[1].click()
202
+ await el.updateComplete
203
+
204
+ const activeTabs = tabs.filter((t) => t.active)
205
+ expect(activeTabs).to.have.length(1)
206
+ expect(activeTabs[0].name).to.equal("vor-ort")
207
+ })
208
+
209
+ it("clicking an inactive tab activates the corresponding panel", async () => {
210
+ const el = await defaultFixture()
211
+ const tabs = getTabs(el)
212
+ const panels = getPanels(el)
213
+
214
+ tabs[2].click()
215
+ await el.updateComplete
216
+
217
+ const activePanel = panels.find((p) => p.active)!
218
+ expect(activePanel.name).to.equal("per-post")
219
+ })
220
+
221
+ it("clicking an already-active tab has no effect", async () => {
222
+ const el = await defaultFixture()
223
+ const [firstTab] = getTabs(el)
224
+ expect(el.active).to.equal("online")
225
+
226
+ firstTab.click()
227
+ await el.updateComplete
228
+
229
+ expect(el.active).to.equal("online")
230
+ expect(getTabs(el).filter((t) => t.active)).to.have.length(1)
231
+ })
232
+
233
+ it("programmatically setting active changes the active tab and panel", async () => {
234
+ const el = await defaultFixture()
235
+
236
+ el.active = "per-post"
237
+ await el.updateComplete
238
+
239
+ const activeTab = getTabs(el).find((t) => t.active)!
240
+ const activePanel = getPanels(el).find((p) => p.active)!
241
+ expect(activeTab.name).to.equal("per-post")
242
+ expect(activePanel.name).to.equal("per-post")
243
+ })
244
+ })
245
+
246
+ // ─── leu:show-tab-panel event ─────────────────────────────────────────────────
247
+
248
+ describe("LeuTabGroup – leu:show-tab-panel event", () => {
249
+ it("fires leu:show-tab-panel when clicking an inactive tab", async () => {
250
+ const el = await defaultFixture()
251
+ const [, secondTab] = getTabs(el)
252
+
253
+ setTimeout(() => secondTab.click())
254
+ const event = (await oneEvent(el, "leu:show-tab-panel")) as CustomEvent
255
+
256
+ expect(event).to.exist
257
+ expect(event.detail.name).to.equal("vor-ort")
258
+ })
259
+
260
+ it("fires leu:show-tab-panel when active is changed programmatically", async () => {
261
+ const el = await defaultFixture()
262
+
263
+ setTimeout(() => {
264
+ el.active = "per-post"
265
+ })
266
+ const event = (await oneEvent(el, "leu:show-tab-panel")) as CustomEvent
267
+
268
+ expect(event.detail.name).to.equal("per-post")
269
+ })
270
+
271
+ it("does NOT fire leu:show-tab-panel when clicking an already-active tab", async () => {
272
+ const el = await defaultFixture()
273
+ const [firstTab] = getTabs(el)
274
+
275
+ let firedCount = 0
276
+ el.addEventListener("leu:show-tab-panel", () => {
277
+ firedCount++
278
+ })
279
+
280
+ firstTab.click()
281
+ await el.updateComplete
282
+
283
+ expect(firedCount).to.equal(0)
284
+ })
285
+
286
+ it("fires leu:show-tab-panel on initial render when active is not pre-set", async () => {
287
+ const showSpy = spy()
288
+
289
+ await fixture<HTMLDivElement>(html`
290
+ <div @leu:show-tab-panel=${showSpy}>
291
+ <leu-tab-group>
292
+ <leu-tab slot="tabs" name="a">A</leu-tab>
293
+ <leu-tab-panel slot="panels" name="a">A</leu-tab-panel>
294
+ <leu-tab slot="tabs" name="b">B</leu-tab>
295
+ <leu-tab-panel slot="panels" name="b">B</leu-tab-panel>
296
+ </leu-tab-group>
297
+ </div>
298
+ `)
299
+
300
+ expect(showSpy).to.be.calledOnce
301
+ expect(showSpy.args[0][0].detail.name).to.equal("a")
302
+ })
303
+
304
+ it("fires leu:show-tab-panel on initial render when active is pre-set", async () => {
305
+ const showSpy = spy()
306
+
307
+ await fixture<HTMLDivElement>(html`
308
+ <div @leu:show-tab-panel=${showSpy}>
309
+ <leu-tab-group active="b">
310
+ <leu-tab slot="tabs" name="a">A</leu-tab>
311
+ <leu-tab-panel slot="panels" name="a">A</leu-tab-panel>
312
+ <leu-tab slot="tabs" name="b">B</leu-tab>
313
+ <leu-tab-panel slot="panels" name="b">B</leu-tab-panel>
314
+ </leu-tab-group>
315
+ </div>
316
+ `)
317
+
318
+ expect(showSpy).to.be.calledOnce
319
+ expect(showSpy.args[0][0].detail.name).to.equal("b")
320
+ })
321
+ })
322
+
323
+ // ─── Keyboard control ─────────────────────────────────────────────────────────
324
+
325
+ describe("LeuTabGroup – keyboard control", () => {
326
+ it("ArrowRight moves to the next tab", async () => {
327
+ const el = await defaultFixture()
328
+
329
+ await sendKeys({ press: "Tab" })
330
+ await sendKeys({ press: "ArrowRight" })
331
+ await el.updateComplete
332
+
333
+ expect(el.active).to.equal("vor-ort")
334
+ })
335
+
336
+ it("ArrowLeft moves to the previous tab", async () => {
337
+ const el = await defaultFixture({ active: "vor-ort" })
338
+
339
+ await sendKeys({ press: "Tab" })
340
+ await sendKeys({ press: "ArrowLeft" })
341
+ await el.updateComplete
342
+
343
+ expect(el.active).to.equal("online")
344
+ })
345
+
346
+ it("ArrowRight wraps from the last tab to the first", async () => {
347
+ const el = await defaultFixture({ active: "per-post" })
348
+
349
+ await sendKeys({ press: "Tab" })
350
+ await sendKeys({ press: "ArrowRight" })
351
+ await el.updateComplete
352
+
353
+ expect(el.active).to.equal("online")
354
+ })
355
+
356
+ it("ArrowLeft wraps from the first tab to the last", async () => {
357
+ const el = await defaultFixture()
358
+
359
+ await sendKeys({ press: "Tab" })
360
+ await sendKeys({ press: "ArrowLeft" })
361
+ await el.updateComplete
362
+
363
+ expect(el.active).to.equal("per-post")
364
+ })
365
+
366
+ it("Home key moves to the first tab", async () => {
367
+ const el = await defaultFixture({ active: "per-post" })
368
+
369
+ await sendKeys({ press: "Tab" })
370
+ await sendKeys({ press: "Home" })
371
+ await el.updateComplete
372
+
373
+ expect(el.active).to.equal("online")
374
+ })
375
+
376
+ it("End key moves to the last tab", async () => {
377
+ const el = await defaultFixture()
378
+
379
+ await sendKeys({ press: "Tab" })
380
+ await sendKeys({ press: "End" })
381
+ await el.updateComplete
382
+
383
+ expect(el.active).to.equal("per-post")
384
+ })
385
+
386
+ it("keyboard navigation moves focus to the newly active tab", async () => {
387
+ const el = await defaultFixture()
388
+ const tabs = getTabs(el)
389
+
390
+ await sendKeys({ press: "Tab" })
391
+ await sendKeys({ press: "ArrowRight" })
392
+ await el.updateComplete
393
+ await new Promise((r) => setTimeout(r, 0))
394
+
395
+ expect(document.activeElement).to.equal(tabs[1])
396
+ })
397
+
398
+ it("non-navigation keys do not change the active tab", async () => {
399
+ const el = await defaultFixture()
400
+
401
+ await sendKeys({ press: "Tab" })
402
+ for (const key of ["ArrowUp", "ArrowDown", "Enter", "Space"]) {
403
+ await sendKeys({ press: key })
404
+ }
405
+ await el.updateComplete
406
+
407
+ expect(el.active).to.equal("online")
408
+ })
409
+
410
+ it("keydown on an unfocused element does not trigger navigation", async () => {
411
+ // A synthetic keydown dispatched on the host (not from a focused leu-tab)
412
+ // must not reach the shadow-DOM tablist listener, so navigation must not occur.
413
+ const el = await defaultFixture()
414
+
415
+ el.dispatchEvent(
416
+ new KeyboardEvent("keydown", {
417
+ key: "ArrowRight",
418
+ bubbles: true,
419
+ composed: true,
420
+ }),
421
+ )
422
+ await el.updateComplete
423
+
424
+ expect(el.active).to.equal("online")
425
+ })
426
+ })
@@ -0,0 +1,102 @@
1
+ import { html } from "lit"
2
+ import { fixture, expect } from "@open-wc/testing"
3
+
4
+ import "../leu-tab-panel.js"
5
+ import type { LeuTabPanel } from "../TabPanel.js"
6
+
7
+ async function defaultFixture() {
8
+ return fixture<LeuTabPanel>(
9
+ html`<leu-tab-panel name="test">Content</leu-tab-panel>`,
10
+ )
11
+ }
12
+
13
+ async function activeFixture() {
14
+ return fixture<LeuTabPanel>(
15
+ html`<leu-tab-panel name="test" active>Content</leu-tab-panel>`,
16
+ )
17
+ }
18
+
19
+ describe("LeuTabPanel", () => {
20
+ it("is a defined element", () => {
21
+ expect(customElements.get("leu-tab-panel")).not.to.be.undefined
22
+ })
23
+
24
+ it("passes the a11y audit in a proper tab context", async () => {
25
+ const wrapper = await fixture(html`
26
+ <div>
27
+ <div role="tablist">
28
+ <div id="panel-tab" role="tab" aria-selected="true" tabindex="0">
29
+ Tab
30
+ </div>
31
+ </div>
32
+ <leu-tab-panel aria-labelledby="panel-tab" name="test" active
33
+ >Content</leu-tab-panel
34
+ >
35
+ </div>
36
+ `)
37
+ await expect(wrapper).to.be.accessible()
38
+ })
39
+
40
+ it("sets role=tabpanel on the host element", async () => {
41
+ const el = await defaultFixture()
42
+ expect(el.getAttribute("role")).to.equal("tabpanel")
43
+ })
44
+
45
+ it("sets tabIndex=0 on the host element", async () => {
46
+ const el = await defaultFixture()
47
+ expect(el.tabIndex).to.equal(0)
48
+ })
49
+
50
+ it("auto-generates an id when none is provided", async () => {
51
+ const el = await defaultFixture()
52
+ expect(el.id).to.match(/^leu-tab-panel-\d+$/)
53
+ })
54
+
55
+ it("keeps a user-provided id", async () => {
56
+ const el = await fixture<LeuTabPanel>(
57
+ html`<leu-tab-panel id="my-panel" name="test">Content</leu-tab-panel>`,
58
+ )
59
+ expect(el.id).to.equal("my-panel")
60
+ })
61
+
62
+ it("generates distinct ids for separate instances", async () => {
63
+ const el1 = await fixture<LeuTabPanel>(
64
+ html`<leu-tab-panel name="a">A</leu-tab-panel>`,
65
+ )
66
+ const el2 = await fixture<LeuTabPanel>(
67
+ html`<leu-tab-panel name="b">B</leu-tab-panel>`,
68
+ )
69
+ expect(el1.id).not.to.equal(el2.id)
70
+ })
71
+
72
+ it("has ariaHidden=true when inactive (default)", async () => {
73
+ const el = await defaultFixture()
74
+ expect(el.ariaHidden).to.equal("true")
75
+ })
76
+
77
+ it("has ariaHidden=false when active", async () => {
78
+ const el = await activeFixture()
79
+ expect(el.ariaHidden).to.equal("false")
80
+ })
81
+
82
+ it("updates ariaHidden reactively when active changes", async () => {
83
+ const el = await defaultFixture()
84
+
85
+ el.active = true
86
+ await el.updateComplete
87
+ expect(el.ariaHidden).to.equal("false")
88
+
89
+ el.active = false
90
+ await el.updateComplete
91
+ expect(el.ariaHidden).to.equal("true")
92
+ })
93
+
94
+ it("renders slotted content", async () => {
95
+ const el = await fixture<LeuTabPanel>(
96
+ html`<leu-tab-panel name="test" active
97
+ ><p>Panel content</p></leu-tab-panel
98
+ >`,
99
+ )
100
+ expect(el).to.have.text("Panel content")
101
+ })
102
+ })
@@ -0,0 +1,139 @@
1
+ import { html } from "lit"
2
+ import { fixture, expect, oneEvent } from "@open-wc/testing"
3
+
4
+ import "../leu-tab.js"
5
+ import type { LeuTab } from "../Tab.js"
6
+
7
+ async function defaultFixture() {
8
+ return fixture<LeuTab>(html`<leu-tab name="test">Test</leu-tab>`)
9
+ }
10
+
11
+ async function activeFixture() {
12
+ return fixture<LeuTab>(html`<leu-tab name="test" active>Test</leu-tab>`)
13
+ }
14
+
15
+ describe("LeuTab", () => {
16
+ it("is a defined element", () => {
17
+ expect(customElements.get("leu-tab")).not.to.be.undefined
18
+ })
19
+
20
+ it("passes the a11y audit inside a tablist", async () => {
21
+ const wrapper = await fixture(html`
22
+ <div role="tablist">
23
+ <leu-tab name="test" active>Test</leu-tab>
24
+ </div>
25
+ `)
26
+ await expect(wrapper).to.be.accessible()
27
+ })
28
+
29
+ it("sets role=tab on the host element", async () => {
30
+ const el = await defaultFixture()
31
+ expect(el.getAttribute("role")).to.equal("tab")
32
+ })
33
+
34
+ it("auto-generates an id when none is provided", async () => {
35
+ const el = await defaultFixture()
36
+ expect(el.id).to.match(/^leu-tab-\d+$/)
37
+ })
38
+
39
+ it("keeps a user-provided id", async () => {
40
+ const el = await fixture<LeuTab>(
41
+ html`<leu-tab id="my-custom-tab" name="test">Test</leu-tab>`,
42
+ )
43
+ expect(el.id).to.equal("my-custom-tab")
44
+ })
45
+
46
+ it("generates distinct ids for separate instances", async () => {
47
+ const el1 = await fixture<LeuTab>(html`<leu-tab name="a">A</leu-tab>`)
48
+ const el2 = await fixture<LeuTab>(html`<leu-tab name="b">B</leu-tab>`)
49
+ expect(el1.id).not.to.equal(el2.id)
50
+ })
51
+
52
+ it("has ariaSelected=false and tabIndex=-1 when inactive", async () => {
53
+ const el = await defaultFixture()
54
+ expect(el.ariaSelected).to.equal("false")
55
+ expect(el.tabIndex).to.equal(-1)
56
+ })
57
+
58
+ it("has ariaSelected=true and tabIndex=0 when active", async () => {
59
+ const el = await activeFixture()
60
+ expect(el.ariaSelected).to.equal("true")
61
+ expect(el.tabIndex).to.equal(0)
62
+ })
63
+
64
+ it("updates ariaSelected and tabIndex reactively when active changes", async () => {
65
+ const el = await defaultFixture()
66
+
67
+ el.active = true
68
+ await el.updateComplete
69
+ expect(el.ariaSelected).to.equal("true")
70
+ expect(el.tabIndex).to.equal(0)
71
+
72
+ el.active = false
73
+ await el.updateComplete
74
+ expect(el.ariaSelected).to.equal("false")
75
+ expect(el.tabIndex).to.equal(-1)
76
+ })
77
+
78
+ it("reflects the active property as a boolean attribute", async () => {
79
+ const el = await defaultFixture()
80
+ expect(el.hasAttribute("active")).to.be.false
81
+
82
+ el.active = true
83
+ await el.updateComplete
84
+ expect(el.hasAttribute("active")).to.be.true
85
+
86
+ el.active = false
87
+ await el.updateComplete
88
+ expect(el.hasAttribute("active")).to.be.false
89
+ })
90
+
91
+ it("reflects the name property as an attribute", async () => {
92
+ const el = await defaultFixture()
93
+ expect(el.getAttribute("name")).to.equal("test")
94
+ })
95
+
96
+ it("sets active=true when clicked", async () => {
97
+ const el = await defaultFixture()
98
+ expect(el.active).to.be.false
99
+ el.click()
100
+ expect(el.active).to.be.true
101
+ })
102
+
103
+ it("dispatches leu:tab-select with the tab name when clicked", async () => {
104
+ const el = await fixture<LeuTab>(
105
+ html`<leu-tab name="my-tab">Test</leu-tab>`,
106
+ )
107
+ setTimeout(() => el.click())
108
+ const event = (await oneEvent(el, "leu:tab-select")) as CustomEvent
109
+ expect(event.detail.name).to.equal("my-tab")
110
+ })
111
+
112
+ it("dispatches leu:tab-select as a bubbling composed event", async () => {
113
+ const el = await fixture<LeuTab>(
114
+ html`<leu-tab name="bubble-test">Test</leu-tab>`,
115
+ )
116
+ // Listen on document to verify the event crosses shadow boundaries
117
+ setTimeout(() => el.click())
118
+ const event = (await oneEvent(document, "leu:tab-select")) as CustomEvent
119
+ expect(event.bubbles).to.be.true
120
+ expect(event.composed).to.be.true
121
+ expect(event.detail.name).to.equal("bubble-test")
122
+ })
123
+
124
+ it("does not dispatch leu:tab-select when already active", async () => {
125
+ const el = await activeFixture()
126
+ let fired = false
127
+ el.addEventListener("leu:tab-select", () => {
128
+ fired = true
129
+ })
130
+ el.click()
131
+ expect(fired).to.be.false
132
+ })
133
+
134
+ it("does not toggle off when clicking an already-active tab", async () => {
135
+ const el = await activeFixture()
136
+ el.click()
137
+ expect(el.active).to.be.true
138
+ })
139
+ })
package/tsconfig.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "include": ["src/**/*.js", "src/**/*.ts", "global.d.ts"],
3
3
  "exclude": ["node_modules", "dist"],
4
4
  "compilerOptions": {
5
+ "types": ["mocha"],
5
6
  "target": "es2021",
6
7
  "module": "esnext",
7
8
  "moduleResolution": "bundler",