kempo-ui 0.4.4 → 0.4.6

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 (185) hide show
  1. package/dist/components/Chat.js +201 -0
  2. package/dist/components/CodeEditor.js +21 -2
  3. package/dist/components/Dropdown.js +1 -1
  4. package/dist/components/HtmlEditor.js +42 -3
  5. package/dist/components/MarkdownEditor.js +173 -0
  6. package/dist/components/Resize.js +12 -2
  7. package/dist/components/TextToSpeech.js +4 -4
  8. package/dist/components/VoiceSelector.js +49 -0
  9. package/dist/components/markdownEditorControls/Bold.js +11 -0
  10. package/dist/components/markdownEditorControls/BulletList.js +11 -0
  11. package/dist/components/markdownEditorControls/Code.js +11 -0
  12. package/dist/components/markdownEditorControls/FormatBlock.js +30 -0
  13. package/dist/components/markdownEditorControls/Heading.js +11 -0
  14. package/dist/components/markdownEditorControls/Image.js +93 -0
  15. package/dist/components/markdownEditorControls/Italic.js +11 -0
  16. package/dist/components/markdownEditorControls/Link.js +11 -0
  17. package/dist/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  18. package/dist/components/markdownEditorControls/Menu.js +30 -0
  19. package/dist/components/markdownEditorControls/NumberedList.js +11 -0
  20. package/dist/components/markdownEditorControls/Quote.js +11 -0
  21. package/dist/components/markdownEditorControls/SpeechToText.js +16 -0
  22. package/dist/components/markdownEditorControls/Strikethrough.js +11 -0
  23. package/dist/components/markdownEditorControls/Table.js +80 -0
  24. package/dist/utils/marked.esm.js +1 -0
  25. package/dist/utils/renderMarkdown.js +1 -0
  26. package/dist/utils/sanitizeHtml.js +1 -0
  27. package/dist/utils/voice.js +1 -0
  28. package/docs/components/accordion.html +19 -0
  29. package/docs/components/aside.html +19 -0
  30. package/docs/components/calendar.html +19 -0
  31. package/docs/components/card.html +19 -0
  32. package/docs/components/chat.html +715 -0
  33. package/docs/components/code-editor.html +55 -0
  34. package/docs/components/color-picker.html +19 -0
  35. package/docs/components/combobox.html +19 -0
  36. package/docs/components/content-slider.html +19 -0
  37. package/docs/components/context.html +19 -0
  38. package/docs/components/dialog.html +19 -0
  39. package/docs/components/dropdown.html +19 -0
  40. package/docs/components/filter-list.html +19 -0
  41. package/docs/components/focus-capture.html +19 -0
  42. package/docs/components/html-editor.html +60 -0
  43. package/docs/components/hybrid-component.html +19 -0
  44. package/docs/components/icon.html +19 -0
  45. package/docs/components/import.html +19 -0
  46. package/docs/components/light-component.html +19 -0
  47. package/docs/components/markdown-editor.html +924 -0
  48. package/docs/components/nav-spacer.html +19 -0
  49. package/docs/components/nav.html +19 -0
  50. package/docs/components/photo-viewer.html +19 -0
  51. package/docs/components/progress.html +19 -0
  52. package/docs/components/resize.html +22 -0
  53. package/docs/components/rich-textarea.html +650 -0
  54. package/docs/components/shadow-component.html +19 -0
  55. package/docs/components/show-more.html +19 -0
  56. package/docs/components/slider.html +19 -0
  57. package/docs/components/sortable.html +19 -0
  58. package/docs/components/speech-to-text.html +19 -0
  59. package/docs/components/spinner.html +19 -0
  60. package/docs/components/split.html +19 -0
  61. package/docs/components/table.html +19 -0
  62. package/docs/components/tableControls.html +19 -0
  63. package/docs/components/tableCustomFields.html +19 -0
  64. package/docs/components/tableFetchRecords.html +19 -0
  65. package/docs/components/tableFieldSortHide.html +19 -0
  66. package/docs/components/tablePagination.html +19 -0
  67. package/docs/components/tablePlaceholder.html +19 -0
  68. package/docs/components/tableRecordEditing.html +19 -0
  69. package/docs/components/tableRecordFiltering.html +19 -0
  70. package/docs/components/tableRecordHiding.html +19 -0
  71. package/docs/components/tableRecordSearching.html +19 -0
  72. package/docs/components/tableRecordSelection.html +19 -0
  73. package/docs/components/tableRowControls.html +19 -0
  74. package/docs/components/tableServerSync.html +19 -0
  75. package/docs/components/tableSorting.html +19 -0
  76. package/docs/components/tabs.html +19 -0
  77. package/docs/components/tags.html +19 -0
  78. package/docs/components/text-to-speech.html +63 -1
  79. package/docs/components/theme-select.html +19 -0
  80. package/docs/components/theme-switcher.html +19 -0
  81. package/docs/components/time.html +19 -0
  82. package/docs/components/timestamp.html +19 -0
  83. package/docs/components/toast.html +19 -0
  84. package/docs/components/toggle.html +19 -0
  85. package/docs/components/tree.html +19 -0
  86. package/docs/components/voice-selector.html +610 -0
  87. package/docs/icons/done_all.svg +1 -0
  88. package/docs/icons/format_quote.svg +1 -0
  89. package/docs/icons/format_strikethrough.svg +1 -0
  90. package/docs/icons/send.svg +1 -0
  91. package/docs/icons/text_fields.svg +1 -0
  92. package/docs/index.html +49 -0
  93. package/docs/src/components/Chat.js +201 -0
  94. package/docs/src/components/CodeEditor.js +21 -2
  95. package/docs/src/components/Dropdown.js +1 -1
  96. package/docs/src/components/HtmlEditor.js +42 -3
  97. package/docs/src/components/MarkdownEditor.js +173 -0
  98. package/docs/src/components/Resize.js +12 -2
  99. package/docs/src/components/TextToSpeech.js +4 -4
  100. package/docs/src/components/VoiceSelector.js +49 -0
  101. package/docs/src/components/markdownEditorControls/Bold.js +11 -0
  102. package/docs/src/components/markdownEditorControls/BulletList.js +11 -0
  103. package/docs/src/components/markdownEditorControls/Code.js +11 -0
  104. package/docs/src/components/markdownEditorControls/FormatBlock.js +30 -0
  105. package/docs/src/components/markdownEditorControls/Heading.js +11 -0
  106. package/docs/src/components/markdownEditorControls/Image.js +93 -0
  107. package/docs/src/components/markdownEditorControls/Italic.js +11 -0
  108. package/docs/src/components/markdownEditorControls/Link.js +11 -0
  109. package/docs/src/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  110. package/docs/src/components/markdownEditorControls/Menu.js +30 -0
  111. package/docs/src/components/markdownEditorControls/NumberedList.js +11 -0
  112. package/docs/src/components/markdownEditorControls/Quote.js +11 -0
  113. package/docs/src/components/markdownEditorControls/SpeechToText.js +16 -0
  114. package/docs/src/components/markdownEditorControls/Strikethrough.js +11 -0
  115. package/docs/src/components/markdownEditorControls/Table.js +80 -0
  116. package/docs/src/utils/marked.esm.js +1 -0
  117. package/docs/src/utils/renderMarkdown.js +1 -0
  118. package/docs/src/utils/sanitizeHtml.js +1 -0
  119. package/docs/src/utils/voice.js +1 -0
  120. package/docs/utils/context.html +19 -0
  121. package/docs/utils/cookie.html +19 -0
  122. package/docs/utils/debounce.html +19 -0
  123. package/docs/utils/drag.html +19 -0
  124. package/docs/utils/elevation.html +23 -10
  125. package/docs/utils/formatTimestamp.html +19 -0
  126. package/docs/utils/object.html +19 -0
  127. package/docs/utils/propConverters.html +19 -0
  128. package/docs/utils/sanitizeHtml.html +555 -0
  129. package/docs/utils/string.html +19 -0
  130. package/docs/utils/theme.html +19 -0
  131. package/docs/utils/toTitleCase.html +19 -0
  132. package/docs/utils/type.html +19 -0
  133. package/docs/utils/voice.html +548 -0
  134. package/docs/utils/wait.html +19 -0
  135. package/docs-src/components/chat.page.html +230 -0
  136. package/docs-src/components/code-editor.page.html +36 -0
  137. package/docs-src/components/html-editor.page.html +41 -0
  138. package/docs-src/components/markdown-editor.page.html +439 -0
  139. package/docs-src/components/resize.page.html +3 -0
  140. package/docs-src/components/text-to-speech.page.html +44 -1
  141. package/docs-src/components/voice-selector.page.html +125 -0
  142. package/docs-src/index.page.html +30 -0
  143. package/docs-src/nav.fragment.html +19 -0
  144. package/docs-src/utils/elevation.page.html +4 -10
  145. package/docs-src/utils/sanitizeHtml.page.html +73 -0
  146. package/docs-src/utils/voice.page.html +66 -0
  147. package/icons/done_all.svg +1 -0
  148. package/icons/format_quote.svg +1 -0
  149. package/icons/format_strikethrough.svg +1 -0
  150. package/icons/send.svg +1 -0
  151. package/icons/text_fields.svg +1 -0
  152. package/llms.txt +7 -2
  153. package/package.json +3 -2
  154. package/src/components/Chat.js +435 -0
  155. package/src/components/CodeEditor.js +58 -2
  156. package/src/components/Dropdown.js +1 -1
  157. package/src/components/HtmlEditor.js +84 -4
  158. package/src/components/MarkdownEditor.js +654 -0
  159. package/src/components/Resize.js +17 -1
  160. package/src/components/TextToSpeech.js +13 -2
  161. package/src/components/VoiceSelector.js +271 -0
  162. package/src/components/markdownEditorControls/Bold.js +30 -0
  163. package/src/components/markdownEditorControls/BulletList.js +30 -0
  164. package/src/components/markdownEditorControls/Code.js +39 -0
  165. package/src/components/markdownEditorControls/FormatBlock.js +102 -0
  166. package/src/components/markdownEditorControls/Heading.js +45 -0
  167. package/src/components/markdownEditorControls/Image.js +250 -0
  168. package/src/components/markdownEditorControls/Italic.js +30 -0
  169. package/src/components/markdownEditorControls/Link.js +74 -0
  170. package/src/components/markdownEditorControls/MarkdownEditorControl.js +158 -0
  171. package/src/components/markdownEditorControls/Menu.js +86 -0
  172. package/src/components/markdownEditorControls/NumberedList.js +69 -0
  173. package/src/components/markdownEditorControls/Quote.js +30 -0
  174. package/src/components/markdownEditorControls/SpeechToText.js +65 -0
  175. package/src/components/markdownEditorControls/Strikethrough.js +35 -0
  176. package/src/components/markdownEditorControls/Table.js +164 -0
  177. package/src/utils/marked.esm.js +77 -0
  178. package/src/utils/renderMarkdown.js +29 -0
  179. package/src/utils/sanitizeHtml.js +137 -0
  180. package/src/utils/voice.js +103 -0
  181. package/tests/components/Chat.browser-test.js +540 -0
  182. package/tests/components/TextToSpeech.browser-test.js +150 -1
  183. package/tests/components/VoiceSelector.browser-test.js +374 -0
  184. package/tests/utils/sanitizeHtml.browser-test.js +179 -0
  185. package/tests/utils/voice.browser-test.js +128 -0
@@ -1,4 +1,5 @@
1
1
  import TextToSpeech from '../../src/components/TextToSpeech.js';
2
+ import { setVoice } from '../../src/utils/voice.js';
2
3
 
3
4
  const createTextToSpeech = async (attrs = {}, slot = '') => {
4
5
  const container = document.createElement('div');
@@ -26,7 +27,7 @@ const cleanup = (container) => {
26
27
  // Stub window.speechSynthesis (which is a read-only getter in Chrome) by
27
28
  // patching its methods on the live object. SpeechSynthesisUtterance is a
28
29
  // constructor, swapped via Object.defineProperty.
29
- const installSynthesisStub = () => {
30
+ const installSynthesisStub = (voicesList = []) => {
30
31
  const captured = {
31
32
  spoken: [],
32
33
  cancelled: 0
@@ -34,6 +35,7 @@ const installSynthesisStub = () => {
34
35
  const synth = window.speechSynthesis;
35
36
  const originalSpeak = synth.speak.bind(synth);
36
37
  const originalCancel = synth.cancel.bind(synth);
38
+ const originalGetVoices = synth.getVoices.bind(synth);
37
39
  synth.speak = (utterance) => {
38
40
  captured.spoken.push(utterance);
39
41
  queueMicrotask(() => {
@@ -41,6 +43,7 @@ const installSynthesisStub = () => {
41
43
  });
42
44
  };
43
45
  synth.cancel = () => { captured.cancelled++; };
46
+ synth.getVoices = () => voicesList;
44
47
 
45
48
  const OriginalUtterance = window.SpeechSynthesisUtterance;
46
49
  function StubUtterance(text){
@@ -65,6 +68,7 @@ const installSynthesisStub = () => {
65
68
  restore: () => {
66
69
  synth.speak = originalSpeak;
67
70
  synth.cancel = originalCancel;
71
+ synth.getVoices = originalGetVoices;
68
72
  Object.defineProperty(window, 'SpeechSynthesisUtterance', {
69
73
  value: OriginalUtterance,
70
74
  configurable: true,
@@ -407,6 +411,151 @@ export default {
407
411
  pass('error event fires with detail.error');
408
412
  },
409
413
 
414
+ /*
415
+ Voice Fallback Chain
416
+ */
417
+ 'should pick first matching voice from a fallback chain': async ({pass, fail}) => {
418
+ const stub = installSynthesisStub([
419
+ { name: 'Google US English' },
420
+ { name: 'Microsoft Zira' }
421
+ ]);
422
+ const { container, el } = await createTextToSpeech({
423
+ text: 'hi',
424
+ voice: 'Samantha, Google US English, Microsoft Zira'
425
+ });
426
+ el.speak();
427
+ const u = stub.captured.spoken[0];
428
+ if(!u.voice || u.voice.name !== 'Google US English'){
429
+ stub.restore();
430
+ cleanup(container);
431
+ return fail(`Expected voice "Google US English", got ${u.voice ? u.voice.name : 'null'}`);
432
+ }
433
+ stub.restore();
434
+ cleanup(container);
435
+ pass('First installed voice in chain is selected');
436
+ },
437
+
438
+ 'should fall through earlier missing entries in the chain': async ({pass, fail}) => {
439
+ const stub = installSynthesisStub([
440
+ { name: 'Microsoft Zira' }
441
+ ]);
442
+ const { container, el } = await createTextToSpeech({
443
+ text: 'hi',
444
+ voice: 'Samantha, Google US English, Microsoft Zira'
445
+ });
446
+ el.speak();
447
+ const u = stub.captured.spoken[0];
448
+ if(!u.voice || u.voice.name !== 'Microsoft Zira'){
449
+ stub.restore();
450
+ cleanup(container);
451
+ return fail(`Expected voice "Microsoft Zira", got ${u.voice ? u.voice.name : 'null'}`);
452
+ }
453
+ stub.restore();
454
+ cleanup(container);
455
+ pass('Falls through to a later installed voice');
456
+ },
457
+
458
+ 'should not set voice when no candidate matches': async ({pass, fail}) => {
459
+ const stub = installSynthesisStub([
460
+ { name: 'Karen' }
461
+ ]);
462
+ const { container, el } = await createTextToSpeech({
463
+ text: 'hi',
464
+ voice: 'Samantha, Google US English'
465
+ });
466
+ el.speak();
467
+ const u = stub.captured.spoken[0];
468
+ if(u.voice !== null){
469
+ stub.restore();
470
+ cleanup(container);
471
+ return fail(`Expected voice null when no chain entry matches, got ${u.voice && u.voice.name}`);
472
+ }
473
+ stub.restore();
474
+ cleanup(container);
475
+ pass('voice stays null when no chain entry is installed');
476
+ },
477
+
478
+ 'should ignore extra whitespace around chain entries': async ({pass, fail}) => {
479
+ const stub = installSynthesisStub([
480
+ { name: 'Google US English' }
481
+ ]);
482
+ const { container, el } = await createTextToSpeech({
483
+ text: 'hi',
484
+ voice: ' Samantha , Google US English '
485
+ });
486
+ el.speak();
487
+ const u = stub.captured.spoken[0];
488
+ if(!u.voice || u.voice.name !== 'Google US English'){
489
+ stub.restore();
490
+ cleanup(container);
491
+ return fail(`Expected voice "Google US English" after trim, got ${u.voice && u.voice.name}`);
492
+ }
493
+ stub.restore();
494
+ cleanup(container);
495
+ pass('Whitespace around comma-separated entries is ignored');
496
+ },
497
+
498
+ 'should fall back to saved voice preference when no voice attr is set': async ({pass, fail}) => {
499
+ const stub = installSynthesisStub([
500
+ { name: 'Karen', lang: 'en-AU' }
501
+ ]);
502
+ setVoice('Karen');
503
+ const { container, el } = await createTextToSpeech({ text: 'hi' });
504
+ el.speak();
505
+ const u = stub.captured.spoken[0];
506
+ if(!u.voice || u.voice.name !== 'Karen'){
507
+ stub.restore();
508
+ setVoice('');
509
+ cleanup(container);
510
+ return fail(`Expected fallback to saved voice "Karen", got ${u.voice && u.voice.name}`);
511
+ }
512
+ stub.restore();
513
+ setVoice('');
514
+ cleanup(container);
515
+ pass('Saved voice preference is used when voice attr is empty');
516
+ },
517
+
518
+ 'should ignore saved voice preference when voice attr is set': async ({pass, fail}) => {
519
+ const stub = installSynthesisStub([
520
+ { name: 'Karen', lang: 'en-AU' },
521
+ { name: 'Samantha', lang: 'en-US' }
522
+ ]);
523
+ setVoice('Karen');
524
+ const { container, el } = await createTextToSpeech({ text: 'hi', voice: 'Samantha' });
525
+ el.speak();
526
+ const u = stub.captured.spoken[0];
527
+ if(!u.voice || u.voice.name !== 'Samantha'){
528
+ stub.restore();
529
+ setVoice('');
530
+ cleanup(container);
531
+ return fail(`Expected explicit voice "Samantha" to win over saved "Karen", got ${u.voice && u.voice.name}`);
532
+ }
533
+ stub.restore();
534
+ setVoice('');
535
+ cleanup(container);
536
+ pass('Explicit voice attr overrides saved preference');
537
+ },
538
+
539
+ 'should still match a single voice name with no commas': async ({pass, fail}) => {
540
+ const stub = installSynthesisStub([
541
+ { name: 'Samantha' }
542
+ ]);
543
+ const { container, el } = await createTextToSpeech({
544
+ text: 'hi',
545
+ voice: 'Samantha'
546
+ });
547
+ el.speak();
548
+ const u = stub.captured.spoken[0];
549
+ if(!u.voice || u.voice.name !== 'Samantha'){
550
+ stub.restore();
551
+ cleanup(container);
552
+ return fail(`Expected voice "Samantha", got ${u.voice && u.voice.name}`);
553
+ }
554
+ stub.restore();
555
+ cleanup(container);
556
+ pass('Single voice name still works');
557
+ },
558
+
410
559
  /*
411
560
  Browser support detection
412
561
  */
@@ -0,0 +1,374 @@
1
+ import VoiceSelector from '../../src/components/VoiceSelector.js';
2
+ import { setVoice } from '../../src/utils/voice.js';
3
+
4
+ const FAKE_VOICES = [
5
+ { name: 'Samantha', lang: 'en-US' },
6
+ { name: 'Alex', lang: 'en-US' },
7
+ { name: 'Daniel', lang: 'en-GB' },
8
+ { name: 'Amelie', lang: 'fr-FR' },
9
+ { name: 'Thomas', lang: 'fr-FR' },
10
+ { name: 'Monica', lang: 'es-ES' }
11
+ ];
12
+
13
+ const installVoicesStub = (voiceList = FAKE_VOICES) => {
14
+ const synth = window.speechSynthesis;
15
+ const original = synth.getVoices.bind(synth);
16
+ synth.getVoices = () => voiceList;
17
+ return {
18
+ restore: () => { synth.getVoices = original; }
19
+ };
20
+ };
21
+
22
+ const createVoiceSelector = async (attrs = {}) => {
23
+ const container = document.createElement('div');
24
+ const parts = [];
25
+ if(attrs.language !== undefined) parts.push(`language="${attrs.language}"`);
26
+ if(attrs.placeholder !== undefined) parts.push(`placeholder="${attrs.placeholder}"`);
27
+ if(attrs.disabled) parts.push('disabled');
28
+ container.innerHTML = `<k-voice-selector ${parts.join(' ')}></k-voice-selector>`;
29
+ document.body.appendChild(container);
30
+ const el = container.querySelector('k-voice-selector');
31
+ await el.updateComplete;
32
+ return { container, el };
33
+ };
34
+
35
+ const langSelect = (el) => el.shadowRoot.querySelector('select.lang');
36
+ const voiceSelect = (el) => el.shadowRoot.querySelector('select.voice');
37
+ const optionValues = (sel) => [...sel.querySelectorAll('option')].map(o => o.value);
38
+
39
+ const cleanup = (container) => {
40
+ if(container && container.parentNode){
41
+ container.parentNode.removeChild(container);
42
+ }
43
+ };
44
+
45
+ const reset = () => setVoice('');
46
+
47
+ export default {
48
+ /*
49
+ Element Creation
50
+ */
51
+ 'should create voice-selector element': async ({pass, fail}) => {
52
+ reset();
53
+ const stub = installVoicesStub();
54
+ const { container, el } = await createVoiceSelector();
55
+ if(!(el instanceof VoiceSelector)){
56
+ stub.restore();
57
+ cleanup(container);
58
+ return fail('Element should be instance of VoiceSelector');
59
+ }
60
+ stub.restore();
61
+ cleanup(container);
62
+ pass('VoiceSelector created');
63
+ },
64
+
65
+ 'should render two select elements (language + voice)': async ({pass, fail}) => {
66
+ reset();
67
+ const stub = installVoicesStub();
68
+ const { container, el } = await createVoiceSelector();
69
+ if(!langSelect(el) || !voiceSelect(el)){
70
+ stub.restore();
71
+ cleanup(container);
72
+ return fail('Should render both .lang and .voice selects');
73
+ }
74
+ stub.restore();
75
+ cleanup(container);
76
+ pass('Both selects rendered');
77
+ },
78
+
79
+ /*
80
+ Language Dropdown Population
81
+ */
82
+ 'should populate language dropdown with distinct primary languages': async ({pass, fail}) => {
83
+ reset();
84
+ const stub = installVoicesStub();
85
+ const { container, el } = await createVoiceSelector();
86
+ el.refresh();
87
+ await el.updateComplete;
88
+ const langs = optionValues(langSelect(el));
89
+ if(!langs.includes('en') || !langs.includes('fr') || !langs.includes('es')){
90
+ stub.restore();
91
+ cleanup(container);
92
+ return fail(`Expected en/fr/es in language list, got ${JSON.stringify(langs)}`);
93
+ }
94
+ // Should de-dupe en-US + en-GB → "en"
95
+ const enCount = langs.filter(l => l === 'en').length;
96
+ if(enCount !== 1){
97
+ stub.restore();
98
+ cleanup(container);
99
+ return fail(`Expected 'en' only once, got ${enCount}`);
100
+ }
101
+ stub.restore();
102
+ cleanup(container);
103
+ pass('Language dropdown shows distinct primary languages');
104
+ },
105
+
106
+ /*
107
+ Language Display (localized + native names)
108
+ */
109
+ 'should display each language name with its native name appended': async ({pass, fail}) => {
110
+ reset();
111
+ const stub = installVoicesStub();
112
+ const { container, el } = await createVoiceSelector();
113
+ el.refresh();
114
+ await el.updateComplete;
115
+ const options = [...langSelect(el).querySelectorAll('option')];
116
+ const spanishOption = options.find(o => o.value === 'es');
117
+ const frenchOption = options.find(o => o.value === 'fr');
118
+ if(!spanishOption || !spanishOption.textContent.includes('Español')){
119
+ stub.restore();
120
+ cleanup(container);
121
+ return fail(`Expected Spanish option to include "Español", got "${spanishOption && spanishOption.textContent}"`);
122
+ }
123
+ if(!frenchOption || !frenchOption.textContent.toLowerCase().includes('français')){
124
+ stub.restore();
125
+ cleanup(container);
126
+ return fail(`Expected French option to include "Français", got "${frenchOption && frenchOption.textContent}"`);
127
+ }
128
+ stub.restore();
129
+ cleanup(container);
130
+ pass('Language options include native names');
131
+ },
132
+
133
+ 'should not duplicate when localized and native names match': async ({pass, fail}) => {
134
+ reset();
135
+ const stub = installVoicesStub([{ name: 'EN Voice', lang: 'en-US' }]);
136
+ const { container, el } = await createVoiceSelector();
137
+ el.refresh();
138
+ await el.updateComplete;
139
+ const enOption = [...langSelect(el).querySelectorAll('option')].find(o => o.value === 'en');
140
+ if(!enOption){
141
+ stub.restore();
142
+ cleanup(container);
143
+ return fail('Should have an English option');
144
+ }
145
+ // If user's locale is English, "English (English)" would be silly — verify we drop the duplicate.
146
+ const userLocale = (navigator.language || '').toLowerCase();
147
+ if(userLocale.startsWith('en')){
148
+ if(enOption.textContent.includes('(English)')){
149
+ stub.restore();
150
+ cleanup(container);
151
+ return fail(`Expected "English" without duplicate parenthetical, got "${enOption.textContent}"`);
152
+ }
153
+ }
154
+ stub.restore();
155
+ cleanup(container);
156
+ pass('Localized and native names are deduplicated when identical');
157
+ },
158
+
159
+ /*
160
+ Voice Dropdown Filtering
161
+ */
162
+ 'should populate voice dropdown for current language': async ({pass, fail}) => {
163
+ reset();
164
+ const stub = installVoicesStub();
165
+ const { container, el } = await createVoiceSelector({ language: 'en' });
166
+ el.refresh();
167
+ await el.updateComplete;
168
+ const voices = optionValues(voiceSelect(el)).filter(Boolean);
169
+ if(!voices.includes('Samantha') || !voices.includes('Daniel') || voices.includes('Amelie')){
170
+ stub.restore();
171
+ cleanup(container);
172
+ return fail(`Expected en-* voices only, got ${JSON.stringify(voices)}`);
173
+ }
174
+ stub.restore();
175
+ cleanup(container);
176
+ pass('Voice dropdown filtered by current language');
177
+ },
178
+
179
+ 'should switch voice list when language dropdown changes': async ({pass, fail}) => {
180
+ reset();
181
+ const stub = installVoicesStub();
182
+ const { container, el } = await createVoiceSelector({ language: 'en' });
183
+ el.refresh();
184
+ await el.updateComplete;
185
+ const lang = langSelect(el);
186
+ lang.value = 'fr';
187
+ lang.dispatchEvent(new Event('change', { bubbles: true }));
188
+ await el.updateComplete;
189
+ const voices = optionValues(voiceSelect(el)).filter(Boolean);
190
+ if(!voices.includes('Amelie') || !voices.includes('Thomas') || voices.includes('Samantha')){
191
+ stub.restore();
192
+ cleanup(container);
193
+ return fail(`Expected fr-* voices after change, got ${JSON.stringify(voices)}`);
194
+ }
195
+ stub.restore();
196
+ cleanup(container);
197
+ pass('Voice list reflows when language changes');
198
+ },
199
+
200
+ 'should include placeholder option in voice dropdown': async ({pass, fail}) => {
201
+ reset();
202
+ const stub = installVoicesStub();
203
+ const { container, el } = await createVoiceSelector({ language: 'en' });
204
+ el.refresh();
205
+ await el.updateComplete;
206
+ const first = voiceSelect(el).querySelector('option');
207
+ if(first.value !== '' || !first.textContent.includes('Browser default')){
208
+ stub.restore();
209
+ cleanup(container);
210
+ return fail(`First voice option should be the placeholder, got value="${first.value}" text="${first.textContent}"`);
211
+ }
212
+ stub.restore();
213
+ cleanup(container);
214
+ pass('Placeholder option present in voice dropdown');
215
+ },
216
+
217
+ /*
218
+ Default language from navigator
219
+ */
220
+ 'should default current language from navigator.language when no attr': async ({pass, fail}) => {
221
+ reset();
222
+ const stub = installVoicesStub();
223
+ // Confine test to languages that exist in the fake list
224
+ const expected = (navigator.language || 'en').split('-')[0].toLowerCase();
225
+ const fallback = ['en', 'fr', 'es'].includes(expected) ? expected : 'en';
226
+ const { container, el } = await createVoiceSelector();
227
+ el.refresh();
228
+ await el.updateComplete;
229
+ if(el.currentLang !== fallback){
230
+ stub.restore();
231
+ cleanup(container);
232
+ return fail(`Expected currentLang "${fallback}" (from navigator), got "${el.currentLang}"`);
233
+ }
234
+ stub.restore();
235
+ cleanup(container);
236
+ pass('Default language picked from navigator');
237
+ },
238
+
239
+ /*
240
+ Persistence
241
+ */
242
+ 'should persist voice via voice utility on change': async ({pass, fail}) => {
243
+ reset();
244
+ const stub = installVoicesStub();
245
+ const { container, el } = await createVoiceSelector({ language: 'en' });
246
+ el.refresh();
247
+ await el.updateComplete;
248
+ const sel = voiceSelect(el);
249
+ sel.value = 'Alex';
250
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
251
+ await el.updateComplete;
252
+ if(localStorage.getItem('k-voice') !== 'Alex'){
253
+ stub.restore();
254
+ cleanup(container);
255
+ reset();
256
+ return fail(`Expected localStorage k-voice="Alex", got "${localStorage.getItem('k-voice')}"`);
257
+ }
258
+ stub.restore();
259
+ cleanup(container);
260
+ reset();
261
+ pass('Selecting a voice persists via voice util');
262
+ },
263
+
264
+ 'should clear voice selection when changing to a language without that voice': async ({pass, fail}) => {
265
+ reset();
266
+ const stub = installVoicesStub();
267
+ const { container, el } = await createVoiceSelector({ language: 'en' });
268
+ el.refresh();
269
+ await el.updateComplete;
270
+ const v = voiceSelect(el);
271
+ v.value = 'Alex';
272
+ v.dispatchEvent(new Event('change', { bubbles: true }));
273
+ await el.updateComplete;
274
+ const lang = langSelect(el);
275
+ lang.value = 'fr';
276
+ lang.dispatchEvent(new Event('change', { bubbles: true }));
277
+ await el.updateComplete;
278
+ if(localStorage.getItem('k-voice') !== null){
279
+ stub.restore();
280
+ cleanup(container);
281
+ reset();
282
+ return fail(`Expected k-voice cleared after switching language, got "${localStorage.getItem('k-voice')}"`);
283
+ }
284
+ stub.restore();
285
+ cleanup(container);
286
+ reset();
287
+ pass('Switching to a language that does not contain the selected voice clears it');
288
+ },
289
+
290
+ 'should reflect external voice changes in the voice dropdown': async ({pass, fail}) => {
291
+ reset();
292
+ const stub = installVoicesStub();
293
+ const { container, el } = await createVoiceSelector({ language: 'en' });
294
+ el.refresh();
295
+ await el.updateComplete;
296
+ setVoice('Daniel');
297
+ await el.updateComplete;
298
+ if(voiceSelect(el).value !== 'Daniel'){
299
+ stub.restore();
300
+ cleanup(container);
301
+ reset();
302
+ return fail(`Expected voice select.value "Daniel", got "${voiceSelect(el).value}"`);
303
+ }
304
+ stub.restore();
305
+ cleanup(container);
306
+ reset();
307
+ pass('External voice updates reflect in voice dropdown');
308
+ },
309
+
310
+ 'should sync currentLang when external voice is in a different language': async ({pass, fail}) => {
311
+ reset();
312
+ const stub = installVoicesStub();
313
+ const { container, el } = await createVoiceSelector({ language: 'en' });
314
+ el.refresh();
315
+ await el.updateComplete;
316
+ setVoice('Amelie');
317
+ await el.updateComplete;
318
+ if(el.currentLang !== 'fr'){
319
+ stub.restore();
320
+ cleanup(container);
321
+ reset();
322
+ return fail(`Expected currentLang to switch to "fr", got "${el.currentLang}"`);
323
+ }
324
+ stub.restore();
325
+ cleanup(container);
326
+ reset();
327
+ pass('External voice in different language updates language dropdown');
328
+ },
329
+
330
+ /*
331
+ Events
332
+ */
333
+ 'should fire change event with detail.voice and detail.language': async ({pass, fail}) => {
334
+ reset();
335
+ const stub = installVoicesStub();
336
+ const { container, el } = await createVoiceSelector({ language: 'en' });
337
+ el.refresh();
338
+ await el.updateComplete;
339
+ let received = null;
340
+ el.addEventListener('change', (e) => { received = e.detail; });
341
+ const sel = voiceSelect(el);
342
+ sel.value = 'Samantha';
343
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
344
+ if(!received || received.voice !== 'Samantha' || received.language !== 'en'){
345
+ stub.restore();
346
+ cleanup(container);
347
+ reset();
348
+ return fail(`Expected detail {voice:"Samantha", language:"en"}, got ${JSON.stringify(received)}`);
349
+ }
350
+ stub.restore();
351
+ cleanup(container);
352
+ reset();
353
+ pass('change event fires with detail.voice and detail.language');
354
+ },
355
+
356
+ /*
357
+ Disabled
358
+ */
359
+ 'should disable both selects when disabled attribute is set': async ({pass, fail}) => {
360
+ reset();
361
+ const stub = installVoicesStub();
362
+ const { container, el } = await createVoiceSelector({ disabled: true, language: 'en' });
363
+ el.refresh();
364
+ await el.updateComplete;
365
+ if(!langSelect(el).disabled || !voiceSelect(el).disabled){
366
+ stub.restore();
367
+ cleanup(container);
368
+ return fail('Both selects should be disabled');
369
+ }
370
+ stub.restore();
371
+ cleanup(container);
372
+ pass('Both selects honor disabled attribute');
373
+ }
374
+ };