@oslokommune/punkt-elements 13.5.1 → 13.5.3

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.
@@ -0,0 +1,224 @@
1
+ import '@testing-library/jest-dom'
2
+ import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { fireEvent } from '@testing-library/dom'
4
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
5
+ import { CustomElementFor } from '../../tests/component-registry'
6
+ import './link'
7
+
8
+ expect.extend(toHaveNoViolations)
9
+
10
+ export interface LinkTestConfig extends BaseTestConfig {
11
+ href?: string
12
+ target?: string
13
+ skin?: string
14
+ variant?: string
15
+ iconName?: string
16
+ iconPosition?: string
17
+ openInNewTab?: boolean
18
+ external?: boolean
19
+ }
20
+
21
+ // Use shared framework
22
+ export const createLinkTest = async (config: LinkTestConfig = {}) => {
23
+ const { container, element } = await createElementTest<
24
+ CustomElementFor<'pkt-link'>,
25
+ LinkTestConfig
26
+ >('pkt-link', config)
27
+
28
+ return {
29
+ container,
30
+ link: element,
31
+ }
32
+ }
33
+
34
+ describe('PktLink', () => {
35
+ describe('Rendering and basic functionality', () => {
36
+ test('renders without errors', async () => {
37
+ const { link } = await createLinkTest()
38
+
39
+ expect(link).toBeInTheDocument()
40
+ await link.updateComplete
41
+ expect(link).toBeTruthy()
42
+ })
43
+
44
+ test('renders with correct structure', async () => {
45
+ const { link } = await createLinkTest({
46
+ href: 'https://example.com',
47
+ content: 'Click me',
48
+ })
49
+ await link.updateComplete
50
+
51
+ expect(link).toBeInTheDocument()
52
+ const anchor = link.querySelector('a')
53
+ expect(anchor).toBeInTheDocument()
54
+ expect(anchor?.href).toBe('https://example.com/')
55
+ expect(anchor?.textContent).toContain('Click me')
56
+ })
57
+ })
58
+
59
+ describe('Properties and attributes', () => {
60
+ test('applies default properties correctly', async () => {
61
+ const { link } = await createLinkTest()
62
+ await link.updateComplete
63
+
64
+ expect(link.href).toBe('#')
65
+ expect(link.external).toBe(false)
66
+ expect(link.iconName).toBeUndefined()
67
+ expect(link.target).toBe('_self')
68
+ })
69
+
70
+ test('sets href property correctly', async () => {
71
+ const { link } = await createLinkTest({
72
+ href: 'https://example.com',
73
+ })
74
+ await link.updateComplete
75
+
76
+ expect(link.href).toBe('https://example.com')
77
+ const anchor = link.querySelector('a')
78
+ expect(anchor?.href).toBe('https://example.com/')
79
+ })
80
+
81
+ test('sets external property correctly', async () => {
82
+ const { link } = await createLinkTest({
83
+ href: 'https://example.com',
84
+ external: true,
85
+ })
86
+ await link.updateComplete
87
+
88
+ expect(link.external).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe('Icon functionality', () => {
93
+ test('renders icon when iconName is provided', async () => {
94
+ const { link } = await createLinkTest({
95
+ href: '#',
96
+ iconName: 'arrow-right',
97
+ })
98
+ await link.updateComplete
99
+
100
+ const icon = link.querySelector('pkt-icon')
101
+ expect(icon).toBeInTheDocument()
102
+ expect(icon?.getAttribute('name')).toBe('arrow-right')
103
+ })
104
+
105
+ test('positions icon correctly', async () => {
106
+ const { link } = await createLinkTest({
107
+ href: '#',
108
+ iconName: 'arrow-right',
109
+ iconPosition: 'right',
110
+ })
111
+ await link.updateComplete
112
+
113
+ const anchor = link.querySelector('a')
114
+ expect(anchor?.classList.contains('pkt-link--icon-right')).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('External link functionality', () => {
119
+ test('applies external class and rel attribute', async () => {
120
+ const { link } = await createLinkTest({
121
+ href: 'https://example.com',
122
+ external: true,
123
+ })
124
+ await link.updateComplete
125
+
126
+ const anchor = link.querySelector('a')
127
+ expect(anchor?.classList.contains('pkt-link--external')).toBe(true)
128
+ expect(anchor?.rel).toBe('noopener noreferrer')
129
+ })
130
+
131
+ test('does not set rel attribute for internal links', async () => {
132
+ const { link } = await createLinkTest({
133
+ href: '/internal-page',
134
+ })
135
+ await link.updateComplete
136
+
137
+ const anchor = link.querySelector('a')
138
+ expect(anchor?.rel).toBe('')
139
+ })
140
+ })
141
+
142
+ describe('Event handling', () => {
143
+ test('handles click events', async () => {
144
+ const { link } = await createLinkTest({
145
+ href: '#test',
146
+ })
147
+ await link.updateComplete
148
+
149
+ const anchor = link.querySelector('a')
150
+ const clickHandler = jest.fn()
151
+ anchor?.addEventListener('click', clickHandler)
152
+
153
+ fireEvent.click(anchor!)
154
+ expect(clickHandler).toHaveBeenCalled()
155
+ })
156
+ })
157
+
158
+ describe('Dynamic updates', () => {
159
+ test('updates href dynamically', async () => {
160
+ const { link } = await createLinkTest({
161
+ href: 'https://example.com',
162
+ })
163
+ await link.updateComplete
164
+
165
+ link.href = 'https://updated.com'
166
+ await link.updateComplete
167
+
168
+ expect(link.href).toBe('https://updated.com')
169
+ const anchor = link.querySelector('a')
170
+ expect(anchor?.href).toBe('https://updated.com/')
171
+ })
172
+
173
+ test('updates external property dynamically', async () => {
174
+ const { link } = await createLinkTest({
175
+ href: 'https://example.com',
176
+ })
177
+ await link.updateComplete
178
+
179
+ link.external = true
180
+ await link.updateComplete
181
+
182
+ expect(link.external).toBe(true)
183
+ const anchor = link.querySelector('a')
184
+ expect(anchor?.classList.contains('pkt-link--external')).toBe(true)
185
+ expect(anchor?.rel).toBe('noopener noreferrer')
186
+ })
187
+ })
188
+
189
+ describe('Accessibility', () => {
190
+ test('basic link is accessible', async () => {
191
+ const { container } = await createLinkTest({
192
+ href: 'https://example.com',
193
+ content: 'Accessible Link',
194
+ })
195
+ await new Promise((resolve) => setTimeout(resolve, 100))
196
+
197
+ const results = await axe(container)
198
+ expect(results).toHaveNoViolations()
199
+ })
200
+ })
201
+
202
+ describe('Integration scenarios', () => {
203
+ test('works with complex configurations', async () => {
204
+ const { link } = await createLinkTest({
205
+ href: 'https://external-site.com',
206
+ iconName: 'external',
207
+ iconPosition: 'right',
208
+ external: true,
209
+ target: '_blank',
210
+ content: 'Complex External Link',
211
+ })
212
+ await link.updateComplete
213
+
214
+ expect(link.href).toBe('https://external-site.com')
215
+ expect(link.iconName).toBe('external')
216
+ expect(link.external).toBe(true)
217
+ expect(link.target).toBe('_blank')
218
+
219
+ const anchor = link.querySelector('a')
220
+ expect(anchor?.classList.contains('pkt-link--external')).toBe(true)
221
+ expect(anchor?.classList.contains('pkt-link--icon-right')).toBe(true)
222
+ })
223
+ })
224
+ })
@@ -0,0 +1,364 @@
1
+ import '@testing-library/jest-dom'
2
+ import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
4
+ import { CustomElementFor } from '../../tests/component-registry'
5
+ import { PktLinkCard, type TLinkCardSkin } from './linkcard'
6
+ import type { IPktLinkCard } from './linkcard'
7
+ import './linkcard'
8
+
9
+ export interface LinkCardTestConfig extends IPktLinkCard, BaseTestConfig {}
10
+
11
+ // Use shared framework
12
+ export const createLinkCardTest = async (config: LinkCardTestConfig = {}) => {
13
+ const { container, element } = await createElementTest<
14
+ CustomElementFor<'pkt-linkcard'>,
15
+ LinkCardTestConfig
16
+ >('pkt-linkcard', config)
17
+
18
+ const link = element.querySelector('a') as HTMLAnchorElement
19
+
20
+ return {
21
+ container,
22
+ linkCard: element,
23
+ link,
24
+ }
25
+ }
26
+ expect.extend(toHaveNoViolations)
27
+
28
+ // Test data constants
29
+ const VALID_SKINS: TLinkCardSkin[] = [
30
+ 'normal',
31
+ 'no-padding',
32
+ 'blue',
33
+ 'beige',
34
+ 'green',
35
+ 'gray',
36
+ 'beige-outline',
37
+ 'gray-outline',
38
+ ]
39
+
40
+ const SAMPLE_ICONS = ['arrow-right', 'external-link', 'download', 'info']
41
+
42
+ // Cleanup after each test
43
+ afterEach(() => {
44
+ document.body.innerHTML = ''
45
+ })
46
+
47
+ // ----------- THE TESTS -----------
48
+
49
+ describe('PktLinkCard', () => {
50
+ describe('Basic Rendering', () => {
51
+ test('renders without errors', async () => {
52
+ const { linkCard, link } = await createLinkCardTest()
53
+
54
+ expect(linkCard).toBeInTheDocument()
55
+ expect(link).toBeInTheDocument()
56
+ expect(link).toHaveClass('pkt-linkcard')
57
+ })
58
+
59
+ test('renders with default properties', async () => {
60
+ const { linkCard, link } = await createLinkCardTest()
61
+
62
+ expect(linkCard.title).toBe('')
63
+ expect(linkCard.href).toBe('#')
64
+ expect(linkCard.iconName).toBe('')
65
+ expect(linkCard.openInNewTab).toBe(false)
66
+ expect(linkCard.skin).toBe('normal')
67
+ expect(linkCard.external).toBe(false)
68
+
69
+ expect(link.getAttribute('href')).toBe('#')
70
+ expect(link.getAttribute('target')).toBe('_self')
71
+ // When openInNewTab is false, rel should be null (ifDefined behavior)
72
+ expect(link.getAttribute('rel')).toBeNull()
73
+ })
74
+
75
+ test('renders content in slot', async () => {
76
+ const content = '<p>Custom link card content</p>'
77
+ const { linkCard } = await createLinkCardTest({ content })
78
+
79
+ const textSlot = linkCard.querySelector('.pkt-linkcard__text')
80
+ expect(textSlot).toBeInTheDocument()
81
+ expect(textSlot?.innerHTML).toBe(content)
82
+ })
83
+ })
84
+
85
+ describe('Title Functionality', () => {
86
+ test('renders title when provided', async () => {
87
+ const title = 'Test Link Card Title'
88
+ const { linkCard } = await createLinkCardTest({ title })
89
+
90
+ expect(linkCard.title).toBe(title)
91
+
92
+ const titleElement = linkCard.querySelector('.pkt-linkcard__title')
93
+ expect(titleElement).toBeInTheDocument()
94
+ expect(titleElement?.textContent).toBe(title)
95
+ })
96
+
97
+ test('does not render title when not provided', async () => {
98
+ const { linkCard } = await createLinkCardTest()
99
+
100
+ const titleElement = linkCard.querySelector('.pkt-linkcard__title')
101
+ expect(titleElement).not.toBeInTheDocument()
102
+ })
103
+
104
+ test('applies correct title classes', async () => {
105
+ const title = 'Test Title'
106
+ const { linkCard } = await createLinkCardTest({ title })
107
+
108
+ const titleElement = linkCard.querySelector('.pkt-linkcard__title')
109
+ expect(titleElement).toHaveClass('pkt-linkcard__title')
110
+ })
111
+ })
112
+
113
+ describe('Link Functionality', () => {
114
+ test('renders with custom href', async () => {
115
+ const href = '/custom-path'
116
+ const { linkCard, link } = await createLinkCardTest({ href })
117
+
118
+ expect(linkCard.href).toBe(href)
119
+ expect(link.getAttribute('href')).toBe(href)
120
+ })
121
+
122
+ test('handles openInNewTab correctly', async () => {
123
+ const { linkCard, link } = await createLinkCardTest({ openInNewTab: true })
124
+
125
+ expect(linkCard.openInNewTab).toBe(true)
126
+ expect(link.getAttribute('target')).toBe('_blank')
127
+ })
128
+
129
+ test('handles openInNewTab false correctly', async () => {
130
+ const { linkCard, link } = await createLinkCardTest({ openInNewTab: false })
131
+
132
+ expect(linkCard.openInNewTab).toBe(false)
133
+ expect(link.getAttribute('target')).toBe('_self')
134
+ })
135
+
136
+ test('applies correct rel attribute for new tab', async () => {
137
+ const { link } = await createLinkCardTest({ openInNewTab: true })
138
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
139
+ })
140
+ })
141
+
142
+ describe('Icon Functionality', () => {
143
+ test('renders icon when iconName provided', async () => {
144
+ const iconName = 'arrow-right'
145
+ const { linkCard } = await createLinkCardTest({ iconName })
146
+
147
+ expect(linkCard.iconName).toBe(iconName)
148
+
149
+ const icon = linkCard.querySelector('pkt-icon')
150
+ expect(icon).toBeInTheDocument()
151
+ expect(icon).toHaveClass('pkt-link__icon')
152
+ expect(icon?.getAttribute('name')).toBe(iconName)
153
+ })
154
+
155
+ test('does not render icon when iconName not provided', async () => {
156
+ const { linkCard } = await createLinkCardTest()
157
+
158
+ const icon = linkCard.querySelector('pkt-icon')
159
+ expect(icon).not.toBeInTheDocument()
160
+ })
161
+
162
+ test('renders with different icon names', async () => {
163
+ for (const iconName of SAMPLE_ICONS) {
164
+ const { linkCard } = await createLinkCardTest({ iconName })
165
+
166
+ const icon = linkCard.querySelector('pkt-icon')
167
+ expect(icon?.getAttribute('name')).toBe(iconName)
168
+ }
169
+ })
170
+ })
171
+
172
+ describe('Skin Variations', () => {
173
+ test('applies default skin class', async () => {
174
+ const { link } = await createLinkCardTest()
175
+ expect(link).toHaveClass('pkt-linkcard')
176
+ expect(link).toHaveClass('pkt-linkcard--normal')
177
+ })
178
+
179
+ test('applies different skin classes correctly', async () => {
180
+ for (const skin of VALID_SKINS) {
181
+ const { link } = await createLinkCardTest({ skin })
182
+
183
+ expect(link).toHaveClass('pkt-linkcard')
184
+ expect(link).toHaveClass(`pkt-linkcard--${skin}`)
185
+ }
186
+ })
187
+
188
+ test('handles skin property changes', async () => {
189
+ const { linkCard, link } = await createLinkCardTest({ skin: 'blue' })
190
+
191
+ expect(link).toHaveClass('pkt-linkcard--blue')
192
+
193
+ // Change skin
194
+ linkCard.skin = 'green'
195
+ await linkCard.updateComplete
196
+
197
+ expect(link).toHaveClass('pkt-linkcard--green')
198
+ expect(link).not.toHaveClass('pkt-linkcard--blue')
199
+ })
200
+ })
201
+
202
+ describe('Property Updates', () => {
203
+ test('updates title dynamically', async () => {
204
+ const { linkCard } = await createLinkCardTest({ title: 'Initial Title' })
205
+
206
+ expect(linkCard.querySelector('.pkt-linkcard__title')?.textContent).toBe('Initial Title')
207
+
208
+ linkCard.title = 'Updated Title'
209
+ await linkCard.updateComplete
210
+
211
+ expect(linkCard.querySelector('.pkt-linkcard__title')?.textContent).toBe('Updated Title')
212
+ })
213
+
214
+ test('updates href dynamically', async () => {
215
+ const { linkCard, link } = await createLinkCardTest({ href: '/initial' })
216
+
217
+ expect(link.getAttribute('href')).toBe('/initial')
218
+
219
+ linkCard.href = '/updated'
220
+ await linkCard.updateComplete
221
+
222
+ expect(link.getAttribute('href')).toBe('/updated')
223
+ })
224
+
225
+ test('updates openInNewTab dynamically', async () => {
226
+ const { linkCard, link } = await createLinkCardTest({ openInNewTab: false })
227
+
228
+ expect(link.getAttribute('target')).toBe('_self')
229
+
230
+ linkCard.openInNewTab = true
231
+ await linkCard.updateComplete
232
+
233
+ expect(link.getAttribute('target')).toBe('_blank')
234
+ })
235
+ })
236
+
237
+ describe('Complex Configurations', () => {
238
+ test('renders with all properties set', async () => {
239
+ const config: LinkCardTestConfig = {
240
+ title: 'Complete Link Card',
241
+ href: '/complete-path',
242
+ iconName: 'arrow-right',
243
+ openInNewTab: true,
244
+ skin: 'blue',
245
+ content: '<p>Complete content</p>',
246
+ }
247
+
248
+ const { linkCard, link } = await createLinkCardTest(config)
249
+
250
+ // Verify all properties
251
+ expect(linkCard.title).toBe(config.title)
252
+ expect(linkCard.href).toBe(config.href)
253
+ expect(linkCard.iconName).toBe(config.iconName)
254
+ expect(linkCard.openInNewTab).toBe(config.openInNewTab)
255
+ expect(linkCard.skin).toBe(config.skin)
256
+
257
+ // Verify DOM elements
258
+ expect(link.getAttribute('href')).toBe(config.href)
259
+ expect(link.getAttribute('target')).toBe('_blank')
260
+ expect(link).toHaveClass(`pkt-linkcard--${config.skin}`)
261
+
262
+ const titleElement = linkCard.querySelector('.pkt-linkcard__title')
263
+ expect(titleElement?.textContent).toBe(config.title)
264
+
265
+ const icon = linkCard.querySelector('pkt-icon')
266
+ expect(icon?.getAttribute('name')).toBe(config.iconName)
267
+
268
+ const textSlot = linkCard.querySelector('.pkt-linkcard__text')
269
+ expect(textSlot?.innerHTML).toBe(config.content)
270
+ })
271
+
272
+ test('renders minimal configuration', async () => {
273
+ const { linkCard, link } = await createLinkCardTest({ href: '/minimal' })
274
+
275
+ expect(linkCard.href).toBe('/minimal')
276
+ expect(link.getAttribute('href')).toBe('/minimal')
277
+ expect(link).toHaveClass('pkt-linkcard--normal')
278
+
279
+ // Should not have title or icon
280
+ expect(linkCard.querySelector('.pkt-linkcard__title')).not.toBeInTheDocument()
281
+ expect(linkCard.querySelector('pkt-icon')).not.toBeInTheDocument()
282
+ })
283
+ })
284
+
285
+ describe('Edge Cases', () => {
286
+ test('handles empty string values', async () => {
287
+ const { linkCard } = await createLinkCardTest({
288
+ title: '',
289
+ href: '',
290
+ iconName: '',
291
+ })
292
+
293
+ expect(linkCard.title).toBe('')
294
+ expect(linkCard.href).toBe('')
295
+ expect(linkCard.iconName).toBe('')
296
+
297
+ // Should not render title or icon with empty strings
298
+ expect(linkCard.querySelector('.pkt-linkcard__title')).not.toBeInTheDocument()
299
+ expect(linkCard.querySelector('pkt-icon')).not.toBeInTheDocument()
300
+ })
301
+
302
+ test('handles special characters in title', async () => {
303
+ const specialTitle = 'Title with "quotes" & <symbols>'
304
+ const { linkCard } = await createLinkCardTest()
305
+
306
+ // Set title via property to avoid HTML attribute encoding issues
307
+ linkCard.title = specialTitle
308
+ await linkCard.updateComplete
309
+
310
+ expect(linkCard.title).toBe(specialTitle)
311
+
312
+ const titleElement = linkCard.querySelector('.pkt-linkcard__title')
313
+ expect(titleElement).toBeInTheDocument()
314
+ expect(titleElement?.textContent).toBe(specialTitle)
315
+ })
316
+ })
317
+
318
+ describe('Accessibility', () => {
319
+ test('has no accessibility violations', async () => {
320
+ const { linkCard } = await createLinkCardTest({
321
+ title: 'Accessible Link Card',
322
+ href: '/accessible',
323
+ })
324
+
325
+ const results = await axe(linkCard)
326
+ expect(results).toHaveNoViolations()
327
+ })
328
+
329
+ test('maintains proper link semantics', async () => {
330
+ const { link } = await createLinkCardTest({
331
+ title: 'Semantic Link',
332
+ href: '/semantic',
333
+ })
334
+
335
+ expect(link.tagName).toBe('A')
336
+ expect(link.getAttribute('href')).toBe('/semantic')
337
+ expect(link.textContent).toContain('Semantic Link')
338
+ })
339
+
340
+ test('handles external link accessibility', async () => {
341
+ const { link } = await createLinkCardTest({
342
+ title: 'External Link',
343
+ href: 'https://example.com',
344
+ openInNewTab: true,
345
+ })
346
+
347
+ expect(link.getAttribute('target')).toBe('_blank')
348
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
349
+ })
350
+ })
351
+
352
+ describe('Type Safety', () => {
353
+ test('validates interface implementation', () => {
354
+ const linkCard = new PktLinkCard()
355
+
356
+ // Check that all interface properties exist
357
+ expect(linkCard).toHaveProperty('title')
358
+ expect(linkCard).toHaveProperty('href')
359
+ expect(linkCard).toHaveProperty('iconName')
360
+ expect(linkCard).toHaveProperty('openInNewTab')
361
+ expect(linkCard).toHaveProperty('skin')
362
+ })
363
+ })
364
+ })
@@ -5,6 +5,7 @@ import { Ref, createRef } from 'lit/directives/ref.js'
5
5
  import { PktElement } from '@/base-elements/element'
6
6
  import { PktSlotController } from '@/controllers/pkt-slot-controller'
7
7
  import '@/components/icon'
8
+ import { ifDefined } from 'lit/directives/if-defined.js'
8
9
 
9
10
  export type TLinkCardSkin =
10
11
  | 'normal'
@@ -15,6 +16,7 @@ export type TLinkCardSkin =
15
16
  | 'gray'
16
17
  | 'beige-outline'
17
18
  | 'gray-outline';
19
+
18
20
  export interface IPktLinkCard {
19
21
  title?: string
20
22
  href?: string
@@ -54,7 +56,7 @@ export class PktLinkCard extends PktElement implements IPktLinkCard {
54
56
  href=${this.href}
55
57
  class=${classes}
56
58
  target=${this.openInNewTab ? '_blank' : '_self'}
57
- rel=${this.openInNewTab ?? 'noopener noreferrer'}
59
+ rel=${ifDefined(this.openInNewTab ? 'noopener noreferrer' : undefined)}
58
60
  >
59
61
  ${this.iconName && html`<pkt-icon class="pkt-link__icon" name="${this.iconName}" />`}
60
62
  ${this.title && html`<div class=${titleClasses}>${this.title}</div>`}