@juspay/svelte-ui-components 4.23.0 → 4.25.0
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/dist/Checkbox/Checkbox.svelte +44 -6
- package/dist/Checkbox/properties.d.ts +18 -2
- package/dist/MarkdownText/MarkdownText.svelte +3 -2
- package/dist/MarkdownText/markdown.d.ts +7 -2
- package/dist/MarkdownText/markdown.js +142 -9
- package/dist/MarkdownText/properties.d.ts +63 -2
- package/dist/Select/Select.svelte +12 -1
- package/dist/Select/properties.d.ts +11 -0
- package/dist/SpeechSynthesis/controller.svelte.d.ts +89 -0
- package/dist/SpeechSynthesis/controller.svelte.js +385 -0
- package/dist/SpeechSynthesis/types.d.ts +68 -0
- package/dist/SpeechSynthesis/types.js +1 -0
- package/dist/Toggle/Toggle.svelte +6 -2
- package/dist/Toggle/properties.d.ts +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-wc/index.js +346 -232
- package/package.json +1 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The engine fires these when OUR OWN stop()/re-speak cancels an in-flight utterance --
|
|
2
|
+
// expected traffic (per the Web Speech spec, cancel() interrupts via onerror, not onend),
|
|
3
|
+
// not a failure worth surfacing as a toast.
|
|
4
|
+
const SILENT_ERROR_CODES = ['canceled', 'interrupted'];
|
|
5
|
+
const DEFAULT_ERROR_MESSAGES = {
|
|
6
|
+
'not-allowed': 'Speech playback is blocked. Allow audio in your browser settings to hear this read aloud.',
|
|
7
|
+
'audio-busy': 'Audio output is busy. Please try again.',
|
|
8
|
+
'audio-hardware': 'No audio output device found.',
|
|
9
|
+
network: 'A network error interrupted speech playback. Please check your connection and try again.',
|
|
10
|
+
'synthesis-failed': 'Could not read this text aloud. Please try again.',
|
|
11
|
+
'synthesis-unavailable': 'Speech playback is unavailable right now. Please try again.',
|
|
12
|
+
'language-unavailable': 'The requested language is not available for speech playback.',
|
|
13
|
+
'voice-unavailable': 'The requested voice is not available for speech playback.',
|
|
14
|
+
'text-too-long': 'This text is too long to read aloud.',
|
|
15
|
+
'invalid-argument': 'Could not read this text aloud due to an invalid option.'
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_MESSAGES = {
|
|
18
|
+
unsupported: 'Speech playback is not supported in this browser.',
|
|
19
|
+
fallback: 'Could not read this text aloud. Please try again.'
|
|
20
|
+
};
|
|
21
|
+
export const DEFAULT_ERROR_TIMEOUT_MS = 4000;
|
|
22
|
+
// Web Speech spec ranges for SpeechSynthesisUtterance.rate/pitch/volume -- a value outside
|
|
23
|
+
// these throws or is silently ignored depending on the browser, so the controller clamps
|
|
24
|
+
// instead of leaving that behavior to chance.
|
|
25
|
+
const RATE_RANGE = { min: 0.1, max: 10, fallback: 1 };
|
|
26
|
+
const PITCH_RANGE = { min: 0, max: 2, fallback: 1 };
|
|
27
|
+
const VOLUME_RANGE = { min: 0, max: 1, fallback: 1 };
|
|
28
|
+
function errorDetail(raw) {
|
|
29
|
+
return raw instanceof Error ? raw.message : String(raw);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Clamps a caller-supplied numeric option into `[min, max]`, falling back to `fallback` for
|
|
33
|
+
* a non-finite value (`NaN`, `+/-Infinity`) rather than clamping garbage into range silently.
|
|
34
|
+
* Exported (and kept free of the class) so range enforcement is unit-testable without a
|
|
35
|
+
* browser or an engine.
|
|
36
|
+
*/
|
|
37
|
+
export function clampSpeechNumber(value, range) {
|
|
38
|
+
if (!Number.isFinite(value)) {
|
|
39
|
+
return range.fallback;
|
|
40
|
+
}
|
|
41
|
+
return Math.min(range.max, Math.max(range.min, value));
|
|
42
|
+
}
|
|
43
|
+
/** Floors a caller-supplied toast delay at zero, falling back to the default for non-finite input. */
|
|
44
|
+
export function clampErrorTimeoutMs(value) {
|
|
45
|
+
if (!Number.isFinite(value)) {
|
|
46
|
+
return DEFAULT_ERROR_TIMEOUT_MS;
|
|
47
|
+
}
|
|
48
|
+
return Math.max(0, value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wraps the real `speechSynthesis` global so the controller only ever talks to
|
|
52
|
+
* `SpeechSynthesisEngineLike` -- never a live `SpeechSynthesisUtterance` (which extends
|
|
53
|
+
* EventTarget). `speak()` builds and wires a real utterance internally per call; nothing
|
|
54
|
+
* outside this function ever sees it.
|
|
55
|
+
*
|
|
56
|
+
* `window.speechSynthesis` is a single page-wide singleton, so voice-change notification is
|
|
57
|
+
* wired with `addEventListener('voiceschanged', ...)` rather than the single-slot
|
|
58
|
+
* `.onvoiceschanged =` property -- the latter would let a second controller's registration
|
|
59
|
+
* silently overwrite a first controller's, since both would be assigning the same DOM
|
|
60
|
+
* property on the same object. `addEventListener` registration is additive: each call to
|
|
61
|
+
* this function adds its own listener, and the engine's own `onvoiceschanged` setter -- the
|
|
62
|
+
* injected-engine contract stays a plain property, unrelated to how this wrapper talks to
|
|
63
|
+
* the real DOM object underneath -- removes exactly that listener when the controller tears
|
|
64
|
+
* down (`set onvoiceschanged(null)`), never another instance's.
|
|
65
|
+
*/
|
|
66
|
+
export function createBrowserEngine(synth) {
|
|
67
|
+
let voicesChangedHandler = null;
|
|
68
|
+
let listening = false;
|
|
69
|
+
const dispatch = () => {
|
|
70
|
+
voicesChangedHandler?.();
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
get speaking() {
|
|
74
|
+
return synth.speaking;
|
|
75
|
+
},
|
|
76
|
+
get onvoiceschanged() {
|
|
77
|
+
return voicesChangedHandler;
|
|
78
|
+
},
|
|
79
|
+
set onvoiceschanged(handler) {
|
|
80
|
+
voicesChangedHandler = handler;
|
|
81
|
+
if (handler === null) {
|
|
82
|
+
if (listening) {
|
|
83
|
+
synth.removeEventListener('voiceschanged', dispatch);
|
|
84
|
+
listening = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (!listening) {
|
|
88
|
+
synth.addEventListener('voiceschanged', dispatch);
|
|
89
|
+
listening = true;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
getVoices: () => synth.getVoices(),
|
|
93
|
+
cancel: () => synth.cancel(),
|
|
94
|
+
speak: (request, callbacks) => {
|
|
95
|
+
// `createBrowserEngine` is exported, so a caller can reach it directly and
|
|
96
|
+
// run it somewhere `SpeechSynthesisUtterance` does not exist. The
|
|
97
|
+
// controller's own path guards `window` before ever getting here; this
|
|
98
|
+
// guard is for the direct caller, and reports through the normal error
|
|
99
|
+
// channel rather than throwing out of `speak()`.
|
|
100
|
+
if (typeof SpeechSynthesisUtterance !== 'function') {
|
|
101
|
+
callbacks.onerror('synthesis-unavailable');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const utterance = new SpeechSynthesisUtterance(request.text);
|
|
105
|
+
utterance.lang = request.lang;
|
|
106
|
+
utterance.rate = request.rate;
|
|
107
|
+
utterance.pitch = request.pitch;
|
|
108
|
+
utterance.volume = request.volume;
|
|
109
|
+
utterance.voice = request.voice;
|
|
110
|
+
utterance.onstart = () => callbacks.onstart();
|
|
111
|
+
utterance.onend = () => callbacks.onend();
|
|
112
|
+
utterance.onerror = (event) => callbacks.onerror(event.error);
|
|
113
|
+
synth.speak(utterance);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function defaultSynthesisEngine() {
|
|
118
|
+
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return createBrowserEngine(window.speechSynthesis);
|
|
122
|
+
}
|
|
123
|
+
/** The primary language subtag (BCP 47), lowercased -- the part before the first '-', which
|
|
124
|
+
* may be two letters ('en') or three ('fil', 'haw', 'yue'), not always the first two characters. */
|
|
125
|
+
function primarySubtag(lang) {
|
|
126
|
+
const dashIndex = lang.indexOf('-');
|
|
127
|
+
const subtag = dashIndex === -1 ? lang : lang.slice(0, dashIndex);
|
|
128
|
+
return subtag.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
/** Platform default among voices matching `lang`'s primary subtag, else the first such match, else the platform default overall, else null. */
|
|
131
|
+
function defaultSelectVoice(voices, lang) {
|
|
132
|
+
const subtag = primarySubtag(lang);
|
|
133
|
+
const matching = subtag.length > 0 ? voices.filter((voice) => primarySubtag(voice.lang) === subtag) : [];
|
|
134
|
+
const pool = matching.length > 0 ? matching : voices;
|
|
135
|
+
const preferred = pool.find((voice) => voice.default);
|
|
136
|
+
return preferred ?? pool[0] ?? null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Headless text-to-speech over the browser's `speechSynthesis` API. The symmetric
|
|
140
|
+
* counterpart to `SpeechToTextController`: support detection, the voiceschanged dance for
|
|
141
|
+
* voice enumeration, toggle-to-stop orchestration (calling `speak()` while already speaking
|
|
142
|
+
* stops instead of starting a new utterance), and a self-hiding error toast. Rendering
|
|
143
|
+
* stays entirely with the host.
|
|
144
|
+
*/
|
|
145
|
+
export class SpeechSynthesisController {
|
|
146
|
+
/** True between the confirmed start and end of an utterance. */
|
|
147
|
+
speaking = $state(false);
|
|
148
|
+
/** Often empty until the platform's voiceschanged fires -- see `initialize()`. */
|
|
149
|
+
voices = $state([]);
|
|
150
|
+
errorMessage = $state('');
|
|
151
|
+
errorVisible = $state(false);
|
|
152
|
+
supported = $state(false);
|
|
153
|
+
options;
|
|
154
|
+
messages;
|
|
155
|
+
errorMessages;
|
|
156
|
+
engine = null;
|
|
157
|
+
everInitialized = false;
|
|
158
|
+
internalSpeaking = false;
|
|
159
|
+
errorTimer = null;
|
|
160
|
+
// Bumped per speak()/stop(); callbacks captured by a superseded utterance compare
|
|
161
|
+
// against it so a late onend/onerror cannot affect state for a newer utterance
|
|
162
|
+
// (mirrors SpeechToTextController's instanceGeneration guard).
|
|
163
|
+
speechGeneration = 0;
|
|
164
|
+
constructor(options = {}) {
|
|
165
|
+
this.options = options;
|
|
166
|
+
this.messages = { ...DEFAULT_MESSAGES, ...options.messages };
|
|
167
|
+
this.errorMessages = { ...DEFAULT_ERROR_MESSAGES, ...options.errorMessages };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Detects the API and starts tracking voices. Safe to call again; `speak` calls it
|
|
171
|
+
* lazily if the host never did. Re-initializing onto a *different* engine (a
|
|
172
|
+
* `getSynthesisEngine` swap, not merely calling this twice against the same one) tears the
|
|
173
|
+
* previous engine down first -- otherwise it would keep speaking with its `onvoiceschanged`
|
|
174
|
+
* handler still live, and a later `stop()`/`destroy()` would only ever reach the new engine.
|
|
175
|
+
*/
|
|
176
|
+
initialize() {
|
|
177
|
+
this.everInitialized = true;
|
|
178
|
+
const getEngine = this.options.getSynthesisEngine ?? defaultSynthesisEngine;
|
|
179
|
+
let engine;
|
|
180
|
+
try {
|
|
181
|
+
engine = getEngine();
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.diagnose('initializeFailed', { error: errorDetail(error) });
|
|
185
|
+
engine = null;
|
|
186
|
+
}
|
|
187
|
+
if (this.engine !== null && this.engine !== engine) {
|
|
188
|
+
this.teardownEngine(this.engine);
|
|
189
|
+
}
|
|
190
|
+
this.supported = engine !== null;
|
|
191
|
+
this.engine = engine;
|
|
192
|
+
if (engine === null) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
engine.onvoiceschanged = () => {
|
|
196
|
+
this.refreshVoices();
|
|
197
|
+
};
|
|
198
|
+
this.refreshVoices();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Speaks `text`. Calling this while already speaking stops instead of starting a new
|
|
203
|
+
* utterance -- the same toggle-to-stop behavior as a mic button's start/stop click.
|
|
204
|
+
*/
|
|
205
|
+
speak(text, options = {}) {
|
|
206
|
+
if (this.internalSpeaking) {
|
|
207
|
+
this.stop();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!this.everInitialized) {
|
|
211
|
+
this.initialize();
|
|
212
|
+
}
|
|
213
|
+
if (!this.supported || this.engine === null) {
|
|
214
|
+
this.showError(this.messages.unsupported);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (text.length === 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const lang = options.lang ?? this.options.lang ?? '';
|
|
221
|
+
const request = {
|
|
222
|
+
text,
|
|
223
|
+
lang,
|
|
224
|
+
rate: clampSpeechNumber(options.rate ?? this.options.rate ?? RATE_RANGE.fallback, RATE_RANGE),
|
|
225
|
+
pitch: clampSpeechNumber(options.pitch ?? this.options.pitch ?? PITCH_RANGE.fallback, PITCH_RANGE),
|
|
226
|
+
volume: clampSpeechNumber(options.volume ?? this.options.volume ?? VOLUME_RANGE.fallback, VOLUME_RANGE),
|
|
227
|
+
// `??` would treat an explicit `voice: null` (opt out of voice selection) the same as
|
|
228
|
+
// an absent option, silently overriding it with resolveVoice() -- checked against the
|
|
229
|
+
// key being unset instead, so `null` passes through untouched.
|
|
230
|
+
voice: typeof options.voice === 'undefined' ? this.resolveVoice(lang) : options.voice
|
|
231
|
+
};
|
|
232
|
+
this.speechGeneration += 1;
|
|
233
|
+
const generation = this.speechGeneration;
|
|
234
|
+
const isCurrent = () => generation === this.speechGeneration;
|
|
235
|
+
const callbacks = {
|
|
236
|
+
onstart: () => {
|
|
237
|
+
if (isCurrent()) {
|
|
238
|
+
this.handleStart();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
onend: () => {
|
|
242
|
+
if (isCurrent()) {
|
|
243
|
+
this.handleEnd();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
onerror: (code) => {
|
|
247
|
+
if (isCurrent()) {
|
|
248
|
+
this.handleUtteranceError(code);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
this.internalSpeaking = true;
|
|
253
|
+
this.errorMessage = '';
|
|
254
|
+
this.errorVisible = false;
|
|
255
|
+
try {
|
|
256
|
+
this.engine.speak(request, callbacks);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.internalSpeaking = false;
|
|
260
|
+
this.diagnose('speakThrew', { error: errorDetail(error) });
|
|
261
|
+
this.showError(this.messages.fallback);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
stop() {
|
|
265
|
+
// Invalidate any in-flight utterance's callbacks before cancel() can fire them -- cancel()
|
|
266
|
+
// itself may synchronously trigger onerror('canceled'), which must NOT re-run through
|
|
267
|
+
// handleUtteranceError (that would double-fire onSpeakingChange with a redundant `false`).
|
|
268
|
+
// The diagnostic for a self-initiated stop is emitted here instead, since the generation
|
|
269
|
+
// bump means the engine round-trip can no longer be trusted to deliver it.
|
|
270
|
+
this.speechGeneration += 1;
|
|
271
|
+
if (this.internalSpeaking) {
|
|
272
|
+
this.internalSpeaking = false;
|
|
273
|
+
this.setSpeaking(false);
|
|
274
|
+
this.diagnose('utteranceCanceled', { code: 'canceled' });
|
|
275
|
+
}
|
|
276
|
+
if (this.engine !== null) {
|
|
277
|
+
this.engine.cancel();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Clears the toast timer and tears the engine connection down. */
|
|
281
|
+
destroy() {
|
|
282
|
+
this.speechGeneration += 1;
|
|
283
|
+
if (this.errorTimer !== null) {
|
|
284
|
+
clearTimeout(this.errorTimer);
|
|
285
|
+
this.errorTimer = null;
|
|
286
|
+
}
|
|
287
|
+
if (this.engine !== null) {
|
|
288
|
+
try {
|
|
289
|
+
this.engine.cancel();
|
|
290
|
+
}
|
|
291
|
+
catch (cancelError) {
|
|
292
|
+
this.diagnose('cancelErrorOnDestroy', { error: errorDetail(cancelError) });
|
|
293
|
+
}
|
|
294
|
+
this.engine.onvoiceschanged = null;
|
|
295
|
+
this.engine = null;
|
|
296
|
+
}
|
|
297
|
+
this.internalSpeaking = false;
|
|
298
|
+
// cancel() firing onerror is not guaranteed (a superseded generation may swallow it),
|
|
299
|
+
// so clear the public state too -- a consumer's speaking indicator must not survive teardown.
|
|
300
|
+
if (this.speaking) {
|
|
301
|
+
this.setSpeaking(false);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Stops a superseded engine and detaches its listener; invalidates any utterance still in flight on it. */
|
|
305
|
+
teardownEngine(engine) {
|
|
306
|
+
this.speechGeneration += 1;
|
|
307
|
+
if (this.internalSpeaking) {
|
|
308
|
+
this.internalSpeaking = false;
|
|
309
|
+
this.setSpeaking(false);
|
|
310
|
+
this.diagnose('utteranceCanceled', { code: 'canceled' });
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
engine.cancel();
|
|
314
|
+
}
|
|
315
|
+
catch (cancelError) {
|
|
316
|
+
this.diagnose('cancelErrorOnReplace', { error: errorDetail(cancelError) });
|
|
317
|
+
}
|
|
318
|
+
engine.onvoiceschanged = null;
|
|
319
|
+
}
|
|
320
|
+
refreshVoices() {
|
|
321
|
+
if (this.engine === null) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Copied rather than assigned through. `getSynthesisEngine` is a documented
|
|
325
|
+
// seam for tests, demos and non-browser hosts, so an engine returning a
|
|
326
|
+
// stable array it mutates in place is a supported shape -- and a raw array
|
|
327
|
+
// mutated behind a `$state` proxy is not reliably observed. The real
|
|
328
|
+
// `speechSynthesis.getVoices()` returns a fresh array (WebIDL sequence),
|
|
329
|
+
// which is why the browser path never showed this. A copy costs nothing and
|
|
330
|
+
// removes the aliasing entirely.
|
|
331
|
+
this.voices = [...this.engine.getVoices()];
|
|
332
|
+
this.diagnose('voicesChanged', { count: this.voices.length });
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Runs voice selection (the caller-supplied `selectVoice` hook, or the built-in default)
|
|
336
|
+
* guarded the same way `getSynthesisEngine` is guarded in `initialize()`: a throwing hook
|
|
337
|
+
* must not propagate out of `speak()` and strand the caller's utterance, so it is reported
|
|
338
|
+
* via `onDiagnostic` and selection falls back to the built-in default instead.
|
|
339
|
+
*/
|
|
340
|
+
resolveVoice(lang) {
|
|
341
|
+
const selectVoice = this.options.selectVoice ?? defaultSelectVoice;
|
|
342
|
+
try {
|
|
343
|
+
return selectVoice(this.voices, lang);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
this.diagnose('selectVoiceThrew', { error: errorDetail(error) });
|
|
347
|
+
return defaultSelectVoice(this.voices, lang);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
handleStart() {
|
|
351
|
+
this.internalSpeaking = true;
|
|
352
|
+
this.setSpeaking(true);
|
|
353
|
+
}
|
|
354
|
+
handleEnd() {
|
|
355
|
+
this.internalSpeaking = false;
|
|
356
|
+
this.setSpeaking(false);
|
|
357
|
+
}
|
|
358
|
+
handleUtteranceError(code) {
|
|
359
|
+
this.internalSpeaking = false;
|
|
360
|
+
this.setSpeaking(false);
|
|
361
|
+
if (SILENT_ERROR_CODES.includes(code)) {
|
|
362
|
+
this.diagnose('utteranceCanceled', { code });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.showError(this.errorMessages[code] ?? this.messages.fallback, code);
|
|
366
|
+
}
|
|
367
|
+
setSpeaking(speaking) {
|
|
368
|
+
this.speaking = speaking;
|
|
369
|
+
this.options.onSpeakingChange?.(speaking);
|
|
370
|
+
}
|
|
371
|
+
showError(message, code = null) {
|
|
372
|
+
this.errorMessage = message;
|
|
373
|
+
this.errorVisible = true;
|
|
374
|
+
if (this.errorTimer !== null) {
|
|
375
|
+
clearTimeout(this.errorTimer);
|
|
376
|
+
}
|
|
377
|
+
this.errorTimer = setTimeout(() => {
|
|
378
|
+
this.errorVisible = false;
|
|
379
|
+
}, clampErrorTimeoutMs(this.options.errorTimeoutMs ?? DEFAULT_ERROR_TIMEOUT_MS));
|
|
380
|
+
this.options.onError?.(message, code);
|
|
381
|
+
}
|
|
382
|
+
diagnose(event, detail) {
|
|
383
|
+
this.options.onDiagnostic?.(event, detail);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's `speechSynthesis` surface, reduced to what the controller drives. Unlike
|
|
3
|
+
* SpeechRecognition, lib.dom does declare this API -- but jsdom does not implement it at
|
|
4
|
+
* runtime, and the real SpeechSynthesisUtterance extends EventTarget, which would force
|
|
5
|
+
* every fake in tests to also fake addEventListener/removeEventListener/dispatchEvent.
|
|
6
|
+
* Utterance construction and event wiring are pushed inside the engine (`speak` takes a
|
|
7
|
+
* plain data request plus a callback bag) so a fake never has to impersonate a live DOM
|
|
8
|
+
* object -- only this flat surface -- and can be injected for tests and demos.
|
|
9
|
+
*/
|
|
10
|
+
export type SpeechSynthesisEngineLike = {
|
|
11
|
+
readonly speaking: boolean;
|
|
12
|
+
onvoiceschanged: (() => void) | null;
|
|
13
|
+
getVoices: () => readonly SpeechSynthesisVoice[];
|
|
14
|
+
speak: (request: SpeechUtteranceRequest, callbacks: SpeechUtteranceCallbacks) => void;
|
|
15
|
+
cancel: () => void;
|
|
16
|
+
};
|
|
17
|
+
export type SpeechUtteranceRequest = {
|
|
18
|
+
text: string;
|
|
19
|
+
lang: string;
|
|
20
|
+
rate: number;
|
|
21
|
+
pitch: number;
|
|
22
|
+
volume: number;
|
|
23
|
+
voice: SpeechSynthesisVoice | null;
|
|
24
|
+
};
|
|
25
|
+
export type SpeechUtteranceCallbacks = {
|
|
26
|
+
onstart: () => void;
|
|
27
|
+
onend: () => void;
|
|
28
|
+
onerror: (code: string) => void;
|
|
29
|
+
};
|
|
30
|
+
/** Per-call overrides for `speak()`; falls back to the controller's own options, then platform defaults. */
|
|
31
|
+
export type SpeechUtteranceOptions = {
|
|
32
|
+
lang?: string;
|
|
33
|
+
rate?: number;
|
|
34
|
+
pitch?: number;
|
|
35
|
+
volume?: number;
|
|
36
|
+
voice?: SpeechSynthesisVoice | null;
|
|
37
|
+
};
|
|
38
|
+
/** User-facing copy the controller emits outside the per-error-code map. */
|
|
39
|
+
export type SpeechSynthesisMessages = {
|
|
40
|
+
unsupported: string;
|
|
41
|
+
fallback: string;
|
|
42
|
+
};
|
|
43
|
+
export type SpeechSynthesisOptions = {
|
|
44
|
+
lang?: string;
|
|
45
|
+
rate?: number;
|
|
46
|
+
pitch?: number;
|
|
47
|
+
volume?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Picks the voice for an utterance when neither the call nor the controller names one.
|
|
50
|
+
* Defaults to the platform's default voice among those matching `lang`, else the first
|
|
51
|
+
* voice matching `lang`, else the platform default overall, else null (let the engine
|
|
52
|
+
* pick). A caller-supplied list of preferred voice names is exactly this hook, called
|
|
53
|
+
* with the enumerated voices -- there is no baked-in preferred-voice list.
|
|
54
|
+
*/
|
|
55
|
+
selectVoice?: (voices: readonly SpeechSynthesisVoice[], lang: string) => SpeechSynthesisVoice | null;
|
|
56
|
+
/** How long an error toast stays visible. */
|
|
57
|
+
errorTimeoutMs?: number;
|
|
58
|
+
/** Per-synthesis-error-code messages, merged over the built-in map. */
|
|
59
|
+
errorMessages?: Record<string, string>;
|
|
60
|
+
/** Copy overrides, merged over the built-in defaults. */
|
|
61
|
+
messages?: Partial<SpeechSynthesisMessages>;
|
|
62
|
+
/** Resolves the synthesis engine; defaults to the browser's. Injectable for tests. */
|
|
63
|
+
getSynthesisEngine?: () => SpeechSynthesisEngineLike | null;
|
|
64
|
+
onSpeakingChange?: (speaking: boolean) => void;
|
|
65
|
+
onError?: (message: string, code: string | null) => void;
|
|
66
|
+
/** Non-fatal internals worth logging; hosts wire this to their telemetry. */
|
|
67
|
+
onDiagnostic?: (event: string, detail: Record<string, string | number | boolean | null>) => void;
|
|
68
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
id,
|
|
11
11
|
ariaLabel,
|
|
12
12
|
ariaLabelledby,
|
|
13
|
-
onclick
|
|
13
|
+
onclick,
|
|
14
|
+
name,
|
|
15
|
+
value
|
|
14
16
|
}: ToggleProperties = $props();
|
|
15
17
|
|
|
16
18
|
// Stable across hydration so the hidden input stays named and text remains clickable.
|
|
@@ -40,6 +42,8 @@
|
|
|
40
42
|
type="checkbox"
|
|
41
43
|
{checked}
|
|
42
44
|
{disabled}
|
|
45
|
+
name={name ?? null}
|
|
46
|
+
value={value ?? null}
|
|
43
47
|
aria-label={ariaLabel?.trim() || null}
|
|
44
48
|
aria-labelledby={ariaLabelledby?.trim() || null}
|
|
45
49
|
onclick={handleCheckboxClick}
|
|
@@ -112,7 +116,7 @@
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
.input-checkbox:focus + .slider {
|
|
115
|
-
box-shadow: 0 0
|
|
119
|
+
box-shadow: var(--toggle-focus-ring, 0 0 0 3px rgba(33, 150, 243, 0.3));
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
.input-checkbox:checked + .slider:before {
|
|
@@ -19,6 +19,15 @@ export type OptionalToggleProperties = {
|
|
|
19
19
|
* `text` or `inputAriaLabel` for labels outside the shadow root.
|
|
20
20
|
*/
|
|
21
21
|
ariaLabelledby?: string;
|
|
22
|
+
/** Native `name` on the underlying `<input type="checkbox">`, so the switch
|
|
23
|
+
* participates in a surrounding `<form>`'s submission. Omitted by default:
|
|
24
|
+
* the input stays nameless and outside `FormData`, exactly as before this
|
|
25
|
+
* prop existed. */
|
|
26
|
+
name?: string;
|
|
27
|
+
/** Native `value` submitted when on. Defaults to `"on"` — the native
|
|
28
|
+
* checkbox default — when omitted. An off toggle never appears in
|
|
29
|
+
* `FormData`, matching native behaviour. */
|
|
30
|
+
value?: string;
|
|
22
31
|
};
|
|
23
32
|
export type ToggleEventProperties = {
|
|
24
33
|
onclick?: (checked: boolean) => void;
|
package/dist/index.d.ts
CHANGED
|
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
|
|
|
102
102
|
export { ChatController } from './Chat/controller.svelte';
|
|
103
103
|
export { partyOf } from './Chat/roles';
|
|
104
104
|
export { SpeechToTextController } from './SpeechToText/controller.svelte';
|
|
105
|
+
export { SpeechSynthesisController } from './SpeechSynthesis/controller.svelte';
|
|
105
106
|
export { createCopyState } from './Snippet/copyState.svelte';
|
|
106
107
|
export type * from './Button/properties';
|
|
107
108
|
export type * from './Modal/properties';
|
|
@@ -189,6 +190,7 @@ export type * from './_chart/narrationHighlighter';
|
|
|
189
190
|
export type * from './Chat/properties';
|
|
190
191
|
export type * from './Chat/types';
|
|
191
192
|
export type * from './SpeechToText/types';
|
|
193
|
+
export type * from './SpeechSynthesis/types';
|
|
192
194
|
export type * from './ChatHeader/properties';
|
|
193
195
|
export type * from './ChatMessage/properties';
|
|
194
196
|
export type * from './ChatMessageList/properties';
|
package/dist/index.js
CHANGED
|
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
|
|
|
102
102
|
export { ChatController } from './Chat/controller.svelte';
|
|
103
103
|
export { partyOf } from './Chat/roles';
|
|
104
104
|
export { SpeechToTextController } from './SpeechToText/controller.svelte';
|
|
105
|
+
export { SpeechSynthesisController } from './SpeechSynthesis/controller.svelte';
|
|
105
106
|
export { createCopyState } from './Snippet/copyState.svelte';
|
|
106
107
|
export { createSoundKit } from './SoundKit/SoundKit';
|
|
107
108
|
export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
|