@donotdev/components 0.0.5 → 0.0.7

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.
@@ -106,13 +106,13 @@ const Button = ({ className, variant, render, display = DISPLAY.AUTO, icon, icon
106
106
  // Render prop pattern - no cloneElement needed (React 19 compatible)
107
107
  const buttonElement = render ? (render(elementProps)) : (_jsx("button", { type: props.type || 'button', role: "button", tabIndex: 0, ...elementProps, children: buttonContent }));
108
108
  // Tooltip: COMPACT/AUTO (may be icon-only when collapsed), others only if explicit
109
- // Default to bottom for buttons (overridden by CSS --tooltip-side in sidebars)
109
+ // Don't specify side - let CSS --tooltip-side take priority (RTL-aware in sidebars)
110
110
  if (effectiveDisplay === DISPLAY.COMPACT ||
111
111
  effectiveDisplay === DISPLAY.AUTO) {
112
- return (_jsx(Tooltip, { content: tooltip || getAriaLabel() || 'Button', side: "bottom", children: buttonElement }));
112
+ return (_jsx(Tooltip, { content: tooltip || getAriaLabel() || 'Button', children: buttonElement }));
113
113
  }
114
114
  if (tooltip) {
115
- return (_jsx(Tooltip, { content: tooltip, side: "bottom", children: buttonElement }));
115
+ return (_jsx(Tooltip, { content: tooltip, children: buttonElement }));
116
116
  }
117
117
  return buttonElement;
118
118
  };
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * @fileoverview Tooltip component
3
- * @description Accessible tooltip component built on Radix UI primitives.
3
+ * @description Accessible tooltip built on Radix UI primitives.
4
4
  *
5
- * @version 0.0.3
5
+ * Positioning: prop > CSS --tooltip-side > 'bottom'
6
+ * Smart hiding: Tooltip auto-hides when trigger has visible label
7
+ *
8
+ * @version 0.0.6
6
9
  * @since 0.0.1
7
10
  * @author AMBROISE PARK Consulting
8
11
  */
@@ -13,36 +16,25 @@ type TooltipSide = 'top' | 'right' | 'bottom' | 'left';
13
16
  export interface TooltipProps {
14
17
  content: string | ReactNode;
15
18
  children: ReactNode;
16
- /**
17
- * Tooltip position. If not set, reads from CSS `--tooltip-side` property,
18
- * then falls back to Radix auto-positioning.
19
- */
19
+ /** Tooltip position. Priority: prop > CSS --tooltip-side > 'bottom' */
20
20
  side?: TooltipSide;
21
21
  align?: 'start' | 'center' | 'end';
22
22
  delayDuration?: number;
23
23
  variant?: FloatingVariant;
24
24
  }
25
25
  /**
26
- * Accessible tooltip component built on Radix UI primitives.
27
- *
28
- * Positioning priority:
29
- * 1. Explicit `side` prop
30
- * 2. CSS `--tooltip-side` property (set by container like Sidebar)
31
- * 3. Radix auto-positioning (default)
26
+ * Accessible tooltip component.
32
27
  *
33
- * SSR Safety:
34
- * During SSR/static generation (Next.js prerendering), TooltipProvider may not
35
- * be available. This component gracefully degrades by rendering just children
36
- * during SSR, then hydrating with full tooltip functionality on client.
28
+ * Auto-hides when trigger has visible label (e.g., button not in compact mode).
29
+ * Only shows tooltip when label is hidden, avoiding redundant information.
37
30
  *
38
31
  * @example
39
- * ```css
40
- * .sidebar { --tooltip-side: right; }
41
- * [dir='rtl'] .sidebar { --tooltip-side: left; }
42
- * ```
32
+ * // Explicit side
33
+ * <Tooltip content="Help" side="right">...</Tooltip>
43
34
  *
44
- * @param {TooltipProps} props - Tooltip props
45
- * @returns {JSX.Element} The rendered tooltip
35
+ * @example
36
+ * // Container-aware (sidebar sets --tooltip-side: right)
37
+ * <Tooltip content="Help">...</Tooltip>
46
38
  */
47
39
  declare const Tooltip: ({ content, children, side, align, delayDuration, variant, }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
48
40
  export default Tooltip;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/atomic/Tooltip/index.tsx"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,OAAyB,EAGvB,eAAe,EAChB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAI/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,KAAK,WAAW,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,EAAE,SAAS,CAAC;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,eAAe,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,QAAA,MAAM,OAAO,GAAI,6DAOd,YAAY,4CA+Dd,CAAC;AAEF,eAAe,OAAO,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/atomic/Tooltip/index.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AAEH,OAAyB,EAGvB,eAAe,EAChB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAI/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,KAAK,WAAW,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,EAAE,SAAS,CAAC;IACpB,uEAAuE;IACvE,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,eAAe,CAAC;CAC3B;AAYD;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,OAAO,GAAI,6DAOd,YAAY,4CAoDd,CAAC;AAEF,eAAe,OAAO,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,CAAC"}
@@ -1,83 +1,78 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // packages/components/src/atomic/Tooltip/index.tsx
3
3
  /**
4
4
  * @fileoverview Tooltip component
5
- * @description Accessible tooltip component built on Radix UI primitives.
5
+ * @description Accessible tooltip built on Radix UI primitives.
6
6
  *
7
- * @version 0.0.3
7
+ * Positioning: prop > CSS --tooltip-side > 'bottom'
8
+ * Smart hiding: Tooltip auto-hides when trigger has visible label
9
+ *
10
+ * @version 0.0.6
8
11
  * @since 0.0.1
9
12
  * @author AMBROISE PARK Consulting
10
13
  */
11
14
  import TooltipPrimitive, { TooltipTrigger, TooltipContent, TooltipProvider, } from './TooltipPrimitive';
12
15
  import { FLOATING_VARIANT } from '../../utils/constants';
13
16
  import { getVariantDataAttrs } from '../../utils/helpers';
14
- import { useRef, useState, useEffect } from 'react';
17
+ import { useCallback, useRef, useState } from 'react';
15
18
  /**
16
- * Accessible tooltip component built on Radix UI primitives.
17
- *
18
- * Positioning priority:
19
- * 1. Explicit `side` prop
20
- * 2. CSS `--tooltip-side` property (set by container like Sidebar)
21
- * 3. Radix auto-positioning (default)
19
+ * Check if trigger element has a visible label.
20
+ * Returns true if label exists and has width (not hidden).
21
+ */
22
+ function hasVisibleLabel(element) {
23
+ if (!element)
24
+ return false;
25
+ const label = element.querySelector('.dndev-interactive-label');
26
+ return label instanceof HTMLElement && label.offsetWidth > 0;
27
+ }
28
+ /**
29
+ * Accessible tooltip component.
22
30
  *
23
- * SSR Safety:
24
- * During SSR/static generation (Next.js prerendering), TooltipProvider may not
25
- * be available. This component gracefully degrades by rendering just children
26
- * during SSR, then hydrating with full tooltip functionality on client.
31
+ * Auto-hides when trigger has visible label (e.g., button not in compact mode).
32
+ * Only shows tooltip when label is hidden, avoiding redundant information.
27
33
  *
28
34
  * @example
29
- * ```css
30
- * .sidebar { --tooltip-side: right; }
31
- * [dir='rtl'] .sidebar { --tooltip-side: left; }
32
- * ```
35
+ * // Explicit side
36
+ * <Tooltip content="Help" side="right">...</Tooltip>
33
37
  *
34
- * @param {TooltipProps} props - Tooltip props
35
- * @returns {JSX.Element} The rendered tooltip
38
+ * @example
39
+ * // Container-aware (sidebar sets --tooltip-side: right)
40
+ * <Tooltip content="Help">...</Tooltip>
36
41
  */
37
42
  const Tooltip = ({ content, children, side, align = 'center', delayDuration = 300, variant, }) => {
38
- const triggerRef = useRef(null);
39
- const [computedSide, setComputedSide] = useState(side);
40
- // SSR safety: Track if component has mounted on client
41
- // During SSR/static generation, render just children to avoid TooltipProvider dependency
42
- const [isMounted, setIsMounted] = useState(false);
43
- useEffect(() => {
44
- setIsMounted(true);
45
- }, []);
46
- // Read CSS custom property after mount (CSR only, SSR-safe)
47
- // Priority: CSS --tooltip-side > explicit side prop > Radix auto
48
- useEffect(() => {
49
- // SSR guard + read CSS property first (highest priority)
50
- if (typeof window !== 'undefined' && triggerRef.current) {
51
- const cssValue = getComputedStyle(triggerRef.current)
43
+ const [mounted, setMounted] = useState(false);
44
+ const [cssSide, setCssSide] = useState(null);
45
+ const [open, setOpen] = useState(false);
46
+ const triggerElement = useRef(null);
47
+ // Callback ref - fires when element mounts, reads CSS var immediately
48
+ const triggerRef = useCallback((node) => {
49
+ triggerElement.current = node;
50
+ if (node) {
51
+ setMounted(true);
52
+ const val = getComputedStyle(node)
52
53
  .getPropertyValue('--tooltip-side')
53
54
  .trim();
54
- if (cssValue) {
55
- setComputedSide(cssValue);
56
- return;
57
- }
55
+ if (val)
56
+ setCssSide(val);
58
57
  }
59
- // Fall back to explicit side prop if no CSS property
60
- if (side !== undefined) {
61
- setComputedSide(side);
62
- return;
58
+ }, []);
59
+ // Handle open change - skip if label is visible
60
+ const handleOpenChange = useCallback((nextOpen) => {
61
+ if (!mounted)
62
+ return; // SSR: no-op
63
+ if (nextOpen && hasVisibleLabel(triggerElement.current)) {
64
+ return; // Label visible, don't show tooltip
63
65
  }
64
- // No side specified - Radix will auto-position
65
- setComputedSide(undefined);
66
- }, [side]);
67
- // During SSR/static generation, render just children
68
- // This prevents TooltipProvider requirement during prerendering
69
- // After hydration, full tooltip functionality is available
70
- if (!isMounted) {
71
- return _jsx(_Fragment, { children: children });
72
- }
66
+ setOpen(nextOpen);
67
+ }, [mounted]);
68
+ // Priority: prop > CSS > 'bottom'
69
+ const finalSide = side ?? cssSide ?? 'bottom';
73
70
  const variantAttrs = getVariantDataAttrs({
74
71
  variant: variant !== FLOATING_VARIANT.DEFAULT ? variant : undefined,
75
72
  });
76
- // Callback ref to capture trigger element (React 19 compatible)
77
- const setTriggerRef = (node) => {
78
- triggerRef.current = node;
79
- };
80
- return (_jsxs(TooltipPrimitive, { delayDuration: delayDuration, children: [_jsx(TooltipTrigger, { asChild: true, ref: setTriggerRef, children: children }), _jsx(TooltipContent, { side: computedSide, align: align, ...variantAttrs, children: content })] }));
73
+ // Always pass open/onOpenChange to avoid controlled/uncontrolled switch
74
+ // During SSR (!mounted), open=false keeps it controlled but closed
75
+ return (_jsxs(TooltipPrimitive, { delayDuration: delayDuration, open: mounted ? open : false, onOpenChange: handleOpenChange, children: [_jsx(TooltipTrigger, { asChild: true, ref: triggerRef, children: children }), mounted && (_jsx(TooltipContent, { side: finalSide, align: align, ...variantAttrs, children: content }))] }));
81
76
  };
82
77
  export default Tooltip;
83
78
  export { TooltipProvider };
@@ -98,73 +98,6 @@ export interface VideoPlayerProps {
98
98
  */
99
99
  allowFullscreen?: boolean;
100
100
  }
101
- /**
102
- * Video player component with lazy-loading facade pattern for optimal Lighthouse scores.
103
- *
104
- * **Lazy-Loading Behavior:**
105
- * - Shows thumbnail facade initially (zero iframe resources)
106
- * - Lazy-loads iframe only on user click/interaction
107
- * - Works for both inline (`modal={false}`) and modal modes
108
- *
109
- * **Thumbnail Priority:**
110
- * 1. Custom `thumbnail` prop (if provided)
111
- * 2. Auto-generated YouTube thumbnail (if `url` is VideoConfig with `platform: 'youtube'`)
112
- * 3. Button fallback (if no thumbnail available)
113
- *
114
- * **URL Formats:**
115
- * - **String URL**: Full embed URL as-is. Requires manual `thumbnail` prop.
116
- * - **VideoConfig Object**: Framework constructs privacy-first URL. Auto-generates YouTube thumbnails.
117
- *
118
- * @component
119
- * @example
120
- * ```tsx
121
- * // Auto-thumbnail (YouTube VideoConfig) - recommended
122
- * <VideoPlayer
123
- * url={{ platform: 'youtube', id: 'dQw4w9WgXcQ' }}
124
- * title="Tutorial Video"
125
- * modal={false}
126
- * />
127
- * // Thumbnail auto-generated from YouTube
128
- *
129
- * // Custom thumbnail override
130
- * <VideoPlayer
131
- * url={{ platform: 'youtube', id: 'dQw4w9WgXcQ' }}
132
- * thumbnail="/custom-screenshot.jpg"
133
- * title="Tutorial Video"
134
- * modal={false}
135
- * />
136
- * // Uses custom thumbnail instead of auto-generated
137
- *
138
- * // String URL - manual thumbnail required
139
- * <VideoPlayer
140
- * url="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
141
- * thumbnail="/custom-thumb.jpg" // Required
142
- * title="Product Demo"
143
- * modal={false}
144
- * />
145
- *
146
- * // Modal mode (lazy-loads iframe when modal opens)
147
- * <VideoPlayer
148
- * url={{ platform: 'youtube', id: 'dQw4w9WgXcQ' }}
149
- * title="Tutorial Video"
150
- * modal={true} // default
151
- * />
152
- *
153
- * // Above-the-fold (eager load thumbnail for LCP)
154
- * <VideoPlayer
155
- * url={{ platform: 'youtube', id: 'dQw4w9WgXcQ' }}
156
- * eager={true}
157
- * title="Hero Video"
158
- * modal={false}
159
- * />
160
- * ```
161
- * @param {VideoPlayerProps} props - The props for the video player
162
- * @returns {JSX.Element} The rendered video player
163
- *
164
- * @version 0.0.1
165
- * @since 0.0.1
166
- * @author AMBROISE PARK Consulting
167
- */
168
101
  declare const VideoPlayer: ({ url, trigger, thumbnail, eager, title, modal, aspectRatio, className, autoplay, allowFullscreen, }: VideoPlayerProps) => import("react/jsx-runtime").JSX.Element;
169
102
  export default VideoPlayer;
170
103
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/atomic/VideoPlayer/index.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,qBAAqB;IACrB,QAAQ,EAAE,SAAS,GAAG,OAAO,CAAC;IAC9B,eAAe;IACf,EAAE,EAAE,MAAM,CAAC;IACX;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,qFAAqF;IACrF,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wBAAwB;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAiED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,QAAA,MAAM,WAAW,GAAI,sGAWlB,gBAAgB,4CA+IlB,CAAC;AAEF,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/atomic/VideoPlayer/index.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,qBAAqB;IACrB,QAAQ,EAAE,SAAS,GAAG,OAAO,CAAC;IAC9B,eAAe;IACf,EAAE,EAAE,MAAM,CAAC;IACX;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,qFAAqF;IACrF,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wBAAwB;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA+JD,QAAA,MAAM,WAAW,GAAI,sGAWlB,gBAAgB,4CAwJlB,CAAC;AAEF,eAAe,WAAW,CAAC"}
@@ -9,8 +9,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * @author AMBROISE PARK Consulting
10
10
  */
11
11
  import { Play } from 'lucide-react';
12
- import { useState, useEffect } from 'react';
12
+ import { useState, useEffect, useRef } from 'react';
13
13
  import { useTranslation } from '@donotdev/core';
14
+ import { useIntersectionObserver } from '@donotdev/components';
14
15
  import { cn } from '../../utils/helpers';
15
16
  import Button, { BUTTON_VARIANT } from '../Button';
16
17
  import Dialog from '../Dialog';
@@ -62,7 +63,7 @@ const getThumbnailUrl = (urlOrConfig) => {
62
63
  return null;
63
64
  }
64
65
  if (urlOrConfig.platform === 'youtube') {
65
- return `https://img.youtube.com/vi/${urlOrConfig.id}/maxresdefault.jpg`;
66
+ return `https://img.youtube.com/vi/${urlOrConfig.id}/hqdefault.jpg`;
66
67
  }
67
68
  return null;
68
69
  };
@@ -133,13 +134,28 @@ const getThumbnailUrl = (urlOrConfig) => {
133
134
  * @since 0.0.1
134
135
  * @author AMBROISE PARK Consulting
135
136
  */
137
+ /**
138
+ * SVG placeholder for video player - zero bytes, instant render
139
+ * Lazy-loads actual thumbnail on intersection
140
+ */
141
+ const VideoPlaceholder = ({ aspectRatio, className, }) => (_jsxs("svg", { className: cn('dndev-video-placeholder', className), viewBox: "0 0 16 9", style: { aspectRatio, width: '100%', display: 'block' }, preserveAspectRatio: "xMidYMid meet", "aria-hidden": "true", children: [_jsx("rect", { width: "16", height: "9", fill: "var(--muted)" }), _jsx("circle", { cx: "8", cy: "4.5", r: "1.5", fill: "var(--foreground)", opacity: "0.8" }), _jsx("path", { d: "M7 3.5L7 5.5L9 4.5Z", fill: "var(--background)" })] }));
136
142
  const VideoPlayer = ({ url = { platform: 'youtube', id: 'dQw4w9WgXcQ' }, trigger, thumbnail, eager = false, title = 'Video', modal = true, aspectRatio = '16/9', className, autoplay = false, allowFullscreen = true, }) => {
137
143
  const { t } = useTranslation(['dndev']);
138
144
  const [isOpen, setIsOpen] = useState(false);
139
145
  const [isLoaded, setIsLoaded] = useState(false);
140
146
  const [iframeReady, setIframeReady] = useState(false);
147
+ const [thumbnailLoaded, setThumbnailLoaded] = useState(eager);
141
148
  const embedUrl = getEmbedUrl(url, autoplay);
142
149
  const thumbnailUrl = thumbnail || getThumbnailUrl(url);
150
+ // Lazy-load thumbnail on intersection (unless eager)
151
+ const { ref: thumbnailRef, isIntersecting } = useIntersectionObserver({
152
+ threshold: 0.1,
153
+ });
154
+ useEffect(() => {
155
+ if (isIntersecting && thumbnailUrl && !eager && !thumbnailLoaded) {
156
+ setThumbnailLoaded(true);
157
+ }
158
+ }, [isIntersecting, thumbnailUrl, eager, thumbnailLoaded]);
143
159
  // Lazy-load iframe when modal opens
144
160
  useEffect(() => {
145
161
  if (isOpen && !isLoaded) {
@@ -153,18 +169,14 @@ const VideoPlayer = ({ url = { platform: 'youtube', id: 'dQw4w9WgXcQ' }, trigger
153
169
  const videoFrame = (_jsx("iframe", { src: isLoaded ? embedUrl : '', title: title, allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", allowFullScreen: allowFullscreen, className: cn('dndev-video-frame', className), style: { aspectRatio }, onLoad: () => setIframeReady(true) }));
154
170
  // Inline video (no modal) - lazy-load on click
155
171
  if (!modal) {
156
- // Not clicked yet - show thumbnail only
172
+ // Not clicked yet - show thumbnail or placeholder
157
173
  if (!isLoaded) {
158
- if (thumbnailUrl) {
159
- return (_jsxs("button", { type: "button", onClick: () => setIsLoaded(true), onKeyDown: (e) => {
160
- if (e.key === 'Enter' || e.key === ' ') {
161
- e.preventDefault();
162
- setIsLoaded(true);
163
- }
164
- }, className: cn('dndev-video-thumbnail', className), style: { aspectRatio }, "aria-label": t('video.clickToWatch', 'Click to watch video'), children: [_jsx("img", { src: thumbnailUrl, alt: title, loading: eager ? 'eager' : 'lazy', fetchPriority: eager ? 'high' : 'low', decoding: "async" }), _jsx("div", { className: "dndev-video-play-overlay", children: _jsx(Play, { className: "dndev-video-play-icon" }) })] }));
165
- }
166
- // No thumbnail - show button
167
- return (_jsx(Button, { icon: Play, onClick: () => setIsLoaded(true), className: className, children: t('video.watchVideo', 'Watch Video') }));
174
+ return (_jsxs("button", { ref: thumbnailRef, type: "button", onClick: () => setIsLoaded(true), onKeyDown: (e) => {
175
+ if (e.key === 'Enter' || e.key === ' ') {
176
+ e.preventDefault();
177
+ setIsLoaded(true);
178
+ }
179
+ }, className: cn('dndev-video-thumbnail', className), style: { aspectRatio }, "aria-label": t('video.clickToWatch', 'Click to watch video'), children: [thumbnailLoaded && thumbnailUrl ? (_jsx("img", { src: thumbnailUrl, alt: title, loading: "eager", fetchPriority: "high", decoding: "async" })) : (_jsx(VideoPlaceholder, { aspectRatio: aspectRatio })), _jsx("div", { className: "dndev-video-play-overlay", children: _jsx(Play, { className: "dndev-video-play-icon" }) })] }));
168
180
  }
169
181
  // Clicked - show iframe with thumbnail overlay until iframe loads
170
182
  return (_jsxs("div", { className: cn('dndev-video-container', className), style: { aspectRatio, position: 'relative' }, children: [videoFrame, !iframeReady && thumbnailUrl && (_jsxs("div", { className: "dndev-video-thumbnail dndev-video-loading-overlay", style: {
@@ -174,7 +186,7 @@ const VideoPlayer = ({ url = { platform: 'youtube', id: 'dQw4w9WgXcQ' }, trigger
174
186
  }, children: [_jsx("img", { src: thumbnailUrl, alt: title, loading: "eager", decoding: "async" }), _jsx("div", { className: "dndev-video-play-overlay dndev-video-loading", children: _jsx(Spinner, { "aria-label": t('video.loading', 'Loading video') }) })] }))] }));
175
187
  }
176
188
  // Modal mode - lazy-load iframe when modal opens
177
- const defaultTrigger = thumbnailUrl ? (_jsxs("button", { type: "button", className: "dndev-video-thumbnail", style: { aspectRatio }, "aria-label": t('video.clickToWatch', 'Click to watch video'), children: [_jsx("img", { src: thumbnailUrl, alt: title, loading: eager ? 'eager' : 'lazy', fetchPriority: eager ? 'high' : 'low', decoding: "async" }), _jsx("div", { className: "dndev-video-play-overlay", children: _jsx(Play, { className: "dndev-video-play-icon" }) })] })) : (_jsx(Button, { icon: Play, children: t('video.watchVideo', 'Watch Video') }));
189
+ const defaultTrigger = (_jsxs("button", { ref: thumbnailRef, type: "button", className: "dndev-video-thumbnail", style: { aspectRatio }, "aria-label": t('video.clickToWatch', 'Click to watch video'), children: [thumbnailLoaded && thumbnailUrl ? (_jsx("img", { src: thumbnailUrl, alt: title, loading: "eager", fetchPriority: "high", decoding: "async" })) : (_jsx(VideoPlaceholder, { aspectRatio: aspectRatio })), _jsx("div", { className: "dndev-video-play-overlay", children: _jsx(Play, { className: "dndev-video-play-icon" }) })] }));
178
190
  return (_jsx(Dialog, { trigger: trigger || defaultTrigger, title: title, open: isOpen, onOpenChange: setIsOpen, showClose: true, className: "dndev-video-dialog", children: videoFrame }));
179
191
  };
180
192
  export default VideoPlayer;
@@ -6,21 +6,11 @@
6
6
  * @author AMBROISE PARK Consulting
7
7
  */
8
8
  export interface UseIntersectionObserverOptions {
9
- /**
10
- * Threshold for intersection (0-1)
11
- * @default 0.1
12
- */
13
- threshold?: number;
14
- /**
15
- * Root margin for intersection observer
16
- * @default '0px'
17
- */
9
+ threshold?: number | number[];
10
+ root?: Element | null;
18
11
  rootMargin?: string;
19
- /**
20
- * Whether to only trigger once
21
- * @default false
22
- */
23
12
  once?: boolean;
13
+ fallbackIntersecting?: boolean;
24
14
  }
25
15
  /**
26
16
  * Return type for useIntersectionObserver hook
@@ -29,54 +19,82 @@ export interface UseIntersectionObserverOptions {
29
19
  * @since 0.0.1
30
20
  * @author AMBROISE PARK Consulting
31
21
  */
32
- export interface UseIntersectionObserverReturn {
33
- /**
34
- * Ref to attach to the element
35
- */
36
- ref: React.RefObject<HTMLElement | null>;
37
- /**
38
- * Whether the element is intersecting
39
- */
22
+ export interface UseIntersectionObserverReturn<T extends Element = Element> {
23
+ ref: React.RefObject<T | null>;
40
24
  isIntersecting: boolean;
41
- /**
42
- * Whether the element has been triggered at least once
43
- */
44
25
  hasTriggered: boolean;
45
- /**
46
- * The intersection observer entry
47
- */
48
26
  entry: IntersectionObserverEntry | null;
49
27
  }
50
28
  /**
51
- * Hook for intersection observer functionality
29
+ * useIntersectionObserver - Basic Intersection Observation Hook (React 19 Optimized)
52
30
  *
53
- * Provides a simple way to detect when an element enters/exits the viewport.
54
- * Replaces Framer Motion's useInView with our own implementation.
31
+ * Lightweight hook for basic intersection observation with optimal performance.
32
+ * Uses React 19's useSyncExternalStore pattern for automatic cleanup, proper
33
+ * SSR handling, and tearing prevention in concurrent rendering.
55
34
  *
56
- * @example Basic usage
35
+ * @param options - Configuration options for intersection observation
36
+ * @returns Object containing ref, isIntersecting state, and entry
37
+ *
38
+ * @example Basic intersection detection
39
+ * ```tsx
40
+ * function IntersectionComponent() {
41
+ * const { ref, isIntersecting } = useIntersectionObserver();
42
+ *
43
+ * return (
44
+ * <div ref={ref} className={isIntersecting ? 'visible' : 'hidden'}>
45
+ * Content that appears when intersecting
46
+ * </div>
47
+ * );
48
+ * }
49
+ * ```
50
+ *
51
+ * @example With custom threshold
57
52
  * ```tsx
58
- * const { ref, isIntersecting } = useIntersectionObserver({
59
- * threshold: 0.3
60
- * });
53
+ * function ThresholdComponent() {
54
+ * const { ref, isIntersecting } = useIntersectionObserver({
55
+ * threshold: 0.5,
56
+ * rootMargin: '50px'
57
+ * });
61
58
  *
62
- * return (
63
- * <div>
64
- * {isIntersecting ? 'Visible!' : 'Not visible'}
65
- * </div>
66
- * );
59
+ * return (
60
+ * <div ref={ref}>
61
+ * {isIntersecting ? '50% visible' : 'Not visible enough'}
62
+ * </div>
63
+ * );
64
+ * }
67
65
  * ```
68
66
  *
69
- * @example With root margin (equivalent to Framer Motion's margin)
67
+ * @example Once-only triggering (React 19 optimized)
70
68
  * ```tsx
71
- * const { ref, isIntersecting } = useIntersectionObserver({
72
- * threshold: 0.3,
73
- * rootMargin: '-70% 0px -30% 0px' // Trigger when 30% visible
74
- * });
69
+ * function OnceComponent() {
70
+ * const { ref, isIntersecting } = useIntersectionObserver({ once: true });
71
+ *
72
+ * return (
73
+ * <div ref={ref}>
74
+ * {isIntersecting && <ExpensiveComponent />}
75
+ * </div>
76
+ * );
77
+ * }
78
+ * ```
79
+ *
80
+ * @example SSR-safe implementation
81
+ * ```tsx
82
+ * function SSRComponent() {
83
+ * const { ref, isIntersecting } = useIntersectionObserver({
84
+ * fallbackIntersecting: true // Shows content during SSR
85
+ * });
86
+ *
87
+ * return (
88
+ * <div ref={ref}>
89
+ * {isIntersecting ? 'Client-side' : 'SSR fallback'}
90
+ * </div>
91
+ * );
92
+ * }
75
93
  * ```
76
94
  *
77
95
  * @version 0.0.1
78
96
  * @since 0.0.1
79
97
  * @author AMBROISE PARK Consulting
80
98
  */
81
- export declare function useIntersectionObserver(options?: UseIntersectionObserverOptions): UseIntersectionObserverReturn;
99
+ export declare function useIntersectionObserver<T extends Element = Element>(options?: UseIntersectionObserverOptions): UseIntersectionObserverReturn<T>;
82
100
  //# sourceMappingURL=useIntersectionObserver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useIntersectionObserver.d.ts","sourceRoot":"","sources":["../../src/hooks/useIntersectionObserver.ts"],"names":[],"mappings":"AAeA;;;;;;GAMG;AACH,MAAM,WAAW,8BAA8B;IAC7C;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,6BAA6B;IAC5C;;OAEG;IACH,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAEzC;;OAEG;IACH,cAAc,EAAE,OAAO,CAAC;IAExB;;OAEG;IACH,YAAY,EAAE,OAAO,CAAC;IAEtB;;OAEG;IACH,KAAK,EAAE,yBAAyB,GAAG,IAAI,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,GAAE,8BAAmC,GAC3C,6BAA6B,CA4C/B"}
1
+ {"version":3,"file":"useIntersectionObserver.d.ts","sourceRoot":"","sources":["../../src/hooks/useIntersectionObserver.ts"],"names":[],"mappings":"AA2BA;;;;;;GAMG;AACH,MAAM,WAAW,8BAA8B;IAC7C,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,6BAA6B,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO;IACxE,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC/B,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,KAAK,EAAE,yBAAyB,GAAG,IAAI,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,EACjE,OAAO,GAAE,8BAAmC,GAC3C,6BAA6B,CAAC,CAAC,CAAC,CA4ClC"}