Skip to content

Commit 0d96edf

Browse files
committed
feat: support updating width on resize
1 parent bcf7260 commit 0d96edf

File tree

3 files changed

+163
-13
lines changed

3 files changed

+163
-13
lines changed

packages/core/src/lib/Adhesive.ts

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ export class Adhesive {
529529
// Request Animation Frame optimization for smooth updates
530530
#rafId: number | null = null;
531531
#pendingUpdate = false;
532+
#pendingResizeUpdate = false;
532533

533534
/**
534535
* Static factory method for convenient instance creation and initialization
@@ -710,8 +711,14 @@ export class Adhesive {
710711
const innerRect = this.#innerWrapper.getBoundingClientRect();
711712

712713
// Calculate dimensions with fallbacks for browser compatibility
713-
const width = outerRect.width || outerRect.right - outerRect.left;
714-
const height = innerRect.height || innerRect.bottom - innerRect.top;
714+
const width =
715+
outerRect.width ||
716+
outerRect.right - outerRect.left ||
717+
this.#outerWrapper.offsetWidth;
718+
const height =
719+
innerRect.height ||
720+
innerRect.bottom - innerRect.top ||
721+
this.#innerWrapper.offsetHeight;
715722
const outerY = outerRect.top + this.#scrollTop;
716723

717724
// Batch update state for better performance
@@ -725,6 +732,72 @@ export class Adhesive {
725732
});
726733
}
727734

735+
/**
736+
* Updates only the width dimensions for resize scenarios
737+
* This method is optimized for responsive width changes without full recalculation
738+
* @internal
739+
*/
740+
#updateWidthDimensions(): void {
741+
if (!this.#outerWrapper || !this.#innerWrapper) return;
742+
743+
// For width updates, we need to temporarily reset positioning to get accurate measurements
744+
const wasFixed = this.#state.status === ADHESIVE_STATUS.FIXED;
745+
const wasRelative = this.#state.status === ADHESIVE_STATUS.RELATIVE;
746+
747+
if (wasFixed || wasRelative) {
748+
// Temporarily clear positioning styles to get natural width
749+
const innerStyle = this.#innerWrapper.style;
750+
const originalPosition = innerStyle.position;
751+
const originalTransform = innerStyle.transform;
752+
const originalTop = innerStyle.top;
753+
const originalBottom = innerStyle.bottom;
754+
755+
innerStyle.position = "static";
756+
innerStyle.transform = "";
757+
innerStyle.top = "";
758+
innerStyle.bottom = "";
759+
760+
// Force reflow to get accurate measurements
761+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
762+
this.#outerWrapper.offsetHeight;
763+
764+
const outerRect = this.#outerWrapper.getBoundingClientRect();
765+
const newWidth =
766+
outerRect.width ||
767+
outerRect.right - outerRect.left ||
768+
this.#outerWrapper.offsetWidth;
769+
770+
// Restore positioning styles
771+
innerStyle.position = originalPosition;
772+
innerStyle.transform = originalTransform;
773+
innerStyle.top = originalTop;
774+
innerStyle.bottom = originalBottom;
775+
776+
// Update state with new width
777+
this.#state.width = newWidth;
778+
this.#state.x = outerRect.left;
779+
} else {
780+
// For initial state, simple measurement is sufficient
781+
const outerRect = this.#outerWrapper.getBoundingClientRect();
782+
const newWidth =
783+
outerRect.width ||
784+
outerRect.right - outerRect.left ||
785+
this.#outerWrapper.offsetWidth;
786+
this.#state.width = newWidth;
787+
this.#state.x = outerRect.left;
788+
}
789+
}
790+
791+
/**
792+
* Forces an immediate width update and style refresh
793+
* Useful for responsive design scenarios where width changes need immediate application
794+
* @internal
795+
*/
796+
#forceWidthUpdate(): void {
797+
this.#updateWidthDimensions();
798+
this.#updateStyles();
799+
}
800+
728801
// =============================================================================
729802
// State Management Methods
730803
// =============================================================================
@@ -787,7 +860,6 @@ export class Adhesive {
787860
};
788861

789862
const isFixed = this.#state.status === ADHESIVE_STATUS.FIXED;
790-
const isNotInitial = this.#state.status !== ADHESIVE_STATUS.INITIAL;
791863
const { position } = this.#options;
792864

793865
// Clear all positioning styles first
@@ -798,8 +870,8 @@ export class Adhesive {
798870

799871
// Set common styles
800872
innerWrapper.style.zIndex = String(this.#options.zIndex);
801-
innerWrapper.style.width = isNotInitial ? `${this.#state.width}px` : "";
802-
outerWrapper.style.height = isNotInitial ? `${this.#state.height}px` : "";
873+
innerWrapper.style.width = isFixed ? `${this.#state.width}px` : "";
874+
outerWrapper.style.height = isFixed ? `${this.#state.height}px` : "";
803875

804876
// Apply positioning based on state
805877
if (isFixed) {
@@ -1069,10 +1141,11 @@ export class Adhesive {
10691141
};
10701142

10711143
/**
1072-
* Optimized resize handler with RAF throttling
1144+
* Optimized window resize handler with RAF throttling
1145+
* Handles viewport dimension changes
10731146
* @internal
10741147
*/
1075-
readonly #onResize = (): void => {
1148+
readonly #onWindowResize = (): void => {
10761149
if (!this.#isEnabled || this.#pendingUpdate) return;
10771150

10781151
this.#pendingUpdate = true;
@@ -1084,6 +1157,47 @@ export class Adhesive {
10841157
});
10851158
};
10861159

1160+
/**
1161+
* ResizeObserver callback for element dimension changes
1162+
* Handles width updates with immediate style application and debouncing for performance
1163+
* @internal
1164+
*/
1165+
readonly #onElementResize = (entries: ResizeObserverEntry[]): void => {
1166+
if (!this.#isEnabled) return;
1167+
1168+
if (this.#pendingResizeUpdate) return;
1169+
1170+
this.#pendingResizeUpdate = true;
1171+
this.#rafId = requestAnimationFrame(() => {
1172+
this.#pendingResizeUpdate = false;
1173+
1174+
// Check if this is a width-affecting resize
1175+
let needsWidthUpdate = false;
1176+
let needsFullUpdate = false;
1177+
1178+
for (const entry of entries) {
1179+
if (entry.target === this.#outerWrapper) {
1180+
// Outer wrapper resize affects width
1181+
needsWidthUpdate = true;
1182+
} else if (entry.target === this.#boundingEl) {
1183+
// Bounding element resize affects boundaries
1184+
needsFullUpdate = true;
1185+
} else if (entry.target === this.#targetEl) {
1186+
// Target element content changes might affect height
1187+
needsFullUpdate = true;
1188+
}
1189+
}
1190+
1191+
if (needsFullUpdate) {
1192+
this.#updateInitialDimensions();
1193+
} else if (needsWidthUpdate) {
1194+
this.#updateWidthDimensions();
1195+
}
1196+
1197+
this.#updateStyles();
1198+
this.#update();
1199+
});
1200+
};
10871201
// =============================================================================
10881202
// Public API Methods
10891203
// =============================================================================
@@ -1116,15 +1230,17 @@ export class Adhesive {
11161230

11171231
// Add event listeners with optimal performance settings
11181232
window.addEventListener("scroll", this.#onScroll, { passive: true });
1119-
window.addEventListener("resize", this.#onResize, { passive: true });
1233+
window.addEventListener("resize", this.#onWindowResize, { passive: true });
11201234

11211235
// Modern ResizeObserver for better performance
11221236
if ("ResizeObserver" in window) {
1123-
this.#observer = new ResizeObserver(this.#onResize);
1237+
this.#observer = new ResizeObserver(this.#onElementResize);
11241238
this.#observer.observe(this.#boundingEl);
11251239
if (this.#outerWrapper) {
11261240
this.#observer.observe(this.#outerWrapper);
11271241
}
1242+
// Also observe the target element for content changes
1243+
this.#observer.observe(this.#targetEl);
11281244
} else {
11291245
const error = ERROR_REGISTRY.RESIZE_OBSERVER_NOT_SUPPORTED;
11301246
console.warn(`@adhesivejs/core: ${error.message}`);
@@ -1222,6 +1338,25 @@ export class Adhesive {
12221338
return { ...this.#state };
12231339
}
12241340

1341+
/**
1342+
* Manually triggers a width update for the sticky element.
1343+
* This is useful when the element's container width changes due to external factors
1344+
* that might not be detected by the ResizeObserver (e.g., CSS changes via JavaScript).
1345+
*
1346+
* @returns The Adhesive instance for method chaining
1347+
*
1348+
* @example
1349+
* ```ts
1350+
* // After programmatically changing container width
1351+
* adhesive.refreshWidth();
1352+
* ```
1353+
*/
1354+
refreshWidth(): this {
1355+
if (!this.#isEnabled) return this;
1356+
this.#forceWidthUpdate();
1357+
return this;
1358+
}
1359+
12251360
/**
12261361
* Cleans up the Adhesive instance by removing event listeners, disconnecting observers,
12271362
* canceling pending animations, and restoring the original DOM structure.
@@ -1236,16 +1371,18 @@ export class Adhesive {
12361371
* ```
12371372
*/
12381373
cleanup(): void {
1239-
// Cancel any pending RAF operations
1374+
// Cancel any pending RAF operations and timeouts
12401375
if (this.#rafId !== null) {
12411376
cancelAnimationFrame(this.#rafId);
12421377
this.#rafId = null;
12431378
}
1379+
12441380
this.#pendingUpdate = false;
1381+
this.#pendingResizeUpdate = false;
12451382

12461383
// Remove event listeners
12471384
window.removeEventListener("scroll", this.#onScroll);
1248-
window.removeEventListener("resize", this.#onResize);
1385+
window.removeEventListener("resize", this.#onWindowResize);
12491386

12501387
// Disconnect observers
12511388
this.#observer?.disconnect();

playground/react/src/App.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export function App() {
1313
const targetEl = useRef<HTMLDivElement>(null);
1414
const boundingEl = useRef<HTMLDivElement>(null);
1515

16-
useAdhesive({ target: targetEl, bounding: boundingEl });
16+
useAdhesive(
17+
{ target: targetEl, bounding: boundingEl },
18+
{ enabled, position },
19+
);
1720

1821
return (
1922
<div>
@@ -37,13 +40,19 @@ export function App() {
3740
</div>
3841
<div className="adhesive-container">
3942
<AdhesiveContainer
43+
enabled={enabled}
4044
position={position}
4145
boundingEl=".adhesive-container"
4246
className="my-classname"
4347
outerClassName="my-outer-classname"
4448
innerClassName="my-inner-classname"
4549
activeClassName="my-active-classname"
4650
releasedClassName="my-released-classname"
51+
style={{
52+
width: "100%",
53+
height: "100px",
54+
backgroundColor: "lightblue",
55+
}}
4756
>
4857
Sticky Element
4958
</AdhesiveContainer>

playground/vue/src/App.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ const position = ref<AdhesivePosition>("top");
1010
const targetEl = useTemplateRef("target");
1111
const boundingEl = useTemplateRef("bounding");
1212
13-
useAdhesive({ target: targetEl, bounding: boundingEl });
13+
useAdhesive({ target: targetEl, bounding: boundingEl }, () => ({
14+
enabled: enabled.value,
15+
position: position.value,
16+
}));
1417
</script>
1518

1619
<template>
@@ -46,6 +49,7 @@ useAdhesive({ target: targetEl, bounding: boundingEl });
4649
inner-class="my-inner-classname"
4750
active-class="my-active-classname"
4851
released-class="my-released-classname"
52+
style="width: 100%; height: 100px; background-color: lightblue"
4953
>
5054
Sticky Element
5155
</AdhesiveContainer>

0 commit comments

Comments
 (0)