PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 0.7.0 All 126 releases
extendify / src / Agent / components / ReplyOptions.jsx

ReplyOptions.jsx in Extendify 3.1.4, at src/Agent/components/ReplyOptions.jsx

270 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 useCallback,
3 useEffect,
4 useLayoutEffect,
5 useRef,
6 useState,
7 } from '@wordpress/element';
8 import { __ } from '@wordpress/i18n';
9 import { arrowUp, chevronDown, chevronUp, Icon } from '@wordpress/icons';
10 import classNames from 'classnames';
11
12 export const ReplyOptions = ({ options, onSubmit }) => {
13 const [own, setOwn] = useState('');
14 const [picked, setPicked] = useState(null);
15 const optionRefs = useRef([]);
16 const ownRef = useRef(null);
17 const cardRef = useRef(null);
18 const typed = own.trim().length > 0;
19
20 // Without this the footer opens below the chat's visible edge.
21 const keepInView = useCallback(() => {
22 const el = cardRef.current;
23 const scroller = el?.closest(
24 '#extendify-agent-chat-scroll-area',
25 )?.parentElement;
26 if (!scroller) return;
27 const overflow =
28 el.getBoundingClientRect().bottom -
29 scroller.getBoundingClientRect().bottom;
30 if (overflow > 0) scroller.scrollTop += overflow + 8;
31 }, []);
32
33 // The box takes focus so typing needs no click and no option looks chosen.
34 useEffect(() => {
35 const target = ownRef.current;
36 target?.focus();
37 if (document.activeElement === target) {
38 keepInView();
39 return;
40 }
41 // Hidden elements refuse focus, and reloads hide the chat until first paint.
42 const timer = setInterval(() => {
43 const active = document.activeElement;
44 if (active && active !== document.body && active !== target) {
45 clearInterval(timer);
46 return;
47 }
48 target?.focus();
49 if (document.activeElement !== target) return;
50 clearInterval(timer);
51 keepInView();
52 }, 100);
53 const stop = setTimeout(() => clearInterval(timer), 3000);
54 return () => {
55 clearInterval(timer);
56 clearTimeout(stop);
57 };
58 }, [keepInView]);
59
60 const adjustHeight = useCallback(() => {
61 const el = ownRef.current;
62 if (!el) return;
63 el.style.height = 'auto';
64 el.style.height = `${el.scrollHeight}px`;
65 keepInView();
66 }, [keepInView]);
67
68 const submit = (value) => {
69 const trimmed = value?.trim();
70 if (!trimmed) return;
71 onSubmit(trimmed);
72 };
73
74 // Sending would discard an answer they already typed, so it only stages.
75 const handleOption = (option, index) => {
76 if (!typed) return submit(option);
77 setPicked((prev) => (prev === index ? null : index));
78 };
79
80 // Arrows never leave the textarea — inside it they move the caret.
81 const focusStop = (index) => {
82 const stops = [...optionRefs.current, ownRef.current].filter(Boolean);
83 stops[Math.max(0, Math.min(index, stops.length - 1))]?.focus();
84 };
85
86 const handleArrows = (event, index) => {
87 if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
88 event.preventDefault();
89 focusStop(index + (event.key === 'ArrowDown' ? 1 : -1));
90 };
91
92 return (
93 <div className="flex flex-col gap-1.5">
94 {options.length ? (
95 <div className="text-xs text-gray-700">
96 {typed
97 ? __('Press to select', 'extendify-local')
98 : __('Press to submit', 'extendify-local')}
99 </div>
100 ) : null}
101 <div
102 ref={cardRef}
103 className="flex flex-col rounded-lg border border-gray-300 bg-gray-50"
104 >
105 <div className="rounded-lg border-b border-gray-300 bg-white">
106 {options.length ? (
107 options.map((option, index) => (
108 <OptionRow
109 key={option}
110 option={option}
111 // The typed answer is the live one until a suggestion is picked.
112 muted={typed && picked !== index}
113 selected={picked === index}
114 divided={index > 0}
115 first={index === 0}
116 last={index === options.length - 1}
117 onSelect={() => handleOption(option, index)}
118 onArrow={(event) => handleArrows(event, index)}
119 optionRef={(el) => {
120 optionRefs.current[index] = el;
121 }}
122 />
123 ))
124 ) : (
125 <div className="p-3 text-sm text-gray-700">
126 {__(
127 'The agent is asking for additional information.',
128 'extendify-local',
129 )}
130 </div>
131 )}
132 </div>
133 {/* biome-ignore lint: the whole footer is the answer box's click target */}
134 <div
135 className="flex cursor-text items-end gap-2 p-3"
136 onClick={() => ownRef.current?.focus()}
137 >
138 <textarea
139 ref={ownRef}
140 value={own}
141 rows={1}
142 onChange={(e) => {
143 setOwn(e.target.value);
144 setPicked(null);
145 adjustHeight();
146 }}
147 // Safari leaves focus here when a button is clicked, so onFocus
148 // alone never fires on the way back.
149 onFocus={() => setPicked(null)}
150 onPointerDown={() => setPicked(null)}
151 onKeyDown={(event) => {
152 if (event.key !== 'Enter' || event.shiftKey) return;
153 event.preventDefault();
154 submit(own);
155 }}
156 className={classNames(
157 'max-h-40 min-h-6 w-full resize-none overflow-y-auto border-none bg-transparent p-0 text-base text-gray-900 transition-opacity placeholder:text-gray-700 focus:shadow-none focus:outline-hidden md:text-sm',
158 { 'opacity-40': picked !== null },
159 )}
160 placeholder={__('Type your own answer…', 'extendify-local')}
161 />
162 {typed ? (
163 <button
164 type="button"
165 onClick={() => submit(picked === null ? own : options[picked])}
166 className={classNames(
167 // Same height as one text line, so the row doesn't grow when it appears.
168 'inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-design-main text-white transition-colors hover:opacity-90 focus-visible:ring-design-main',
169 picked === null ? 'w-6 p-0' : 'px-2.5 text-sm',
170 )}
171 >
172 {picked === null ? (
173 <Icon fill="currentColor" icon={arrowUp} size={16} />
174 ) : (
175 __('Submit', 'extendify-local')
176 )}
177 <span className="sr-only">
178 {__('Send answer', 'extendify-local')}
179 </span>
180 </button>
181 ) : null}
182 </div>
183 {typed ? (
184 <div className="px-3 pb-2 text-xss text-gray-700">
185 {__('Shift + Enter for a new line', 'extendify-local')}
186 </div>
187 ) : null}
188 </div>
189 </div>
190 );
191 };
192
193 const OptionRow = ({
194 option,
195 muted,
196 selected,
197 divided,
198 first,
199 last,
200 onSelect,
201 onArrow,
202 optionRef,
203 }) => {
204 const textRef = useRef(null);
205 const [clipped, setClipped] = useState(false);
206 const [open, setOpen] = useState(false);
207
208 // A clamped box reports scrollHeight as the clamped height; lift to measure.
209 useLayoutEffect(() => {
210 const el = textRef.current;
211 if (!el) return;
212 const clamped = el.clientHeight;
213 el.style.webkitLineClamp = 'unset';
214 const full = el.scrollHeight;
215 el.style.webkitLineClamp = '';
216 setClipped(full - clamped > 1);
217 }, []);
218
219 return (
220 <div
221 className={classNames('relative transition-opacity hover:opacity-100', {
222 'opacity-40': muted,
223 'border-0 border-t border-solid border-gray-300': divided,
224 'ring-2 ring-inset ring-design-main': selected,
225 // Focus rings follow border-radius; a square row overdraws the card's corner.
226 'rounded-t-lg': first,
227 'rounded-b-lg': last,
228 })}
229 >
230 <button
231 type="button"
232 ref={optionRef}
233 onClick={onSelect}
234 onKeyDown={onArrow}
235 className={classNames(
236 'w-full cursor-pointer border-0 bg-transparent p-3 text-left text-sm text-gray-900 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-design-main',
237 // Reserve room under the overlaid toggle so text can't run beneath it.
238 { 'pe-10': clipped, 'rounded-t-lg': first, 'rounded-b-lg': last },
239 )}
240 >
241 {/* `block` would beat line-clamp's display:-webkit-box and unclamp it. */}
242 <span
243 ref={textRef}
244 className={classNames('leading-5', open ? 'block' : 'line-clamp-2')}
245 >
246 {option}
247 </span>
248 </button>
249 {clipped ? (
250 <button
251 type="button"
252 onClick={() => setOpen((prev) => !prev)}
253 className="absolute end-2 top-2.5 flex h-6 w-6 cursor-pointer items-center justify-center rounded-sm border-0 bg-gray-100 p-0 text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-900 focus-visible:ring-2 focus-visible:ring-design-main"
254 >
255 <Icon
256 fill="currentColor"
257 icon={open ? chevronUp : chevronDown}
258 size={18}
259 />
260 <span className="sr-only">
261 {open
262 ? __('Show less', 'extendify-local')
263 : __('Show more', 'extendify-local')}
264 </span>
265 </button>
266 ) : null}
267 </div>
268 );
269 };
270