@constructor-io/constructorio-ui-pia 2.17.0 → 2.18.1
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/README.md +7 -273
- package/dist/constructorio-ui-pia-bundled.js +33 -33
- package/lib/cjs/components/Answer/Answer.js +2 -2
- package/lib/cjs/components/CioPia/CioPiaQna.js +12 -2
- package/lib/cjs/components/CioPiaRecs/RecsPodRefinement.js +13 -3
- package/lib/cjs/components/Input/Input.js +3 -2
- package/lib/cjs/components/PiaConversation/PiaConversation.js +19 -3
- package/lib/cjs/components/PiaConversation/PiaModal.js +24 -1
- package/lib/cjs/hooks/useReturnFocusToInput.js +28 -0
- package/lib/cjs/stories/assets/storybook-styles.css +0 -135
- package/lib/cjs/utils/contentTransformers.js +52 -6
- package/lib/cjs/version.js +1 -1
- package/lib/mjs/components/Answer/Answer.js +2 -2
- package/lib/mjs/components/CioPia/CioPiaQna.js +12 -2
- package/lib/mjs/components/CioPiaRecs/RecsPodRefinement.js +13 -3
- package/lib/mjs/components/Input/Input.js +3 -2
- package/lib/mjs/components/PiaConversation/PiaConversation.js +19 -3
- package/lib/mjs/components/PiaConversation/PiaModal.js +23 -2
- package/lib/mjs/hooks/useReturnFocusToInput.js +25 -0
- package/lib/mjs/stories/assets/storybook-styles.css +0 -135
- package/lib/mjs/utils/contentTransformers.js +55 -5
- package/lib/mjs/version.js +1 -1
- package/lib/types/components/Input/Input.d.ts +2 -1
- package/lib/types/components/PiaConversation/PiaModal.d.ts +2 -0
- package/lib/types/hooks/useReturnFocusToInput.d.ts +9 -0
- package/lib/types/types.d.ts +5 -0
- package/lib/types/utils/contentTransformers.d.ts +10 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { createContext, useCallback, useEffect, useRef, useState, } from 'react';
|
|
2
2
|
import Input from '../Input/Input';
|
|
3
3
|
import { translate } from '../../utils/translate';
|
|
4
4
|
import SuggestedQuestionsContainer from '../SuggestedQuestionsContainer/SuggestedQuestionsContainer';
|
|
5
5
|
const OVERFLOW_HIDDEN_CLASS = 'cio-pia-modal-open';
|
|
6
|
+
/** Lets the conversation rendered as the modal's children hand it the node of its input. */
|
|
7
|
+
export const ModalInputRefContext = createContext(undefined);
|
|
6
8
|
function CloseIcon() {
|
|
7
9
|
return (React.createElement("svg", { width: '20', height: '20', viewBox: '0 0 20 20', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', "aria-hidden": 'true', focusable: 'false' },
|
|
8
10
|
React.createElement("path", { d: 'M15.8334 5.34166L14.6584 4.16666L10.0001 8.82499L5.34175 4.16666L4.16675 5.34166L8.82508 9.99999L4.16675 14.6583L5.34175 15.8333L10.0001 11.175L14.6584 15.8333L15.8334 14.6583L11.1751 9.99999L15.8334 5.34166Z', fill: 'currentColor' })));
|
|
@@ -11,6 +13,11 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
11
13
|
const [isOpen, setIsOpen] = useState(false);
|
|
12
14
|
const dialogRef = useRef(null);
|
|
13
15
|
const triggerRef = useRef(null);
|
|
16
|
+
const initialFocusRef = useRef(null);
|
|
17
|
+
const inputNodeRef = useRef(null);
|
|
18
|
+
const setInputNode = useCallback((node) => {
|
|
19
|
+
inputNodeRef.current = node;
|
|
20
|
+
}, []);
|
|
14
21
|
const openModal = useCallback(() => {
|
|
15
22
|
const active = document.activeElement;
|
|
16
23
|
const isRealTrigger = !!active && active !== document.body && active !== document.documentElement;
|
|
@@ -27,6 +34,7 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
27
34
|
return undefined;
|
|
28
35
|
if (isOpen) {
|
|
29
36
|
dialog.showModal();
|
|
37
|
+
initialFocusRef.current = document.activeElement;
|
|
30
38
|
document.body.classList.add(OVERFLOW_HIDDEN_CLASS);
|
|
31
39
|
}
|
|
32
40
|
else {
|
|
@@ -38,6 +46,18 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
38
46
|
document.body.classList.remove(OVERFLOW_HIDDEN_CLASS);
|
|
39
47
|
};
|
|
40
48
|
}, [isOpen]);
|
|
49
|
+
// The input is disabled when the dialog opens, so showModal() parks focus on the first enabled
|
|
50
|
+
// control. Hand it to the input once the answer has loaded, unless the shopper has moved it.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isOpen || isLoading || !initialFocusRef.current)
|
|
53
|
+
return;
|
|
54
|
+
const placed = initialFocusRef.current;
|
|
55
|
+
initialFocusRef.current = null;
|
|
56
|
+
const active = document.activeElement;
|
|
57
|
+
if (active !== placed && active !== document.body)
|
|
58
|
+
return;
|
|
59
|
+
inputNodeRef.current?.focus();
|
|
60
|
+
}, [isOpen, isLoading]);
|
|
41
61
|
// Restore focus to the trigger. Re-runs on `isLoading`: the input trigger is
|
|
42
62
|
// disabled while a request is in flight and `focus()` would no-op.
|
|
43
63
|
useEffect(() => {
|
|
@@ -89,5 +109,6 @@ export default function PiaModal({ initialQuestions, handleSubmitQuestion, handl
|
|
|
89
109
|
React.createElement("p", { className: 'cio-pia-title', id: 'cio-pia-modal-title' }, translate('Ask about this product', translations)),
|
|
90
110
|
React.createElement("button", { type: 'button', className: 'cio-pia-modal-close-button', "aria-label": translate('Close', translations), onClick: closeModal },
|
|
91
111
|
React.createElement(CloseIcon, null))),
|
|
92
|
-
React.createElement("div", { className: 'cio-pia-modal-body' },
|
|
112
|
+
React.createElement("div", { className: 'cio-pia-modal-body' },
|
|
113
|
+
React.createElement(ModalInputRefContext.Provider, { value: setInputNode }, children))))));
|
|
93
114
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* While an answer loads, the control the shopper used to ask is disabled or swapped for a
|
|
4
|
+
* skeleton, and the browser drops focus to <body>.
|
|
5
|
+
*/
|
|
6
|
+
export default function useReturnFocusToInput(isLoading) {
|
|
7
|
+
const inputNodeRef = useRef(null);
|
|
8
|
+
const pendingRef = useRef(false);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (isLoading || !pendingRef.current)
|
|
11
|
+
return;
|
|
12
|
+
pendingRef.current = false;
|
|
13
|
+
const active = document.activeElement;
|
|
14
|
+
const focusWasDropped = !active || active === document.body;
|
|
15
|
+
if (inputNodeRef.current && focusWasDropped)
|
|
16
|
+
inputNodeRef.current.focus();
|
|
17
|
+
}, [isLoading]);
|
|
18
|
+
const inputRef = useCallback((node) => {
|
|
19
|
+
inputNodeRef.current = node;
|
|
20
|
+
}, []);
|
|
21
|
+
const returnFocusAfterLoading = useCallback(() => {
|
|
22
|
+
pendingRef.current = true;
|
|
23
|
+
}, []);
|
|
24
|
+
return { inputRef, returnFocusAfterLoading };
|
|
25
|
+
}
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
div.subheading {
|
|
2
|
-
--mediumdark: '#999999';
|
|
3
|
-
font-weight: 700;
|
|
4
|
-
font-size: 13px;
|
|
5
|
-
color: #999;
|
|
6
|
-
letter-spacing: 6px;
|
|
7
|
-
line-height: 24px;
|
|
8
|
-
text-transform: uppercase;
|
|
9
|
-
margin-bottom: 12px;
|
|
10
|
-
margin-top: 40px;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
1
|
div.link-list {
|
|
14
2
|
display: grid;
|
|
15
3
|
grid-template-columns: 1fr;
|
|
@@ -78,126 +66,3 @@ a.link-item p {
|
|
|
78
66
|
line-height: 20px;
|
|
79
67
|
}
|
|
80
68
|
|
|
81
|
-
span.badge {
|
|
82
|
-
display: inline-block;
|
|
83
|
-
border-radius: 1em;
|
|
84
|
-
font-size: 11px;
|
|
85
|
-
line-height: 12px;
|
|
86
|
-
font-weight: 700;
|
|
87
|
-
background: #e7fdd8;
|
|
88
|
-
color: #66bf3c;
|
|
89
|
-
padding: 4px 12px;
|
|
90
|
-
margin-right: 10px;
|
|
91
|
-
vertical-align: top;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
div.badge-wrapper {
|
|
95
|
-
font-size: 13px;
|
|
96
|
-
line-height: 20px;
|
|
97
|
-
margin-top: 40px;
|
|
98
|
-
margin-bottom: 40px;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
div.badge-wrapper code {
|
|
102
|
-
font-size: 12px;
|
|
103
|
-
display: inline-block;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.badge.tip {
|
|
107
|
-
background: #e7fdd8;
|
|
108
|
-
color: #66bf3c;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.badge.note {
|
|
112
|
-
background: #d8e8fd;
|
|
113
|
-
color: #3c70bf;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
.cio-flow-diagram {
|
|
117
|
-
display: flex;
|
|
118
|
-
flex-direction: column;
|
|
119
|
-
gap: 4px;
|
|
120
|
-
margin: 24px 0 0;
|
|
121
|
-
font-family: system-ui, sans-serif;
|
|
122
|
-
font-size: 14px;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.cio-flow-diagram .flow-step {
|
|
126
|
-
display: flex;
|
|
127
|
-
align-items: center;
|
|
128
|
-
gap: 12px;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
.cio-flow-diagram .flow-box {
|
|
132
|
-
padding: 10px 16px;
|
|
133
|
-
border-radius: 8px;
|
|
134
|
-
min-width: 220px;
|
|
135
|
-
text-align: center;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
.cio-flow-diagram .flow-box--consumer {
|
|
139
|
-
background: #eff6ff;
|
|
140
|
-
border: 1px solid #bfdbfe;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
.cio-flow-diagram .flow-box--internal {
|
|
144
|
-
background: #f5f3ff;
|
|
145
|
-
border: 1px solid #ddd6fe;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.cio-flow-diagram .flow-box--output {
|
|
149
|
-
background: #f0fdf4;
|
|
150
|
-
border: 1px solid #bbf7d0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
.cio-flow-diagram .flow-arrow {
|
|
154
|
-
margin-left: 110px;
|
|
155
|
-
color: #9ca3af;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
.cio-flow-diagram .flow-label {
|
|
159
|
-
font-size: 12px;
|
|
160
|
-
color: #9ca3af;
|
|
161
|
-
font-style: italic;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.cio-flow-diagram .flow-callback {
|
|
165
|
-
font-size: 12px;
|
|
166
|
-
color: #059669;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.cio-flow-diagram .flow-box-subtitle {
|
|
170
|
-
font-size: 12px;
|
|
171
|
-
color: #6b7280;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
.cio-flow-legend {
|
|
175
|
-
display: flex;
|
|
176
|
-
gap: 16px;
|
|
177
|
-
margin: 32px 0 16px;
|
|
178
|
-
font-size: 12px;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.cio-flow-legend .legend-swatch {
|
|
182
|
-
display: inline-block;
|
|
183
|
-
width: 12px;
|
|
184
|
-
height: 12px;
|
|
185
|
-
border-radius: 3px;
|
|
186
|
-
margin-right: 4px;
|
|
187
|
-
vertical-align: middle;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
.cio-flow-legend .legend-swatch--consumer {
|
|
191
|
-
background: #eff6ff;
|
|
192
|
-
border: 1px solid #bfdbfe;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
.cio-flow-legend .legend-swatch--internal {
|
|
196
|
-
background: #f5f3ff;
|
|
197
|
-
border: 1px solid #ddd6fe;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.cio-flow-legend .legend-swatch--output {
|
|
201
|
-
background: #f0fdf4;
|
|
202
|
-
border: 1px solid #bbf7d0;
|
|
203
|
-
}
|
|
@@ -1,21 +1,71 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
2
|
import { marked } from 'marked';
|
|
3
|
+
// For catalog HTML (product descriptions).
|
|
3
4
|
const purifyConfig = {
|
|
4
5
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
|
5
6
|
};
|
|
7
|
+
// For the answer, which an LLM writes from text a shopper can steer, so only the tags `marked`
|
|
8
|
+
// outputs are kept. Images, media and SVG would load a URL with no click and could leak data;
|
|
9
|
+
// form controls and `style` could fake a form.
|
|
10
|
+
const markdownPurifyConfig = {
|
|
11
|
+
ALLOWED_TAGS: 'p br strong em del a ul ol li blockquote hr code pre table thead tbody tr th td h1 h2 h3 h4 h5 h6'.split(' '),
|
|
12
|
+
ALLOWED_ATTR: ['href', 'align', 'start'],
|
|
13
|
+
};
|
|
14
|
+
// Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
|
|
15
|
+
// links to this site, its subdomains, email and phone stay clickable. A link a custom `sanitize`
|
|
16
|
+
// deliberately keeps (e.g. `sms:`) stays too, but web links to other sites never do.
|
|
17
|
+
function isOnThisSite(href, { keepOtherSchemes = false } = {}) {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(href, window.location.href);
|
|
20
|
+
if (url.protocol === 'mailto:' || url.protocol === 'tel:')
|
|
21
|
+
return true;
|
|
22
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
23
|
+
return keepOtherSchemes;
|
|
24
|
+
const site = window.location.hostname.replace(/^www\./, '');
|
|
25
|
+
return url.hostname === site || url.hostname.endsWith(`.${site}`);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function unlinkOffSiteLinks(html, options) {
|
|
32
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
33
|
+
doc.querySelectorAll('a[href]').forEach((link) => {
|
|
34
|
+
if (!isOnThisSite(link.getAttribute('href'), options)) {
|
|
35
|
+
link.replaceWith(...Array.from(link.childNodes));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return doc.body.innerHTML;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sanitizes catalog HTML such as a product description. Browser only: without a DOM to sanitize
|
|
42
|
+
* with (server rendering) it returns `''`.
|
|
43
|
+
*/
|
|
6
44
|
export function sanitizeHtml(html) {
|
|
7
|
-
if (!html)
|
|
45
|
+
if (!html || !DOMPurify.isSupported)
|
|
8
46
|
return '';
|
|
9
47
|
return DOMPurify.sanitize(html, purifyConfig);
|
|
10
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Turns the answer's markdown into sanitized HTML. Only the tags markdown produces are kept, and
|
|
51
|
+
* only links to this site, its subdomains, `mailto:` and `tel:` stay clickable. A custom `sanitize`
|
|
52
|
+
* can keep other schemes, but web links to other sites always become plain text. Browser only:
|
|
53
|
+
* without a DOM to sanitize with (server rendering) it returns `''`, so render the plain text.
|
|
54
|
+
*/
|
|
11
55
|
export function renderMarkdown(content, options) {
|
|
12
|
-
if (!content)
|
|
56
|
+
if (!content || !DOMPurify.isSupported)
|
|
13
57
|
return '';
|
|
14
58
|
const html = marked.parse(content, { async: false, breaks: true });
|
|
15
59
|
if (options?.sanitize) {
|
|
16
60
|
const purifier = DOMPurify(window);
|
|
17
|
-
const config = {
|
|
18
|
-
|
|
61
|
+
const config = {
|
|
62
|
+
...markdownPurifyConfig,
|
|
63
|
+
ALLOWED_TAGS: [...(markdownPurifyConfig.ALLOWED_TAGS ?? [])],
|
|
64
|
+
ALLOWED_ATTR: [...(markdownPurifyConfig.ALLOWED_ATTR ?? [])],
|
|
65
|
+
};
|
|
66
|
+
return unlinkOffSiteLinks(options.sanitize(purifier, html, { config }), {
|
|
67
|
+
keepOtherSchemes: true,
|
|
68
|
+
});
|
|
19
69
|
}
|
|
20
|
-
return DOMPurify.sanitize(html,
|
|
70
|
+
return unlinkOffSiteLinks(DOMPurify.sanitize(html, markdownPurifyConfig));
|
|
21
71
|
}
|
package/lib/mjs/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '2.
|
|
1
|
+
export default '2.18.1';
|
|
@@ -11,6 +11,7 @@ interface InputProps {
|
|
|
11
11
|
translations?: Translations;
|
|
12
12
|
componentOverride?: ComponentOverrideProps<InputRenderProps>;
|
|
13
13
|
error?: string;
|
|
14
|
+
inputRef?: (node: HTMLElement | null) => void;
|
|
14
15
|
placeholderKey?: string;
|
|
15
16
|
/**
|
|
16
17
|
* Render the Send button after the field.
|
|
@@ -19,5 +20,5 @@ interface InputProps {
|
|
|
19
20
|
*/
|
|
20
21
|
showSendButton?: boolean;
|
|
21
22
|
}
|
|
22
|
-
declare function Input({ value: providedValue, placeholder, disabled, onSubmit, onFocus, translations, componentOverride, error, placeholderKey, showSendButton, }: InputProps): React.JSX.Element;
|
|
23
|
+
declare function Input({ value: providedValue, placeholder, disabled, onSubmit, onFocus, translations, componentOverride, error, inputRef, placeholderKey, showSendButton, }: InputProps): React.JSX.Element;
|
|
23
24
|
export default Input;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from 'react';
|
|
2
2
|
import { Translations, Question, CioPiaComponentOverrides } from '../../types';
|
|
3
|
+
/** Lets the conversation rendered as the modal's children hand it the node of its input. */
|
|
4
|
+
export declare const ModalInputRefContext: React.Context<((node: HTMLElement | null) => void) | undefined>;
|
|
3
5
|
interface PiaModalProps {
|
|
4
6
|
initialQuestions: Question[];
|
|
5
7
|
handleSubmitQuestion: (question: string) => void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface UseReturnFocusToInputReturn {
|
|
2
|
+
inputRef: (node: HTMLElement | null) => void;
|
|
3
|
+
returnFocusAfterLoading: () => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* While an answer loads, the control the shopper used to ask is disabled or swapped for a
|
|
7
|
+
* skeleton, and the browser drops focus to <body>.
|
|
8
|
+
*/
|
|
9
|
+
export default function useReturnFocusToInput(isLoading: boolean): UseReturnFocusToInputReturn;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -291,6 +291,11 @@ export interface InputRenderProps {
|
|
|
291
291
|
translations?: Translations;
|
|
292
292
|
/** Validation message for the value that was just submitted, when there is one. */
|
|
293
293
|
error?: string;
|
|
294
|
+
/**
|
|
295
|
+
* Attach to your text field (`ref={inputRef}`) so focus returns to it after an answer loads.
|
|
296
|
+
* Without it, keyboard focus is lost while your field is disabled or a suggestion is replaced.
|
|
297
|
+
*/
|
|
298
|
+
inputRef?: (node: HTMLElement | null) => void;
|
|
294
299
|
}
|
|
295
300
|
/**
|
|
296
301
|
* Component overrides for CioPia.
|
|
@@ -23,5 +23,15 @@ export interface RenderMarkdownOptions {
|
|
|
23
23
|
*/
|
|
24
24
|
sanitize?: (purifier: typeof DOMPurify, html: string, options: SanitizeOptions) => string;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Sanitizes catalog HTML such as a product description. Browser only: without a DOM to sanitize
|
|
28
|
+
* with (server rendering) it returns `''`.
|
|
29
|
+
*/
|
|
26
30
|
export declare function sanitizeHtml(html: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Turns the answer's markdown into sanitized HTML. Only the tags markdown produces are kept, and
|
|
33
|
+
* only links to this site, its subdomains, `mailto:` and `tel:` stay clickable. A custom `sanitize`
|
|
34
|
+
* can keep other schemes, but web links to other sites always become plain text. Browser only:
|
|
35
|
+
* without a DOM to sanitize with (server rendering) it returns `''`, so render the plain text.
|
|
36
|
+
*/
|
|
27
37
|
export declare function renderMarkdown(content: string, options?: RenderMarkdownOptions): string;
|
package/lib/types/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "2.
|
|
1
|
+
declare const _default: "2.18.1";
|
|
2
2
|
export default _default;
|
package/package.json
CHANGED