@trackunit/react-components 1.10.94 → 1.10.96

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.
Files changed (3) hide show
  1. package/index.cjs.js +152 -85
  2. package/index.esm.js +153 -86
  3. package/package.json +5 -5
package/index.cjs.js CHANGED
@@ -2168,22 +2168,85 @@ const useDebounce = (value, { onBounce, delay = 500 } = {}) => {
2168
2168
  return debouncedValue;
2169
2169
  };
2170
2170
 
2171
+ const UNINITIALIZED = Symbol("UNINITIALIZED");
2171
2172
  /**
2172
- * Differentiate between the first and subsequent renders.
2173
+ * Hook to watch for changes in a value and react to them.
2174
+ * Uses deep equality comparison via es-toolkit's isEqual.
2173
2175
  *
2174
- * @returns {boolean} Returns true if it is the first render, false otherwise.
2176
+ * @param props - The hook properties
2177
+ * @param props.value - The value to watch for changes
2178
+ * @param props.onChange - Function to call when the value changes
2179
+ * @param props.immediate - Whether to run the callback immediately on mount (default: false)
2180
+ * @param props.skip - Whether to skip watching for changes (default: false)
2175
2181
  */
2176
- const useIsFirstRender = () => {
2177
- const [isFirstRender, setIsFirstRender] = react.useState(true);
2178
- react.useLayoutEffect(() => {
2179
- queueMicrotask(() => {
2180
- setIsFirstRender(false);
2181
- });
2182
- }, []);
2183
- return isFirstRender;
2182
+ const useWatch = ({ value, onChange, immediate = false, skip = false }) => {
2183
+ const prevValue = react.useRef(UNINITIALIZED);
2184
+ const onChangeRef = react.useRef(onChange);
2185
+ // Update the ref whenever onChange changes
2186
+ react.useEffect(() => {
2187
+ onChangeRef.current = onChange;
2188
+ }, [onChange]);
2189
+ react.useEffect(() => {
2190
+ if (skip) {
2191
+ return;
2192
+ }
2193
+ const prev = prevValue.current;
2194
+ const hasChanged = prev === UNINITIALIZED ? false : !esToolkit.isEqual(value, prev);
2195
+ if (immediate && prev === UNINITIALIZED) {
2196
+ onChangeRef.current(value, null);
2197
+ }
2198
+ else if (hasChanged && prev !== UNINITIALIZED) {
2199
+ onChangeRef.current(value, prev);
2200
+ }
2201
+ prevValue.current = value;
2202
+ }, [value, immediate, skip]);
2184
2203
  };
2185
2204
 
2186
2205
  const SCROLL_DEBOUNCE_TIME = 50;
2206
+ const scrollStateReducer = (state, action) => {
2207
+ switch (action.type) {
2208
+ case "UPDATE_SCROLLABLE": {
2209
+ if (state.isScrollable === action.payload) {
2210
+ return state;
2211
+ }
2212
+ return { ...state, isScrollable: action.payload };
2213
+ }
2214
+ case "UPDATE_POSITION": {
2215
+ if (state.isAtBeginning === action.payload.isAtBeginning &&
2216
+ state.isAtEnd === action.payload.isAtEnd &&
2217
+ state.scrollPosition.start === action.payload.scrollPosition.start &&
2218
+ state.scrollPosition.end === action.payload.scrollPosition.end) {
2219
+ return state;
2220
+ }
2221
+ return {
2222
+ ...state,
2223
+ isAtBeginning: action.payload.isAtBeginning,
2224
+ isAtEnd: action.payload.isAtEnd,
2225
+ scrollPosition: action.payload.scrollPosition,
2226
+ };
2227
+ }
2228
+ case "UPDATE_ALL": {
2229
+ if (state.isScrollable === action.payload.isScrollable &&
2230
+ state.isAtBeginning === action.payload.isAtBeginning &&
2231
+ state.isAtEnd === action.payload.isAtEnd &&
2232
+ state.scrollPosition.start === action.payload.scrollPosition.start &&
2233
+ state.scrollPosition.end === action.payload.scrollPosition.end) {
2234
+ return state;
2235
+ }
2236
+ return action.payload;
2237
+ }
2238
+ default: {
2239
+ const exhaustiveCheck = action;
2240
+ throw new Error(`Unknown action: ${exhaustiveCheck}`);
2241
+ }
2242
+ }
2243
+ };
2244
+ const initialScrollState = {
2245
+ isScrollable: false,
2246
+ isAtBeginning: true,
2247
+ isAtEnd: false,
2248
+ scrollPosition: { start: 0, end: 0 },
2249
+ };
2187
2250
  /**
2188
2251
  * Hook for detecting scroll values in horizontal or vertical direction.
2189
2252
  * Returns a ref callback to attach to the element you want to observe.
@@ -2200,12 +2263,8 @@ const SCROLL_DEBOUNCE_TIME = 50;
2200
2263
  const useScrollDetection = (options) => {
2201
2264
  const { direction = "vertical", onScrollStateChange, skip = false } = options ?? {};
2202
2265
  const [element, setElement] = react.useState(null);
2203
- const [isScrollable, setIsScrollable] = react.useState(false);
2204
- const [isAtBeginning, setIsAtBeginning] = react.useState(true);
2205
- const [isAtEnd, setIsAtEnd] = react.useState(false);
2206
- const [scrollPosition, setScrollPosition] = react.useState({ start: 0, end: 0 });
2266
+ const [scrollState, dispatch] = react.useReducer(scrollStateReducer, initialScrollState);
2207
2267
  const observerRef = react.useRef(null);
2208
- const isFirstRender = useIsFirstRender();
2209
2268
  // Callback ref to track the element
2210
2269
  const ref = react.useCallback((node) => {
2211
2270
  setElement(node);
@@ -2217,13 +2276,7 @@ const useScrollDetection = (options) => {
2217
2276
  const hasOverflow = direction === "horizontal"
2218
2277
  ? element.scrollWidth > element.clientWidth
2219
2278
  : element.scrollHeight > element.clientHeight;
2220
- setIsScrollable(prev => {
2221
- if (prev !== hasOverflow) {
2222
- // State will be updated, so we'll notify in the next effect
2223
- return hasOverflow;
2224
- }
2225
- return prev;
2226
- });
2279
+ dispatch({ type: "UPDATE_SCROLLABLE", payload: hasOverflow });
2227
2280
  }, [element, direction]);
2228
2281
  const checkScrollPosition = react.useCallback(() => {
2229
2282
  if (!element) {
@@ -2231,21 +2284,23 @@ const useScrollDetection = (options) => {
2231
2284
  }
2232
2285
  if (direction === "horizontal") {
2233
2286
  const { scrollLeft, scrollWidth, clientWidth } = element;
2234
- const newIsAtBeginning = scrollLeft === 0;
2235
- const newIsAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2236
- const newScrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2237
- setIsAtBeginning(newIsAtBeginning);
2238
- setIsAtEnd(newIsAtEnd);
2239
- setScrollPosition(newScrollPosition);
2287
+ const isAtBeginning = scrollLeft === 0;
2288
+ const isAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2289
+ const scrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2290
+ dispatch({
2291
+ type: "UPDATE_POSITION",
2292
+ payload: { isAtBeginning, isAtEnd, scrollPosition },
2293
+ });
2240
2294
  }
2241
2295
  else {
2242
2296
  const { scrollTop, scrollHeight, clientHeight } = element;
2243
- const newIsAtBeginning = scrollTop === 0;
2244
- const newIsAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2245
- const newScrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2246
- setIsAtBeginning(newIsAtBeginning);
2247
- setIsAtEnd(newIsAtEnd);
2248
- setScrollPosition(newScrollPosition);
2297
+ const isAtBeginning = scrollTop === 0;
2298
+ const isAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2299
+ const scrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2300
+ dispatch({
2301
+ type: "UPDATE_POSITION",
2302
+ payload: { isAtBeginning, isAtEnd, scrollPosition },
2303
+ });
2249
2304
  }
2250
2305
  }, [element, direction]);
2251
2306
  const [scrollTrigger, setScrollTrigger] = react.useState(0);
@@ -2264,25 +2319,49 @@ const useScrollDetection = (options) => {
2264
2319
  react.useEffect(() => {
2265
2320
  checkScrollPositionRef.current = checkScrollPosition;
2266
2321
  }, [checkScrollPosition]);
2267
- // Notify about state changes whenever any state value changes
2268
- react.useEffect(() => {
2269
- if (isFirstRender) {
2270
- return;
2271
- }
2272
- onScrollStateChange?.({
2273
- isScrollable,
2274
- isAtBeginning,
2275
- isAtEnd,
2276
- scrollPosition,
2277
- });
2278
- }, [isScrollable, isAtBeginning, isAtEnd, scrollPosition, onScrollStateChange, isFirstRender]);
2322
+ useWatch({
2323
+ value: scrollState,
2324
+ onChange: (changedState) => onScrollStateChange?.(changedState),
2325
+ skip: skip || !onScrollStateChange,
2326
+ });
2279
2327
  react.useEffect(() => {
2280
2328
  if (skip || !element) {
2281
2329
  return;
2282
2330
  }
2283
- // Initial checks
2284
- checkScrollableRef.current();
2285
- checkScrollPositionRef.current();
2331
+ // Initial checks - batch into single update
2332
+ const hasOverflow = direction === "horizontal"
2333
+ ? element.scrollWidth > element.clientWidth
2334
+ : element.scrollHeight > element.clientHeight;
2335
+ if (direction === "horizontal") {
2336
+ const { scrollLeft, scrollWidth, clientWidth } = element;
2337
+ const isAtBeginning = scrollLeft === 0;
2338
+ const isAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2339
+ const scrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2340
+ dispatch({
2341
+ type: "UPDATE_ALL",
2342
+ payload: {
2343
+ isScrollable: hasOverflow,
2344
+ isAtBeginning,
2345
+ isAtEnd,
2346
+ scrollPosition,
2347
+ },
2348
+ });
2349
+ }
2350
+ else {
2351
+ const { scrollTop, scrollHeight, clientHeight } = element;
2352
+ const isAtBeginning = scrollTop === 0;
2353
+ const isAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2354
+ const scrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2355
+ dispatch({
2356
+ type: "UPDATE_ALL",
2357
+ payload: {
2358
+ isScrollable: hasOverflow,
2359
+ isAtBeginning,
2360
+ isAtEnd,
2361
+ scrollPosition,
2362
+ },
2363
+ });
2364
+ }
2286
2365
  observerRef.current = new ResizeObserver(() => {
2287
2366
  checkScrollableRef.current();
2288
2367
  checkScrollPositionRef.current();
@@ -2295,8 +2374,15 @@ const useScrollDetection = (options) => {
2295
2374
  }
2296
2375
  element.removeEventListener("scroll", handleScroll);
2297
2376
  };
2298
- }, [element, handleScroll, skip]);
2299
- return react.useMemo(() => ({ ref, element, isScrollable, isAtBeginning, isAtEnd, scrollPosition }), [ref, element, isScrollable, isAtBeginning, isAtEnd, scrollPosition]);
2377
+ }, [element, handleScroll, skip, direction]);
2378
+ return react.useMemo(() => ({
2379
+ ref,
2380
+ element,
2381
+ isScrollable: scrollState.isScrollable,
2382
+ isAtBeginning: scrollState.isAtBeginning,
2383
+ isAtEnd: scrollState.isAtEnd,
2384
+ scrollPosition: scrollState.scrollPosition,
2385
+ }), [ref, element, scrollState]);
2300
2386
  };
2301
2387
 
2302
2388
  const cvaZStackContainer = cssClassVarianceUtilities.cvaMerge(["grid", "grid-cols-1", "grid-rows-1"]);
@@ -5611,6 +5697,21 @@ const useHover = ({ debounced = false, delay = 100, direction = "out" } = { debo
5611
5697
  }), [onMouseEnter, onMouseLeave, value]);
5612
5698
  };
5613
5699
 
5700
+ /**
5701
+ * Differentiate between the first and subsequent renders.
5702
+ *
5703
+ * @returns {boolean} Returns true if it is the first render, false otherwise.
5704
+ */
5705
+ const useIsFirstRender = () => {
5706
+ const [isFirstRender, setIsFirstRender] = react.useState(true);
5707
+ react.useLayoutEffect(() => {
5708
+ queueMicrotask(() => {
5709
+ setIsFirstRender(false);
5710
+ });
5711
+ }, []);
5712
+ return isFirstRender;
5713
+ };
5714
+
5614
5715
  /**
5615
5716
  * Custom hook for checking if the browser is in fullscreen mode.
5616
5717
  */
@@ -5928,40 +6029,6 @@ function useTextSearch(items, props) {
5928
6029
  return react.useMemo(() => [result, searchText, setSearchText], [result, searchText, setSearchText]);
5929
6030
  }
5930
6031
 
5931
- const UNINITIALIZED = Symbol("UNINITIALIZED");
5932
- /**
5933
- * Hook to watch for changes in a value and react to them.
5934
- * Uses deep equality comparison via es-toolkit's isEqual.
5935
- *
5936
- * @param props - The hook properties
5937
- * @param props.value - The value to watch for changes
5938
- * @param props.onChange - Function to call when the value changes
5939
- * @param props.immediate - Whether to run the callback immediately on mount (default: false)
5940
- * @param props.skip - Whether to skip watching for changes (default: false)
5941
- */
5942
- const useWatch = ({ value, onChange, immediate = false, skip = false }) => {
5943
- const prevValue = react.useRef(UNINITIALIZED);
5944
- const onChangeRef = react.useRef(onChange);
5945
- // Update the ref whenever onChange changes
5946
- react.useEffect(() => {
5947
- onChangeRef.current = onChange;
5948
- }, [onChange]);
5949
- react.useEffect(() => {
5950
- if (skip) {
5951
- return;
5952
- }
5953
- const prev = prevValue.current;
5954
- const hasChanged = prev === UNINITIALIZED ? false : !esToolkit.isEqual(value, prev);
5955
- if (immediate && prev === UNINITIALIZED) {
5956
- onChangeRef.current(value, null);
5957
- }
5958
- else if (hasChanged && prev !== UNINITIALIZED) {
5959
- onChangeRef.current(value, prev);
5960
- }
5961
- prevValue.current = value;
5962
- }, [value, immediate, skip]);
5963
- };
5964
-
5965
6032
  const hasFocus = () => typeof document !== "undefined" && document.hasFocus();
5966
6033
  /**
5967
6034
  * Use this hook to disable functionality while the tab is hidden within the browser or to react to focus or blur events
package/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
2
- import { useRef, useMemo, useEffect, useState, useCallback, createElement, forwardRef, Fragment, memo, useLayoutEffect, Children, isValidElement, cloneElement, createContext, useContext, useReducer } from 'react';
2
+ import { useRef, useMemo, useEffect, useState, useCallback, createElement, forwardRef, Fragment, memo, useReducer, Children, isValidElement, cloneElement, createContext, useContext, useLayoutEffect } from 'react';
3
3
  import { objectKeys, uuidv4, objectEntries, objectValues, nonNullable, filterByMultiple } from '@trackunit/shared-utils';
4
4
  import { intentPalette, generalPalette, criticalityPalette, activityPalette, utilizationPalette, sitesPalette, rentalStatusPalette, themeScreenSizeAsNumber, color } from '@trackunit/ui-design-tokens';
5
5
  import { iconNames } from '@trackunit/ui-icons';
@@ -2166,22 +2166,85 @@ const useDebounce = (value, { onBounce, delay = 500 } = {}) => {
2166
2166
  return debouncedValue;
2167
2167
  };
2168
2168
 
2169
+ const UNINITIALIZED = Symbol("UNINITIALIZED");
2169
2170
  /**
2170
- * Differentiate between the first and subsequent renders.
2171
+ * Hook to watch for changes in a value and react to them.
2172
+ * Uses deep equality comparison via es-toolkit's isEqual.
2171
2173
  *
2172
- * @returns {boolean} Returns true if it is the first render, false otherwise.
2174
+ * @param props - The hook properties
2175
+ * @param props.value - The value to watch for changes
2176
+ * @param props.onChange - Function to call when the value changes
2177
+ * @param props.immediate - Whether to run the callback immediately on mount (default: false)
2178
+ * @param props.skip - Whether to skip watching for changes (default: false)
2173
2179
  */
2174
- const useIsFirstRender = () => {
2175
- const [isFirstRender, setIsFirstRender] = useState(true);
2176
- useLayoutEffect(() => {
2177
- queueMicrotask(() => {
2178
- setIsFirstRender(false);
2179
- });
2180
- }, []);
2181
- return isFirstRender;
2180
+ const useWatch = ({ value, onChange, immediate = false, skip = false }) => {
2181
+ const prevValue = useRef(UNINITIALIZED);
2182
+ const onChangeRef = useRef(onChange);
2183
+ // Update the ref whenever onChange changes
2184
+ useEffect(() => {
2185
+ onChangeRef.current = onChange;
2186
+ }, [onChange]);
2187
+ useEffect(() => {
2188
+ if (skip) {
2189
+ return;
2190
+ }
2191
+ const prev = prevValue.current;
2192
+ const hasChanged = prev === UNINITIALIZED ? false : !isEqual(value, prev);
2193
+ if (immediate && prev === UNINITIALIZED) {
2194
+ onChangeRef.current(value, null);
2195
+ }
2196
+ else if (hasChanged && prev !== UNINITIALIZED) {
2197
+ onChangeRef.current(value, prev);
2198
+ }
2199
+ prevValue.current = value;
2200
+ }, [value, immediate, skip]);
2182
2201
  };
2183
2202
 
2184
2203
  const SCROLL_DEBOUNCE_TIME = 50;
2204
+ const scrollStateReducer = (state, action) => {
2205
+ switch (action.type) {
2206
+ case "UPDATE_SCROLLABLE": {
2207
+ if (state.isScrollable === action.payload) {
2208
+ return state;
2209
+ }
2210
+ return { ...state, isScrollable: action.payload };
2211
+ }
2212
+ case "UPDATE_POSITION": {
2213
+ if (state.isAtBeginning === action.payload.isAtBeginning &&
2214
+ state.isAtEnd === action.payload.isAtEnd &&
2215
+ state.scrollPosition.start === action.payload.scrollPosition.start &&
2216
+ state.scrollPosition.end === action.payload.scrollPosition.end) {
2217
+ return state;
2218
+ }
2219
+ return {
2220
+ ...state,
2221
+ isAtBeginning: action.payload.isAtBeginning,
2222
+ isAtEnd: action.payload.isAtEnd,
2223
+ scrollPosition: action.payload.scrollPosition,
2224
+ };
2225
+ }
2226
+ case "UPDATE_ALL": {
2227
+ if (state.isScrollable === action.payload.isScrollable &&
2228
+ state.isAtBeginning === action.payload.isAtBeginning &&
2229
+ state.isAtEnd === action.payload.isAtEnd &&
2230
+ state.scrollPosition.start === action.payload.scrollPosition.start &&
2231
+ state.scrollPosition.end === action.payload.scrollPosition.end) {
2232
+ return state;
2233
+ }
2234
+ return action.payload;
2235
+ }
2236
+ default: {
2237
+ const exhaustiveCheck = action;
2238
+ throw new Error(`Unknown action: ${exhaustiveCheck}`);
2239
+ }
2240
+ }
2241
+ };
2242
+ const initialScrollState = {
2243
+ isScrollable: false,
2244
+ isAtBeginning: true,
2245
+ isAtEnd: false,
2246
+ scrollPosition: { start: 0, end: 0 },
2247
+ };
2185
2248
  /**
2186
2249
  * Hook for detecting scroll values in horizontal or vertical direction.
2187
2250
  * Returns a ref callback to attach to the element you want to observe.
@@ -2198,12 +2261,8 @@ const SCROLL_DEBOUNCE_TIME = 50;
2198
2261
  const useScrollDetection = (options) => {
2199
2262
  const { direction = "vertical", onScrollStateChange, skip = false } = options ?? {};
2200
2263
  const [element, setElement] = useState(null);
2201
- const [isScrollable, setIsScrollable] = useState(false);
2202
- const [isAtBeginning, setIsAtBeginning] = useState(true);
2203
- const [isAtEnd, setIsAtEnd] = useState(false);
2204
- const [scrollPosition, setScrollPosition] = useState({ start: 0, end: 0 });
2264
+ const [scrollState, dispatch] = useReducer(scrollStateReducer, initialScrollState);
2205
2265
  const observerRef = useRef(null);
2206
- const isFirstRender = useIsFirstRender();
2207
2266
  // Callback ref to track the element
2208
2267
  const ref = useCallback((node) => {
2209
2268
  setElement(node);
@@ -2215,13 +2274,7 @@ const useScrollDetection = (options) => {
2215
2274
  const hasOverflow = direction === "horizontal"
2216
2275
  ? element.scrollWidth > element.clientWidth
2217
2276
  : element.scrollHeight > element.clientHeight;
2218
- setIsScrollable(prev => {
2219
- if (prev !== hasOverflow) {
2220
- // State will be updated, so we'll notify in the next effect
2221
- return hasOverflow;
2222
- }
2223
- return prev;
2224
- });
2277
+ dispatch({ type: "UPDATE_SCROLLABLE", payload: hasOverflow });
2225
2278
  }, [element, direction]);
2226
2279
  const checkScrollPosition = useCallback(() => {
2227
2280
  if (!element) {
@@ -2229,21 +2282,23 @@ const useScrollDetection = (options) => {
2229
2282
  }
2230
2283
  if (direction === "horizontal") {
2231
2284
  const { scrollLeft, scrollWidth, clientWidth } = element;
2232
- const newIsAtBeginning = scrollLeft === 0;
2233
- const newIsAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2234
- const newScrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2235
- setIsAtBeginning(newIsAtBeginning);
2236
- setIsAtEnd(newIsAtEnd);
2237
- setScrollPosition(newScrollPosition);
2285
+ const isAtBeginning = scrollLeft === 0;
2286
+ const isAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2287
+ const scrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2288
+ dispatch({
2289
+ type: "UPDATE_POSITION",
2290
+ payload: { isAtBeginning, isAtEnd, scrollPosition },
2291
+ });
2238
2292
  }
2239
2293
  else {
2240
2294
  const { scrollTop, scrollHeight, clientHeight } = element;
2241
- const newIsAtBeginning = scrollTop === 0;
2242
- const newIsAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2243
- const newScrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2244
- setIsAtBeginning(newIsAtBeginning);
2245
- setIsAtEnd(newIsAtEnd);
2246
- setScrollPosition(newScrollPosition);
2295
+ const isAtBeginning = scrollTop === 0;
2296
+ const isAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2297
+ const scrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2298
+ dispatch({
2299
+ type: "UPDATE_POSITION",
2300
+ payload: { isAtBeginning, isAtEnd, scrollPosition },
2301
+ });
2247
2302
  }
2248
2303
  }, [element, direction]);
2249
2304
  const [scrollTrigger, setScrollTrigger] = useState(0);
@@ -2262,25 +2317,49 @@ const useScrollDetection = (options) => {
2262
2317
  useEffect(() => {
2263
2318
  checkScrollPositionRef.current = checkScrollPosition;
2264
2319
  }, [checkScrollPosition]);
2265
- // Notify about state changes whenever any state value changes
2266
- useEffect(() => {
2267
- if (isFirstRender) {
2268
- return;
2269
- }
2270
- onScrollStateChange?.({
2271
- isScrollable,
2272
- isAtBeginning,
2273
- isAtEnd,
2274
- scrollPosition,
2275
- });
2276
- }, [isScrollable, isAtBeginning, isAtEnd, scrollPosition, onScrollStateChange, isFirstRender]);
2320
+ useWatch({
2321
+ value: scrollState,
2322
+ onChange: (changedState) => onScrollStateChange?.(changedState),
2323
+ skip: skip || !onScrollStateChange,
2324
+ });
2277
2325
  useEffect(() => {
2278
2326
  if (skip || !element) {
2279
2327
  return;
2280
2328
  }
2281
- // Initial checks
2282
- checkScrollableRef.current();
2283
- checkScrollPositionRef.current();
2329
+ // Initial checks - batch into single update
2330
+ const hasOverflow = direction === "horizontal"
2331
+ ? element.scrollWidth > element.clientWidth
2332
+ : element.scrollHeight > element.clientHeight;
2333
+ if (direction === "horizontal") {
2334
+ const { scrollLeft, scrollWidth, clientWidth } = element;
2335
+ const isAtBeginning = scrollLeft === 0;
2336
+ const isAtEnd = Math.abs(scrollWidth - scrollLeft - clientWidth) <= 1;
2337
+ const scrollPosition = { start: scrollLeft, end: clientWidth - scrollLeft };
2338
+ dispatch({
2339
+ type: "UPDATE_ALL",
2340
+ payload: {
2341
+ isScrollable: hasOverflow,
2342
+ isAtBeginning,
2343
+ isAtEnd,
2344
+ scrollPosition,
2345
+ },
2346
+ });
2347
+ }
2348
+ else {
2349
+ const { scrollTop, scrollHeight, clientHeight } = element;
2350
+ const isAtBeginning = scrollTop === 0;
2351
+ const isAtEnd = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;
2352
+ const scrollPosition = { start: scrollTop, end: clientHeight - scrollTop };
2353
+ dispatch({
2354
+ type: "UPDATE_ALL",
2355
+ payload: {
2356
+ isScrollable: hasOverflow,
2357
+ isAtBeginning,
2358
+ isAtEnd,
2359
+ scrollPosition,
2360
+ },
2361
+ });
2362
+ }
2284
2363
  observerRef.current = new ResizeObserver(() => {
2285
2364
  checkScrollableRef.current();
2286
2365
  checkScrollPositionRef.current();
@@ -2293,8 +2372,15 @@ const useScrollDetection = (options) => {
2293
2372
  }
2294
2373
  element.removeEventListener("scroll", handleScroll);
2295
2374
  };
2296
- }, [element, handleScroll, skip]);
2297
- return useMemo(() => ({ ref, element, isScrollable, isAtBeginning, isAtEnd, scrollPosition }), [ref, element, isScrollable, isAtBeginning, isAtEnd, scrollPosition]);
2375
+ }, [element, handleScroll, skip, direction]);
2376
+ return useMemo(() => ({
2377
+ ref,
2378
+ element,
2379
+ isScrollable: scrollState.isScrollable,
2380
+ isAtBeginning: scrollState.isAtBeginning,
2381
+ isAtEnd: scrollState.isAtEnd,
2382
+ scrollPosition: scrollState.scrollPosition,
2383
+ }), [ref, element, scrollState]);
2298
2384
  };
2299
2385
 
2300
2386
  const cvaZStackContainer = cvaMerge(["grid", "grid-cols-1", "grid-rows-1"]);
@@ -5609,6 +5695,21 @@ const useHover = ({ debounced = false, delay = 100, direction = "out" } = { debo
5609
5695
  }), [onMouseEnter, onMouseLeave, value]);
5610
5696
  };
5611
5697
 
5698
+ /**
5699
+ * Differentiate between the first and subsequent renders.
5700
+ *
5701
+ * @returns {boolean} Returns true if it is the first render, false otherwise.
5702
+ */
5703
+ const useIsFirstRender = () => {
5704
+ const [isFirstRender, setIsFirstRender] = useState(true);
5705
+ useLayoutEffect(() => {
5706
+ queueMicrotask(() => {
5707
+ setIsFirstRender(false);
5708
+ });
5709
+ }, []);
5710
+ return isFirstRender;
5711
+ };
5712
+
5612
5713
  /**
5613
5714
  * Custom hook for checking if the browser is in fullscreen mode.
5614
5715
  */
@@ -5926,40 +6027,6 @@ function useTextSearch(items, props) {
5926
6027
  return useMemo(() => [result, searchText, setSearchText], [result, searchText, setSearchText]);
5927
6028
  }
5928
6029
 
5929
- const UNINITIALIZED = Symbol("UNINITIALIZED");
5930
- /**
5931
- * Hook to watch for changes in a value and react to them.
5932
- * Uses deep equality comparison via es-toolkit's isEqual.
5933
- *
5934
- * @param props - The hook properties
5935
- * @param props.value - The value to watch for changes
5936
- * @param props.onChange - Function to call when the value changes
5937
- * @param props.immediate - Whether to run the callback immediately on mount (default: false)
5938
- * @param props.skip - Whether to skip watching for changes (default: false)
5939
- */
5940
- const useWatch = ({ value, onChange, immediate = false, skip = false }) => {
5941
- const prevValue = useRef(UNINITIALIZED);
5942
- const onChangeRef = useRef(onChange);
5943
- // Update the ref whenever onChange changes
5944
- useEffect(() => {
5945
- onChangeRef.current = onChange;
5946
- }, [onChange]);
5947
- useEffect(() => {
5948
- if (skip) {
5949
- return;
5950
- }
5951
- const prev = prevValue.current;
5952
- const hasChanged = prev === UNINITIALIZED ? false : !isEqual(value, prev);
5953
- if (immediate && prev === UNINITIALIZED) {
5954
- onChangeRef.current(value, null);
5955
- }
5956
- else if (hasChanged && prev !== UNINITIALIZED) {
5957
- onChangeRef.current(value, prev);
5958
- }
5959
- prevValue.current = value;
5960
- }, [value, immediate, skip]);
5961
- };
5962
-
5963
6030
  const hasFocus = () => typeof document !== "undefined" && document.hasFocus();
5964
6031
  /**
5965
6032
  * Use this hook to disable functionality while the tab is hidden within the browser or to react to focus or blur events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.10.94",
3
+ "version": "1.10.96",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -14,10 +14,10 @@
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.7.118",
18
- "@trackunit/css-class-variance-utilities": "1.7.118",
19
- "@trackunit/shared-utils": "1.9.118",
20
- "@trackunit/ui-icons": "1.7.119",
17
+ "@trackunit/ui-design-tokens": "1.7.119",
18
+ "@trackunit/css-class-variance-utilities": "1.7.119",
19
+ "@trackunit/shared-utils": "1.9.119",
20
+ "@trackunit/ui-icons": "1.7.120",
21
21
  "@tanstack/react-router": "1.114.29",
22
22
  "es-toolkit": "^1.39.10",
23
23
  "@tanstack/react-virtual": "3.13.12",