@envive-ai/react-widgets 0.3.66 → 0.3.67
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/SearchResults/SearchResultsWidget.d.cts +2 -2
- package/dist/SearchResults/SearchResultsWidget.d.ts +2 -2
- package/dist/SearchZeroState/SearchZeroStateWidget.d.cts +2 -2
- package/dist/SuggestionBar/SuggestionBar.cjs +22 -5
- package/dist/SuggestionBar/SuggestionBar.d.cts +2 -2
- package/dist/SuggestionBar/SuggestionBar.js +24 -7
- package/dist/SuggestionBar/SuggestionBarAnimationToggle.cjs +100 -0
- package/dist/SuggestionBar/SuggestionBarAnimationToggle.js +98 -0
- package/dist/SuggestionButtonContainer/SuggestionButtonContainer.cjs +12 -5
- package/dist/SuggestionButtonContainer/SuggestionButtonContainer.js +12 -5
- package/dist/SuggestionButtonContainer/types.d.cts +2 -0
- package/dist/SuggestionButtonContainer/types.d.ts +2 -0
- package/dist/util/useHorizontalScrollAnimation.cjs +17 -4
- package/dist/util/useHorizontalScrollAnimation.js +17 -4
- package/package.json +1 -1
- package/src/SuggestionBar/SuggestionBar.tsx +22 -1
- package/src/SuggestionBar/SuggestionBarAnimationToggle.tsx +137 -0
- package/src/SuggestionButtonContainer/SuggestionButtonContainer.tsx +12 -1
- package/src/SuggestionButtonContainer/types.ts +2 -0
- package/src/stories/SuggestionBar.stories.tsx +43 -0
- package/src/util/useHorizontalScrollAnimation.ts +32 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react_jsx_runtime1 from "react/jsx-runtime";
|
|
2
2
|
|
|
3
3
|
//#region src/SearchResults/SearchResultsWidget.d.ts
|
|
4
|
-
declare const SearchResultsWidget: () =>
|
|
4
|
+
declare const SearchResultsWidget: () => react_jsx_runtime1.JSX.Element;
|
|
5
5
|
//#endregion
|
|
6
6
|
export { SearchResultsWidget };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
2
2
|
|
|
3
3
|
//#region src/SearchResults/SearchResultsWidget.d.ts
|
|
4
|
-
declare const SearchResultsWidget: () =>
|
|
4
|
+
declare const SearchResultsWidget: () => react_jsx_runtime0.JSX.Element;
|
|
5
5
|
//#endregion
|
|
6
6
|
export { SearchResultsWidget };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react_jsx_runtime2 from "react/jsx-runtime";
|
|
2
2
|
|
|
3
3
|
//#region src/SearchZeroState/SearchZeroStateWidget.d.ts
|
|
4
4
|
type SearchZeroStateWidgetProps = {
|
|
@@ -10,6 +10,6 @@ declare const SearchZeroStateWidget: ({
|
|
|
10
10
|
initialIsOpen,
|
|
11
11
|
widgetConfigId,
|
|
12
12
|
entryPointRef
|
|
13
|
-
}: SearchZeroStateWidgetProps) =>
|
|
13
|
+
}: SearchZeroStateWidgetProps) => react_jsx_runtime2.JSX.Element;
|
|
14
14
|
//#endregion
|
|
15
15
|
export { SearchZeroStateWidget };
|
|
@@ -2,8 +2,10 @@ const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
|
|
|
2
2
|
const require_index = require('../node_modules/react-indiana-drag-scroll/dist/index.cjs');
|
|
3
3
|
const require_v4 = require('../node_modules/uuid/dist-node/v4.cjs');
|
|
4
4
|
const require_types = require('./types.cjs');
|
|
5
|
+
const require_useReducedMotionWithOverride = require('../util/useReducedMotionWithOverride.cjs');
|
|
5
6
|
const require_SuggestionButtonContainer = require('../SuggestionButtonContainer/SuggestionButtonContainer.cjs');
|
|
6
7
|
require('../SuggestionButtonContainer/index.cjs');
|
|
8
|
+
const require_SuggestionBarAnimationToggle = require('./SuggestionBarAnimationToggle.cjs');
|
|
7
9
|
let react = require("react");
|
|
8
10
|
let jotai = require("jotai");
|
|
9
11
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
@@ -21,7 +23,12 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
21
23
|
const { ref } = (0, import_dist.useScrollContainer)();
|
|
22
24
|
const isSmallScreen = (0, __envive_ai_react_hooks_hooks_IsSmallScreen.useIsSmallScreen)();
|
|
23
25
|
const logPerfMetric = (0, jotai.useSetAtom)(__envive_ai_react_hooks_atoms_chat.logPerfMetricAtom);
|
|
24
|
-
|
|
26
|
+
const reducedMotion = require_useReducedMotionWithOverride.useReducedMotionWithOverride();
|
|
27
|
+
const [isUserPaused, setIsUserPaused] = (0, react.useState)(false);
|
|
28
|
+
const isAnimated = animationSpeed !== "none";
|
|
29
|
+
const showAnimationToggle = isAnimated && !reducedMotion;
|
|
30
|
+
const scrollContainerId = `${id}-scroll-container`;
|
|
31
|
+
(0, __envive_ai_react_hooks_hooks_TrackComponentVisibleEvent.useTrackComponentVisibleEvent)(componentVisibleTriggerRef, { animated: isAnimated });
|
|
25
32
|
const handleClickSuggestion = (0, react.useCallback)((buttonText) => {
|
|
26
33
|
handleReply({
|
|
27
34
|
id: require_v4.default(),
|
|
@@ -43,10 +50,19 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
43
50
|
logPerfMetric,
|
|
44
51
|
setRefs
|
|
45
52
|
]);
|
|
46
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.
|
|
53
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
47
54
|
id,
|
|
48
55
|
className: "spiffy-tw-w-full spiffy-tw-justify-center spiffy-tw-overflow-hidden",
|
|
49
|
-
|
|
56
|
+
style: showAnimationToggle ? { position: "relative" } : void 0,
|
|
57
|
+
children: [showAnimationToggle && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_SuggestionBarAnimationToggle.SuggestionBarAnimationToggle, {
|
|
58
|
+
isPaused: isUserPaused,
|
|
59
|
+
onToggle: () => setIsUserPaused((paused) => !paused),
|
|
60
|
+
buttonVariation,
|
|
61
|
+
hoverButtonVariation,
|
|
62
|
+
buttonBorderRadius,
|
|
63
|
+
controlsId: scrollContainerId
|
|
64
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
65
|
+
id: scrollContainerId,
|
|
50
66
|
className: `spiffy-tw-no-scrollbar spiffy-tw-relative spiffy-tw-w-full spiffy-tw-overflow-y-hidden spiffy-tw-overflow-x-scroll ${twoRowsOnMobile && isSmallScreen ? "spiffy-tw-h-20" : "spiffy-tw-h-9"} `,
|
|
51
67
|
ref: handleContainerRef,
|
|
52
68
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -62,10 +78,11 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
62
78
|
boldFirstButton,
|
|
63
79
|
twoRowsOnMobile,
|
|
64
80
|
animationSpeed,
|
|
65
|
-
buttonBorderRadius
|
|
81
|
+
buttonBorderRadius,
|
|
82
|
+
isUserPaused
|
|
66
83
|
})
|
|
67
84
|
})
|
|
68
|
-
})
|
|
85
|
+
})]
|
|
69
86
|
});
|
|
70
87
|
}
|
|
71
88
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SuggestionBarLocationForMetrics } from "./types.cjs";
|
|
2
|
-
import * as
|
|
2
|
+
import * as react_jsx_runtime3 from "react/jsx-runtime";
|
|
3
3
|
import { SuggestionButtonVariant } from "@envive-ai/react-hooks/contexts/types";
|
|
4
4
|
import { Message } from "postcss";
|
|
5
5
|
|
|
@@ -29,6 +29,6 @@ declare function SuggestionBar({
|
|
|
29
29
|
buttonBorderRadius,
|
|
30
30
|
handleReply,
|
|
31
31
|
dataTestId
|
|
32
|
-
}: Readonly<SuggestionBarProps>):
|
|
32
|
+
}: Readonly<SuggestionBarProps>): react_jsx_runtime3.JSX.Element;
|
|
33
33
|
//#endregion
|
|
34
34
|
export { SuggestionBar };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import v4_default from "../node_modules/uuid/dist/v4.js";
|
|
2
2
|
import { SuggestionBarLocationForMetrics } from "./types.js";
|
|
3
|
+
import { useReducedMotionWithOverride } from "../util/useReducedMotionWithOverride.js";
|
|
3
4
|
import { SuggestionButtonContainer } from "../SuggestionButtonContainer/SuggestionButtonContainer.js";
|
|
4
5
|
import "../SuggestionButtonContainer/index.js";
|
|
5
|
-
import {
|
|
6
|
+
import { SuggestionBarAnimationToggle } from "./SuggestionBarAnimationToggle.js";
|
|
7
|
+
import { useCallback, useRef, useState } from "react";
|
|
6
8
|
import { useSetAtom } from "jotai";
|
|
7
|
-
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
10
|
import { MessageRole, MessageType } from "@envive-ai/react-hooks/application/models";
|
|
9
11
|
import { useScrollContainer } from "react-indiana-drag-scroll";
|
|
10
12
|
import { PerfMetricsEvents, logPerfMetricAtom } from "@envive-ai/react-hooks/atoms/chat";
|
|
@@ -19,7 +21,12 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
19
21
|
const { ref } = useScrollContainer();
|
|
20
22
|
const isSmallScreen = useIsSmallScreen();
|
|
21
23
|
const logPerfMetric = useSetAtom(logPerfMetricAtom);
|
|
22
|
-
|
|
24
|
+
const reducedMotion = useReducedMotionWithOverride();
|
|
25
|
+
const [isUserPaused, setIsUserPaused] = useState(false);
|
|
26
|
+
const isAnimated = animationSpeed !== "none";
|
|
27
|
+
const showAnimationToggle = isAnimated && !reducedMotion;
|
|
28
|
+
const scrollContainerId = `${id}-scroll-container`;
|
|
29
|
+
useTrackComponentVisibleEvent(componentVisibleTriggerRef, { animated: isAnimated });
|
|
23
30
|
const handleClickSuggestion = useCallback((buttonText) => {
|
|
24
31
|
handleReply({
|
|
25
32
|
id: v4_default(),
|
|
@@ -41,10 +48,19 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
41
48
|
logPerfMetric,
|
|
42
49
|
setRefs
|
|
43
50
|
]);
|
|
44
|
-
return /* @__PURE__ */
|
|
51
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
45
52
|
id,
|
|
46
53
|
className: "spiffy-tw-w-full spiffy-tw-justify-center spiffy-tw-overflow-hidden",
|
|
47
|
-
|
|
54
|
+
style: showAnimationToggle ? { position: "relative" } : void 0,
|
|
55
|
+
children: [showAnimationToggle && /* @__PURE__ */ jsx(SuggestionBarAnimationToggle, {
|
|
56
|
+
isPaused: isUserPaused,
|
|
57
|
+
onToggle: () => setIsUserPaused((paused) => !paused),
|
|
58
|
+
buttonVariation,
|
|
59
|
+
hoverButtonVariation,
|
|
60
|
+
buttonBorderRadius,
|
|
61
|
+
controlsId: scrollContainerId
|
|
62
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
63
|
+
id: scrollContainerId,
|
|
48
64
|
className: `spiffy-tw-no-scrollbar spiffy-tw-relative spiffy-tw-w-full spiffy-tw-overflow-y-hidden spiffy-tw-overflow-x-scroll ${twoRowsOnMobile && isSmallScreen ? "spiffy-tw-h-20" : "spiffy-tw-h-9"} `,
|
|
49
65
|
ref: handleContainerRef,
|
|
50
66
|
children: /* @__PURE__ */ jsx("div", {
|
|
@@ -60,10 +76,11 @@ function SuggestionBar({ id, locationForMetrics, buttonTexts, buttonVariation, h
|
|
|
60
76
|
boldFirstButton,
|
|
61
77
|
twoRowsOnMobile,
|
|
62
78
|
animationSpeed,
|
|
63
|
-
buttonBorderRadius
|
|
79
|
+
buttonBorderRadius,
|
|
80
|
+
isUserPaused
|
|
64
81
|
})
|
|
65
82
|
})
|
|
66
|
-
})
|
|
83
|
+
})]
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
|
|
2
|
+
let react = require("react");
|
|
3
|
+
let classnames = require("classnames");
|
|
4
|
+
classnames = require_rolldown_runtime.__toESM(classnames);
|
|
5
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
6
|
+
let __envive_ai_react_toolkit_SuggestionButton = require("@envive-ai/react-toolkit/SuggestionButton");
|
|
7
|
+
|
|
8
|
+
//#region src/SuggestionBar/SuggestionBarAnimationToggle.tsx
|
|
9
|
+
const SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID = "suggestion-bar-animation-toggle";
|
|
10
|
+
const PauseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
11
|
+
width: "12",
|
|
12
|
+
height: "12",
|
|
13
|
+
viewBox: "0 0 12 12",
|
|
14
|
+
"aria-hidden": "true",
|
|
15
|
+
focusable: "false",
|
|
16
|
+
fill: "currentColor",
|
|
17
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
18
|
+
x: "2",
|
|
19
|
+
y: "1",
|
|
20
|
+
width: "3",
|
|
21
|
+
height: "10",
|
|
22
|
+
rx: "1"
|
|
23
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
24
|
+
x: "7",
|
|
25
|
+
y: "1",
|
|
26
|
+
width: "3",
|
|
27
|
+
height: "10",
|
|
28
|
+
rx: "1"
|
|
29
|
+
})]
|
|
30
|
+
});
|
|
31
|
+
const PlayIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
32
|
+
width: "12",
|
|
33
|
+
height: "12",
|
|
34
|
+
viewBox: "0 0 12 12",
|
|
35
|
+
"aria-hidden": "true",
|
|
36
|
+
focusable: "false",
|
|
37
|
+
fill: "currentColor",
|
|
38
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 1.5v9a.75.75 0 0 0 1.14.64l7.2-4.5a.75.75 0 0 0 0-1.28l-7.2-4.5A.75.75 0 0 0 3 1.5Z" })
|
|
39
|
+
});
|
|
40
|
+
const VISUALLY_HIDDEN_STYLE = {
|
|
41
|
+
position: "absolute",
|
|
42
|
+
width: 1,
|
|
43
|
+
height: 1,
|
|
44
|
+
padding: 0,
|
|
45
|
+
margin: -1,
|
|
46
|
+
overflow: "hidden",
|
|
47
|
+
clip: "rect(0 0 0 0)",
|
|
48
|
+
whiteSpace: "nowrap",
|
|
49
|
+
border: 0
|
|
50
|
+
};
|
|
51
|
+
const FOCUSED_STYLE = {
|
|
52
|
+
position: "absolute",
|
|
53
|
+
top: 0,
|
|
54
|
+
left: 0,
|
|
55
|
+
zIndex: 1,
|
|
56
|
+
height: 36,
|
|
57
|
+
minHeight: 36,
|
|
58
|
+
gap: 6,
|
|
59
|
+
whiteSpace: "nowrap",
|
|
60
|
+
outline: "2px solid currentColor",
|
|
61
|
+
outlineOffset: -4
|
|
62
|
+
};
|
|
63
|
+
const SOLID_BACKDROP_BY_VARIANT = {
|
|
64
|
+
outlined: "var(--spiffy-colors-background-light, #ffffff)",
|
|
65
|
+
transparentDark: "var(--spiffy-colors-background-light, #ffffff)",
|
|
66
|
+
transparent: "var(--spiffy-colors-background-dark, #000000)"
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Keyboard-accessible pause/play control for the auto-scrolling suggestion bar
|
|
70
|
+
* (WCAG 2.2.2 Pause, Stop, Hide). Styled with the same variant as the suggestion
|
|
71
|
+
* buttons so it blends in with the merchant's theme when it shows up on focus.
|
|
72
|
+
* Styles are inline so they don't depend on the host generating new utility classes.
|
|
73
|
+
*/
|
|
74
|
+
const SuggestionBarAnimationToggle = ({ isPaused, onToggle, buttonVariation, hoverButtonVariation, buttonBorderRadius, controlsId }) => {
|
|
75
|
+
const [isFocused, setIsFocused] = (0, react.useState)(false);
|
|
76
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
77
|
+
type: "button",
|
|
78
|
+
onClick: onToggle,
|
|
79
|
+
onFocus: () => setIsFocused(true),
|
|
80
|
+
onBlur: () => setIsFocused(false),
|
|
81
|
+
"aria-controls": controlsId,
|
|
82
|
+
"data-testid": SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID,
|
|
83
|
+
style: isFocused ? {
|
|
84
|
+
...FOCUSED_STYLE,
|
|
85
|
+
backgroundColor: SOLID_BACKDROP_BY_VARIANT[buttonVariation]
|
|
86
|
+
} : VISUALLY_HIDDEN_STYLE,
|
|
87
|
+
className: (0, classnames.default)("spiffy-tw-flex spiffy-tw-cursor-pointer spiffy-tw-items-center spiffy-tw-px-4", {
|
|
88
|
+
"!spiffy-tw-rounded-[20px]": buttonBorderRadius === "lg",
|
|
89
|
+
"!spiffy-tw-rounded-[8px]": buttonBorderRadius === "md",
|
|
90
|
+
"!spiffy-tw-rounded-[0px]": buttonBorderRadius === "sm"
|
|
91
|
+
}, (0, __envive_ai_react_toolkit_SuggestionButton.getSuggestionButtonVariantClasses)(buttonVariation, hoverButtonVariation)),
|
|
92
|
+
children: [isPaused ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayIcon, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PauseIcon, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
93
|
+
className: "spiffy-tw-text-[14px]",
|
|
94
|
+
children: isPaused ? "Play scrolling suggestions" : "Pause scrolling suggestions"
|
|
95
|
+
})]
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
exports.SuggestionBarAnimationToggle = SuggestionBarAnimationToggle;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import classNames from "classnames";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { getSuggestionButtonVariantClasses } from "@envive-ai/react-toolkit/SuggestionButton";
|
|
5
|
+
|
|
6
|
+
//#region src/SuggestionBar/SuggestionBarAnimationToggle.tsx
|
|
7
|
+
const SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID = "suggestion-bar-animation-toggle";
|
|
8
|
+
const PauseIcon = () => /* @__PURE__ */ jsxs("svg", {
|
|
9
|
+
width: "12",
|
|
10
|
+
height: "12",
|
|
11
|
+
viewBox: "0 0 12 12",
|
|
12
|
+
"aria-hidden": "true",
|
|
13
|
+
focusable: "false",
|
|
14
|
+
fill: "currentColor",
|
|
15
|
+
children: [/* @__PURE__ */ jsx("rect", {
|
|
16
|
+
x: "2",
|
|
17
|
+
y: "1",
|
|
18
|
+
width: "3",
|
|
19
|
+
height: "10",
|
|
20
|
+
rx: "1"
|
|
21
|
+
}), /* @__PURE__ */ jsx("rect", {
|
|
22
|
+
x: "7",
|
|
23
|
+
y: "1",
|
|
24
|
+
width: "3",
|
|
25
|
+
height: "10",
|
|
26
|
+
rx: "1"
|
|
27
|
+
})]
|
|
28
|
+
});
|
|
29
|
+
const PlayIcon = () => /* @__PURE__ */ jsx("svg", {
|
|
30
|
+
width: "12",
|
|
31
|
+
height: "12",
|
|
32
|
+
viewBox: "0 0 12 12",
|
|
33
|
+
"aria-hidden": "true",
|
|
34
|
+
focusable: "false",
|
|
35
|
+
fill: "currentColor",
|
|
36
|
+
children: /* @__PURE__ */ jsx("path", { d: "M3 1.5v9a.75.75 0 0 0 1.14.64l7.2-4.5a.75.75 0 0 0 0-1.28l-7.2-4.5A.75.75 0 0 0 3 1.5Z" })
|
|
37
|
+
});
|
|
38
|
+
const VISUALLY_HIDDEN_STYLE = {
|
|
39
|
+
position: "absolute",
|
|
40
|
+
width: 1,
|
|
41
|
+
height: 1,
|
|
42
|
+
padding: 0,
|
|
43
|
+
margin: -1,
|
|
44
|
+
overflow: "hidden",
|
|
45
|
+
clip: "rect(0 0 0 0)",
|
|
46
|
+
whiteSpace: "nowrap",
|
|
47
|
+
border: 0
|
|
48
|
+
};
|
|
49
|
+
const FOCUSED_STYLE = {
|
|
50
|
+
position: "absolute",
|
|
51
|
+
top: 0,
|
|
52
|
+
left: 0,
|
|
53
|
+
zIndex: 1,
|
|
54
|
+
height: 36,
|
|
55
|
+
minHeight: 36,
|
|
56
|
+
gap: 6,
|
|
57
|
+
whiteSpace: "nowrap",
|
|
58
|
+
outline: "2px solid currentColor",
|
|
59
|
+
outlineOffset: -4
|
|
60
|
+
};
|
|
61
|
+
const SOLID_BACKDROP_BY_VARIANT = {
|
|
62
|
+
outlined: "var(--spiffy-colors-background-light, #ffffff)",
|
|
63
|
+
transparentDark: "var(--spiffy-colors-background-light, #ffffff)",
|
|
64
|
+
transparent: "var(--spiffy-colors-background-dark, #000000)"
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Keyboard-accessible pause/play control for the auto-scrolling suggestion bar
|
|
68
|
+
* (WCAG 2.2.2 Pause, Stop, Hide). Styled with the same variant as the suggestion
|
|
69
|
+
* buttons so it blends in with the merchant's theme when it shows up on focus.
|
|
70
|
+
* Styles are inline so they don't depend on the host generating new utility classes.
|
|
71
|
+
*/
|
|
72
|
+
const SuggestionBarAnimationToggle = ({ isPaused, onToggle, buttonVariation, hoverButtonVariation, buttonBorderRadius, controlsId }) => {
|
|
73
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
74
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
75
|
+
type: "button",
|
|
76
|
+
onClick: onToggle,
|
|
77
|
+
onFocus: () => setIsFocused(true),
|
|
78
|
+
onBlur: () => setIsFocused(false),
|
|
79
|
+
"aria-controls": controlsId,
|
|
80
|
+
"data-testid": SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID,
|
|
81
|
+
style: isFocused ? {
|
|
82
|
+
...FOCUSED_STYLE,
|
|
83
|
+
backgroundColor: SOLID_BACKDROP_BY_VARIANT[buttonVariation]
|
|
84
|
+
} : VISUALLY_HIDDEN_STYLE,
|
|
85
|
+
className: classNames("spiffy-tw-flex spiffy-tw-cursor-pointer spiffy-tw-items-center spiffy-tw-px-4", {
|
|
86
|
+
"!spiffy-tw-rounded-[20px]": buttonBorderRadius === "lg",
|
|
87
|
+
"!spiffy-tw-rounded-[8px]": buttonBorderRadius === "md",
|
|
88
|
+
"!spiffy-tw-rounded-[0px]": buttonBorderRadius === "sm"
|
|
89
|
+
}, getSuggestionButtonVariantClasses(buttonVariation, hoverButtonVariation)),
|
|
90
|
+
children: [isPaused ? /* @__PURE__ */ jsx(PlayIcon, {}) : /* @__PURE__ */ jsx(PauseIcon, {}), /* @__PURE__ */ jsx("span", {
|
|
91
|
+
className: "spiffy-tw-text-[14px]",
|
|
92
|
+
children: isPaused ? "Play scrolling suggestions" : "Pause scrolling suggestions"
|
|
93
|
+
})]
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { SuggestionBarAnimationToggle };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
|
|
2
|
+
const require_useReducedMotionWithOverride = require('../util/useReducedMotionWithOverride.cjs');
|
|
2
3
|
const require_useHorizontalScrollAnimation = require('../util/useHorizontalScrollAnimation.cjs');
|
|
3
4
|
let react = require("react");
|
|
4
5
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
@@ -14,14 +15,16 @@ function ButtonContainer({ children }) {
|
|
|
14
15
|
children
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
|
-
const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, buttonTexts = [], boldFirstButton = false, twoRowsOnMobile = false, animationSpeed = "none", buttonBorderRadius = "lg", scrollContainerRef, onButtonClick }) => {
|
|
18
|
+
const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, buttonTexts = [], boldFirstButton = false, twoRowsOnMobile = false, animationSpeed = "none", buttonBorderRadius = "lg", scrollContainerRef, onButtonClick, isUserPaused = false }) => {
|
|
18
19
|
const componentVisibleTriggerRef = (0, react.useRef)(null);
|
|
19
20
|
require_useHorizontalScrollAnimation.useHorizontalScrollAnimation({
|
|
20
21
|
scrollContainerRef,
|
|
21
|
-
animationSpeed
|
|
22
|
+
animationSpeed,
|
|
23
|
+
isUserPaused
|
|
22
24
|
});
|
|
23
25
|
const isSmallScreen = (0, __envive_ai_react_hooks_hooks_IsSmallScreen.useIsSmallScreen)();
|
|
24
|
-
const
|
|
26
|
+
const reducedMotion = require_useReducedMotionWithOverride.useReducedMotionWithOverride();
|
|
27
|
+
const isAnimated = animationSpeed !== "none" && !reducedMotion;
|
|
25
28
|
(0, __envive_ai_react_hooks_hooks_TrackComponentVisibleEvent.useTrackComponentVisibleEvent)(componentVisibleTriggerRef, { animated: isAnimated });
|
|
26
29
|
const visibleButtonsFirstRow = buttonTexts.slice(0, twoRowsOnMobile && isSmallScreen ? Math.ceil((buttonTexts.length + 1) / 2) : void 0);
|
|
27
30
|
const visibleButtonsSecondRow = buttonTexts.slice(Math.ceil((buttonTexts.length + 1) / 2), buttonTexts.length);
|
|
@@ -44,7 +47,9 @@ const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, butt
|
|
|
44
47
|
boldText: boldFirstButton && i === 0,
|
|
45
48
|
borderRadius: buttonBorderRadius,
|
|
46
49
|
onClick: () => onButtonClick(suggestion),
|
|
47
|
-
dataTestId: __envive_ai_react_hooks_config.SUGGESTION_BAR_BUTTON_TESTID
|
|
50
|
+
dataTestId: __envive_ai_react_hooks_config.SUGGESTION_BAR_BUTTON_TESTID,
|
|
51
|
+
ariaHidden: true,
|
|
52
|
+
tabIndex: -1
|
|
48
53
|
}, `animation-dupe-${i}`))] }), twoRowsOnMobile && isSmallScreen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
49
54
|
className: "spiffy-tw-mt-1.5",
|
|
50
55
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ButtonContainer, { children: [visibleButtonsSecondRow.map((suggestion, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__envive_ai_react_toolkit_SuggestionButton.SuggestionButton, {
|
|
@@ -64,7 +69,9 @@ const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, butt
|
|
|
64
69
|
boldText: boldFirstButton && i === 0,
|
|
65
70
|
borderRadius: buttonBorderRadius,
|
|
66
71
|
onClick: () => onButtonClick(suggestion),
|
|
67
|
-
dataTestId: __envive_ai_react_hooks_config.SUGGESTION_BAR_BUTTON_TESTID
|
|
72
|
+
dataTestId: __envive_ai_react_hooks_config.SUGGESTION_BAR_BUTTON_TESTID,
|
|
73
|
+
ariaHidden: true,
|
|
74
|
+
tabIndex: -1
|
|
68
75
|
}, `animation-dupe-${i}`))] })
|
|
69
76
|
})]
|
|
70
77
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useReducedMotionWithOverride } from "../util/useReducedMotionWithOverride.js";
|
|
1
2
|
import { useHorizontalScrollAnimation } from "../util/useHorizontalScrollAnimation.js";
|
|
2
3
|
import { useRef } from "react";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -13,14 +14,16 @@ function ButtonContainer({ children }) {
|
|
|
13
14
|
children
|
|
14
15
|
});
|
|
15
16
|
}
|
|
16
|
-
const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, buttonTexts = [], boldFirstButton = false, twoRowsOnMobile = false, animationSpeed = "none", buttonBorderRadius = "lg", scrollContainerRef, onButtonClick }) => {
|
|
17
|
+
const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, buttonTexts = [], boldFirstButton = false, twoRowsOnMobile = false, animationSpeed = "none", buttonBorderRadius = "lg", scrollContainerRef, onButtonClick, isUserPaused = false }) => {
|
|
17
18
|
const componentVisibleTriggerRef = useRef(null);
|
|
18
19
|
useHorizontalScrollAnimation({
|
|
19
20
|
scrollContainerRef,
|
|
20
|
-
animationSpeed
|
|
21
|
+
animationSpeed,
|
|
22
|
+
isUserPaused
|
|
21
23
|
});
|
|
22
24
|
const isSmallScreen = useIsSmallScreen();
|
|
23
|
-
const
|
|
25
|
+
const reducedMotion = useReducedMotionWithOverride();
|
|
26
|
+
const isAnimated = animationSpeed !== "none" && !reducedMotion;
|
|
24
27
|
useTrackComponentVisibleEvent(componentVisibleTriggerRef, { animated: isAnimated });
|
|
25
28
|
const visibleButtonsFirstRow = buttonTexts.slice(0, twoRowsOnMobile && isSmallScreen ? Math.ceil((buttonTexts.length + 1) / 2) : void 0);
|
|
26
29
|
const visibleButtonsSecondRow = buttonTexts.slice(Math.ceil((buttonTexts.length + 1) / 2), buttonTexts.length);
|
|
@@ -43,7 +46,9 @@ const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, butt
|
|
|
43
46
|
boldText: boldFirstButton && i === 0,
|
|
44
47
|
borderRadius: buttonBorderRadius,
|
|
45
48
|
onClick: () => onButtonClick(suggestion),
|
|
46
|
-
dataTestId: SUGGESTION_BAR_BUTTON_TESTID
|
|
49
|
+
dataTestId: SUGGESTION_BAR_BUTTON_TESTID,
|
|
50
|
+
ariaHidden: true,
|
|
51
|
+
tabIndex: -1
|
|
47
52
|
}, `animation-dupe-${i}`))] }), twoRowsOnMobile && isSmallScreen && /* @__PURE__ */ jsx("div", {
|
|
48
53
|
className: "spiffy-tw-mt-1.5",
|
|
49
54
|
children: /* @__PURE__ */ jsxs(ButtonContainer, { children: [visibleButtonsSecondRow.map((suggestion, i) => /* @__PURE__ */ jsx(SuggestionButton, {
|
|
@@ -63,7 +68,9 @@ const SuggestionButtonContainer = ({ buttonVariation, hoverButtonVariation, butt
|
|
|
63
68
|
boldText: boldFirstButton && i === 0,
|
|
64
69
|
borderRadius: buttonBorderRadius,
|
|
65
70
|
onClick: () => onButtonClick(suggestion),
|
|
66
|
-
dataTestId: SUGGESTION_BAR_BUTTON_TESTID
|
|
71
|
+
dataTestId: SUGGESTION_BAR_BUTTON_TESTID,
|
|
72
|
+
ariaHidden: true,
|
|
73
|
+
tabIndex: -1
|
|
67
74
|
}, `animation-dupe-${i}`))] })
|
|
68
75
|
})]
|
|
69
76
|
});
|
|
@@ -12,6 +12,8 @@ interface SuggestionButtonContainerProps extends TestProps {
|
|
|
12
12
|
twoRowsOnMobile?: boolean | undefined;
|
|
13
13
|
animationSpeed?: 'standard' | 'slow' | 'none';
|
|
14
14
|
buttonBorderRadius?: 'sm' | 'md' | 'lg';
|
|
15
|
+
/** Pauses the auto-scroll animation until toggled back (user pause/play control). */
|
|
16
|
+
isUserPaused?: boolean;
|
|
15
17
|
}
|
|
16
18
|
//#endregion
|
|
17
19
|
export { SuggestionButtonContainerProps };
|
|
@@ -12,6 +12,8 @@ interface SuggestionButtonContainerProps extends TestProps {
|
|
|
12
12
|
twoRowsOnMobile?: boolean | undefined;
|
|
13
13
|
animationSpeed?: 'standard' | 'slow' | 'none';
|
|
14
14
|
buttonBorderRadius?: 'sm' | 'md' | 'lg';
|
|
15
|
+
/** Pauses the auto-scroll animation until toggled back (user pause/play control). */
|
|
16
|
+
isUserPaused?: boolean;
|
|
15
17
|
}
|
|
16
18
|
//#endregion
|
|
17
19
|
export { SuggestionButtonContainerProps };
|
|
@@ -3,10 +3,12 @@ const require_useReducedMotionWithOverride = require('./useReducedMotionWithOver
|
|
|
3
3
|
let react = require("react");
|
|
4
4
|
|
|
5
5
|
//#region src/util/useHorizontalScrollAnimation.ts
|
|
6
|
-
function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "standard" }) {
|
|
6
|
+
function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "standard", isUserPaused = false }) {
|
|
7
7
|
const reducedMotion = require_useReducedMotionWithOverride.useReducedMotionWithOverride();
|
|
8
8
|
const resumeTimeoutRef = (0, react.useRef)(null);
|
|
9
9
|
const scrollAnimationRef = (0, react.useRef)(null);
|
|
10
|
+
const isUserPausedRef = (0, react.useRef)(isUserPaused);
|
|
11
|
+
isUserPausedRef.current = isUserPaused;
|
|
10
12
|
const pauseOnHover = true;
|
|
11
13
|
let PIXELS_PER_SECOND = 40;
|
|
12
14
|
switch (animationSpeed) {
|
|
@@ -32,8 +34,9 @@ function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "st
|
|
|
32
34
|
let lastTimestamp = null;
|
|
33
35
|
let accumulatedScroll = 0;
|
|
34
36
|
const step = (timestamp) => {
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
+
if (isPaused || isUserPausedRef.current) lastTimestamp = null;
|
|
38
|
+
else {
|
|
39
|
+
if (lastTimestamp === null) lastTimestamp = timestamp;
|
|
37
40
|
const delta = timestamp - lastTimestamp;
|
|
38
41
|
lastTimestamp = timestamp;
|
|
39
42
|
accumulatedScroll += PIXELS_PER_SECOND * (delta / 1e3);
|
|
@@ -58,21 +61,31 @@ function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "st
|
|
|
58
61
|
}
|
|
59
62
|
};
|
|
60
63
|
const scheduleResumeAnimation = () => {
|
|
64
|
+
if (resumeTimeoutRef.current) clearTimeout(resumeTimeoutRef.current);
|
|
61
65
|
resumeTimeoutRef.current = setTimeout(() => {
|
|
66
|
+
if (container.contains(document.activeElement)) return;
|
|
62
67
|
isPaused = false;
|
|
63
|
-
lastTimestamp = null;
|
|
64
68
|
}, RESUME_DELAY_MS);
|
|
65
69
|
};
|
|
70
|
+
const handleFocusOut = (event) => {
|
|
71
|
+
const nextTarget = event.relatedTarget;
|
|
72
|
+
if (nextTarget && container.contains(nextTarget)) return;
|
|
73
|
+
scheduleResumeAnimation();
|
|
74
|
+
};
|
|
66
75
|
container.addEventListener("mouseenter", pauseAnimation);
|
|
67
76
|
container.addEventListener("mouseleave", scheduleResumeAnimation);
|
|
68
77
|
container.addEventListener("touchstart", pauseAnimation);
|
|
69
78
|
container.addEventListener("touchend", scheduleResumeAnimation);
|
|
79
|
+
container.addEventListener("focusin", pauseAnimation);
|
|
80
|
+
container.addEventListener("focusout", handleFocusOut);
|
|
70
81
|
return function cleanup() {
|
|
71
82
|
if (scrollAnimationRef.current) cancelAnimationFrame(scrollAnimationRef.current);
|
|
72
83
|
container.removeEventListener("mouseenter", pauseAnimation);
|
|
73
84
|
container.removeEventListener("mouseleave", scheduleResumeAnimation);
|
|
74
85
|
container.removeEventListener("touchstart", pauseAnimation);
|
|
75
86
|
container.removeEventListener("touchend", scheduleResumeAnimation);
|
|
87
|
+
container.removeEventListener("focusin", pauseAnimation);
|
|
88
|
+
container.removeEventListener("focusout", handleFocusOut);
|
|
76
89
|
if (resumeTimeoutRef.current) clearTimeout(resumeTimeoutRef.current);
|
|
77
90
|
};
|
|
78
91
|
}, [
|
|
@@ -2,10 +2,12 @@ import { useReducedMotionWithOverride } from "./useReducedMotionWithOverride.js"
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
|
|
4
4
|
//#region src/util/useHorizontalScrollAnimation.ts
|
|
5
|
-
function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "standard" }) {
|
|
5
|
+
function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "standard", isUserPaused = false }) {
|
|
6
6
|
const reducedMotion = useReducedMotionWithOverride();
|
|
7
7
|
const resumeTimeoutRef = useRef(null);
|
|
8
8
|
const scrollAnimationRef = useRef(null);
|
|
9
|
+
const isUserPausedRef = useRef(isUserPaused);
|
|
10
|
+
isUserPausedRef.current = isUserPaused;
|
|
9
11
|
const pauseOnHover = true;
|
|
10
12
|
let PIXELS_PER_SECOND = 40;
|
|
11
13
|
switch (animationSpeed) {
|
|
@@ -31,8 +33,9 @@ function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "st
|
|
|
31
33
|
let lastTimestamp = null;
|
|
32
34
|
let accumulatedScroll = 0;
|
|
33
35
|
const step = (timestamp) => {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
+
if (isPaused || isUserPausedRef.current) lastTimestamp = null;
|
|
37
|
+
else {
|
|
38
|
+
if (lastTimestamp === null) lastTimestamp = timestamp;
|
|
36
39
|
const delta = timestamp - lastTimestamp;
|
|
37
40
|
lastTimestamp = timestamp;
|
|
38
41
|
accumulatedScroll += PIXELS_PER_SECOND * (delta / 1e3);
|
|
@@ -57,21 +60,31 @@ function useHorizontalScrollAnimation({ scrollContainerRef, animationSpeed = "st
|
|
|
57
60
|
}
|
|
58
61
|
};
|
|
59
62
|
const scheduleResumeAnimation = () => {
|
|
63
|
+
if (resumeTimeoutRef.current) clearTimeout(resumeTimeoutRef.current);
|
|
60
64
|
resumeTimeoutRef.current = setTimeout(() => {
|
|
65
|
+
if (container.contains(document.activeElement)) return;
|
|
61
66
|
isPaused = false;
|
|
62
|
-
lastTimestamp = null;
|
|
63
67
|
}, RESUME_DELAY_MS);
|
|
64
68
|
};
|
|
69
|
+
const handleFocusOut = (event) => {
|
|
70
|
+
const nextTarget = event.relatedTarget;
|
|
71
|
+
if (nextTarget && container.contains(nextTarget)) return;
|
|
72
|
+
scheduleResumeAnimation();
|
|
73
|
+
};
|
|
65
74
|
container.addEventListener("mouseenter", pauseAnimation);
|
|
66
75
|
container.addEventListener("mouseleave", scheduleResumeAnimation);
|
|
67
76
|
container.addEventListener("touchstart", pauseAnimation);
|
|
68
77
|
container.addEventListener("touchend", scheduleResumeAnimation);
|
|
78
|
+
container.addEventListener("focusin", pauseAnimation);
|
|
79
|
+
container.addEventListener("focusout", handleFocusOut);
|
|
69
80
|
return function cleanup() {
|
|
70
81
|
if (scrollAnimationRef.current) cancelAnimationFrame(scrollAnimationRef.current);
|
|
71
82
|
container.removeEventListener("mouseenter", pauseAnimation);
|
|
72
83
|
container.removeEventListener("mouseleave", scheduleResumeAnimation);
|
|
73
84
|
container.removeEventListener("touchstart", pauseAnimation);
|
|
74
85
|
container.removeEventListener("touchend", scheduleResumeAnimation);
|
|
86
|
+
container.removeEventListener("focusin", pauseAnimation);
|
|
87
|
+
container.removeEventListener("focusout", handleFocusOut);
|
|
75
88
|
if (resumeTimeoutRef.current) clearTimeout(resumeTimeoutRef.current);
|
|
76
89
|
};
|
|
77
90
|
}, [
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef } from 'react';
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
2
|
import { useScrollContainer } from 'react-indiana-drag-scroll';
|
|
3
3
|
import { v4 as uuid } from 'uuid';
|
|
4
4
|
import { useSetAtom } from 'jotai';
|
|
@@ -13,6 +13,8 @@ import { Message } from 'postcss';
|
|
|
13
13
|
import { SuggestionButtonVariant } from '@envive-ai/react-hooks/contexts/types';
|
|
14
14
|
import { SuggestionBarLocationForMetrics } from './types';
|
|
15
15
|
import { SuggestionButtonContainer } from '../SuggestionButtonContainer';
|
|
16
|
+
import { useReducedMotionWithOverride } from '../util/useReducedMotionWithOverride';
|
|
17
|
+
import { SuggestionBarAnimationToggle } from './SuggestionBarAnimationToggle';
|
|
16
18
|
|
|
17
19
|
// SuggestionBarV2 Props
|
|
18
20
|
interface SuggestionBarProps {
|
|
@@ -51,8 +53,13 @@ function SuggestionBar({
|
|
|
51
53
|
const { ref } = useScrollContainer();
|
|
52
54
|
const isSmallScreen = useIsSmallScreen();
|
|
53
55
|
const logPerfMetric = useSetAtom(logPerfMetricAtom);
|
|
56
|
+
const reducedMotion = useReducedMotionWithOverride();
|
|
57
|
+
const [isUserPaused, setIsUserPaused] = useState(false);
|
|
54
58
|
|
|
55
59
|
const isAnimated = animationSpeed !== 'none';
|
|
60
|
+
// Moving content needs a keyboard-accessible pause control (WCAG 2.2.2).
|
|
61
|
+
const showAnimationToggle = isAnimated && !reducedMotion;
|
|
62
|
+
const scrollContainerId = `${id}-scroll-container`;
|
|
56
63
|
|
|
57
64
|
// Track component visibility
|
|
58
65
|
useTrackComponentVisibleEvent(componentVisibleTriggerRef, {
|
|
@@ -101,8 +108,21 @@ function SuggestionBar({
|
|
|
101
108
|
<div
|
|
102
109
|
id={id}
|
|
103
110
|
className="spiffy-tw-w-full spiffy-tw-justify-center spiffy-tw-overflow-hidden"
|
|
111
|
+
// Anchors the pause control, which overlays the bar only while it has keyboard focus.
|
|
112
|
+
style={showAnimationToggle ? { position: 'relative' } : undefined}
|
|
104
113
|
>
|
|
114
|
+
{showAnimationToggle && (
|
|
115
|
+
<SuggestionBarAnimationToggle
|
|
116
|
+
isPaused={isUserPaused}
|
|
117
|
+
onToggle={() => setIsUserPaused(paused => !paused)}
|
|
118
|
+
buttonVariation={buttonVariation}
|
|
119
|
+
hoverButtonVariation={hoverButtonVariation}
|
|
120
|
+
buttonBorderRadius={buttonBorderRadius}
|
|
121
|
+
controlsId={scrollContainerId}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
105
124
|
<div
|
|
125
|
+
id={scrollContainerId}
|
|
106
126
|
className={`spiffy-tw-no-scrollbar spiffy-tw-relative spiffy-tw-w-full spiffy-tw-overflow-y-hidden spiffy-tw-overflow-x-scroll ${twoRowsOnMobile && isSmallScreen ? 'spiffy-tw-h-20' : 'spiffy-tw-h-9'} `}
|
|
107
127
|
ref={handleContainerRef}
|
|
108
128
|
>
|
|
@@ -121,6 +141,7 @@ function SuggestionBar({
|
|
|
121
141
|
twoRowsOnMobile={twoRowsOnMobile}
|
|
122
142
|
animationSpeed={animationSpeed}
|
|
123
143
|
buttonBorderRadius={buttonBorderRadius}
|
|
144
|
+
isUserPaused={isUserPaused}
|
|
124
145
|
/>
|
|
125
146
|
</div>
|
|
126
147
|
</div>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { getSuggestionButtonVariantClasses } from '@envive-ai/react-toolkit/SuggestionButton';
|
|
4
|
+
import { SuggestionButtonVariant } from '@envive-ai/react-hooks/contexts/types';
|
|
5
|
+
|
|
6
|
+
export const SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID = 'suggestion-bar-animation-toggle';
|
|
7
|
+
|
|
8
|
+
interface SuggestionBarAnimationToggleProps {
|
|
9
|
+
isPaused: boolean;
|
|
10
|
+
onToggle: () => void;
|
|
11
|
+
buttonVariation: SuggestionButtonVariant;
|
|
12
|
+
hoverButtonVariation: SuggestionButtonVariant;
|
|
13
|
+
buttonBorderRadius: 'sm' | 'md' | 'lg';
|
|
14
|
+
controlsId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PauseIcon = () => (
|
|
18
|
+
<svg
|
|
19
|
+
width="12"
|
|
20
|
+
height="12"
|
|
21
|
+
viewBox="0 0 12 12"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
focusable="false"
|
|
24
|
+
fill="currentColor"
|
|
25
|
+
>
|
|
26
|
+
<rect
|
|
27
|
+
x="2"
|
|
28
|
+
y="1"
|
|
29
|
+
width="3"
|
|
30
|
+
height="10"
|
|
31
|
+
rx="1"
|
|
32
|
+
/>
|
|
33
|
+
<rect
|
|
34
|
+
x="7"
|
|
35
|
+
y="1"
|
|
36
|
+
width="3"
|
|
37
|
+
height="10"
|
|
38
|
+
rx="1"
|
|
39
|
+
/>
|
|
40
|
+
</svg>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const PlayIcon = () => (
|
|
44
|
+
<svg
|
|
45
|
+
width="12"
|
|
46
|
+
height="12"
|
|
47
|
+
viewBox="0 0 12 12"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
focusable="false"
|
|
50
|
+
fill="currentColor"
|
|
51
|
+
>
|
|
52
|
+
<path d="M3 1.5v9a.75.75 0 0 0 1.14.64l7.2-4.5a.75.75 0 0 0 0-1.28l-7.2-4.5A.75.75 0 0 0 3 1.5Z" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Visually hidden until focused (skip-link pattern): mouse/touch users never see it,
|
|
57
|
+
// keyboard users get it as the first tab stop of the bar.
|
|
58
|
+
const VISUALLY_HIDDEN_STYLE: React.CSSProperties = {
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
width: 1,
|
|
61
|
+
height: 1,
|
|
62
|
+
padding: 0,
|
|
63
|
+
margin: -1,
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
clip: 'rect(0 0 0 0)',
|
|
66
|
+
whiteSpace: 'nowrap',
|
|
67
|
+
border: 0,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// When focused it overlays the start of the bar instead of pushing the suggestions.
|
|
71
|
+
const FOCUSED_STYLE: React.CSSProperties = {
|
|
72
|
+
position: 'absolute',
|
|
73
|
+
top: 0,
|
|
74
|
+
left: 0,
|
|
75
|
+
zIndex: 1,
|
|
76
|
+
height: 36,
|
|
77
|
+
minHeight: 36,
|
|
78
|
+
gap: 6,
|
|
79
|
+
whiteSpace: 'nowrap',
|
|
80
|
+
outline: '2px solid currentColor',
|
|
81
|
+
outlineOffset: -4,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Variants without a background of their own would let the overlaid control blend
|
|
85
|
+
// into the suggestion underneath, so give them a solid backdrop matching their text.
|
|
86
|
+
const SOLID_BACKDROP_BY_VARIANT: Partial<Record<SuggestionButtonVariant, string>> = {
|
|
87
|
+
outlined: 'var(--spiffy-colors-background-light, #ffffff)',
|
|
88
|
+
transparentDark: 'var(--spiffy-colors-background-light, #ffffff)',
|
|
89
|
+
transparent: 'var(--spiffy-colors-background-dark, #000000)',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keyboard-accessible pause/play control for the auto-scrolling suggestion bar
|
|
94
|
+
* (WCAG 2.2.2 Pause, Stop, Hide). Styled with the same variant as the suggestion
|
|
95
|
+
* buttons so it blends in with the merchant's theme when it shows up on focus.
|
|
96
|
+
* Styles are inline so they don't depend on the host generating new utility classes.
|
|
97
|
+
*/
|
|
98
|
+
export const SuggestionBarAnimationToggle = ({
|
|
99
|
+
isPaused,
|
|
100
|
+
onToggle,
|
|
101
|
+
buttonVariation,
|
|
102
|
+
hoverButtonVariation,
|
|
103
|
+
buttonBorderRadius,
|
|
104
|
+
controlsId,
|
|
105
|
+
}: SuggestionBarAnimationToggleProps) => {
|
|
106
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={onToggle}
|
|
112
|
+
onFocus={() => setIsFocused(true)}
|
|
113
|
+
onBlur={() => setIsFocused(false)}
|
|
114
|
+
aria-controls={controlsId}
|
|
115
|
+
data-testid={SUGGESTION_BAR_ANIMATION_TOGGLE_TESTID}
|
|
116
|
+
style={
|
|
117
|
+
isFocused
|
|
118
|
+
? { ...FOCUSED_STYLE, backgroundColor: SOLID_BACKDROP_BY_VARIANT[buttonVariation] }
|
|
119
|
+
: VISUALLY_HIDDEN_STYLE
|
|
120
|
+
}
|
|
121
|
+
className={classNames(
|
|
122
|
+
'spiffy-tw-flex spiffy-tw-cursor-pointer spiffy-tw-items-center spiffy-tw-px-4',
|
|
123
|
+
{
|
|
124
|
+
'!spiffy-tw-rounded-[20px]': buttonBorderRadius === 'lg',
|
|
125
|
+
'!spiffy-tw-rounded-[8px]': buttonBorderRadius === 'md',
|
|
126
|
+
'!spiffy-tw-rounded-[0px]': buttonBorderRadius === 'sm',
|
|
127
|
+
},
|
|
128
|
+
getSuggestionButtonVariantClasses(buttonVariation, hoverButtonVariation),
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{isPaused ? <PlayIcon /> : <PauseIcon />}
|
|
132
|
+
<span className="spiffy-tw-text-[14px]">
|
|
133
|
+
{isPaused ? 'Play scrolling suggestions' : 'Pause scrolling suggestions'}
|
|
134
|
+
</span>
|
|
135
|
+
</button>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
@@ -5,6 +5,7 @@ import { SUGGESTION_BAR_BUTTON_TESTID } from '@envive-ai/react-hooks/config';
|
|
|
5
5
|
import { useIsSmallScreen } from '@envive-ai/react-hooks/hooks/IsSmallScreen';
|
|
6
6
|
import { useTrackComponentVisibleEvent } from '@envive-ai/react-hooks/hooks/TrackComponentVisibleEvent';
|
|
7
7
|
import { useHorizontalScrollAnimation } from 'src/util/useHorizontalScrollAnimation';
|
|
8
|
+
import { useReducedMotionWithOverride } from 'src/util/useReducedMotionWithOverride';
|
|
8
9
|
import { SuggestionButtonContainerProps } from './types';
|
|
9
10
|
|
|
10
11
|
// ButtonContainer props
|
|
@@ -31,6 +32,7 @@ const SuggestionButtonContainer: React.FC<SuggestionButtonContainerProps> = ({
|
|
|
31
32
|
buttonBorderRadius = 'lg',
|
|
32
33
|
scrollContainerRef,
|
|
33
34
|
onButtonClick,
|
|
35
|
+
isUserPaused = false,
|
|
34
36
|
}: Readonly<SuggestionButtonContainerProps>) => {
|
|
35
37
|
// Refs
|
|
36
38
|
const componentVisibleTriggerRef = useRef<HTMLDivElement>(null);
|
|
@@ -39,10 +41,13 @@ const SuggestionButtonContainer: React.FC<SuggestionButtonContainerProps> = ({
|
|
|
39
41
|
useHorizontalScrollAnimation({
|
|
40
42
|
scrollContainerRef,
|
|
41
43
|
animationSpeed,
|
|
44
|
+
isUserPaused,
|
|
42
45
|
});
|
|
43
46
|
const isSmallScreen = useIsSmallScreen();
|
|
47
|
+
const reducedMotion = useReducedMotionWithOverride();
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
// The hook never animates under reduced motion, so skip the looping copies too.
|
|
50
|
+
const isAnimated = animationSpeed !== 'none' && !reducedMotion;
|
|
46
51
|
|
|
47
52
|
// Track component visibility
|
|
48
53
|
useTrackComponentVisibleEvent(componentVisibleTriggerRef, {
|
|
@@ -88,6 +93,10 @@ const SuggestionButtonContainer: React.FC<SuggestionButtonContainerProps> = ({
|
|
|
88
93
|
borderRadius={buttonBorderRadius}
|
|
89
94
|
onClick={() => onButtonClick(suggestion)}
|
|
90
95
|
dataTestId={SUGGESTION_BAR_BUTTON_TESTID}
|
|
96
|
+
// Looping copies exist only for the visual marquee: keep them out of the
|
|
97
|
+
// tab order and the accessibility tree so each suggestion is reachable once.
|
|
98
|
+
ariaHidden
|
|
99
|
+
tabIndex={-1}
|
|
91
100
|
/>
|
|
92
101
|
))}
|
|
93
102
|
</ButtonContainer>
|
|
@@ -119,6 +128,8 @@ const SuggestionButtonContainer: React.FC<SuggestionButtonContainerProps> = ({
|
|
|
119
128
|
borderRadius={buttonBorderRadius}
|
|
120
129
|
onClick={() => onButtonClick(suggestion)}
|
|
121
130
|
dataTestId={SUGGESTION_BAR_BUTTON_TESTID}
|
|
131
|
+
ariaHidden
|
|
132
|
+
tabIndex={-1}
|
|
122
133
|
/>
|
|
123
134
|
))}
|
|
124
135
|
</ButtonContainer>
|
|
@@ -12,4 +12,6 @@ export interface SuggestionButtonContainerProps extends TestProps {
|
|
|
12
12
|
twoRowsOnMobile?: boolean | undefined;
|
|
13
13
|
animationSpeed?: 'standard' | 'slow' | 'none';
|
|
14
14
|
buttonBorderRadius?: 'sm' | 'md' | 'lg';
|
|
15
|
+
/** Pauses the auto-scroll animation until toggled back (user pause/play control). */
|
|
16
|
+
isUserPaused?: boolean;
|
|
15
17
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
2
3
|
import { SuggestionBar, SuggestionBarLocationForMetrics } from 'src/SuggestionBar';
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof SuggestionBar> = {
|
|
@@ -42,4 +43,46 @@ export const Animated: Story = {
|
|
|
42
43
|
...Default.args,
|
|
43
44
|
animationSpeed: 'standard',
|
|
44
45
|
},
|
|
46
|
+
play: async ({ canvasElement }) => {
|
|
47
|
+
const canvas = within(canvasElement);
|
|
48
|
+
|
|
49
|
+
// WCAG 2.2.2: the moving bar exposes a keyboard-accessible pause control,
|
|
50
|
+
// first in the tab order.
|
|
51
|
+
const toggle = await canvas.findByRole('button', { name: 'Pause scrolling suggestions' });
|
|
52
|
+
const [firstTabStop] = [...canvasElement.querySelectorAll('button')].filter(
|
|
53
|
+
button => button.tabIndex >= 0,
|
|
54
|
+
);
|
|
55
|
+
await expect(firstTabStop).toBe(toggle);
|
|
56
|
+
|
|
57
|
+
// Hidden for mouse users, revealed only while it has keyboard focus.
|
|
58
|
+
await expect(toggle.style.position).toBe('absolute');
|
|
59
|
+
await expect(toggle.style.width).toBe('1px');
|
|
60
|
+
toggle.focus();
|
|
61
|
+
await expect(toggle).toHaveFocus();
|
|
62
|
+
await expect(toggle.style.width).toBe('');
|
|
63
|
+
|
|
64
|
+
await userEvent.keyboard('{Enter}');
|
|
65
|
+
await expect(
|
|
66
|
+
canvas.getByRole('button', { name: 'Play scrolling suggestions' }),
|
|
67
|
+
).toBeInTheDocument();
|
|
68
|
+
|
|
69
|
+
// Looping copies are hidden, so each suggestion is exposed (and tabbable) once.
|
|
70
|
+
await expect(canvas.getAllByRole('button', { name: /Button \d/ })).toHaveLength(3);
|
|
71
|
+
const copies = canvasElement.querySelectorAll('button[aria-hidden="true"]');
|
|
72
|
+
await expect(copies).toHaveLength(3);
|
|
73
|
+
copies.forEach(copy => expect(copy).toHaveAttribute('tabindex', '-1'));
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const Static: Story = {
|
|
78
|
+
args: Default.args,
|
|
79
|
+
play: async ({ canvasElement }) => {
|
|
80
|
+
const canvas = within(canvasElement);
|
|
81
|
+
// No motion, so no pause control and no looping copies.
|
|
82
|
+
await canvas.findByRole('button', { name: /Button 1/ });
|
|
83
|
+
await expect(
|
|
84
|
+
canvas.queryByRole('button', { name: /scrolling suggestions/ }),
|
|
85
|
+
).not.toBeInTheDocument();
|
|
86
|
+
await expect(canvas.getAllByRole('button')).toHaveLength(3);
|
|
87
|
+
},
|
|
45
88
|
};
|
|
@@ -5,6 +5,11 @@ import { useReducedMotionWithOverride } from 'src/util/useReducedMotionWithOverr
|
|
|
5
5
|
interface UseHorizontalScrollAnimationOptions {
|
|
6
6
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
|
7
7
|
animationSpeed?: 'standard' | 'slow' | 'none';
|
|
8
|
+
/**
|
|
9
|
+
* Explicit pause requested by the user via the pause/play control. Unlike the
|
|
10
|
+
* hover/focus/touch pause, it never auto-resumes.
|
|
11
|
+
*/
|
|
12
|
+
isUserPaused?: boolean;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
// When using this hook, ensure that scrouuContainerRef is not undefined. It is allowed to
|
|
@@ -12,10 +17,14 @@ interface UseHorizontalScrollAnimationOptions {
|
|
|
12
17
|
export function useHorizontalScrollAnimation({
|
|
13
18
|
scrollContainerRef,
|
|
14
19
|
animationSpeed = 'standard',
|
|
20
|
+
isUserPaused = false,
|
|
15
21
|
}: UseHorizontalScrollAnimationOptions): void {
|
|
16
22
|
const reducedMotion = useReducedMotionWithOverride();
|
|
17
23
|
const resumeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
18
24
|
const scrollAnimationRef = useRef<number | null>(null);
|
|
25
|
+
// Read through a ref so toggling the control doesn't restart the animation effect.
|
|
26
|
+
const isUserPausedRef = useRef(isUserPaused);
|
|
27
|
+
isUserPausedRef.current = isUserPaused;
|
|
19
28
|
|
|
20
29
|
const pauseOnHover = true; // This is unlikely to need to be configurable but I'm throwing it up here just in case.
|
|
21
30
|
|
|
@@ -55,8 +64,12 @@ export function useHorizontalScrollAnimation({
|
|
|
55
64
|
let accumulatedScroll = 0; // Accumulate fractional pixels
|
|
56
65
|
|
|
57
66
|
const step = (timestamp: number) => {
|
|
58
|
-
if (
|
|
59
|
-
|
|
67
|
+
if (isPaused || isUserPausedRef.current) {
|
|
68
|
+
// Drop the timestamp so the first frame after resuming doesn't jump by
|
|
69
|
+
// the whole paused duration.
|
|
70
|
+
lastTimestamp = null;
|
|
71
|
+
} else {
|
|
72
|
+
if (lastTimestamp === null) lastTimestamp = timestamp;
|
|
60
73
|
const delta = timestamp - lastTimestamp;
|
|
61
74
|
lastTimestamp = timestamp;
|
|
62
75
|
|
|
@@ -90,18 +103,32 @@ export function useHorizontalScrollAnimation({
|
|
|
90
103
|
}
|
|
91
104
|
};
|
|
92
105
|
const scheduleResumeAnimation = () => {
|
|
106
|
+
if (resumeTimeoutRef.current) clearTimeout(resumeTimeoutRef.current);
|
|
93
107
|
resumeTimeoutRef.current = setTimeout(() => {
|
|
108
|
+
// Keep paused while keyboard focus is still inside the bar, e.g. the mouse
|
|
109
|
+
// left but the user is tabbing through the suggestions.
|
|
110
|
+
if (container.contains(document.activeElement)) return;
|
|
94
111
|
isPaused = false;
|
|
95
|
-
lastTimestamp = null; // Reset timestamp so delta is correct after pause
|
|
96
112
|
}, RESUME_DELAY_MS);
|
|
97
113
|
};
|
|
98
114
|
|
|
115
|
+
// Only resume when focus leaves the bar entirely, not when it moves between
|
|
116
|
+
// suggestions.
|
|
117
|
+
const handleFocusOut = (event: FocusEvent) => {
|
|
118
|
+
const nextTarget = event.relatedTarget as Node | null;
|
|
119
|
+
if (nextTarget && container.contains(nextTarget)) return;
|
|
120
|
+
scheduleResumeAnimation();
|
|
121
|
+
};
|
|
122
|
+
|
|
99
123
|
if (pauseOnHover) {
|
|
100
124
|
container.addEventListener('mouseenter', pauseAnimation);
|
|
101
125
|
container.addEventListener('mouseleave', scheduleResumeAnimation);
|
|
102
126
|
container.addEventListener('touchstart', pauseAnimation);
|
|
103
127
|
container.addEventListener('touchend', scheduleResumeAnimation);
|
|
104
128
|
}
|
|
129
|
+
// Keyboard users get the same pause as hover (WCAG 2.2.2).
|
|
130
|
+
container.addEventListener('focusin', pauseAnimation);
|
|
131
|
+
container.addEventListener('focusout', handleFocusOut);
|
|
105
132
|
|
|
106
133
|
return function cleanup() {
|
|
107
134
|
if (scrollAnimationRef.current) {
|
|
@@ -113,6 +140,8 @@ export function useHorizontalScrollAnimation({
|
|
|
113
140
|
container.removeEventListener('touchstart', pauseAnimation);
|
|
114
141
|
container.removeEventListener('touchend', scheduleResumeAnimation);
|
|
115
142
|
}
|
|
143
|
+
container.removeEventListener('focusin', pauseAnimation);
|
|
144
|
+
container.removeEventListener('focusout', handleFocusOut);
|
|
116
145
|
if (resumeTimeoutRef.current) {
|
|
117
146
|
clearTimeout(resumeTimeoutRef.current);
|
|
118
147
|
}
|