@oslokommune/punkt-react 16.1.0 → 16.3.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.
- package/CHANGELOG.md +36 -0
- package/dist/index.d.ts +29 -13
- package/dist/punkt-react.es.js +3671 -3511
- package/dist/punkt-react.umd.js +277 -277
- package/package.json +4 -4
- package/src/components/accordion/AccordionItem.tsx +3 -0
- package/src/components/card/Card.test.tsx +373 -0
- package/src/components/card/Card.tsx +159 -24
- package/src/components/modal/Modal.test.tsx +12 -7
- package/src/components/modal/Modal.tsx +155 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oslokommune/punkt-react",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.3.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": "^16.
|
|
42
|
+
"@oslokommune/punkt-elements": "^16.3.0",
|
|
43
43
|
"classnames": "^2.5.1",
|
|
44
44
|
"prettier": "^3.3.3",
|
|
45
45
|
"react-hook-form": "^7.53.0"
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@eslint/eslintrc": "^3.3.3",
|
|
51
51
|
"@eslint/js": "^9.37.0",
|
|
52
52
|
"@oslokommune/punkt-assets": "^16.0.0",
|
|
53
|
-
"@oslokommune/punkt-css": "^16.0
|
|
53
|
+
"@oslokommune/punkt-css": "^16.3.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": "
|
|
112
|
+
"gitHead": "3561a39e6329e3fc85d558d89cf20b58aa410d24"
|
|
113
113
|
}
|
|
@@ -58,6 +58,9 @@ export const PktAccordionItem = forwardRef<HTMLDetailsElement, IPktAccordionItem
|
|
|
58
58
|
const detailsElement = e.currentTarget
|
|
59
59
|
const newOpenState = detailsElement.open
|
|
60
60
|
|
|
61
|
+
// Ignorer toggle-events som skyldes React-synkronisering av open-attributtet
|
|
62
|
+
if (newOpenState === isOpen) return
|
|
63
|
+
|
|
61
64
|
if (controlledIsOpen === undefined) {
|
|
62
65
|
setInternalIsOpen(newOpenState)
|
|
63
66
|
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
import { cleanup, render } from '@testing-library/react'
|
|
4
|
+
import { axe, toHaveNoViolations } from 'jest-axe'
|
|
5
|
+
import { createRef } from 'react'
|
|
6
|
+
|
|
7
|
+
import { PktCard } from './Card'
|
|
8
|
+
|
|
9
|
+
expect.extend(toHaveNoViolations)
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup)
|
|
12
|
+
|
|
13
|
+
describe('PktCard', () => {
|
|
14
|
+
describe('Rendering and basic functionality', () => {
|
|
15
|
+
test('renders without errors', () => {
|
|
16
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
17
|
+
const article = container.querySelector('article.pkt-card')
|
|
18
|
+
expect(article).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('renders content in children', () => {
|
|
22
|
+
const { container } = render(<PktCard><p>Test content here</p></PktCard>)
|
|
23
|
+
const content = container.querySelector('.pkt-card__content')
|
|
24
|
+
expect(content).toBeInTheDocument()
|
|
25
|
+
expect(content?.textContent).toContain('Test content here')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('renders basic structure correctly', () => {
|
|
29
|
+
const { container } = render(<PktCard heading="Test Heading">Test content</PktCard>)
|
|
30
|
+
const article = container.querySelector('article')
|
|
31
|
+
const wrapper = article?.querySelector('.pkt-card__wrapper')
|
|
32
|
+
const header = wrapper?.querySelector('.pkt-card__header')
|
|
33
|
+
const content = wrapper?.querySelector('.pkt-card__content')
|
|
34
|
+
|
|
35
|
+
expect(wrapper).toBeInTheDocument()
|
|
36
|
+
expect(header).toBeInTheDocument()
|
|
37
|
+
expect(content).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('forwards ref correctly', () => {
|
|
41
|
+
const ref = createRef<HTMLDivElement>()
|
|
42
|
+
const { unmount } = render(<PktCard ref={ref} heading="Test">innhold</PktCard>)
|
|
43
|
+
expect(ref.current).toBeInstanceOf(HTMLElement)
|
|
44
|
+
unmount()
|
|
45
|
+
expect(ref.current).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('Properties and attributes', () => {
|
|
50
|
+
test('applies default properties correctly', () => {
|
|
51
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
52
|
+
const article = container.querySelector('article')
|
|
53
|
+
expect(article).toHaveClass('pkt-card--outlined')
|
|
54
|
+
expect(article).toHaveClass('pkt-card--vertical')
|
|
55
|
+
expect(article).toHaveClass('pkt-card--padding-default')
|
|
56
|
+
expect(article).toHaveClass('pkt-card--border-on-hover')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('applies different skin properties correctly', () => {
|
|
60
|
+
const skins = ['outlined', 'outlined-beige', 'gray', 'beige', 'green', 'blue'] as const
|
|
61
|
+
for (const skin of skins) {
|
|
62
|
+
const { container, unmount } = render(<PktCard skin={skin}>innhold</PktCard>)
|
|
63
|
+
const article = container.querySelector('article')
|
|
64
|
+
expect(article).toHaveClass(`pkt-card--${skin}`)
|
|
65
|
+
unmount()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('applies different layout properties correctly', () => {
|
|
70
|
+
const layouts = ['vertical', 'horizontal'] as const
|
|
71
|
+
for (const layout of layouts) {
|
|
72
|
+
const { container, unmount } = render(<PktCard layout={layout}>innhold</PktCard>)
|
|
73
|
+
const article = container.querySelector('article')
|
|
74
|
+
expect(article).toHaveClass(`pkt-card--${layout}`)
|
|
75
|
+
unmount()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('applies different padding properties correctly', () => {
|
|
80
|
+
const paddingOptions = ['none', 'default'] as const
|
|
81
|
+
for (const padding of paddingOptions) {
|
|
82
|
+
const { container, unmount } = render(<PktCard padding={padding}>innhold</PktCard>)
|
|
83
|
+
const article = container.querySelector('article')
|
|
84
|
+
expect(article).toHaveClass(`pkt-card--padding-${padding}`)
|
|
85
|
+
unmount()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('handles borderOnHover property correctly', () => {
|
|
90
|
+
const { container } = render(<PktCard borderOnHover={false}>innhold</PktCard>)
|
|
91
|
+
const article = container.querySelector('article')
|
|
92
|
+
expect(article).not.toHaveClass('pkt-card--border-on-hover')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('supports custom className', () => {
|
|
96
|
+
const { container } = render(<PktCard className="custom-class">innhold</PktCard>)
|
|
97
|
+
const wrapper = container.querySelector('.pkt-card-root')
|
|
98
|
+
expect(wrapper).toHaveClass('custom-class')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('Heading functionality', () => {
|
|
103
|
+
test('renders heading when provided', () => {
|
|
104
|
+
const { container } = render(<PktCard heading="Test Card Title">innhold</PktCard>)
|
|
105
|
+
const heading = container.querySelector('.pkt-card__heading')
|
|
106
|
+
expect(heading).toBeInTheDocument()
|
|
107
|
+
expect(heading?.textContent?.trim()).toBe('Test Card Title')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('renders subheading when provided', () => {
|
|
111
|
+
const { container } = render(<PktCard subheading="Test Subheading">innhold</PktCard>)
|
|
112
|
+
const subheading = container.querySelector('.pkt-card__subheading')
|
|
113
|
+
expect(subheading).toBeInTheDocument()
|
|
114
|
+
expect(subheading?.textContent).toBe('Test Subheading')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('applies correct heading level', () => {
|
|
118
|
+
const { container } = render(
|
|
119
|
+
<PktCard heading="Test" headingLevel={2}>innhold</PktCard>,
|
|
120
|
+
)
|
|
121
|
+
const heading = container.querySelector('h2.pkt-card__heading')
|
|
122
|
+
expect(heading).toBeInTheDocument()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('defaults to h3 heading level', () => {
|
|
126
|
+
const { container } = render(<PktCard heading="Test">innhold</PktCard>)
|
|
127
|
+
const heading = container.querySelector('h3.pkt-card__heading')
|
|
128
|
+
expect(heading).toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('does not render header when no heading or subheading', () => {
|
|
132
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
133
|
+
const header = container.querySelector('.pkt-card__header')
|
|
134
|
+
expect(header).not.toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('Link functionality', () => {
|
|
139
|
+
test('renders as regular card when no clickCardLink', () => {
|
|
140
|
+
const { container } = render(<PktCard heading="Test Title">innhold</PktCard>)
|
|
141
|
+
const link = container.querySelector('.pkt-card__link')
|
|
142
|
+
expect(link).not.toBeInTheDocument()
|
|
143
|
+
|
|
144
|
+
const article = container.querySelector('article')
|
|
145
|
+
expect(article?.getAttribute('aria-label')).toBe('Test Title')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('renders as link card when clickCardLink provided', () => {
|
|
149
|
+
const { container } = render(
|
|
150
|
+
<PktCard heading="Test Title" clickCardLink="/test-url">innhold</PktCard>,
|
|
151
|
+
)
|
|
152
|
+
const linkHeading = container.querySelector('.pkt-card__link-heading')
|
|
153
|
+
const link = container.querySelector('.pkt-card__link')
|
|
154
|
+
expect(linkHeading).toBeInTheDocument()
|
|
155
|
+
expect(link).toBeInTheDocument()
|
|
156
|
+
expect(link?.getAttribute('href')).toBe('/test-url')
|
|
157
|
+
expect(link?.textContent).toBe('Test Title')
|
|
158
|
+
|
|
159
|
+
const article = container.querySelector('article')
|
|
160
|
+
expect(article?.getAttribute('aria-label')).toBe('Test Title lenkekort')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('does not render plain heading when clickCardLink is set', () => {
|
|
164
|
+
const { container } = render(
|
|
165
|
+
<PktCard heading="Test" clickCardLink="/test">innhold</PktCard>,
|
|
166
|
+
)
|
|
167
|
+
const headings = container.querySelectorAll('.pkt-card__heading')
|
|
168
|
+
// Should only have the link heading, not both
|
|
169
|
+
expect(headings).toHaveLength(1)
|
|
170
|
+
expect(headings[0]).toHaveClass('pkt-card__link-heading')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('handles openLinkInNewTab correctly', () => {
|
|
174
|
+
const { container } = render(
|
|
175
|
+
<PktCard heading="Test" clickCardLink="/test" openLinkInNewTab>innhold</PktCard>,
|
|
176
|
+
)
|
|
177
|
+
const link = container.querySelector('.pkt-card__link')
|
|
178
|
+
expect(link?.getAttribute('target')).toBe('_blank')
|
|
179
|
+
expect(link?.getAttribute('rel')).toBe('noopener noreferrer')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('applies correct aria-label for link cards with custom ariaLabel', () => {
|
|
183
|
+
const { container } = render(
|
|
184
|
+
<PktCard clickCardLink="/test" ariaLabel="Custom aria label">innhold</PktCard>,
|
|
185
|
+
)
|
|
186
|
+
const article = container.querySelector('article')
|
|
187
|
+
expect(article?.getAttribute('aria-label')).toBe('Custom aria label')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('Image functionality', () => {
|
|
192
|
+
test('renders image when provided', () => {
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<PktCard image={{ src: '/test-image.jpg', alt: 'Test image' }}>innhold</PktCard>,
|
|
195
|
+
)
|
|
196
|
+
const imageDiv = container.querySelector('.pkt-card__image')
|
|
197
|
+
const img = imageDiv?.querySelector('img')
|
|
198
|
+
expect(imageDiv).toBeInTheDocument()
|
|
199
|
+
expect(img).toBeInTheDocument()
|
|
200
|
+
expect(img?.getAttribute('src')).toBe('/test-image.jpg')
|
|
201
|
+
expect(img?.getAttribute('alt')).toBe('Test image')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('does not render image when not provided', () => {
|
|
205
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
206
|
+
const imageDiv = container.querySelector('.pkt-card__image')
|
|
207
|
+
expect(imageDiv).not.toBeInTheDocument()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('applies correct image shape classes', () => {
|
|
211
|
+
const shapes = ['square', 'round'] as const
|
|
212
|
+
for (const shape of shapes) {
|
|
213
|
+
const { container, unmount } = render(
|
|
214
|
+
<PktCard image={{ src: '/test.jpg', alt: 'Test' }} imageShape={shape}>innhold</PktCard>,
|
|
215
|
+
)
|
|
216
|
+
const imageDiv = container.querySelector('.pkt-card__image')
|
|
217
|
+
expect(imageDiv).toHaveClass(`pkt-card__image-${shape}`)
|
|
218
|
+
unmount()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('Tags functionality', () => {
|
|
224
|
+
test('renders tags when provided', () => {
|
|
225
|
+
const tags = [
|
|
226
|
+
{ text: 'Tag 1', skin: 'blue' as const },
|
|
227
|
+
{ text: 'Tag 2', skin: 'green' as const },
|
|
228
|
+
]
|
|
229
|
+
const { container } = render(<PktCard tags={tags}>innhold</PktCard>)
|
|
230
|
+
const tagsContainer = container.querySelector('.pkt-card__tags')
|
|
231
|
+
expect(tagsContainer).toBeInTheDocument()
|
|
232
|
+
expect(tagsContainer?.getAttribute('aria-label')).toBe('merkelapper')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('renders single tag with correct aria-label', () => {
|
|
236
|
+
const tags = [{ text: 'Single Tag' }]
|
|
237
|
+
const { container } = render(<PktCard tags={tags}>innhold</PktCard>)
|
|
238
|
+
const tagsContainer = container.querySelector('.pkt-card__tags')
|
|
239
|
+
expect(tagsContainer?.getAttribute('aria-label')).toBe('merkelapp')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('applies correct tag position classes', () => {
|
|
243
|
+
const positions = ['top', 'bottom'] as const
|
|
244
|
+
for (const position of positions) {
|
|
245
|
+
const tags = [{ text: 'Test Tag' }]
|
|
246
|
+
const { container, unmount } = render(
|
|
247
|
+
<PktCard tags={tags} tagPosition={position}>innhold</PktCard>,
|
|
248
|
+
)
|
|
249
|
+
const tagsContainer = container.querySelector('.pkt-card__tags')
|
|
250
|
+
expect(tagsContainer).toHaveClass(`pkt-card__tags-${position}`)
|
|
251
|
+
unmount()
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('does not render tags when array is empty', () => {
|
|
256
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
257
|
+
const tagsContainer = container.querySelector('.pkt-card__tags')
|
|
258
|
+
expect(tagsContainer).not.toBeInTheDocument()
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('Metadata functionality', () => {
|
|
263
|
+
test('renders metadata when provided', () => {
|
|
264
|
+
const { container } = render(
|
|
265
|
+
<PktCard metaLead="Author Name" metaTrail="2023-12-01">innhold</PktCard>,
|
|
266
|
+
)
|
|
267
|
+
const metadata = container.querySelector('.pkt-card__metadata')
|
|
268
|
+
const metaLead = metadata?.querySelector('.pkt-card__metadata-lead')
|
|
269
|
+
const metaTrail = metadata?.querySelector('.pkt-card__metadata-trail')
|
|
270
|
+
|
|
271
|
+
expect(metadata).toBeInTheDocument()
|
|
272
|
+
expect(metaLead?.textContent).toBe('Author Name')
|
|
273
|
+
expect(metaTrail?.textContent).toBe('2023-12-01')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('renders only metaLead when metaTrail not provided', () => {
|
|
277
|
+
const { container } = render(<PktCard metaLead="Author Only">innhold</PktCard>)
|
|
278
|
+
const metaLead = container.querySelector('.pkt-card__metadata-lead')
|
|
279
|
+
const metaTrail = container.querySelector('.pkt-card__metadata-trail')
|
|
280
|
+
expect(metaLead).toBeInTheDocument()
|
|
281
|
+
expect(metaTrail).not.toBeInTheDocument()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('renders only metaTrail when metaLead not provided', () => {
|
|
285
|
+
const { container } = render(<PktCard metaTrail="Date Only">innhold</PktCard>)
|
|
286
|
+
const metaLead = container.querySelector('.pkt-card__metadata-lead')
|
|
287
|
+
const metaTrail = container.querySelector('.pkt-card__metadata-trail')
|
|
288
|
+
expect(metaLead).not.toBeInTheDocument()
|
|
289
|
+
expect(metaTrail).toBeInTheDocument()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('does not render metadata when neither provided', () => {
|
|
293
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
294
|
+
const metadata = container.querySelector('.pkt-card__metadata')
|
|
295
|
+
expect(metadata).not.toBeInTheDocument()
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('Content placement and structure', () => {
|
|
300
|
+
test('renders content elements in correct order', () => {
|
|
301
|
+
const tags = [{ text: 'Test Tag' }]
|
|
302
|
+
const { container } = render(
|
|
303
|
+
<PktCard
|
|
304
|
+
heading="Test Title"
|
|
305
|
+
subheading="Test Sub"
|
|
306
|
+
tags={tags}
|
|
307
|
+
image={{ src: '/test.jpg', alt: 'Test' }}
|
|
308
|
+
metaLead="Author"
|
|
309
|
+
metaTrail="Date"
|
|
310
|
+
>
|
|
311
|
+
innhold
|
|
312
|
+
</PktCard>,
|
|
313
|
+
)
|
|
314
|
+
const article = container.querySelector('article')
|
|
315
|
+
const children = Array.from(article?.children || [])
|
|
316
|
+
|
|
317
|
+
// Image first, then wrapper
|
|
318
|
+
expect(children[0]).toHaveClass('pkt-card__image')
|
|
319
|
+
expect(children[1]).toHaveClass('pkt-card__wrapper')
|
|
320
|
+
|
|
321
|
+
const wrapperChildren = Array.from(children[1]?.children || [])
|
|
322
|
+
|
|
323
|
+
// Order within wrapper: tags (top), header, content, metadata
|
|
324
|
+
expect(wrapperChildren[0]).toHaveClass('pkt-card__tags-top')
|
|
325
|
+
expect(wrapperChildren[1]).toHaveClass('pkt-card__header')
|
|
326
|
+
expect(wrapperChildren[2]).toHaveClass('pkt-card__content')
|
|
327
|
+
expect(wrapperChildren[3]).toHaveClass('pkt-card__metadata')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('places tags at bottom when tagPosition is bottom', () => {
|
|
331
|
+
const tags = [{ text: 'Test Tag' }]
|
|
332
|
+
const { container } = render(
|
|
333
|
+
<PktCard heading="Test Title" tags={tags} tagPosition="bottom">innhold</PktCard>,
|
|
334
|
+
)
|
|
335
|
+
const wrapperChildren = Array.from(
|
|
336
|
+
container.querySelector('.pkt-card__wrapper')?.children || [],
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// Order: header, content, tags (bottom)
|
|
340
|
+
expect(wrapperChildren[0]).toHaveClass('pkt-card__header')
|
|
341
|
+
expect(wrapperChildren[1]).toHaveClass('pkt-card__content')
|
|
342
|
+
expect(wrapperChildren[2]).toHaveClass('pkt-card__tags-bottom')
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe('Accessibility', () => {
|
|
347
|
+
test('has no accessibility violations', async () => {
|
|
348
|
+
const { container } = render(<PktCard heading="Accessible Card">innhold</PktCard>)
|
|
349
|
+
const results = await axe(container)
|
|
350
|
+
expect(results).toHaveNoViolations()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('applies custom aria-label', () => {
|
|
354
|
+
const { container } = render(
|
|
355
|
+
<PktCard heading="Test" ariaLabel="Custom accessible label">innhold</PktCard>,
|
|
356
|
+
)
|
|
357
|
+
const article = container.querySelector('article')
|
|
358
|
+
expect(article?.getAttribute('aria-label')).toBe('Custom accessible label')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('falls back to heading for aria-label', () => {
|
|
362
|
+
const { container } = render(<PktCard heading="Default Aria Label">innhold</PktCard>)
|
|
363
|
+
const article = container.querySelector('article')
|
|
364
|
+
expect(article?.getAttribute('aria-label')).toBe('Default Aria Label')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('falls back to "kort" when no heading or aria-label', () => {
|
|
368
|
+
const { container } = render(<PktCard>innhold</PktCard>)
|
|
369
|
+
const article = container.querySelector('article')
|
|
370
|
+
expect(article?.getAttribute('aria-label')).toBe('kort')
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -1,53 +1,188 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
// eslint-disable-next-line no-restricted-syntax -- React is required for createComponent
|
|
6
|
-
import React, { FC, ForwardedRef, forwardRef, type ReactElement } from 'react'
|
|
3
|
+
import classNames from 'classnames'
|
|
4
|
+
import { forwardRef, HTMLAttributes, ReactNode, Ref } from 'react'
|
|
7
5
|
import type { TCardSkin, TLayout } from 'shared-types'
|
|
8
6
|
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
import { IPktTag } from '../tag/Tag'
|
|
7
|
+
import { PktIcon } from '../icon/Icon'
|
|
8
|
+
import { PktTag } from '../tag/Tag'
|
|
12
9
|
|
|
13
10
|
export type { TCardSkin, TLayout }
|
|
14
11
|
type TCardPadding = 'none' | 'default'
|
|
15
12
|
type TCardImageShape = 'square' | 'round'
|
|
16
13
|
type TCardTagPosition = 'top' | 'bottom'
|
|
14
|
+
type THeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
|
|
15
|
+
|
|
16
|
+
type TTagSkin = 'blue' | 'blue-light' | 'blue-dark' | 'green' | 'red' | 'beige' | 'yellow' | 'grey' | 'gray'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
interface ICardTag {
|
|
19
|
+
skin?: TTagSkin
|
|
20
|
+
iconName?: string
|
|
21
|
+
ariaLabel?: string
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IPktCard extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|
19
26
|
ariaLabel?: string
|
|
20
27
|
metaLead?: string | null
|
|
21
|
-
borderOnHover?: boolean
|
|
28
|
+
borderOnHover?: boolean
|
|
22
29
|
metaTrail?: string | null
|
|
23
30
|
layout?: TLayout
|
|
24
31
|
heading?: string
|
|
25
|
-
headingLevel?:
|
|
32
|
+
headingLevel?: THeadingLevel
|
|
26
33
|
image?: { src: string; alt: string }
|
|
27
34
|
imageShape?: TCardImageShape
|
|
28
35
|
clickCardLink?: string | null
|
|
29
36
|
padding?: TCardPadding
|
|
30
|
-
openLinkInNewTab?: boolean
|
|
37
|
+
openLinkInNewTab?: boolean
|
|
31
38
|
skin?: TCardSkin
|
|
32
39
|
subheading?: string
|
|
33
40
|
tagPosition?: TCardTagPosition
|
|
34
|
-
tags?:
|
|
41
|
+
tags?: ICardTag[]
|
|
42
|
+
children?: ReactNode
|
|
43
|
+
ref?: Ref<HTMLDivElement>
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
export const PktCard = forwardRef<HTMLDivElement, IPktCard>(
|
|
47
|
+
(
|
|
48
|
+
{
|
|
49
|
+
children,
|
|
50
|
+
className,
|
|
51
|
+
ariaLabel,
|
|
52
|
+
metaLead,
|
|
53
|
+
borderOnHover = true,
|
|
54
|
+
metaTrail,
|
|
55
|
+
layout = 'vertical',
|
|
56
|
+
heading = '',
|
|
57
|
+
headingLevel = 3,
|
|
58
|
+
image,
|
|
59
|
+
imageShape = 'square',
|
|
60
|
+
clickCardLink,
|
|
61
|
+
padding = 'default',
|
|
62
|
+
openLinkInNewTab = false,
|
|
63
|
+
skin = 'outlined',
|
|
64
|
+
subheading = '',
|
|
65
|
+
tagPosition = 'top',
|
|
66
|
+
tags = [],
|
|
67
|
+
...props
|
|
68
|
+
},
|
|
69
|
+
ref,
|
|
70
|
+
) => {
|
|
71
|
+
const classes = classNames({
|
|
72
|
+
'pkt-card': true,
|
|
73
|
+
[`pkt-card--${skin}`]: skin,
|
|
74
|
+
[`pkt-card--${layout}`]: layout,
|
|
75
|
+
[`pkt-card--padding-${padding}`]: padding,
|
|
76
|
+
'pkt-card--border-on-hover': borderOnHover,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const ariaLabelLenke =
|
|
80
|
+
ariaLabel?.trim() || (heading ? `${heading} lenkekort` : 'lenkekort')
|
|
81
|
+
const ariaLabelVanlig = ariaLabel?.trim() || (heading ? heading : 'kort')
|
|
82
|
+
|
|
83
|
+
const HeadingTag = `h${headingLevel}` as keyof JSX.IntrinsicElements
|
|
84
|
+
|
|
85
|
+
const renderImage = () => {
|
|
86
|
+
if (!image?.src) return null
|
|
87
|
+
return (
|
|
88
|
+
<div className={classNames('pkt-card__image', `pkt-card__image-${imageShape}`)}>
|
|
89
|
+
<img src={image.src} alt={image.alt || ''} />
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const renderTags = () => {
|
|
95
|
+
if (tags.length === 0) return null
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className={classNames('pkt-card__tags', `pkt-card__tags-${tagPosition}`)}
|
|
99
|
+
role="list"
|
|
100
|
+
aria-label={tags.length > 1 ? 'merkelapper' : 'merkelapp'}
|
|
101
|
+
>
|
|
102
|
+
{tags.map((tag, index) => (
|
|
103
|
+
<PktTag
|
|
104
|
+
key={index}
|
|
105
|
+
role="listitem"
|
|
106
|
+
textStyle="normal-text"
|
|
107
|
+
size="medium"
|
|
108
|
+
skin={tag.skin}
|
|
109
|
+
iconName={tag.iconName}
|
|
110
|
+
>
|
|
111
|
+
<span>{tag.text}</span>
|
|
112
|
+
</PktTag>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const renderHeading = () => {
|
|
119
|
+
if (!heading || clickCardLink) return null
|
|
120
|
+
return (
|
|
121
|
+
<HeadingTag className="pkt-heading pkt-heading--medium pkt-heading--fw-regular pkt-card__heading">
|
|
122
|
+
{heading}
|
|
123
|
+
</HeadingTag>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const renderLinkHeading = () => {
|
|
128
|
+
if (!clickCardLink) return null
|
|
129
|
+
return (
|
|
130
|
+
<HeadingTag className="pkt-heading pkt-heading--medium pkt-heading--fw-regular pkt-card__link-heading pkt-card__heading">
|
|
131
|
+
<a
|
|
132
|
+
className="pkt-card__link"
|
|
133
|
+
href={clickCardLink}
|
|
134
|
+
target={openLinkInNewTab ? '_blank' : '_self'}
|
|
135
|
+
rel={openLinkInNewTab ? 'noopener noreferrer' : undefined}
|
|
136
|
+
>
|
|
137
|
+
{heading}
|
|
138
|
+
</a>
|
|
139
|
+
</HeadingTag>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const renderSubheading = () => {
|
|
144
|
+
if (!subheading) return null
|
|
145
|
+
return <p className="pkt-card__subheading">{subheading}</p>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const renderHeader = () => {
|
|
149
|
+
if (!heading && !subheading) return null
|
|
150
|
+
return (
|
|
151
|
+
<header className="pkt-card__header">
|
|
152
|
+
{renderHeading()}
|
|
153
|
+
{renderLinkHeading()}
|
|
154
|
+
{renderSubheading()}
|
|
155
|
+
</header>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const renderMetadata = () => {
|
|
160
|
+
if (!metaLead && !metaTrail) return null
|
|
161
|
+
return (
|
|
162
|
+
<footer className="pkt-card__metadata">
|
|
163
|
+
{metaLead && <span className="pkt-card__metadata-lead">{metaLead}</span>}
|
|
164
|
+
{metaTrail && <span className="pkt-card__metadata-trail">{metaTrail}</span>}
|
|
165
|
+
</footer>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
44
168
|
|
|
45
|
-
export const PktCard: FC<IPktCard> = forwardRef(
|
|
46
|
-
({ children, ...props }: IPktCard, ref: ForwardedRef<HTMLElement>): ReactElement => {
|
|
47
169
|
return (
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
170
|
+
<div ref={ref} className={classNames('pkt-card-root', className)} style={{ display: 'block', width: '100%' }}>
|
|
171
|
+
<article
|
|
172
|
+
{...props}
|
|
173
|
+
className={classes}
|
|
174
|
+
aria-label={clickCardLink ? ariaLabelLenke : ariaLabelVanlig}
|
|
175
|
+
>
|
|
176
|
+
{renderImage()}
|
|
177
|
+
<div className="pkt-card__wrapper">
|
|
178
|
+
{tagPosition === 'top' && renderTags()}
|
|
179
|
+
{renderHeader()}
|
|
180
|
+
<section className="pkt-card__content">{children}</section>
|
|
181
|
+
{tagPosition === 'bottom' && renderTags()}
|
|
182
|
+
{renderMetadata()}
|
|
183
|
+
</div>
|
|
184
|
+
</article>
|
|
185
|
+
</div>
|
|
51
186
|
)
|
|
52
187
|
},
|
|
53
188
|
)
|
|
@@ -11,35 +11,40 @@ expect.extend(toHaveNoViolations)
|
|
|
11
11
|
afterEach(cleanup)
|
|
12
12
|
|
|
13
13
|
describe('PktModal', () => {
|
|
14
|
-
test('ref works correctly',
|
|
15
|
-
const ref = createRef<
|
|
14
|
+
test('ref works correctly', () => {
|
|
15
|
+
const ref = createRef<HTMLDialogElement>()
|
|
16
16
|
const { unmount } = render(
|
|
17
17
|
<PktModal ref={ref} headingText="Modal Title">
|
|
18
18
|
modal content
|
|
19
19
|
</PktModal>,
|
|
20
20
|
)
|
|
21
|
-
|
|
22
|
-
expect(ref.current).toBeInstanceOf(HTMLElement)
|
|
21
|
+
expect(ref.current).toBeInstanceOf(HTMLDialogElement)
|
|
23
22
|
unmount()
|
|
24
23
|
expect(ref.current).toBeNull()
|
|
25
24
|
})
|
|
26
25
|
|
|
27
|
-
test('renders with the specified size',
|
|
26
|
+
test('renders with the specified size', () => {
|
|
28
27
|
const { container } = render(
|
|
29
28
|
<PktModal headingText="Modal Title" size="small">
|
|
30
29
|
modal content
|
|
31
30
|
</PktModal>,
|
|
32
31
|
)
|
|
33
|
-
await window.customElements.whenDefined('pkt-modal')
|
|
34
32
|
const dialogElement = container.querySelector('dialog.pkt-modal')
|
|
35
33
|
expect(dialogElement).toHaveClass('pkt-modal--small')
|
|
36
34
|
})
|
|
37
35
|
|
|
38
36
|
test('renders with no wcag errors with axe', async () => {
|
|
39
37
|
const { container } = render(<PktModal headingText="Modal Title"></PktModal>)
|
|
40
|
-
await window.customElements.whenDefined('pkt-modal')
|
|
41
38
|
const results = await axe(container)
|
|
42
39
|
|
|
43
40
|
expect(results).toHaveNoViolations()
|
|
44
41
|
})
|
|
42
|
+
|
|
43
|
+
test('renders with default props', () => {
|
|
44
|
+
const { container } = render(<PktModal>innhold</PktModal>)
|
|
45
|
+
const dialog = container.querySelector('dialog.pkt-modal')
|
|
46
|
+
expect(dialog).toBeInTheDocument()
|
|
47
|
+
expect(dialog).toHaveClass('pkt-modal--medium')
|
|
48
|
+
})
|
|
49
|
+
|
|
45
50
|
})
|