@seamly/web-ui 21.0.9 → 22.0.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 (141) hide show
  1. package/build/dist/lib/components.js +9228 -7777
  2. package/build/dist/lib/components.js.map +1 -0
  3. package/build/dist/lib/components.min.js +2 -1
  4. package/build/dist/lib/components.min.js.LICENSE.txt +2 -2
  5. package/build/dist/lib/components.min.js.map +1 -0
  6. package/build/dist/lib/config.js +2 -1
  7. package/build/dist/lib/config.js.map +1 -0
  8. package/build/dist/lib/config.min.js +2 -1
  9. package/build/dist/lib/config.min.js.map +1 -0
  10. package/build/dist/lib/contexts.js +2 -1
  11. package/build/dist/lib/contexts.js.map +1 -0
  12. package/build/dist/lib/contexts.min.js +2 -1
  13. package/build/dist/lib/contexts.min.js.map +1 -0
  14. package/build/dist/lib/deprecated-view.css +1 -1
  15. package/build/dist/lib/deprecated-view.js +1 -1
  16. package/build/dist/lib/hooks.js +6999 -5996
  17. package/build/dist/lib/hooks.js.map +1 -0
  18. package/build/dist/lib/hooks.min.js +2 -1
  19. package/build/dist/lib/hooks.min.js.map +1 -0
  20. package/build/dist/lib/index.debug.js +940 -370
  21. package/build/dist/lib/index.debug.js.map +1 -0
  22. package/build/dist/lib/index.debug.min.js +2 -1
  23. package/build/dist/lib/index.debug.min.js.LICENSE.txt +334 -110
  24. package/build/dist/lib/index.debug.min.js.map +1 -0
  25. package/build/dist/lib/index.js +2810 -5472
  26. package/build/dist/lib/index.js.map +1 -0
  27. package/build/dist/lib/index.min.js +2 -1
  28. package/build/dist/lib/index.min.js.LICENSE.txt +2 -2
  29. package/build/dist/lib/index.min.js.map +1 -0
  30. package/build/dist/lib/sounds/beep.mp3 +0 -0
  31. package/build/dist/lib/standalone.js +10575 -13540
  32. package/build/dist/lib/standalone.js.map +1 -0
  33. package/build/dist/lib/standalone.min.js +2 -1
  34. package/build/dist/lib/standalone.min.js.LICENSE.txt +1 -1
  35. package/build/dist/lib/standalone.min.js.map +1 -0
  36. package/build/dist/lib/storage.js +2 -1
  37. package/build/dist/lib/storage.js.map +1 -0
  38. package/build/dist/lib/storage.min.js +2 -1
  39. package/build/dist/lib/storage.min.js.map +1 -0
  40. package/build/dist/lib/style-guide.js +1701 -5859
  41. package/build/dist/lib/style-guide.js.map +1 -0
  42. package/build/dist/lib/style-guide.min.js +2 -1
  43. package/build/dist/lib/style-guide.min.js.LICENSE.txt +2 -2
  44. package/build/dist/lib/style-guide.min.js.map +1 -0
  45. package/build/dist/lib/styles-default-implementation.css +1 -1
  46. package/build/dist/lib/styles-default-implementation.js +1 -1
  47. package/build/dist/lib/styles.css +1 -1
  48. package/build/dist/lib/styles.js +1 -1
  49. package/build/dist/lib/utils.js +11536 -14530
  50. package/build/dist/lib/utils.js.map +1 -0
  51. package/build/dist/lib/utils.min.js +2 -1
  52. package/build/dist/lib/utils.min.js.LICENSE.txt +1 -6
  53. package/build/dist/lib/utils.min.js.map +1 -0
  54. package/package.json +58 -48
  55. package/src/javascripts/api/conversation-connector.ts +2 -0
  56. package/src/javascripts/api/errors/seamly-api-error.ts +15 -0
  57. package/src/javascripts/api/index.ts +168 -94
  58. package/src/javascripts/config.ts +1 -1
  59. package/src/javascripts/config.types.ts +18 -11
  60. package/src/javascripts/domains/config/selectors.ts +1 -1
  61. package/src/javascripts/domains/config/slice.ts +12 -0
  62. package/src/javascripts/domains/forms/forms.types.ts +1 -0
  63. package/src/javascripts/domains/forms/hooks.ts +10 -2
  64. package/src/javascripts/domains/store/selectors.ts +23 -10
  65. package/src/javascripts/domains/store/slice.ts +63 -11
  66. package/src/javascripts/domains/store/store.types.ts +41 -1
  67. package/src/javascripts/domains/translations/components/options-button.tsx +1 -4
  68. package/src/javascripts/domains/translations/hooks.ts +11 -4
  69. package/src/javascripts/index.ts +2 -0
  70. package/src/javascripts/lib/url-helpers.ts +24 -0
  71. package/src/javascripts/schema.ts +10 -0
  72. package/src/javascripts/style-guide/states.js +109 -0
  73. package/src/javascripts/ui/components/conversation/conversation.tsx +2 -0
  74. package/src/javascripts/ui/components/conversation/event/chat-scroll/chat-scroll-provider.tsx +2 -0
  75. package/src/javascripts/ui/components/conversation/event/choice-prompt.js +1 -1
  76. package/src/javascripts/ui/components/conversation/event/text.js +1 -1
  77. package/src/javascripts/ui/components/conversation/event/upload.js +50 -9
  78. package/src/javascripts/ui/components/conversation/use-chat-scroll.ts +3 -2
  79. package/src/javascripts/ui/components/core/seamly-event-subscriber.ts +7 -1
  80. package/src/javascripts/ui/components/core/seamly-file-upload.tsx +156 -0
  81. package/src/javascripts/ui/components/entry/abort-transaction-button/abort-transaction-button.tsx +45 -0
  82. package/src/javascripts/ui/components/entry/text-entry/hooks.ts +108 -0
  83. package/src/javascripts/ui/components/entry/text-entry/index.js +7 -4
  84. package/src/javascripts/ui/components/entry/text-entry/{text-entry-form.js → text-entry-form.tsx} +8 -22
  85. package/src/javascripts/ui/components/form-controls/{input.js → input.tsx} +13 -2
  86. package/src/javascripts/ui/components/form-controls/{wrapper.js → wrapper.tsx} +8 -4
  87. package/src/javascripts/ui/components/view/{index.js → index.tsx} +53 -4
  88. package/src/javascripts/ui/components/view/window-view/{index.js → index.tsx} +14 -2
  89. package/src/javascripts/ui/components/widgets/{in-out-transition.js → in-out-transition.tsx} +67 -28
  90. package/src/javascripts/ui/hooks/{seamly-api-hooks.js → seamly-api-hooks.ts} +1 -1
  91. package/src/javascripts/ui/hooks/sounds/beep.mp3 +0 -0
  92. package/src/javascripts/ui/hooks/use-click-outside.ts +5 -3
  93. package/src/javascripts/ui/hooks/use-notifications.ts +114 -0
  94. package/src/javascripts/ui/hooks/use-timeout.ts +20 -0
  95. package/src/stylesheets/3-chat/_chat.scss +3 -5
  96. package/src/stylesheets/4-base/_formelements.scss +0 -36
  97. package/src/stylesheets/5-components/_abort-transaction.scss +10 -0
  98. package/src/stylesheets/5-components/_buttons.scss +18 -3
  99. package/src/stylesheets/5-components/_character-limit.scss +2 -2
  100. package/src/stylesheets/5-components/_chat-status.scss +26 -37
  101. package/src/stylesheets/5-components/_choice-prompt.scss +9 -10
  102. package/src/stylesheets/5-components/_conversation.scss +9 -62
  103. package/src/stylesheets/5-components/_disclaimer.scss +11 -3
  104. package/src/stylesheets/5-components/_error.scss +3 -2
  105. package/src/stylesheets/5-components/_idle.scss +3 -8
  106. package/src/stylesheets/5-components/_input.scss +34 -13
  107. package/src/stylesheets/5-components/_interrupt.scss +3 -10
  108. package/src/stylesheets/5-components/_loader.scss +1 -2
  109. package/src/stylesheets/5-components/_message-author.scss +2 -4
  110. package/src/stylesheets/5-components/_message-body.scss +33 -10
  111. package/src/stylesheets/5-components/_message-card.scss +2 -10
  112. package/src/stylesheets/5-components/_message-carousel.scss +4 -4
  113. package/src/stylesheets/5-components/_message-cta.scss +0 -6
  114. package/src/stylesheets/5-components/_message.scss +1 -0
  115. package/src/stylesheets/5-components/_modal.scss +2 -5
  116. package/src/stylesheets/5-components/_options.scss +17 -22
  117. package/src/stylesheets/5-components/_pre-chat-messages.scss +3 -1
  118. package/src/stylesheets/5-components/_prompt.scss +3 -7
  119. package/src/stylesheets/5-components/_skip-link.scss +2 -1
  120. package/src/stylesheets/5-components/_suggestions.scss +2 -2
  121. package/src/stylesheets/5-components/_translation-options.scss +5 -2
  122. package/src/stylesheets/5-components/_unread-messages.scss +33 -0
  123. package/src/stylesheets/5-components/_upload.scss +20 -27
  124. package/src/stylesheets/6-default-implementation/_hover.scss +14 -17
  125. package/src/stylesheets/7-deprecated/1-settings/_config.scss +17 -0
  126. package/src/stylesheets/7-deprecated/3-app/_app.scss +2 -1
  127. package/src/stylesheets/7-deprecated/5-components/_card.scss +1 -0
  128. package/src/stylesheets/7-deprecated/5-components/_chat-status.scss +66 -20
  129. package/src/stylesheets/7-deprecated/5-components/_conversation.scss +1 -4
  130. package/src/stylesheets/7-deprecated/5-components/_input.scss +6 -1
  131. package/src/stylesheets/7-deprecated/5-components/_interrupt.scss +1 -4
  132. package/src/stylesheets/7-deprecated/5-components/_message.scss +49 -12
  133. package/src/stylesheets/7-deprecated/5-components/_translation-options.scss +30 -37
  134. package/src/stylesheets/7-deprecated/5-components/_unread-messages.scss +38 -0
  135. package/src/stylesheets/deprecated-view.scss +1 -0
  136. package/src/stylesheets/styles.scss +2 -0
  137. package/webpack/config.common.js +6 -1
  138. package/webpack/config.package.js +18 -0
  139. package/webpack/defaults.js +1 -1
  140. package/src/javascripts/ui/components/core/seamly-file-upload.js +0 -86
  141. package/src/javascripts/ui/components/entry/text-entry/hooks.js +0 -46
@@ -31,7 +31,7 @@ export const useChoicePrompt = (event) => {
31
31
  body: event.payload.body?.prompt,
32
32
  translatedBody: event.payload.translatedBody && {
33
33
  ...event.payload.translatedBody,
34
- data: event.payload.translatedBody.data.prompt,
34
+ data: event.payload.translatedBody.prompt,
35
35
  },
36
36
  },
37
37
  }
@@ -17,7 +17,7 @@ const Text = ({ event, ...props }) => {
17
17
  return {
18
18
  bodyProps: {
19
19
  dangerouslySetInnerHTML: {
20
- __html: body.text,
20
+ __html: body?.data?.text || body.text,
21
21
  },
22
22
  },
23
23
  }
@@ -1,16 +1,53 @@
1
1
  import { useMemo } from 'preact/hooks'
2
+ import { useSelector } from 'react-redux'
2
3
  import MessageContainer from 'ui/components/conversation/message-container'
3
4
  import Icon from 'ui/components/layout/icon'
4
5
  import { useI18n } from 'domains/i18n/hooks'
5
6
  import { useTranslatedEventData } from 'domains/translations/hooks'
6
7
  import { className } from 'lib/css'
7
8
 
9
+ const PROCESSING_IMAGE = 'PROCESSING_IMAGE'
10
+
11
+ const useImageFromStorage = (currentFileId) => {
12
+ const { processingFileUploads } = useSelector(({ state }) => state)
13
+
14
+ return useMemo(() => {
15
+ const isProcessingImg = processingFileUploads.some(
16
+ (fileId) => fileId === currentFileId,
17
+ )
18
+
19
+ if (isProcessingImg) return PROCESSING_IMAGE
20
+
21
+ try {
22
+ return sessionStorage.getItem(`image-${currentFileId}`)
23
+ } catch (error) {
24
+ return undefined
25
+ }
26
+ }, [currentFileId, processingFileUploads])
27
+ }
28
+
29
+ const UploadedImage = ({ img, filename }) => {
30
+ const { t } = useI18n()
31
+ const srText = t('fileUpload.srFileUploadedText', { fileName: filename })
32
+
33
+ return (
34
+ <>
35
+ <span className={className(['download', 'download--preview'])}>
36
+ <img src={img} alt={srText} />
37
+ <span aria-hidden="true" className={className('file-download')}>
38
+ {filename}
39
+ </span>
40
+ </span>
41
+ </>
42
+ )
43
+ }
44
+
8
45
  const UploadContent = ({ children, url, target }) =>
9
46
  url ? (
10
47
  <a
11
48
  href={url}
12
49
  download
13
- target={target || undefined}
50
+ target={target}
14
51
  className={className(['download', 'download-link'])}
15
52
  >
16
53
  {children}
@@ -22,8 +59,9 @@ const UploadContent = ({ children, url, target }) =>
22
59
  const Upload = ({ event, ...props }) => {
23
60
  const { t } = useI18n()
24
61
  const { body } = useTranslatedEventData(event)
25
- const { fromClient } = event.payload
62
+ const { fromClient, id } = event.payload
26
63
  const { filename, url } = body
64
+ const img = useImageFromStorage(id)
27
65
 
28
66
  const srText = useMemo(
29
67
  () =>
@@ -35,13 +73,16 @@ const Upload = ({ event, ...props }) => {
35
73
 
36
74
  return (
37
75
  <MessageContainer event={event} type="upload" {...props}>
38
- <UploadContent url={url} target={!fromClient ? '_blank' : undefined}>
39
- <Icon name="download" size="16" />
40
- <span aria-hidden="true" className={className('file-download')}>
41
- {filename}
42
- </span>
43
- <span className={className('visually-hidden')}>{srText}</span>
44
- </UploadContent>
76
+ {img && img !== PROCESSING_IMAGE ? (
77
+ <UploadedImage img={img} filename={filename} />
78
+ ) : (
79
+ <UploadContent url={url} target={!fromClient ? '_blank' : undefined}>
80
+ <Icon name="download" size="16" alt={srText} />
81
+ <span aria-hidden="true" className={className('file-download')}>
82
+ {filename}
83
+ </span>
84
+ </UploadContent>
85
+ )}
45
86
  </MessageContainer>
46
87
  )
47
88
  }
@@ -37,8 +37,8 @@ const useChatScroll = (
37
37
  const isLoading = useSeamlyIsLoading()
38
38
  const { isOpen } = useVisibility()
39
39
  const loadedImageEventIds = useLoadedImageEventIds()
40
- const isLastEventFromClient = useSelector(
41
- (state: RootState) => state.state.isLastEventFromClient,
40
+ const { processingFileUploads, isLastEventFromClient } = useSelector(
41
+ ({ state }: RootState) => state,
42
42
  )
43
43
 
44
44
  useEffect(() => {
@@ -84,6 +84,7 @@ const useChatScroll = (
84
84
  isLoading,
85
85
  isOpen,
86
86
  loadedImageEventIds,
87
+ processingFileUploads,
87
88
  scrollToBottom,
88
89
  ])
89
90
 
@@ -24,6 +24,7 @@ import {
24
24
  setHistory,
25
25
  setIsLoading,
26
26
  setParticipant,
27
+ setProactiveMessages,
27
28
  setServiceDataItem,
28
29
  setServiceEntryMetadata,
29
30
  } from 'domains/store/slice'
@@ -201,7 +202,7 @@ const SeamlyEventSubscriber = () => {
201
202
  case 'system':
202
203
  if (payload.type === 'service_changed') {
203
204
  const { serviceSettings, ...eventPayload } = payload
204
- const { entry } = serviceSettings
205
+ const { entry, proactiveMessages } = serviceSettings
205
206
  const { upload } = entry.options
206
207
 
207
208
  dispatch(
@@ -211,6 +212,10 @@ const SeamlyEventSubscriber = () => {
211
212
  }),
212
213
  )
213
214
 
215
+ dispatch(
216
+ setProactiveMessages(proactiveMessages.enabled || false),
217
+ )
218
+
214
219
  dispatch(setServiceEntryMetadata(entry))
215
220
  if (payload.serviceSessionId) {
216
221
  dispatch(setActiveService(payload.serviceSessionId))
@@ -244,6 +249,7 @@ const SeamlyEventSubscriber = () => {
244
249
  )
245
250
  break
246
251
  case 'conversation_erred':
252
+ case 'attach_channel_erred':
247
253
  const seamlyGeneralError = new SeamlyGeneralError(event)
248
254
  dispatch(
249
255
  setInterrupt({
@@ -0,0 +1,156 @@
1
+ import { useCallback } from 'preact/hooks'
2
+ import { useDispatch } from 'react-redux'
3
+ import SeamlyFileUploadContext from 'ui/components/core/seamly-file-upload-context'
4
+ import { useSeamlyApiContext, useSeamlyCommands } from 'ui/hooks/seamly-hooks'
5
+ import { useI18n } from 'domains/i18n/hooks'
6
+ import {
7
+ doneProcessingImage,
8
+ registerUpload,
9
+ setUploadComplete,
10
+ setUploadError,
11
+ setUploadProgress,
12
+ startProcessingImage,
13
+ } from 'domains/store/slice'
14
+ import { MessageUpload } from 'domains/store/store.types'
15
+ import { randomId } from 'lib/id'
16
+
17
+ const calculateImgSize = (img) => {
18
+ const MAX_WIDTH = 600
19
+ const MAX_HEIGHT = 600
20
+
21
+ const { height, width } = img
22
+ if (width > height && width > MAX_WIDTH) {
23
+ return [MAX_WIDTH, (height * MAX_WIDTH) / width]
24
+ }
25
+
26
+ if (height > MAX_HEIGHT) {
27
+ return [(width * MAX_HEIGHT) / height, MAX_HEIGHT]
28
+ }
29
+
30
+ return [width, height]
31
+ }
32
+
33
+ const resizeImage = (img) => {
34
+ const canvas = document.createElement('canvas')
35
+ const ctx = canvas.getContext('2d')
36
+
37
+ const [width, height] = calculateImgSize(img)
38
+ canvas.width = width
39
+ canvas.height = height
40
+
41
+ ctx.drawImage(img, 0, 0, width, height)
42
+
43
+ return canvas.toDataURL()
44
+ }
45
+
46
+ const toBase64 = (file): Promise<string> =>
47
+ new Promise((resolve, reject) => {
48
+ const reader = new FileReader()
49
+ reader.readAsDataURL(file)
50
+ reader.onload = () =>
51
+ typeof reader.result === 'string' ? resolve(reader.result) : undefined
52
+ reader.onerror = (error) => reject(error)
53
+ })
54
+
55
+ const SeamlyFileUpload = ({ children }) => {
56
+ const { t } = useI18n()
57
+ const dispatch = useDispatch()
58
+ const api = useSeamlyApiContext()
59
+ const { addUploadBubble } = useSeamlyCommands()
60
+
61
+ const addImageToSessionStorage = useCallback(
62
+ async (file, fileId) => {
63
+ dispatch(startProcessingImage(fileId))
64
+ const base64 = await toBase64(file)
65
+ const img = new Image()
66
+
67
+ img.src = base64
68
+
69
+ await img.decode()
70
+ const newDataUri = resizeImage(img)
71
+
72
+ try {
73
+ sessionStorage.setItem(`image-${fileId}`, newDataUri)
74
+ } catch (error) {
75
+ // Nothing to do!
76
+ } finally {
77
+ dispatch(doneProcessingImage(fileId))
78
+ }
79
+ },
80
+ [dispatch],
81
+ )
82
+
83
+ const onUploadFileHandler = useCallback(
84
+ (file) => {
85
+ const fileId = randomId()
86
+
87
+ const uploadHandle = api.uploadFile(
88
+ file,
89
+ (p) => {
90
+ dispatch(setUploadProgress({ fileId, progress: Math.ceil(p) }))
91
+ },
92
+ async (result: MessageUpload) => {
93
+ const {
94
+ id,
95
+ transactionId,
96
+ occurredAt,
97
+ body: { contentType, filename, filesize, url },
98
+ } = result
99
+
100
+ dispatch(setUploadComplete(fileId))
101
+ addUploadBubble(
102
+ id,
103
+ transactionId,
104
+ occurredAt,
105
+ contentType,
106
+ filename,
107
+ filesize,
108
+ url,
109
+ )
110
+
111
+ await addImageToSessionStorage(file, id)
112
+ },
113
+ (err) => {
114
+ const errorKey = err?.error || ''
115
+ let errorText
116
+
117
+ switch (errorKey) {
118
+ case 'file_uploads_are_disabled':
119
+ errorText = t('fileUpload.errors.unavailable')
120
+ break
121
+ case 'request_entity_too_large':
122
+ errorText = t('fileUpload.errors.tooLarge')
123
+ break
124
+ case 'file_has_invalid_mime_type':
125
+ errorText = t('fileUpload.errors.wrongType')
126
+ break
127
+ case 'virus_found':
128
+ errorText = t('fileUpload.errors.virusFound')
129
+ break
130
+ default:
131
+ errorText = t('fileUpload.errors.general')
132
+ }
133
+
134
+ dispatch(setUploadError({ fileId, errorText }))
135
+ },
136
+ )
137
+
138
+ dispatch(
139
+ registerUpload({
140
+ fileId,
141
+ fileName: file.name,
142
+ uploadHandle,
143
+ }),
144
+ )
145
+ },
146
+ [addImageToSessionStorage, addUploadBubble, api, dispatch, t],
147
+ )
148
+
149
+ return (
150
+ <SeamlyFileUploadContext.Provider value={onUploadFileHandler}>
151
+ {children}
152
+ </SeamlyFileUploadContext.Provider>
153
+ )
154
+ }
155
+
156
+ export default SeamlyFileUpload
@@ -0,0 +1,45 @@
1
+ import { useSeamlyApiContext } from 'ui/hooks/seamly-api-hooks'
2
+ import { actionTypes } from 'ui/utils/seamly-utils'
3
+ import { className } from 'lib/css'
4
+ import { useEntryAbortTransaction } from '../text-entry/hooks'
5
+
6
+ export default function AbortTransactionButton() {
7
+ const { abortTransaction, clearEntryAbortTransaction } =
8
+ useEntryAbortTransaction()
9
+ const api = useSeamlyApiContext()
10
+
11
+ if (!abortTransaction) return null
12
+
13
+ const handleAbortTransaction = () => {
14
+ api.send('action', {
15
+ type: actionTypes.setTopic,
16
+ body: {
17
+ name: abortTransaction.topicName,
18
+ fallbackMessage: abortTransaction.topicFallbackMessage,
19
+ },
20
+ })
21
+
22
+ clearEntryAbortTransaction()
23
+ }
24
+
25
+ return (
26
+ <li
27
+ className={className([
28
+ 'cvco-conversation__item',
29
+ 'cvco-conversation__item--abort-transaction',
30
+ ])}
31
+ >
32
+ <button
33
+ className={className([
34
+ 'button',
35
+ 'button--secondary',
36
+ 'abort-transaction__button',
37
+ ])}
38
+ type="button"
39
+ onClick={handleAbortTransaction}
40
+ >
41
+ {abortTransaction.label}
42
+ </button>
43
+ </li>
44
+ )
45
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useMemo } from 'preact/hooks'
2
+ import { useDispatch } from 'react-redux'
3
+ import { maxCharacterSrDebounceDelay, maxCharacterWarningLimit } from 'config'
4
+ import { useLiveRegion } from 'ui/hooks/live-region-hooks'
5
+ import {
6
+ useEntryTextLimit,
7
+ useSeamlyStateContext,
8
+ } from 'ui/hooks/seamly-state-hooks'
9
+ import { debounce } from 'ui/utils/general-utils'
10
+ import { ControlState } from 'domains/forms/forms.types'
11
+ import { useFormControl } from 'domains/forms/hooks'
12
+ import { useI18n } from 'domains/i18n/hooks'
13
+ import { clearAbortTransaction } from 'domains/store/slice'
14
+
15
+ export function useCharacterLimit(controlName) {
16
+ const { t } = useI18n()
17
+ const { sendAssertive } = useLiveRegion()
18
+ const { hasLimit, limit } = useEntryTextLimit()
19
+
20
+ const debouncedSendAssertive = useMemo(
21
+ () => debounce(sendAssertive, maxCharacterSrDebounceDelay),
22
+ [sendAssertive],
23
+ )
24
+ const validateLimit = useMemo(() => {
25
+ return debounce((_reachedCharacterWarning, _remainingChars) => {
26
+ if (_reachedCharacterWarning) {
27
+ debouncedSendAssertive(
28
+ t('input.srCharacterLimitText', { limit: _remainingChars }),
29
+ )
30
+ }
31
+ }, maxCharacterSrDebounceDelay)
32
+ }, [debouncedSendAssertive, t])
33
+
34
+ const [{ value }] = useFormControl(controlName)
35
+ const remainingChars = hasLimit && value ? limit - value.length : limit
36
+ const reachedCharacterWarning = hasLimit
37
+ ? remainingChars <= maxCharacterWarningLimit
38
+ : false
39
+ const reachedCharacterLimit = hasLimit ? remainingChars < 0 : false
40
+
41
+ useEffect(() => {
42
+ validateLimit(reachedCharacterWarning, remainingChars)
43
+ }, [reachedCharacterWarning, remainingChars, validateLimit])
44
+
45
+ return {
46
+ hasCharacterLimit: hasLimit,
47
+ characterLimit: limit,
48
+ reachedCharacterWarning,
49
+ reachedCharacterLimit,
50
+ remainingChars,
51
+ }
52
+ }
53
+
54
+ export const useEntryTextTranslation = (controlName: ControlState['name']) => {
55
+ const { hasCharacterLimit, characterLimit } = useCharacterLimit(controlName)
56
+
57
+ const {
58
+ entryMeta: {
59
+ optionsOverride: { text },
60
+ },
61
+ } = useSeamlyStateContext()
62
+
63
+ const { t } = useI18n()
64
+
65
+ const placeholder: string = useMemo(
66
+ () =>
67
+ t('input.inputPlaceholder', {
68
+ hasLimit: hasCharacterLimit,
69
+ text: text?.placeholder || t('input.inputPlaceholderText'),
70
+ limit: hasCharacterLimit ? characterLimit : null,
71
+ }),
72
+ [t, hasCharacterLimit, characterLimit, text?.placeholder],
73
+ )
74
+
75
+ const label: string = useMemo(
76
+ () =>
77
+ t('input.inputLabel', {
78
+ hasLimit: !text?.label ? hasCharacterLimit : false,
79
+ text: text?.label || t('input.inputLabelText'),
80
+ limit: !text?.label && hasCharacterLimit ? characterLimit : null,
81
+ }),
82
+ [t, hasCharacterLimit, characterLimit, text?.label],
83
+ )
84
+
85
+ const labelClass: string = useMemo(
86
+ () => (text?.label ? 'input__label' : 'visually-hidden'),
87
+ [text?.label],
88
+ )
89
+
90
+ return { placeholder, label, labelClass }
91
+ }
92
+
93
+ export const useEntryAbortTransaction = () => {
94
+ const dispatch = useDispatch()
95
+
96
+ const {
97
+ entryMeta: { actions },
98
+ } = useSeamlyStateContext()
99
+
100
+ const clearEntryAbortTransaction = () => {
101
+ dispatch(clearAbortTransaction())
102
+ }
103
+
104
+ return {
105
+ abortTransaction: actions?.abortTransaction,
106
+ clearEntryAbortTransaction,
107
+ }
108
+ }
@@ -9,7 +9,7 @@ import { visibilityStates } from 'domains/visibility/constants'
9
9
  import { useVisibility } from 'domains/visibility/hooks'
10
10
  import TextEntryForm from './text-entry-form'
11
11
 
12
- const controlName = 'textMessageEntry'
12
+ export const textEntryControlName = 'textMessageEntry'
13
13
 
14
14
  export default function TextEntry({ ...props }) {
15
15
  const { isOpen, setVisibility } = useVisibility()
@@ -18,8 +18,8 @@ export default function TextEntry({ ...props }) {
18
18
  const { sendMessage } = useSeamlyCommands()
19
19
  const handleSubmit = useCallback(
20
20
  (values, { updateControlValue }) => {
21
- sendMessage({ body: values[controlName] })
22
- updateControlValue(controlName, '')
21
+ sendMessage({ body: values[textEntryControlName] })
22
+ updateControlValue(textEntryControlName, '')
23
23
  focusSkipLinkTarget()
24
24
 
25
25
  if (!isOpen) {
@@ -36,7 +36,10 @@ export default function TextEntry({ ...props }) {
36
36
  persistData={true}
37
37
  onSubmit={handleSubmit}
38
38
  >
39
- <TextEntryForm controlName={controlName} skipLinkId={skipLinkId} />
39
+ <TextEntryForm
40
+ controlName={textEntryControlName}
41
+ skipLinkId={skipLinkId}
42
+ />
40
43
  </FormProvider>
41
44
  )
42
45
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useLayoutEffect, useMemo } from 'preact/hooks'
1
+ import { useCallback, useLayoutEffect } from 'preact/hooks'
2
2
  import Form from 'ui/components/form-controls/form'
3
3
  import Input from 'ui/components/form-controls/input'
4
4
  import Icon from 'ui/components/layout/icon'
@@ -7,7 +7,7 @@ import { useLiveRegion, useSeamlyCommands } from 'ui/hooks/seamly-hooks'
7
7
  import { useFormControl } from 'domains/forms/hooks'
8
8
  import { useI18n } from 'domains/i18n/hooks'
9
9
  import { className } from 'lib/css'
10
- import { useCharacterLimit } from './hooks'
10
+ import { useCharacterLimit, useEntryTextTranslation } from './hooks'
11
11
 
12
12
  export default function TextEntryForm({ controlName, skipLinkId }) {
13
13
  const { t } = useI18n()
@@ -15,16 +15,17 @@ export default function TextEntryForm({ controlName, skipLinkId }) {
15
15
  const { emitEvent } = useSeamlyCommands()
16
16
  const handleKeyUp = useSeamlyTyping()
17
17
  const { setBlockAutoEntrySwitch } = useSeamlyEntry()
18
-
18
+ const { placeholder, label, labelClass } =
19
+ useEntryTextTranslation(controlName)
19
20
  // TODO: Standardize the validation on form fields
20
21
  const {
21
22
  hasCharacterLimit,
22
- characterLimit,
23
23
  reachedCharacterWarning,
24
24
  reachedCharacterLimit,
25
25
  remainingChars,
26
26
  } = useCharacterLimit(controlName)
27
27
  const [{ value }] = useFormControl(controlName)
28
+
28
29
  const hasValue = !!value
29
30
 
30
31
  const handleFocus = useCallback(() => {
@@ -34,22 +35,7 @@ export default function TextEntryForm({ controlName, skipLinkId }) {
34
35
 
35
36
  emitEvent('ui.inputFocus')
36
37
  }, [t, sendAssertive, reachedCharacterWarning, remainingChars, emitEvent])
37
- const placeholder = useMemo(
38
- () =>
39
- t('input.inputPlaceholder', {
40
- hasLimit: hasCharacterLimit,
41
- limit: hasCharacterLimit ? characterLimit : null,
42
- }),
43
- [t, hasCharacterLimit, characterLimit],
44
- )
45
- const labelText = useMemo(
46
- () =>
47
- t('input.inputLabel', {
48
- hasLimit: hasCharacterLimit,
49
- limit: hasCharacterLimit ? characterLimit : null,
50
- }),
51
- [t, hasCharacterLimit, characterLimit],
52
- )
38
+
53
39
  // When the input holds a value, the component should be blocked from switching
54
40
  // to file upload form.
55
41
  useLayoutEffect(() => {
@@ -87,8 +73,8 @@ export default function TextEntryForm({ controlName, skipLinkId }) {
87
73
  className={className('input__text')}
88
74
  autocomplete="off"
89
75
  placeholder={placeholder}
90
- labelText={labelText}
91
- labelClass={className('visually-hidden')}
76
+ labelText={label}
77
+ labelClass={className(labelClass)}
92
78
  aria-invalid={hasCharacterLimit ? reachedCharacterLimit : null}
93
79
  onKeyUp={handleKeyUp}
94
80
  onFocus={handleFocus}
@@ -1,16 +1,27 @@
1
+ import { HTMLAttributes } from 'preact/compat'
1
2
  import { useFormContext, useFormControl } from 'domains/forms/hooks'
2
3
  import FormControlWrapper from './wrapper'
3
4
 
5
+ type InputProps = HTMLAttributes<HTMLInputElement> & {
6
+ id: string
7
+ name: string
8
+ type: string
9
+ labelText: string
10
+ labelClass: string
11
+ contentHint?: string
12
+ 'aria-describedby'?: string
13
+ }
14
+
4
15
  function Input({
5
16
  id,
6
17
  name,
7
18
  type,
8
19
  labelText,
9
20
  labelClass,
10
- contentHint,
21
+ contentHint = null,
11
22
  'aria-describedby': ariaDescribedBy,
12
23
  ...props
13
- }) {
24
+ }: InputProps) {
14
25
  const { isSubmitted } = useFormContext()
15
26
  const [field, { error }] = useFormControl(name)
16
27
  const hasError = isSubmitted && error
@@ -12,9 +12,6 @@ const FormControlWrapper = ({
12
12
  }) => {
13
13
  return (
14
14
  <>
15
- <label htmlFor={id} className={labelClass}>
16
- {labelText}
17
- </label>
18
15
  {contentHint && (
19
16
  <span
20
17
  id={`${id}-content-hint`}
@@ -23,8 +20,15 @@ const FormControlWrapper = ({
23
20
  {contentHint}
24
21
  </span>
25
22
  )}
23
+
26
24
  <Error id={`${id}-error`} error={!validity && errorText} />
27
- {children}
25
+
26
+ <div className={className('form-control__wrapper')}>
27
+ <label htmlFor={id} className={labelClass}>
28
+ {labelText}
29
+ </label>
30
+ {children}
31
+ </div>
28
32
  </>
29
33
  )
30
34
  }