@oslokommune/punkt-elements 16.16.1 → 16.17.2

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 (139) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{accordion-nNH87Y60.js → accordion-CU2tJ5vn.js} +23 -9
  3. package/dist/{accordion-BB1JrONI.cjs → accordion-DDZkjXFb.cjs} +20 -7
  4. package/dist/{checkbox-nTbihMxW.cjs → checkbox-BerAYcfK.cjs} +1 -1
  5. package/dist/{checkbox-Bjp43NxA.js → checkbox-CF5mSBR5.js} +1 -1
  6. package/dist/{combobox-C4Q6kQEs.cjs → combobox-DYttdLJ4.cjs} +1 -1
  7. package/dist/{combobox-CL9BLuxt.js → combobox-DjvRMKOD.js} +3 -3
  8. package/dist/components/header-menu/header-menu.d.ts +41 -0
  9. package/dist/components/header-menu/header-menu.test.d.ts +0 -0
  10. package/dist/components/header-menu/index.d.ts +5 -0
  11. package/dist/components/header-menu/types.d.ts +6 -0
  12. package/dist/components/index.d.ts +4 -0
  13. package/dist/components/searchinput/index.d.ts +4 -0
  14. package/dist/components/searchinput/searchinput.d.ts +27 -0
  15. package/dist/components/searchinput/searchinput.test.d.ts +0 -0
  16. package/dist/{datepicker-DYd3JCs_.cjs → datepicker-D3QQbd_H.cjs} +1 -1
  17. package/dist/{datepicker-BUK1Qanw.js → datepicker-M_jcY3wp.js} +2 -2
  18. package/dist/docs/components/header-full.d.ts +13 -0
  19. package/dist/docs/components/header-menu.d.ts +8 -0
  20. package/dist/docs/components/searchinput.d.ts +14 -0
  21. package/dist/{fileupload-DjUAvBcP.cjs → fileupload-BD_0oMrW.cjs} +1 -1
  22. package/dist/{fileupload-DbmqRkz3.js → fileupload-F9UMO7eS.js} +3 -3
  23. package/dist/focus-modality-controller-BSZAQk8y.cjs +1 -0
  24. package/dist/focus-modality-controller-pzSZkDkH.js +29 -0
  25. package/dist/{header-Dps8o-ZY.cjs → header-DZranHLm.cjs} +2 -2
  26. package/dist/{header-T5O6sfCN.js → header-j0SSHF4C.js} +4 -3
  27. package/dist/header-menu-CPI1_5oZ.cjs +126 -0
  28. package/dist/header-menu-zhGYdnvz.js +319 -0
  29. package/dist/header-menu.d.ts +1 -0
  30. package/dist/{helptext-C5rG4VkA.js → helptext-BdqoW19r.js} +1 -1
  31. package/dist/{helptext-DQgStO4D.cjs → helptext-KWXjKljV.cjs} +1 -1
  32. package/dist/{input-element-CjebcQd1.js → input-element-B1ciW1N2.js} +1 -1
  33. package/dist/{input-element-FGcr5hGd.cjs → input-element-D0egtEnr.cjs} +1 -1
  34. package/dist/{input-wrapper-B_XFi211.cjs → input-wrapper-DOIWggEv.cjs} +1 -1
  35. package/dist/{input-wrapper-DBpMVSxt.js → input-wrapper-DVU68NcJ.js} +5 -29
  36. package/dist/{modal-B6591sWa.cjs → modal-C_LxgxzC.cjs} +1 -1
  37. package/dist/{modal-DyIC3uhs.js → modal-Ca7dETTw.js} +1 -1
  38. package/dist/pkt-accordion.cjs +1 -1
  39. package/dist/pkt-accordion.js +1 -1
  40. package/dist/pkt-checkbox.cjs +1 -1
  41. package/dist/pkt-checkbox.js +1 -1
  42. package/dist/pkt-combobox.cjs +1 -1
  43. package/dist/pkt-combobox.js +1 -1
  44. package/dist/pkt-consent.cjs +1 -1
  45. package/dist/pkt-consent.js +1 -1
  46. package/dist/pkt-datepicker.cjs +1 -1
  47. package/dist/pkt-datepicker.js +1 -1
  48. package/dist/pkt-fileupload.cjs +1 -1
  49. package/dist/pkt-fileupload.js +1 -1
  50. package/dist/pkt-header-menu.cjs +1 -0
  51. package/dist/pkt-header-menu.js +2 -0
  52. package/dist/pkt-header.cjs +1 -1
  53. package/dist/pkt-header.js +1 -1
  54. package/dist/pkt-helptext.cjs +1 -1
  55. package/dist/pkt-helptext.js +1 -1
  56. package/dist/pkt-index.cjs +1 -1
  57. package/dist/pkt-index.js +24 -22
  58. package/dist/pkt-input-wrapper.cjs +1 -1
  59. package/dist/pkt-input-wrapper.js +1 -1
  60. package/dist/pkt-link.cjs +1 -1
  61. package/dist/pkt-link.js +1 -1
  62. package/dist/pkt-linkcard.cjs +1 -1
  63. package/dist/pkt-linkcard.js +1 -1
  64. package/dist/pkt-loader.cjs +1 -1
  65. package/dist/pkt-loader.js +1 -1
  66. package/dist/pkt-messagebox.cjs +1 -1
  67. package/dist/pkt-messagebox.js +1 -1
  68. package/dist/pkt-modal.cjs +1 -1
  69. package/dist/pkt-modal.js +1 -1
  70. package/dist/pkt-progressbar.cjs +1 -1
  71. package/dist/pkt-progressbar.js +1 -1
  72. package/dist/pkt-radiobutton.cjs +1 -1
  73. package/dist/pkt-radiobutton.js +1 -1
  74. package/dist/pkt-searchinput.cjs +1 -0
  75. package/dist/pkt-searchinput.js +2 -0
  76. package/dist/pkt-select.cjs +1 -1
  77. package/dist/pkt-select.js +1 -1
  78. package/dist/pkt-tabs.cjs +1 -1
  79. package/dist/pkt-tabs.js +1 -1
  80. package/dist/pkt-textarea.cjs +1 -1
  81. package/dist/pkt-textarea.js +1 -1
  82. package/dist/pkt-textinput.cjs +1 -1
  83. package/dist/pkt-textinput.js +1 -1
  84. package/dist/pkt-timepicker.cjs +1 -1
  85. package/dist/pkt-timepicker.js +1 -1
  86. package/dist/{radiobutton-DvXr7tDk.cjs → radiobutton-B1Xg6XzP.cjs} +1 -1
  87. package/dist/{radiobutton-B-8-wWFf.js → radiobutton-BAn1V_mQ.js} +1 -1
  88. package/dist/searchinput-BVIaoetB.cjs +60 -0
  89. package/dist/searchinput-CIseS5tL.js +147 -0
  90. package/dist/searchinput.d.ts +1 -0
  91. package/dist/{select-D9B8Ighz.js → select-6XCVj1lP.js} +3 -3
  92. package/dist/{select-B75wzvyz.cjs → select-DO0xGhQQ.cjs} +1 -1
  93. package/dist/shared-types/header-menu.d.ts +108 -0
  94. package/dist/shared-types/index.d.ts +3 -0
  95. package/dist/shared-types/searchinput.d.ts +41 -0
  96. package/dist/shared-types/skin.d.ts +1 -1
  97. package/dist/shared-utils/header-menu/example-data.d.ts +11 -0
  98. package/dist/shared-utils/header-menu/fetch-header-footer-data.d.ts +12 -0
  99. package/dist/shared-utils/header-menu/icon-map.d.ts +34 -0
  100. package/dist/shared-utils/header-menu/index.d.ts +11 -0
  101. package/dist/shared-utils/header-menu/select-megamenu.d.ts +16 -0
  102. package/dist/tests/component-registry.d.ts +2 -0
  103. package/dist/{textarea-CjqprL6Y.cjs → textarea-CV6z3LDf.cjs} +1 -1
  104. package/dist/{textarea-CIWXMaAD.js → textarea-DBSNSvrX.js} +2 -2
  105. package/dist/{textinput-CxzAxM8E.js → textinput-BZZnFDk3.js} +2 -2
  106. package/dist/{textinput-B0VWmVIF.cjs → textinput-Dsc-8-BB.cjs} +1 -1
  107. package/dist/{timepicker-Cv9juQeB.cjs → timepicker-C2Ua6vHY.cjs} +1 -1
  108. package/dist/{timepicker-DXrpg5C7.js → timepicker-DDQ7CfOy.js} +2 -2
  109. package/package.json +2 -2
  110. package/src/components/accordion/accordionitem.ts +22 -5
  111. package/src/components/header/header-service.ts +1 -0
  112. package/src/components/header-menu/header-menu.test.ts +314 -0
  113. package/src/components/header-menu/header-menu.ts +401 -0
  114. package/src/components/header-menu/index.ts +6 -0
  115. package/src/components/header-menu/types.ts +31 -0
  116. package/src/components/index.ts +10 -0
  117. package/src/components/searchinput/index.ts +10 -0
  118. package/src/components/searchinput/searchinput.test.ts +312 -0
  119. package/src/components/searchinput/searchinput.ts +213 -0
  120. /package/dist/{consent-zh2npq2K.cjs → consent-B5dIDyLD.cjs} +0 -0
  121. /package/dist/{consent-f_IGyHss.js → consent-DSHrt9eS.js} +0 -0
  122. /package/dist/{dialog-polyfill.esm-BmVHGRTX.js → dialog-polyfill.esm-B7W3teD0.js} +0 -0
  123. /package/dist/{dialog-polyfill.esm-CXDsb3l-.cjs → dialog-polyfill.esm-BnAQvMQO.cjs} +0 -0
  124. /package/dist/{input-wrapper-CYCCx8MP.cjs → input-wrapper-CQGoyLdZ.cjs} +0 -0
  125. /package/dist/{input-wrapper-kw2y6Jp_.js → input-wrapper-DwSsP39J.js} +0 -0
  126. /package/dist/{link-B8zYc5WK.cjs → link-DMKjKawh.cjs} +0 -0
  127. /package/dist/{link-CjJcwcxR.js → link-F51Ho7CV.js} +0 -0
  128. /package/dist/{linkcard-C4x-NJMi.cjs → linkcard-CBmJbSoV.cjs} +0 -0
  129. /package/dist/{linkcard-Ddh5R_L-.js → linkcard-E07qJZMP.js} +0 -0
  130. /package/dist/{loader-CGQXAuyM.cjs → loader-q_eO6zaE.cjs} +0 -0
  131. /package/dist/{loader-DeizpX45.js → loader-teN65oWo.js} +0 -0
  132. /package/dist/{messagebox-DqoPqo4K.js → messagebox-Ct9lfe0Z.js} +0 -0
  133. /package/dist/{messagebox-Ccu29rDv.cjs → messagebox-D1QeQqb8.cjs} +0 -0
  134. /package/dist/{pkt-options-controller-DbLrdRwH.js → pkt-options-controller-CCB15L_G.js} +0 -0
  135. /package/dist/{pkt-options-controller-BPBb_O2K.cjs → pkt-options-controller-CeAQ-D-i.cjs} +0 -0
  136. /package/dist/{progressbar-CrwpnIkv.cjs → progressbar-Ca7pRjmt.cjs} +0 -0
  137. /package/dist/{progressbar-uC-0W6o4.js → progressbar-o-M17HPA.js} +0 -0
  138. /package/dist/{tabs-RnDtIFv3.cjs → tabs-CN_RIMWg.cjs} +0 -0
  139. /package/dist/{tabs-4eWA7CsK.js → tabs-ClI1XQDt.js} +0 -0
@@ -0,0 +1,312 @@
1
+ import '@testing-library/jest-dom'
2
+ import { fireEvent } from '@testing-library/dom'
3
+ import { axe, toHaveNoViolations } from 'jest-axe'
4
+ import { vi } from 'vitest'
5
+
6
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
7
+ import { CustomElementFor } from '../../tests/component-registry'
8
+ import './searchinput'
9
+
10
+ expect.extend(toHaveNoViolations)
11
+
12
+ interface SearchInputTestConfig extends BaseTestConfig {
13
+ id?: string
14
+ name?: string
15
+ appearance?: 'local' | 'local-with-button' | 'global'
16
+ label?: string
17
+ placeholder?: string
18
+ disabled?: boolean
19
+ fullwidth?: boolean
20
+ action?: string
21
+ method?: 'get' | 'post' | 'dialog'
22
+ value?: string
23
+ }
24
+
25
+ const createSearchInputTest = async (config: SearchInputTestConfig = {}) => {
26
+ const { container, element } = await createElementTest<
27
+ CustomElementFor<'pkt-searchinput'>,
28
+ SearchInputTestConfig
29
+ >('pkt-searchinput', {
30
+ id: 'test-searchinput',
31
+ ...config,
32
+ })
33
+
34
+ return {
35
+ container,
36
+ searchinput: element,
37
+ }
38
+ }
39
+
40
+ afterEach(() => {
41
+ document.body.innerHTML = ''
42
+ })
43
+
44
+ describe('PktSearchInput', () => {
45
+ describe('wrapper', () => {
46
+ test('renders form when action exists', async () => {
47
+ const { searchinput } = await createSearchInputTest({ action: '/search', method: 'get' })
48
+ const wrapper = searchinput.querySelector('.pkt-searchinput')
49
+ expect(wrapper?.tagName.toLowerCase()).toBe('form')
50
+ expect(wrapper).toHaveAttribute('role', 'search')
51
+ expect(wrapper).toHaveAttribute('action', '/search')
52
+ expect(wrapper).toHaveAttribute('method', 'get')
53
+ })
54
+
55
+ test('renders div when action does not exist', async () => {
56
+ const { searchinput } = await createSearchInputTest()
57
+ const wrapper = searchinput.querySelector('.pkt-searchinput')
58
+ expect(wrapper?.tagName.toLowerCase()).toBe('div')
59
+ expect(wrapper).toHaveAttribute('role', 'search')
60
+ expect(searchinput.querySelector('form')).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('label', () => {
65
+ test('renders label when label exists', async () => {
66
+ const { searchinput } = await createSearchInputTest({ label: 'Test Label' })
67
+ await searchinput.updateComplete
68
+ const label = searchinput.querySelector('label.pkt-inputwrapper__label')
69
+ expect(label).toBeInTheDocument()
70
+ expect(label?.textContent).toContain('Test Label')
71
+ expect(label).toHaveAttribute('for', 'test-searchinput')
72
+ })
73
+
74
+ test('does not render label when label is absent', async () => {
75
+ const { searchinput } = await createSearchInputTest()
76
+ await searchinput.updateComplete
77
+ expect(searchinput.querySelector('label')).toBeNull()
78
+ })
79
+ })
80
+
81
+ describe('input attributes', () => {
82
+ test('input has expected type, name, id, placeholder, disabled', async () => {
83
+ const { searchinput } = await createSearchInputTest({
84
+ id: 'my-search',
85
+ name: 'q',
86
+ placeholder: 'Finn…',
87
+ disabled: true,
88
+ })
89
+ await searchinput.updateComplete
90
+
91
+ const input = searchinput.querySelector('input') as HTMLInputElement
92
+ expect(input.type).toBe('search')
93
+ expect(input.id).toBe('my-search')
94
+ expect(input.name).toBe('q')
95
+ expect(input.placeholder).toBe('Finn…')
96
+ expect(input.disabled).toBe(true)
97
+ })
98
+
99
+ test('name defaults to id when name is not set', async () => {
100
+ const { searchinput } = await createSearchInputTest({ id: 'search-field' })
101
+ await searchinput.updateComplete
102
+ expect((searchinput.querySelector('input') as HTMLInputElement).name).toBe('search-field')
103
+ })
104
+
105
+ test('placeholder defaults to Søk…', async () => {
106
+ const { searchinput } = await createSearchInputTest()
107
+ await searchinput.updateComplete
108
+ expect((searchinput.querySelector('input') as HTMLInputElement).placeholder).toBe('Søk…')
109
+ })
110
+ })
111
+
112
+ describe('appearance and fullwidth classes', () => {
113
+ test('applies appearance modifier and fullwidth on wrapper', async () => {
114
+ const { searchinput } = await createSearchInputTest({
115
+ appearance: 'global',
116
+ fullwidth: true,
117
+ })
118
+ await searchinput.updateComplete
119
+ const wrapper = searchinput.querySelector('.pkt-searchinput')
120
+ expect(wrapper?.classList.contains('pkt-searchinput--global')).toBe(true)
121
+ expect(wrapper?.classList.contains('pkt-searchinput--fullwidth')).toBe(true)
122
+ })
123
+
124
+ test('local uses pkt-input__container; local-with-button uses pkt-searchinput__field', async () => {
125
+ const { searchinput: localEl } = await createSearchInputTest({ appearance: 'local' })
126
+ await localEl.updateComplete
127
+ expect(localEl.querySelector('.pkt-input__container')).toBeTruthy()
128
+ expect(localEl.querySelector('.pkt-searchinput__field')).toBeNull()
129
+
130
+ const { searchinput: lwb } = await createSearchInputTest({
131
+ appearance: 'local-with-button',
132
+ })
133
+ await lwb.updateComplete
134
+ expect(lwb.querySelector('.pkt-searchinput__field')).toBeTruthy()
135
+ expect(lwb.querySelector('.pkt-input__container')).toBeNull()
136
+ })
137
+ })
138
+
139
+ describe('pkt-search', () => {
140
+ test('fires on Enter with detail { value }', async () => {
141
+ const { searchinput } = await createSearchInputTest()
142
+ const searchSpy = vi.fn()
143
+ searchinput.addEventListener('pkt-search', searchSpy)
144
+
145
+ const input = searchinput.querySelector('input') as HTMLInputElement
146
+ fireEvent.input(input, { target: { value: 'oslo' } })
147
+ fireEvent.keyDown(input, { key: 'Enter' })
148
+
149
+ expect(searchSpy).toHaveBeenCalledTimes(1)
150
+ expect(searchSpy.mock.calls[0][0].detail).toEqual({ value: 'oslo' })
151
+ })
152
+
153
+ test('fires on search button click with detail { value }', async () => {
154
+ const { searchinput } = await createSearchInputTest({ value: 'kommune' })
155
+ await searchinput.updateComplete
156
+ const searchSpy = vi.fn()
157
+ searchinput.addEventListener('pkt-search', searchSpy)
158
+
159
+ const button = searchinput.querySelector('.pkt-searchinput__button') as HTMLButtonElement
160
+ fireEvent.click(button)
161
+
162
+ expect(searchSpy).toHaveBeenCalledTimes(1)
163
+ expect(searchSpy.mock.calls[0][0].detail).toEqual({ value: 'kommune' })
164
+ })
165
+ })
166
+
167
+ describe('search submit button classes', () => {
168
+ test('local: tertiary, icon-only, pkt-input-icon', async () => {
169
+ const { searchinput } = await createSearchInputTest({ appearance: 'local' })
170
+ await searchinput.updateComplete
171
+ const button = searchinput.querySelector('.pkt-searchinput__button') as HTMLButtonElement
172
+ expect(button.tagName.toLowerCase()).toBe('button')
173
+ expect(button.classList.contains('pkt-input-icon')).toBe(true)
174
+ expect(button.classList.contains('pkt-btn--tertiary')).toBe(true)
175
+ expect(button.classList.contains('pkt-btn--icon-only')).toBe(true)
176
+ expect(button.classList.contains('pkt-btn--medium')).toBe(true)
177
+ })
178
+
179
+ test('global: primary, yellow', async () => {
180
+ const { searchinput } = await createSearchInputTest({ appearance: 'global' })
181
+ await searchinput.updateComplete
182
+ const button = searchinput.querySelector('.pkt-searchinput__button') as HTMLButtonElement
183
+ expect(button.classList.contains('pkt-btn--primary')).toBe(true)
184
+ expect(button.classList.contains('pkt-btn--yellow')).toBe(true)
185
+ expect(button.classList.contains('pkt-input-icon')).toBe(false)
186
+ })
187
+ })
188
+
189
+ describe('suggestions', () => {
190
+ test('renders from suggestions property without mutating the array', async () => {
191
+ const suggestions = [
192
+ { title: 'A', text: 'tekst', href: 'https://oslo.kommune.no' },
193
+ { title: 'B', text: 'tekst 2' },
194
+ ]
195
+ const snapshot = JSON.stringify(suggestions)
196
+
197
+ const { searchinput } = await createSearchInputTest()
198
+ searchinput.suggestions = suggestions
199
+ await searchinput.updateComplete
200
+
201
+ expect(searchinput.querySelectorAll('.pkt-searchinput__suggestion')).toHaveLength(2)
202
+ expect(JSON.stringify(suggestions)).toBe(snapshot)
203
+ })
204
+
205
+ test('suggestion with href renders as link', async () => {
206
+ const { searchinput } = await createSearchInputTest()
207
+ searchinput.suggestions = [{ title: 'Lenke', href: 'https://oslo.kommune.no' }]
208
+ await searchinput.updateComplete
209
+
210
+ const link = searchinput.querySelector('a.pkt-searchinput__suggestion')
211
+ expect(link).toBeTruthy()
212
+ expect(link?.getAttribute('href')).toBe('https://oslo.kommune.no')
213
+ })
214
+
215
+ test('suggestion without href renders as button type="button"', async () => {
216
+ const { searchinput } = await createSearchInputTest()
217
+ searchinput.suggestions = [{ title: 'Knapp', text: 'beskrivelse' }]
218
+ await searchinput.updateComplete
219
+
220
+ const btn = searchinput.querySelector('button.pkt-searchinput__suggestion')
221
+ expect(btn).toBeTruthy()
222
+ expect(btn?.getAttribute('type')).toBe('button')
223
+ })
224
+
225
+ test('nonInteractive suggestion renders as div and does not emit pkt-suggestion-click', async () => {
226
+ const suggestion = { text: 'Ingen resultater', nonInteractive: true }
227
+ const { searchinput } = await createSearchInputTest()
228
+ searchinput.suggestions = [suggestion]
229
+ await searchinput.updateComplete
230
+
231
+ expect(searchinput.querySelector('div.pkt-searchinput__suggestion')).toBeTruthy()
232
+ expect(searchinput.querySelector('button.pkt-searchinput__suggestion')).toBeNull()
233
+
234
+ const spy = vi.fn()
235
+ searchinput.addEventListener('pkt-suggestion-click', spy)
236
+ const div = searchinput.querySelector('div.pkt-searchinput__suggestion') as HTMLDivElement
237
+ fireEvent.click(div)
238
+
239
+ expect(spy).not.toHaveBeenCalled()
240
+ })
241
+
242
+ test('pkt-suggestion-click fires with detail { index, suggestion } on link click', async () => {
243
+ const suggestion = { title: 'T', href: 'https://oslo.kommune.no' }
244
+ const { searchinput } = await createSearchInputTest()
245
+ searchinput.suggestions = [suggestion]
246
+ await searchinput.updateComplete
247
+
248
+ const spy = vi.fn()
249
+ searchinput.addEventListener('pkt-suggestion-click', spy)
250
+ searchinput.addEventListener('pkt-suggestion-click', (e: Event) => e.preventDefault())
251
+
252
+ const link = searchinput.querySelector('a.pkt-searchinput__suggestion') as HTMLAnchorElement
253
+ fireEvent.click(link)
254
+
255
+ expect(spy).toHaveBeenCalledTimes(1)
256
+ const evt = spy.mock.calls[0][0] as CustomEvent
257
+ expect(evt.detail).toEqual({ index: 0, suggestion })
258
+ expect(evt.cancelable).toBe(true)
259
+ })
260
+
261
+ test('pkt-suggestion-click fires with detail { index, suggestion } on button click', async () => {
262
+ const suggestion = { title: 'X', text: 'y' }
263
+ const { searchinput } = await createSearchInputTest()
264
+ searchinput.suggestions = [suggestion]
265
+ await searchinput.updateComplete
266
+
267
+ const spy = vi.fn()
268
+ searchinput.addEventListener('pkt-suggestion-click', spy)
269
+
270
+ const btn = searchinput.querySelector('button.pkt-searchinput__suggestion') as HTMLButtonElement
271
+ fireEvent.click(btn)
272
+
273
+ expect(spy).toHaveBeenCalledTimes(1)
274
+ expect((spy.mock.calls[0][0] as CustomEvent).detail).toEqual({
275
+ index: 0,
276
+ suggestion,
277
+ })
278
+ })
279
+
280
+ test('canceling pkt-suggestion-click causes preventDefault on the anchor MouseEvent', async () => {
281
+ const { searchinput } = await createSearchInputTest()
282
+ searchinput.suggestions = [{ title: 'Resultat', href: 'https://oslo.kommune.no' }]
283
+ await searchinput.updateComplete
284
+
285
+ searchinput.addEventListener('pkt-suggestion-click', (e: Event) => e.preventDefault())
286
+
287
+ const link = searchinput.querySelector('a.pkt-searchinput__suggestion') as HTMLAnchorElement
288
+ let seen: MouseEvent | undefined
289
+ link.addEventListener('click', (e: MouseEvent) => {
290
+ seen = e
291
+ })
292
+
293
+ fireEvent.click(link)
294
+
295
+ expect(seen).toBeDefined()
296
+ expect(seen!.defaultPrevented).toBe(true)
297
+ })
298
+ })
299
+
300
+ describe('accessibility', () => {
301
+ test('has no axe violations with label and suggestions', async () => {
302
+ const { searchinput } = await createSearchInputTest({
303
+ label: 'Søk etter noe',
304
+ })
305
+ searchinput.suggestions = [{ title: 'Resultat', text: 'Eksempeltekst' }]
306
+ await searchinput.updateComplete
307
+
308
+ const results = await axe(searchinput)
309
+ expect(results).toHaveNoViolations()
310
+ })
311
+ })
312
+ })
@@ -0,0 +1,213 @@
1
+ import { PktElement } from '@/base-elements/element'
2
+ import { html, nothing } from 'lit'
3
+ import { customElement, property } from 'lit/decorators.js'
4
+ import { classMap } from 'lit/directives/class-map.js'
5
+ import { ifDefined } from 'lit/directives/if-defined.js'
6
+ import { FocusModalityController } from '@/controllers/focus-modality-controller'
7
+
8
+ import '@/components/icon'
9
+
10
+ import type {
11
+ IPktSearchInput,
12
+ IPktSearchInputSuggestion,
13
+ TPktSearchInputAppearance,
14
+ TPktSearchInputMethod,
15
+ } from 'shared-types/searchinput'
16
+
17
+ export type {
18
+ IPktSearchInput,
19
+ IPktSearchInputSuggestion,
20
+ TPktSearchInputAppearance,
21
+ TPktSearchInputMethod,
22
+ } from 'shared-types/searchinput'
23
+
24
+ export class PktSearchInput extends PktElement<IPktSearchInput> implements IPktSearchInput {
25
+ @property({ type: String }) appearance: TPktSearchInputAppearance = 'local'
26
+ @property({ type: Boolean, reflect: true }) disabled: boolean = false
27
+ @property({ type: Boolean, reflect: true }) fullwidth: boolean = false
28
+ @property({ type: String, reflect: true }) id: string = ''
29
+ @property({ type: String }) label: string | undefined
30
+ @property({ type: String }) name: string | undefined
31
+ @property({ type: String }) placeholder: string = 'Søk…'
32
+ @property({ type: String }) value: string = ''
33
+ @property({ type: Array }) suggestions: IPktSearchInputSuggestion[] | undefined
34
+ @property({ type: String }) action: string | undefined
35
+ @property({ type: String }) method: TPktSearchInputMethod | undefined
36
+
37
+ focusModality = new FocusModalityController(this, '.pkt-searchinput')
38
+
39
+ private get suggestionsId() {
40
+ return `${this.id}-suggestions`
41
+ }
42
+
43
+ private dispatchSearch(): void {
44
+ this.dispatchEvent(
45
+ new CustomEvent('pkt-search', {
46
+ detail: { value: this.value },
47
+ bubbles: true,
48
+ composed: true,
49
+ }),
50
+ )
51
+ }
52
+
53
+ private onInput(event: InputEvent): void {
54
+ const target = event.target as HTMLInputElement
55
+ this.value = target.value
56
+ }
57
+
58
+ private onInputKeydown(event: KeyboardEvent): void {
59
+ if (event.key !== 'Enter') return
60
+ event.preventDefault()
61
+ this.dispatchSearch()
62
+ }
63
+
64
+ private onSearchClick(event: Event): void {
65
+ event.preventDefault()
66
+ this.dispatchSearch()
67
+ }
68
+
69
+ private onSuggestionClick(
70
+ event: Event,
71
+ suggestion: IPktSearchInputSuggestion,
72
+ index: number,
73
+ ): void {
74
+ const customEvent = new CustomEvent('pkt-suggestion-click', {
75
+ detail: {
76
+ index,
77
+ suggestion,
78
+ },
79
+ bubbles: true,
80
+ composed: true,
81
+ cancelable: true,
82
+ })
83
+ const allowed = this.dispatchEvent(customEvent)
84
+ if (!allowed) {
85
+ event.preventDefault()
86
+ }
87
+ }
88
+
89
+ private renderSuggestion(suggestion: IPktSearchInputSuggestion, index: number) {
90
+ const suggestionContent = html`
91
+ ${suggestion.title
92
+ ? html`<h3 class="pkt-searchinput__suggestion-title">${suggestion.title}</h3>`
93
+ : nothing}
94
+ ${suggestion.text
95
+ ? html`<p class="pkt-searchinput__suggestion-text">${suggestion.text}</p>`
96
+ : nothing}
97
+ `
98
+
99
+ if (suggestion.href) {
100
+ return html`<a
101
+ href=${suggestion.href}
102
+ class="pkt-searchinput__suggestion"
103
+ @click=${(event: Event) => this.onSuggestionClick(event, suggestion, index)}
104
+ >
105
+ ${suggestionContent}
106
+ </a>`
107
+ }
108
+
109
+ if (suggestion.nonInteractive) {
110
+ return html`<div class="pkt-searchinput__suggestion">${suggestionContent}</div>`
111
+ }
112
+
113
+ return html`<button
114
+ type="button"
115
+ class="pkt-searchinput__suggestion"
116
+ @click=${(event: Event) => this.onSuggestionClick(event, suggestion, index)}
117
+ >
118
+ ${suggestionContent}
119
+ </button>`
120
+ }
121
+
122
+ render() {
123
+ const wrapperClasses = classMap({
124
+ 'pkt-searchinput': true,
125
+ [`pkt-searchinput--${this.appearance}`]: true,
126
+ 'pkt-searchinput--fullwidth': this.fullwidth,
127
+ })
128
+ const buttonClasses = classMap({
129
+ 'pkt-searchinput__button': true,
130
+ 'pkt-input-icon': this.appearance === 'local',
131
+ 'pkt-btn': true,
132
+ 'pkt-btn--medium': true,
133
+ 'pkt-btn--icon-only': true,
134
+ 'pkt-btn--tertiary': this.appearance === 'local',
135
+ 'pkt-btn--primary': this.appearance === 'global' || this.appearance === 'local-with-button',
136
+ 'pkt-btn--yellow': this.appearance === 'global',
137
+ })
138
+ const inputClasses = classMap({
139
+ 'pkt-input': true,
140
+ 'pkt-input--fullwidth': this.fullwidth,
141
+ })
142
+ const fieldClass =
143
+ this.appearance === 'local' ? 'pkt-input__container' : 'pkt-searchinput__field'
144
+
145
+ const body = html`
146
+ ${this.label
147
+ ? html`<label for=${ifDefined(this.id || undefined)} class="pkt-inputwrapper__label"
148
+ >${this.label}</label
149
+ >`
150
+ : nothing}
151
+ <div class=${fieldClass}>
152
+ <input
153
+ class=${inputClasses}
154
+ type="search"
155
+ name=${this.name || this.id}
156
+ id=${this.id}
157
+ placeholder=${this.placeholder || 'Søk…'}
158
+ .value=${this.value}
159
+ ?disabled=${this.disabled}
160
+ autocomplete="off"
161
+ aria-autocomplete="list"
162
+ aria-controls=${this.suggestionsId}
163
+ @input=${this.onInput}
164
+ @keydown=${this.onInputKeydown}
165
+ />
166
+ <button
167
+ type="submit"
168
+ class=${buttonClasses}
169
+ ?disabled=${this.disabled}
170
+ @click=${this.onSearchClick}
171
+ >
172
+ <pkt-icon class="pkt-btn__icon" name="magnifying-glass-big" />
173
+ <span class="pkt-btn__text">${this.label || this.placeholder || 'Søk…'}</span>
174
+ </button>
175
+ </div>
176
+ ${this.suggestions
177
+ ? html`<ul
178
+ id=${this.suggestionsId}
179
+ class="pkt-searchinput__suggestions"
180
+ aria-live="assertive"
181
+ >
182
+ ${this.suggestions.map(
183
+ (suggestion, index) => html` <li>${this.renderSuggestion(suggestion, index)}</li> `,
184
+ )}
185
+ </ul>`
186
+ : nothing}
187
+ `
188
+
189
+ if (this.action) {
190
+ return html`<form
191
+ role="search"
192
+ class=${wrapperClasses}
193
+ action=${this.action}
194
+ method=${ifDefined(this.method)}
195
+ @submit=${(event: Event) => {
196
+ event.preventDefault()
197
+ }}
198
+ >
199
+ ${body}
200
+ </form>`
201
+ }
202
+
203
+ return html`<div role="search" class=${wrapperClasses}>${body}</div>`
204
+ }
205
+ }
206
+
207
+ export default PktSearchInput
208
+
209
+ try {
210
+ customElement('pkt-searchinput')(PktSearchInput)
211
+ } catch (e) {
212
+ console.warn('Forsøker å definere <pkt-searchinput>, men den er allerede definert')
213
+ }
File without changes
File without changes
File without changes
File without changes
File without changes