homeflowjs 1.0.75 → 1.0.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,13 @@
1
1
  import { useState, useEffect, useRef } from 'react';
2
- import { isFunction } from '../utils';
3
- import useIntersectionObserver from './use-intersection-observer';;
2
+ import { isFunction, createMutationObserver } from '../utils';
3
+ import useIntersectionObserver from './use-intersection-observer';
4
4
 
5
5
  const getTargetElementHelper = (target) => {
6
6
  // If target is chunk ID
7
7
  let targetElement;
8
8
  if (!isNaN(target)) {
9
- targetElement = document.querySelector(
10
- `[data-content-id="${target}"]`,
11
- );
9
+ targetElement = document.querySelector(`[data-content-id="${target}"]`);
10
+ if (!targetElement) return;
12
11
  }
13
12
 
14
13
  // Check for ID or class name
@@ -16,11 +15,20 @@ const getTargetElementHelper = (target) => {
16
15
  if (!targetElement) targetElement = document.querySelector(`#${target}`);
17
16
 
18
17
  return targetElement;
19
- }
18
+ };
19
+
20
+ const scrollToTarget = (currentTarget = {}) => {
21
+ const {
22
+ hash,
23
+ targetElement,
24
+ scrollAmount = 0,
25
+ yOffset = 0,
26
+ additionalYOffset = 0,
27
+ } = currentTarget;
20
28
 
21
- const scrollToTarget = ({ targetElement, yOffset = 0, additionalYOffset = 0 } = {}, signal) => {
22
- if(!targetElement) return;
29
+ if (!targetElement) return;
23
30
 
31
+ // Calculate vertical offset
24
32
  let verticalOffset = 0;
25
33
  if (typeof yOffset === 'number') {
26
34
  verticalOffset = yOffset;
@@ -30,22 +38,33 @@ const scrollToTarget = ({ targetElement, yOffset = 0, additionalYOffset = 0 } =
30
38
  verticalOffset = value;
31
39
  }
32
40
  }
33
- if(additionalYOffset) {
34
- verticalOffset = verticalOffset + parseInt(additionalYOffset, 10);
41
+
42
+ if (additionalYOffset) {
43
+ verticalOffset += parseInt(additionalYOffset, 10);
35
44
  }
36
45
 
37
- /**
38
- * Scroll to the target element
39
- * Note: when using getBoundingClientRect(), the position of the div will
40
- * change whenever you scroll on the page. To get the absolute position of
41
- * the element on the page, not relative to the window,we need to add the
42
- * number of scrolled pixels to the element’s window location using scrollY:
43
- */
44
- const rect = targetElement.getBoundingClientRect();
46
+ // If verticalOffset is negative, add it instead of subtracting
47
+ const scrollTop = verticalOffset < 0
48
+ ? scrollAmount + Math.abs(verticalOffset)
49
+ : scrollAmount - verticalOffset;
50
+
45
51
  window.scroll({
46
- top: rect.top + window.scrollY + verticalOffset,
52
+ top: scrollTop,
53
+ behavior: 'smooth',
54
+ });
55
+
56
+ // Update the URL hash
57
+ if (hash) window.location.hash = hash;
58
+ };
59
+
60
+ /**
61
+ * This is used to continuously scroll to the bottom of page.
62
+ */
63
+ const scrollToBottom = () => {
64
+ window.scroll({
65
+ top: document.body.scrollHeight,
66
+ left: 0,
47
67
  behavior: 'smooth',
48
- ...(signal && { signal: signal }), // This is used to abort the scroll via a trigger in the useIntersectionObserver
49
68
  });
50
69
  };
51
70
 
@@ -55,86 +74,164 @@ const getURLParams = (hash) => {
55
74
  return { target, yOffsetFromURLParams: yOffset };
56
75
  };
57
76
 
77
+ const getTarget = (hash) => {
78
+ // Try to get target from URL params
79
+ const { target, yOffsetFromURLParams } = getURLParams(hash);
80
+ if (!target) return { targetElement: null, yOffsetFromURLParams: null };
81
+
82
+ // Look for target element in DOM
83
+ const targetElement = getTargetElementHelper(target);
84
+ if (!targetElement) {
85
+ return { targetElement: null, yOffsetFromURLParams: null };
86
+ }
87
+
88
+ return { targetElement, yOffsetFromURLParams };
89
+ };
90
+
91
+ const handleScroll = ({ hash, observerRef, setCurrentTarget, ...options }) => {
92
+ const { targetElement, yOffsetFromURLParams } = getTarget(hash);
93
+
94
+ /**
95
+ * If no targetElement found, we need to navigate using the hash
96
+ * because the targetElement is on another page.
97
+ */
98
+ if (!targetElement) {
99
+ window.location.href = hash;
100
+ return;
101
+ }
102
+
103
+ const targetObj = {
104
+ targetElement,
105
+ ...(yOffsetFromURLParams ? { yOffset: parseInt(yOffsetFromURLParams, 10) } : {}),
106
+ hash, // Add hash to the target object so onIntersect can update the URL
107
+ ...options,
108
+ };
109
+
110
+ // Trigger scrollToTriggered callback if it exists
111
+ if (typeof options.scrollToTriggered === 'function') {
112
+ options.scrollToTriggered();
113
+ }
114
+
115
+ /**
116
+ * Check if we need to scroll up the page. If we need to
117
+ * scroll up, chances are the rest of the the page content
118
+ * is loaded so we don't need to automatically scroll down the
119
+ * page and the targetElementPositionTop should be accurate enough.
120
+ */
121
+ const targetElementPositionTop = targetElement.getBoundingClientRect().top;
122
+ if (
123
+ targetElementPositionTop + window.scrollY <
124
+ window.scrollY - window.innerHeight
125
+ ) {
126
+ targetObj.scrollAmount = targetElementPositionTop + window.scrollY;
127
+ scrollToTarget(targetObj);
128
+ setCurrentTarget(targetObj);
129
+ return;
130
+ }
131
+
132
+ // AUTO scroll down the page
133
+ scrollToBottom();
134
+ const observer = createMutationObserver((mutationsList, observer) => {
135
+ scrollToBottom();
136
+ });
137
+ observer.observe(document.body, { childList: true, subtree: true });
138
+
139
+ // Store observer in ref so it can be disconnected later
140
+ if (observerRef) {
141
+ observerRef.current = observer;
142
+ }
143
+
144
+ // Set current target so the intersectionObserver can use it
145
+ setCurrentTarget(targetObj);
146
+ };
58
147
 
59
148
  /**
60
- * Custom hook that provides functionality to scroll to a specific target element
61
- * on the page, either based on URL hash parameters or click events to links with
62
- * href="#scroll-to?target=". It also handles layout shifts caused by lazy-loaded components
63
- * using an intersection observer to ensure accurate scrolling.
149
+ * useScrollTo
150
+ *
151
+ * A hook for handling smooth scrolling to elements on pages with lazy-loaded content.
152
+ * It can auto-scroll based on the URL hash on page load, or when an element with a specific
153
+ * href (e.g. `#/scroll-to?target=158827`) is clicked. The hook accounts for dynamic content
154
+ * loading that may shift the target element's position during scrolling.
64
155
  *
65
- * Features:
66
- * - Scrolls to a target element specified by a URL hash or click event.
67
- * - Supports additional vertical offsets (static or dynamic).
68
- * - Uses an AbortController to manage and stop scrolling when necessary.
69
- * - Resolves layout shift issues by re-calculating the target position using an intersection observer.
70
- * - Automatically attaches click event listeners to links with href="#scroll-to?target=",
71
- * target can either be a element id, class name or chunk id.
72
- * - Handles URL navigation with scroll-to hash parameters.
156
+ * The hook determines the initial position of the target element. If the element is above the
157
+ * current scroll position, it assumes the page is loaded and scrolls directly to the element.
158
+ * If the element is below, it auto-scrolls to the bottom of the page, then corrects the scroll
159
+ * position once the target is in view, ensuring the element is aligned at the top.
73
160
  *
74
- * @param {Object} options - Configuration options for scrolling behavior.
75
- * @param {number|function} [options.yOffset] - Vertical offset for scrolling. Can be a number or a function.
76
- * @param {number} [options.additionalYOffset] - Additional vertical offset for scrolling.
161
+ * Additional features:
162
+ * - Handles scroll offsets (static or dynamic).
163
+ * - Allows for a theme-specific offset (e.g., to account for navigation bar heights) via `additionalYOffset`.
164
+ * - Updates the URL hash after scrolling.
165
+ * - Supports callbacks for scroll start and completion.
166
+ * - Cleans up observers and event listeners automatically.
167
+ *
168
+ * @param {Object} [options] - Optional configuration object.
169
+ * @param {number|function} [options.yOffset] - A fixed offset (in px) or a function returning an offset, applied when scrolling to the target. Positive values scroll up from the target, negative values scroll down from the target.
170
+ * @param {number} [options.additionalYOffset] - An extra offset (in px) to account for theme-specific adjustments, such as navigation bar heights.
171
+ * @param {function} [options.scrollToTriggered] - Callback fired when a scroll-to event is triggered.
172
+ * @param {function} [options.scrollToComplete] - Callback fired when scrolling to the target is complete.
173
+ *
174
+ * @returns {void}
77
175
  */
78
- const useScrollTo = (options) => {
176
+ const useScrollTo = (options = {}) => {
79
177
  const [currentTarget, setCurrentTarget] = useState(null);
80
- const scrollAbortControllerRef = useRef(null);
81
-
82
- const handleScrollTo = (hash) => {
83
- const { target, yOffsetFromURLParams } = getURLParams(hash || window.location.hash);
84
- if (!target) return;
85
-
86
- const targetElement = getTargetElementHelper(target);
87
- const targetObj = {
88
- targetElement,
89
- additionalYOffset: yOffsetFromURLParams,
90
- ...(hash && { hash }), // Add hash to the target object so onIntersect can update the URL
91
- ...options,
92
- }
178
+ const observerRef = useRef(null);
93
179
 
94
- // Update state so that the intersection observer can pick it up
95
- setCurrentTarget(targetObj);
180
+ useIntersectionObserver({
181
+ target: currentTarget?.targetElement,
182
+ onIntersect: () => {
183
+ // This will fire on first scroll
184
+ if (observerRef.current) {
185
+ // Kill observer that is handling the scrollToBottom;
186
+ observerRef.current.disconnect();
187
+ observerRef.current = null;
96
188
 
97
- // AbortController to manage the scroll
98
- const controller = new AbortController();
99
- scrollAbortControllerRef.current = controller;
189
+ /**
190
+ * Kill current scroll
191
+ * (only way to do it because we can't currently
192
+ * attach an abortcontroller to scrollTo)
193
+ */
194
+ window.scrollTo({
195
+ top: window.scrollY,
196
+ left: 0,
197
+ behavior: 'auto',
198
+ });
100
199
 
101
- // Scroll to the target element;
102
- scrollToTarget({ ...targetObj }, controller.signal);
103
- }
104
-
105
- /**
106
- * How this useIntersectionObserver is being used:
107
- * This fixes a bug with the scrollToTarget function
108
- * where the getBoundingClientRect() method returns
109
- * a different value between scroll to's. This is caused
110
- * because of layout shift with lazy loaded components
111
- * while scrolling.
112
- *
113
- * How it works:
114
- * When the scrollToTarget is fired, the useIntersectionObserver
115
- * will check if the target element is in the viewport. If it is
116
- * it stops the window.scroll method in the scrollToTarget function
117
- * stopping the scroll. This is done by using the AbortController.
118
- * Then it scrollToTarget again, this in turn gets the most up to date
119
- * getBoundingClientRect() value and scrolls to the target element.
120
- *
121
- */
122
- useIntersectionObserver({
123
- target: currentTarget?.targetElement,
124
- onIntersect: () => {
125
- if (scrollAbortControllerRef.current) {
126
- scrollAbortControllerRef.current.abort(); // Stop the scroll
127
- scrollAbortControllerRef.current = null; // Reset the controller
128
- scrollToTarget({ ...currentTarget }); // Fire the scrollToTarget function again
129
- if (currentTarget?.hash) window.location.hash = currentTarget.hash; // Update the URL hash
200
+ // Get targets current position because it might have changed
201
+ const targetElementPositionTop =
202
+ currentTarget.targetElement.getBoundingClientRect().top;
203
+
204
+ /**
205
+ * The scrollAmount is the current scroll position, which
206
+ * will be near bottom of the page. Plus the current scroll
207
+ * position, which is at the top of the page.
208
+ */
209
+ const scrollAmount = targetElementPositionTop + window.scrollY;
210
+
211
+ // Update current target state (will need this to trigger the scroll correction)
212
+ setCurrentTarget({
213
+ ...currentTarget,
214
+ scrollAmount,
215
+ });
130
216
  }
131
- }
217
+
218
+ // This will fire after the scroll correction
219
+ if (currentTarget?.scrollCorrectionFired) {
220
+
221
+ // Trigger scrollToComplete callback if it exists
222
+ if (typeof currentTarget.scrollToComplete === 'function') {
223
+ currentTarget.scrollToComplete();
224
+ }
225
+
226
+ // Remove current target
227
+ setCurrentTarget(null);
228
+ }
229
+ },
132
230
  });
133
231
 
134
-
135
- // Handle onclick scroll to events
232
+ // Handles onclick scroll to events
136
233
  useEffect(() => {
137
- const clickEventController = new AbortController()
234
+ const clickEventController = new AbortController();
138
235
  const scrollToEls = document.querySelectorAll(
139
236
  '[href*="#scroll-to"], [href*="#/scroll-to"]'
140
237
  );
@@ -143,29 +240,80 @@ const useScrollTo = (options) => {
143
240
  el.addEventListener(
144
241
  'click',
145
242
  (e) => {
146
-
147
- /**
148
- * Prevent default behavior, hash change is handled onIntersect,
149
- * otherwise it will cause the scroll to be instant
150
- */
151
243
  e.preventDefault();
152
- handleScrollTo(e.currentTarget.getAttribute('href'));
244
+ const hash = e.currentTarget.getAttribute('href');
245
+ if (hash) {
246
+ handleScroll({
247
+ hash: hash,
248
+ observerRef,
249
+ setCurrentTarget,
250
+ ...options,
251
+ });
252
+ }
153
253
  },
154
254
  { signal: clickEventController.signal }
155
255
  );
156
256
  });
157
257
 
158
- //On click element event listeners cleanup
159
258
  return () => {
160
- clickEventController.abort()
161
- }
259
+ clickEventController.abort();
260
+ };
162
261
  }, []);
163
262
 
164
- // Handle when a user navigates to a URL with a scroll to hash
263
+ // Handles when a user navigates to a URL with a scroll to hash
165
264
  useEffect(() => {
166
- handleScrollTo();
167
- }, [])
265
+ const timeout = setTimeout(() => {
266
+ // Start scrolling
267
+ handleScroll({
268
+ hash: window.location.hash,
269
+ observerRef,
270
+ setCurrentTarget,
271
+ ...options,
272
+ });
273
+ }, 1000);
274
+
275
+ /**
276
+ * Clears observer after 5 seconds if whatever reason
277
+ * observerRef.current.disconnect() is never fired. Otherwise
278
+ * scrollToBottom will constantly fire if the scroll gets
279
+ * to the bottom of the page.
280
+ */
281
+ if (observerRef && observerRef.current) {
282
+ // Clear any previous timeout
283
+ if (observerRef.disconnectTimeout) {
284
+ clearTimeout(observerRef.disconnectTimeout);
285
+ }
286
+ observerRef.disconnectTimeout = setTimeout(() => {
287
+ if (observerRef.current) {
288
+ observerRef.current.disconnect();
289
+ observerRef.current = null;
290
+ }
291
+ }, 5000);
292
+ }
293
+
294
+ return () => {
295
+ clearTimeout(timeout);
296
+ // Disconnect observer on cleanup
297
+ if (observerRef.current) {
298
+ observerRef.current.disconnect();
299
+ }
300
+ if (observerRef.disconnectTimeout) {
301
+ clearTimeout(observerRef.disconnectTimeout);
302
+ observerRef.disconnectTimeout = null;
303
+ }
304
+ };
305
+ }, []);
168
306
 
307
+ // Handles scroll correction
308
+ useEffect(() => {
309
+ if (currentTarget?.scrollAmount && !currentTarget?.scrollCorrectionFired) {
310
+ scrollToTarget(currentTarget);
311
+ setCurrentTarget({
312
+ ...currentTarget,
313
+ scrollCorrectionFired: true,
314
+ });
315
+ }
316
+ }, [currentTarget]);
169
317
  };
170
318
 
171
319
  export default useScrollTo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homeflowjs",
3
- "version": "1.0.75",
3
+ "version": "1.0.77",
4
4
  "sideEffects": [
5
5
  "modal/**/*",
6
6
  "user/default-profile/**/*",
@@ -18,6 +18,7 @@ const SaveSearchButton = (props) => {
18
18
  removeSavedSearchAsync,
19
19
  style,
20
20
  notificationMessage,
21
+ renderAsButton,
21
22
  showNotification,
22
23
  } = props;
23
24
 
@@ -42,6 +43,19 @@ const SaveSearchButton = (props) => {
42
43
  }
43
44
  };
44
45
 
46
+ if (renderAsButton) return (
47
+ <button
48
+ role="button"
49
+ onClick={toggleSearch}
50
+ className={`${className} ${savedSearch ? 'saved' : ''}`}
51
+ {...(style && {
52
+ style,
53
+ })}
54
+ >
55
+ {savedSearch ? SavedComponent : UnsavedComponent}
56
+ </button>
57
+ )
58
+
45
59
  return (
46
60
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/anchor-is-valid
47
61
  <a
@@ -56,6 +70,7 @@ const SaveSearchButton = (props) => {
56
70
 
57
71
  SaveSearchButton.propTypes = {
58
72
  search: PropTypes.object.isRequired,
73
+ renderAsButton: PropTypes.bool,
59
74
  className: PropTypes.string,
60
75
  savedSearches: PropTypes.array,
61
76
  UnsavedComponent: PropTypes.element.isRequired,
@@ -79,6 +94,7 @@ SaveSearchButton.defaultProps = {
79
94
  className: '',
80
95
  style: {},
81
96
  showNotification: false,
97
+ renderAsButton: false,
82
98
  };
83
99
 
84
100
  const mapStateToProps = (state) => ({
package/utils/index.js CHANGED
@@ -157,3 +157,20 @@ export function JSON_to_URLEncoded(element,key,list){
157
157
  export function isFunction(functionToCheck) {
158
158
  return functionToCheck && Object.prototype.toString.call(functionToCheck) === '[object Function]';
159
159
  }
160
+
161
+ /**
162
+ * Creates a MutationObserver that invokes the provided callback function
163
+ * whenever mutations are observed. The callback receives the list of mutations
164
+ * and the observer instance as arguments.
165
+ *
166
+ * @param {Function} callback - Function to be called when mutations are observed.
167
+ * Receives (mutationsList: MutationRecord[], observer: MutationObserver).
168
+ * @returns {MutationObserver} The created MutationObserver instance.
169
+ */
170
+ export function createMutationObserver(callback) {
171
+ return new MutationObserver((mutationsList, observer) => {
172
+ if (typeof callback === 'function') {
173
+ callback(mutationsList, observer);
174
+ }
175
+ });
176
+ };