@pelatform/ui.hook 0.1.3 → 0.2.1

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Version](https://img.shields.io/npm/v/@pelatform/ui.hook.svg)](https://www.npmjs.com/package/@pelatform/ui.hook)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- A collection of production-ready React hooks for the Pelatform UI Library. This package provides 15 reusable hooks for analytics, responsive design, form handling, navigation, DOM management, and more.
6
+ A collection of production-ready React hooks for the Pelatform UI Library. This package provides 18 reusable hooks for analytics, responsive design, form handling, navigation, DOM management, and more.
7
7
 
8
8
  ## Installation
9
9
 
@@ -67,6 +67,37 @@ import { useViewport } from "@pelatform/ui.hook";
67
67
  const [height, width] = useViewport();
68
68
  ```
69
69
 
70
+ #### `useIntersectionObserver`
71
+
72
+ Observe element intersection with viewport using Intersection Observer API.
73
+
74
+ ```typescript
75
+ import { useIntersectionObserver } from "@pelatform/ui.hook";
76
+
77
+ const ref = useRef<HTMLDivElement>(null);
78
+ const { isIntersecting, entry } = useIntersectionObserver(ref, {
79
+ threshold: 0.5,
80
+ triggerOnce: true,
81
+ });
82
+
83
+ <div ref={ref}>
84
+ {isIntersecting ? "Visible!" : "Not visible"}
85
+ </div>
86
+ ```
87
+
88
+ #### `useIsMac`
89
+
90
+ Detect if the user's operating system is macOS.
91
+
92
+ ```typescript
93
+ import { useIsMac } from "@pelatform/ui.hook";
94
+
95
+ const isMac = useIsMac();
96
+
97
+ // Useful for keyboard shortcut hints (⌘ vs Ctrl)
98
+ <div>Press {isMac ? "⌘" : "Ctrl"} + K to open command menu</div>
99
+ ```
100
+
70
101
  ### Form & Input Management
71
102
 
72
103
  #### `useFileUpload`
@@ -151,14 +182,14 @@ const elementScroll = useScrollPosition({ targetRef: myRef });
151
182
 
152
183
  ### DOM Management
153
184
 
154
- #### `useBodyClasses`
185
+ #### `useBodyClass`
155
186
 
156
187
  Dynamically add/remove CSS classes from the document body element.
157
188
 
158
189
  ```typescript
159
- import { useBodyClasses } from "@pelatform/ui.hook";
190
+ import { useBodyClass } from "@pelatform/ui.hook";
160
191
 
161
- useBodyClasses("dark-theme overflow-hidden");
192
+ useBodyClass("dark-theme overflow-hidden");
162
193
  ```
163
194
 
164
195
  #### `useMutationObserver`
@@ -245,7 +276,8 @@ useRemoveGAParams(); // Cleans URL after GA processes linker attribution
245
276
  - **Responsive Design**: `useMediaQuery`, `useIsMobile`, `useViewport`
246
277
  - **Form Management**: `useFileUpload`, `useSliderInput`, `useCopyToClipboard`
247
278
  - **Navigation**: `useMenu`, `useScrollPosition`
248
- - **DOM Interaction**: `useMutationObserver`, `useBodyClasses`
279
+ - **DOM Interaction**: `useMutationObserver`, `useBodyClass`, `useIntersectionObserver`
280
+ - **Platform Detection**: `useIsMac`
249
281
  - **Security**: `useRecaptchaV2`
250
282
  - **SSR Safety**: `useMounted`, `useHydrated`, `useRemoveGAParams`
251
283
 
@@ -255,12 +287,14 @@ useRemoveGAParams(); // Cleans URL after GA processes linker attribution
255
287
 
256
288
  - `useMounted`
257
289
  - `useHydrated`
258
- - `useBodyClasses`
290
+ - `useBodyClass`
259
291
  - `useRemoveGAParams`
260
292
  - `useMediaQuery`
261
293
  - `useIsMobile`
294
+ - `useIsMac`
262
295
  - `useViewport`
263
296
  - `useScrollPosition`
297
+ - `useIntersectionObserver`
264
298
 
265
299
  **Moderate** (State Management):
266
300
 
package/dist/index.d.ts CHANGED
@@ -421,6 +421,91 @@ declare const formatBytes: (bytes: number, decimals?: number) => string;
421
421
  */
422
422
  declare function useHydrated(): boolean;
423
423
 
424
+ /**
425
+ * Intersection observer hook for React components
426
+ * Efficiently observes visibility of DOM elements using a shared IntersectionObserver instance.
427
+ * Ideal for lazy loading, infinite scrolling, and animating elements when they enter the viewport.
428
+ */
429
+
430
+ /**
431
+ * Configuration options for the `useIntersectionObserver` hook.
432
+ *
433
+ * Extends the native `IntersectionObserverInit` options with an additional
434
+ * convenience flag for freezing the observer state.
435
+ */
436
+ interface IntersectionObserverOptions extends IntersectionObserverInit {
437
+ /**
438
+ * When `true`, the hook stops observing once the element becomes visible
439
+ * and keeps returning `true` for subsequent renders.
440
+ *
441
+ * This is useful for one-time animations or lazy loading where you only
442
+ * care about the first time an element enters the viewport.
443
+ */
444
+ freezeOnceVisible?: boolean;
445
+ }
446
+ /**
447
+ * React hook that tracks whether a DOM element is currently intersecting the viewport.
448
+ *
449
+ * Features:
450
+ * - Uses a shared `IntersectionObserver` instance for better performance.
451
+ * - Supports custom `threshold`, `root`, and `rootMargin` options.
452
+ * - Optional `freezeOnceVisible` flag to stop observing after first intersection.
453
+ *
454
+ * @param elementRef - React ref pointing to the DOM element to observe.
455
+ * @param options - Optional observer configuration and behavior flags.
456
+ * @param options.threshold - Intersection threshold(s) for triggering visibility changes.
457
+ * @param options.root - Scrollable ancestor element to use as the viewport (defaults to browser viewport).
458
+ * @param options.rootMargin - Margin around the root, expressed in CSS units (e.g. `"0px 0px -20% 0px"`).
459
+ * @param options.freezeOnceVisible - When `true`, stops observing after the element first becomes visible.
460
+ *
461
+ * @returns `true` when the element is intersecting based on the given options, otherwise `false`.
462
+ *
463
+ * @example
464
+ * ```tsx
465
+ * const ref = React.useRef<HTMLDivElement | null>(null);
466
+ * const isVisible = useIntersectionObserver(ref, {
467
+ * threshold: 0.2,
468
+ * rootMargin: "0px 0px -10% 0px",
469
+ * freezeOnceVisible: true,
470
+ * });
471
+ *
472
+ * return (
473
+ * <div ref={ref} className={isVisible ? "animate-in" : "opacity-0"}>
474
+ * I will animate when I enter the viewport.
475
+ * </div>
476
+ * );
477
+ * ```
478
+ */
479
+ declare function useIntersectionObserver(elementRef: React.RefObject<Element | null>, { threshold, root, rootMargin, freezeOnceVisible, }?: IntersectionObserverOptions): boolean;
480
+
481
+ /**
482
+ * Platform detection hook for React components
483
+ * Determines whether the current user agent is running on a macOS device.
484
+ * Useful for rendering platform-specific keyboard shortcuts or UI variations.
485
+ */
486
+ /**
487
+ * React hook that returns whether the current platform is macOS.
488
+ *
489
+ * This hook:
490
+ * - Runs only on the client (browser) side.
491
+ * - Checks `navigator.platform` and normalizes the value to uppercase.
492
+ * - Returns a boolean indicating if the platform contains `"MAC"`.
493
+ *
494
+ * @returns `true` if the current platform is macOS, otherwise `false`.
495
+ *
496
+ * @example
497
+ * ```tsx
498
+ * const isMac = useIsMac();
499
+ *
500
+ * return (
501
+ * <kbd>
502
+ * {isMac ? "⌘" : "Ctrl"} + K
503
+ * </kbd>
504
+ * );
505
+ * ```
506
+ */
507
+ declare function useIsMac(): boolean;
508
+
424
509
  /**
425
510
  * Media query hook for responsive React components
426
511
  * Provides real-time tracking of CSS media query matches
@@ -1108,4 +1193,4 @@ type ViewportDimensions = [number, number];
1108
1193
  */
1109
1194
  declare const useViewport: () => ViewportDimensions;
1110
1195
 
1111
- export { type FileMetadata, type FileUploadActions, type FileUploadOptions, type FileUploadState, type FileWithPreview, type GtagWindow, formatBytes, useAnalytics, useBodyClasses, useCopyToClipboard, useFileUpload, useHydrated, useIsMobile, useMediaQuery, useMenu, useMounted, useMutationObserver, useRecaptchaV2, useRemoveGAParams, useScrollPosition, useSliderInput, useViewport };
1196
+ export { type FileMetadata, type FileUploadActions, type FileUploadOptions, type FileUploadState, type FileWithPreview, type GtagWindow, formatBytes, useAnalytics, useBodyClasses, useCopyToClipboard, useFileUpload, useHydrated, useIntersectionObserver, useIsMac, useIsMobile, useMediaQuery, useMenu, useMounted, useMutationObserver, useRecaptchaV2, useRemoveGAParams, useScrollPosition, useSliderInput, useViewport };
package/dist/index.js CHANGED
@@ -413,7 +413,7 @@ var formatBytes = (bytes, decimals = 2) => {
413
413
  return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i];
414
414
  };
415
415
 
416
- // src/use-hydrated.tsx
416
+ // src/use-hydrated.ts
417
417
  import { useSyncExternalStore } from "react";
418
418
  function useHydrated() {
419
419
  return useSyncExternalStore(
@@ -427,8 +427,88 @@ function subscribe() {
427
427
  };
428
428
  }
429
429
 
430
+ // src/use-intersection-observer.ts
431
+ import * as React2 from "react";
432
+ var SharedObserver = class {
433
+ observer = null;
434
+ callbacks = /* @__PURE__ */ new Map();
435
+ options;
436
+ constructor(options) {
437
+ this.options = options;
438
+ }
439
+ getObserver() {
440
+ if (!this.observer) {
441
+ this.observer = new IntersectionObserver((entries) => {
442
+ entries.forEach((entry) => {
443
+ const callback = this.callbacks.get(entry.target);
444
+ if (callback) {
445
+ callback(entry.isIntersecting);
446
+ }
447
+ });
448
+ }, this.options);
449
+ }
450
+ return this.observer;
451
+ }
452
+ observe(element, callback) {
453
+ this.callbacks.set(element, callback);
454
+ this.getObserver().observe(element);
455
+ }
456
+ unobserve(element) {
457
+ this.callbacks.delete(element);
458
+ this.observer?.unobserve(element);
459
+ }
460
+ disconnect() {
461
+ this.observer?.disconnect();
462
+ this.callbacks.clear();
463
+ this.observer = null;
464
+ }
465
+ };
466
+ var observers = /* @__PURE__ */ new Map();
467
+ function getSharedObserver(options) {
468
+ const key = JSON.stringify(options);
469
+ if (!observers.has(key)) {
470
+ observers.set(key, new SharedObserver(options));
471
+ }
472
+ return observers.get(key);
473
+ }
474
+ function useIntersectionObserver(elementRef, {
475
+ threshold = 0,
476
+ root = null,
477
+ rootMargin = "0%",
478
+ freezeOnceVisible = false
479
+ } = {}) {
480
+ const [isIntersecting, setIntersecting] = React2.useState(false);
481
+ const frozen = React2.useRef(false);
482
+ React2.useEffect(() => {
483
+ const element = elementRef?.current;
484
+ if (!element || freezeOnceVisible && frozen.current) return;
485
+ const observer = getSharedObserver({ threshold, root, rootMargin });
486
+ observer.observe(element, (intersecting) => {
487
+ setIntersecting(intersecting);
488
+ if (intersecting && freezeOnceVisible) {
489
+ frozen.current = true;
490
+ observer.unobserve(element);
491
+ }
492
+ });
493
+ return () => {
494
+ observer.unobserve(element);
495
+ };
496
+ }, [elementRef, threshold, root, rootMargin, freezeOnceVisible]);
497
+ return isIntersecting;
498
+ }
499
+
500
+ // src/use-is-mac.ts
501
+ import { useEffect as useEffect3, useState as useState4 } from "react";
502
+ function useIsMac() {
503
+ const [isMac, setIsMac] = useState4(true);
504
+ useEffect3(() => {
505
+ setIsMac(navigator.platform.toUpperCase().includes("MAC"));
506
+ }, []);
507
+ return isMac;
508
+ }
509
+
430
510
  // src/use-media-query.ts
431
- import { useEffect as useEffect2, useState as useState3 } from "react";
511
+ import { useEffect as useEffect4, useState as useState5 } from "react";
432
512
  var getMatches = (query) => {
433
513
  if (typeof window !== "undefined") {
434
514
  return window.matchMedia(query).matches;
@@ -436,8 +516,8 @@ var getMatches = (query) => {
436
516
  return false;
437
517
  };
438
518
  var useMediaQuery = (query) => {
439
- const [matches, setMatches] = useState3(getMatches(query));
440
- useEffect2(() => {
519
+ const [matches, setMatches] = useState5(getMatches(query));
520
+ useEffect4(() => {
441
521
  function handleChange() {
442
522
  setMatches(getMatches(query));
443
523
  }
@@ -543,12 +623,12 @@ var useMenu = (pathname) => {
543
623
  };
544
624
  };
545
625
 
546
- // src/use-mobile.ts
547
- import * as React2 from "react";
626
+ // src/use-is-mobile.ts
627
+ import * as React3 from "react";
548
628
  var DEFAULT_MOBILE_BREAKPOINT = 1024;
549
629
  function useIsMobile(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
550
- const [isMobile, setIsMobile] = React2.useState(void 0);
551
- React2.useEffect(() => {
630
+ const [isMobile, setIsMobile] = React3.useState(void 0);
631
+ React3.useEffect(() => {
552
632
  const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
553
633
  const onChange = () => {
554
634
  setIsMobile(window.innerWidth < breakpoint);
@@ -561,17 +641,17 @@ function useIsMobile(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
561
641
  }
562
642
 
563
643
  // src/use-mounted.ts
564
- import * as React3 from "react";
644
+ import * as React4 from "react";
565
645
  function useMounted() {
566
- const [mounted, setMounted] = React3.useState(false);
567
- React3.useEffect(() => {
646
+ const [mounted, setMounted] = React4.useState(false);
647
+ React4.useEffect(() => {
568
648
  setMounted(true);
569
649
  }, []);
570
650
  return mounted;
571
651
  }
572
652
 
573
653
  // src/use-mutation-observer.ts
574
- import * as React4 from "react";
654
+ import * as React5 from "react";
575
655
  var DEFAULT_OPTIONS = {
576
656
  /** Watch for attribute changes */
577
657
  attributes: true,
@@ -583,7 +663,7 @@ var DEFAULT_OPTIONS = {
583
663
  subtree: true
584
664
  };
585
665
  var useMutationObserver = (ref, callback, options = DEFAULT_OPTIONS) => {
586
- React4.useEffect(() => {
666
+ React5.useEffect(() => {
587
667
  if (ref.current) {
588
668
  const observer = new MutationObserver(callback);
589
669
  observer.observe(ref.current, options);
@@ -595,7 +675,7 @@ var useMutationObserver = (ref, callback, options = DEFAULT_OPTIONS) => {
595
675
  };
596
676
 
597
677
  // src/use-recaptcha-v2.ts
598
- import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef2 } from "react";
678
+ import { useCallback as useCallback4, useEffect as useEffect8, useRef as useRef3 } from "react";
599
679
  var RECAPTCHA_SCRIPT_ID = "recaptcha-v2-script";
600
680
  var scriptLoadPromise = null;
601
681
  function loadRecaptchaScript() {
@@ -622,10 +702,10 @@ function loadRecaptchaScript() {
622
702
  return scriptLoadPromise;
623
703
  }
624
704
  function useRecaptchaV2(siteKey) {
625
- const widgetId = useRef2(null);
626
- const containerRef = useRef2(null);
627
- const isRendered = useRef2(false);
628
- const isInitializing = useRef2(false);
705
+ const widgetId = useRef3(null);
706
+ const containerRef = useRef3(null);
707
+ const isRendered = useRef3(false);
708
+ const isInitializing = useRef3(false);
629
709
  const initializeRecaptcha = useCallback4(async () => {
630
710
  if (isInitializing.current) return;
631
711
  if (!containerRef.current || !siteKey) return;
@@ -667,7 +747,7 @@ function useRecaptchaV2(siteKey) {
667
747
  isInitializing.current = false;
668
748
  }
669
749
  }, [siteKey]);
670
- useEffect6(() => {
750
+ useEffect8(() => {
671
751
  if (containerRef.current) {
672
752
  initializeRecaptcha();
673
753
  }
@@ -719,9 +799,9 @@ function useRecaptchaV2(siteKey) {
719
799
  }
720
800
 
721
801
  // src/use-remove-ga-params.ts
722
- import { useEffect as useEffect7 } from "react";
802
+ import { useEffect as useEffect9 } from "react";
723
803
  function useRemoveGAParams() {
724
- useEffect7(() => {
804
+ useEffect9(() => {
725
805
  const url = new URL(window.location.href);
726
806
  if (url.searchParams.has("_gl")) {
727
807
  const timer = setTimeout(() => {
@@ -734,10 +814,10 @@ function useRemoveGAParams() {
734
814
  }
735
815
 
736
816
  // src/use-scroll-position.ts
737
- import { useEffect as useEffect8, useState as useState6 } from "react";
817
+ import { useEffect as useEffect10, useState as useState8 } from "react";
738
818
  var useScrollPosition = ({ targetRef } = {}) => {
739
- const [scrollPosition, setScrollPosition] = useState6(0);
740
- useEffect8(() => {
819
+ const [scrollPosition, setScrollPosition] = useState8(0);
820
+ useEffect10(() => {
741
821
  const target = targetRef?.current || document;
742
822
  const scrollable = target === document ? window : target;
743
823
  const updatePosition = () => {
@@ -754,10 +834,10 @@ var useScrollPosition = ({ targetRef } = {}) => {
754
834
  };
755
835
 
756
836
  // src/use-slider-input.ts
757
- import { useCallback as useCallback5, useState as useState7 } from "react";
837
+ import { useCallback as useCallback5, useState as useState9 } from "react";
758
838
  function useSliderInput({ minValue, maxValue, initialValue }) {
759
- const [sliderValues, setSliderValues] = useState7(initialValue);
760
- const [inputValues, setInputValues] = useState7(initialValue);
839
+ const [sliderValues, setSliderValues] = useState9(initialValue);
840
+ const [inputValues, setInputValues] = useState9(initialValue);
761
841
  const handleSliderChange = useCallback5((values) => {
762
842
  setSliderValues(values);
763
843
  setInputValues(values);
@@ -805,15 +885,15 @@ function useSliderInput({ minValue, maxValue, initialValue }) {
805
885
  }
806
886
 
807
887
  // src/use-viewport.ts
808
- import { useEffect as useEffect9, useState as useState8 } from "react";
888
+ import { useEffect as useEffect11, useState as useState10 } from "react";
809
889
  var useViewport = () => {
810
- const [dimensions, setDimensions] = useState8(() => {
890
+ const [dimensions, setDimensions] = useState10(() => {
811
891
  if (typeof window !== "undefined") {
812
892
  return [window.innerHeight, window.innerWidth];
813
893
  }
814
894
  return [0, 0];
815
895
  });
816
- useEffect9(() => {
896
+ useEffect11(() => {
817
897
  const handleResize = () => {
818
898
  setDimensions([window.innerHeight, window.innerWidth]);
819
899
  };
@@ -832,6 +912,8 @@ export {
832
912
  useCopyToClipboard,
833
913
  useFileUpload,
834
914
  useHydrated,
915
+ useIntersectionObserver,
916
+ useIsMac,
835
917
  useIsMobile,
836
918
  useMediaQuery,
837
919
  useMenu,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pelatform/ui.hook",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Hook components of the Pelatform UI Library.",
5
5
  "author": "Pelatform",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  ],
40
40
  "devDependencies": {
41
41
  "@pelatform/tsconfig": "^0.1.4",
42
- "@types/node": "^25.0.9",
43
- "@types/react": "^19.2.8",
44
- "react": "^19.2.3",
42
+ "@types/node": "^25.3.3",
43
+ "@types/react": "^19.2.14",
44
+ "react": "^19.2.4",
45
45
  "tsup": "^8.5.1",
46
46
  "typescript": "^5.9.3"
47
47
  },