playbook_ui 16.1.0.pre.rc.1 → 16.1.0.pre.rc.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_advanced_table/advanced_table.html.erb +2 -2
  3. data/app/pb_kits/playbook/pb_advanced_table/advanced_table.rb +4 -0
  4. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_inline_row_loading.md +2 -2
  5. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_inline_row_loading_rails.html.erb +64 -0
  6. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_inline_row_loading_rails.md +18 -0
  7. data/app/pb_kits/playbook/pb_advanced_table/docs/example.yml +1 -0
  8. data/app/pb_kits/playbook/pb_advanced_table/table_body.rb +51 -1
  9. data/app/pb_kits/playbook/pb_advanced_table/table_header.html.erb +1 -1
  10. data/app/pb_kits/playbook/pb_advanced_table/table_header.rb +34 -0
  11. data/app/pb_kits/playbook/pb_advanced_table/table_row.html.erb +1 -1
  12. data/app/pb_kits/playbook/pb_advanced_table/table_row.rb +19 -0
  13. data/app/pb_kits/playbook/pb_table/docs/_sections.yml +68 -0
  14. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_input_options.html.erb +39 -0
  15. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_input_options.md +3 -0
  16. data/app/pb_kits/playbook/pb_textarea/docs/example.yml +1 -0
  17. data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +4 -10
  18. data/app/pb_kits/playbook/pb_textarea/textarea.rb +28 -0
  19. data/app/pb_kits/playbook/utilities/test/globalProps/borderRadius.test.js +33 -0
  20. data/app/pb_kits/playbook/utilities/test/globalProps/bottom.test.js +60 -0
  21. data/app/pb_kits/playbook/utilities/test/globalProps/cursor.test.js +42 -0
  22. data/app/pb_kits/playbook/utilities/test/globalProps/dark.test.js +33 -0
  23. data/app/pb_kits/playbook/utilities/test/globalProps/gap.test.js +87 -0
  24. data/app/pb_kits/playbook/utilities/test/globalProps/height.test.js +68 -0
  25. data/app/pb_kits/playbook/utilities/test/globalProps/htmlOptions.test.js +510 -0
  26. data/app/pb_kits/playbook/utilities/test/globalProps/left.test.js +60 -0
  27. data/app/pb_kits/playbook/utilities/test/globalProps/lineHeight.test.js +33 -0
  28. data/app/pb_kits/playbook/utilities/test/globalProps/margin.test.js +95 -0
  29. data/app/pb_kits/playbook/utilities/test/globalProps/numberSpacing.test.js +33 -0
  30. data/app/pb_kits/playbook/utilities/test/globalProps/overflow.test.js +68 -0
  31. data/app/pb_kits/playbook/utilities/test/globalProps/padding.test.js +95 -0
  32. data/app/pb_kits/playbook/utilities/test/globalProps/position.test.js +33 -0
  33. data/app/pb_kits/playbook/utilities/test/globalProps/right.test.js +60 -0
  34. data/app/pb_kits/playbook/utilities/test/globalProps/shadow.test.js +33 -0
  35. data/app/pb_kits/playbook/utilities/test/globalProps/textAlign.test.js +41 -0
  36. data/app/pb_kits/playbook/utilities/test/globalProps/top.test.js +60 -0
  37. data/app/pb_kits/playbook/utilities/test/globalProps/verticalAlign.test.js +40 -0
  38. data/app/pb_kits/playbook/utilities/test/globalProps/width.test.js +66 -0
  39. data/app/pb_kits/playbook/utilities/test/globalProps/zIndex.test.js +50 -0
  40. data/lib/playbook/version.rb +1 -1
  41. metadata +28 -2
@@ -0,0 +1,33 @@
1
+ import { testGlobalProp, testGlobalPropAbsence, testGlobalPropInvalidValues } from './globalPropsTestHelper'
2
+ import Body from '../../../pb_body/_body'
3
+ import Button from '../../../pb_button/_button'
4
+ import Card from '../../../pb_card/_card'
5
+ import Title from '../../../pb_title/_title'
6
+ import TextInput from '../../../pb_text_input/_text_input'
7
+ import Flex from '../../../pb_flex/_flex'
8
+ import Link from '../../../pb_link/_link'
9
+ import Badge from '../../../pb_badge/_badge'
10
+
11
+ testGlobalProp(
12
+ 'dark',
13
+ [true],
14
+ () => 'dark',
15
+ null,
16
+ [Body, Button, Card, Title, TextInput, Flex, Link, Badge]
17
+ )
18
+
19
+ testGlobalPropAbsence(
20
+ 'dark',
21
+ ['dark'],
22
+ undefined,
23
+ { skipNull: true }
24
+ )
25
+
26
+ // NOTE: Currently using skipKnownIssues: true because globalProps.ts generates classes for invalid values
27
+ testGlobalPropInvalidValues(
28
+ 'dark',
29
+ ['invalid', 'bad_value', 123, 'true', 'false'],
30
+ ['dark_invalid', 'dark_bad_value', 'dark_123', 'dark_true', 'dark_false'],
31
+ undefined,
32
+ { skipKnownIssues: true }
33
+ )
@@ -0,0 +1,87 @@
1
+ import { testGlobalProp, testGlobalPropResponsiveWithDefault, testGlobalPropAbsence, testGlobalPropInvalidValues } from './globalPropsTestHelper'
2
+ import Body from '../../../pb_body/_body'
3
+ import Button from '../../../pb_button/_button'
4
+ import Card from '../../../pb_card/_card'
5
+ import Title from '../../../pb_title/_title'
6
+ import Flex from '../../../pb_flex/_flex'
7
+ import Link from '../../../pb_link/_link'
8
+ import Badge from '../../../pb_badge/_badge'
9
+
10
+ // NOTE: TextInput excluded - gap properties are not valid props for input elements
11
+ // Test gap prop
12
+ testGlobalProp(
13
+ 'gap',
14
+ ['xs', 'sm', 'md', 'lg', 'xl'],
15
+ (v) => `gap_${v}`,
16
+ (size, v) => `gap_${size}_${v}`,
17
+ [Body, Button, Card, Title, Flex, Link, Badge]
18
+ )
19
+
20
+ testGlobalPropResponsiveWithDefault(
21
+ 'gap',
22
+ { default: 'md', xs: 'xs', sm: 'md', md: 'lg' },
23
+ (v) => `gap_${v}`,
24
+ (size, v) => `gap_${size}_${v}`
25
+ )
26
+
27
+ testGlobalPropAbsence(
28
+ 'gap',
29
+ ['gap_xs', 'gap_sm', 'gap_md', 'gap_lg', 'gap_xl'],
30
+ undefined,
31
+ { skipNull: true }
32
+ )
33
+
34
+ // Test columnGap prop
35
+ testGlobalProp(
36
+ 'columnGap',
37
+ ['xs', 'sm', 'md', 'lg', 'xl'],
38
+ (v) => `column_gap_${v}`,
39
+ (size, v) => `column_gap_${size}_${v}`,
40
+ [Body, Button, Card, Title, Flex, Link, Badge]
41
+ )
42
+
43
+ testGlobalPropResponsiveWithDefault(
44
+ 'columnGap',
45
+ { default: 'md', xs: 'xs', sm: 'md', md: 'lg' },
46
+ (v) => `column_gap_${v}`,
47
+ (size, v) => `column_gap_${size}_${v}`
48
+ )
49
+
50
+ testGlobalPropAbsence(
51
+ 'columnGap',
52
+ ['column_gap_xs', 'column_gap_sm', 'column_gap_md', 'column_gap_lg', 'column_gap_xl'],
53
+ undefined,
54
+ { skipNull: true }
55
+ )
56
+
57
+ // Test rowGap prop
58
+ testGlobalProp(
59
+ 'rowGap',
60
+ ['xs', 'sm', 'md', 'lg', 'xl'],
61
+ (v) => `row_gap_${v}`,
62
+ (size, v) => `row_gap_${size}_${v}`,
63
+ [Body, Button, Card, Title, Flex, Link, Badge]
64
+ )
65
+
66
+ testGlobalPropResponsiveWithDefault(
67
+ 'rowGap',
68
+ { default: 'md', xs: 'xs', sm: 'md', md: 'lg' },
69
+ (v) => `row_gap_${v}`,
70
+ (size, v) => `row_gap_${size}_${v}`
71
+ )
72
+
73
+ testGlobalPropAbsence(
74
+ 'rowGap',
75
+ ['row_gap_xs', 'row_gap_sm', 'row_gap_md', 'row_gap_lg', 'row_gap_xl'],
76
+ undefined,
77
+ { skipNull: true }
78
+ )
79
+
80
+ // NOTE: Currently using skipKnownIssues: true because globalProps.ts generates classes for invalid values
81
+ testGlobalPropInvalidValues(
82
+ 'gap',
83
+ ['invalid', 'bad_value', 'not_a_size', 'special-chars!@#'],
84
+ ['gap_invalid', 'gap_bad_value', 'gap_not_a_size', 'gap_special-chars!@#'],
85
+ undefined,
86
+ { skipKnownIssues: true }
87
+ )
@@ -0,0 +1,68 @@
1
+ import { testGlobalProp, testGlobalPropAbsence, testGlobalPropInvalidValues } from './globalPropsTestHelper'
2
+ import Body from '../../../pb_body/_body'
3
+ import Button from '../../../pb_button/_button'
4
+ import Card from '../../../pb_card/_card'
5
+ import Title from '../../../pb_title/_title'
6
+ import Flex from '../../../pb_flex/_flex'
7
+ import Link from '../../../pb_link/_link'
8
+ import Badge from '../../../pb_badge/_badge'
9
+
10
+ const validHeightValues = ['auto', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl']
11
+
12
+ // NOTE: TextInput excluded - height properties are not valid props for input elements
13
+ // Test height prop
14
+ testGlobalProp(
15
+ 'height',
16
+ validHeightValues,
17
+ (v) => `height_${v}`,
18
+ null,
19
+ [Body, Button, Card, Title, Flex, Link, Badge]
20
+ )
21
+
22
+ testGlobalPropAbsence(
23
+ 'height',
24
+ ['height_auto', 'height_xs', 'height_sm', 'height_md', 'height_lg', 'height_xl', 'height_xxl', 'height_xxxl'],
25
+ undefined,
26
+ { skipNull: true }
27
+ )
28
+
29
+ // Test minHeight prop
30
+ testGlobalProp(
31
+ 'minHeight',
32
+ validHeightValues,
33
+ (v) => `min_height_${v}`,
34
+ null,
35
+ [Body, Button, Card, Title, Flex, Link, Badge]
36
+ )
37
+
38
+ testGlobalPropAbsence(
39
+ 'minHeight',
40
+ ['min_height_auto', 'min_height_xs', 'min_height_sm', 'min_height_md', 'min_height_lg', 'min_height_xl', 'min_height_xxl', 'min_height_xxxl'],
41
+ undefined,
42
+ { skipNull: true }
43
+ )
44
+
45
+ // Test maxHeight prop
46
+ testGlobalProp(
47
+ 'maxHeight',
48
+ validHeightValues,
49
+ (v) => `max_height_${v}`,
50
+ null,
51
+ [Body, Button, Card, Title, Flex, Link, Badge]
52
+ )
53
+
54
+ testGlobalPropAbsence(
55
+ 'maxHeight',
56
+ ['max_height_auto', 'max_height_xs', 'max_height_sm', 'max_height_md', 'max_height_lg', 'max_height_xl', 'max_height_xxl', 'max_height_xxxl'],
57
+ undefined,
58
+ { skipNull: true }
59
+ )
60
+
61
+ // NOTE: Currently using skipKnownIssues: true because globalProps.ts generates classes for invalid values
62
+ testGlobalPropInvalidValues(
63
+ 'height',
64
+ ['invalid', 'bad_value', 'not_a_height', 'special-chars!@#', '100px', '50%'],
65
+ ['height_invalid', 'height_bad_value', 'height_not_a_height', 'height_special-chars!@#', 'height_100px', 'height_50_percent'],
66
+ undefined,
67
+ { skipKnownIssues: true }
68
+ )
@@ -0,0 +1,510 @@
1
+ import React from 'react'
2
+ import { render, screen } from '../../test-utils'
3
+ import Body from '../../../pb_body/_body'
4
+ import Button from '../../../pb_button/_button'
5
+ import Card from '../../../pb_card/_card'
6
+ import Title from '../../../pb_title/_title'
7
+ import Flex from '../../../pb_flex/_flex'
8
+ import Link from '../../../pb_link/_link'
9
+ import Badge from '../../../pb_badge/_badge'
10
+
11
+ // Test kits that support htmlOptions
12
+ const testKits = [Body, Button, Card, Title, Flex, Link, Badge]
13
+
14
+ describe('htmlOptions global prop', () => {
15
+ describe('applies HTML attributes to DOM elements', () => {
16
+ testKits.forEach((Kit) => {
17
+ const kitName = Kit.displayName || Kit.name || 'Kit'
18
+
19
+ test(`applies string attributes to ${kitName}`, () => {
20
+ const testId = `html-options-${kitName.toLowerCase()}-string`
21
+ render(
22
+ <Kit
23
+ data={{ testid: testId }}
24
+ htmlOptions={{
25
+ 'data-test-custom': 'custom-value',
26
+ 'data-foo': 'bar',
27
+ title: 'Tooltip text',
28
+ lang: 'en'
29
+ }}
30
+ text="Test"
31
+ />
32
+ )
33
+ const element = screen.getByTestId(testId)
34
+
35
+ expect(element).toHaveAttribute('data-test-custom', 'custom-value')
36
+ expect(element).toHaveAttribute('data-foo', 'bar')
37
+ expect(element).toHaveAttribute('title', 'Tooltip text')
38
+ expect(element).toHaveAttribute('lang', 'en')
39
+ })
40
+
41
+ test(`applies numeric attributes to ${kitName}`, () => {
42
+ const testId = `html-options-${kitName.toLowerCase()}-numeric`
43
+ // Note: Button, Link, and Badge have their own tabIndex prop that overrides htmlOptions
44
+ const htmlOpts = ['Button', 'Link', 'Badge'].includes(kitName)
45
+ ? {
46
+ 'data-number': 42,
47
+ 'data-zero': 0,
48
+ 'data-index': 5
49
+ }
50
+ : {
51
+ tabIndex: 0,
52
+ 'data-number': 42,
53
+ 'data-zero': 0
54
+ }
55
+
56
+ render(
57
+ <Kit
58
+ data={{ testid: testId }}
59
+ htmlOptions={htmlOpts}
60
+ text="Test"
61
+ />
62
+ )
63
+ const element = screen.getByTestId(testId)
64
+
65
+ if (!['Button', 'Link', 'Badge'].includes(kitName)) {
66
+ expect(element).toHaveAttribute('tabIndex', '0')
67
+ }
68
+ expect(element).toHaveAttribute('data-number', '42')
69
+ expect(element).toHaveAttribute('data-zero', '0')
70
+ if (['Button', 'Link', 'Badge'].includes(kitName)) {
71
+ expect(element).toHaveAttribute('data-index', '5')
72
+ }
73
+ })
74
+
75
+ test(`applies boolean attributes to ${kitName}`, () => {
76
+ const testId = `html-options-${kitName.toLowerCase()}-boolean`
77
+ render(
78
+ <Kit
79
+ data={{ testid: testId }}
80
+ htmlOptions={{
81
+ hidden: true,
82
+ contentEditable: false,
83
+ 'data-enabled': true
84
+ }}
85
+ text="Test"
86
+ />
87
+ )
88
+ const element = screen.getByTestId(testId)
89
+
90
+ expect(element).toHaveAttribute('hidden')
91
+ expect(element).toHaveAttribute('contentEditable', 'false')
92
+ expect(element).toHaveAttribute('data-enabled', 'true')
93
+ })
94
+ })
95
+ })
96
+
97
+ describe('handles empty and undefined htmlOptions', () => {
98
+ testKits.forEach((Kit) => {
99
+ const kitName = Kit.displayName || Kit.name || 'Kit'
100
+
101
+ test(`${kitName} handles empty htmlOptions object`, () => {
102
+ const testId = `html-options-${kitName.toLowerCase()}-empty`
103
+ render(
104
+ <Kit
105
+ data={{ testid: testId }}
106
+ htmlOptions={{}}
107
+ text="Test"
108
+ />
109
+ )
110
+ const element = screen.getByTestId(testId)
111
+
112
+ // Should render without errors
113
+ expect(element).toBeInTheDocument()
114
+ })
115
+
116
+ test(`${kitName} handles undefined htmlOptions`, () => {
117
+ const testId = `html-options-${kitName.toLowerCase()}-undefined`
118
+ render(
119
+ <Kit
120
+ data={{ testid: testId }}
121
+ text="Test"
122
+ />
123
+ )
124
+ const element = screen.getByTestId(testId)
125
+
126
+ // Should render without errors
127
+ expect(element).toBeInTheDocument()
128
+ })
129
+ })
130
+ })
131
+
132
+ describe('interacts with other props', () => {
133
+ test('htmlOptions overrides aria props when spread after', () => {
134
+ const testId = 'html-options-aria-conflict'
135
+ render(
136
+ <Body
137
+ aria={{ label: 'Aria label' }}
138
+ data={{ testid: testId }}
139
+ htmlOptions={{ 'aria-label': 'HTML options label' }}
140
+ text="Test"
141
+ />
142
+ )
143
+ const element = screen.getByTestId(testId)
144
+
145
+ // htmlOptions is spread after ariaProps, so it overrides aria prop values
146
+ // Kit spread order: {...ariaProps}, {...dataProps}, {...htmlProps}
147
+ expect(element).toHaveAttribute('aria-label', 'HTML options label')
148
+ })
149
+
150
+ test('htmlOptions overrides data props when spread after', () => {
151
+ const testId = 'html-options-data-conflict'
152
+ render(
153
+ <Body
154
+ data={{ testid: testId, custom: 'data-value' }}
155
+ htmlOptions={{ 'data-custom': 'html-options-value' }}
156
+ text="Test"
157
+ />
158
+ )
159
+ const element = screen.getByTestId(testId)
160
+
161
+ // htmlOptions is spread after dataProps, so it overrides data prop values
162
+ // Kit spread order: {...ariaProps}, {...dataProps}, {...htmlProps}
163
+ expect(element).toHaveAttribute('data-testid', testId)
164
+ expect(element).toHaveAttribute('data-custom', 'html-options-value')
165
+ })
166
+
167
+ test('className prop overrides htmlOptions className', () => {
168
+ const testId = 'html-options-classname-conflict'
169
+ render(
170
+ <Body
171
+ className="custom-class"
172
+ data={{ testid: testId }}
173
+ htmlOptions={{ className: 'html-options-class' }}
174
+ text="Test"
175
+ />
176
+ )
177
+ const element = screen.getByTestId(testId)
178
+
179
+ // className prop is merged into classes before htmlProps spread
180
+ // htmlOptions className would override, but className prop is set explicitly after htmlProps
181
+ // Kit sets: className={classes} after spreading htmlProps
182
+ expect(element).toHaveClass('custom-class')
183
+ // htmlOptions className is overridden by the explicit className prop
184
+ })
185
+
186
+ test('id prop overrides htmlOptions id when set explicitly', () => {
187
+ const testId = 'html-options-id-conflict'
188
+ render(
189
+ <Body
190
+ data={{ testid: testId }}
191
+ htmlOptions={{ id: 'html-options-id' }}
192
+ id="prop-id"
193
+ text="Test"
194
+ />
195
+ )
196
+ const element = screen.getByTestId(testId)
197
+
198
+ // id prop is set explicitly after htmlProps spread, so it overrides htmlOptions id
199
+ // Kit sets: id={id} after spreading htmlProps
200
+ expect(element).toHaveAttribute('id', 'prop-id')
201
+ })
202
+ })
203
+
204
+ describe('handles function values', () => {
205
+ test('htmlOptions can contain function values', () => {
206
+ const testId = 'html-options-function'
207
+ const onClickHandler = jest.fn()
208
+
209
+ render(
210
+ <Body
211
+ data={{ testid: testId }}
212
+ htmlOptions={{ onClick: onClickHandler }}
213
+ text="Test"
214
+ />
215
+ )
216
+ const element = screen.getByTestId(testId)
217
+
218
+ // Function should be attached to element
219
+ expect(element.onclick).toBeDefined()
220
+
221
+ // Can trigger the handler
222
+ element.click()
223
+ expect(onClickHandler).toHaveBeenCalledTimes(1)
224
+ })
225
+
226
+ test('htmlOptions can contain multiple event handlers', () => {
227
+ const testId = 'html-options-multiple-handlers'
228
+ const onClick = jest.fn()
229
+ const onDoubleClick = jest.fn()
230
+
231
+ render(
232
+ <Body
233
+ data={{ testid: testId }}
234
+ htmlOptions={{
235
+ onClick,
236
+ onDoubleClick
237
+ }}
238
+ text="Test"
239
+ />
240
+ )
241
+ const element = screen.getByTestId(testId)
242
+
243
+ // Verify handlers are attached by checking they can be called
244
+ element.click()
245
+ expect(onClick).toHaveBeenCalledTimes(1)
246
+
247
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
248
+ expect(onDoubleClick).toHaveBeenCalledTimes(1)
249
+ })
250
+ })
251
+
252
+ describe('handles special HTML attributes', () => {
253
+ test('applies data-* attributes correctly', () => {
254
+ const testId = 'html-options-data-attributes'
255
+ render(
256
+ <Body
257
+ data={{ testid: testId }}
258
+ htmlOptions={{
259
+ 'data-cy': 'test-element',
260
+ 'data-analytics': 'track-me',
261
+ 'data-value': '123'
262
+ }}
263
+ text="Test"
264
+ />
265
+ )
266
+ const element = screen.getByTestId(testId)
267
+
268
+ expect(element).toHaveAttribute('data-cy', 'test-element')
269
+ // Note: data prop's data-testid takes precedence over htmlOptions' data-testid
270
+ expect(element).toHaveAttribute('data-analytics', 'track-me')
271
+ expect(element).toHaveAttribute('data-value', '123')
272
+ })
273
+
274
+ test('applies aria-* attributes correctly', () => {
275
+ const testId = 'html-options-aria-attributes'
276
+ render(
277
+ <Body
278
+ data={{ testid: testId }}
279
+ htmlOptions={{
280
+ 'aria-describedby': 'description-id',
281
+ 'aria-labelledby': 'label-id',
282
+ 'aria-live': 'polite',
283
+ 'aria-atomic': 'true'
284
+ }}
285
+ text="Test"
286
+ />
287
+ )
288
+ const element = screen.getByTestId(testId)
289
+
290
+ expect(element).toHaveAttribute('aria-describedby', 'description-id')
291
+ expect(element).toHaveAttribute('aria-labelledby', 'label-id')
292
+ expect(element).toHaveAttribute('aria-live', 'polite')
293
+ expect(element).toHaveAttribute('aria-atomic', 'true')
294
+ })
295
+
296
+ test('applies standard HTML attributes correctly', () => {
297
+ const testId = 'html-options-standard-attributes'
298
+ render(
299
+ <Body
300
+ data={{ testid: testId }}
301
+ htmlOptions={{
302
+ role: 'button',
303
+ dir: 'rtl',
304
+ spellCheck: true,
305
+ translate: 'no',
306
+ draggable: false
307
+ }}
308
+ text="Test"
309
+ />
310
+ )
311
+ const element = screen.getByTestId(testId)
312
+
313
+ expect(element).toHaveAttribute('role', 'button')
314
+ expect(element).toHaveAttribute('dir', 'rtl')
315
+ expect(element).toHaveAttribute('spellCheck', 'true')
316
+ expect(element).toHaveAttribute('translate', 'no')
317
+ expect(element).toHaveAttribute('draggable', 'false')
318
+ })
319
+ })
320
+
321
+ describe('handles complex htmlOptions objects', () => {
322
+ test('applies multiple attributes of different types', () => {
323
+ const testId = 'html-options-complex'
324
+ const onClick = jest.fn()
325
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
326
+
327
+ render(
328
+ <Body
329
+ data={{ testid: testId }}
330
+ htmlOptions={{
331
+ // String attributes
332
+ 'data-test': 'value',
333
+ title: 'Tooltip',
334
+ // Numeric attributes (using data-* since tabIndex might be overridden)
335
+ 'data-count': 10,
336
+ 'data-index': 5,
337
+ // Boolean attributes (avoiding contentEditable to prevent React warning)
338
+ hidden: false,
339
+ // Function attributes
340
+ onClick,
341
+ // Aria attributes
342
+ 'aria-label': 'Complex element',
343
+ // Standard HTML attributes
344
+ role: 'region',
345
+ lang: 'fr'
346
+ }}
347
+ text="Test"
348
+ />
349
+ )
350
+ const element = screen.getByTestId(testId)
351
+
352
+ // Verify all attributes are applied
353
+ expect(element).toHaveAttribute('data-test', 'value')
354
+ expect(element).toHaveAttribute('title', 'Tooltip')
355
+ expect(element).toHaveAttribute('data-count', '10')
356
+ expect(element).toHaveAttribute('data-index', '5')
357
+ expect(element).toHaveAttribute('aria-label', 'Complex element')
358
+ expect(element).toHaveAttribute('role', 'region')
359
+ expect(element).toHaveAttribute('lang', 'fr')
360
+
361
+ // Verify function is attached
362
+ element.click()
363
+ expect(onClick).toHaveBeenCalledTimes(1)
364
+
365
+ consoleSpy.mockRestore()
366
+ })
367
+ })
368
+
369
+ describe('works with different kit types', () => {
370
+ test('works with Button kit', () => {
371
+ const testId = 'html-options-button'
372
+ render(
373
+ <Button
374
+ data={{ testid: testId }}
375
+ htmlOptions={{
376
+ 'data-button-custom': 'button-value',
377
+ type: 'button'
378
+ }}
379
+ text="Click me"
380
+ />
381
+ )
382
+ const element = screen.getByTestId(testId)
383
+
384
+ expect(element).toHaveAttribute('data-button-custom', 'button-value')
385
+ expect(element).toHaveAttribute('type', 'button')
386
+ })
387
+
388
+ test('works with Link kit', () => {
389
+ const testId = 'html-options-link'
390
+ render(
391
+ <Link
392
+ data={{ testid: testId }}
393
+ href="#"
394
+ htmlOptions={{
395
+ 'data-link-custom': 'link-value',
396
+ 'data-extra': 'extra-value'
397
+ }}
398
+ target="_blank"
399
+ text="Link text"
400
+ />
401
+ )
402
+ const element = screen.getByTestId(testId)
403
+
404
+ expect(element).toHaveAttribute('data-link-custom', 'link-value')
405
+ expect(element).toHaveAttribute('data-extra', 'extra-value')
406
+ // Note: target and rel are set by Link's own props, not htmlOptions
407
+ expect(element).toHaveAttribute('target', '_blank')
408
+ })
409
+
410
+ test('works with Card kit', () => {
411
+ const testId = 'html-options-card'
412
+ render(
413
+ <Card
414
+ data={{ testid: testId }}
415
+ htmlOptions={{
416
+ 'data-card-custom': 'card-value',
417
+ role: 'article'
418
+ }}
419
+ >
420
+ {"Card content"}
421
+ </Card>
422
+ )
423
+ const element = screen.getByTestId(testId)
424
+
425
+ expect(element).toHaveAttribute('data-card-custom', 'card-value')
426
+ expect(element).toHaveAttribute('role', 'article')
427
+ })
428
+
429
+ test('works with Flex kit', () => {
430
+ const testId = 'html-options-flex'
431
+ render(
432
+ <Flex
433
+ data={{ testid: testId }}
434
+ htmlOptions={{
435
+ 'data-flex-custom': 'flex-value',
436
+ 'data-layout': 'container'
437
+ }}
438
+ >
439
+ {"Flex content"}
440
+ </Flex>
441
+ )
442
+ const element = screen.getByTestId(testId)
443
+
444
+ expect(element).toHaveAttribute('data-flex-custom', 'flex-value')
445
+ expect(element).toHaveAttribute('data-layout', 'container')
446
+ })
447
+ })
448
+
449
+ describe('edge cases', () => {
450
+ test('handles null values in htmlOptions', () => {
451
+ const testId = 'html-options-null-values'
452
+ render(
453
+ <Body
454
+ data={{ testid: testId }}
455
+ htmlOptions={{
456
+ 'data-valid': 'value',
457
+ 'data-null': null,
458
+ 'data-undefined': undefined
459
+ }}
460
+ text="Test"
461
+ />
462
+ )
463
+ const element = screen.getByTestId(testId)
464
+
465
+ // Valid attribute should be present
466
+ expect(element).toHaveAttribute('data-valid', 'value')
467
+ // Null/undefined values might be converted to strings or omitted
468
+ // This tests the actual behavior
469
+ expect(element).toBeInTheDocument()
470
+ })
471
+
472
+ test('handles very long attribute values', () => {
473
+ const testId = 'html-options-long-values'
474
+ const longValue = 'a'.repeat(1000)
475
+
476
+ render(
477
+ <Body
478
+ data={{ testid: testId }}
479
+ htmlOptions={{
480
+ 'data-long': longValue,
481
+ title: longValue
482
+ }}
483
+ text="Test"
484
+ />
485
+ )
486
+ const element = screen.getByTestId(testId)
487
+
488
+ expect(element).toHaveAttribute('data-long', longValue)
489
+ expect(element).toHaveAttribute('title', longValue)
490
+ })
491
+
492
+ test('handles special characters in attribute values', () => {
493
+ const testId = 'html-options-special-chars'
494
+ render(
495
+ <Body
496
+ data={{ testid: testId }}
497
+ htmlOptions={{
498
+ 'data-special': 'value with spaces & symbols!@#$%',
499
+ title: 'Tooltip with "quotes" and \'apostrophes\''
500
+ }}
501
+ text="Test"
502
+ />
503
+ )
504
+ const element = screen.getByTestId(testId)
505
+
506
+ expect(element).toHaveAttribute('data-special', 'value with spaces & symbols!@#$%')
507
+ expect(element).toHaveAttribute('title', 'Tooltip with "quotes" and \'apostrophes\'')
508
+ })
509
+ })
510
+ })