Fix positioned highlighted elements get clipped #454 #563

This commit is contained in:
Kamran Ahmed
2026-06-25 15:45:30 +01:00
parent 2633c2f453
commit 0dfcdacceb
5 changed files with 72 additions and 12 deletions
+8 -2
View File
@@ -18,6 +18,8 @@ heading.
- `allowScroll` config to lock body scroll while a tour is active.
- `onDoneClick` hook, fired when the done button on the final step is clicked.
([#500](https://github.com/nilbuild/driver.js/issues/500))
- `data` property on a step for passing arbitrary data, accessible from hooks
for custom per-step logic. ([#539](https://github.com/nilbuild/driver.js/issues/539))
### Changed
@@ -34,14 +36,18 @@ heading.
- The popover exposes `driver-popover-side-*` and `driver-popover-align-*`
classes so you can target its position from CSS, and the arrow positioning was
rewritten for more accurate placement.
- Dropped the CSS `:has()` selector for broader browser compatibility.
([#586](https://github.com/nilbuild/driver.js/issues/586))
### Fixed
- Document event listener leak in `onDriverClick` that left handlers attached
after the tour was destroyed.
([#452](https://github.com/nilbuild/driver.js/issues/452))
- Dropped the CSS `:has()` selector for broader browser compatibility.
([#586](https://github.com/nilbuild/driver.js/issues/586))
- Positioned highlighted elements and their children (e.g. dropdown menus) no
longer get clipped.
([#454](https://github.com/nilbuild/driver.js/issues/454),
[#563](https://github.com/nilbuild/driver.js/issues/563))
## 1.5.0
+2 -2
View File
@@ -139,8 +139,8 @@
pointer-events: none;
}
/* Disable the scrolling of parent element if it has an active element*/
.driver-active-element-parent {
/* Disable the scrolling of a scrollable parent that holds an active element*/
.driver-active-element-parent-no-scroll {
overflow: hidden !important;
}
+7 -3
View File
@@ -3,7 +3,7 @@ import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
import { getConfig, getCurrentDriver } from "./config";
import { hidePopover, renderPopover } from "./popover";
import { repositionPopover } from "./position";
import { bringInView } from "./utils";
import { bringInView, isScrollable } from "./utils";
import { getState, setState } from "./state";
function mountDummyElement(): Element {
@@ -157,7 +157,7 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
}
document.querySelectorAll(".driver-active-element-parent").forEach(element => {
element.classList.remove("driver-active-element-parent");
element.classList.remove("driver-active-element-parent", "driver-active-element-parent-no-scroll");
});
fromElement.classList.remove("driver-active-element", "driver-no-interaction");
@@ -173,6 +173,10 @@ function transferHighlight(toElement: Element, toStep: DriveStep) {
const toParent = toElement.parentElement;
if (toParent && toParent !== document.body) {
toParent.classList.add("driver-active-element-parent");
if (isScrollable(toParent)) {
toParent.classList.add("driver-active-element-parent-no-scroll");
}
}
toElement.classList.add("driver-active-element");
@@ -186,7 +190,7 @@ export function destroyHighlight() {
document.querySelectorAll(".driver-active-element").forEach(element => {
const parent = element.parentElement;
if (parent && parent !== document.body) {
parent.classList.remove("driver-active-element-parent");
parent.classList.remove("driver-active-element-parent", "driver-active-element-parent-no-scroll");
}
element.classList.remove("driver-active-element", "driver-no-interaction");
+7
View File
@@ -1,5 +1,12 @@
import { getConfig } from "./config";
export function isScrollable(element: Element) {
const style = window.getComputedStyle(element);
return [style.overflow, style.overflowX, style.overflowY].some(value => {
return value === "auto" || value === "scroll";
});
}
export function easeInOutQuad(elapsed: number, initialValue: number, amountOfChange: number, duration: number): number {
if ((elapsed /= duration / 2) < 1) {
return (amountOfChange / 2) * elapsed * elapsed + initialValue;
+48 -5
View File
@@ -7,14 +7,16 @@ const NO_INTERACTION_CLASS = "driver-no-interaction";
const PARENT_CLASS = "driver-active-element-parent";
const NO_SCROLL_CLASS = "driver-active-element-parent-no-scroll";
const NESTED_HTML = `
<div id="container-a"><button id="child-a" type="button">A</button></div>
<div id="container-b"><button id="child-b" type="button">B</button></div>
<button id="top-level" type="button">Top</button>
`;
describe("active element parent", () => {
it("locks scrolling on the highlighted element's parent", () => {
describe("active element parent marker", () => {
it("marks the highlighted element's parent regardless of scrollability", () => {
document.body.innerHTML = NESTED_HTML;
const d = createDriver({ animate: false, steps: [{ element: "#child-a" }] });
d.drive();
@@ -22,7 +24,7 @@ describe("active element parent", () => {
expect(document.getElementById("container-a")?.classList.contains(PARENT_CLASS)).toBe(true);
});
it("moves the lock to the new parent when highlighting a different branch", () => {
it("moves the marker to the new parent when highlighting a different branch", () => {
document.body.innerHTML = NESTED_HTML;
const d = createDriver({
animate: false,
@@ -35,7 +37,7 @@ describe("active element parent", () => {
expect(document.getElementById("container-b")?.classList.contains(PARENT_CLASS)).toBe(true);
});
it("never locks the body element", () => {
it("never marks the body element", () => {
document.body.innerHTML = NESTED_HTML;
const d = createDriver({ animate: false, steps: [{ element: "#top-level" }] });
d.drive();
@@ -43,7 +45,7 @@ describe("active element parent", () => {
expect(document.body.classList.contains(PARENT_CLASS)).toBe(false);
});
it("releases the lock when the tour is destroyed", () => {
it("releases the marker when the tour is destroyed", () => {
document.body.innerHTML = NESTED_HTML;
const d = createDriver({ animate: false, steps: [{ element: "#child-a" }] });
d.drive();
@@ -53,6 +55,47 @@ describe("active element parent", () => {
});
});
describe("active element parent scroll lock", () => {
it("does not lock a non-scrollable parent so positioned children are not clipped", () => {
document.body.innerHTML = `
<div id="dropdown" style="position: relative">
<button id="dropdown-toggle" type="button">Toggle</button>
</div>
`;
const d = createDriver({ animate: false, steps: [{ element: "#dropdown-toggle" }] });
d.drive();
const dropdown = document.getElementById("dropdown");
expect(dropdown?.classList.contains(PARENT_CLASS)).toBe(true);
expect(dropdown?.classList.contains(NO_SCROLL_CLASS)).toBe(false);
});
it("locks a genuinely scrollable parent", () => {
document.body.innerHTML = `
<div id="scroll-area" style="overflow: auto">
<button id="scroll-child" type="button">Child</button>
</div>
`;
const d = createDriver({ animate: false, steps: [{ element: "#scroll-child" }] });
d.drive();
expect(document.getElementById("scroll-area")?.classList.contains(NO_SCROLL_CLASS)).toBe(true);
});
it("releases the scroll lock when the tour is destroyed", () => {
document.body.innerHTML = `
<div id="scroll-area" style="overflow: auto">
<button id="scroll-child" type="button">Child</button>
</div>
`;
const d = createDriver({ animate: false, steps: [{ element: "#scroll-child" }] });
d.drive();
d.destroy();
expect(document.getElementById("scroll-area")?.classList.contains(NO_SCROLL_CLASS)).toBe(false);
});
});
describe("disableActiveInteraction", () => {
it("leaves the active element interactive by default", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });