@ews-admin/global-design-system 1.37.0 → 1.39.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.
Files changed (47) hide show
  1. package/dist/components/AssistantChat/ActionTooltip.d.ts +12 -0
  2. package/dist/components/AssistantChat/ActionTooltip.d.ts.map +1 -0
  3. package/dist/components/AssistantChat/AssistantChat.d.ts.map +1 -1
  4. package/dist/components/AssistantChat/AssistantChat.types.d.ts +37 -1
  5. package/dist/components/AssistantChat/AssistantChat.types.d.ts.map +1 -1
  6. package/dist/components/AssistantChat/AssistantComposer.d.ts +16 -3
  7. package/dist/components/AssistantChat/AssistantComposer.d.ts.map +1 -1
  8. package/dist/components/AssistantChat/AssistantDictationBar.d.ts +28 -0
  9. package/dist/components/AssistantChat/AssistantDictationBar.d.ts.map +1 -0
  10. package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts +1 -1
  11. package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts.map +1 -1
  12. package/dist/components/AssistantChat/AssistantVoiceMode.d.ts +37 -0
  13. package/dist/components/AssistantChat/AssistantVoiceMode.d.ts.map +1 -0
  14. package/dist/components/AssistantChat/assistantMarkdown.d.ts.map +1 -1
  15. package/dist/components/AssistantChat/index.d.ts +4 -0
  16. package/dist/components/AssistantChat/index.d.ts.map +1 -1
  17. package/dist/components/AssistantLauncher/AssistantLauncher.d.ts +15 -0
  18. package/dist/components/AssistantLauncher/AssistantLauncher.d.ts.map +1 -1
  19. package/dist/components/AssistantLauncher/index.d.ts +1 -1
  20. package/dist/components/AssistantLauncher/index.d.ts.map +1 -1
  21. package/dist/hooks/index.d.ts +1 -1
  22. package/dist/hooks/index.d.ts.map +1 -1
  23. package/dist/hooks/useSpeechRecognition.d.ts +2 -0
  24. package/dist/hooks/useSpeechRecognition.d.ts.map +1 -1
  25. package/dist/index.css +1 -1
  26. package/dist/index.d.ts +133 -7
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.esm.css +1 -1
  29. package/dist/index.esm.js +435 -58
  30. package/dist/index.esm.js.map +1 -1
  31. package/dist/index.js +437 -56
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/AssistantChat/ActionTooltip.tsx +31 -0
  35. package/src/components/AssistantChat/AssistantChat.tsx +43 -5
  36. package/src/components/AssistantChat/AssistantChat.types.ts +27 -1
  37. package/src/components/AssistantChat/AssistantComposer.tsx +106 -68
  38. package/src/components/AssistantChat/AssistantDictationBar.tsx +268 -0
  39. package/src/components/AssistantChat/AssistantSuggestionCard.tsx +7 -6
  40. package/src/components/AssistantChat/AssistantVoiceMode.tsx +304 -0
  41. package/src/components/AssistantChat/assistantMarkdown.tsx +4 -1
  42. package/src/components/AssistantChat/index.ts +4 -0
  43. package/src/components/AssistantLauncher/AssistantLauncher.tsx +156 -79
  44. package/src/components/AssistantLauncher/index.ts +1 -1
  45. package/src/hooks/index.ts +1 -1
  46. package/src/hooks/useSpeechRecognition.ts +9 -0
  47. package/src/index.ts +9 -1
@@ -1,7 +1,9 @@
1
- import { ArrowUp, AudioLines, Maximize2, Mic, Minimize2, X } from "lucide-react";
1
+ import { ArrowLeft, ArrowUp, AudioLines, Maximize2, Mic, Minimize2, X } from "lucide-react";
2
2
  import React, { useCallback, useEffect, useId, useRef, useState } from "react";
3
3
  import { listAssistantConversations, saveAssistantConversation } from "../../assistant/store";
4
- import { useSpeechRecognition } from "../../hooks/useSpeechRecognition";
4
+ import { useSpeechSupported } from "../../hooks/useSpeechRecognition";
5
+ import { ActionTooltip } from "../AssistantChat/ActionTooltip";
6
+ import { AssistantDictationBar } from "../AssistantChat/AssistantDictationBar";
5
7
  import { cn, generateId } from "../../utils";
6
8
  import { AssistantChat } from "../AssistantChat/AssistantChat";
7
9
  import type {
@@ -23,6 +25,15 @@ export interface AssistantLauncherTexts {
23
25
  collapse?: string;
24
26
  /** The history button of the panel; falls back to `texts.recentConversations`. */
25
27
  history?: string;
28
+ /** The back button of an embedded page (`embed`); falls back to `close`. */
29
+ backToConversation?: string;
30
+ }
31
+
32
+ /** A page shown in the panel in place of the conversation, e.g. a booking calendar. */
33
+ export interface AssistantEmbeddedPage {
34
+ url: string;
35
+ /** Header title and accessible name of the frame. */
36
+ title: string;
26
37
  }
27
38
 
28
39
  export interface AssistantLauncherProps
@@ -47,6 +58,13 @@ export interface AssistantLauncherProps
47
58
  className?: string;
48
59
  /** Extra classes on the panel (e.g. a wider max width). */
49
60
  panelClassName?: string;
61
+ /**
62
+ * A page (iframe) shown in the panel over the conversation, which stays
63
+ * mounted underneath; the back button calls `onEmbedClose`. Null or absent
64
+ * shows the conversation.
65
+ */
66
+ embed?: AssistantEmbeddedPage | null;
67
+ onEmbedClose?: () => void;
50
68
  }
51
69
 
52
70
  const DIRECTION: Record<AssistantChatProps["language"], "ltr" | "rtl"> = {
@@ -86,6 +104,8 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
86
104
  hidden = false,
87
105
  className,
88
106
  panelClassName,
107
+ embed,
108
+ onEmbedClose,
89
109
  headerAction,
90
110
  voice = true,
91
111
  conversations: hostConversations,
@@ -147,11 +167,18 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
147
167
  [setOpen]
148
168
  );
149
169
 
150
- const speech = useSpeechRecognition({
151
- language: chatProps.language,
152
- onInterim: (text) => setDraft(text),
153
- onResult: (text) => submitFromBar(text),
154
- });
170
+ // One click on the bar mic (or the voice button) opens the panel straight in
171
+ // the voice conversation, listening at once.
172
+ const [voiceRequest, setVoiceRequest] = useState<{ id: string } | null>(null);
173
+ const openVoice = useCallback(() => {
174
+ setVoiceRequest({ id: generateId("v") });
175
+ setOpen(true);
176
+ }, [setOpen]);
177
+ const speechSupported = useSpeechSupported();
178
+ // The waveform button records a dictation in the bar itself (recording bar).
179
+ const [dictating, setDictating] = useState(false);
180
+ const [composerDraft, setComposerDraft] = useState<{ id: string; content: string } | null>(null);
181
+ const dictateLabel = chatProps.texts.dictate ?? launcherTexts.voice ?? "Dicter un message";
155
182
 
156
183
  const handleConversationCreated = useCallback(
157
184
  (conversation: AssistantConversationSummary) => {
@@ -218,7 +245,7 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
218
245
  if (hidden) return null;
219
246
 
220
247
  const dir = DIRECTION[chatProps.language];
221
- const showMic = voice && speech.supported;
248
+ const showMic = voice && speechSupported;
222
249
  const expandLabel = launcherTexts.expand ?? launcherTexts.open;
223
250
  const collapseLabel = launcherTexts.collapse ?? launcherTexts.close;
224
251
  const texts = launcherTexts.history ? { ...chatProps.texts, history: launcherTexts.history } : chatProps.texts;
@@ -254,78 +281,89 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
254
281
  >
255
282
  {chatProps.texts.brandName.charAt(0)}
256
283
  </button>
257
- {/* The field opens the panel: typing happens in the panel composer, which gets the focus. */}
258
- <input
259
- type="text"
260
- readOnly
261
- value={draft}
262
- onMouseDown={(event) => {
263
- event.preventDefault();
264
- setOpen(true);
265
- }}
266
- onFocus={() => setOpen(true)}
267
- onKeyDown={(event) => {
268
- if (event.key === "Enter" || event.key === " ") {
269
- event.preventDefault();
284
+ {dictating ? (
285
+ <AssistantDictationBar
286
+ language={chatProps.language}
287
+ texts={chatProps.texts.dictation}
288
+ className="min-w-0 flex-1"
289
+ onCancel={() => setDictating(false)}
290
+ onDone={(text) => {
291
+ setDictating(false);
292
+ if (text) setComposerDraft({ id: generateId("d"), content: text });
270
293
  setOpen(true);
271
- }
272
- }}
273
- placeholder={speech.listening ? chatProps.texts.listening : launcherTexts.open}
274
- aria-label={launcherTexts.open}
275
- aria-haspopup="dialog"
276
- aria-controls={panelId}
277
- className="min-w-0 flex-1 cursor-text bg-transparent py-2 text-lg text-ews-gray-800 placeholder:text-ews-gray-400 focus:outline-none"
278
- data-testid="assistant-launcher-input"
279
- />
280
- {showMic && (
281
- <button
282
- type="button"
283
- aria-label={chatProps.texts.holdToTalk}
284
- aria-pressed={speech.listening}
285
- title={chatProps.texts.holdToTalk}
286
- onMouseDown={speech.start}
287
- onMouseUp={speech.stop}
288
- onMouseLeave={speech.listening ? speech.stop : undefined}
289
- onTouchStart={speech.start}
290
- onTouchEnd={speech.stop}
291
- onKeyDown={(event) => {
292
- if (event.key === " " || event.key === "Enter") {
293
- event.preventDefault();
294
- if (speech.listening) speech.stop();
295
- else speech.start();
296
- }
297
294
  }}
298
- className={cn(
299
- "flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary",
300
- speech.listening
301
- ? "bg-ews-error text-white animate-pulse"
302
- : "text-ews-gray-600 hover:bg-ews-gray-100 hover:text-ews-primary"
295
+ onSend={(text) => {
296
+ setDictating(false);
297
+ submitFromBar(text);
298
+ }}
299
+ />
300
+ ) : (
301
+ <>
302
+ {/* The field opens the panel: typing happens in the panel composer, which gets the focus. */}
303
+ <input
304
+ type="text"
305
+ readOnly
306
+ value={draft}
307
+ onMouseDown={(event) => {
308
+ event.preventDefault();
309
+ setOpen(true);
310
+ }}
311
+ onFocus={() => setOpen(true)}
312
+ onKeyDown={(event) => {
313
+ if (event.key === "Enter" || event.key === " ") {
314
+ event.preventDefault();
315
+ setOpen(true);
316
+ }
317
+ }}
318
+ placeholder={launcherTexts.open}
319
+ aria-label={launcherTexts.open}
320
+ aria-haspopup="dialog"
321
+ aria-controls={panelId}
322
+ className="min-w-0 flex-1 cursor-text bg-transparent py-2 text-lg text-ews-gray-800 placeholder:text-ews-gray-400 focus:outline-none"
323
+ data-testid="assistant-launcher-input"
324
+ />
325
+ {showMic && (
326
+ <ActionTooltip label={chatProps.texts.holdToTalk}>
327
+ {(describedBy) => (
328
+ <button
329
+ type="button"
330
+ aria-label={chatProps.texts.holdToTalk}
331
+ aria-describedby={describedBy}
332
+ onClick={openVoice}
333
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-ews-gray-600 transition-colors hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary"
334
+ data-testid="assistant-launcher-mic"
335
+ >
336
+ <Mic className="h-5 w-5" aria-hidden="true" />
337
+ </button>
338
+ )}
339
+ </ActionTooltip>
303
340
  )}
304
- data-testid="assistant-launcher-mic"
305
- >
306
- <Mic className="h-5 w-5" aria-hidden="true" />
307
- </button>
341
+ <ActionTooltip label={dictateLabel}>
342
+ {(describedBy) => (
343
+ <button
344
+ type="button"
345
+ onClick={speechSupported ? () => setDictating(true) : () => setOpen(true)}
346
+ aria-label={dictateLabel}
347
+ aria-describedby={describedBy}
348
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ews-gray-100 text-ews-gray-700 transition-colors hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary"
349
+ data-testid="assistant-launcher-voice"
350
+ >
351
+ <AudioLines className="h-5 w-5" aria-hidden="true" />
352
+ </button>
353
+ )}
354
+ </ActionTooltip>
355
+ <button
356
+ type="button"
357
+ onClick={() => submitFromBar(draft)}
358
+ aria-label={chatProps.texts.send}
359
+ title={chatProps.texts.send}
360
+ className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-ews-primary text-white shadow-sm transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1"
361
+ data-testid="assistant-launcher-send"
362
+ >
363
+ <ArrowUp className="h-5 w-5" aria-hidden="true" />
364
+ </button>
365
+ </>
308
366
  )}
309
- <button
310
- type="button"
311
- onClick={() => setOpen(true)}
312
- aria-label={launcherTexts.voice ?? launcherTexts.open}
313
- title={launcherTexts.voice ?? launcherTexts.open}
314
- className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ews-gray-100 text-ews-gray-700 transition-colors hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary"
315
- data-testid="assistant-launcher-voice"
316
- >
317
- <AudioLines className="h-5 w-5" aria-hidden="true" />
318
- </button>
319
- <button
320
- type="button"
321
- onClick={() => submitFromBar(draft)}
322
- aria-label={chatProps.texts.send}
323
- title={chatProps.texts.send}
324
- className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-ews-primary text-white shadow-sm transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1"
325
- data-testid="assistant-launcher-send"
326
- >
327
- <ArrowUp className="h-5 w-5" aria-hidden="true" />
328
- </button>
329
367
  </div>
330
368
  </div>
331
369
  )}
@@ -337,7 +375,9 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
337
375
  data-testid="assistant-launcher-panel"
338
376
  >
339
377
  <div
340
- className="absolute inset-0 bg-black/40"
378
+ // Mobile: a dimmed page under a full-screen panel. From tablets up the panel floats over the
379
+ // page, which stays visible (no dimming); a click outside still closes it.
380
+ className="absolute inset-0 bg-black/40 sm:bg-transparent"
341
381
  onClick={() => setOpen(false)}
342
382
  data-testid="assistant-launcher-overlay"
343
383
  />
@@ -349,18 +389,55 @@ export const AssistantLauncher: React.FC<AssistantLauncherProps> = ({
349
389
  aria-label={launcherTexts.open}
350
390
  data-expanded={isExpanded}
351
391
  className={cn(
352
- "absolute inset-y-0 end-0 flex w-full flex-col bg-white shadow-2xl transition-[width,max-width] duration-200",
392
+ "absolute inset-y-0 end-0 flex w-full flex-col bg-white shadow-2xl",
393
+ // From tablets up: a floating card (MED-1801, like the Notiwa widget), clear of the top,
394
+ // the bottom and the side of the screen, rounded, with a soft border and shadow.
395
+ "sm:inset-y-auto sm:bottom-6 sm:end-6 sm:overflow-hidden sm:rounded-3xl sm:border sm:border-ews-gray-100",
396
+ "sm:shadow-[0_24px_60px_rgba(15,23,42,0.22)]",
397
+ // Only fixed widths and heights, so expanding animates from one size to the other (a
398
+ // max-width switching to none jumped to full width first).
399
+ "sm:max-w-[calc(100vw-3rem)] sm:transition-[width,height] sm:duration-300 sm:ease-out",
400
+ isExpanded ? "sm:h-[calc(100vh-3rem)]" : "sm:h-[calc(100vh-6rem)]",
353
401
  // Expanded: a dashboard reaching the middle of the screen (never full width), tablets and up.
354
- isExpanded ? "sm:max-w-md md:w-[max(50vw,40rem)] md:max-w-none" : "sm:max-w-md",
402
+ isExpanded ? "sm:w-[32rem] md:w-[max(50vw,40rem)]" : "sm:w-[32rem]",
355
403
  panelClassName
356
404
  )}
357
405
  >
406
+ {embed && (
407
+ <div className="absolute inset-0 z-20 flex flex-col bg-white" data-testid="assistant-launcher-embed">
408
+ <div className="flex items-center gap-2 border-b border-ews-gray-100 px-3 py-2">
409
+ <button
410
+ type="button"
411
+ onClick={onEmbedClose}
412
+ aria-label={launcherTexts.backToConversation ?? launcherTexts.close}
413
+ title={launcherTexts.backToConversation ?? launcherTexts.close}
414
+ className="rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary"
415
+ data-testid="assistant-launcher-embed-back"
416
+ >
417
+ <ArrowLeft className="h-5 w-5 rtl:rotate-180" aria-hidden="true" />
418
+ </button>
419
+ <h2 className="min-w-0 flex-1 truncate text-base font-semibold text-ews-gray-900">{embed.title}</h2>
420
+ <button
421
+ type="button"
422
+ onClick={() => setOpen(false)}
423
+ aria-label={launcherTexts.close}
424
+ title={launcherTexts.close}
425
+ className="rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary"
426
+ >
427
+ <X className="h-5 w-5" aria-hidden="true" />
428
+ </button>
429
+ </div>
430
+ <iframe title={embed.title} src={embed.url} className="block w-full flex-1 border-0" />
431
+ </div>
432
+ )}
358
433
  <AssistantChat
359
434
  {...chatProps}
360
435
  texts={texts}
361
436
  voice={voice}
362
437
  layout={isExpanded ? "wide" : "panel"}
363
438
  queuedPrompt={queuedPrompt}
439
+ voiceRequest={voiceRequest}
440
+ composerDraft={composerDraft}
364
441
  conversations={hostManaged ? hostConversations : storedConversations}
365
442
  activeConversationId={hostManaged ? hostActiveId : managedActiveId}
366
443
  initialMessages={hostManaged ? hostInitialMessages : managedMessages}
@@ -1,2 +1,2 @@
1
1
  export { AssistantLauncher } from "./AssistantLauncher";
2
- export type { AssistantLauncherProps, AssistantLauncherTexts } from "./AssistantLauncher";
2
+ export type { AssistantEmbeddedPage, AssistantLauncherProps, AssistantLauncherTexts } from "./AssistantLauncher";
@@ -4,5 +4,5 @@ export { useIdleLock } from "./useIdleLock";
4
4
  export type { UseIdleLockOptions, UseIdleLockResult } from "./useIdleLock";
5
5
  export { useSelectField } from "./useSelectField";
6
6
  export type { UseSelectFieldProps } from "./useSelectField";
7
- export { useSpeechRecognition, getSpeechRecognition } from "./useSpeechRecognition";
7
+ export { useSpeechRecognition, useSpeechSupported, getSpeechRecognition } from "./useSpeechRecognition";
8
8
  export type { UseSpeechRecognitionOptions } from "./useSpeechRecognition";
@@ -26,6 +26,15 @@ export function getSpeechRecognition(): SpeechRecognitionCtor | undefined {
26
26
  return w.SpeechRecognition ?? w.webkitSpeechRecognition;
27
27
  }
28
28
 
29
+ /** True once mounted in a browser that has the speech recognition API (false on the server). */
30
+ export function useSpeechSupported(): boolean {
31
+ const [supported, setSupported] = useState(false);
32
+ useEffect(() => {
33
+ setSupported(getSpeechRecognition() !== undefined);
34
+ }, []);
35
+ return supported;
36
+ }
37
+
29
38
  export interface UseSpeechRecognitionOptions {
30
39
  /** `fr`, `en`, `ar` or a BCP 47 tag. */
31
40
  language: string;
package/src/index.ts CHANGED
@@ -65,6 +65,9 @@ export type { PinCodeInputProps } from "./components/PinCodeInput";
65
65
  export {
66
66
  AssistantChat,
67
67
  AssistantComposer,
68
+ AssistantVoiceMode,
69
+ AssistantDictationBar,
70
+ toSpokenText,
68
71
  AssistantConfirmationCard,
69
72
  AssistantConversationList,
70
73
  AssistantMessages,
@@ -90,12 +93,16 @@ export type {
90
93
  AssistantMessagesProps,
91
94
  AssistantSuggestion,
92
95
  AssistantSuggestionCardProps,
96
+ AssistantVoiceModeProps,
97
+ AssistantDictationBarProps,
98
+ AssistantDictationTexts,
99
+ AssistantVoiceModeTexts,
93
100
  AssistantTransport,
94
101
  } from "./components/AssistantChat";
95
102
 
96
103
  // MED-1787: in-app launcher (bottom bar + slide-over) and the shared assistant client
97
104
  export { AssistantLauncher } from "./components/AssistantLauncher";
98
- export type { AssistantLauncherProps, AssistantLauncherTexts } from "./components/AssistantLauncher";
105
+ export type { AssistantEmbeddedPage, AssistantLauncherProps, AssistantLauncherTexts } from "./components/AssistantLauncher";
99
106
  export {
100
107
  ASSISTANT_CLIENT_ID_KEY,
101
108
  ASSISTANT_CONVERSATIONS_KEY,
@@ -162,6 +169,7 @@ export {
162
169
  useMediaQuery,
163
170
  useSelectField,
164
171
  useSpeechRecognition,
172
+ useSpeechSupported,
165
173
  getSpeechRecognition,
166
174
  } from "./hooks";
167
175
  export type {