@oslokommune/punkt-react 15.4.7 → 16.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "15.4.7",
3
+ "version": "16.0.1",
4
4
  "description": "React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo",
5
5
  "homepage": "https://punkt.oslo.kommune.no",
6
6
  "author": "Team Designsystem, Oslo Origo",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@lit-labs/ssr-dom-shim": "^1.2.1",
41
41
  "@lit/react": "^1.0.7",
42
- "@oslokommune/punkt-elements": "^15.4.5",
42
+ "@oslokommune/punkt-elements": "^16.0.0",
43
43
  "classnames": "^2.5.1",
44
44
  "prettier": "^3.3.3",
45
45
  "react-hook-form": "^7.53.0"
@@ -49,8 +49,8 @@
49
49
  "@eslint/compat": "^2.0.2",
50
50
  "@eslint/eslintrc": "^3.3.3",
51
51
  "@eslint/js": "^9.37.0",
52
- "@oslokommune/punkt-assets": "^15.0.0",
53
- "@oslokommune/punkt-css": "^15.4.4",
52
+ "@oslokommune/punkt-assets": "^16.0.0",
53
+ "@oslokommune/punkt-css": "^16.0.0",
54
54
  "@testing-library/jest-dom": "^6.5.0",
55
55
  "@testing-library/react": "^16.0.1",
56
56
  "@testing-library/user-event": "^14.5.2",
@@ -109,5 +109,5 @@
109
109
  "url": "https://github.com/oslokommune/punkt/issues"
110
110
  },
111
111
  "license": "MIT",
112
- "gitHead": "53dc69a861f5e7a497bd946595ea9737df0bca5c"
112
+ "gitHead": "4723e100e99cc759cce3ee23c065c1af1d5ceb86"
113
113
  }
@@ -0,0 +1,277 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render } from '@testing-library/react'
4
+ import { axe, toHaveNoViolations } from 'jest-axe'
5
+
6
+ import type { IPktComboboxOption } from 'shared-types/combobox'
7
+ import { PktCombobox } from './Combobox'
8
+ import type { IPktCombobox } from './types'
9
+
10
+ expect.extend(toHaveNoViolations)
11
+
12
+ const comboboxId = 'test-combobox'
13
+ const label = 'Test Combobox'
14
+
15
+ const getDefaultOptions = (): IPktComboboxOption[] => [
16
+ { value: 'apple', label: 'Apple' },
17
+ { value: 'banana', label: 'Banana' },
18
+ { value: 'cherry', label: 'Cherry' },
19
+ ]
20
+
21
+ const createComboboxTest = (props: Partial<IPktCombobox> = {}) => {
22
+ const defaultProps: IPktCombobox = {
23
+ label,
24
+ id: comboboxId,
25
+ ...props,
26
+ }
27
+
28
+ return render(<PktCombobox {...defaultProps} />)
29
+ }
30
+
31
+ describe('PktCombobox', () => {
32
+ describe('Accessibility (axe)', () => {
33
+ test('basic combobox has no accessibility violations', async () => {
34
+ const { container } = createComboboxTest()
35
+
36
+ const results = await axe(container)
37
+ expect(results).toHaveNoViolations()
38
+ })
39
+
40
+ test('combobox with options has no accessibility violations', async () => {
41
+ const { container } = createComboboxTest({
42
+ options: getDefaultOptions(),
43
+ })
44
+
45
+ const results = await axe(container)
46
+ expect(results).toHaveNoViolations()
47
+ })
48
+
49
+ test('combobox with text input has no accessibility violations', async () => {
50
+ const { container } = createComboboxTest({
51
+ allowUserInput: true,
52
+ options: getDefaultOptions(),
53
+ })
54
+
55
+ const results = await axe(container)
56
+ expect(results).toHaveNoViolations()
57
+ })
58
+
59
+ test('combobox with typeahead has no accessibility violations', async () => {
60
+ const { container } = createComboboxTest({
61
+ typeahead: true,
62
+ options: getDefaultOptions(),
63
+ })
64
+
65
+ const results = await axe(container)
66
+ expect(results).toHaveNoViolations()
67
+ })
68
+
69
+ test('multiple combobox has no accessibility violations', async () => {
70
+ const { container } = createComboboxTest({
71
+ multiple: true,
72
+ options: getDefaultOptions(),
73
+ })
74
+
75
+ // Exclude nested-interactive: the decorative checkbox inside li[role="option"]
76
+ // is aria-hidden and non-focusable, but axe flags it anyway.
77
+ // Selection state is conveyed by aria-selected on the option element.
78
+ const results = await axe(container, {
79
+ rules: { 'nested-interactive': { enabled: false } },
80
+ })
81
+ expect(results).toHaveNoViolations()
82
+ })
83
+
84
+ test('disabled combobox has no accessibility violations', async () => {
85
+ const { container } = createComboboxTest({ disabled: true })
86
+
87
+ const results = await axe(container)
88
+ expect(results).toHaveNoViolations()
89
+ })
90
+
91
+ test('combobox with error state has no accessibility violations', async () => {
92
+ const { container } = createComboboxTest({
93
+ hasError: true,
94
+ errorMessage: 'Required field',
95
+ })
96
+
97
+ const results = await axe(container)
98
+ expect(results).toHaveNoViolations()
99
+ })
100
+
101
+ test('combobox with selected value has no accessibility violations', async () => {
102
+ const { container } = createComboboxTest({
103
+ defaultValue: 'apple',
104
+ options: getDefaultOptions(),
105
+ })
106
+
107
+ const results = await axe(container)
108
+ expect(results).toHaveNoViolations()
109
+ })
110
+
111
+ test('combobox with multiple selected values has no accessibility violations', async () => {
112
+ const { container } = createComboboxTest({
113
+ multiple: true,
114
+ defaultValue: ['apple', 'banana'],
115
+ options: getDefaultOptions(),
116
+ })
117
+
118
+ // Exclude nested-interactive: decorative checkboxes inside options (see above)
119
+ const results = await axe(container, {
120
+ rules: { 'nested-interactive': { enabled: false } },
121
+ })
122
+ expect(results).toHaveNoViolations()
123
+ })
124
+ })
125
+
126
+ describe('ARIA attributes', () => {
127
+ test('select-only combobox has correct ARIA attributes', () => {
128
+ const { container } = createComboboxTest()
129
+
130
+ const comboboxInput = container.querySelector('.pkt-combobox__input')
131
+
132
+ expect(comboboxInput?.getAttribute('role')).toBe('combobox')
133
+ expect(comboboxInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`)
134
+ expect(comboboxInput?.getAttribute('aria-haspopup')).toBe('listbox')
135
+ expect(comboboxInput?.getAttribute('aria-expanded')).toBe('false')
136
+ expect(comboboxInput?.getAttribute('aria-labelledby')).toBe(`${comboboxId}-combobox-label`)
137
+ })
138
+
139
+ test('combobox aria-expanded updates when dropdown opens', () => {
140
+ const { container } = createComboboxTest()
141
+
142
+ const comboboxInput = container.querySelector('.pkt-combobox__input')
143
+ expect(comboboxInput?.getAttribute('aria-expanded')).toBe('false')
144
+
145
+ fireEvent.click(comboboxInput!)
146
+
147
+ expect(comboboxInput?.getAttribute('aria-expanded')).toBe('true')
148
+ })
149
+
150
+ test('text input has correct ARIA attributes for allowUserInput', () => {
151
+ const { container } = createComboboxTest({ allowUserInput: true })
152
+
153
+ const textInput = container.querySelector('input[type="text"][role="combobox"]')
154
+
155
+ expect(textInput?.getAttribute('role')).toBe('combobox')
156
+ expect(textInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`)
157
+ expect(textInput?.getAttribute('aria-label')).toBe(label)
158
+ expect(textInput?.getAttribute('aria-autocomplete')).toBe('list')
159
+ })
160
+
161
+ test('text input has correct ARIA attributes for typeahead', () => {
162
+ const { container } = createComboboxTest({ typeahead: true })
163
+
164
+ const textInput = container.querySelector('input[type="text"]')
165
+ expect(textInput?.getAttribute('aria-autocomplete')).toBe('both')
166
+ })
167
+
168
+ test('text input sets aria-activedescendant when value is selected', () => {
169
+ const { container } = createComboboxTest({
170
+ allowUserInput: true,
171
+ defaultValue: 'apple',
172
+ options: getDefaultOptions(),
173
+ })
174
+
175
+ const textInput = container.querySelector('input[type="text"]')
176
+ expect(textInput?.getAttribute('aria-activedescendant')).toBeTruthy()
177
+ })
178
+
179
+ test('text input aria-expanded reflects dropdown state', () => {
180
+ const { container } = createComboboxTest({ allowUserInput: true })
181
+
182
+ const textInput = container.querySelector('input[type="text"]')
183
+ expect(textInput?.getAttribute('aria-expanded')).toBe('false')
184
+
185
+ fireEvent.focus(textInput!)
186
+
187
+ expect(textInput?.getAttribute('aria-expanded')).toBe('true')
188
+ })
189
+
190
+ test('listbox has correct id for aria-controls reference', () => {
191
+ const { container } = createComboboxTest()
192
+
193
+ const listbox = container.querySelector('.pkt-listbox')
194
+ expect(listbox?.getAttribute('id')).toBe(`${comboboxId}-listbox`)
195
+ })
196
+
197
+ test('listbox has role=listbox', () => {
198
+ const { container } = createComboboxTest({ options: getDefaultOptions() })
199
+
200
+ const listbox = container.querySelector('.pkt-listbox')
201
+ expect(listbox?.getAttribute('role')).toBe('listbox')
202
+ })
203
+
204
+ test('listbox has aria-label with component label', () => {
205
+ const { container } = createComboboxTest({ options: getDefaultOptions() })
206
+
207
+ const listbox = container.querySelector('.pkt-listbox')
208
+ expect(listbox?.getAttribute('aria-label')).toBe(`Liste: ${label}`)
209
+ })
210
+
211
+ test('options have role=option with aria-selected', () => {
212
+ const { container } = createComboboxTest({
213
+ defaultValue: 'apple',
214
+ options: getDefaultOptions(),
215
+ })
216
+
217
+ const options = container.querySelectorAll('.pkt-listbox__option')
218
+ expect(options[0].getAttribute('role')).toBe('option')
219
+ expect(options[0].getAttribute('aria-selected')).toBe('true')
220
+ expect(options[1].getAttribute('aria-selected')).toBe('false')
221
+ })
222
+ })
223
+
224
+ describe('Keyboard accessibility', () => {
225
+ test('select-only combobox is focusable when not disabled', () => {
226
+ const { container } = createComboboxTest()
227
+
228
+ const comboboxInput = container.querySelector('.pkt-combobox__input') as HTMLElement
229
+ expect(comboboxInput.getAttribute('tabindex')).toBe('0')
230
+ })
231
+
232
+ test('select-only combobox is not focusable when disabled', () => {
233
+ const { container } = createComboboxTest({ disabled: true })
234
+
235
+ const comboboxInput = container.querySelector('.pkt-combobox__input') as HTMLElement
236
+ expect(comboboxInput.getAttribute('tabindex')).toBe('-1')
237
+ })
238
+
239
+ test('text input is part of tab order', () => {
240
+ const { container } = createComboboxTest({ allowUserInput: true })
241
+
242
+ const textInput = container.querySelector('input[type="text"]') as HTMLElement
243
+ expect(textInput).toBeInTheDocument()
244
+ // Text inputs are naturally tabbable (no tabindex needed)
245
+ expect(textInput.hasAttribute('tabindex')).toBe(false)
246
+ })
247
+
248
+ test('text input is disabled when component is disabled', () => {
249
+ const { container } = createComboboxTest({
250
+ allowUserInput: true,
251
+ disabled: true,
252
+ })
253
+
254
+ const textInput = container.querySelector('input[type="text"]') as HTMLInputElement
255
+ expect(textInput).toBeDisabled()
256
+ })
257
+ })
258
+
259
+ describe('Label association', () => {
260
+ test('input wrapper label targets text input when allowUserInput', () => {
261
+ const { container } = createComboboxTest({ allowUserInput: true })
262
+
263
+ const labelEl = container.querySelector('label')
264
+ expect(labelEl).toBeInTheDocument()
265
+ expect(labelEl?.getAttribute('for')).toBe(`${comboboxId}-input`)
266
+ })
267
+
268
+ test('input wrapper uses fieldset/legend when no text input (select-only)', () => {
269
+ const { container } = createComboboxTest()
270
+
271
+ const fieldset = container.querySelector('fieldset')
272
+ expect(fieldset).toBeInTheDocument()
273
+ const legend = container.querySelector('legend')
274
+ expect(legend).toBeInTheDocument()
275
+ })
276
+ })
277
+ })