@nualang/nualang-ui-components 0.1.1412 → 0.1.1413

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.
@@ -1,350 +1,767 @@
1
- import { useState, useEffect, useRef, Fragment } from "react";
2
- import PropTypes from "prop-types";
3
- import { MenuItem, ListItemAvatar, Avatar, Grid, Box, Typography, Slider, Tooltip, TextField, Collapse, ListItemButton, ListItemText, IconButton, Chip } from "@mui/material";
4
- import ExpandLessIcon from "@mui/icons-material/ExpandLess";
5
- import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
1
+ import { useState, useEffect, useRef, useCallback, useId } from "react";
2
+ import { Avatar, Box, Button, ButtonBase, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from "@mui/material";
3
+ import { alpha } from "@mui/material/styles";
4
+ import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
5
+ import PauseRoundedIcon from "@mui/icons-material/PauseRounded";
6
+ import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
6
7
  import WarningAmberIcon from "@mui/icons-material/WarningAmber";
7
8
  import awsIconPath from "../../img/aws.svg";
8
9
  import azureIconPath from "../../img/azure.svg";
10
+
11
+ // Panel surfaces from the design spec, with dark-mode equivalents. Text, accent
12
+ // and dividers come straight from the theme.
9
13
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
10
- function VoicePitchSilder({
14
+ const panelTokens = theme => {
15
+ const light = theme.palette.mode === "light";
16
+ const white = theme.palette.common.white;
17
+ return {
18
+ panelBorder: light ? "#e6e6e2" : theme.palette.divider,
19
+ tabRule: light ? "#eeeeea" : theme.palette.divider,
20
+ cardBorder: light ? "#eaeae5" : alpha(white, 0.16),
21
+ cardBorderHover: light ? "#c9c9c2" : alpha(white, 0.32),
22
+ cardSelectedBg: light ? "#fbfbf9" : alpha(white, 0.06),
23
+ footerBg: light ? "#fcfcfb" : alpha(white, 0.03),
24
+ footerRule: light ? "#f0f0ec" : theme.palette.divider,
25
+ mutedText: light ? "#8f8f87" : alpha(white, 0.7),
26
+ faintText: light ? "#b3b3ac" : alpha(white, 0.45),
27
+ panelShadow: light ? "0 1px 2px rgba(0,0,0,0.04), 0 18px 44px -22px rgba(0,0,0,0.22)" : "0 1px 2px rgba(0,0,0,0.3), 0 18px 44px -22px rgba(0,0,0,0.7)"
28
+ };
29
+ };
30
+
31
+ // MUI's own visuallyHidden lives in @mui/utils, which is only a transitive
32
+ // dependency here — declaring an import on it would make it an implicit one.
33
+ const visuallyHidden = {
34
+ position: "absolute",
35
+ width: 1,
36
+ height: 1,
37
+ overflow: "hidden",
38
+ clipPath: "inset(50%)",
39
+ whiteSpace: "nowrap"
40
+ };
41
+ const actionButtonSx = {
42
+ fontSize: "13.5px",
43
+ fontWeight: 600,
44
+ textTransform: "none"
45
+ };
46
+ const badgeSx = {
47
+ height: 18,
48
+ "& .MuiChip-icon": {
49
+ fontSize: 11,
50
+ marginLeft: 0.25,
51
+ marginRight: -0.5
52
+ },
53
+ "& .MuiChip-label": {
54
+ px: 0.75,
55
+ fontSize: "0.625rem",
56
+ fontWeight: 600
57
+ }
58
+ };
59
+
60
+ // The speech API expresses medium as no pitch at all, and a ToggleButton can't
61
+ // carry null, so the buttons are keyed by label and mapped to a pitch here.
62
+ const PITCH_OPTIONS = [{
63
+ labelKey: "voice_pitch_x_low",
64
+ pitch: "x-low"
65
+ }, {
66
+ labelKey: "voice_pitch_low",
67
+ pitch: "low"
68
+ }, {
69
+ labelKey: "voice_pitch_medium",
70
+ pitch: null
71
+ }, {
72
+ labelKey: "voice_pitch_high",
73
+ pitch: "high"
74
+ }, {
75
+ labelKey: "voice_pitch_x_high",
76
+ pitch: "x-high"
77
+ }];
78
+
79
+ // Arrow keys move the selection around the voice grid.
80
+ const ARROW_STEPS = {
81
+ ArrowRight: 1,
82
+ ArrowDown: 1,
83
+ ArrowLeft: -1,
84
+ ArrowUp: -1
85
+ };
86
+
87
+ // "Spanish, US" -> "US". Accent labels are prefixed with the language name,
88
+ // which the tab row doesn't need to repeat.
89
+ const accentTabLabel = (label = "") => {
90
+ const separator = label.indexOf(", ");
91
+ return separator === -1 ? label : label.slice(separator + 2);
92
+ };
93
+ function ChildChip({
94
+ t
95
+ }) {
96
+ return /*#__PURE__*/_jsx(Chip, {
97
+ label: t("child"),
98
+ size: "small",
99
+ variant: "outlined",
100
+ sx: {
101
+ ...badgeSx,
102
+ textTransform: "capitalize"
103
+ }
104
+ });
105
+ }
106
+ function LegacyChip({
107
+ t
108
+ }) {
109
+ return /*#__PURE__*/_jsx(Tooltip, {
110
+ title: t("legacy_voice_tooltip_text"),
111
+ placement: "top",
112
+ children: /*#__PURE__*/_jsx(Chip, {
113
+ icon: /*#__PURE__*/_jsx(WarningAmberIcon, {}),
114
+ label: t("legacy"),
115
+ size: "small",
116
+ color: "warning",
117
+ variant: "outlined",
118
+ sx: badgeSx
119
+ })
120
+ });
121
+ }
122
+ function ProviderIcon({
123
+ provider
124
+ }) {
125
+ return /*#__PURE__*/_jsx(Box, {
126
+ component: "img",
127
+ src: provider === "azure" ? azureIconPath : awsIconPath,
128
+ alt: `${provider || "aws"} Icon`,
129
+ sx: {
130
+ width: 16,
131
+ height: 16,
132
+ display: "block"
133
+ }
134
+ });
135
+ }
136
+ function VoicePitchToggle({
11
137
  t,
12
- selectedVoice,
13
- voicePitch,
14
- onVoicePitchChange
138
+ value,
139
+ disabled,
140
+ onChange
15
141
  }) {
16
- const voicePitchMarks = [{
17
- value: 5,
18
- label: t("voice_pitch_x_low"),
19
- pitchValue: "x-low"
20
- }, {
21
- value: 25,
22
- label: t("voice_pitch_low"),
23
- pitchValue: "low"
24
- }, {
25
- value: 50,
26
- label: t("voice_pitch_medium"),
27
- pitchValue: null
28
- }, {
29
- value: 75,
30
- label: t("voice_pitch_high"),
31
- pitchValue: "high"
32
- }, {
33
- value: 95,
34
- label: t("voice_pitch_x_high"),
35
- pitchValue: "x-high"
36
- }];
37
- const sliderValue = voicePitchMarks.find(mark => mark.pitchValue === voicePitch)?.value || 50;
38
- if (selectedVoice?.isNeuralOnly) {
39
- return /*#__PURE__*/_jsx(Tooltip, {
40
- title: t("voice_pitch_disabled"),
41
- children: /*#__PURE__*/_jsx("div", {
42
- children: /*#__PURE__*/_jsx(Slider, {
43
- disabled: true,
44
- "aria-label": t("voice_pitch"),
45
- defaultValue: 50,
46
- step: null,
47
- marks: voicePitchMarks,
48
- value: sliderValue
49
- })
50
- })
51
- });
52
- }
53
- return /*#__PURE__*/_jsx(Slider, {
142
+ const group = /*#__PURE__*/_jsx(ToggleButtonGroup, {
143
+ exclusive: true,
144
+ size: "small",
145
+ disabled: disabled,
146
+ value: PITCH_OPTIONS.find(option => option.pitch === (value || null))?.labelKey,
54
147
  "aria-label": t("voice_pitch"),
55
- defaultValue: 50,
56
- step: null,
57
- marks: voicePitchMarks,
58
- value: sliderValue,
59
- onChange: (_, newValue) => {
60
- const currentValue = sliderValue;
61
- if (newValue !== currentValue) {
62
- const newPitchValue = voicePitchMarks.find(mark => mark.value === newValue)?.pitchValue;
63
- onVoicePitchChange({
64
- target: {
65
- name: "voicePitch",
66
- value: newPitchValue
67
- }
68
- });
148
+ onChange: (_, labelKey) => {
149
+ // labelKey is null when the active button is clicked again — a no-op,
150
+ // since one pitch is always in effect.
151
+ const option = PITCH_OPTIONS.find(o => o.labelKey === labelKey);
152
+ if (option) onChange(option.pitch);
153
+ },
154
+ sx: theme => ({
155
+ borderRadius: "999px",
156
+ "& .MuiToggleButton-root": {
157
+ textTransform: "none",
158
+ fontSize: "12px",
159
+ fontWeight: 600,
160
+ px: "10px",
161
+ py: "4px",
162
+ lineHeight: 1.4,
163
+ color: "text.secondary",
164
+ borderColor: panelTokens(theme).cardBorderHover
165
+ },
166
+ "& .MuiToggleButtonGroup-firstButton": {
167
+ borderTopLeftRadius: "999px",
168
+ borderBottomLeftRadius: "999px",
169
+ pl: "14px"
170
+ },
171
+ "& .MuiToggleButtonGroup-lastButton": {
172
+ borderTopRightRadius: "999px",
173
+ borderBottomRightRadius: "999px",
174
+ pr: "14px"
175
+ },
176
+ "& .MuiToggleButton-root.Mui-selected": {
177
+ backgroundColor: "primary.main",
178
+ color: theme.palette.primary.contrastText,
179
+ "&:hover": {
180
+ backgroundColor: "primary.dark"
181
+ }
69
182
  }
70
- }
183
+ }),
184
+ children: PITCH_OPTIONS.map(option => /*#__PURE__*/_jsx(ToggleButton, {
185
+ value: option.labelKey,
186
+ children: t(option.labelKey)
187
+ }, option.labelKey))
188
+ });
189
+ return /*#__PURE__*/_jsxs(Box, {
190
+ sx: {
191
+ display: "flex",
192
+ flexDirection: "column",
193
+ gap: "6px"
194
+ },
195
+ children: [/*#__PURE__*/_jsx(Typography, {
196
+ component: "span",
197
+ sx: theme => ({
198
+ fontSize: "10.5px",
199
+ fontWeight: 600,
200
+ letterSpacing: "0.08em",
201
+ textTransform: "uppercase",
202
+ color: panelTokens(theme).mutedText
203
+ }),
204
+ children: t("voice_pitch")
205
+ }), disabled ? /*#__PURE__*/_jsx(Tooltip, {
206
+ title: t("voice_pitch_disabled"),
207
+ children: /*#__PURE__*/_jsx("span", {
208
+ children: group
209
+ })
210
+ }) : group]
71
211
  });
72
212
  }
213
+ function VoiceCard({
214
+ t,
215
+ voiceOption,
216
+ languageCode,
217
+ selected,
218
+ tabbable,
219
+ playing,
220
+ showProviderIcons,
221
+ showPreview,
222
+ onSelect,
223
+ onPreview,
224
+ cardRef
225
+ }) {
226
+ const previewLabel = playing ? t("playing") : t("preview");
227
+ return (
228
+ /*#__PURE__*/
229
+ // ButtonBase over a plain Box: it synthesises Enter/Space activation for
230
+ // non-native components and picks up the theme's focus-visible ring. A div
231
+ // rather than the default button, since the preview control nests inside.
232
+ _jsxs(ButtonBase, {
233
+ component: "div"
234
+ // The id and data-language-code are part of the component's contract:
235
+ // consumers read the accent off the DOM node when a voice is committed.
236
+ ,
237
+ id: `voice-${voiceOption.name}`,
238
+ "data-language-code": languageCode,
239
+ ref: cardRef,
240
+ role: "radio",
241
+ "aria-checked": selected
242
+ // One stop for the whole grid: the selected card, or the first one when
243
+ // the selection is empty or sits behind another tab.
244
+ ,
245
+ tabIndex: tabbable ? 0 : -1,
246
+ onClick: onSelect,
247
+ sx: theme => {
248
+ const tokens = panelTokens(theme);
249
+ return {
250
+ p: "12px 14px",
251
+ borderRadius: "12px",
252
+ display: "flex",
253
+ alignItems: "center",
254
+ justifyContent: "flex-start",
255
+ textAlign: "left",
256
+ gap: "11px",
257
+ border: "1px solid",
258
+ borderColor: selected ? "primary.main" : tokens.cardBorder,
259
+ backgroundColor: selected ? tokens.cardSelectedBg : theme.palette.background.paper,
260
+ boxShadow: selected ? `inset 0 0 0 1px ${theme.palette.primary.main}` : "none",
261
+ "&:hover": {
262
+ borderColor: selected ? "primary.main" : tokens.cardBorderHover
263
+ }
264
+ };
265
+ },
266
+ children: [/*#__PURE__*/_jsx(Avatar, {
267
+ src: voiceOption.userImage,
268
+ alt: "",
269
+ sx: {
270
+ width: 40,
271
+ height: 40,
272
+ flex: "0 0 auto"
273
+ }
274
+ }), /*#__PURE__*/_jsxs(Box, {
275
+ sx: {
276
+ display: "flex",
277
+ flexDirection: "column",
278
+ alignItems: "flex-start",
279
+ gap: "4px",
280
+ minWidth: 0
281
+ },
282
+ children: [/*#__PURE__*/_jsxs(Box, {
283
+ sx: {
284
+ display: "flex",
285
+ alignItems: "center",
286
+ gap: "6px"
287
+ },
288
+ children: [/*#__PURE__*/_jsx(Typography, {
289
+ component: "span",
290
+ sx: {
291
+ fontSize: "15.5px",
292
+ fontWeight: selected ? 600 : 500,
293
+ color: "text.primary",
294
+ minWidth: 0,
295
+ overflow: "hidden",
296
+ textOverflow: "ellipsis",
297
+ whiteSpace: "nowrap"
298
+ },
299
+ children: voiceOption.name
300
+ }), showProviderIcons && /*#__PURE__*/_jsx(ProviderIcon, {
301
+ provider: voiceOption.provider
302
+ })]
303
+ }), (voiceOption.legacy || voiceOption.isChild) && /*#__PURE__*/_jsxs(Box, {
304
+ sx: {
305
+ display: "flex",
306
+ alignItems: "center",
307
+ gap: "4px",
308
+ flexWrap: "wrap"
309
+ },
310
+ children: [voiceOption.legacy && /*#__PURE__*/_jsx(LegacyChip, {
311
+ t: t
312
+ }), voiceOption.isChild && /*#__PURE__*/_jsx(ChildChip, {
313
+ t: t
314
+ })]
315
+ })]
316
+ }), showPreview && /*#__PURE__*/_jsx(IconButton, {
317
+ "aria-pressed": playing,
318
+ "aria-label": `${previewLabel} ${voiceOption.name}`,
319
+ onClick: event => {
320
+ event.stopPropagation();
321
+ onPreview();
322
+ },
323
+ sx: {
324
+ ml: "auto",
325
+ flex: "0 0 auto",
326
+ width: 36,
327
+ height: 36,
328
+ border: "1px solid",
329
+ borderColor: "primary.main",
330
+ color: playing ? "primary.contrastText" : "primary.main",
331
+ backgroundColor: playing ? "primary.main" : "transparent",
332
+ "&:hover": {
333
+ borderColor: "primary.main",
334
+ backgroundColor: playing ? "primary.dark" : "action.hover"
335
+ },
336
+ "& .MuiSvgIcon-root": {
337
+ fontSize: 20
338
+ }
339
+ },
340
+ children: playing ? /*#__PURE__*/_jsx(PauseRoundedIcon, {}) : /*#__PURE__*/_jsx(PlayArrowRoundedIcon, {})
341
+ })]
342
+ })
343
+ );
344
+ }
73
345
  function VoiceSelector(props) {
74
346
  const {
75
347
  t = text => text,
76
348
  voices,
77
349
  value = "",
350
+ name,
351
+ label,
78
352
  onChange,
79
353
  onVoicePitchChange,
80
354
  voicePitch,
81
355
  generateAudio = false,
82
356
  isUserInternal,
83
357
  hidePitch = false,
358
+ handleSpeak,
359
+ learnLang,
360
+ previewText,
84
361
  ...otherProps
85
362
  } = props;
86
- const [openAccentIndex, setOpenAccentIndex] = useState(null);
87
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
88
- const dropdownRef = useRef(null);
89
- const handleToggleAccent = (event, index) => {
90
- event.stopPropagation();
91
- setOpenAccentIndex(prevIndex => prevIndex === index ? null : index);
92
- };
93
- const voiceAccents = Object.values(voices);
94
- const voicesArray = voiceAccents.flatMap(voiceOption => voiceOption.voiceOptions);
95
- const selectedVoice = (voicesArray || []).find(v => v.name === value);
363
+ const [open, setOpen] = useState(false);
364
+ const [activeAccentIndex, setActiveAccentIndex] = useState(0);
365
+ const [draftVoice, setDraftVoice] = useState(value);
366
+ const [draftPitch, setDraftPitch] = useState(voicePitch || null);
367
+ const [playingVoice, setPlayingVoice] = useState(null);
368
+ const audioRef = useRef(null);
369
+ const previewRequestRef = useRef(0);
370
+ const cardRefs = useRef({});
371
+ const titleId = useId();
372
+ const voiceAccents = Object.values(voices || {});
373
+ const selectedVoice = voiceAccents.flatMap(accent => accent.voiceOptions || []).find(option => option.name === value);
374
+ const activeAccent = voiceAccents[activeAccentIndex];
375
+ const activeVoices = activeAccent?.voiceOptions || [];
376
+ // The draft survives a tab change, so it is looked up across every accent
377
+ // rather than the active one — browsing accents must not move the selection.
378
+ // Nothing is highlighted while the drafted voice sits behind another tab.
379
+ const draftAccent = voiceAccents.find(accent => (accent.voiceOptions || []).some(option => option.name === draftVoice));
380
+ const draftVoiceOption = (draftAccent?.voiceOptions || []).find(option => option.name === draftVoice);
381
+ // A radiogroup keeps a single tab stop. With no selection on this tab that
382
+ // falls to the first card, so the grid stays reachable by keyboard.
383
+ const gridEntryName = activeVoices.some(option => option.name === draftVoiceOption?.name) ? draftVoiceOption.name : activeVoices[0]?.name;
96
384
  const showProviderIcons = generateAudio || window.location.host === "localhost:9009" || isUserInternal;
97
- useEffect(() => {
98
- setOpenAccentIndex(null);
99
- }, [voices]);
100
- useEffect(() => {
101
- const handleClickOutside = event => {
102
- if (dropdownRef.current && !dropdownRef.current.contains(event.target) && !event.target.closest("[data-accent-item]")) {
103
- setIsDropdownOpen(false);
385
+ const showPreview = Boolean(handleSpeak);
386
+ const showPitch = !hidePitch && Boolean(onVoicePitchChange);
387
+ const panelTitle = label || t("voices");
388
+
389
+ // Neural-only voices don't accept a pitch, so the control is inert for them.
390
+ const pitchFor = voiceOption => voiceOption?.isNeuralOnly ? null : draftPitch;
391
+ const pitchDisabled = Boolean(draftVoiceOption?.isNeuralOnly);
392
+ const stopPlayback = useCallback(() => {
393
+ previewRequestRef.current += 1;
394
+ if (audioRef.current) {
395
+ audioRef.current.pause();
396
+ audioRef.current = null;
397
+ }
398
+ setPlayingVoice(null);
399
+ }, []);
400
+ useEffect(() => () => stopPlayback(), [stopPlayback]);
401
+ const handleOpen = () => {
402
+ const accentIndex = voiceAccents.findIndex(accent => (accent.voiceOptions || []).some(option => option.name === value));
403
+ const nextIndex = accentIndex === -1 ? 0 : accentIndex;
404
+ setActiveAccentIndex(nextIndex);
405
+ // Only an already-selected voice starts out drafted — opening the panel is
406
+ // browsing, so nothing is picked on the user's behalf.
407
+ setDraftVoice(value);
408
+ setDraftPitch(voicePitch || null);
409
+ setOpen(true);
410
+ };
411
+ const handleClose = () => {
412
+ stopPlayback();
413
+ setOpen(false);
414
+ };
415
+ const handleAccentChange = (_, index) => {
416
+ stopPlayback();
417
+ setActiveAccentIndex(index);
418
+ };
419
+ const handlePreview = async voiceOption => {
420
+ if (playingVoice === voiceOption.name) {
421
+ stopPlayback();
422
+ return;
423
+ }
424
+ stopPlayback();
425
+ setDraftVoice(voiceOption.name);
426
+ const requestId = previewRequestRef.current += 1;
427
+ setPlayingVoice(voiceOption.name);
428
+ try {
429
+ const result = await handleSpeak({
430
+ text: previewText || voiceOption.name,
431
+ learnLanguage: learnLang,
432
+ voiceName: voiceOption.name,
433
+ languageCode: activeAccent?.languageCode,
434
+ pitch: pitchFor(voiceOption),
435
+ mute: true
436
+ });
437
+ // A newer preview (or a close/unmount) superseded this request.
438
+ if (previewRequestRef.current !== requestId) return;
439
+ const audioUrl = typeof result === "string" ? result : result?.audioUrl;
440
+ if (!audioUrl) {
441
+ setPlayingVoice(null);
442
+ return;
104
443
  }
105
- };
106
- if (isDropdownOpen) {
107
- document.addEventListener("click", handleClickOutside);
108
- } else {
109
- document.removeEventListener("click", handleClickOutside);
444
+ const audio = new Audio(audioUrl);
445
+ audioRef.current = audio;
446
+ audio.addEventListener("ended", () => setPlayingVoice(null));
447
+ await audio.play();
448
+ } catch (error) {
449
+ console.error(error);
450
+ if (previewRequestRef.current === requestId) setPlayingVoice(null);
110
451
  }
111
- return () => {
112
- document.removeEventListener("click", handleClickOutside);
113
- };
114
- }, [isDropdownOpen]);
115
- useEffect(() => {
116
- if (value && voices) {
117
- const selectedAccentIndex = voiceAccents.findIndex(accent => accent.voiceOptions.some(voiceOption => voiceOption.name === value));
118
- setOpenAccentIndex(selectedAccentIndex !== -1 ? selectedAccentIndex : null);
452
+ };
453
+
454
+ // Radiogroup keyboard support: selection follows focus across the grid.
455
+ const handleGridKeyDown = event => {
456
+ const step = ARROW_STEPS[event.key];
457
+ if (!step || activeVoices.length === 0) return;
458
+ event.preventDefault();
459
+ const currentIndex = activeVoices.findIndex(option => option.name === draftVoice);
460
+ // The draft may belong to another accent, in which case there is nothing to
461
+ // step from and arrowing into this grid enters at the first card.
462
+ const nextVoice = currentIndex === -1 ? activeVoices[0] : activeVoices[(currentIndex + step + activeVoices.length) % activeVoices.length];
463
+ setDraftVoice(nextVoice.name);
464
+ cardRefs.current[nextVoice.name]?.focus();
465
+ };
466
+ const handleCommit = () => {
467
+ if (!draftVoiceOption) {
468
+ handleClose();
469
+ return;
119
470
  }
120
- }, [value, voices]);
471
+ onChange({
472
+ target: {
473
+ name,
474
+ value: draftVoiceOption.name,
475
+ // The accent that owns the draft, not the one on screen — they differ
476
+ // when the panel is closed from a tab the draft was not picked in.
477
+ languageCode: draftAccent?.languageCode,
478
+ picture: draftVoiceOption.userImage || ""
479
+ }
480
+ });
481
+ // After onChange, since consumers reset the pitch when the voice changes.
482
+ // Only when it actually moved, so an unchanged pitch doesn't trigger a
483
+ // second sample playback in the consumer.
484
+ const committedPitch = pitchFor(draftVoiceOption);
485
+ if (showPitch && (voicePitch || null) !== committedPitch) {
486
+ onVoicePitchChange({
487
+ target: {
488
+ name: "voicePitch",
489
+ value: committedPitch
490
+ }
491
+ });
492
+ }
493
+ handleClose();
494
+ };
121
495
  return /*#__PURE__*/_jsxs(_Fragment, {
122
496
  children: [/*#__PURE__*/_jsx(TextField, {
123
- ref: dropdownRef,
124
- select: true,
125
- value: value,
126
- onClick: () => setIsDropdownOpen(true),
127
- onChange: e => {
128
- onChange(e);
129
- setOpenAccentIndex(null);
130
- },
131
497
  ...otherProps,
498
+ name: name,
499
+ label: label,
500
+ value: selectedVoice?.name || "",
501
+ onClick: handleOpen,
502
+ onKeyDown: event => {
503
+ if (["Enter", " ", "ArrowDown"].includes(event.key)) {
504
+ event.preventDefault();
505
+ handleOpen();
506
+ }
507
+ },
132
508
  slotProps: {
133
509
  inputLabel: {
134
510
  shrink: Boolean(value) || undefined
135
511
  },
136
512
  input: {
137
- notched: Boolean(value) || undefined
138
- },
139
- select: {
140
- open: isDropdownOpen,
141
- MenuProps: {
142
- PaperProps: {
143
- sx: {
144
- maxHeight: "80vh",
145
- width: "60vw",
146
- left: "10 !important",
147
- right: "10 !important",
148
- top: "50% !important",
149
- transform: "translateY(-50%) !important",
150
- overflowY: "auto"
151
- }
152
- },
153
- anchorOrigin: {
154
- vertical: "top",
155
- horizontal: "center"
156
- },
157
- transformOrigin: {
158
- vertical: "center",
159
- horizontal: "center"
160
- },
161
- getContentAnchorEl: null
513
+ readOnly: true,
514
+ notched: Boolean(value) || undefined,
515
+ sx: {
516
+ cursor: "pointer"
162
517
  },
163
- renderValue: selected => {
164
- const selectedVoice = voicesArray.find(v => v.name === selected) || voicesArray[0];
165
- return /*#__PURE__*/_jsxs(Grid, {
166
- container: true,
518
+ startAdornment: selectedVoice ? /*#__PURE__*/_jsx(Avatar, {
519
+ src: selectedVoice.userImage,
520
+ alt: "",
521
+ sx: {
522
+ width: 32,
523
+ height: 32,
524
+ mr: 1
525
+ }
526
+ }) : null,
527
+ endAdornment: /*#__PURE__*/_jsxs(Box, {
528
+ sx: {
529
+ display: "flex",
530
+ alignItems: "center",
531
+ gap: 0.5
532
+ },
533
+ children: [selectedVoice?.legacy && /*#__PURE__*/_jsx(LegacyChip, {
534
+ t: t
535
+ }), selectedVoice?.isChild && /*#__PURE__*/_jsx(ChildChip, {
536
+ t: t
537
+ }), showProviderIcons && selectedVoice && /*#__PURE__*/_jsx(ProviderIcon, {
538
+ provider: selectedVoice.provider
539
+ }), /*#__PURE__*/_jsx(ArrowDropDownIcon, {
167
540
  sx: {
168
- alignItems: "center",
169
- width: "100%"
170
- },
171
- children: [/*#__PURE__*/_jsx(Grid, {
172
- children: /*#__PURE__*/_jsx(Avatar, {
173
- src: selectedVoice?.userImage,
174
- alt: selectedVoice?.name,
175
- sx: {
176
- mr: 1
177
- }
178
- })
179
- }), /*#__PURE__*/_jsx(Grid, {
180
- children: /*#__PURE__*/_jsxs(Typography, {
181
- variant: "body1",
182
- sx: {
183
- display: "flex",
184
- alignItems: "center",
185
- gap: 1
186
- },
187
- children: [selectedVoice?.isChild ? `${selectedVoice?.name} (${t("child")})` : selectedVoice?.name, selectedVoice?.legacy && /*#__PURE__*/_jsx(Tooltip, {
188
- title: t("legacy_voice_tooltip_text"),
189
- placement: "right",
190
- children: /*#__PURE__*/_jsx(Chip, {
191
- icon: /*#__PURE__*/_jsx(WarningAmberIcon, {}),
192
- label: "Legacy",
193
- size: "small",
194
- color: "warning",
195
- variant: "outlined",
196
- sx: {
197
- height: 18,
198
- "& .MuiChip-icon": {
199
- fontSize: 11,
200
- marginLeft: 0.25,
201
- marginRight: -0.5
202
- },
203
- "& .MuiChip-label": {
204
- px: 0.75,
205
- fontSize: "0.625rem",
206
- fontWeight: 600
207
- }
208
- }
209
- })
210
- })]
211
- })
212
- }), showProviderIcons && /*#__PURE__*/_jsx(Grid, {
213
- sx: {
214
- mt: 1,
215
- ml: "auto"
216
- },
217
- children: /*#__PURE__*/_jsx("img", {
218
- src: selectedVoice.provider === "azure" ? azureIconPath : awsIconPath,
219
- alt: `${selectedVoice.provider} Icon`
220
- })
221
- })]
222
- });
541
+ color: "action.active"
542
+ }
543
+ })]
544
+ })
545
+ },
546
+ htmlInput: {
547
+ role: "combobox",
548
+ "aria-haspopup": "dialog",
549
+ "aria-expanded": open,
550
+ sx: {
551
+ cursor: "pointer"
552
+ }
553
+ }
554
+ }
555
+ }), /*#__PURE__*/_jsxs(Dialog, {
556
+ open: open,
557
+ onClose: handleClose,
558
+ maxWidth: false,
559
+ "aria-labelledby": titleId,
560
+ slotProps: {
561
+ paper: {
562
+ elevation: 0,
563
+ sx: theme => {
564
+ const tokens = panelTokens(theme);
565
+ return {
566
+ width: "100%",
567
+ maxWidth: 720,
568
+ border: "1px solid",
569
+ borderColor: tokens.panelBorder,
570
+ borderRadius: "16px",
571
+ boxShadow: tokens.panelShadow
572
+ };
223
573
  }
224
574
  }
225
575
  },
226
- children: voiceAccents.map((voiceAccent, index) => /*#__PURE__*/_jsxs(Fragment, {
227
- children: [/*#__PURE__*/_jsxs(ListItemButton, {
228
- onClick: event => handleToggleAccent(event, index),
229
- "data-accent-item": true,
230
- children: [/*#__PURE__*/_jsx(ListItemText, {
231
- disableSticky: true,
232
- children: voiceAccent.label
233
- }), /*#__PURE__*/_jsx(IconButton, {
234
- size: "small",
235
- "data-accent-item": true,
236
- children: openAccentIndex === index ? /*#__PURE__*/_jsx(ExpandLessIcon, {}) : /*#__PURE__*/_jsx(ExpandMoreIcon, {})
237
- })]
238
- }), /*#__PURE__*/_jsx(Collapse, {
239
- in: openAccentIndex === index,
240
- timeout: "auto",
241
- unmountOnExit: true,
242
- children: voiceAccent.voiceOptions.map(voiceOption => /*#__PURE__*/_jsx(MenuItem, {
243
- value: voiceOption.name,
244
- id: `voice-${voiceOption.name}`,
245
- "data-language-code": voiceAccent.languageCode,
246
- onClick: () => {
247
- onChange({
248
- target: {
249
- value: voiceOption.name
250
- }
251
- });
252
- },
253
- children: /*#__PURE__*/_jsxs(Grid, {
254
- container: true,
255
- direction: "row",
576
+ children: [/*#__PURE__*/_jsxs(DialogTitle, {
577
+ id: titleId,
578
+ sx: {
579
+ p: "18px 20px 0",
580
+ display: "flex",
581
+ alignItems: "baseline",
582
+ gap: "10px",
583
+ flexWrap: "wrap",
584
+ fontSize: "16px",
585
+ fontWeight: 600
586
+ },
587
+ children: [panelTitle, showProviderIcons && /*#__PURE__*/_jsx(Typography, {
588
+ component: "span",
589
+ sx: theme => ({
590
+ fontSize: "11.5px",
591
+ color: panelTokens(theme).mutedText
592
+ }),
593
+ children: t("internal_view_provider_marks")
594
+ })]
595
+ }), /*#__PURE__*/_jsx(Tabs, {
596
+ value: activeAccentIndex,
597
+ onChange: handleAccentChange,
598
+ variant: "scrollable",
599
+ scrollButtons: "auto",
600
+ allowScrollButtonsMobile: true,
601
+ "aria-label": t("accent"),
602
+ sx: theme => ({
603
+ px: "20px",
604
+ pt: "16px",
605
+ minHeight: 0,
606
+ flex: "0 0 auto",
607
+ borderBottom: "1px solid",
608
+ borderColor: panelTokens(theme).tabRule,
609
+ // v9 renamed the flex container slot from flexContainer to list.
610
+ "& .MuiTabs-list": {
611
+ gap: "26px"
612
+ }
613
+ }),
614
+ children: voiceAccents.map((accent, index) => {
615
+ const isActive = index === activeAccentIndex;
616
+ return /*#__PURE__*/_jsx(Tab, {
617
+ disableRipple: true,
618
+ label: /*#__PURE__*/_jsxs(Box, {
256
619
  sx: {
257
- alignItems: "center",
258
- width: "100%"
620
+ display: "flex",
621
+ flexDirection: "column",
622
+ alignItems: "flex-start",
623
+ gap: "3px"
259
624
  },
260
- children: [/*#__PURE__*/_jsx(Grid, {
261
- children: /*#__PURE__*/_jsx(ListItemAvatar, {
262
- children: /*#__PURE__*/_jsx(Avatar, {
263
- src: voiceOption.userImage,
264
- alt: ""
265
- })
266
- })
267
- }), /*#__PURE__*/_jsxs(Grid, {
625
+ children: [/*#__PURE__*/_jsx(Box, {
626
+ component: "span",
268
627
  sx: {
269
- flexGrow: 1,
270
- display: "flex",
271
- alignItems: "center",
272
- gap: 1
628
+ fontSize: "14.5px",
629
+ fontWeight: isActive ? 600 : 500
273
630
  },
274
- children: [voiceOption.isChild ? `${voiceOption.name} (${t("child")})` : voiceOption.name, voiceOption.legacy && /*#__PURE__*/_jsx(Tooltip, {
275
- title: t("legacy_voice_tooltip_text"),
276
- placement: "right",
277
- children: /*#__PURE__*/_jsx(Chip, {
278
- icon: /*#__PURE__*/_jsx(WarningAmberIcon, {}),
279
- label: "Legacy",
280
- size: "small",
281
- color: "warning",
282
- variant: "outlined",
283
- sx: {
284
- height: 18,
285
- "& .MuiChip-icon": {
286
- fontSize: 11,
287
- marginLeft: 0.25,
288
- marginRight: -0.5
289
- },
290
- "& .MuiChip-label": {
291
- px: 0.75,
292
- fontSize: "0.625rem",
293
- fontWeight: 600
294
- }
295
- }
296
- })
297
- })]
298
- }), showProviderIcons && /*#__PURE__*/_jsx(Grid, {
299
- sx: {
300
- mr: 2,
301
- mt: 1
302
- },
303
- children: /*#__PURE__*/_jsx("img", {
304
- src: voiceOption.provider === "azure" ? azureIconPath : awsIconPath,
305
- alt: `${voiceOption.provider} Icon`
306
- })
631
+ children: accentTabLabel(accent.label)
632
+ }), /*#__PURE__*/_jsxs(Box, {
633
+ component: "span",
634
+ sx: theme => ({
635
+ fontSize: "11.5px",
636
+ letterSpacing: "0.04em",
637
+ fontWeight: 400,
638
+ color: isActive ? panelTokens(theme).mutedText : panelTokens(theme).faintText
639
+ }),
640
+ children: [(accent.voiceOptions || []).length, " ", t("voices")]
307
641
  })]
642
+ }),
643
+ sx: theme => ({
644
+ minWidth: 0,
645
+ minHeight: 0,
646
+ p: "0 2px 12px",
647
+ textTransform: "none",
648
+ alignItems: "flex-start",
649
+ "&:hover": {
650
+ backgroundColor: "transparent"
651
+ },
652
+ [theme.breakpoints.up("sm")]: {
653
+ minWidth: 0
654
+ }
308
655
  })
309
- }, voiceOption.name))
310
- })]
311
- }, voiceAccent.languageCode))
312
- }), value && !hidePitch && /*#__PURE__*/_jsxs(Box, {
313
- sx: {
314
- width: "100%",
315
- px: 1,
316
- my: 2
317
- },
318
- children: [/*#__PURE__*/_jsx(Typography, {
319
- id: "input-slider",
320
- gutterBottom: true,
321
- children: t("voice_pitch")
322
- }), /*#__PURE__*/_jsx(Grid, {
323
- container: true,
324
- spacing: 2,
656
+ }, accent.languageCode);
657
+ })
658
+ }), /*#__PURE__*/_jsx(DialogContent, {
659
+ role: "radiogroup",
660
+ "aria-label": panelTitle,
661
+ onKeyDown: handleGridKeyDown,
325
662
  sx: {
326
- alignItems: "center",
327
- width: "100%"
663
+ p: "16px 16px 18px",
664
+ display: "grid",
665
+ gridTemplateColumns: {
666
+ xs: "1fr",
667
+ sm: "repeat(2, 1fr)",
668
+ md: "repeat(3, 1fr)"
669
+ },
670
+ gap: "10px",
671
+ maxHeight: 420
672
+ },
673
+ children: activeVoices.map(voiceOption => /*#__PURE__*/_jsx(VoiceCard, {
674
+ t: t,
675
+ voiceOption: voiceOption,
676
+ languageCode: activeAccent?.languageCode,
677
+ selected: draftVoiceOption?.name === voiceOption.name,
678
+ tabbable: gridEntryName === voiceOption.name,
679
+ playing: playingVoice === voiceOption.name,
680
+ showProviderIcons: showProviderIcons,
681
+ showPreview: showPreview,
682
+ onSelect: () => setDraftVoice(voiceOption.name),
683
+ onPreview: () => handlePreview(voiceOption),
684
+ cardRef: node => {
685
+ cardRefs.current[voiceOption.name] = node;
686
+ }
687
+ }, voiceOption.name))
688
+ }), /*#__PURE__*/_jsxs(DialogActions, {
689
+ sx: theme => {
690
+ const tokens = panelTokens(theme);
691
+ return {
692
+ p: "13px 20px",
693
+ justifyContent: "space-between",
694
+ gap: "16px",
695
+ borderTop: "1px solid",
696
+ borderColor: tokens.footerRule,
697
+ backgroundColor: tokens.footerBg
698
+ };
328
699
  },
329
- children: /*#__PURE__*/_jsx(Grid, {
330
- size: "grow",
331
- children: /*#__PURE__*/_jsx(VoicePitchSilder, {
332
- t: t,
333
- selectedVoice: selectedVoice,
334
- voicePitch: voicePitch,
335
- onVoicePitchChange: onVoicePitchChange
700
+ children: [showPitch ? /*#__PURE__*/_jsx(VoicePitchToggle, {
701
+ t: t,
702
+ value: pitchFor(draftVoiceOption),
703
+ disabled: pitchDisabled,
704
+ onChange: setDraftPitch
705
+ }) :
706
+ /*#__PURE__*/
707
+ // Nothing to tune, so the footer states what's about to be
708
+ // committed — the draft's own accent, which may not be the tab on
709
+ // screen. Blank until something is drafted.
710
+ _jsx(Box, {
711
+ sx: {
712
+ display: "flex",
713
+ alignItems: "center",
714
+ gap: "10px",
715
+ minWidth: 0
716
+ },
717
+ children: draftVoiceOption && /*#__PURE__*/_jsxs(_Fragment, {
718
+ children: [/*#__PURE__*/_jsx(Avatar, {
719
+ src: draftVoiceOption.userImage,
720
+ alt: "",
721
+ sx: {
722
+ width: 30,
723
+ height: 30
724
+ }
725
+ }), /*#__PURE__*/_jsxs(Typography, {
726
+ sx: {
727
+ fontSize: "13.5px",
728
+ fontWeight: 600,
729
+ color: "text.primary",
730
+ overflow: "hidden",
731
+ textOverflow: "ellipsis",
732
+ whiteSpace: "nowrap"
733
+ },
734
+ children: [draftVoiceOption.name, draftAccent?.label ? ` · ${draftAccent.label}` : ""]
735
+ })]
336
736
  })
337
- })
737
+ }), /*#__PURE__*/_jsxs(Box, {
738
+ sx: {
739
+ display: "flex",
740
+ alignItems: "center",
741
+ gap: "8px",
742
+ flex: "0 0 auto"
743
+ },
744
+ children: [/*#__PURE__*/_jsx(Button, {
745
+ color: "primary",
746
+ onClick: handleClose,
747
+ sx: actionButtonSx,
748
+ "data-cy": "close-voice-selector",
749
+ children: t("close")
750
+ }), /*#__PURE__*/_jsx(Button, {
751
+ variant: "contained",
752
+ color: "primary",
753
+ disabled: !draftVoiceOption,
754
+ onClick: handleCommit,
755
+ sx: actionButtonSx,
756
+ children: t("use_voice")
757
+ })]
758
+ })]
759
+ }), /*#__PURE__*/_jsx(Box, {
760
+ "aria-live": "polite",
761
+ sx: visuallyHidden,
762
+ children: playingVoice ? `${t("playing")} ${playingVoice}` : ""
338
763
  })]
339
764
  })]
340
765
  });
341
766
  }
342
- VoiceSelector.propTypes = {
343
- t: PropTypes.func.isRequired,
344
- languages: PropTypes.object.isRequired,
345
- value: PropTypes.string.isRequired,
346
- onChange: PropTypes.func.isRequired,
347
- onBlur: PropTypes.func,
348
- error: PropTypes.bool
349
- };
350
767
  export default VoiceSelector;