@envive-ai/react-widgets 0.3.66 → 0.3.68

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.
@@ -1,11 +1,11 @@
1
1
  import { SearchResultsEntryPointWidgetConfig } from "./types.cjs";
2
- import * as react_jsx_runtime0 from "react/jsx-runtime";
2
+ import * as react_jsx_runtime1 from "react/jsx-runtime";
3
3
  import { SearchResultsHocProps } from "@envive-ai/react-hooks/hooks/Search";
4
4
 
5
5
  //#region src/SearchResults/SearchResults.d.ts
6
6
  interface SearchResultsProps extends SearchResultsHocProps {
7
7
  widgetConfig: SearchResultsEntryPointWidgetConfig;
8
8
  }
9
- declare const SearchResults: (props: Omit<SearchResultsProps, string | number | symbol>) => react_jsx_runtime0.JSX.Element;
9
+ declare const SearchResults: (props: Omit<SearchResultsProps, string | number | symbol>) => react_jsx_runtime1.JSX.Element;
10
10
  //#endregion
11
11
  export { SearchResults };
@@ -1,6 +1,6 @@
1
- import * as react_jsx_runtime0 from "react/jsx-runtime";
1
+ import * as react_jsx_runtime3 from "react/jsx-runtime";
2
2
 
3
3
  //#region src/SearchResults/SearchResultsWidget.d.ts
4
- declare const SearchResultsWidget: () => react_jsx_runtime0.JSX.Element;
4
+ declare const SearchResultsWidget: () => react_jsx_runtime3.JSX.Element;
5
5
  //#endregion
6
6
  export { SearchResultsWidget };
@@ -1,4 +1,4 @@
1
- import * as react_jsx_runtime3 from "react/jsx-runtime";
1
+ import * as react_jsx_runtime0 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) => react_jsx_runtime3.JSX.Element;
13
+ }: SearchZeroStateWidgetProps) => react_jsx_runtime0.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
- (0, __envive_ai_react_hooks_hooks_TrackComponentVisibleEvent.useTrackComponentVisibleEvent)(componentVisibleTriggerRef, { animated: animationSpeed !== "none" });
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.jsx)("div", {
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
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
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 react_jsx_runtime2 from "react/jsx-runtime";
2
+ import * as react_jsx_runtime0 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>): react_jsx_runtime2.JSX.Element;
32
+ }: Readonly<SuggestionBarProps>): react_jsx_runtime0.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 { useCallback, useRef } from "react";
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
- useTrackComponentVisibleEvent(componentVisibleTriggerRef, { animated: animationSpeed !== "none" });
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__ */ jsx("div", {
51
+ return /* @__PURE__ */ jsxs("div", {
45
52
  id,
46
53
  className: "spiffy-tw-w-full spiffy-tw-justify-center spiffy-tw-overflow-hidden",
47
- children: /* @__PURE__ */ jsx("div", {
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 isAnimated = animationSpeed !== "none";
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 isAnimated = animationSpeed !== "none";
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 (lastTimestamp === null) lastTimestamp = timestamp;
36
- if (!isPaused) {
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 (lastTimestamp === null) lastTimestamp = timestamp;
35
- if (!isPaused) {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@envive-ai/react-widgets",
3
- "version": "0.3.66",
3
+ "version": "0.3.68",
4
4
  "description": "React widget library for Envive services.",
5
5
  "keywords": [
6
6
  "react",
@@ -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
- const isAnimated = animationSpeed !== 'none';
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 (lastTimestamp === null) lastTimestamp = timestamp;
59
- if (!isPaused) {
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
  }