@invisibleloop/pulse 0.20.38 → 0.20.40
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/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/agent/workflow.md +15 -0
- package/src/cli/feedback.js +187 -0
- package/src/cli/scaffold.js +10 -1
- package/src/mcp/server.js +48 -0
- package/scripts/css-purge.test.js +0 -147
- package/scripts/strip-server.test.js +0 -491
- package/src/cli/check-bundles.test.js +0 -137
- package/src/cli/cli.test.js +0 -341
- package/src/cli/diagnose.test.js +0 -124
- package/src/cli/process-utils.test.js +0 -114
- package/src/cli/review.test.js +0 -143
- package/src/cli/stamp.test.js +0 -149
- package/src/cli/validate.test.js +0 -153
- package/src/mcp/guards.test.js +0 -90
- package/src/mcp/mcp.test.js +0 -617
- package/src/md/md.test.js +0 -217
- package/src/runtime/image.test.js +0 -111
- package/src/runtime/morph.test.js +0 -317
- package/src/runtime/navigate.test.js +0 -304
- package/src/runtime/runtime.test.js +0 -1069
- package/src/runtime/ssr.test.js +0 -750
- package/src/server/server.test.js +0 -2474
- package/src/spec/schema.test.js +0 -467
- package/src/store/store.test.js +0 -210
- package/src/testing/testing.test.js +0 -508
- package/src/ui/ui.test.js +0 -1157
package/src/ui/ui.test.js
DELETED
|
@@ -1,1157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pulse UI — Component tests
|
|
3
|
-
*
|
|
4
|
-
* Tests cover: default rendering, all variants, HTML escaping (XSS),
|
|
5
|
-
* accessibility attributes, and edge cases.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { test } from 'node:test'
|
|
9
|
-
import assert from 'node:assert/strict'
|
|
10
|
-
import { readFileSync } from 'node:fs'
|
|
11
|
-
import { button } from './button.js'
|
|
12
|
-
import { badge } from './badge.js'
|
|
13
|
-
import { card } from './card.js'
|
|
14
|
-
import { input } from './input.js'
|
|
15
|
-
import { select } from './select.js'
|
|
16
|
-
import { textarea } from './textarea.js'
|
|
17
|
-
import { alert } from './alert.js'
|
|
18
|
-
import { stat } from './stat.js'
|
|
19
|
-
import { avatar } from './avatar.js'
|
|
20
|
-
import { empty } from './empty.js'
|
|
21
|
-
import { table } from './table.js'
|
|
22
|
-
import { hero } from './hero.js'
|
|
23
|
-
import { feature } from './feature.js'
|
|
24
|
-
import { accordion } from './accordion.js'
|
|
25
|
-
import { nav } from './nav.js'
|
|
26
|
-
import { appBadge } from './app-badge.js'
|
|
27
|
-
import { container } from './container.js'
|
|
28
|
-
import { section } from './section.js'
|
|
29
|
-
import { grid } from './grid.js'
|
|
30
|
-
import { stack } from './stack.js'
|
|
31
|
-
import { cluster } from './cluster.js'
|
|
32
|
-
import { divider } from './divider.js'
|
|
33
|
-
import { media } from './media.js'
|
|
34
|
-
import { slider } from './slider.js'
|
|
35
|
-
import { toggle } from './switch.js'
|
|
36
|
-
import { radio, radioGroup } from './radio.js'
|
|
37
|
-
import { fileUpload } from './fileupload.js'
|
|
38
|
-
import { modal, modalTrigger } from './modal.js'
|
|
39
|
-
import { uiImage } from './uiimage.js'
|
|
40
|
-
|
|
41
|
-
// ─── button ─────────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
test('button: renders <button> by default', () => {
|
|
44
|
-
const html = button({ label: 'Save' })
|
|
45
|
-
assert.match(html, /<button/)
|
|
46
|
-
assert.match(html, /Save/)
|
|
47
|
-
assert.match(html, /type="button"/)
|
|
48
|
-
assert.match(html, /ui-btn--primary/)
|
|
49
|
-
assert.match(html, /ui-btn--md/)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('button: renders <a> when href is provided', () => {
|
|
53
|
-
const html = button({ label: 'Go', href: '/dashboard' })
|
|
54
|
-
assert.match(html, /<a /)
|
|
55
|
-
assert.match(html, /href="\/dashboard"/)
|
|
56
|
-
assert.doesNotMatch(html, /<button/)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('button: applies variant classes', () => {
|
|
60
|
-
for (const v of ['primary', 'secondary', 'ghost', 'danger']) {
|
|
61
|
-
assert.match(button({ label: 'x', variant: v }), new RegExp(`ui-btn--${v}`))
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('button: applies size classes', () => {
|
|
66
|
-
for (const s of ['sm', 'md', 'lg']) {
|
|
67
|
-
assert.match(button({ label: 'x', size: s }), new RegExp(`ui-btn--${s}`))
|
|
68
|
-
}
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('button: unknown variant falls back to primary', () => {
|
|
72
|
-
assert.match(button({ label: 'x', variant: 'rainbow' }), /ui-btn--primary/)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test('button: disabled <button> has disabled attribute and aria-disabled', () => {
|
|
76
|
-
const html = button({ label: 'x', disabled: true })
|
|
77
|
-
assert.match(html, /disabled/)
|
|
78
|
-
assert.match(html, /aria-disabled="true"/)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('button: disabled <a> has aria-disabled and tabindex=-1, no href behavior issues', () => {
|
|
82
|
-
const html = button({ label: 'x', href: '/x', disabled: true })
|
|
83
|
-
assert.match(html, /aria-disabled="true"/)
|
|
84
|
-
assert.match(html, /tabindex="-1"/)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('button: fullWidth adds modifier class', () => {
|
|
88
|
-
assert.match(button({ label: 'x', fullWidth: true }), /ui-btn--full/)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('button: escapes label to prevent XSS', () => {
|
|
92
|
-
const html = button({ label: '<script>alert(1)</script>' })
|
|
93
|
-
assert.doesNotMatch(html, /<script>/)
|
|
94
|
-
assert.match(html, /<script>/)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
test('button: escapes href to prevent XSS', () => {
|
|
98
|
-
const html = button({ label: 'x', href: '"><script>alert(1)</script>' })
|
|
99
|
-
assert.doesNotMatch(html, /<script>/)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
test('button: submit type', () => {
|
|
103
|
-
assert.match(button({ label: 'x', type: 'submit' }), /type="submit"/)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
test('button: renders icon HTML inside element', () => {
|
|
107
|
-
const html = button({ label: 'x', icon: '<svg/>' })
|
|
108
|
-
assert.match(html, /ui-btn-icon/)
|
|
109
|
-
assert.match(html, /<svg\/>/)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('button: attrs with undefined values are omitted', () => {
|
|
113
|
-
const html = button({ label: 'Minus', attrs: { disabled: undefined, 'data-count': '3' } })
|
|
114
|
-
assert.doesNotMatch(html, /disabled="undefined"/)
|
|
115
|
-
assert.match(html, /data-count="3"/)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('button: attrs with defined values are rendered', () => {
|
|
119
|
-
const html = button({ label: 'Add', attrs: { 'data-action': 'add' } })
|
|
120
|
-
assert.match(html, /data-action="add"/)
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
// ─── badge ──────────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
test('badge: renders with default variant', () => {
|
|
126
|
-
const html = badge({ label: 'New' })
|
|
127
|
-
assert.match(html, /<span/)
|
|
128
|
-
assert.match(html, /ui-badge--default/)
|
|
129
|
-
assert.match(html, /New/)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('badge: applies all variants', () => {
|
|
133
|
-
for (const v of ['default', 'success', 'warning', 'error', 'info']) {
|
|
134
|
-
assert.match(badge({ label: 'x', variant: v }), new RegExp(`ui-badge--${v}`))
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
test('badge: unknown variant falls back to default', () => {
|
|
139
|
-
assert.match(badge({ label: 'x', variant: 'neon' }), /ui-badge--default/)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
test('badge: escapes label', () => {
|
|
143
|
-
const html = badge({ label: '<b>bold</b>' })
|
|
144
|
-
assert.doesNotMatch(html, /<b>/)
|
|
145
|
-
assert.match(html, /<b>/)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// ─── card ───────────────────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
test('card: renders card wrapper', () => {
|
|
151
|
-
const html = card({ content: '<p>Hello</p>' })
|
|
152
|
-
assert.match(html, /ui-card/)
|
|
153
|
-
assert.match(html, /ui-card-body/)
|
|
154
|
-
assert.match(html, /<p>Hello<\/p>/)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('card: renders title when provided', () => {
|
|
158
|
-
const html = card({ title: 'My Card', content: '' })
|
|
159
|
-
assert.match(html, /ui-card-title/)
|
|
160
|
-
assert.match(html, /My Card/)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
test('card: renders footer when provided', () => {
|
|
164
|
-
const html = card({ content: '', footer: '<button>OK</button>' })
|
|
165
|
-
assert.match(html, /ui-card-footer/)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('card: no title/footer when not provided', () => {
|
|
169
|
-
const html = card({ content: 'x' })
|
|
170
|
-
assert.doesNotMatch(html, /ui-card-header/)
|
|
171
|
-
assert.doesNotMatch(html, /ui-card-footer/)
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
test('card: flush modifier', () => {
|
|
175
|
-
assert.match(card({ content: '', flush: true }), /ui-card--flush/)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
test('card: escapes title to prevent XSS', () => {
|
|
179
|
-
const html = card({ title: '<script>', content: '' })
|
|
180
|
-
assert.doesNotMatch(html, /<script>/)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// ─── input ──────────────────────────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
test('input: renders label and input linked by for/id', () => {
|
|
186
|
-
const html = input({ name: 'email', label: 'Email' })
|
|
187
|
-
assert.match(html, /for="field-email"/)
|
|
188
|
-
assert.match(html, /id="field-email"/)
|
|
189
|
-
assert.match(html, /Email/)
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
test('input: renders error with role=alert and aria-invalid', () => {
|
|
193
|
-
const html = input({ name: 'email', label: 'Email', error: 'Required' })
|
|
194
|
-
assert.match(html, /role="alert"/)
|
|
195
|
-
assert.match(html, /aria-invalid="true"/)
|
|
196
|
-
assert.match(html, /Required/)
|
|
197
|
-
assert.match(html, /aria-describedby/)
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
test('input: renders hint with aria-describedby', () => {
|
|
201
|
-
const html = input({ name: 'email', label: 'Email', hint: 'We will never spam you' })
|
|
202
|
-
assert.match(html, /ui-hint/)
|
|
203
|
-
assert.match(html, /aria-describedby/)
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
test('input: required adds required attribute and aria-required', () => {
|
|
207
|
-
const html = input({ name: 'x', label: 'X', required: true })
|
|
208
|
-
assert.match(html, /required/)
|
|
209
|
-
assert.match(html, /aria-required="true"/)
|
|
210
|
-
assert.match(html, /ui-required/)
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('input: disabled attribute', () => {
|
|
214
|
-
const html = input({ name: 'x', label: 'X', disabled: true })
|
|
215
|
-
assert.match(html, /disabled/)
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
test('input: pre-filled value', () => {
|
|
219
|
-
const html = input({ name: 'x', label: 'X', value: 'hello' })
|
|
220
|
-
assert.match(html, /value="hello"/)
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
test('input: escapes value to prevent XSS', () => {
|
|
224
|
-
const html = input({ name: 'x', label: 'X', value: '"><script>alert(1)</script>' })
|
|
225
|
-
assert.doesNotMatch(html, /<script>/)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
test('input: escapes error message', () => {
|
|
229
|
-
const html = input({ name: 'x', label: 'X', error: '<b>bad</b>' })
|
|
230
|
-
assert.doesNotMatch(html, /<b>/)
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
test('input: custom id overrides generated id', () => {
|
|
234
|
-
const html = input({ name: 'x', label: 'X', id: 'custom-id' })
|
|
235
|
-
assert.match(html, /id="custom-id"/)
|
|
236
|
-
assert.match(html, /for="custom-id"/)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
test('input: error field adds ui-field--error wrapper class', () => {
|
|
240
|
-
const html = input({ name: 'x', label: 'X', error: 'oops' })
|
|
241
|
-
assert.match(html, /ui-field--error/)
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
// ─── select ─────────────────────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
test('select: renders options from strings', () => {
|
|
247
|
-
const html = select({ name: 's', label: 'Pick', options: ['One', 'Two'] })
|
|
248
|
-
assert.match(html, /value="One"/)
|
|
249
|
-
assert.match(html, /value="Two"/)
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
test('select: renders options from objects', () => {
|
|
253
|
-
const html = select({ name: 's', label: 'Pick', options: [{ value: 'a', label: 'Alpha' }] })
|
|
254
|
-
assert.match(html, /value="a"/)
|
|
255
|
-
assert.match(html, /Alpha/)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
test('select: marks selected option', () => {
|
|
259
|
-
const html = select({ name: 's', label: 'Pick', options: ['a', 'b'], value: 'b' })
|
|
260
|
-
assert.match(html, /value="b" selected/)
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
test('select: renders chevron', () => {
|
|
264
|
-
assert.match(select({ name: 's', label: 'S', options: [] }), /ui-select-chevron/)
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
test('select: error triggers aria-invalid and role=alert', () => {
|
|
268
|
-
const html = select({ name: 's', label: 'S', options: [], error: 'Required' })
|
|
269
|
-
assert.match(html, /aria-invalid="true"/)
|
|
270
|
-
assert.match(html, /role="alert"/)
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
test('select: escapes option values and labels', () => {
|
|
274
|
-
const html = select({ name: 's', label: 'S', options: [{ value: '"><script>', label: '<b>' }] })
|
|
275
|
-
assert.doesNotMatch(html, /<script>/)
|
|
276
|
-
assert.doesNotMatch(html, /<b>/)
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
// ─── textarea ───────────────────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
test('textarea: renders with label', () => {
|
|
282
|
-
const html = textarea({ name: 'msg', label: 'Message' })
|
|
283
|
-
assert.match(html, /for="field-msg"/)
|
|
284
|
-
assert.match(html, /<textarea/)
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
test('textarea: value is escaped and placed inside element', () => {
|
|
288
|
-
const html = textarea({ name: 'msg', label: 'M', value: '<script>evil</script>' })
|
|
289
|
-
assert.doesNotMatch(html, /<script>evil/)
|
|
290
|
-
assert.match(html, /<script>/)
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
test('textarea: rows attribute', () => {
|
|
294
|
-
const html = textarea({ name: 'x', label: 'X', rows: 8 })
|
|
295
|
-
assert.match(html, /rows="8"/)
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
test('textarea: error renders role=alert', () => {
|
|
299
|
-
const html = textarea({ name: 'x', label: 'X', error: 'Too long' })
|
|
300
|
-
assert.match(html, /role="alert"/)
|
|
301
|
-
assert.match(html, /Too long/)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// ─── alert ──────────────────────────────────────────────────────────────────
|
|
305
|
-
|
|
306
|
-
test('alert: info uses role=status', () => {
|
|
307
|
-
assert.match(alert({ variant: 'info', content: 'x' }), /role="status"/)
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
test('alert: success uses role=status', () => {
|
|
311
|
-
assert.match(alert({ variant: 'success', content: 'x' }), /role="status"/)
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
test('alert: warning uses role=alert', () => {
|
|
315
|
-
assert.match(alert({ variant: 'warning', content: 'x' }), /role="alert"/)
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
test('alert: error uses role=alert', () => {
|
|
319
|
-
assert.match(alert({ variant: 'error', content: 'x' }), /role="alert"/)
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
test('alert: renders title when provided', () => {
|
|
323
|
-
const html = alert({ variant: 'info', title: 'Note', content: 'Something' })
|
|
324
|
-
assert.match(html, /ui-alert-title/)
|
|
325
|
-
assert.match(html, /Note/)
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
test('alert: renders icon', () => {
|
|
329
|
-
assert.match(alert({ variant: 'info', content: 'x' }), /ui-alert-icon/)
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
test('alert: unknown variant falls back to info', () => {
|
|
333
|
-
assert.match(alert({ variant: 'banana', content: 'x' }), /ui-alert--info/)
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
test('alert: escapes title', () => {
|
|
337
|
-
const html = alert({ variant: 'info', title: '<script>', content: '' })
|
|
338
|
-
assert.doesNotMatch(html, /<script>/)
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
// ─── stat ────────────────────────────────────────────────────────────────────
|
|
342
|
-
|
|
343
|
-
test('stat: renders label and value', () => {
|
|
344
|
-
const html = stat({ label: 'Users', value: '1,204' })
|
|
345
|
-
assert.match(html, /ui-stat-label/)
|
|
346
|
-
assert.match(html, /ui-stat-value/)
|
|
347
|
-
assert.match(html, /Users/)
|
|
348
|
-
assert.match(html, /1,204/)
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
test('stat: renders change with trend class', () => {
|
|
352
|
-
const html = stat({ label: 'X', value: '10', change: '+5%', trend: 'up' })
|
|
353
|
-
assert.match(html, /ui-stat-change--up/)
|
|
354
|
-
assert.match(html, /\+5%/)
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
test('stat: trend icon span has role=img for valid aria-label', () => {
|
|
358
|
-
const html = stat({ label: 'X', value: '10', change: '+5%', trend: 'up' })
|
|
359
|
-
assert.match(html, /role="img"/)
|
|
360
|
-
assert.match(html, /aria-label="increase"/)
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
test('stat: no change rendered when change is empty', () => {
|
|
364
|
-
const html = stat({ label: 'X', value: '10' })
|
|
365
|
-
assert.doesNotMatch(html, /ui-stat-change/)
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
test('stat: escapes label and value', () => {
|
|
369
|
-
const html = stat({ label: '<b>L</b>', value: '<i>V</i>' })
|
|
370
|
-
assert.doesNotMatch(html, /<b>/)
|
|
371
|
-
assert.doesNotMatch(html, /<i>/)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
test('stat: unknown trend falls back to neutral', () => {
|
|
375
|
-
assert.match(stat({ label: 'X', value: '1', change: '+1', trend: 'sideways' }), /ui-stat-change--neutral/)
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
test('stat: center prop adds ui-stat--center class', () => {
|
|
379
|
-
assert.match(stat({ label: 'X', value: '1', center: true }), /ui-stat--center/)
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
test('stat: center is absent by default', () => {
|
|
383
|
-
assert.doesNotMatch(stat({ label: 'X', value: '1' }), /ui-stat--center/)
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
// ─── avatar ──────────────────────────────────────────────────────────────────
|
|
387
|
-
|
|
388
|
-
test('avatar: renders <img> when src provided', () => {
|
|
389
|
-
const html = avatar({ src: '/photo.jpg', alt: 'Alice' })
|
|
390
|
-
assert.match(html, /<img /)
|
|
391
|
-
assert.match(html, /src="\/photo.jpg"/)
|
|
392
|
-
assert.match(html, /alt="Alice"/)
|
|
393
|
-
assert.match(html, /loading="lazy"/)
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
test('avatar: renders <span> with initials when no src', () => {
|
|
397
|
-
const html = avatar({ alt: 'Alice Smith' })
|
|
398
|
-
assert.match(html, /<span/)
|
|
399
|
-
assert.match(html, /AS/)
|
|
400
|
-
assert.match(html, /role="img"/)
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
test('avatar: uses explicit initials over derived', () => {
|
|
404
|
-
const html = avatar({ alt: 'Alice Smith', initials: 'A' })
|
|
405
|
-
assert.match(html, />A</)
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
test('avatar: applies size class', () => {
|
|
409
|
-
assert.match(avatar({ alt: 'x', size: 'lg' }), /ui-avatar--lg/)
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
test('avatar: unknown size falls back to md', () => {
|
|
413
|
-
assert.match(avatar({ alt: 'x', size: 'giant' }), /ui-avatar--md/)
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
test('avatar: escapes src to prevent XSS', () => {
|
|
417
|
-
const html = avatar({ src: '"><script>alert(1)</script>', alt: 'x' })
|
|
418
|
-
assert.doesNotMatch(html, /<script>/)
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
// ─── empty ───────────────────────────────────────────────────────────────────
|
|
422
|
-
|
|
423
|
-
test('empty: renders title', () => {
|
|
424
|
-
const html = empty({ title: 'No results' })
|
|
425
|
-
assert.match(html, /No results/)
|
|
426
|
-
assert.match(html, /ui-empty-title/)
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
test('empty: renders description when provided', () => {
|
|
430
|
-
const html = empty({ title: 'x', description: 'Try a different search' })
|
|
431
|
-
assert.match(html, /ui-empty-desc/)
|
|
432
|
-
assert.match(html, /Try a different search/)
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
test('empty: renders action button when provided', () => {
|
|
436
|
-
const html = empty({ title: 'x', action: { label: 'Add item', href: '/new' } })
|
|
437
|
-
assert.match(html, /Add item/)
|
|
438
|
-
assert.match(html, /href="\/new"/)
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
test('empty: no action rendered when not provided', () => {
|
|
442
|
-
const html = empty({ title: 'x' })
|
|
443
|
-
assert.doesNotMatch(html, /ui-btn/)
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
test('empty: escapes title and description', () => {
|
|
447
|
-
const html = empty({ title: '<script>', description: '<b>bad</b>' })
|
|
448
|
-
assert.doesNotMatch(html, /<script>/)
|
|
449
|
-
assert.doesNotMatch(html, /<b>/)
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
// ─── table ───────────────────────────────────────────────────────────────────
|
|
453
|
-
|
|
454
|
-
test('table: renders headers with scope=col', () => {
|
|
455
|
-
const html = table({ headers: ['Name', 'Role'], rows: [] })
|
|
456
|
-
assert.match(html, /scope="col"/)
|
|
457
|
-
assert.match(html, /Name/)
|
|
458
|
-
assert.match(html, /Role/)
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
test('table: renders rows', () => {
|
|
462
|
-
const html = table({ headers: ['A'], rows: [['Cell 1'], ['Cell 2']] })
|
|
463
|
-
assert.match(html, /Cell 1/)
|
|
464
|
-
assert.match(html, /Cell 2/)
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
test('table: renders caption', () => {
|
|
468
|
-
const html = table({ headers: [], rows: [], caption: 'User list' })
|
|
469
|
-
assert.match(html, /ui-table-caption/)
|
|
470
|
-
assert.match(html, /User list/)
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
test('table: scroll wrapper has role=region and tabindex', () => {
|
|
474
|
-
const html = table({ headers: [], rows: [] })
|
|
475
|
-
assert.match(html, /role="region"/)
|
|
476
|
-
assert.match(html, /tabindex="0"/)
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
test('table: escapes headers', () => {
|
|
480
|
-
const html = table({ headers: ['<script>'], rows: [] })
|
|
481
|
-
assert.doesNotMatch(html, /<script>/)
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
test('table: empty rows renders empty tbody', () => {
|
|
485
|
-
const html = table({ headers: ['A', 'B'], rows: [] })
|
|
486
|
-
assert.match(html, /<tbody><\/tbody>/)
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
// ─── hero ────────────────────────────────────────────────────────────────────
|
|
490
|
-
|
|
491
|
-
test('hero: renders title, eyebrow, subtitle', () => {
|
|
492
|
-
const html = hero({ eyebrow: 'New', title: 'Hello', subtitle: 'World' })
|
|
493
|
-
assert.match(html, /ui-hero-eyebrow/)
|
|
494
|
-
assert.match(html, /ui-hero-title/)
|
|
495
|
-
assert.match(html, /ui-hero-subtitle/)
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
test('hero: left align adds ui-hero--left', () => {
|
|
499
|
-
assert.match(hero({ title: 'x', align: 'left' }), /ui-hero--left/)
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
test('hero: center align (default) has no modifier class', () => {
|
|
503
|
-
assert.doesNotMatch(hero({ title: 'x' }), /ui-hero--left/)
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
test('hero: actions slot passes through raw HTML', () => {
|
|
507
|
-
const html = hero({ title: 'x', actions: '<a href="/dl">Download</a>' })
|
|
508
|
-
assert.match(html, /href="\/dl"/)
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
test('hero: escapes title and eyebrow', () => {
|
|
512
|
-
const html = hero({ title: '<script>', eyebrow: '<b>bad</b>' })
|
|
513
|
-
assert.doesNotMatch(html, /<script>/)
|
|
514
|
-
assert.doesNotMatch(html, /<b>bad<\/b>/)
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
// ─── feature ─────────────────────────────────────────────────────────────────
|
|
518
|
-
|
|
519
|
-
test('feature: renders title and description', () => {
|
|
520
|
-
const html = feature({ title: 'Fast', description: 'Under 100ms' })
|
|
521
|
-
assert.match(html, /ui-feature-title/)
|
|
522
|
-
assert.match(html, /ui-feature-desc/)
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
test('feature: icon slot passes through raw HTML', () => {
|
|
526
|
-
const html = feature({ icon: '<svg><circle/></svg>', title: 'x' })
|
|
527
|
-
assert.match(html, /<svg>/)
|
|
528
|
-
assert.match(html, /ui-feature-icon/)
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
test('feature: escapes title and description', () => {
|
|
532
|
-
const html = feature({ title: '<script>', description: '<b>bad</b>' })
|
|
533
|
-
assert.doesNotMatch(html, /<script>/)
|
|
534
|
-
assert.doesNotMatch(html, /<b>bad<\/b>/)
|
|
535
|
-
})
|
|
536
|
-
|
|
537
|
-
// ─── accordion ───────────────────────────────────────────────────────────────
|
|
538
|
-
|
|
539
|
-
test('accordion: renders details/summary', () => {
|
|
540
|
-
const html = accordion({ items: [{ question: 'What?', answer: 'This.' }] })
|
|
541
|
-
assert.match(html, /<details/)
|
|
542
|
-
assert.match(html, /<summary/)
|
|
543
|
-
assert.match(html, /What\?/)
|
|
544
|
-
assert.match(html, /This\./)
|
|
545
|
-
})
|
|
546
|
-
|
|
547
|
-
test('accordion: renders multiple items', () => {
|
|
548
|
-
const html = accordion({ items: [{ question: 'A', answer: '1' }, { question: 'B', answer: '2' }] })
|
|
549
|
-
assert.match(html, /ui-accordion-item/)
|
|
550
|
-
assert.match(html, />A</)
|
|
551
|
-
assert.match(html, />B</)
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
test('accordion: empty items renders empty container', () => {
|
|
555
|
-
const html = accordion({ items: [] })
|
|
556
|
-
assert.match(html, /ui-accordion/)
|
|
557
|
-
})
|
|
558
|
-
|
|
559
|
-
test('accordion: escapes question and answer', () => {
|
|
560
|
-
const html = accordion({ items: [{ question: '<script>', answer: '<b>x</b>' }] })
|
|
561
|
-
assert.doesNotMatch(html, /<script>/)
|
|
562
|
-
assert.doesNotMatch(html, /<b>x<\/b>/)
|
|
563
|
-
})
|
|
564
|
-
|
|
565
|
-
// ─── nav ─────────────────────────────────────────────────────────────────────
|
|
566
|
-
|
|
567
|
-
test('nav: renders logo and links', () => {
|
|
568
|
-
const html = nav({ logo: 'MyApp', links: [{ label: 'Features', href: '#features' }] })
|
|
569
|
-
assert.match(html, /MyApp/)
|
|
570
|
-
assert.match(html, /Features/)
|
|
571
|
-
assert.match(html, /href="#features"/)
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
test('nav: sticky adds modifier class', () => {
|
|
575
|
-
assert.match(nav({ logo: 'x', sticky: true }), /ui-nav--sticky/)
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
test('nav: no sticky class by default', () => {
|
|
579
|
-
assert.doesNotMatch(nav({ logo: 'x' }), /ui-nav--sticky/)
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
test('nav: action slot passes through raw HTML', () => {
|
|
583
|
-
const html = nav({ logo: 'x', action: '<button>Sign up</button>' })
|
|
584
|
-
assert.match(html, /Sign up/)
|
|
585
|
-
})
|
|
586
|
-
|
|
587
|
-
test('nav: escapes link labels and hrefs', () => {
|
|
588
|
-
const html = nav({ logo: 'x', links: [{ label: '<b>X</b>', href: 'javascript:void(0)' }] })
|
|
589
|
-
assert.doesNotMatch(html, /<b>X<\/b>/)
|
|
590
|
-
})
|
|
591
|
-
|
|
592
|
-
test('nav: aria-current="page" on current link', () => {
|
|
593
|
-
const html = nav({ logo: 'x', links: [
|
|
594
|
-
{ label: 'Home', href: '/', current: true },
|
|
595
|
-
{ label: 'About', href: '/about' },
|
|
596
|
-
] })
|
|
597
|
-
assert.match(html, /aria-current="page"/)
|
|
598
|
-
// Appears in both desktop and mobile nav — once per nav, so 2 total
|
|
599
|
-
const matches = html.match(/aria-current="page"/g)
|
|
600
|
-
assert.ok(matches && matches.length >= 1, 'At least one link should have aria-current')
|
|
601
|
-
assert.doesNotMatch(html, /href="\/about"[^>]*aria-current/)
|
|
602
|
-
})
|
|
603
|
-
|
|
604
|
-
test('nav: no aria-current when current not set', () => {
|
|
605
|
-
const html = nav({ logo: 'x', links: [{ label: 'Home', href: '/' }] })
|
|
606
|
-
assert.doesNotMatch(html, /aria-current/)
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
test('nav: aria-current on utility link', () => {
|
|
610
|
-
const html = nav({ logo: 'x', links: [], utilityLinks: [
|
|
611
|
-
{ label: 'Sign in', href: '/login', current: true },
|
|
612
|
-
] })
|
|
613
|
-
assert.match(html, /aria-current="page"/)
|
|
614
|
-
})
|
|
615
|
-
|
|
616
|
-
// ─── appBadge ────────────────────────────────────────────────────────────────
|
|
617
|
-
|
|
618
|
-
test('appBadge: renders apple badge by default', () => {
|
|
619
|
-
const html = appBadge({ href: 'https://apps.apple.com/x' })
|
|
620
|
-
assert.match(html, /App Store/)
|
|
621
|
-
assert.match(html, /apps\.apple\.com/)
|
|
622
|
-
})
|
|
623
|
-
|
|
624
|
-
test('appBadge: renders google badge', () => {
|
|
625
|
-
assert.match(appBadge({ store: 'google', href: '/play' }), /Google Play/)
|
|
626
|
-
})
|
|
627
|
-
|
|
628
|
-
test('appBadge: unknown store falls back to apple', () => {
|
|
629
|
-
assert.match(appBadge({ store: 'unknown' }), /App Store/)
|
|
630
|
-
})
|
|
631
|
-
|
|
632
|
-
test('appBadge: has aria-label and rel=noopener', () => {
|
|
633
|
-
const html = appBadge({ href: '/x' })
|
|
634
|
-
assert.match(html, /aria-label/)
|
|
635
|
-
assert.match(html, /noopener/)
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
// ─── container ───────────────────────────────────────────────────────────────
|
|
639
|
-
|
|
640
|
-
test('container: renders content', () => {
|
|
641
|
-
assert.match(container({ content: '<p>Hello</p>' }), /Hello/)
|
|
642
|
-
})
|
|
643
|
-
|
|
644
|
-
test('container: default size is lg', () => {
|
|
645
|
-
assert.match(container({ content: '' }), /ui-container--lg/)
|
|
646
|
-
})
|
|
647
|
-
|
|
648
|
-
test('container: size variants apply modifier class', () => {
|
|
649
|
-
assert.match(container({ content: '', size: 'sm' }), /ui-container--sm/)
|
|
650
|
-
assert.match(container({ content: '', size: 'md' }), /ui-container--md/)
|
|
651
|
-
assert.match(container({ content: '', size: 'xl' }), /ui-container--xl/)
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
test('container: unknown size falls back to lg', () => {
|
|
655
|
-
assert.match(container({ content: '', size: 'huge' }), /ui-container--lg/)
|
|
656
|
-
})
|
|
657
|
-
|
|
658
|
-
// ─── section ─────────────────────────────────────────────────────────────────
|
|
659
|
-
|
|
660
|
-
test('section: renders as <section> element', () => {
|
|
661
|
-
assert.match(section({ content: 'x' }), /<section/)
|
|
662
|
-
})
|
|
663
|
-
|
|
664
|
-
test('section: alt variant adds modifier class', () => {
|
|
665
|
-
assert.match(section({ content: '', variant: 'alt' }), /ui-section--alt/)
|
|
666
|
-
})
|
|
667
|
-
|
|
668
|
-
test('section: dark variant adds modifier class', () => {
|
|
669
|
-
assert.match(section({ content: '', variant: 'dark' }), /ui-section--dark/)
|
|
670
|
-
})
|
|
671
|
-
|
|
672
|
-
test('section: default variant has no modifier class', () => {
|
|
673
|
-
assert.doesNotMatch(section({ content: '' }), /ui-section--default/)
|
|
674
|
-
})
|
|
675
|
-
|
|
676
|
-
test('section: sm padding adds modifier class', () => {
|
|
677
|
-
assert.match(section({ content: '', padding: 'sm' }), /ui-section--sm/)
|
|
678
|
-
})
|
|
679
|
-
|
|
680
|
-
test('section: id attribute renders when provided', () => {
|
|
681
|
-
assert.match(section({ content: '', id: 'features' }), /id="features"/)
|
|
682
|
-
})
|
|
683
|
-
|
|
684
|
-
test('section: unknown variant falls back to default', () => {
|
|
685
|
-
assert.doesNotMatch(section({ content: '', variant: 'rainbow' }), /ui-section--rainbow/)
|
|
686
|
-
})
|
|
687
|
-
|
|
688
|
-
// ─── grid ────────────────────────────────────────────────────────────────────
|
|
689
|
-
|
|
690
|
-
test('grid: renders content', () => {
|
|
691
|
-
assert.match(grid({ content: '<div>A</div>' }), /A/)
|
|
692
|
-
})
|
|
693
|
-
|
|
694
|
-
test('grid: default cols is 3', () => {
|
|
695
|
-
assert.match(grid({ content: '' }), /ui-grid--cols-3/)
|
|
696
|
-
})
|
|
697
|
-
|
|
698
|
-
test('grid: cols variants apply modifier class', () => {
|
|
699
|
-
assert.match(grid({ content: '', cols: 2 }), /ui-grid--cols-2/)
|
|
700
|
-
assert.match(grid({ content: '', cols: 4 }), /ui-grid--cols-4/)
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
test('grid: unknown cols falls back to 3', () => {
|
|
704
|
-
assert.match(grid({ content: '', cols: 5 }), /ui-grid--cols-3/)
|
|
705
|
-
})
|
|
706
|
-
|
|
707
|
-
test('grid: gap sm adds modifier class', () => {
|
|
708
|
-
assert.match(grid({ content: '', gap: 'sm' }), /ui-grid--gap-sm/)
|
|
709
|
-
})
|
|
710
|
-
|
|
711
|
-
test('grid: default gap has no modifier class', () => {
|
|
712
|
-
assert.doesNotMatch(grid({ content: '' }), /ui-grid--gap-md/)
|
|
713
|
-
})
|
|
714
|
-
|
|
715
|
-
// ─── stack ───────────────────────────────────────────────────────────────────
|
|
716
|
-
|
|
717
|
-
test('stack: renders content', () => {
|
|
718
|
-
assert.match(stack({ content: '<p>x</p>' }), /x/)
|
|
719
|
-
})
|
|
720
|
-
|
|
721
|
-
test('stack: gap variants apply modifier class', () => {
|
|
722
|
-
assert.match(stack({ content: '', gap: 'lg' }), /ui-stack--gap-lg/)
|
|
723
|
-
assert.match(stack({ content: '', gap: 'xs' }), /ui-stack--gap-xs/)
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
test('stack: default gap has no modifier class', () => {
|
|
727
|
-
assert.doesNotMatch(stack({ content: '' }), /ui-stack--gap-md/)
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
test('stack: align center adds modifier class', () => {
|
|
731
|
-
assert.match(stack({ content: '', align: 'center' }), /ui-stack--align-center/)
|
|
732
|
-
})
|
|
733
|
-
|
|
734
|
-
test('stack: default align has no modifier class', () => {
|
|
735
|
-
assert.doesNotMatch(stack({ content: '' }), /ui-stack--align-stretch/)
|
|
736
|
-
})
|
|
737
|
-
|
|
738
|
-
// ─── cluster ─────────────────────────────────────────────────────────────────
|
|
739
|
-
|
|
740
|
-
test('cluster: renders content', () => {
|
|
741
|
-
assert.match(cluster({ content: '<span>x</span>' }), /x/)
|
|
742
|
-
})
|
|
743
|
-
|
|
744
|
-
test('cluster: justify center adds modifier class', () => {
|
|
745
|
-
assert.match(cluster({ content: '', justify: 'center' }), /ui-cluster--justify-center/)
|
|
746
|
-
})
|
|
747
|
-
|
|
748
|
-
test('cluster: justify between adds modifier class', () => {
|
|
749
|
-
assert.match(cluster({ content: '', justify: 'between' }), /ui-cluster--justify-between/)
|
|
750
|
-
})
|
|
751
|
-
|
|
752
|
-
test('cluster: nowrap adds modifier class', () => {
|
|
753
|
-
assert.match(cluster({ content: '', wrap: false }), /ui-cluster--nowrap/)
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
test('cluster: wrap is true by default (no nowrap class)', () => {
|
|
757
|
-
assert.doesNotMatch(cluster({ content: '' }), /ui-cluster--nowrap/)
|
|
758
|
-
})
|
|
759
|
-
|
|
760
|
-
// ─── divider ─────────────────────────────────────────────────────────────────
|
|
761
|
-
|
|
762
|
-
test('divider: renders <hr> when no label', () => {
|
|
763
|
-
assert.match(divider(), /<hr/)
|
|
764
|
-
})
|
|
765
|
-
|
|
766
|
-
test('divider: renders label version as <div>', () => {
|
|
767
|
-
assert.match(divider({ label: 'or' }), /<div/)
|
|
768
|
-
assert.match(divider({ label: 'or' }), /or/)
|
|
769
|
-
})
|
|
770
|
-
|
|
771
|
-
test('divider: has role=separator and aria-label when label provided', () => {
|
|
772
|
-
const html = divider({ label: 'or' })
|
|
773
|
-
assert.match(html, /role="separator"/)
|
|
774
|
-
assert.match(html, /aria-label="or"/)
|
|
775
|
-
})
|
|
776
|
-
|
|
777
|
-
test('divider: escapes label', () => {
|
|
778
|
-
assert.doesNotMatch(divider({ label: '<script>' }), /<script>/)
|
|
779
|
-
})
|
|
780
|
-
|
|
781
|
-
// ─── media ───────────────────────────────────────────────────────────────────
|
|
782
|
-
|
|
783
|
-
test('media: renders image and content slots', () => {
|
|
784
|
-
const html = media({ image: '<img src="/x.jpg">', content: '<p>Text</p>' })
|
|
785
|
-
assert.match(html, /ui-media-image/)
|
|
786
|
-
assert.match(html, /ui-media-content/)
|
|
787
|
-
assert.match(html, /x\.jpg/)
|
|
788
|
-
assert.match(html, /Text/)
|
|
789
|
-
})
|
|
790
|
-
|
|
791
|
-
test('media: reverse adds modifier class', () => {
|
|
792
|
-
assert.match(media({ image: '', content: '', reverse: true }), /ui-media--reverse/)
|
|
793
|
-
})
|
|
794
|
-
|
|
795
|
-
test('media: no reverse class by default', () => {
|
|
796
|
-
assert.doesNotMatch(media({ image: '', content: '' }), /ui-media--reverse/)
|
|
797
|
-
})
|
|
798
|
-
|
|
799
|
-
test('media: align start adds modifier class', () => {
|
|
800
|
-
assert.match(media({ image: '', content: '', align: 'start' }), /ui-media--align-start/)
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
test('media: gap lg adds modifier class', () => {
|
|
804
|
-
assert.match(media({ image: '', content: '', gap: 'lg' }), /ui-media--gap-lg/)
|
|
805
|
-
})
|
|
806
|
-
|
|
807
|
-
// ─── slider ──────────────────────────────────────────────────────────────────
|
|
808
|
-
|
|
809
|
-
test('slider: renders range input with label', () => {
|
|
810
|
-
const html = slider({ name: 'vol', label: 'Volume' })
|
|
811
|
-
assert.match(html, /type="range"/)
|
|
812
|
-
assert.match(html, /name="vol"/)
|
|
813
|
-
assert.match(html, /Volume/)
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
test('slider: sets --slider-fill inline style from value', () => {
|
|
817
|
-
const html = slider({ name: 'x', min: 0, max: 100, value: 50 })
|
|
818
|
-
assert.match(html, /--slider-fill:50\.00%/)
|
|
819
|
-
})
|
|
820
|
-
|
|
821
|
-
test('slider: --slider-fill is 0% when value equals min', () => {
|
|
822
|
-
const html = slider({ name: 'x', min: 0, max: 100, value: 0 })
|
|
823
|
-
assert.match(html, /--slider-fill:0\.00%/)
|
|
824
|
-
})
|
|
825
|
-
|
|
826
|
-
test('slider: --slider-fill is 100% when value equals max', () => {
|
|
827
|
-
const html = slider({ name: 'x', min: 0, max: 100, value: 100 })
|
|
828
|
-
assert.match(html, /--slider-fill:100\.00%/)
|
|
829
|
-
})
|
|
830
|
-
|
|
831
|
-
test('slider: has --slider-fill on wrapper for CSS fill gradient', () => {
|
|
832
|
-
const html = slider({ name: 'x' })
|
|
833
|
-
// fill is on the .ui-field wrapper so pulse-ui.js can update it via closest('.ui-field')
|
|
834
|
-
assert.match(html, /--slider-fill/)
|
|
835
|
-
assert.doesNotMatch(html, /oninput=/) // no inline handler — handled by pulse-ui.js
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
test('slider: event prop renders data-event attribute', () => {
|
|
839
|
-
const html = slider({ name: 'x', event: 'change:setBrightness' })
|
|
840
|
-
assert.match(html, /data-event="change:setBrightness"/)
|
|
841
|
-
})
|
|
842
|
-
|
|
843
|
-
test('slider: no data-event when event not provided', () => {
|
|
844
|
-
assert.doesNotMatch(slider({ name: 'x' }), /data-event/)
|
|
845
|
-
})
|
|
846
|
-
|
|
847
|
-
test('slider: hint renders with aria-describedby', () => {
|
|
848
|
-
const html = slider({ name: 'x', hint: 'Drag to adjust' })
|
|
849
|
-
assert.match(html, /Drag to adjust/)
|
|
850
|
-
assert.match(html, /aria-describedby/)
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
test('slider: disabled attribute', () => {
|
|
854
|
-
assert.match(slider({ name: 'x', disabled: true }), /disabled/)
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
test('slider: clamps value to min', () => {
|
|
858
|
-
assert.match(slider({ name: 'x', min: 10, max: 100, value: 0 }), /value="10"/)
|
|
859
|
-
})
|
|
860
|
-
|
|
861
|
-
test('slider: clamps value to max', () => {
|
|
862
|
-
assert.match(slider({ name: 'x', min: 0, max: 100, value: 200 }), /value="100"/)
|
|
863
|
-
})
|
|
864
|
-
|
|
865
|
-
// ─── toggle (switch) ─────────────────────────────────────────────────────────
|
|
866
|
-
|
|
867
|
-
test('toggle: renders checkbox input with label', () => {
|
|
868
|
-
const html = toggle({ name: 'enabled', label: 'Enable' })
|
|
869
|
-
assert.match(html, /type="checkbox"/)
|
|
870
|
-
assert.match(html, /name="enabled"/)
|
|
871
|
-
assert.match(html, /Enable/)
|
|
872
|
-
})
|
|
873
|
-
|
|
874
|
-
test('toggle: checked attribute when checked=true', () => {
|
|
875
|
-
assert.match(toggle({ name: 'x', checked: true }), /checked/)
|
|
876
|
-
})
|
|
877
|
-
|
|
878
|
-
test('toggle: no checked attribute by default', () => {
|
|
879
|
-
assert.doesNotMatch(toggle({ name: 'x' }), /checked/)
|
|
880
|
-
})
|
|
881
|
-
|
|
882
|
-
test('toggle: event prop renders data-event attribute', () => {
|
|
883
|
-
const html = toggle({ name: 'x', event: 'change:setEnabled' })
|
|
884
|
-
assert.match(html, /data-event="change:setEnabled"/)
|
|
885
|
-
})
|
|
886
|
-
|
|
887
|
-
test('toggle: no data-event when event not provided', () => {
|
|
888
|
-
assert.doesNotMatch(toggle({ name: 'x' }), /data-event/)
|
|
889
|
-
})
|
|
890
|
-
|
|
891
|
-
test('toggle: disabled attribute', () => {
|
|
892
|
-
assert.match(toggle({ name: 'x', disabled: true }), /disabled/)
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
test('toggle: hint renders aria-describedby', () => {
|
|
896
|
-
const html = toggle({ name: 'x', hint: 'Turn on notifications' })
|
|
897
|
-
assert.match(html, /Turn on notifications/)
|
|
898
|
-
assert.match(html, /aria-describedby/)
|
|
899
|
-
})
|
|
900
|
-
|
|
901
|
-
// ─── radio / radioGroup ──────────────────────────────────────────────────────
|
|
902
|
-
|
|
903
|
-
test('radio: renders radio input with label', () => {
|
|
904
|
-
const html = radio({ name: 'colour', value: 'red', label: 'Red' })
|
|
905
|
-
assert.match(html, /type="radio"/)
|
|
906
|
-
assert.match(html, /name="colour"/)
|
|
907
|
-
assert.match(html, /value="red"/)
|
|
908
|
-
assert.match(html, /Red/)
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
test('radio: checked attribute when checked=true', () => {
|
|
912
|
-
assert.match(radio({ name: 'x', value: 'a', checked: true }), /checked/)
|
|
913
|
-
})
|
|
914
|
-
|
|
915
|
-
test('radio: event prop renders data-event', () => {
|
|
916
|
-
const html = radio({ name: 'x', value: 'a', event: 'change:setChoice' })
|
|
917
|
-
assert.match(html, /data-event="change:setChoice"/)
|
|
918
|
-
})
|
|
919
|
-
|
|
920
|
-
test('radio: no data-event when event not provided', () => {
|
|
921
|
-
assert.doesNotMatch(radio({ name: 'x', value: 'a' }), /data-event/)
|
|
922
|
-
})
|
|
923
|
-
|
|
924
|
-
test('radio: escapes value and label', () => {
|
|
925
|
-
const html = radio({ name: 'x', value: '"><script>', label: '<b>bold</b>' })
|
|
926
|
-
assert.doesNotMatch(html, /<script>/)
|
|
927
|
-
assert.doesNotMatch(html, /<b>bold/)
|
|
928
|
-
})
|
|
929
|
-
|
|
930
|
-
test('radioGroup: renders fieldset with legend', () => {
|
|
931
|
-
const html = radioGroup({ name: 'size', legend: 'Size', options: [{ value: 's', label: 'Small' }] })
|
|
932
|
-
assert.match(html, /<fieldset/)
|
|
933
|
-
assert.match(html, /Size/)
|
|
934
|
-
assert.match(html, /Small/)
|
|
935
|
-
})
|
|
936
|
-
|
|
937
|
-
test('radioGroup: marks selected option as checked', () => {
|
|
938
|
-
const html = radioGroup({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], value: 'b' })
|
|
939
|
-
const inputs = [...html.matchAll(/type="radio"[^>]*checked/g)]
|
|
940
|
-
assert.equal(inputs.length, 1)
|
|
941
|
-
assert.ok(html.includes('value="b"'))
|
|
942
|
-
})
|
|
943
|
-
|
|
944
|
-
test('radioGroup: event prop propagates data-event to all radio inputs', () => {
|
|
945
|
-
const html = radioGroup({ name: 'x', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], event: 'change:setPlan' })
|
|
946
|
-
const matches = [...html.matchAll(/data-event="change:setPlan"/g)]
|
|
947
|
-
assert.equal(matches.length, 2)
|
|
948
|
-
})
|
|
949
|
-
|
|
950
|
-
test('radioGroup: error renders role=alert', () => {
|
|
951
|
-
const html = radioGroup({ name: 'x', options: [], error: 'Pick one' })
|
|
952
|
-
assert.match(html, /role="alert"/)
|
|
953
|
-
assert.match(html, /Pick one/)
|
|
954
|
-
})
|
|
955
|
-
|
|
956
|
-
// ─── fileUpload ───────────────────────────────────────────────────────────────
|
|
957
|
-
|
|
958
|
-
test('fileUpload: renders file input with correct name', () => {
|
|
959
|
-
const html = fileUpload({ name: 'avatar', label: 'Photo' })
|
|
960
|
-
assert.match(html, /type="file"/)
|
|
961
|
-
assert.match(html, /name="avatar"/)
|
|
962
|
-
assert.match(html, /Photo/)
|
|
963
|
-
})
|
|
964
|
-
|
|
965
|
-
test('fileUpload: accept attribute when provided', () => {
|
|
966
|
-
assert.match(fileUpload({ name: 'x', accept: 'image/*' }), /accept="image\/\*"/)
|
|
967
|
-
})
|
|
968
|
-
|
|
969
|
-
test('fileUpload: multiple attribute when provided', () => {
|
|
970
|
-
assert.match(fileUpload({ name: 'x', multiple: true }), /multiple/)
|
|
971
|
-
})
|
|
972
|
-
|
|
973
|
-
test('fileUpload: no multiple attribute by default', () => {
|
|
974
|
-
assert.doesNotMatch(fileUpload({ name: 'x' }), /multiple/)
|
|
975
|
-
})
|
|
976
|
-
|
|
977
|
-
test('fileUpload: required adds required and aria-required', () => {
|
|
978
|
-
const html = fileUpload({ name: 'x', required: true })
|
|
979
|
-
assert.match(html, /required/)
|
|
980
|
-
assert.match(html, /aria-required="true"/)
|
|
981
|
-
})
|
|
982
|
-
|
|
983
|
-
test('fileUpload: disabled applies to input and zone', () => {
|
|
984
|
-
const html = fileUpload({ name: 'x', disabled: true })
|
|
985
|
-
assert.match(html, /ui-upload--disabled/)
|
|
986
|
-
assert.match(html, /disabled/)
|
|
987
|
-
})
|
|
988
|
-
|
|
989
|
-
test('fileUpload: event prop renders data-event on input', () => {
|
|
990
|
-
assert.match(fileUpload({ name: 'x', event: 'change:fileSelected' }), /data-event="change:fileSelected"/)
|
|
991
|
-
})
|
|
992
|
-
|
|
993
|
-
test('fileUpload: no data-event when event not provided', () => {
|
|
994
|
-
assert.doesNotMatch(fileUpload({ name: 'x' }), /data-event/)
|
|
995
|
-
})
|
|
996
|
-
|
|
997
|
-
test('fileUpload: error renders role=alert and ui-upload--error', () => {
|
|
998
|
-
const html = fileUpload({ name: 'x', error: 'File too large' })
|
|
999
|
-
assert.match(html, /role="alert"/)
|
|
1000
|
-
assert.match(html, /File too large/)
|
|
1001
|
-
assert.match(html, /ui-upload--error/)
|
|
1002
|
-
assert.match(html, /aria-invalid="true"/)
|
|
1003
|
-
})
|
|
1004
|
-
|
|
1005
|
-
test('fileUpload: hint renders with aria-describedby', () => {
|
|
1006
|
-
const html = fileUpload({ name: 'x', hint: 'PNG, JPG up to 5 MB' })
|
|
1007
|
-
assert.match(html, /PNG, JPG up to 5 MB/)
|
|
1008
|
-
assert.match(html, /aria-describedby/)
|
|
1009
|
-
})
|
|
1010
|
-
|
|
1011
|
-
test('fileUpload: no inline drag handlers — handled by pulse-ui.js', () => {
|
|
1012
|
-
const html = fileUpload({ name: 'x' })
|
|
1013
|
-
// All drag/click/keyboard behaviour is delegated via pulse-ui.js (CSP-safe)
|
|
1014
|
-
assert.doesNotMatch(html, /ondragover=/)
|
|
1015
|
-
assert.doesNotMatch(html, /ondrop=/)
|
|
1016
|
-
assert.doesNotMatch(html, /onclick=/)
|
|
1017
|
-
})
|
|
1018
|
-
|
|
1019
|
-
test('fileUpload: drag handlers absent when disabled', () => {
|
|
1020
|
-
const html = fileUpload({ name: 'x', disabled: true })
|
|
1021
|
-
assert.doesNotMatch(html, /ondragover=/)
|
|
1022
|
-
})
|
|
1023
|
-
|
|
1024
|
-
test('fileUpload: zone has role=button and tabindex for keyboard access', () => {
|
|
1025
|
-
const html = fileUpload({ name: 'x' })
|
|
1026
|
-
assert.match(html, /role="button"/)
|
|
1027
|
-
assert.match(html, /tabindex="0"/)
|
|
1028
|
-
})
|
|
1029
|
-
|
|
1030
|
-
test('fileUpload: input is hidden — opened programmatically by pulse-ui.js', () => {
|
|
1031
|
-
const html = fileUpload({ name: 'x' })
|
|
1032
|
-
assert.match(html, /class="ui-upload-input"/)
|
|
1033
|
-
assert.doesNotMatch(html, /onkeydown=/) // keyboard handling in pulse-ui.js
|
|
1034
|
-
})
|
|
1035
|
-
|
|
1036
|
-
test('fileUpload: zone has role=button and tabindex=0', () => {
|
|
1037
|
-
const html = fileUpload({ name: 'x' })
|
|
1038
|
-
assert.match(html, /role="button"/)
|
|
1039
|
-
assert.match(html, /tabindex="0"/)
|
|
1040
|
-
})
|
|
1041
|
-
|
|
1042
|
-
test('fileUpload: disabled zone has tabindex=-1', () => {
|
|
1043
|
-
assert.match(fileUpload({ name: 'x', disabled: true }), /tabindex="-1"/)
|
|
1044
|
-
})
|
|
1045
|
-
|
|
1046
|
-
test('fileUpload: escapes name to prevent XSS', () => {
|
|
1047
|
-
const html = fileUpload({ name: '"><script>alert(1)</script>', label: 'x' })
|
|
1048
|
-
assert.doesNotMatch(html, /<script>alert/)
|
|
1049
|
-
})
|
|
1050
|
-
|
|
1051
|
-
// ─── select: event prop ──────────────────────────────────────────────────────
|
|
1052
|
-
|
|
1053
|
-
test('select: event prop renders data-event attribute', () => {
|
|
1054
|
-
const html = select({ name: 'cat', options: [], event: 'change:setCategory' })
|
|
1055
|
-
assert.match(html, /data-event="change:setCategory"/)
|
|
1056
|
-
})
|
|
1057
|
-
|
|
1058
|
-
test('select: no data-event when event not provided', () => {
|
|
1059
|
-
assert.doesNotMatch(select({ name: 'x', options: [] }), /data-event/)
|
|
1060
|
-
})
|
|
1061
|
-
|
|
1062
|
-
// ─── modal / modalTrigger ────────────────────────────────────────────────────
|
|
1063
|
-
|
|
1064
|
-
test('modal: renders a <dialog> element', () => {
|
|
1065
|
-
const html = modal({ id: 'test', title: 'Hello', content: '<p>Body</p>' })
|
|
1066
|
-
assert.match(html, /<dialog/)
|
|
1067
|
-
assert.match(html, /id="test"/)
|
|
1068
|
-
})
|
|
1069
|
-
|
|
1070
|
-
test('modal: sets aria-labelledby from id', () => {
|
|
1071
|
-
const html = modal({ id: 'my-modal', title: 'Title' })
|
|
1072
|
-
assert.match(html, /aria-labelledby="my-modal-title"/)
|
|
1073
|
-
})
|
|
1074
|
-
|
|
1075
|
-
test('modal: applies size modifier class', () => {
|
|
1076
|
-
assert.match(modal({ id: 'x', size: 'lg' }), /ui-modal--lg/)
|
|
1077
|
-
assert.doesNotMatch(modal({ id: 'x', size: 'md' }), /ui-modal--md/)
|
|
1078
|
-
})
|
|
1079
|
-
|
|
1080
|
-
test('modal: renders footer only when provided', () => {
|
|
1081
|
-
assert.match(modal({ id: 'x', footer: '<button>OK</button>' }), /<footer/)
|
|
1082
|
-
assert.doesNotMatch(modal({ id: 'x' }), /<footer/)
|
|
1083
|
-
})
|
|
1084
|
-
|
|
1085
|
-
test('modal: escapes title to prevent XSS', () => {
|
|
1086
|
-
const html = modal({ id: 'x', title: '<script>alert(1)</script>' })
|
|
1087
|
-
assert.doesNotMatch(html, /<script>alert/)
|
|
1088
|
-
})
|
|
1089
|
-
|
|
1090
|
-
test('modalTrigger: renders button with data-dialog-open', () => {
|
|
1091
|
-
const html = modalTrigger({ target: 'confirm', label: 'Open' })
|
|
1092
|
-
assert.match(html, /data-dialog-open="confirm"/)
|
|
1093
|
-
assert.match(html, /<button/)
|
|
1094
|
-
})
|
|
1095
|
-
|
|
1096
|
-
test('modalTrigger: does not use legacy data-modal-open', () => {
|
|
1097
|
-
const html = modalTrigger({ target: 'confirm', label: 'Open' })
|
|
1098
|
-
assert.doesNotMatch(html, /data-modal-open/)
|
|
1099
|
-
})
|
|
1100
|
-
|
|
1101
|
-
test('modalTrigger: escapes target id to prevent XSS', () => {
|
|
1102
|
-
const html = modalTrigger({ target: '"><script>', label: 'x' })
|
|
1103
|
-
assert.doesNotMatch(html, /<script>/)
|
|
1104
|
-
})
|
|
1105
|
-
|
|
1106
|
-
// ─── uiImage ────────────────────────────────────────────────────────────────
|
|
1107
|
-
|
|
1108
|
-
test('uiImage: renders img with src and alt', () => {
|
|
1109
|
-
const html = uiImage({ src: '/photo.jpg', alt: 'A photo' })
|
|
1110
|
-
assert.match(html, /src="\/photo\.jpg"/)
|
|
1111
|
-
assert.match(html, /alt="A photo"/)
|
|
1112
|
-
})
|
|
1113
|
-
|
|
1114
|
-
test('uiImage: defaults to loading="lazy"', () => {
|
|
1115
|
-
const html = uiImage({ src: '/photo.jpg', alt: '' })
|
|
1116
|
-
assert.match(html, /loading="lazy"/)
|
|
1117
|
-
})
|
|
1118
|
-
|
|
1119
|
-
test('uiImage: accepts loading="eager"', () => {
|
|
1120
|
-
const html = uiImage({ src: '/photo.jpg', alt: '', loading: 'eager' })
|
|
1121
|
-
assert.match(html, /loading="eager"/)
|
|
1122
|
-
})
|
|
1123
|
-
|
|
1124
|
-
test('uiImage: omits fetchpriority when not set', () => {
|
|
1125
|
-
const html = uiImage({ src: '/photo.jpg', alt: '' })
|
|
1126
|
-
assert.doesNotMatch(html, /fetchpriority/)
|
|
1127
|
-
})
|
|
1128
|
-
|
|
1129
|
-
test('uiImage: renders fetchpriority="high" when set', () => {
|
|
1130
|
-
const html = uiImage({ src: '/photo.jpg', alt: '', fetchpriority: 'high' })
|
|
1131
|
-
assert.match(html, /fetchpriority="high"/)
|
|
1132
|
-
})
|
|
1133
|
-
|
|
1134
|
-
test('uiImage: wraps in aspect-ratio crop when ratio given', () => {
|
|
1135
|
-
const html = uiImage({ src: '/photo.jpg', alt: '', ratio: '16/9' })
|
|
1136
|
-
assert.match(html, /aspect-ratio:16\/9/)
|
|
1137
|
-
assert.match(html, /ui-image-img--cover/)
|
|
1138
|
-
})
|
|
1139
|
-
|
|
1140
|
-
test('uiImage: escapes src to prevent XSS', () => {
|
|
1141
|
-
const html = uiImage({ src: '"><script>alert(1)</script>', alt: '' })
|
|
1142
|
-
assert.doesNotMatch(html, /<script>/)
|
|
1143
|
-
})
|
|
1144
|
-
|
|
1145
|
-
test('pulse-ui.css: form controls are at least 16px on touch devices (iOS focus-zoom)', () => {
|
|
1146
|
-
// iOS Safari zooms the page when a focused input's font-size is under 16px,
|
|
1147
|
-
// forcing the user to pinch back out. A (pointer: coarse) block must keep
|
|
1148
|
-
// every text-entry control at >= 16px on touch devices.
|
|
1149
|
-
const css = readFileSync(new URL('../../public/pulse-ui.css', import.meta.url), 'utf8')
|
|
1150
|
-
const block = css.match(/@media \(pointer: coarse\) \{[\s\S]*?\n\}/)?.[0] ?? ''
|
|
1151
|
-
for (const sel of ['.ui-input', '.ui-textarea', '.ui-select', '.ui-search-input']) {
|
|
1152
|
-
assert.ok(block.includes(sel), `${sel} missing from the (pointer: coarse) 16px block`)
|
|
1153
|
-
}
|
|
1154
|
-
assert.match(block, /font-size:\s*max\(1rem, 16px\)/)
|
|
1155
|
-
})
|
|
1156
|
-
|
|
1157
|
-
console.log('✓ All UI component tests passed')
|