kempo-ui 0.4.4 → 0.4.5

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 (90) hide show
  1. package/dist/components/TextToSpeech.js +4 -4
  2. package/dist/components/VoiceSelector.js +49 -0
  3. package/dist/utils/voice.js +1 -0
  4. package/docs/components/accordion.html +7 -0
  5. package/docs/components/aside.html +7 -0
  6. package/docs/components/calendar.html +7 -0
  7. package/docs/components/card.html +7 -0
  8. package/docs/components/code-editor.html +7 -0
  9. package/docs/components/color-picker.html +7 -0
  10. package/docs/components/combobox.html +7 -0
  11. package/docs/components/content-slider.html +7 -0
  12. package/docs/components/context.html +7 -0
  13. package/docs/components/dialog.html +7 -0
  14. package/docs/components/dropdown.html +7 -0
  15. package/docs/components/filter-list.html +7 -0
  16. package/docs/components/focus-capture.html +7 -0
  17. package/docs/components/html-editor.html +7 -0
  18. package/docs/components/hybrid-component.html +7 -0
  19. package/docs/components/icon.html +7 -0
  20. package/docs/components/import.html +7 -0
  21. package/docs/components/light-component.html +7 -0
  22. package/docs/components/nav-spacer.html +7 -0
  23. package/docs/components/nav.html +7 -0
  24. package/docs/components/photo-viewer.html +7 -0
  25. package/docs/components/progress.html +7 -0
  26. package/docs/components/resize.html +7 -0
  27. package/docs/components/shadow-component.html +7 -0
  28. package/docs/components/show-more.html +7 -0
  29. package/docs/components/slider.html +7 -0
  30. package/docs/components/sortable.html +7 -0
  31. package/docs/components/speech-to-text.html +7 -0
  32. package/docs/components/spinner.html +7 -0
  33. package/docs/components/split.html +7 -0
  34. package/docs/components/table.html +7 -0
  35. package/docs/components/tableControls.html +7 -0
  36. package/docs/components/tableCustomFields.html +7 -0
  37. package/docs/components/tableFetchRecords.html +7 -0
  38. package/docs/components/tableFieldSortHide.html +7 -0
  39. package/docs/components/tablePagination.html +7 -0
  40. package/docs/components/tablePlaceholder.html +7 -0
  41. package/docs/components/tableRecordEditing.html +7 -0
  42. package/docs/components/tableRecordFiltering.html +7 -0
  43. package/docs/components/tableRecordHiding.html +7 -0
  44. package/docs/components/tableRecordSearching.html +7 -0
  45. package/docs/components/tableRecordSelection.html +7 -0
  46. package/docs/components/tableRowControls.html +7 -0
  47. package/docs/components/tableServerSync.html +7 -0
  48. package/docs/components/tableSorting.html +7 -0
  49. package/docs/components/tabs.html +7 -0
  50. package/docs/components/tags.html +7 -0
  51. package/docs/components/text-to-speech.html +51 -1
  52. package/docs/components/theme-select.html +7 -0
  53. package/docs/components/theme-switcher.html +7 -0
  54. package/docs/components/time.html +7 -0
  55. package/docs/components/timestamp.html +7 -0
  56. package/docs/components/toast.html +7 -0
  57. package/docs/components/toggle.html +7 -0
  58. package/docs/components/tree.html +7 -0
  59. package/docs/components/voice-selector.html +598 -0
  60. package/docs/index.html +19 -0
  61. package/docs/src/components/TextToSpeech.js +4 -4
  62. package/docs/src/components/VoiceSelector.js +49 -0
  63. package/docs/src/utils/voice.js +1 -0
  64. package/docs/utils/context.html +7 -0
  65. package/docs/utils/cookie.html +7 -0
  66. package/docs/utils/debounce.html +7 -0
  67. package/docs/utils/drag.html +7 -0
  68. package/docs/utils/elevation.html +7 -0
  69. package/docs/utils/formatTimestamp.html +7 -0
  70. package/docs/utils/object.html +7 -0
  71. package/docs/utils/propConverters.html +7 -0
  72. package/docs/utils/string.html +7 -0
  73. package/docs/utils/theme.html +7 -0
  74. package/docs/utils/toTitleCase.html +7 -0
  75. package/docs/utils/type.html +7 -0
  76. package/docs/utils/voice.html +536 -0
  77. package/docs/utils/wait.html +7 -0
  78. package/docs-src/components/text-to-speech.page.html +44 -1
  79. package/docs-src/components/voice-selector.page.html +125 -0
  80. package/docs-src/index.page.html +12 -0
  81. package/docs-src/nav.fragment.html +7 -0
  82. package/docs-src/utils/voice.page.html +66 -0
  83. package/llms.txt +2 -0
  84. package/package.json +1 -1
  85. package/src/components/TextToSpeech.js +13 -2
  86. package/src/components/VoiceSelector.js +271 -0
  87. package/src/utils/voice.js +103 -0
  88. package/tests/components/TextToSpeech.browser-test.js +150 -1
  89. package/tests/components/VoiceSelector.browser-test.js +374 -0
  90. package/tests/utils/voice.browser-test.js +128 -0
@@ -0,0 +1,271 @@
1
+ import { html, css } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import {
4
+ getVoice,
5
+ setVoice,
6
+ subscribeToVoice,
7
+ subscribeToAvailableVoices
8
+ } from '../utils/voice.js';
9
+
10
+ const primaryLang = (tag) => (tag || '').split('-')[0].toLowerCase();
11
+
12
+ const browserLanguage = () => {
13
+ if(typeof navigator === 'undefined') return 'en';
14
+ return primaryLang(navigator.language || 'en') || 'en';
15
+ };
16
+
17
+ let displayNames = null;
18
+ try {
19
+ if(typeof Intl !== 'undefined' && Intl.DisplayNames){
20
+ const locale = (typeof navigator !== 'undefined' && navigator.language) || 'en';
21
+ displayNames = new Intl.DisplayNames([locale], { type: 'language' });
22
+ }
23
+ } catch(e) {}
24
+
25
+ // Cache for native-name lookups (Intl.DisplayNames in the language's own locale)
26
+ // so we don't construct a new instance per option per render.
27
+ const nativeNameCache = new Map();
28
+ const getNativeName = (code) => {
29
+ if(nativeNameCache.has(code)) return nativeNameCache.get(code);
30
+ let native = '';
31
+ try {
32
+ if(typeof Intl !== 'undefined' && Intl.DisplayNames){
33
+ const dn = new Intl.DisplayNames([code], { type: 'language' });
34
+ native = dn.of(code) || '';
35
+ }
36
+ } catch(e) {}
37
+ // Capitalize the first character so dropdown labels look consistent —
38
+ // Intl.DisplayNames returns "español"/"français" lowercase, which looks
39
+ // off next to the capitalized localized name.
40
+ if(native){
41
+ native = native.charAt(0).toUpperCase() + native.slice(1);
42
+ }
43
+ nativeNameCache.set(code, native);
44
+ return native;
45
+ };
46
+
47
+ // Render a language code as "Localized (Native)" — e.g. "Spanish (Español)",
48
+ // "Japanese (日本語)". The native name is a deliberate accessibility escape
49
+ // hatch: if the user accidentally has the page in a language they can't read,
50
+ // they can still recognize their own language by its native name. When the
51
+ // localized and native names match (user already in that language) we skip
52
+ // the duplicate.
53
+ const formatLanguage = (code) => {
54
+ let localized = code;
55
+ if(displayNames){
56
+ try {
57
+ localized = displayNames.of(code) || code;
58
+ } catch(e) {}
59
+ }
60
+ const native = getNativeName(code);
61
+ if(native && native.toLowerCase() !== localized.toLowerCase()){
62
+ return `${localized} (${native})`;
63
+ }
64
+ return localized;
65
+ };
66
+
67
+ // Strip the parenthetical "(language (region))" that OS-level voices often
68
+ // append to their name (e.g. "Eddy (German (Germany))" → "Eddy"). The actual
69
+ // `value` we submit to SpeechSynthesis still uses the full name.
70
+ const displayVoiceName = (name) => {
71
+ const cleaned = name.split('(')[0].trim();
72
+ return cleaned || name;
73
+ };
74
+
75
+ export default class VoiceSelector extends ShadowComponent {
76
+ static properties = {
77
+ language: { type: String, reflect: true },
78
+ placeholder: { type: String, reflect: true },
79
+ disabled: { type: Boolean, reflect: true },
80
+ voices: { state: true },
81
+ selected: { state: true },
82
+ currentLang: { state: true }
83
+ };
84
+
85
+ #unsubVoices = null;
86
+ #unsubSelected = null;
87
+ #unsupported = false;
88
+ #langInitialized = false;
89
+
90
+ /*
91
+ Lifecycle Callbacks
92
+ */
93
+ constructor() {
94
+ super();
95
+ this.language = '';
96
+ this.placeholder = 'Browser default';
97
+ this.disabled = false;
98
+ this.voices = [];
99
+ this.selected = '';
100
+ this.currentLang = '';
101
+ }
102
+
103
+ connectedCallback() {
104
+ super.connectedCallback();
105
+ if(typeof window === 'undefined' || !window.speechSynthesis){
106
+ this.#unsupported = true;
107
+ return;
108
+ }
109
+ this.#unsubVoices = subscribeToAvailableVoices(list => {
110
+ this.voices = list;
111
+ this.#initLanguageOnce();
112
+ });
113
+ this.#unsubSelected = subscribeToVoice(name => {
114
+ this.selected = name || '';
115
+ // Keep the language dropdown in sync with externally-set voices
116
+ if(name){
117
+ const match = this.voices.find(v => v.name === name);
118
+ if(match && match.lang){
119
+ this.currentLang = primaryLang(match.lang);
120
+ }
121
+ }
122
+ });
123
+ }
124
+
125
+ disconnectedCallback() {
126
+ super.disconnectedCallback();
127
+ if(this.#unsubVoices) this.#unsubVoices();
128
+ if(this.#unsubSelected) this.#unsubSelected();
129
+ this.#unsubVoices = null;
130
+ this.#unsubSelected = null;
131
+ }
132
+
133
+ /*
134
+ Utility
135
+ */
136
+ #initLanguageOnce = () => {
137
+ if(this.#langInitialized || this.voices.length === 0) return;
138
+ this.#langInitialized = true;
139
+ // Priority: existing voice's language > attribute > navigator > first available
140
+ const fromVoice = this.selected
141
+ ? this.voices.find(v => v.name === this.selected)
142
+ : null;
143
+ if(fromVoice && fromVoice.lang){
144
+ this.currentLang = primaryLang(fromVoice.lang);
145
+ return;
146
+ }
147
+ if(this.language){
148
+ this.currentLang = primaryLang(this.language);
149
+ return;
150
+ }
151
+ const navLang = browserLanguage();
152
+ const langs = this.availableLanguages;
153
+ if(langs.includes(navLang)){
154
+ this.currentLang = navLang;
155
+ } else if(langs.length > 0){
156
+ this.currentLang = langs[0];
157
+ }
158
+ };
159
+
160
+ get availableLanguages() {
161
+ const langs = new Set();
162
+ for(const v of this.voices){
163
+ if(v.lang) langs.add(primaryLang(v.lang));
164
+ }
165
+ return [...langs].sort();
166
+ }
167
+
168
+ get voicesForCurrentLanguage() {
169
+ if(!this.currentLang) return this.voices;
170
+ return this.voices.filter(v => primaryLang(v.lang) === this.currentLang);
171
+ }
172
+
173
+ /*
174
+ Event Handlers
175
+ */
176
+ handleLanguageChange = (e) => {
177
+ if(this.disabled) return;
178
+ this.currentLang = e.target.value;
179
+ // If the saved voice no longer exists in this language, clear it
180
+ const stillValid = this.voicesForCurrentLanguage.some(v => v.name === this.selected);
181
+ if(!stillValid){
182
+ setVoice('');
183
+ }
184
+ };
185
+
186
+ handleVoiceChange = (e) => {
187
+ if(this.disabled) return;
188
+ const value = e.target.value;
189
+ setVoice(value);
190
+ this.dispatchEvent(new CustomEvent('change', {
191
+ detail: { voice: value, language: this.currentLang },
192
+ bubbles: true
193
+ }));
194
+ };
195
+
196
+ /*
197
+ Public Methods
198
+ */
199
+ refresh = () => {
200
+ if(this.#unsupported) return;
201
+ this.voices = window.speechSynthesis.getVoices();
202
+ this.#initLanguageOnce();
203
+ };
204
+
205
+ /*
206
+ Rendering
207
+ */
208
+ render() {
209
+ if(this.#unsupported){
210
+ return html`
211
+ <select disabled aria-label="Voice selector (unsupported)">
212
+ <option>Speech synthesis not supported</option>
213
+ </select>
214
+ `;
215
+ }
216
+ const langs = this.availableLanguages;
217
+ const voicesInLang = this.voicesForCurrentLanguage;
218
+ return html`
219
+ <div class="row">
220
+ <div class="col" style="min-width: 10rem; flex: 0 0 auto">
221
+ <select
222
+ class="lang"
223
+ aria-label="Language"
224
+ ?disabled=${this.disabled || langs.length === 0}
225
+ .value=${this.currentLang}
226
+ @change=${this.handleLanguageChange}
227
+ >
228
+ ${langs.map(code => html`
229
+ <option value=${code} ?selected=${code === this.currentLang}>${formatLanguage(code)}</option>
230
+ `)}
231
+ </select>
232
+ </div>
233
+ <div class="col">
234
+ <select
235
+ class="voice"
236
+ aria-label="Voice"
237
+ ?disabled=${this.disabled}
238
+ .value=${this.selected}
239
+ @change=${this.handleVoiceChange}
240
+ >
241
+ <option value="">${this.placeholder}</option>
242
+ ${voicesInLang.map(v => html`
243
+ <option
244
+ value=${v.name}
245
+ ?selected=${v.name === this.selected}
246
+ >${displayVoiceName(v.name)}</option>
247
+ `)}
248
+ </select>
249
+ </div>
250
+ </div>
251
+ `;
252
+ }
253
+
254
+ /*
255
+ Styles
256
+ */
257
+ static styles = css`
258
+ :host {
259
+ display: block;
260
+ }
261
+ :host([disabled]) {
262
+ opacity: 0.5;
263
+ pointer-events: none;
264
+ }
265
+ select {
266
+ width: 100%;
267
+ }
268
+ `;
269
+ }
270
+
271
+ customElements.define('k-voice-selector', VoiceSelector);
@@ -0,0 +1,103 @@
1
+ /*
2
+ Voice Utility
3
+ - Manage the user's preferred speech-synthesis voice
4
+ - Subscribe to voice changes
5
+ - Persist voice to localStorage
6
+ - Sync across tabs via the storage event
7
+ */
8
+
9
+ import createContext from './context.js';
10
+
11
+ const STORAGE_KEY = 'k-voice';
12
+
13
+ const getInitialVoice = () => {
14
+ try {
15
+ const stored = localStorage.getItem(STORAGE_KEY);
16
+ return stored || '';
17
+ } catch(e){
18
+ return '';
19
+ }
20
+ };
21
+
22
+ const voiceContext = createContext('voice', getInitialVoice());
23
+
24
+ const persist = voice => {
25
+ try {
26
+ if(voice) localStorage.setItem(STORAGE_KEY, voice);
27
+ else localStorage.removeItem(STORAGE_KEY);
28
+ } catch(e) {}
29
+ };
30
+
31
+ voiceContext.subscribe(persist);
32
+
33
+ if(typeof window !== 'undefined'){
34
+ window.addEventListener('storage', event => {
35
+ if(event.key === STORAGE_KEY){
36
+ voiceContext.set(event.newValue || '');
37
+ }
38
+ });
39
+ }
40
+
41
+ export const setVoice = name => voiceContext.set(name || '');
42
+
43
+ export const getVoice = () => voiceContext.get();
44
+
45
+ export const subscribeToVoice = callback => voiceContext.subscribe(callback);
46
+
47
+ /*
48
+ Helpers for enumerating available voices from the browser.
49
+ getVoices() may return [] on the first call; the browser fires a
50
+ `voiceschanged` event when the list is ready. waitForVoices() returns a
51
+ promise that resolves once the list is populated (or after a small timeout
52
+ if the browser never fires the event).
53
+ */
54
+ export const getAvailableVoices = () => {
55
+ if(typeof window === 'undefined' || !window.speechSynthesis) return [];
56
+ return window.speechSynthesis.getVoices();
57
+ };
58
+
59
+ export const waitForVoices = (timeoutMs = 2000) => {
60
+ if(typeof window === 'undefined' || !window.speechSynthesis){
61
+ return Promise.resolve([]);
62
+ }
63
+ const synth = window.speechSynthesis;
64
+ const initial = synth.getVoices();
65
+ if(initial.length > 0) return Promise.resolve(initial);
66
+ return new Promise(resolve => {
67
+ let settled = false;
68
+ const handler = () => {
69
+ if(settled) return;
70
+ settled = true;
71
+ synth.removeEventListener('voiceschanged', handler);
72
+ resolve(synth.getVoices());
73
+ };
74
+ synth.addEventListener('voiceschanged', handler);
75
+ setTimeout(() => {
76
+ if(settled) return;
77
+ settled = true;
78
+ synth.removeEventListener('voiceschanged', handler);
79
+ resolve(synth.getVoices());
80
+ }, timeoutMs);
81
+ });
82
+ };
83
+
84
+ export const subscribeToAvailableVoices = callback => {
85
+ if(typeof window === 'undefined' || !window.speechSynthesis){
86
+ callback([]);
87
+ return () => {};
88
+ }
89
+ const synth = window.speechSynthesis;
90
+ const fire = () => callback(synth.getVoices());
91
+ synth.addEventListener('voiceschanged', fire);
92
+ fire();
93
+ return () => synth.removeEventListener('voiceschanged', fire);
94
+ };
95
+
96
+ export default {
97
+ get: getVoice,
98
+ set: setVoice,
99
+ subscribe: subscribeToVoice,
100
+ getAvailable: getAvailableVoices,
101
+ waitForVoices,
102
+ subscribeAvailable: subscribeToAvailableVoices
103
+ };
@@ -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
  */