@oslokommune/punkt-react 15.4.6 → 16.0.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 (29) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/index.d.ts +38 -15
  3. package/dist/punkt-react.es.js +12025 -10664
  4. package/dist/punkt-react.umd.js +562 -549
  5. package/package.json +5 -5
  6. package/src/components/accordion/Accordion.test.tsx +3 -2
  7. package/src/components/alert/Alert.test.tsx +2 -1
  8. package/src/components/backlink/BackLink.test.tsx +2 -1
  9. package/src/components/button/Button.test.tsx +4 -3
  10. package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
  11. package/src/components/checkbox/Checkbox.test.tsx +2 -1
  12. package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
  13. package/src/components/combobox/Combobox.core.test.tsx +469 -0
  14. package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
  15. package/src/components/combobox/Combobox.selection.test.tsx +548 -0
  16. package/src/components/combobox/Combobox.tsx +59 -54
  17. package/src/components/combobox/ComboboxInput.tsx +140 -0
  18. package/src/components/combobox/ComboboxTags.tsx +110 -0
  19. package/src/components/combobox/Listbox.tsx +172 -0
  20. package/src/components/combobox/types.ts +145 -0
  21. package/src/components/combobox/useComboboxState.ts +1141 -0
  22. package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
  23. package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
  24. package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
  25. package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
  26. package/src/components/radio/RadioButton.test.tsx +3 -2
  27. package/src/components/searchinput/SearchInput.test.tsx +6 -5
  28. package/src/components/tabs/Tabs.test.tsx +13 -12
  29. package/src/components/tag/Tag.test.tsx +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "15.4.6",
3
+ "version": "16.0.0",
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": "03d974d6df2ab807ecb13eb54ec6073234bab2b5"
112
+ "gitHead": "b3e88b7c06f9a63c2e911693af6b9860cd73c27b"
113
113
  }
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom'
2
2
 
3
3
  import { fireEvent,render, screen } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
+ import { vi } from 'vitest'
5
6
  import { createRef } from 'react'
6
7
 
7
8
  import { PktAccordion } from './Accordion'
@@ -22,7 +23,7 @@ describe('PktAccordion', () => {
22
23
  })
23
24
 
24
25
  test('renders children', () => {
25
- const mockToggleOpen = jest.fn()
26
+ const mockToggleOpen = vi.fn()
26
27
  render(
27
28
  <PktAccordion>
28
29
  <PktAccordionItem title="Title 1" id="item1" onClick={mockToggleOpen}>
@@ -144,7 +145,7 @@ describe('PktAccordionItem', () => {
144
145
  })
145
146
 
146
147
  test('calls onClick handler', () => {
147
- const mockOnClick = jest.fn()
148
+ const mockOnClick = vi.fn()
148
149
  const { container } = render(
149
150
  <PktAccordionItem title="Title" id="item1" onClick={mockOnClick}>
150
151
  Content
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom'
2
2
 
3
3
  import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
+ import { vi } from 'vitest'
5
6
 
6
7
  import { PktAlert } from './Alert'
7
8
 
@@ -11,7 +12,7 @@ afterEach(cleanup)
11
12
 
12
13
  describe('PktAlert', () => {
13
14
  test('calls onClose when close button is clicked', async () => {
14
- const onClose = jest.fn()
15
+ const onClose = vi.fn()
15
16
  const { getByLabelText } = render(
16
17
  <PktAlert closeAlert={true} onClose={onClose}>
17
18
  Hello World
@@ -1,5 +1,6 @@
1
1
  import { fireEvent, render } from '@testing-library/react'
2
2
  import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { vi } from 'vitest'
3
4
 
4
5
  import { PktBackLink } from './BackLink'
5
6
 
@@ -23,7 +24,7 @@ describe('PktBackLink', () => {
23
24
  })
24
25
 
25
26
  it('calls onClick when clicked', async () => {
26
- const onClickMock = jest.fn()
27
+ const onClickMock = vi.fn()
27
28
  const { getByText } = render(<PktBackLink text="Back" onClick={onClickMock} />)
28
29
  await window.customElements.whenDefined('pkt-backlink')
29
30
  const link = getByText('Back')
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom'
3
3
  import { render, screen } from '@testing-library/react'
4
4
  import userEvent from '@testing-library/user-event'
5
5
  import { axe, toHaveNoViolations } from 'jest-axe'
6
+ import { vi } from 'vitest'
6
7
  import { createRef } from 'react'
7
8
  import { useForm } from 'react-hook-form'
8
9
 
@@ -13,7 +14,7 @@ expect.extend(toHaveNoViolations)
13
14
  // Mock Form Component
14
15
  const MockForm = ({ buttonPosition }: { buttonPosition: 'inside' | 'outside' }) => {
15
16
  const { register, handleSubmit, reset } = useForm()
16
- const onSubmit = jest.fn()
17
+ const onSubmit = vi.fn()
17
18
 
18
19
  return (
19
20
  <>
@@ -95,7 +96,7 @@ test('forwardRef works correctly', async () => {
95
96
  })
96
97
 
97
98
  test('PktButton triggers click when focused and enter is pressed', async () => {
98
- const handleClick = jest.fn()
99
+ const handleClick = vi.fn()
99
100
  render(<PktButton onClick={handleClick}>trøkk her</PktButton>)
100
101
 
101
102
  const button = screen.getByRole('button', { name: /trøkk her/i })
@@ -108,7 +109,7 @@ test('PktButton triggers click when focused and enter is pressed', async () => {
108
109
  })
109
110
 
110
111
  test('PktButton triggers click when focused and space is pressed', async () => {
111
- const handleClick = jest.fn()
112
+ const handleClick = vi.fn()
112
113
  render(<PktButton onClick={handleClick}>Trøkk igjen</PktButton>)
113
114
 
114
115
  const button = screen.getByRole('button')
@@ -1,5 +1,6 @@
1
1
  import '@testing-library/jest-dom'
2
2
  import { render, fireEvent, act } from '@testing-library/react'
3
+ import { vi } from 'vitest'
3
4
 
4
5
  import { PktCalendar, IPktCalendar } from './Calendar'
5
6
 
@@ -41,7 +42,7 @@ describe('PktCalendar', () => {
41
42
  })
42
43
 
43
44
  test('handles escape key', () => {
44
- const onClose = jest.fn()
45
+ const onClose = vi.fn()
45
46
  const { container } = createCalendar({ onClose })
46
47
 
47
48
  const calendarEl = container.querySelector('.pkt-calendar') as HTMLElement
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom'
2
2
 
3
3
  import { fireEvent, render, screen } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
+ import { vi } from 'vitest'
5
6
 
6
7
  import { PktCheckbox } from './Checkbox'
7
8
 
@@ -45,7 +46,7 @@ describe('PktCheckbox', () => {
45
46
  })
46
47
 
47
48
  test('handles onClick callback', async () => {
48
- const onClickMock = jest.fn()
49
+ const onClickMock = vi.fn()
49
50
  const { getByLabelText } = render(<PktCheckbox id="myCheckbox" label="My Checkbox" onClick={onClickMock} />)
50
51
 
51
52
  // Get the checkbox label element
@@ -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
+ })