botframework-webchat-fluent-theme 4.17.0-main.20240411.647b269 → 4.17.0-main.20240411.ff34969
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/botframework-webchat-fluent-theme.development.js +4081 -0
- package/dist/botframework-webchat-fluent-theme.development.js.map +1 -0
- package/dist/botframework-webchat-fluent-theme.production.min.js +8 -16
- package/dist/botframework-webchat-fluent-theme.production.min.js.map +1 -1
- package/dist/index.js +1350 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -4
- package/src/bundle.ts +9 -2
- package/src/components/DropZone.tsx +3 -0
- package/src/components/SendBox.tsx +3 -0
- package/src/components/SuggestedActions.tsx +3 -0
- package/src/components/TelephoneKeypad.tsx +1 -0
- package/src/components/Theme.tsx +71 -0
- package/src/components/dropZone/index.tsx +132 -0
- package/src/components/sendbox/AddAttachmentButton.tsx +77 -0
- package/src/components/sendbox/Attachments.tsx +40 -0
- package/src/components/sendbox/ErrorMessage.tsx +26 -0
- package/src/components/sendbox/TelephoneKeypadToolbarButton.tsx +30 -0
- package/src/components/sendbox/TextArea.tsx +146 -0
- package/src/components/sendbox/Toolbar.tsx +115 -0
- package/src/components/sendbox/index.tsx +234 -0
- package/src/components/sendbox/private/useSubmitError.ts +46 -0
- package/src/components/sendbox/private/useUniqueId.ts +13 -0
- package/src/components/suggestedActions/AccessibleButton.tsx +59 -0
- package/src/components/suggestedActions/SuggestedAction.tsx +129 -0
- package/src/components/suggestedActions/index.tsx +103 -0
- package/src/components/suggestedActions/private/computeSuggestedActionText.ts +21 -0
- package/src/components/telephoneKeypad/Provider.tsx +22 -0
- package/src/components/telephoneKeypad/Surrogate.tsx +13 -0
- package/src/components/telephoneKeypad/index.ts +6 -0
- package/src/components/telephoneKeypad/private/Button.tsx +107 -0
- package/src/components/telephoneKeypad/private/Context.ts +18 -0
- package/src/components/telephoneKeypad/private/TelephoneKeypad.tsx +168 -0
- package/src/components/telephoneKeypad/types.ts +1 -0
- package/src/components/telephoneKeypad/useShown.ts +9 -0
- package/src/external.umd/botframework-webchat-api.ts +3 -0
- package/src/external.umd/botframework-webchat-component.ts +4 -0
- package/src/external.umd/react.ts +1 -0
- package/src/icons/AddDocumentIcon.tsx +20 -0
- package/src/icons/AttachmentIcon.tsx +20 -0
- package/src/icons/SendIcon.tsx +20 -0
- package/src/icons/TelephoneKeypad.tsx +20 -0
- package/src/index.ts +2 -1
- package/src/private/FluentThemeProvider.tsx +11 -7
- package/src/private/useStyleToEmotionObject.ts +32 -0
- package/src/styles/index.ts +15 -0
- package/src/testIds.ts +21 -0
- package/src/types/PropsOf.ts +7 -0
- package/src/external/ThemeProvider.tsx +0 -16
- package/src/private/SendBox.tsx +0 -7
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { hooks } from 'botframework-webchat-component';
|
|
2
|
+
import cx from 'classnames';
|
|
3
|
+
import React, { memo, useCallback, useRef, useState, type FormEventHandler, type MouseEventHandler } from 'react';
|
|
4
|
+
import { useRefFrom } from 'use-ref-from';
|
|
5
|
+
import { SendIcon } from '../../icons/SendIcon';
|
|
6
|
+
import { useStyles } from '../../styles';
|
|
7
|
+
import testIds from '../../testIds';
|
|
8
|
+
import DropZone from '../DropZone';
|
|
9
|
+
import SuggestedActions from '../SuggestedActions';
|
|
10
|
+
import { TelephoneKeypadSurrogate, useTelephoneKeypadShown, type DTMF } from '../TelephoneKeypad';
|
|
11
|
+
import AddAttachmentButton from './AddAttachmentButton';
|
|
12
|
+
import Attachments from './Attachments';
|
|
13
|
+
import ErrorMessage from './ErrorMessage';
|
|
14
|
+
import TelephoneKeypadToolbarButton from './TelephoneKeypadToolbarButton';
|
|
15
|
+
import TextArea from './TextArea';
|
|
16
|
+
import { Toolbar, ToolbarButton, ToolbarSeparator } from './Toolbar';
|
|
17
|
+
import useSubmitError from './private/useSubmitError';
|
|
18
|
+
import useUniqueId from './private/useUniqueId';
|
|
19
|
+
|
|
20
|
+
const { useStyleOptions, useMakeThumbnail, useLocalizer, useSendBoxAttachments, useSendMessage } = hooks;
|
|
21
|
+
|
|
22
|
+
const styles = {
|
|
23
|
+
'webchat-fluent__sendbox': {
|
|
24
|
+
color: 'var(--webchat-colorNeutralForeground1)',
|
|
25
|
+
fontFamily: 'var(--webchat-fontFamilyBase)',
|
|
26
|
+
padding: '0 10px 10px',
|
|
27
|
+
textRendering: 'optimizeLegibility'
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
'webchat-fluent__sendbox__sendbox': {
|
|
31
|
+
backgroundColor: 'var(--webchat-colorNeutralBackground1)',
|
|
32
|
+
border: '1px solid var(--webchat-colorNeutralStroke1)',
|
|
33
|
+
borderRadius: 'var(--webchat-borderRadiusLarge)',
|
|
34
|
+
display: 'grid',
|
|
35
|
+
fontFamily: 'var(--webchat-fontFamilyBase)',
|
|
36
|
+
fontSize: '14px',
|
|
37
|
+
gap: '6px',
|
|
38
|
+
lineHeight: '20px',
|
|
39
|
+
padding: '8px',
|
|
40
|
+
position: 'relative',
|
|
41
|
+
|
|
42
|
+
'&:focus-within': {
|
|
43
|
+
borderColor: 'var(--webchat-colorNeutralStroke1Selected)',
|
|
44
|
+
// TODO clarify with design the color:
|
|
45
|
+
// - Teams is using colorCompoundBrandForeground1
|
|
46
|
+
// - Fluent is using colorCompoundBrandStroke
|
|
47
|
+
// - we are using colorCompoundBrandForeground1Hover
|
|
48
|
+
boxShadow: 'inset 0 -6px 0 -3px var(--webchat-colorCompoundBrandForeground1Hover)'
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
'webchat-fluent__sendbox__sendbox-text': {
|
|
53
|
+
backgroundColor: 'transparent',
|
|
54
|
+
border: 'none',
|
|
55
|
+
flex: 'auto',
|
|
56
|
+
fontFamily: 'var(--webchat-fontFamilyBase)',
|
|
57
|
+
fontSize: '14px',
|
|
58
|
+
lineHeight: '20px',
|
|
59
|
+
outline: 'none',
|
|
60
|
+
padding: '4px 4px 0',
|
|
61
|
+
resize: 'none'
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
'webchat-fluent__sendbox__sendbox-controls': {
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
display: 'flex',
|
|
67
|
+
paddingInlineStart: '4px'
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
'webchat-fluent__sendbox__text-counter': {
|
|
71
|
+
color: 'var(--webchat-colorNeutralForeground4)',
|
|
72
|
+
cursor: 'default',
|
|
73
|
+
fontFamily: 'var(--webchat-fontFamilyNumeric)',
|
|
74
|
+
fontSize: '10px',
|
|
75
|
+
lineHeight: '14px'
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'webchat-fluent__sendbox__text-counter--error': {
|
|
79
|
+
color: 'var(--webchat-colorStatusDangerForeground1)'
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function SendBox(
|
|
84
|
+
props: Readonly<{
|
|
85
|
+
className?: string | undefined;
|
|
86
|
+
placeholder?: string | undefined;
|
|
87
|
+
}>
|
|
88
|
+
) {
|
|
89
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
90
|
+
const [message, setMessage] = useState('');
|
|
91
|
+
const [attachments, setAttachments] = useSendBoxAttachments();
|
|
92
|
+
const [{ maxMessageLength }] = useStyleOptions();
|
|
93
|
+
const isMessageLengthExceeded = !!maxMessageLength && message.length > maxMessageLength;
|
|
94
|
+
const classNames = useStyles(styles);
|
|
95
|
+
const localize = useLocalizer();
|
|
96
|
+
const sendMessage = useSendMessage();
|
|
97
|
+
const makeThumbnail = useMakeThumbnail();
|
|
98
|
+
const errorMessageId = useUniqueId('webchat-fluent__sendbox__error-message-id');
|
|
99
|
+
const [errorRef, errorMessage] = useSubmitError({ message, attachments });
|
|
100
|
+
const [telephoneKeypadShown, setTelephoneKeypadShown] = useTelephoneKeypadShown();
|
|
101
|
+
|
|
102
|
+
const attachmentsRef = useRefFrom(attachments);
|
|
103
|
+
const messageRef = useRefFrom(message);
|
|
104
|
+
|
|
105
|
+
const handleSendBoxClick = useCallback<MouseEventHandler>(
|
|
106
|
+
event => {
|
|
107
|
+
if ('tabIndex' in event.target && typeof event.target.tabIndex === 'number' && event.target.tabIndex >= 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// TODO: Should call `useFocus('sendBox')`.
|
|
112
|
+
inputRef.current?.focus();
|
|
113
|
+
},
|
|
114
|
+
[inputRef]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const handleMessageChange: React.FormEventHandler<HTMLTextAreaElement> = useCallback(
|
|
118
|
+
event => setMessage(event.currentTarget.value),
|
|
119
|
+
[setMessage]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const handleAddFiles = useCallback(
|
|
123
|
+
async (inputFiles: File[]) => {
|
|
124
|
+
const newAttachments = Object.freeze(
|
|
125
|
+
await Promise.all(
|
|
126
|
+
inputFiles.map(file =>
|
|
127
|
+
makeThumbnail(file).then(thumbnailURL =>
|
|
128
|
+
Object.freeze({
|
|
129
|
+
blob: file,
|
|
130
|
+
...(thumbnailURL && { thumbnailURL })
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
setAttachments(newAttachments);
|
|
138
|
+
|
|
139
|
+
// TODO: Currently in the UX, we have no way to remove attachments.
|
|
140
|
+
// Keep concatenating doesn't make sense in current UX.
|
|
141
|
+
// When end-user can remove attachment, we should enable the code again.
|
|
142
|
+
// setAttachments(attachments => attachments.concat(newAttachments));
|
|
143
|
+
},
|
|
144
|
+
[makeThumbnail, setAttachments]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const handleFormSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
|
148
|
+
event => {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
|
|
151
|
+
if (errorRef.current !== 'empty' && !isMessageLengthExceeded) {
|
|
152
|
+
sendMessage(messageRef.current, undefined, { attachments: attachmentsRef.current });
|
|
153
|
+
|
|
154
|
+
setMessage('');
|
|
155
|
+
setAttachments([]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// TODO: Should call `useFocus('sendBox')`.
|
|
159
|
+
inputRef.current?.focus();
|
|
160
|
+
},
|
|
161
|
+
[attachmentsRef, messageRef, sendMessage, setAttachments, setMessage, isMessageLengthExceeded, errorRef, inputRef]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const handleTelephoneKeypadButtonClick = useCallback(
|
|
165
|
+
(dtmf: DTMF) => {
|
|
166
|
+
// TODO: We need more official way of sending DTMF.
|
|
167
|
+
sendMessage(`/DTMF ${dtmf}`);
|
|
168
|
+
|
|
169
|
+
// TODO: In the future when we work on input modality, it should manage the focus in a better way.
|
|
170
|
+
setTelephoneKeypadShown(false);
|
|
171
|
+
},
|
|
172
|
+
[sendMessage, setTelephoneKeypadShown]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const aria = {
|
|
176
|
+
'aria-invalid': 'false' as const,
|
|
177
|
+
...(errorMessage && {
|
|
178
|
+
'aria-invalid': 'true' as const,
|
|
179
|
+
'aria-errormessage': errorMessageId
|
|
180
|
+
})
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<form {...aria} className={cx(classNames['webchat-fluent__sendbox'], props.className)} onSubmit={handleFormSubmit}>
|
|
185
|
+
<SuggestedActions />
|
|
186
|
+
<div className={cx(classNames['webchat-fluent__sendbox__sendbox'])} onClickCapture={handleSendBoxClick}>
|
|
187
|
+
<TelephoneKeypadSurrogate
|
|
188
|
+
autoFocus={true}
|
|
189
|
+
isHorizontal={false}
|
|
190
|
+
onButtonClick={handleTelephoneKeypadButtonClick}
|
|
191
|
+
/>
|
|
192
|
+
<TextArea
|
|
193
|
+
aria-label={isMessageLengthExceeded ? localize('TEXT_INPUT_LENGTH_EXCEEDED_ALT') : localize('TEXT_INPUT_ALT')}
|
|
194
|
+
className={classNames['webchat-fluent__sendbox__sendbox-text']}
|
|
195
|
+
data-testid={testIds.sendBoxTextBox}
|
|
196
|
+
hidden={telephoneKeypadShown}
|
|
197
|
+
onInput={handleMessageChange}
|
|
198
|
+
placeholder={props.placeholder ?? localize('TEXT_INPUT_PLACEHOLDER')}
|
|
199
|
+
ref={inputRef}
|
|
200
|
+
value={message}
|
|
201
|
+
/>
|
|
202
|
+
<Attachments attachments={attachments} />
|
|
203
|
+
<div className={cx(classNames['webchat-fluent__sendbox__sendbox-controls'])}>
|
|
204
|
+
{maxMessageLength && (
|
|
205
|
+
<div
|
|
206
|
+
className={cx(classNames['webchat-fluent__sendbox__text-counter'], {
|
|
207
|
+
[classNames['webchat-fluent__sendbox__text-counter--error']]: isMessageLengthExceeded
|
|
208
|
+
})}
|
|
209
|
+
>
|
|
210
|
+
{`${message.length}/${maxMessageLength}`}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
<Toolbar>
|
|
214
|
+
<TelephoneKeypadToolbarButton />
|
|
215
|
+
<AddAttachmentButton onFilesAdded={handleAddFiles} />
|
|
216
|
+
<ToolbarSeparator />
|
|
217
|
+
<ToolbarButton
|
|
218
|
+
aria-label={localize('TEXT_INPUT_SEND_BUTTON_ALT')}
|
|
219
|
+
data-testid={testIds.sendBoxSendButton}
|
|
220
|
+
disabled={isMessageLengthExceeded}
|
|
221
|
+
type="submit"
|
|
222
|
+
>
|
|
223
|
+
<SendIcon />
|
|
224
|
+
</ToolbarButton>
|
|
225
|
+
</Toolbar>
|
|
226
|
+
</div>
|
|
227
|
+
<DropZone onFilesAdded={handleAddFiles} />
|
|
228
|
+
<ErrorMessage error={errorMessage} id={errorMessageId} />
|
|
229
|
+
</div>
|
|
230
|
+
</form>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default memo(SendBox);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { hooks } from 'botframework-webchat-component';
|
|
2
|
+
import { RefObject, useMemo } from 'react';
|
|
3
|
+
import { useRefFrom } from 'use-ref-from';
|
|
4
|
+
|
|
5
|
+
const { useConnectivityStatus, useLocalizer } = hooks;
|
|
6
|
+
|
|
7
|
+
type ErrorMessageStringMap = ReadonlyMap<SendError, string>;
|
|
8
|
+
|
|
9
|
+
type SendError = 'empty' | 'offline';
|
|
10
|
+
|
|
11
|
+
const useSubmitError = ({
|
|
12
|
+
attachments,
|
|
13
|
+
message
|
|
14
|
+
}: Readonly<{
|
|
15
|
+
attachments: readonly Readonly<{ blob: Blob | File; thumbnailURL?: URL | undefined }>[];
|
|
16
|
+
message: string;
|
|
17
|
+
}>) => {
|
|
18
|
+
const [connectivityStatus] = useConnectivityStatus();
|
|
19
|
+
const localize = useLocalizer();
|
|
20
|
+
|
|
21
|
+
const submitErrorRef = useRefFrom<'empty' | 'offline' | undefined>(
|
|
22
|
+
connectivityStatus !== 'connected' && connectivityStatus !== 'reconnected'
|
|
23
|
+
? 'offline'
|
|
24
|
+
: !message && !attachments.length
|
|
25
|
+
? 'empty'
|
|
26
|
+
: undefined
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const errorMessageStringMap = useMemo<ErrorMessageStringMap>(
|
|
30
|
+
() =>
|
|
31
|
+
Object.freeze(
|
|
32
|
+
new Map<SendError, string>()
|
|
33
|
+
.set('empty', localize('SEND_BOX_IS_EMPTY_TOOLTIP_ALT'))
|
|
34
|
+
// TODO: [P0] We should add a new string for "Cannot send message while offline."
|
|
35
|
+
.set('offline', localize('CONNECTIVITY_STATUS_ALT_FATAL'))
|
|
36
|
+
),
|
|
37
|
+
[localize]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return useMemo<Readonly<[RefObject<SendError | undefined>, string | undefined]>>(
|
|
41
|
+
() => Object.freeze([submitErrorRef, submitErrorRef.current && errorMessageStringMap.get(submitErrorRef.current)]),
|
|
42
|
+
[errorMessageStringMap, submitErrorRef]
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default useSubmitError;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* eslint no-magic-numbers: ["error", { "ignore": [2, 5, 36] }] */
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
// TODO: fix math-random fails to import crypto
|
|
5
|
+
// import random from 'math-random';
|
|
6
|
+
|
|
7
|
+
export default function useUniqueId(prefix?: string): string {
|
|
8
|
+
const id = useMemo(() => Math.random().toString(36).substr(2, 5), []);
|
|
9
|
+
|
|
10
|
+
prefix = prefix ? `${prefix}--` : '';
|
|
11
|
+
|
|
12
|
+
return `${prefix}${id}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { MouseEventHandler, ReactNode, forwardRef, memo, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const preventDefaultHandler: MouseEventHandler<HTMLButtonElement> = event => event.preventDefault();
|
|
4
|
+
|
|
5
|
+
type AccessibleButtonProps = Readonly<{
|
|
6
|
+
className?: string | undefined;
|
|
7
|
+
'aria-hidden'?: boolean;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
11
|
+
tabIndex?: number;
|
|
12
|
+
type: 'button';
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
// Differences between <button> and <AccessibleButton>:
|
|
16
|
+
// - Disable behavior
|
|
17
|
+
// - When the widget is disabled
|
|
18
|
+
// - Set "aria-disabled" attribute to "true"
|
|
19
|
+
// - Set "readonly" attribute
|
|
20
|
+
// - Set "tabIndex" to -1
|
|
21
|
+
// - Remove "onClick" handler
|
|
22
|
+
// - Why this is needed
|
|
23
|
+
// - Browser compatibility: when the widget is disabled, different browser send focus to different places
|
|
24
|
+
// - When the widget become disabled, it's reasonable to keep the focus on the same widget for an extended period of time
|
|
25
|
+
// - When the user presses TAB after the current widget is disabled, it should jump to the next non-disabled widget
|
|
26
|
+
|
|
27
|
+
// Developers using this accessible widget will need to:
|
|
28
|
+
// - Style the disabled widget themselves, using CSS query `:disabled, [aria-disabled="true"] {}`
|
|
29
|
+
// - Modify all code that check disabled through "disabled" attribute to use aria-disabled="true" instead
|
|
30
|
+
// - aria-disabled="true" is the source of truth
|
|
31
|
+
// - If the widget is contained by a <form>, the developer need to filter out some `onSubmit` event caused by this widget
|
|
32
|
+
|
|
33
|
+
const AccessibleButton = forwardRef<HTMLButtonElement, AccessibleButtonProps>(
|
|
34
|
+
({ 'aria-hidden': ariaHidden, children, disabled, onClick, tabIndex, ...props }, forwardedRef) => {
|
|
35
|
+
const targetRef = useRef<HTMLButtonElement>(null);
|
|
36
|
+
|
|
37
|
+
const ref = forwardedRef || targetRef;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
aria-disabled={disabled ? 'true' : 'false'}
|
|
42
|
+
aria-hidden={ariaHidden}
|
|
43
|
+
onClick={disabled ? preventDefaultHandler : onClick}
|
|
44
|
+
ref={ref}
|
|
45
|
+
tabIndex={tabIndex}
|
|
46
|
+
{...(disabled && {
|
|
47
|
+
'aria-disabled': 'true',
|
|
48
|
+
tabIndex: -1
|
|
49
|
+
})}
|
|
50
|
+
{...props}
|
|
51
|
+
type="button"
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export default memo(AccessibleButton);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { hooks } from 'botframework-webchat-component';
|
|
2
|
+
import { type DirectLineCardAction } from 'botframework-webchat-core';
|
|
3
|
+
import cx from 'classnames';
|
|
4
|
+
import React, { MouseEventHandler, memo, useCallback, useRef } from 'react';
|
|
5
|
+
import AccessibleButton from './AccessibleButton';
|
|
6
|
+
import { useStyles } from '../../styles';
|
|
7
|
+
|
|
8
|
+
const { useScrollToEnd, useStyleSet, usePerformCardAction, useFocus, useSuggestedActions, useDisabled } = hooks;
|
|
9
|
+
|
|
10
|
+
type SuggestedActionProps = Readonly<{
|
|
11
|
+
buttonText: string | undefined;
|
|
12
|
+
className?: string | undefined;
|
|
13
|
+
displayText?: string | undefined;
|
|
14
|
+
image?: string | undefined;
|
|
15
|
+
imageAlt?: string | undefined;
|
|
16
|
+
itemIndex: number;
|
|
17
|
+
text?: string | undefined;
|
|
18
|
+
type?:
|
|
19
|
+
| 'call'
|
|
20
|
+
| 'downloadFile'
|
|
21
|
+
| 'imBack'
|
|
22
|
+
| 'messageBack'
|
|
23
|
+
| 'openUrl'
|
|
24
|
+
| 'playAudio'
|
|
25
|
+
| 'playVideo'
|
|
26
|
+
| 'postBack'
|
|
27
|
+
| 'showImage'
|
|
28
|
+
| 'signin';
|
|
29
|
+
value?: any;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
const styles = {
|
|
33
|
+
'webchat-fluent__suggested-action': {
|
|
34
|
+
background: 'transparent',
|
|
35
|
+
border: '1px solid var(--webchat-colorBrandStroke2)',
|
|
36
|
+
borderRadius: '8px',
|
|
37
|
+
cursor: 'pointer',
|
|
38
|
+
fontSize: '12px',
|
|
39
|
+
lineHeight: '14px',
|
|
40
|
+
padding: '6px 8px 4px',
|
|
41
|
+
textAlign: 'start',
|
|
42
|
+
display: 'flex',
|
|
43
|
+
gap: '4px',
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
transition: 'all .15s ease-out',
|
|
46
|
+
|
|
47
|
+
'@media (hover: hover)': {
|
|
48
|
+
'&:not([aria-disabled="true"]):hover': {
|
|
49
|
+
backgroundColor: 'var(--webchat-colorBrandBackground2Hover)',
|
|
50
|
+
color: 'var(--webchat-colorBrandForeground2Hover)'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
'&:not([aria-disabled="true"]):active': {
|
|
54
|
+
backgroundColor: 'var(--webchat-colorBrandBackground2Pressed)',
|
|
55
|
+
color: 'var(--webchat-colorBrandForeground2Pressed)'
|
|
56
|
+
},
|
|
57
|
+
'&[aria-disabled="true"]': {
|
|
58
|
+
color: ' var(--webchat-colorNeutralForegroundDisabled)',
|
|
59
|
+
cursor: 'not-allowed'
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
'webchat-fluent__suggested-action__image': {
|
|
64
|
+
width: '1em',
|
|
65
|
+
height: '1em',
|
|
66
|
+
fontSize: '20px',
|
|
67
|
+
translate: '0 -1px'
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function SuggestedAction({
|
|
72
|
+
buttonText,
|
|
73
|
+
className,
|
|
74
|
+
displayText,
|
|
75
|
+
image,
|
|
76
|
+
imageAlt,
|
|
77
|
+
text,
|
|
78
|
+
type,
|
|
79
|
+
value
|
|
80
|
+
}: SuggestedActionProps) {
|
|
81
|
+
const [_, setSuggestedActions] = useSuggestedActions();
|
|
82
|
+
const [{ suggestedAction: suggestedActionStyleSet }] = useStyleSet();
|
|
83
|
+
const [disabled] = useDisabled();
|
|
84
|
+
const focus = useFocus();
|
|
85
|
+
const focusRef = useRef<HTMLButtonElement>(null);
|
|
86
|
+
const performCardAction = usePerformCardAction();
|
|
87
|
+
const classNames = useStyles(styles);
|
|
88
|
+
const scrollToEnd = useScrollToEnd();
|
|
89
|
+
|
|
90
|
+
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
|
|
91
|
+
({ target }) => {
|
|
92
|
+
(async function () {
|
|
93
|
+
// We need to focus to the send box before we are performing this card action.
|
|
94
|
+
// The will make sure the focus is always on Web Chat.
|
|
95
|
+
// Otherwise, the focus may momentarily send to `document.body` and screen reader will be confused.
|
|
96
|
+
await focus('sendBoxWithoutKeyboard');
|
|
97
|
+
|
|
98
|
+
// TODO: [P3] #XXX We should not destruct DirectLineCardAction into React props and pass them in. It makes typings difficult.
|
|
99
|
+
// Instead, we should pass a "cardAction" props.
|
|
100
|
+
performCardAction({ displayText, text, type, value } as DirectLineCardAction, { target });
|
|
101
|
+
|
|
102
|
+
// Since "openUrl" action do not submit, the suggested action buttons do not hide after click.
|
|
103
|
+
type === 'openUrl' && setSuggestedActions([]);
|
|
104
|
+
|
|
105
|
+
scrollToEnd();
|
|
106
|
+
})();
|
|
107
|
+
},
|
|
108
|
+
[displayText, focus, performCardAction, scrollToEnd, setSuggestedActions, text, type, value]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<AccessibleButton
|
|
113
|
+
className={cx(
|
|
114
|
+
classNames['webchat-fluent__suggested-action'],
|
|
115
|
+
suggestedActionStyleSet + '',
|
|
116
|
+
(className || '') + ''
|
|
117
|
+
)}
|
|
118
|
+
disabled={disabled}
|
|
119
|
+
onClick={handleClick}
|
|
120
|
+
ref={focusRef}
|
|
121
|
+
type="button"
|
|
122
|
+
>
|
|
123
|
+
{image && <img alt={imageAlt} className={classNames['webchat-fluent__suggested-action__image']} src={image} />}
|
|
124
|
+
<span>{buttonText}</span>
|
|
125
|
+
</AccessibleButton>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default memo(SuggestedAction);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { hooks } from 'botframework-webchat-component';
|
|
2
|
+
import cx from 'classnames';
|
|
3
|
+
import React, { memo, type ReactNode } from 'react';
|
|
4
|
+
import { useStyles } from '../../styles';
|
|
5
|
+
import computeSuggestedActionText from './private/computeSuggestedActionText';
|
|
6
|
+
import SuggestedAction from './SuggestedAction';
|
|
7
|
+
|
|
8
|
+
const { useLocalizer, useStyleSet, useSuggestedActions } = hooks;
|
|
9
|
+
|
|
10
|
+
const styles = {
|
|
11
|
+
'webchat-fluent__suggested-actions': {
|
|
12
|
+
alignItems: 'flex-end',
|
|
13
|
+
alignSelf: 'flex-end',
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'column',
|
|
16
|
+
gap: '8px',
|
|
17
|
+
|
|
18
|
+
'&:not(:empty)': {
|
|
19
|
+
paddingBlockEnd: '8px',
|
|
20
|
+
paddingInlineStart: '4px'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function SuggestedActionStackedContainer(
|
|
26
|
+
props: Readonly<{
|
|
27
|
+
'aria-label'?: string | undefined;
|
|
28
|
+
children?: ReactNode | undefined;
|
|
29
|
+
className?: string | undefined;
|
|
30
|
+
}>
|
|
31
|
+
) {
|
|
32
|
+
const [{ suggestedActions: suggestedActionsStyleSet }] = useStyleSet();
|
|
33
|
+
const classNames = useStyles(styles);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
aria-label={props['aria-label']}
|
|
38
|
+
aria-live="polite"
|
|
39
|
+
aria-orientation="vertical"
|
|
40
|
+
className={cx(classNames['webchat-fluent__suggested-actions'], suggestedActionsStyleSet + '', props.className)}
|
|
41
|
+
role="toolbar"
|
|
42
|
+
>
|
|
43
|
+
{!!props.children && !!React.Children.count(props.children) && props.children}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function SuggestedActions() {
|
|
49
|
+
const classNames = useStyles(styles);
|
|
50
|
+
const localize = useLocalizer();
|
|
51
|
+
const [suggestedActions] = useSuggestedActions();
|
|
52
|
+
const children = suggestedActions.map((cardAction, index) => {
|
|
53
|
+
const { displayText, image, imageAltText, text, type, value } = cardAction as {
|
|
54
|
+
displayText?: string;
|
|
55
|
+
image?: string;
|
|
56
|
+
imageAltText?: string;
|
|
57
|
+
text?: string;
|
|
58
|
+
type:
|
|
59
|
+
| 'call'
|
|
60
|
+
| 'downloadFile'
|
|
61
|
+
| 'imBack'
|
|
62
|
+
| 'messageBack'
|
|
63
|
+
| 'openUrl'
|
|
64
|
+
| 'playAudio'
|
|
65
|
+
| 'playVideo'
|
|
66
|
+
| 'postBack'
|
|
67
|
+
| 'showImage'
|
|
68
|
+
| 'signin';
|
|
69
|
+
value?: { [key: string]: any } | string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!suggestedActions?.length) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<SuggestedAction
|
|
78
|
+
buttonText={computeSuggestedActionText(cardAction)}
|
|
79
|
+
displayText={displayText}
|
|
80
|
+
image={image}
|
|
81
|
+
// Image alt text should use `imageAltText` field and fallback to `text` field.
|
|
82
|
+
// https://github.com/microsoft/botframework-sdk/blob/main/specs/botframework-activity/botframework-activity.md#image-alt-text
|
|
83
|
+
imageAlt={image && (imageAltText || text)}
|
|
84
|
+
itemIndex={index}
|
|
85
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
86
|
+
key={index}
|
|
87
|
+
text={text}
|
|
88
|
+
type={type}
|
|
89
|
+
value={value}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
return (
|
|
94
|
+
<SuggestedActionStackedContainer
|
|
95
|
+
aria-label={localize('SUGGESTED_ACTIONS_LABEL_ALT')}
|
|
96
|
+
className={classNames['webchat-fluent__suggested-actions']}
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
</SuggestedActionStackedContainer>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default memo(SuggestedActions);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DirectLineCardAction } from 'botframework-webchat-core';
|
|
2
|
+
|
|
3
|
+
// Please refer to this article to find out how to compute the "button text" for suggested action.
|
|
4
|
+
// https://github.com/Microsoft/botframework-sdk/blob/main/specs/botframework-activity/botframework-activity.md#card-action
|
|
5
|
+
export default function computeSuggestedActionText(cardAction: DirectLineCardAction) {
|
|
6
|
+
// "CardAction" must contains at least image or title.
|
|
7
|
+
const { title } = cardAction as { title?: string };
|
|
8
|
+
const { type, value } = cardAction;
|
|
9
|
+
|
|
10
|
+
if (type === 'messageBack') {
|
|
11
|
+
return title || cardAction.displayText;
|
|
12
|
+
} else if (title) {
|
|
13
|
+
return title;
|
|
14
|
+
} else if (typeof value === 'string') {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// TODO: [P1] This is copied from botframework-webchat-component. Think about what we should do to eliminate duplications.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React, { memo, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import Context from './private/Context';
|
|
4
|
+
|
|
5
|
+
type Props = Readonly<{ children?: ReactNode | undefined }>;
|
|
6
|
+
|
|
7
|
+
const Provider = memo(({ children }: Props) => {
|
|
8
|
+
const [shown, setShown] = useState(false);
|
|
9
|
+
|
|
10
|
+
const context = useMemo(
|
|
11
|
+
() =>
|
|
12
|
+
Object.freeze({
|
|
13
|
+
setShown,
|
|
14
|
+
shown
|
|
15
|
+
}),
|
|
16
|
+
[setShown, shown]
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return <Context.Provider value={context}>{children}</Context.Provider>;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default Provider;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { PropsOf } from '../../types/PropsOf';
|
|
4
|
+
import TelephoneKeypad from './private/TelephoneKeypad';
|
|
5
|
+
import useShown from './useShown';
|
|
6
|
+
|
|
7
|
+
type Props = PropsOf<typeof TelephoneKeypad>;
|
|
8
|
+
|
|
9
|
+
const TelephoneKeypadSurrogate = memo((props: Props) => (useShown()[0] ? <TelephoneKeypad {...props} /> : false));
|
|
10
|
+
|
|
11
|
+
TelephoneKeypadSurrogate.displayName = 'TelephoneKeypad.Surrogate';
|
|
12
|
+
|
|
13
|
+
export default TelephoneKeypadSurrogate;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import TelephoneKeypadProvider from './Provider';
|
|
2
|
+
import TelephoneKeypadSurrogate from './Surrogate';
|
|
3
|
+
import { type DTMF } from './types';
|
|
4
|
+
import useTelephoneKeypadShown from './useShown';
|
|
5
|
+
|
|
6
|
+
export { TelephoneKeypadProvider, TelephoneKeypadSurrogate, useTelephoneKeypadShown, type DTMF };
|