@trackunit/react-components 2.13.11 → 2.13.13

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/index.cjs.js CHANGED
@@ -29,6 +29,8 @@ var defaultTranslations = {
29
29
  "breadcrumb.expandButton": "expand",
30
30
  "copyButton.copied": "Copied!",
31
31
  "copyButton.copy": "Copy",
32
+ "horizontalOverflowScroller.scrollLeft": "Scroll left",
33
+ "horizontalOverflowScroller.scrollRight": "Scroll right",
32
34
  "moreMenu.buttonLabel": "Show more"
33
35
  };
34
36
 
@@ -5788,34 +5790,79 @@ const ZStack = ({ children, className, "data-testid": dataTestId, style, ref })
5788
5790
  }) }));
5789
5791
  };
5790
5792
 
5793
+ const fadeMaskStyleSheet = `
5794
+ @property --tu-hos-solid-left {
5795
+ syntax: '<length>';
5796
+ initial-value: 0px;
5797
+ inherits: false;
5798
+ }
5799
+
5800
+ @property --tu-hos-solid-right {
5801
+ syntax: '<length>';
5802
+ initial-value: 0px;
5803
+ inherits: false;
5804
+ }
5805
+
5806
+ @property --tu-hos-fade-left {
5807
+ syntax: '<length>';
5808
+ initial-value: 0px;
5809
+ inherits: false;
5810
+ }
5811
+
5812
+ @property --tu-hos-fade-right {
5813
+ syntax: '<length>';
5814
+ initial-value: 0px;
5815
+ inherits: false;
5816
+ }
5817
+ `;
5791
5818
  const cvaHorizontalOverflowScroller = cssClassVarianceUtilities.cva({
5792
- base: ["flex", "flex-nowrap", "gap-1", "overflow-y-hidden", "overflow-x-scroll", "w-full", "no-scrollbar"],
5819
+ base: [
5820
+ "flex",
5821
+ "flex-nowrap",
5822
+ "gap-1",
5823
+ "overflow-y-hidden",
5824
+ "overflow-x-scroll",
5825
+ "scroll-px-9",
5826
+ "w-full",
5827
+ "no-scrollbar",
5828
+ ],
5793
5829
  });
5794
- const cvaHorizontalOverflowScrollerAndIndicatorsContainer = cssClassVarianceUtilities.cva({ base: ["group", "w-full", "overflow-clip"] });
5795
-
5796
- const cvaOverflowIndicatorContainer = cssClassVarianceUtilities.cva({
5797
- base: ["pointer-events-none", "h-full", "w-full", "isolate", "pb-px"],
5830
+ const cvaHorizontalOverflowScrollerAndIndicatorsContainer = cssClassVarianceUtilities.cva({
5831
+ base: ["group", "w-full", "overflow-clip"],
5798
5832
  });
5799
- const cvaOverflowIndicatorGradient = cssClassVarianceUtilities.cva({
5833
+ const cvaHorizontalOverflowScrollerFadeMask = cssClassVarianceUtilities.cva({
5800
5834
  base: [
5801
- "pointer-events-none",
5802
- "self-stretch",
5803
- "w-16",
5804
- "opacity-0",
5805
- "transition-opacity",
5806
5835
  "duration-200",
5807
- "group-hover:opacity-100",
5808
- "group-focus-within:opacity-100",
5836
+ "ease-out",
5837
+ "[mask-image:linear-gradient(to_right,transparent_0,transparent_var(--tu-hos-solid-left),#000_var(--tu-hos-fade-left),#000_calc(100%_-_var(--tu-hos-fade-right)),transparent_calc(100%_-_var(--tu-hos-solid-right)),transparent_100%)]",
5838
+ "[transition-property:--tu-hos-solid-left,--tu-hos-solid-right,--tu-hos-fade-left,--tu-hos-fade-right]",
5809
5839
  ],
5810
5840
  variants: {
5811
- direction: {
5812
- left: ["bg-gradient-to-r", "from-white", "from-30%", "to-transparent"],
5813
- right: ["bg-gradient-to-l", "from-white", "from-30%", "to-transparent"],
5841
+ canScrollLeft: {
5842
+ true: [
5843
+ "[--tu-hos-solid-left:0px]",
5844
+ "[--tu-hos-fade-left:1.25rem]",
5845
+ "group-hover:[--tu-hos-solid-left:1rem]",
5846
+ "group-hover:[--tu-hos-fade-left:2.25rem]",
5847
+ "group-has-[:focus-visible]:[--tu-hos-fade-left:2.25rem]",
5848
+ ],
5849
+ false: ["[--tu-hos-solid-left:0px]", "[--tu-hos-fade-left:0px]"],
5850
+ },
5851
+ canScrollRight: {
5852
+ true: [
5853
+ "[--tu-hos-solid-right:0px]",
5854
+ "[--tu-hos-fade-right:1.25rem]",
5855
+ "group-hover:[--tu-hos-solid-right:1rem]",
5856
+ "group-hover:[--tu-hos-fade-right:2.25rem]",
5857
+ "group-has-[:focus-visible]:[--tu-hos-fade-right:2.25rem]",
5858
+ ],
5859
+ false: ["[--tu-hos-solid-right:0px]", "[--tu-hos-fade-right:0px]"],
5814
5860
  },
5815
5861
  },
5816
5862
  });
5817
- const cvaJustificationContainer = cssClassVarianceUtilities.cva({
5818
- base: ["flex", "w-full", "items-center", "pointer-events-none"],
5863
+
5864
+ const cvaOverflowIndicatorHitRow = cssClassVarianceUtilities.cva({
5865
+ base: ["flex", "h-full", "w-full", "items-center", "pointer-events-none", "z-10"],
5819
5866
  variants: {
5820
5867
  direction: {
5821
5868
  left: ["justify-start"],
@@ -5823,43 +5870,72 @@ const cvaJustificationContainer = cssClassVarianceUtilities.cva({
5823
5870
  },
5824
5871
  },
5825
5872
  });
5826
- const cvaOverflowIndicatorButton = cssClassVarianceUtilities.cva({
5873
+ const cvaOverflowIndicatorHit = cssClassVarianceUtilities.cva({
5827
5874
  base: [
5828
- "shadow-md",
5875
+ "pointer-events-auto",
5876
+ "inline-flex",
5877
+ "h-full",
5878
+ "items-center",
5879
+ "justify-center",
5880
+ "px-1",
5881
+ "border-0",
5882
+ "bg-transparent",
5883
+ "cursor-pointer",
5884
+ "supports-[color:contrast-color(white)]:[color:contrast-color(var(--hos-surface,Canvas))]",
5885
+ "supports-[not_(color:contrast-color(white))]:text-neutral-900",
5886
+ "supports-[not_(color:contrast-color(white))]:[filter:drop-shadow(0_0_0.5px_#fff)_drop-shadow(0_1px_0_#fff)_drop-shadow(0_-1px_0_#fff)_drop-shadow(1px_0_0_#fff)_drop-shadow(-1px_0_0_#fff)]",
5829
5887
  "opacity-0",
5888
+ "starting:opacity-0",
5830
5889
  "transition-opacity",
5831
5890
  "duration-200",
5832
- "pointer-events-auto",
5833
- "starting:opacity-0",
5834
- "group-hover:opacity-100",
5835
- "group-focus-within:opacity-100",
5836
- "rounded-full",
5837
- "mx-1",
5891
+ // Keyboard focus (not a plain mouse-click `:focus`) always reveals the control it landed
5892
+ // on, even the disabled one at an exhausted edge -- that's the only way a sighted keyboard
5893
+ // user gets to see what they just tabbed to.
5894
+ "group-has-[:focus-visible]:opacity-100",
5838
5895
  ],
5896
+ variants: {
5897
+ disabled: {
5898
+ // Nothing to scroll to on this side: don't let a mouse hover over the row reveal a
5899
+ // control that would be a no-op if clicked.
5900
+ true: [],
5901
+ false: ["group-hover:opacity-100"],
5902
+ },
5903
+ },
5904
+ defaultVariants: {
5905
+ disabled: false,
5906
+ },
5839
5907
  });
5840
5908
 
5841
5909
  /**
5842
- * Overflow indicator component that shows visual cues when content extends beyond visible area
5843
- * Shows a scroll button on hover to navigate in the specified direction
5910
+ * Edge control for HorizontalOverflowScroller. The fade lives on the content
5911
+ * mask; this is the hover/focus hit target that pages left or right.
5844
5912
  *
5845
5913
  * @param {OverflowIndicatorProps} props - The props for the component
5846
5914
  * @returns {ReactElement} OverflowIndicator component
5847
5915
  */
5848
- const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, onClickScroll, style, }) => {
5916
+ const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, onClickScroll, disabled = false, style, }) => {
5917
+ const [t] = useTranslation();
5849
5918
  const iconName = direction === "left" ? "ChevronLeft" : "ChevronRight";
5850
- return (jsxRuntime.jsxs(ZStack, { className: cvaOverflowIndicatorContainer({ className }), "data-testid": dataTestId, style: style, children: [jsxRuntime.jsx("div", { className: cvaJustificationContainer({ direction }), children: jsxRuntime.jsx("div", { className: cvaOverflowIndicatorGradient({ direction }), "data-testid": dataTestId ? `${dataTestId}-gradient` : undefined }) }), jsxRuntime.jsx("div", { className: cvaJustificationContainer({ direction }), children: jsxRuntime.jsx(IconButton, { className: cvaOverflowIndicatorButton(), "data-testid": dataTestId ? `${dataTestId}-button` : undefined, disabled: true, icon: jsxRuntime.jsx(Icon, { name: iconName, size: "small" }), onClick: onClickScroll, size: "small", title: "" // We dont want to show a tooltip here
5851
- , variant: "secondary" }) })] }));
5919
+ const ariaLabel = direction === "left" ? t("horizontalOverflowScroller.scrollLeft") : t("horizontalOverflowScroller.scrollRight");
5920
+ return (jsxRuntime.jsx("div", { className: cvaOverflowIndicatorHitRow({ className, direction }), "data-testid": dataTestId, style: style, children: jsxRuntime.jsx("button", { "aria-disabled": disabled, "aria-label": ariaLabel, className: cvaOverflowIndicatorHit({ disabled }), "data-testid": dataTestId ? `${dataTestId}-button` : undefined, onClick: disabled ? undefined : onClickScroll, type: "button", children: jsxRuntime.jsx(Icon, { ariaHidden: true, name: iconName, size: "small" }) }) }));
5852
5921
  };
5853
5922
 
5854
5923
  /**
5855
5924
  * HorizontalOverflowScroller displays child elements in a horizontal row with automatic overflow detection.
5856
- * When content overflows, it shows left/right scroll indicators with click-to-scroll functionality.
5925
+ * When content overflows, it fades the overflowing edge and reveals chevrons on hover or focus so the row can be paged.
5926
+ *
5927
+ * The fade is a CSS mask on the content itself, so it works on any background. At rest the ramp is short so more
5928
+ * items stay visible; hover grows a solid plateau under the chevron and pushes the ramp further in, while
5929
+ * keyboard focus only extends the ramp — a plateau there would erase the focus ring of a child flush with the
5930
+ * edge. Set `--hos-surface` on an ancestor to the background color behind the scroller so the chevrons pick a
5931
+ * contrasting color. It defaults to `Canvas`.
5857
5932
  *
5858
5933
  * ### When to use
5859
5934
  * Use HorizontalOverflowScroller to display a row of items (cards, tags, buttons) that may overflow on smaller screens, with visual cues that more content is available.
5860
5935
  *
5861
5936
  * ### When not to use
5862
5937
  * Do not use HorizontalOverflowScroller for tab navigation — use `TabList` which has its own scroll handling.
5938
+ * Do not use it to hide extra actions — use `MoreMenu` when the overflowing items are secondary actions rather than a row of content to browse.
5863
5939
  *
5864
5940
  * @example Horizontal scroller for KPI cards
5865
5941
  * ```tsx
@@ -5879,36 +5955,36 @@ const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, on
5879
5955
  * @returns {ReactElement} HorizontalOverflowScroller component
5880
5956
  */
5881
5957
  const HorizontalOverflowScroller = ({ className, "data-testid": dataTestId, children, onScrollStateChange, style, ref, }) => {
5882
- const childrenArray = react.Children.toArray(children);
5883
- const { geometry: containerGeometry, ref: measureRef, element } = useMeasure();
5884
- const { ref: scrollRef, isScrollable, isAtBeginning, isAtEnd, } = useScrollDetection({
5958
+ const onScrollStateChangeRef = react.useRef(onScrollStateChange);
5959
+ react.useEffect(() => {
5960
+ onScrollStateChangeRef.current = onScrollStateChange;
5961
+ }, [onScrollStateChange]);
5962
+ const handleScrollStateChange = react.useCallback((state) => {
5963
+ onScrollStateChangeRef.current?.({
5964
+ isScrollable: state.isScrollable,
5965
+ isAtBeginning: state.isAtBeginning,
5966
+ isAtEnd: state.isAtEnd,
5967
+ });
5968
+ }, []);
5969
+ const { ref: scrollRef, element, isScrollable, isAtBeginning, isAtEnd, } = useScrollDetection({
5885
5970
  direction: "horizontal",
5886
- onScrollStateChange: onScrollStateChange
5887
- ? (state) => onScrollStateChange({
5888
- isScrollable: state.isScrollable,
5889
- isAtBeginning: state.isAtBeginning,
5890
- isAtEnd: state.isAtEnd,
5891
- })
5892
- : undefined,
5971
+ onScrollStateChange: handleScrollStateChange,
5893
5972
  });
5894
- const mergedRef = useMergeRefs([measureRef, scrollRef, ref]);
5895
- const handleScrollLeft = () => {
5896
- if (!element || containerGeometry?.width === undefined)
5897
- return;
5898
- element.scrollBy({
5899
- left: -containerGeometry.width,
5900
- behavior: "smooth",
5901
- });
5902
- };
5903
- const handleScrollRight = () => {
5904
- if (!element || containerGeometry?.width === undefined)
5973
+ const mergedRef = useMergeRefs([scrollRef, ref]);
5974
+ const canScrollLeft = isScrollable && !isAtBeginning;
5975
+ const canScrollRight = isScrollable && !isAtEnd;
5976
+ const scrollByPage = react.useCallback((direction) => {
5977
+ if (!element) {
5905
5978
  return;
5979
+ }
5906
5980
  element.scrollBy({
5907
- left: containerGeometry.width,
5981
+ left: direction * element.clientWidth,
5908
5982
  behavior: "smooth",
5909
5983
  });
5910
- };
5911
- return (jsxRuntime.jsxs(ZStack, { className: cvaHorizontalOverflowScrollerAndIndicatorsContainer({ className }), style: style, children: [jsxRuntime.jsx("div", { className: cvaHorizontalOverflowScroller(), "data-testid": dataTestId, ref: mergedRef, children: childrenArray.map((child, index) => (jsxRuntime.jsx(react.Fragment, { children: child }, index))) }), isScrollable && !isAtBeginning ? (jsxRuntime.jsx(OverflowIndicator, { "data-testid": `${dataTestId}-left-indicator`, direction: "left", onClickScroll: handleScrollLeft })) : null, isScrollable && !isAtEnd ? (jsxRuntime.jsx(OverflowIndicator, { "data-testid": `${dataTestId}-right-indicator`, direction: "right", onClickScroll: handleScrollRight })) : null] }));
5984
+ }, [element]);
5985
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: fadeMaskStyleSheet }), jsxRuntime.jsxs(ZStack, { className: cvaHorizontalOverflowScrollerAndIndicatorsContainer({ className }), style: style, children: [jsxRuntime.jsx("div", { className: cvaHorizontalOverflowScroller({
5986
+ className: cvaHorizontalOverflowScrollerFadeMask({ canScrollLeft, canScrollRight }),
5987
+ }), "data-testid": dataTestId, ref: mergedRef, children: children }), isScrollable ? (jsxRuntime.jsx(OverflowIndicator, { "data-testid": dataTestId ? `${dataTestId}-left-indicator` : undefined, direction: "left", disabled: !canScrollLeft, onClickScroll: () => scrollByPage(-1) })) : null, isScrollable ? (jsxRuntime.jsx(OverflowIndicator, { "data-testid": dataTestId ? `${dataTestId}-right-indicator` : undefined, direction: "right", disabled: !canScrollRight, onClickScroll: () => scrollByPage(1) })) : null] })] }));
5912
5988
  };
5913
5989
 
5914
5990
  const cvaIndicator = cssClassVarianceUtilities.cva({ base: ["inline-flex", "items-center"] });
@@ -8290,6 +8366,7 @@ const cvaMoreMenu = cssClassVarianceUtilities.cva({ base: ["p-0"] });
8290
8366
  *
8291
8367
  * ### When not to use
8292
8368
  * Do not use MoreMenu for primary actions that should always be visible. Use `Button` instead.
8369
+ * Do not use it to overflow a row of content (tags, KPIs, chips) — use `HorizontalOverflowScroller` when those items should stay visible and be browsed horizontally.
8293
8370
  *
8294
8371
  * @example Action items with render prop — Pass a function as `children` to receive the `close` callback and dismiss the menu after an action.
8295
8372
  * ```tsx
package/index.esm.js CHANGED
@@ -28,6 +28,8 @@ var defaultTranslations = {
28
28
  "breadcrumb.expandButton": "expand",
29
29
  "copyButton.copied": "Copied!",
30
30
  "copyButton.copy": "Copy",
31
+ "horizontalOverflowScroller.scrollLeft": "Scroll left",
32
+ "horizontalOverflowScroller.scrollRight": "Scroll right",
31
33
  "moreMenu.buttonLabel": "Show more"
32
34
  };
33
35
 
@@ -5787,34 +5789,79 @@ const ZStack = ({ children, className, "data-testid": dataTestId, style, ref })
5787
5789
  }) }));
5788
5790
  };
5789
5791
 
5792
+ const fadeMaskStyleSheet = `
5793
+ @property --tu-hos-solid-left {
5794
+ syntax: '<length>';
5795
+ initial-value: 0px;
5796
+ inherits: false;
5797
+ }
5798
+
5799
+ @property --tu-hos-solid-right {
5800
+ syntax: '<length>';
5801
+ initial-value: 0px;
5802
+ inherits: false;
5803
+ }
5804
+
5805
+ @property --tu-hos-fade-left {
5806
+ syntax: '<length>';
5807
+ initial-value: 0px;
5808
+ inherits: false;
5809
+ }
5810
+
5811
+ @property --tu-hos-fade-right {
5812
+ syntax: '<length>';
5813
+ initial-value: 0px;
5814
+ inherits: false;
5815
+ }
5816
+ `;
5790
5817
  const cvaHorizontalOverflowScroller = cva({
5791
- base: ["flex", "flex-nowrap", "gap-1", "overflow-y-hidden", "overflow-x-scroll", "w-full", "no-scrollbar"],
5818
+ base: [
5819
+ "flex",
5820
+ "flex-nowrap",
5821
+ "gap-1",
5822
+ "overflow-y-hidden",
5823
+ "overflow-x-scroll",
5824
+ "scroll-px-9",
5825
+ "w-full",
5826
+ "no-scrollbar",
5827
+ ],
5792
5828
  });
5793
- const cvaHorizontalOverflowScrollerAndIndicatorsContainer = cva({ base: ["group", "w-full", "overflow-clip"] });
5794
-
5795
- const cvaOverflowIndicatorContainer = cva({
5796
- base: ["pointer-events-none", "h-full", "w-full", "isolate", "pb-px"],
5829
+ const cvaHorizontalOverflowScrollerAndIndicatorsContainer = cva({
5830
+ base: ["group", "w-full", "overflow-clip"],
5797
5831
  });
5798
- const cvaOverflowIndicatorGradient = cva({
5832
+ const cvaHorizontalOverflowScrollerFadeMask = cva({
5799
5833
  base: [
5800
- "pointer-events-none",
5801
- "self-stretch",
5802
- "w-16",
5803
- "opacity-0",
5804
- "transition-opacity",
5805
5834
  "duration-200",
5806
- "group-hover:opacity-100",
5807
- "group-focus-within:opacity-100",
5835
+ "ease-out",
5836
+ "[mask-image:linear-gradient(to_right,transparent_0,transparent_var(--tu-hos-solid-left),#000_var(--tu-hos-fade-left),#000_calc(100%_-_var(--tu-hos-fade-right)),transparent_calc(100%_-_var(--tu-hos-solid-right)),transparent_100%)]",
5837
+ "[transition-property:--tu-hos-solid-left,--tu-hos-solid-right,--tu-hos-fade-left,--tu-hos-fade-right]",
5808
5838
  ],
5809
5839
  variants: {
5810
- direction: {
5811
- left: ["bg-gradient-to-r", "from-white", "from-30%", "to-transparent"],
5812
- right: ["bg-gradient-to-l", "from-white", "from-30%", "to-transparent"],
5840
+ canScrollLeft: {
5841
+ true: [
5842
+ "[--tu-hos-solid-left:0px]",
5843
+ "[--tu-hos-fade-left:1.25rem]",
5844
+ "group-hover:[--tu-hos-solid-left:1rem]",
5845
+ "group-hover:[--tu-hos-fade-left:2.25rem]",
5846
+ "group-has-[:focus-visible]:[--tu-hos-fade-left:2.25rem]",
5847
+ ],
5848
+ false: ["[--tu-hos-solid-left:0px]", "[--tu-hos-fade-left:0px]"],
5849
+ },
5850
+ canScrollRight: {
5851
+ true: [
5852
+ "[--tu-hos-solid-right:0px]",
5853
+ "[--tu-hos-fade-right:1.25rem]",
5854
+ "group-hover:[--tu-hos-solid-right:1rem]",
5855
+ "group-hover:[--tu-hos-fade-right:2.25rem]",
5856
+ "group-has-[:focus-visible]:[--tu-hos-fade-right:2.25rem]",
5857
+ ],
5858
+ false: ["[--tu-hos-solid-right:0px]", "[--tu-hos-fade-right:0px]"],
5813
5859
  },
5814
5860
  },
5815
5861
  });
5816
- const cvaJustificationContainer = cva({
5817
- base: ["flex", "w-full", "items-center", "pointer-events-none"],
5862
+
5863
+ const cvaOverflowIndicatorHitRow = cva({
5864
+ base: ["flex", "h-full", "w-full", "items-center", "pointer-events-none", "z-10"],
5818
5865
  variants: {
5819
5866
  direction: {
5820
5867
  left: ["justify-start"],
@@ -5822,43 +5869,72 @@ const cvaJustificationContainer = cva({
5822
5869
  },
5823
5870
  },
5824
5871
  });
5825
- const cvaOverflowIndicatorButton = cva({
5872
+ const cvaOverflowIndicatorHit = cva({
5826
5873
  base: [
5827
- "shadow-md",
5874
+ "pointer-events-auto",
5875
+ "inline-flex",
5876
+ "h-full",
5877
+ "items-center",
5878
+ "justify-center",
5879
+ "px-1",
5880
+ "border-0",
5881
+ "bg-transparent",
5882
+ "cursor-pointer",
5883
+ "supports-[color:contrast-color(white)]:[color:contrast-color(var(--hos-surface,Canvas))]",
5884
+ "supports-[not_(color:contrast-color(white))]:text-neutral-900",
5885
+ "supports-[not_(color:contrast-color(white))]:[filter:drop-shadow(0_0_0.5px_#fff)_drop-shadow(0_1px_0_#fff)_drop-shadow(0_-1px_0_#fff)_drop-shadow(1px_0_0_#fff)_drop-shadow(-1px_0_0_#fff)]",
5828
5886
  "opacity-0",
5887
+ "starting:opacity-0",
5829
5888
  "transition-opacity",
5830
5889
  "duration-200",
5831
- "pointer-events-auto",
5832
- "starting:opacity-0",
5833
- "group-hover:opacity-100",
5834
- "group-focus-within:opacity-100",
5835
- "rounded-full",
5836
- "mx-1",
5890
+ // Keyboard focus (not a plain mouse-click `:focus`) always reveals the control it landed
5891
+ // on, even the disabled one at an exhausted edge -- that's the only way a sighted keyboard
5892
+ // user gets to see what they just tabbed to.
5893
+ "group-has-[:focus-visible]:opacity-100",
5837
5894
  ],
5895
+ variants: {
5896
+ disabled: {
5897
+ // Nothing to scroll to on this side: don't let a mouse hover over the row reveal a
5898
+ // control that would be a no-op if clicked.
5899
+ true: [],
5900
+ false: ["group-hover:opacity-100"],
5901
+ },
5902
+ },
5903
+ defaultVariants: {
5904
+ disabled: false,
5905
+ },
5838
5906
  });
5839
5907
 
5840
5908
  /**
5841
- * Overflow indicator component that shows visual cues when content extends beyond visible area
5842
- * Shows a scroll button on hover to navigate in the specified direction
5909
+ * Edge control for HorizontalOverflowScroller. The fade lives on the content
5910
+ * mask; this is the hover/focus hit target that pages left or right.
5843
5911
  *
5844
5912
  * @param {OverflowIndicatorProps} props - The props for the component
5845
5913
  * @returns {ReactElement} OverflowIndicator component
5846
5914
  */
5847
- const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, onClickScroll, style, }) => {
5915
+ const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, onClickScroll, disabled = false, style, }) => {
5916
+ const [t] = useTranslation();
5848
5917
  const iconName = direction === "left" ? "ChevronLeft" : "ChevronRight";
5849
- return (jsxs(ZStack, { className: cvaOverflowIndicatorContainer({ className }), "data-testid": dataTestId, style: style, children: [jsx("div", { className: cvaJustificationContainer({ direction }), children: jsx("div", { className: cvaOverflowIndicatorGradient({ direction }), "data-testid": dataTestId ? `${dataTestId}-gradient` : undefined }) }), jsx("div", { className: cvaJustificationContainer({ direction }), children: jsx(IconButton, { className: cvaOverflowIndicatorButton(), "data-testid": dataTestId ? `${dataTestId}-button` : undefined, disabled: true, icon: jsx(Icon, { name: iconName, size: "small" }), onClick: onClickScroll, size: "small", title: "" // We dont want to show a tooltip here
5850
- , variant: "secondary" }) })] }));
5918
+ const ariaLabel = direction === "left" ? t("horizontalOverflowScroller.scrollLeft") : t("horizontalOverflowScroller.scrollRight");
5919
+ return (jsx("div", { className: cvaOverflowIndicatorHitRow({ className, direction }), "data-testid": dataTestId, style: style, children: jsx("button", { "aria-disabled": disabled, "aria-label": ariaLabel, className: cvaOverflowIndicatorHit({ disabled }), "data-testid": dataTestId ? `${dataTestId}-button` : undefined, onClick: disabled ? undefined : onClickScroll, type: "button", children: jsx(Icon, { ariaHidden: true, name: iconName, size: "small" }) }) }));
5851
5920
  };
5852
5921
 
5853
5922
  /**
5854
5923
  * HorizontalOverflowScroller displays child elements in a horizontal row with automatic overflow detection.
5855
- * When content overflows, it shows left/right scroll indicators with click-to-scroll functionality.
5924
+ * When content overflows, it fades the overflowing edge and reveals chevrons on hover or focus so the row can be paged.
5925
+ *
5926
+ * The fade is a CSS mask on the content itself, so it works on any background. At rest the ramp is short so more
5927
+ * items stay visible; hover grows a solid plateau under the chevron and pushes the ramp further in, while
5928
+ * keyboard focus only extends the ramp — a plateau there would erase the focus ring of a child flush with the
5929
+ * edge. Set `--hos-surface` on an ancestor to the background color behind the scroller so the chevrons pick a
5930
+ * contrasting color. It defaults to `Canvas`.
5856
5931
  *
5857
5932
  * ### When to use
5858
5933
  * Use HorizontalOverflowScroller to display a row of items (cards, tags, buttons) that may overflow on smaller screens, with visual cues that more content is available.
5859
5934
  *
5860
5935
  * ### When not to use
5861
5936
  * Do not use HorizontalOverflowScroller for tab navigation — use `TabList` which has its own scroll handling.
5937
+ * Do not use it to hide extra actions — use `MoreMenu` when the overflowing items are secondary actions rather than a row of content to browse.
5862
5938
  *
5863
5939
  * @example Horizontal scroller for KPI cards
5864
5940
  * ```tsx
@@ -5878,36 +5954,36 @@ const OverflowIndicator = ({ className, "data-testid": dataTestId, direction, on
5878
5954
  * @returns {ReactElement} HorizontalOverflowScroller component
5879
5955
  */
5880
5956
  const HorizontalOverflowScroller = ({ className, "data-testid": dataTestId, children, onScrollStateChange, style, ref, }) => {
5881
- const childrenArray = Children.toArray(children);
5882
- const { geometry: containerGeometry, ref: measureRef, element } = useMeasure();
5883
- const { ref: scrollRef, isScrollable, isAtBeginning, isAtEnd, } = useScrollDetection({
5957
+ const onScrollStateChangeRef = useRef(onScrollStateChange);
5958
+ useEffect(() => {
5959
+ onScrollStateChangeRef.current = onScrollStateChange;
5960
+ }, [onScrollStateChange]);
5961
+ const handleScrollStateChange = useCallback((state) => {
5962
+ onScrollStateChangeRef.current?.({
5963
+ isScrollable: state.isScrollable,
5964
+ isAtBeginning: state.isAtBeginning,
5965
+ isAtEnd: state.isAtEnd,
5966
+ });
5967
+ }, []);
5968
+ const { ref: scrollRef, element, isScrollable, isAtBeginning, isAtEnd, } = useScrollDetection({
5884
5969
  direction: "horizontal",
5885
- onScrollStateChange: onScrollStateChange
5886
- ? (state) => onScrollStateChange({
5887
- isScrollable: state.isScrollable,
5888
- isAtBeginning: state.isAtBeginning,
5889
- isAtEnd: state.isAtEnd,
5890
- })
5891
- : undefined,
5970
+ onScrollStateChange: handleScrollStateChange,
5892
5971
  });
5893
- const mergedRef = useMergeRefs([measureRef, scrollRef, ref]);
5894
- const handleScrollLeft = () => {
5895
- if (!element || containerGeometry?.width === undefined)
5896
- return;
5897
- element.scrollBy({
5898
- left: -containerGeometry.width,
5899
- behavior: "smooth",
5900
- });
5901
- };
5902
- const handleScrollRight = () => {
5903
- if (!element || containerGeometry?.width === undefined)
5972
+ const mergedRef = useMergeRefs([scrollRef, ref]);
5973
+ const canScrollLeft = isScrollable && !isAtBeginning;
5974
+ const canScrollRight = isScrollable && !isAtEnd;
5975
+ const scrollByPage = useCallback((direction) => {
5976
+ if (!element) {
5904
5977
  return;
5978
+ }
5905
5979
  element.scrollBy({
5906
- left: containerGeometry.width,
5980
+ left: direction * element.clientWidth,
5907
5981
  behavior: "smooth",
5908
5982
  });
5909
- };
5910
- return (jsxs(ZStack, { className: cvaHorizontalOverflowScrollerAndIndicatorsContainer({ className }), style: style, children: [jsx("div", { className: cvaHorizontalOverflowScroller(), "data-testid": dataTestId, ref: mergedRef, children: childrenArray.map((child, index) => (jsx(Fragment$1, { children: child }, index))) }), isScrollable && !isAtBeginning ? (jsx(OverflowIndicator, { "data-testid": `${dataTestId}-left-indicator`, direction: "left", onClickScroll: handleScrollLeft })) : null, isScrollable && !isAtEnd ? (jsx(OverflowIndicator, { "data-testid": `${dataTestId}-right-indicator`, direction: "right", onClickScroll: handleScrollRight })) : null] }));
5983
+ }, [element]);
5984
+ return (jsxs(Fragment, { children: [jsx("style", { children: fadeMaskStyleSheet }), jsxs(ZStack, { className: cvaHorizontalOverflowScrollerAndIndicatorsContainer({ className }), style: style, children: [jsx("div", { className: cvaHorizontalOverflowScroller({
5985
+ className: cvaHorizontalOverflowScrollerFadeMask({ canScrollLeft, canScrollRight }),
5986
+ }), "data-testid": dataTestId, ref: mergedRef, children: children }), isScrollable ? (jsx(OverflowIndicator, { "data-testid": dataTestId ? `${dataTestId}-left-indicator` : undefined, direction: "left", disabled: !canScrollLeft, onClickScroll: () => scrollByPage(-1) })) : null, isScrollable ? (jsx(OverflowIndicator, { "data-testid": dataTestId ? `${dataTestId}-right-indicator` : undefined, direction: "right", disabled: !canScrollRight, onClickScroll: () => scrollByPage(1) })) : null] })] }));
5911
5987
  };
5912
5988
 
5913
5989
  const cvaIndicator = cva({ base: ["inline-flex", "items-center"] });
@@ -8289,6 +8365,7 @@ const cvaMoreMenu = cva({ base: ["p-0"] });
8289
8365
  *
8290
8366
  * ### When not to use
8291
8367
  * Do not use MoreMenu for primary actions that should always be visible. Use `Button` instead.
8368
+ * Do not use it to overflow a row of content (tags, KPIs, chips) — use `HorizontalOverflowScroller` when those items should stay visible and be browsed horizontally.
8292
8369
  *
8293
8370
  * @example Action items with render prop — Pass a function as `children` to receive the `close` callback and dismiss the menu after an action.
8294
8371
  * ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "2.13.11",
3
+ "version": "2.13.13",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -14,16 +14,16 @@
14
14
  "@floating-ui/react": "^0.26.25",
15
15
  "string-ts": "^2.0.0",
16
16
  "tailwind-merge": "^2.0.0",
17
- "@trackunit/ui-design-tokens": "1.15.26",
18
- "@trackunit/css-class-variance-utilities": "2.0.7",
19
- "@trackunit/shared-utils": "1.16.42",
20
- "@trackunit/ui-icons": "1.14.35",
17
+ "@trackunit/ui-design-tokens": "1.15.28",
18
+ "@trackunit/css-class-variance-utilities": "2.0.9",
19
+ "@trackunit/shared-utils": "1.16.44",
20
+ "@trackunit/ui-icons": "1.14.37",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "@tanstack/react-virtual": "^3.14.3",
23
23
  "dequal": "^2.0.3",
24
24
  "fflate": "^0.8.2",
25
25
  "zod": "^3.25.76",
26
- "@trackunit/i18n-library-translation": "2.4.51"
26
+ "@trackunit/i18n-library-translation": "2.4.53"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^19.0.0",
@@ -1,27 +1,35 @@
1
- import { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import { CommonProps } from "../../common/CommonProps";
3
- import type { Styleable } from "../../common/Styleable";
4
3
  import { Refable } from "../../common/Refable";
4
+ import type { Styleable } from "../../common/Styleable";
5
+ export type HorizontalOverflowScrollState = {
6
+ readonly isScrollable: boolean;
7
+ readonly isAtBeginning: boolean;
8
+ readonly isAtEnd: boolean;
9
+ };
5
10
  export interface HorizontalOverflowScrollerProps extends CommonProps, Styleable, Refable<HTMLDivElement> {
6
11
  children: ReactNode;
7
12
  /**
8
- * Callback fired when scroll state changes (scrollable, at edges, etc.)
13
+ * Called when overflow or edge position changes.
9
14
  */
10
- onScrollStateChange?: (state: {
11
- isScrollable: boolean;
12
- isAtBeginning: boolean;
13
- isAtEnd: boolean;
14
- }) => void;
15
+ onScrollStateChange?: (state: HorizontalOverflowScrollState) => void;
15
16
  }
16
17
  /**
17
18
  * HorizontalOverflowScroller displays child elements in a horizontal row with automatic overflow detection.
18
- * When content overflows, it shows left/right scroll indicators with click-to-scroll functionality.
19
+ * When content overflows, it fades the overflowing edge and reveals chevrons on hover or focus so the row can be paged.
20
+ *
21
+ * The fade is a CSS mask on the content itself, so it works on any background. At rest the ramp is short so more
22
+ * items stay visible; hover grows a solid plateau under the chevron and pushes the ramp further in, while
23
+ * keyboard focus only extends the ramp — a plateau there would erase the focus ring of a child flush with the
24
+ * edge. Set `--hos-surface` on an ancestor to the background color behind the scroller so the chevrons pick a
25
+ * contrasting color. It defaults to `Canvas`.
19
26
  *
20
27
  * ### When to use
21
28
  * Use HorizontalOverflowScroller to display a row of items (cards, tags, buttons) that may overflow on smaller screens, with visual cues that more content is available.
22
29
  *
23
30
  * ### When not to use
24
31
  * Do not use HorizontalOverflowScroller for tab navigation — use `TabList` which has its own scroll handling.
32
+ * Do not use it to hide extra actions — use `MoreMenu` when the overflowing items are secondary actions rather than a row of content to browse.
25
33
  *
26
34
  * @example Horizontal scroller for KPI cards
27
35
  * ```tsx
@@ -1,3 +1,4 @@
1
+ export declare const fadeMaskStyleSheet = "\n @property --tu-hos-solid-left {\n syntax: '<length>';\n initial-value: 0px;\n inherits: false;\n }\n\n @property --tu-hos-solid-right {\n syntax: '<length>';\n initial-value: 0px;\n inherits: false;\n }\n\n @property --tu-hos-fade-left {\n syntax: '<length>';\n initial-value: 0px;\n inherits: false;\n }\n\n @property --tu-hos-fade-right {\n syntax: '<length>';\n initial-value: 0px;\n inherits: false;\n }\n";
1
2
  export declare const cvaHorizontalOverflowScroller: import("cva").CVAComponent<Omit<{
2
3
  base: string[];
3
4
  }, "defaultVariants"> & {
@@ -10,3 +11,37 @@ export declare const cvaHorizontalOverflowScrollerAndIndicatorsContainer: import
10
11
  variants: unknown;
11
12
  defaultVariants: Omit<{}, never>;
12
13
  }, unknown>;
14
+ export declare const cvaHorizontalOverflowScrollerFadeMask: import("cva").CVAComponent<Omit<{
15
+ base: string[];
16
+ variants: {
17
+ canScrollLeft: {
18
+ true: string[];
19
+ false: string[];
20
+ };
21
+ canScrollRight: {
22
+ true: string[];
23
+ false: string[];
24
+ };
25
+ };
26
+ }, "defaultVariants"> & {
27
+ variants: {
28
+ canScrollLeft: {
29
+ true: string[];
30
+ false: string[];
31
+ };
32
+ canScrollRight: {
33
+ true: string[];
34
+ false: string[];
35
+ };
36
+ };
37
+ defaultVariants: Omit<{}, never>;
38
+ }, {
39
+ canScrollLeft: {
40
+ true: string[];
41
+ false: string[];
42
+ };
43
+ canScrollRight: {
44
+ true: string[];
45
+ false: string[];
46
+ };
47
+ }>;
@@ -1,23 +1,27 @@
1
- import { MouseEventHandler, ReactElement } from "react";
1
+ import { type MouseEventHandler, type ReactElement } from "react";
2
2
  import { CommonProps } from "../../common/CommonProps";
3
3
  import type { Styleable } from "../../common/Styleable";
4
4
  type OverflowDirection = "left" | "right";
5
5
  export interface OverflowIndicatorProps extends CommonProps, Styleable {
6
6
  /**
7
- * Direction of the overflow indicator
7
+ * Which edge of the scroller this control sits on.
8
8
  */
9
9
  direction: OverflowDirection;
10
10
  /**
11
- * Callback function to handle scroll action
11
+ * Called when the chevron is activated to page in this direction.
12
12
  */
13
13
  onClickScroll?: MouseEventHandler<HTMLButtonElement>;
14
+ /**
15
+ * Whether the control has reached the corresponding scroll edge.
16
+ */
17
+ disabled?: boolean;
14
18
  }
15
19
  /**
16
- * Overflow indicator component that shows visual cues when content extends beyond visible area
17
- * Shows a scroll button on hover to navigate in the specified direction
20
+ * Edge control for HorizontalOverflowScroller. The fade lives on the content
21
+ * mask; this is the hover/focus hit target that pages left or right.
18
22
  *
19
23
  * @param {OverflowIndicatorProps} props - The props for the component
20
24
  * @returns {ReactElement} OverflowIndicator component
21
25
  */
22
- export declare const OverflowIndicator: ({ className, "data-testid": dataTestId, direction, onClickScroll, style, }: OverflowIndicatorProps) => ReactElement;
26
+ export declare const OverflowIndicator: ({ className, "data-testid": dataTestId, direction, onClickScroll, disabled, style, }: OverflowIndicatorProps) => ReactElement;
23
27
  export {};
@@ -1,10 +1,4 @@
1
- export declare const cvaOverflowIndicatorContainer: import("cva").CVAComponent<Omit<{
2
- base: string[];
3
- }, "defaultVariants"> & {
4
- variants: unknown;
5
- defaultVariants: Omit<{}, never>;
6
- }, unknown>;
7
- export declare const cvaOverflowIndicatorGradient: import("cva").CVAComponent<Omit<{
1
+ export declare const cvaOverflowIndicatorHitRow: import("cva").CVAComponent<Omit<{
8
2
  base: string[];
9
3
  variants: {
10
4
  direction: {
@@ -26,31 +20,30 @@ export declare const cvaOverflowIndicatorGradient: import("cva").CVAComponent<Om
26
20
  right: string[];
27
21
  };
28
22
  }>;
29
- export declare const cvaJustificationContainer: import("cva").CVAComponent<Omit<{
23
+ export declare const cvaOverflowIndicatorHit: import("cva").CVAComponent<Omit<{
30
24
  base: string[];
31
25
  variants: {
32
- direction: {
33
- left: string[];
34
- right: string[];
26
+ disabled: {
27
+ true: never[];
28
+ false: string[];
35
29
  };
36
30
  };
31
+ defaultVariants: {
32
+ disabled: false;
33
+ };
37
34
  }, "defaultVariants"> & {
38
35
  variants: {
39
- direction: {
40
- left: string[];
41
- right: string[];
36
+ disabled: {
37
+ true: never[];
38
+ false: string[];
42
39
  };
43
40
  };
44
- defaultVariants: Omit<{}, never>;
41
+ defaultVariants: Omit<{}, "disabled"> & {
42
+ disabled: false;
43
+ };
45
44
  }, {
46
- direction: {
47
- left: string[];
48
- right: string[];
45
+ disabled: {
46
+ true: never[];
47
+ false: string[];
49
48
  };
50
49
  }>;
51
- export declare const cvaOverflowIndicatorButton: import("cva").CVAComponent<Omit<{
52
- base: string[];
53
- }, "defaultVariants"> & {
54
- variants: unknown;
55
- defaultVariants: Omit<{}, never>;
56
- }, unknown>;
@@ -50,6 +50,7 @@ export interface MoreMenuProps extends CommonProps, Styleable, Refable<HTMLDivEl
50
50
  *
51
51
  * ### When not to use
52
52
  * Do not use MoreMenu for primary actions that should always be visible. Use `Button` instead.
53
+ * Do not use it to overflow a row of content (tags, KPIs, chips) — use `HorizontalOverflowScroller` when those items should stay visible and be browsed horizontally.
53
54
  *
54
55
  * @example Action items with render prop — Pass a function as `children` to receive the `close` callback and dismiss the menu after an action.
55
56
  * ```tsx