kempo-ui 0.4.2 → 0.4.4

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 (104) hide show
  1. package/.claude/skills/get-icon/SKILL.md +144 -0
  2. package/.claude/skills/highlight-code/SKILL.md +62 -0
  3. package/.claude/skills/new-component/SKILL.md +362 -0
  4. package/.github/skills/get-icon/SKILL.md +4 -4
  5. package/.github/skills/highlight-code/SKILL.md +51 -20
  6. package/.github/skills/new-component/SKILL.md +183 -152
  7. package/AGENTS.md +2 -2
  8. package/dist/components/Calendar.js +170 -0
  9. package/dist/components/SpeechToText.js +60 -0
  10. package/dist/components/TextToSpeech.js +56 -0
  11. package/dist/components/Time.js +37 -0
  12. package/docs/components/accordion.html +16 -0
  13. package/docs/components/aside.html +16 -0
  14. package/docs/components/calendar.html +720 -0
  15. package/docs/components/card.html +16 -0
  16. package/docs/components/code-editor.html +16 -0
  17. package/docs/components/color-picker.html +16 -0
  18. package/docs/components/combobox.html +16 -0
  19. package/docs/components/content-slider.html +16 -0
  20. package/docs/components/context.html +16 -0
  21. package/docs/components/dialog.html +16 -0
  22. package/docs/components/dropdown.html +16 -0
  23. package/docs/components/filter-list.html +16 -0
  24. package/docs/components/focus-capture.html +16 -0
  25. package/docs/components/html-editor.html +16 -0
  26. package/docs/components/hybrid-component.html +16 -0
  27. package/docs/components/icon.html +16 -0
  28. package/docs/components/import.html +16 -0
  29. package/docs/components/light-component.html +16 -0
  30. package/docs/components/nav-spacer.html +16 -0
  31. package/docs/components/nav.html +16 -0
  32. package/docs/components/photo-viewer.html +16 -0
  33. package/docs/components/progress.html +16 -0
  34. package/docs/components/resize.html +16 -0
  35. package/docs/components/shadow-component.html +16 -0
  36. package/docs/components/show-more.html +16 -0
  37. package/docs/components/slider.html +16 -0
  38. package/docs/components/sortable.html +16 -0
  39. package/docs/components/speech-to-text.html +717 -0
  40. package/docs/components/spinner.html +16 -0
  41. package/docs/components/split.html +16 -0
  42. package/docs/components/table.html +16 -0
  43. package/docs/components/tableControls.html +16 -0
  44. package/docs/components/tableCustomFields.html +16 -0
  45. package/docs/components/tableFetchRecords.html +16 -0
  46. package/docs/components/tableFieldSortHide.html +16 -0
  47. package/docs/components/tablePagination.html +16 -0
  48. package/docs/components/tablePlaceholder.html +16 -0
  49. package/docs/components/tableRecordEditing.html +16 -0
  50. package/docs/components/tableRecordFiltering.html +16 -0
  51. package/docs/components/tableRecordHiding.html +16 -0
  52. package/docs/components/tableRecordSearching.html +16 -0
  53. package/docs/components/tableRecordSelection.html +16 -0
  54. package/docs/components/tableRowControls.html +16 -0
  55. package/docs/components/tableServerSync.html +16 -0
  56. package/docs/components/tableSorting.html +16 -0
  57. package/docs/components/tabs.html +16 -0
  58. package/docs/components/tags.html +16 -0
  59. package/docs/components/text-to-speech.html +668 -0
  60. package/docs/components/theme-select.html +16 -0
  61. package/docs/components/theme-switcher.html +16 -0
  62. package/docs/components/time.html +670 -0
  63. package/docs/components/timestamp.html +16 -0
  64. package/docs/components/toast.html +16 -0
  65. package/docs/components/toggle.html +16 -0
  66. package/docs/components/tree.html +16 -0
  67. package/docs/icons/mic.svg +1 -0
  68. package/docs/icons/record_voice_over.svg +1 -0
  69. package/docs/icons/stop.svg +1 -0
  70. package/docs/index.html +40 -0
  71. package/docs/src/components/Calendar.js +170 -0
  72. package/docs/src/components/SpeechToText.js +60 -0
  73. package/docs/src/components/TextToSpeech.js +56 -0
  74. package/docs/src/components/Time.js +37 -0
  75. package/docs/utils/context.html +16 -0
  76. package/docs/utils/cookie.html +16 -0
  77. package/docs/utils/debounce.html +16 -0
  78. package/docs/utils/drag.html +16 -0
  79. package/docs/utils/elevation.html +16 -0
  80. package/docs/utils/formatTimestamp.html +16 -0
  81. package/docs/utils/object.html +16 -0
  82. package/docs/utils/propConverters.html +16 -0
  83. package/docs/utils/string.html +16 -0
  84. package/docs/utils/theme.html +16 -0
  85. package/docs/utils/toTitleCase.html +16 -0
  86. package/docs/utils/type.html +16 -0
  87. package/docs/utils/wait.html +16 -0
  88. package/docs-src/components/calendar.page.html +254 -0
  89. package/docs-src/components/speech-to-text.page.html +251 -0
  90. package/docs-src/components/text-to-speech.page.html +202 -0
  91. package/docs-src/components/time.page.html +204 -0
  92. package/docs-src/index.page.html +24 -0
  93. package/docs-src/nav.fragment.html +16 -0
  94. package/icons/mic.svg +1 -0
  95. package/icons/record_voice_over.svg +1 -0
  96. package/icons/stop.svg +1 -0
  97. package/llms.txt +4 -0
  98. package/package.json +1 -1
  99. package/src/components/Calendar.js +473 -0
  100. package/src/components/SpeechToText.js +252 -0
  101. package/src/components/TextToSpeech.js +194 -0
  102. package/src/components/Time.js +137 -0
  103. package/tests/components/SpeechToText.browser-test.js +530 -0
  104. package/tests/components/TextToSpeech.browser-test.js +432 -0
@@ -0,0 +1,432 @@
1
+ import TextToSpeech from '../../src/components/TextToSpeech.js';
2
+
3
+ const createTextToSpeech = async (attrs = {}, slot = '') => {
4
+ const container = document.createElement('div');
5
+ const parts = [];
6
+ if(attrs.text !== undefined) parts.push(`text="${attrs.text}"`);
7
+ if(attrs.voice !== undefined) parts.push(`voice="${attrs.voice}"`);
8
+ if(attrs.language !== undefined) parts.push(`language="${attrs.language}"`);
9
+ if(attrs.rate !== undefined) parts.push(`rate="${attrs.rate}"`);
10
+ if(attrs.pitch !== undefined) parts.push(`pitch="${attrs.pitch}"`);
11
+ if(attrs.volume !== undefined) parts.push(`volume="${attrs.volume}"`);
12
+ if(attrs.disabled) parts.push('disabled');
13
+ container.innerHTML = `<k-text-to-speech ${parts.join(' ')}>${slot}</k-text-to-speech>`;
14
+ document.body.appendChild(container);
15
+ const el = container.querySelector('k-text-to-speech');
16
+ await el.updateComplete;
17
+ return { container, el };
18
+ };
19
+
20
+ const cleanup = (container) => {
21
+ if(container && container.parentNode){
22
+ container.parentNode.removeChild(container);
23
+ }
24
+ };
25
+
26
+ // Stub window.speechSynthesis (which is a read-only getter in Chrome) by
27
+ // patching its methods on the live object. SpeechSynthesisUtterance is a
28
+ // constructor, swapped via Object.defineProperty.
29
+ const installSynthesisStub = () => {
30
+ const captured = {
31
+ spoken: [],
32
+ cancelled: 0
33
+ };
34
+ const synth = window.speechSynthesis;
35
+ const originalSpeak = synth.speak.bind(synth);
36
+ const originalCancel = synth.cancel.bind(synth);
37
+ synth.speak = (utterance) => {
38
+ captured.spoken.push(utterance);
39
+ queueMicrotask(() => {
40
+ if(typeof utterance.onstart === 'function') utterance.onstart();
41
+ });
42
+ };
43
+ synth.cancel = () => { captured.cancelled++; };
44
+
45
+ const OriginalUtterance = window.SpeechSynthesisUtterance;
46
+ function StubUtterance(text){
47
+ this.text = text;
48
+ this.rate = 1;
49
+ this.pitch = 1;
50
+ this.volume = 1;
51
+ this.lang = '';
52
+ this.voice = null;
53
+ this.onstart = null;
54
+ this.onend = null;
55
+ this.onerror = null;
56
+ }
57
+ Object.defineProperty(window, 'SpeechSynthesisUtterance', {
58
+ value: StubUtterance,
59
+ configurable: true,
60
+ writable: true
61
+ });
62
+
63
+ return {
64
+ captured,
65
+ restore: () => {
66
+ synth.speak = originalSpeak;
67
+ synth.cancel = originalCancel;
68
+ Object.defineProperty(window, 'SpeechSynthesisUtterance', {
69
+ value: OriginalUtterance,
70
+ configurable: true,
71
+ writable: true
72
+ });
73
+ }
74
+ };
75
+ };
76
+
77
+ export default {
78
+ /*
79
+ Element Creation
80
+ */
81
+ 'should create text-to-speech element': async ({pass, fail}) => {
82
+ const { container, el } = await createTextToSpeech();
83
+ if(!(el instanceof TextToSpeech)){
84
+ cleanup(container);
85
+ return fail('Element should be instance of TextToSpeech');
86
+ }
87
+ cleanup(container);
88
+ pass('TextToSpeech element created correctly');
89
+ },
90
+
91
+ 'should have shadow root': async ({pass, fail}) => {
92
+ const { container, el } = await createTextToSpeech();
93
+ if(!el.shadowRoot){
94
+ cleanup(container);
95
+ return fail('TextToSpeech should have shadow root');
96
+ }
97
+ cleanup(container);
98
+ pass('TextToSpeech has shadow root');
99
+ },
100
+
101
+ 'should render a button': async ({pass, fail}) => {
102
+ const { container, el } = await createTextToSpeech();
103
+ if(!el.shadowRoot.querySelector('button')){
104
+ cleanup(container);
105
+ return fail('Should render a button');
106
+ }
107
+ cleanup(container);
108
+ pass('Button rendered');
109
+ },
110
+
111
+ 'should render record_voice_over k-icon when idle': async ({pass, fail}) => {
112
+ const { container, el } = await createTextToSpeech();
113
+ const icon = el.shadowRoot.querySelector('button k-icon');
114
+ if(icon.getAttribute('name') !== 'record_voice_over'){
115
+ cleanup(container);
116
+ return fail(`Idle icon should be "record_voice_over", got "${icon.getAttribute('name')}"`);
117
+ }
118
+ cleanup(container);
119
+ pass('Idle k-icon is record_voice_over');
120
+ },
121
+
122
+ 'should render stop k-icon when speaking': async ({pass, fail}) => {
123
+ const { container, el } = await createTextToSpeech();
124
+ el.speaking = true;
125
+ await el.updateComplete;
126
+ const icon = el.shadowRoot.querySelector('button k-icon');
127
+ if(icon.getAttribute('name') !== 'stop'){
128
+ cleanup(container);
129
+ return fail(`Speaking icon should be "stop", got "${icon.getAttribute('name')}"`);
130
+ }
131
+ cleanup(container);
132
+ pass('Speaking k-icon is stop');
133
+ },
134
+
135
+ /*
136
+ Default Properties
137
+ */
138
+ 'should default text to empty string': async ({pass, fail}) => {
139
+ const { container, el } = await createTextToSpeech();
140
+ if(el.text !== ''){
141
+ cleanup(container);
142
+ return fail(`Expected text to be "", got "${el.text}"`);
143
+ }
144
+ cleanup(container);
145
+ pass('Default text is empty');
146
+ },
147
+
148
+ 'should default rate to 1': async ({pass, fail}) => {
149
+ const { container, el } = await createTextToSpeech();
150
+ if(el.rate !== 1){
151
+ cleanup(container);
152
+ return fail(`Expected rate to be 1, got ${el.rate}`);
153
+ }
154
+ cleanup(container);
155
+ pass('Default rate is 1');
156
+ },
157
+
158
+ 'should default pitch to 1': async ({pass, fail}) => {
159
+ const { container, el } = await createTextToSpeech();
160
+ if(el.pitch !== 1){
161
+ cleanup(container);
162
+ return fail(`Expected pitch to be 1, got ${el.pitch}`);
163
+ }
164
+ cleanup(container);
165
+ pass('Default pitch is 1');
166
+ },
167
+
168
+ 'should default volume to 1': async ({pass, fail}) => {
169
+ const { container, el } = await createTextToSpeech();
170
+ if(el.volume !== 1){
171
+ cleanup(container);
172
+ return fail(`Expected volume to be 1, got ${el.volume}`);
173
+ }
174
+ cleanup(container);
175
+ pass('Default volume is 1');
176
+ },
177
+
178
+ 'should default speaking to false': async ({pass, fail}) => {
179
+ const { container, el } = await createTextToSpeech();
180
+ if(el.speaking !== false){
181
+ cleanup(container);
182
+ return fail(`Expected speaking to be false, got ${el.speaking}`);
183
+ }
184
+ cleanup(container);
185
+ pass('Default speaking is false');
186
+ },
187
+
188
+ /*
189
+ Attribute Reflection
190
+ */
191
+ 'should reflect text attribute': async ({pass, fail}) => {
192
+ const { container, el } = await createTextToSpeech({ text: 'Hello' });
193
+ if(el.text !== 'Hello'){
194
+ cleanup(container);
195
+ return fail(`Expected text to be "Hello", got "${el.text}"`);
196
+ }
197
+ cleanup(container);
198
+ pass('text attribute reflects to property');
199
+ },
200
+
201
+ 'should reflect rate, pitch, volume as numbers': async ({pass, fail}) => {
202
+ const { container, el } = await createTextToSpeech({ rate: 1.5, pitch: 0.8, volume: 0.5 });
203
+ if(el.rate !== 1.5 || el.pitch !== 0.8 || el.volume !== 0.5){
204
+ cleanup(container);
205
+ return fail(`Expected rate=1.5/pitch=0.8/volume=0.5, got rate=${el.rate}/pitch=${el.pitch}/volume=${el.volume}`);
206
+ }
207
+ cleanup(container);
208
+ pass('Numeric attrs reflect correctly');
209
+ },
210
+
211
+ /*
212
+ Effective Text Resolution
213
+ */
214
+ 'should prefer text attribute over slot content': async ({pass, fail}) => {
215
+ const { container, el } = await createTextToSpeech({ text: 'attr text' }, 'slot text');
216
+ if(el.effectiveText !== 'attr text'){
217
+ cleanup(container);
218
+ return fail(`Expected effectiveText to be "attr text", got "${el.effectiveText}"`);
219
+ }
220
+ cleanup(container);
221
+ pass('text attribute takes priority');
222
+ },
223
+
224
+ 'should fall back to slotted text when text attr is empty': async ({pass, fail}) => {
225
+ const { container, el } = await createTextToSpeech({}, 'slot only');
226
+ if(el.effectiveText !== 'slot only'){
227
+ cleanup(container);
228
+ return fail(`Expected effectiveText to be "slot only", got "${el.effectiveText}"`);
229
+ }
230
+ cleanup(container);
231
+ pass('falls back to slot when text attr empty');
232
+ },
233
+
234
+ /*
235
+ Disabled
236
+ */
237
+ 'should disable the button when disabled attribute is set': async ({pass, fail}) => {
238
+ const { container, el } = await createTextToSpeech({ disabled: true });
239
+ const btn = el.shadowRoot.querySelector('button');
240
+ if(!btn.disabled){
241
+ cleanup(container);
242
+ return fail('Button should be disabled when disabled attribute is set');
243
+ }
244
+ cleanup(container);
245
+ pass('Button is disabled when disabled attribute is set');
246
+ },
247
+
248
+ /*
249
+ Public Methods
250
+ */
251
+ 'should expose speak, stop, and toggle methods': async ({pass, fail}) => {
252
+ const { container, el } = await createTextToSpeech();
253
+ for(const m of ['speak', 'stop', 'toggle']){
254
+ if(typeof el[m] !== 'function'){
255
+ cleanup(container);
256
+ return fail(`TextToSpeech should have a ${m}() method`);
257
+ }
258
+ }
259
+ cleanup(container);
260
+ pass('speak, stop, toggle methods exist');
261
+ },
262
+
263
+ /*
264
+ Speak / Stop behavior (with stub)
265
+ */
266
+ 'should call speechSynthesis.speak with an utterance when speak() is called': async ({pass, fail}) => {
267
+ const stub = installSynthesisStub();
268
+ const { container, el } = await createTextToSpeech({ text: 'hello there' });
269
+ el.speak();
270
+ if(stub.captured.spoken.length !== 1){
271
+ stub.restore();
272
+ cleanup(container);
273
+ return fail(`Expected 1 utterance spoken, got ${stub.captured.spoken.length}`);
274
+ }
275
+ if(stub.captured.spoken[0].text !== 'hello there'){
276
+ stub.restore();
277
+ cleanup(container);
278
+ return fail(`Expected utterance text "hello there", got "${stub.captured.spoken[0].text}"`);
279
+ }
280
+ stub.restore();
281
+ cleanup(container);
282
+ pass('speak() queues an utterance with the right text');
283
+ },
284
+
285
+ 'should pass rate/pitch/volume/lang to the utterance': async ({pass, fail}) => {
286
+ const stub = installSynthesisStub();
287
+ const { container, el } = await createTextToSpeech({
288
+ text: 'x', rate: 1.5, pitch: 1.2, volume: 0.4, language: 'fr-FR'
289
+ });
290
+ el.speak();
291
+ const u = stub.captured.spoken[0];
292
+ if(u.rate !== 1.5 || u.pitch !== 1.2 || u.volume !== 0.4 || u.lang !== 'fr-FR'){
293
+ stub.restore();
294
+ cleanup(container);
295
+ return fail(`Utterance properties wrong: rate=${u.rate}, pitch=${u.pitch}, volume=${u.volume}, lang=${u.lang}`);
296
+ }
297
+ stub.restore();
298
+ cleanup(container);
299
+ pass('Utterance properties match component config');
300
+ },
301
+
302
+ 'should accept an override text on speak()': async ({pass, fail}) => {
303
+ const stub = installSynthesisStub();
304
+ const { container, el } = await createTextToSpeech({ text: 'attr' });
305
+ el.speak('override');
306
+ const u = stub.captured.spoken[0];
307
+ if(u.text !== 'override'){
308
+ stub.restore();
309
+ cleanup(container);
310
+ return fail(`Expected override text, got "${u.text}"`);
311
+ }
312
+ stub.restore();
313
+ cleanup(container);
314
+ pass('speak(override) uses the override text');
315
+ },
316
+
317
+ 'should not speak when text and slot are both empty': async ({pass, fail}) => {
318
+ const stub = installSynthesisStub();
319
+ const { container, el } = await createTextToSpeech();
320
+ el.speak();
321
+ if(stub.captured.spoken.length !== 0){
322
+ stub.restore();
323
+ cleanup(container);
324
+ return fail('Should not speak when there is no text');
325
+ }
326
+ stub.restore();
327
+ cleanup(container);
328
+ pass('speak() is a no-op when text is empty');
329
+ },
330
+
331
+ 'should call cancel before speaking new utterance': async ({pass, fail}) => {
332
+ const stub = installSynthesisStub();
333
+ const { container, el } = await createTextToSpeech({ text: 'one' });
334
+ el.speak();
335
+ el.speak('two');
336
+ if(stub.captured.cancelled < 2){
337
+ stub.restore();
338
+ cleanup(container);
339
+ return fail(`Expected cancel to be called before each speak, got ${stub.captured.cancelled} cancels`);
340
+ }
341
+ stub.restore();
342
+ cleanup(container);
343
+ pass('cancel runs before each new utterance');
344
+ },
345
+
346
+ 'should set speaking to true on start and false on end': async ({pass, fail}) => {
347
+ const stub = installSynthesisStub();
348
+ const { container, el } = await createTextToSpeech({ text: 'hi' });
349
+ el.speak();
350
+ // Wait for the queued onstart microtask
351
+ await Promise.resolve();
352
+ if(el.speaking !== true){
353
+ stub.restore();
354
+ cleanup(container);
355
+ return fail('speaking should be true after start');
356
+ }
357
+ el.handleEnd();
358
+ if(el.speaking !== false){
359
+ stub.restore();
360
+ cleanup(container);
361
+ return fail('speaking should be false after end');
362
+ }
363
+ stub.restore();
364
+ cleanup(container);
365
+ pass('speaking flips on start/end');
366
+ },
367
+
368
+ /*
369
+ Events
370
+ */
371
+ 'should fire start event on handleStart': async ({pass, fail}) => {
372
+ const { container, el } = await createTextToSpeech();
373
+ let fired = false;
374
+ el.addEventListener('start', () => { fired = true; });
375
+ el.handleStart();
376
+ if(!fired){
377
+ cleanup(container);
378
+ return fail('start event should fire');
379
+ }
380
+ cleanup(container);
381
+ pass('start event fires');
382
+ },
383
+
384
+ 'should fire end event on handleEnd': async ({pass, fail}) => {
385
+ const { container, el } = await createTextToSpeech();
386
+ let fired = false;
387
+ el.addEventListener('end', () => { fired = true; });
388
+ el.handleEnd();
389
+ if(!fired){
390
+ cleanup(container);
391
+ return fail('end event should fire');
392
+ }
393
+ cleanup(container);
394
+ pass('end event fires');
395
+ },
396
+
397
+ 'should fire error event with detail.error': async ({pass, fail}) => {
398
+ const { container, el } = await createTextToSpeech();
399
+ let received = null;
400
+ el.addEventListener('error', (e) => { received = e.detail; });
401
+ el.handleError({ error: 'audio-busy' });
402
+ if(!received || received.error !== 'audio-busy'){
403
+ cleanup(container);
404
+ return fail(`Expected detail.error="audio-busy", got ${JSON.stringify(received)}`);
405
+ }
406
+ cleanup(container);
407
+ pass('error event fires with detail.error');
408
+ },
409
+
410
+ /*
411
+ Browser support detection
412
+ */
413
+ 'should disable button when speechSynthesis is unsupported': async ({pass, fail}) => {
414
+ const original = {
415
+ speechSynthesis: window.speechSynthesis,
416
+ SpeechSynthesisUtterance: window.SpeechSynthesisUtterance
417
+ };
418
+ delete window.speechSynthesis;
419
+ delete window.SpeechSynthesisUtterance;
420
+ const { container, el } = await createTextToSpeech();
421
+ const btn = el.shadowRoot.querySelector('button');
422
+ const wasDisabled = btn.disabled;
423
+ if(original.speechSynthesis) window.speechSynthesis = original.speechSynthesis;
424
+ if(original.SpeechSynthesisUtterance) window.SpeechSynthesisUtterance = original.SpeechSynthesisUtterance;
425
+ if(!wasDisabled){
426
+ cleanup(container);
427
+ return fail('Button should be disabled when speechSynthesis is unsupported');
428
+ }
429
+ cleanup(container);
430
+ pass('Button auto-disables when SpeechSynthesis API is missing');
431
+ }
432
+ };