@ews-admin/global-design-system 1.36.0 → 1.38.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 (43) 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 +35 -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.map +1 -1
  18. package/dist/hooks/index.d.ts +1 -1
  19. package/dist/hooks/index.d.ts.map +1 -1
  20. package/dist/hooks/useSpeechRecognition.d.ts +2 -0
  21. package/dist/hooks/useSpeechRecognition.d.ts.map +1 -1
  22. package/dist/index.css +1 -1
  23. package/dist/index.d.ts +116 -7
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.esm.css +1 -1
  26. package/dist/index.esm.js +431 -57
  27. package/dist/index.esm.js.map +1 -1
  28. package/dist/index.js +433 -55
  29. package/dist/index.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/AssistantChat/ActionTooltip.tsx +31 -0
  32. package/src/components/AssistantChat/AssistantChat.tsx +36 -4
  33. package/src/components/AssistantChat/AssistantChat.types.ts +25 -1
  34. package/src/components/AssistantChat/AssistantComposer.tsx +106 -68
  35. package/src/components/AssistantChat/AssistantDictationBar.tsx +268 -0
  36. package/src/components/AssistantChat/AssistantSuggestionCard.tsx +7 -6
  37. package/src/components/AssistantChat/AssistantVoiceMode.tsx +304 -0
  38. package/src/components/AssistantChat/assistantMarkdown.tsx +4 -1
  39. package/src/components/AssistantChat/index.ts +4 -0
  40. package/src/components/AssistantLauncher/AssistantLauncher.tsx +106 -77
  41. package/src/hooks/index.ts +1 -1
  42. package/src/hooks/useSpeechRecognition.ts +9 -0
  43. package/src/index.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "EWS Global Design System - Reusable components for EWS applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
@@ -0,0 +1,31 @@
1
+ import React, { useId } from "react";
2
+ import { cn } from "../../utils";
3
+
4
+ /**
5
+ * A visible tooltip for an icon button (hover and keyboard focus), so users
6
+ * know what the mic and the dictation buttons do. The button keeps its own
7
+ * aria-label; the tooltip is tied to it with aria-describedby.
8
+ */
9
+ export const ActionTooltip: React.FC<{
10
+ label: string;
11
+ children: (describedBy: string) => React.ReactNode;
12
+ className?: string;
13
+ }> = ({ label, children, className }) => {
14
+ const id = useId();
15
+ return (
16
+ <span className={cn("group relative inline-flex", className)}>
17
+ {children(id)}
18
+ <span
19
+ id={id}
20
+ role="tooltip"
21
+ className={cn(
22
+ "pointer-events-none absolute bottom-full start-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md",
23
+ "bg-ews-gray-900 px-2 py-1 text-xs font-medium text-white opacity-0 shadow transition-opacity duration-150",
24
+ "group-hover:opacity-100 group-focus-within:opacity-100 rtl:translate-x-1/2"
25
+ )}
26
+ >
27
+ {label}
28
+ </span>
29
+ </span>
30
+ );
31
+ };
@@ -12,6 +12,7 @@ import { AssistantConfirmationCard } from "./AssistantConfirmationCard";
12
12
  import { AssistantConversationList } from "./AssistantConversationList";
13
13
  import { AssistantMessages } from "./AssistantMessages";
14
14
  import { AssistantSuggestionCard } from "./AssistantSuggestionCard";
15
+ import { AssistantVoiceMode } from "./AssistantVoiceMode";
15
16
 
16
17
  const DIRECTION: Record<AssistantChatProps["language"], "ltr" | "rtl"> = {
17
18
  fr: "ltr",
@@ -55,6 +56,8 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
55
56
  onCancelConfirmation,
56
57
  layout = "page",
57
58
  queuedPrompt = null,
59
+ voiceRequest = null,
60
+ composerDraft = null,
58
61
  onNavigate,
59
62
  historyOpen = false,
60
63
  onHistoryOpenChange,
@@ -179,6 +182,15 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
179
182
  void send(queuedPrompt.content);
180
183
  }, [busy, queuedPrompt, send]);
181
184
 
185
+ // Voice conversation screen, opened by the mic (here or in the launcher bar).
186
+ const [voiceOpen, setVoiceOpen] = useState(false);
187
+ const handledVoiceRef = useRef<string | null>(null);
188
+ useEffect(() => {
189
+ if (!voiceRequest || voiceRequest.id === handledVoiceRef.current) return;
190
+ handledVoiceRef.current = voiceRequest.id;
191
+ setVoiceOpen(true);
192
+ }, [voiceRequest]);
193
+
182
194
  /** Retry re-sends the user message that preceded the failed answer. */
183
195
  const retry = useCallback(
184
196
  (failed: AssistantMessage) => {
@@ -211,7 +223,7 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
211
223
  dir={dir}
212
224
  lang={language}
213
225
  className={cn(
214
- "overflow-hidden bg-white",
226
+ "relative overflow-hidden bg-white",
215
227
  layout === "page" && "grid min-h-[36rem] grid-cols-1 rounded-2xl border border-ews-gray-200 shadow-sm md:grid-cols-[16rem_1fr]",
216
228
  layout === "wide" && "grid h-full min-h-0 grid-cols-1 md:grid-cols-[16rem_1fr]",
217
229
  compact && "relative flex h-full min-h-0 flex-col",
@@ -257,7 +269,7 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
257
269
  </div>
258
270
  )}
259
271
 
260
- <div className="flex min-h-0 min-w-0 flex-1 flex-col">
272
+ <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
261
273
  <header className="flex items-center justify-between gap-3 border-b border-ews-gray-200 px-4 py-3">
262
274
  <div className="flex min-w-0 items-center gap-2">
263
275
  <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-ews-primary text-sm font-bold text-white" aria-hidden="true">
@@ -309,7 +321,8 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
309
321
  <section
310
322
  className={cn(
311
323
  "relative overflow-hidden bg-ews-primary-light text-center",
312
- compact ? "px-4 py-6" : "px-6 py-10"
324
+ // Extra bottom room: the white sheet of cards overlaps the hero (mockup tools).
325
+ compact ? "px-4 pb-12 pt-8" : "px-6 pb-16 pt-12"
313
326
  )}
314
327
  >
315
328
  {heroBackground && <div className="absolute inset-0" aria-hidden="true">{heroBackground}</div>}
@@ -323,7 +336,11 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
323
336
  </div>
324
337
  </section>
325
338
  <div
326
- className={cn("grid gap-3 px-4 py-5", compact ? "grid-cols-1" : "sm:grid-cols-2 lg:grid-cols-4")}
339
+ // A white sheet rounded on top, slid over the bottom of the hero, holding the cards.
340
+ className={cn(
341
+ "relative -mt-6 grid flex-1 content-start gap-3 rounded-t-3xl bg-white px-4 py-5",
342
+ compact ? "grid-cols-2" : "grid-cols-2 lg:grid-cols-4"
343
+ )}
327
344
  data-testid="assistant-suggestions"
328
345
  >
329
346
  {suggestions.map((suggestion) => (
@@ -374,7 +391,11 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
374
391
  onSend={(content) => void send(content)}
375
392
  disabled={busy}
376
393
  voice={voice}
394
+ onVoice={() => setVoiceOpen(true)}
395
+ draft={composerDraft}
377
396
  texts={{
397
+ dictate: texts.dictate,
398
+ dictation: texts.dictation,
378
399
  placeholder: texts.composerPlaceholder,
379
400
  hint: texts.composerHint,
380
401
  send: texts.send,
@@ -384,6 +405,17 @@ export const AssistantChat: React.FC<AssistantChatProps> = ({
384
405
  />
385
406
  {disclaimer && <div className="mt-2 px-2 text-xs text-ews-gray-500">{disclaimer}</div>}
386
407
  </div>
408
+ {/* In the thread column, so the conversation list stays visible beside it in the wide layouts. */}
409
+ {voiceOpen && (
410
+ <AssistantVoiceMode
411
+ language={language}
412
+ messages={messages}
413
+ busy={busy}
414
+ onSend={(content) => void send(content)}
415
+ onClose={() => setVoiceOpen(false)}
416
+ texts={texts.voiceMode}
417
+ />
418
+ )}
387
419
  </div>
388
420
  </div>
389
421
  );
@@ -98,7 +98,7 @@ export interface AssistantChatTexts {
98
98
  composerPlaceholder: string;
99
99
  composerHint: string;
100
100
  send: string;
101
- /** Mic button: hold to talk. */
101
+ /** Mic button label: opens the voice conversation (MED-1801 voice mode). */
102
102
  holdToTalk: string;
103
103
  listening: string;
104
104
  thinking: string;
@@ -111,6 +111,23 @@ export interface AssistantChatTexts {
111
111
  cancel: string;
112
112
  /** The history button of the panel layout; falls back to `recentConversations`. */
113
113
  history?: string;
114
+ /** The dictation (waveform) button: label and tooltip. French default. */
115
+ dictate?: string;
116
+ /** Texts of the dictation recording bar; French defaults when absent. */
117
+ dictation?: { cancel?: string; done?: string; send?: string; unavailable?: string };
118
+ /** Texts of the voice conversation screen; French defaults when absent. */
119
+ voiceMode?: {
120
+ title?: string;
121
+ listening?: string;
122
+ thinking?: string;
123
+ speaking?: string;
124
+ muted?: string;
125
+ mute?: string;
126
+ unmute?: string;
127
+ end?: string;
128
+ /** Shown when the browser refuses the mic (permission denied, or the page is not https). */
129
+ unavailable?: string;
130
+ };
114
131
  }
115
132
 
116
133
  export interface AssistantConfirmation {
@@ -161,6 +178,13 @@ export interface AssistantChatProps {
161
178
  * soon as possible. Each `id` is sent once; a new id sends again.
162
179
  */
163
180
  queuedPrompt?: { id: string; content: string } | null;
181
+ /**
182
+ * Opens the voice conversation screen from outside (the launcher bar mic).
183
+ * Each `id` opens it once; a new id opens it again.
184
+ */
185
+ voiceRequest?: { id: string } | null;
186
+ /** A text to put in the composer from outside (a dictation done in the launcher bar). Each id once. */
187
+ composerDraft?: { id: string; content: string } | null;
164
188
  /**
165
189
  * Called when a link in an answer is clicked. Return true when the host
166
190
  * navigated itself (e.g. its router): the browser then does not follow the
@@ -1,21 +1,31 @@
1
- import { Mic, SendHorizontal } from "lucide-react";
2
- import React, { useCallback, useRef, useState } from "react";
3
- import { useSpeechRecognition } from "../../hooks/useSpeechRecognition";
1
+ import { AudioLines, Mic, SendHorizontal } from "lucide-react";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useSpeechSupported } from "../../hooks/useSpeechRecognition";
4
4
  import { cn } from "../../utils";
5
+ import { ActionTooltip } from "./ActionTooltip";
6
+ import { AssistantDictationBar, type AssistantDictationTexts } from "./AssistantDictationBar";
5
7
  import type { AssistantLanguage } from "./AssistantChat.types";
6
8
 
7
9
  export interface AssistantComposerProps {
8
10
  language: AssistantLanguage;
9
11
  onSend: (content: string) => void;
10
12
  disabled?: boolean;
11
- /** Mic button; hidden anyway where the browser has no speech API. */
13
+ /** Mic and dictation buttons; hidden anyway where the browser has no speech API. */
12
14
  voice?: boolean;
15
+ /** Mic click: opens the voice conversation. Without it the mic is not shown. */
16
+ onVoice?: () => void;
17
+ /** A text to put in the field from outside (e.g. a dictation done in the launcher bar). Each id once. */
18
+ draft?: { id: string; content: string } | null;
13
19
  texts: {
14
20
  placeholder: string;
15
21
  hint: string;
16
22
  send: string;
23
+ /** Mic: voice conversation (label and tooltip). */
17
24
  holdToTalk: string;
18
25
  listening: string;
26
+ /** Waveform button: dictation (label and tooltip). French default. */
27
+ dictate?: string;
28
+ dictation?: AssistantDictationTexts;
19
29
  };
20
30
  className?: string;
21
31
  }
@@ -23,19 +33,24 @@ export interface AssistantComposerProps {
23
33
  export const MAX_MESSAGE_LENGTH = 2000;
24
34
 
25
35
  /**
26
- * The composer of the mockup: textarea, hint, push-to-talk mic (hold to
27
- * record, release to send) and a send button. Enter sends, Shift+Enter breaks.
36
+ * The composer of the mockup: textarea, hint, a dictation button (waveform:
37
+ * a recording bar transcribes into the field), a mic (the voice conversation)
38
+ * and send. Both voice buttons show a tooltip. Enter sends, Shift+Enter breaks.
28
39
  */
29
40
  export const AssistantComposer: React.FC<AssistantComposerProps> = ({
30
41
  language,
31
42
  onSend,
32
43
  disabled = false,
33
44
  voice = true,
45
+ onVoice,
46
+ draft = null,
34
47
  texts,
35
48
  className,
36
49
  }) => {
37
50
  const [value, setValue] = useState("");
51
+ const [dictating, setDictating] = useState(false);
38
52
  const textareaRef = useRef<HTMLTextAreaElement>(null);
53
+ const speechSupported = useSpeechSupported();
39
54
 
40
55
  const submit = useCallback(
41
56
  (content: string) => {
@@ -47,11 +62,13 @@ export const AssistantComposer: React.FC<AssistantComposerProps> = ({
47
62
  [disabled, onSend]
48
63
  );
49
64
 
50
- const speech = useSpeechRecognition({
51
- language,
52
- onInterim: (text) => setValue(text),
53
- onResult: (text) => submit(text),
54
- });
65
+ const handledDraftRef = useRef<string | null>(null);
66
+ useEffect(() => {
67
+ if (!draft || draft.id === handledDraftRef.current) return;
68
+ handledDraftRef.current = draft.id;
69
+ setValue(draft.content.slice(0, MAX_MESSAGE_LENGTH));
70
+ textareaRef.current?.focus();
71
+ }, [draft]);
55
72
 
56
73
  const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
57
74
  if (event.key === "Enter" && !event.shiftKey) {
@@ -60,74 +77,95 @@ export const AssistantComposer: React.FC<AssistantComposerProps> = ({
60
77
  }
61
78
  };
62
79
 
63
- const showMic = voice && speech.supported;
80
+ const showVoice = voice && speechSupported;
81
+ const dictateLabel = texts.dictate ?? "Dicter un message";
64
82
 
65
83
  // A plain div rather than a <form>: Enter and the send button submit, and a
66
84
  // form followed by a sibling trips React hydration under happy-dom (the apps'
67
85
  // server-rendering tests) although browsers are fine.
68
86
  return (
69
87
  <div className={cn("w-full", className)}>
70
- <div
71
- className={cn(
72
- "flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm",
73
- "focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"
74
- )}
75
- >
76
- <textarea
77
- ref={textareaRef}
78
- value={value}
79
- onChange={(event) => setValue(event.target.value)}
80
- onKeyDown={onKeyDown}
81
- rows={1}
82
- maxLength={MAX_MESSAGE_LENGTH}
83
- placeholder={speech.listening ? texts.listening : texts.placeholder}
84
- aria-label={texts.placeholder}
85
- disabled={disabled}
86
- className="max-h-40 min-h-[2.5rem] flex-1 resize-none bg-transparent py-2 text-base text-ews-gray-900 placeholder:text-ews-gray-400 focus:outline-none disabled:opacity-60"
88
+ {dictating ? (
89
+ <AssistantDictationBar
90
+ language={language}
91
+ texts={texts.dictation}
92
+ onCancel={() => setDictating(false)}
93
+ onDone={(text) => {
94
+ setDictating(false);
95
+ if (text) setValue((current) => (current ? `${current} ${text}` : text).slice(0, MAX_MESSAGE_LENGTH));
96
+ setTimeout(() => textareaRef.current?.focus(), 0);
97
+ }}
98
+ onSend={(text) => {
99
+ setDictating(false);
100
+ submit(value ? `${value} ${text}` : text);
101
+ }}
87
102
  />
88
- {showMic && (
103
+ ) : (
104
+ <div
105
+ className={cn(
106
+ "flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm",
107
+ "focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"
108
+ )}
109
+ >
110
+ <textarea
111
+ ref={textareaRef}
112
+ value={value}
113
+ onChange={(event) => setValue(event.target.value)}
114
+ onKeyDown={onKeyDown}
115
+ rows={1}
116
+ maxLength={MAX_MESSAGE_LENGTH}
117
+ placeholder={texts.placeholder}
118
+ aria-label={texts.placeholder}
119
+ disabled={disabled}
120
+ className="max-h-40 min-h-[2.5rem] flex-1 resize-none bg-transparent py-2 text-base text-ews-gray-900 placeholder:text-ews-gray-400 focus:outline-none disabled:opacity-60"
121
+ />
122
+ {showVoice && (
123
+ <ActionTooltip label={dictateLabel}>
124
+ {(describedBy) => (
125
+ <button
126
+ type="button"
127
+ aria-label={dictateLabel}
128
+ aria-describedby={describedBy}
129
+ disabled={disabled}
130
+ onClick={() => setDictating(true)}
131
+ className="rounded-full p-2 text-ews-gray-500 transition-colors hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary disabled:opacity-50"
132
+ data-testid="assistant-dictate"
133
+ >
134
+ <AudioLines className="h-5 w-5" aria-hidden="true" />
135
+ </button>
136
+ )}
137
+ </ActionTooltip>
138
+ )}
139
+ {showVoice && onVoice && (
140
+ <ActionTooltip label={texts.holdToTalk}>
141
+ {(describedBy) => (
142
+ <button
143
+ type="button"
144
+ aria-label={texts.holdToTalk}
145
+ aria-describedby={describedBy}
146
+ disabled={disabled}
147
+ onClick={onVoice}
148
+ className="rounded-full p-2 text-ews-gray-500 transition-colors hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary disabled:opacity-50"
149
+ data-testid="assistant-mic"
150
+ >
151
+ <Mic className="h-5 w-5" aria-hidden="true" />
152
+ </button>
153
+ )}
154
+ </ActionTooltip>
155
+ )}
89
156
  <button
90
157
  type="button"
91
- aria-label={texts.holdToTalk}
92
- aria-pressed={speech.listening}
93
- title={texts.holdToTalk}
94
- disabled={disabled}
95
- onMouseDown={speech.start}
96
- onMouseUp={speech.stop}
97
- onMouseLeave={speech.listening ? speech.stop : undefined}
98
- onTouchStart={speech.start}
99
- onTouchEnd={speech.stop}
100
- onKeyDown={(event) => {
101
- if (event.key === " " || event.key === "Enter") {
102
- event.preventDefault();
103
- if (speech.listening) speech.stop();
104
- else speech.start();
105
- }
106
- }}
107
- className={cn(
108
- "rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary",
109
- speech.listening
110
- ? "bg-ews-error text-white animate-pulse"
111
- : "text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary",
112
- "disabled:opacity-50"
113
- )}
114
- data-testid="assistant-mic"
158
+ onClick={() => submit(value)}
159
+ aria-label={texts.send}
160
+ title={texts.send}
161
+ disabled={disabled || value.trim().length === 0}
162
+ className="rounded-full bg-ews-primary p-2 text-white transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1 disabled:opacity-40"
163
+ data-testid="assistant-send"
115
164
  >
116
- <Mic className="h-5 w-5" aria-hidden="true" />
165
+ <SendHorizontal className="h-5 w-5 rtl:-scale-x-100" aria-hidden="true" />
117
166
  </button>
118
- )}
119
- <button
120
- type="button"
121
- onClick={() => submit(value)}
122
- aria-label={texts.send}
123
- title={texts.send}
124
- disabled={disabled || value.trim().length === 0}
125
- className="rounded-full bg-ews-primary p-2 text-white transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1 disabled:opacity-40"
126
- data-testid="assistant-send"
127
- >
128
- <SendHorizontal className="h-5 w-5 rtl:-scale-x-100" aria-hidden="true" />
129
- </button>
130
- </div>
167
+ </div>
168
+ )}
131
169
  <p className="mt-1.5 px-2 text-xs text-ews-gray-400">{texts.hint}</p>
132
170
  </div>
133
171
  );