Skip to content

Commit 2ffc6ad

Browse files
authored
Treat hover pointer div as buttons (#32)
1 parent b42fe58 commit 2ffc6ad

3 files changed

Lines changed: 186 additions & 0 deletions

File tree

src/context-providers/dom/elem-interactive.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,95 @@ export const isInteractiveElem = (
7474
return { isInteractive: true, reason: "Is draggable" };
7575
}
7676

77+
// Many sites (especially older WordPress / jQuery themes) attach click
78+
// handlers to plain `<div>` elements without setting any role / aria
79+
// attribute, and only set `cursor: pointer` on `:hover`. Their click
80+
// handlers are typically attached via event delegation, so the
81+
// `data-has-interactive-listener` marker above doesn't fire either.
82+
//
83+
// To recover those, we walk the page's stylesheets once and collect every
84+
// selector that sets `cursor: pointer` inside a `:hover` rule, then check
85+
// whether the element matches any of those selectors.
86+
for (const selector of getHoverPointerSelectors()) {
87+
try {
88+
if (element.matches(selector)) {
89+
return { isInteractive: true, reason: "Has cursor: pointer on hover" };
90+
}
91+
} catch {
92+
// Invalid selector (e.g. `:has()` in older browsers) — skip.
93+
}
94+
}
95+
7796
return { isInteractive: false, reason: "Not interactive" };
7897
};
7998

99+
/**
100+
* Walks every accessible stylesheet on the page and returns the list of base
101+
* selectors (with `:hover` stripped) whose `:hover` rule sets
102+
* `cursor: pointer`. Cached across calls so we only pay the CSSOM walk once
103+
* per injected script execution.
104+
*
105+
* Cross-origin stylesheets throw on `cssRules` access — those are silently
106+
* skipped, which means we miss buttons styled by 3rd-party CSS, but that is
107+
* an acceptable trade-off (and very rare for primary page content).
108+
*/
109+
let _hoverPointerSelectorsCache: string[] | null = null;
110+
const getHoverPointerSelectors = (): string[] => {
111+
if (_hoverPointerSelectorsCache !== null) {
112+
return _hoverPointerSelectorsCache;
113+
}
114+
const selectors: string[] = [];
115+
116+
const visitRules = (rules: CSSRuleList) => {
117+
for (let i = 0; i < rules.length; i++) {
118+
const rule = rules[i];
119+
// Descend into @media / @supports / etc. (CSSGroupingRule).
120+
const groupingRules = (rule as unknown as { cssRules?: CSSRuleList })
121+
.cssRules;
122+
if (groupingRules) {
123+
try {
124+
visitRules(groupingRules);
125+
} catch {
126+
// Some grouping rule types throw on access — skip.
127+
}
128+
}
129+
const styleRule = rule as CSSStyleRule;
130+
if (
131+
!styleRule.selectorText ||
132+
!styleRule.style ||
133+
styleRule.style.cursor !== "pointer"
134+
) {
135+
continue;
136+
}
137+
// A rule's selectorText may be a comma-separated list, e.g.
138+
// ".btn:hover, .card:hover, .footer-link". Split, keep only the
139+
// segments that contain :hover, strip :hover from each, and re-emit.
140+
const segments = styleRule.selectorText.split(",");
141+
for (const raw of segments) {
142+
const segment = raw.trim();
143+
if (!segment.includes(":hover")) continue;
144+
const base = segment.replace(/:hover\b/g, "").trim();
145+
if (base) selectors.push(base);
146+
}
147+
}
148+
};
149+
150+
for (let i = 0; i < document.styleSheets.length; i++) {
151+
const sheet = document.styleSheets[i];
152+
let rules: CSSRuleList | null = null;
153+
try {
154+
rules = sheet.cssRules;
155+
} catch {
156+
// Cross-origin stylesheet — skip.
157+
continue;
158+
}
159+
if (rules) visitRules(rules);
160+
}
161+
162+
_hoverPointerSelectorsCache = selectors;
163+
return selectors;
164+
};
165+
80166
export const isIgnoredElem = (element: HTMLElement): boolean => {
81167
const rect = element.getBoundingClientRect();
82168
const isNotVisible = rect.width === 0 || rect.height === 0;

src/context-providers/dom/inject/build-dom-view-script.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,58 @@
100100
if (isDraggable) {
101101
return { isInteractive: true, reason: "Is draggable" };
102102
}
103+
for (const selector of getHoverPointerSelectors()) {
104+
try {
105+
if (element.matches(selector)) {
106+
return { isInteractive: true, reason: "Has cursor: pointer on hover" };
107+
}
108+
} catch {
109+
}
110+
}
103111
return { isInteractive: false, reason: "Not interactive" };
104112
};
113+
var _hoverPointerSelectorsCache = null;
114+
var getHoverPointerSelectors = () => {
115+
if (_hoverPointerSelectorsCache !== null) {
116+
return _hoverPointerSelectorsCache;
117+
}
118+
const selectors = [];
119+
const visitRules = (rules) => {
120+
for (let i = 0; i < rules.length; i++) {
121+
const rule = rules[i];
122+
const groupingRules = rule.cssRules;
123+
if (groupingRules) {
124+
try {
125+
visitRules(groupingRules);
126+
} catch {
127+
}
128+
}
129+
const styleRule = rule;
130+
if (!styleRule.selectorText || !styleRule.style || styleRule.style.cursor !== "pointer") {
131+
continue;
132+
}
133+
const segments = styleRule.selectorText.split(",");
134+
for (const raw of segments) {
135+
const segment = raw.trim();
136+
if (!segment.includes(":hover")) continue;
137+
const base = segment.replace(/:hover\b/g, "").trim();
138+
if (base) selectors.push(base);
139+
}
140+
}
141+
};
142+
for (let i = 0; i < document.styleSheets.length; i++) {
143+
const sheet = document.styleSheets[i];
144+
let rules = null;
145+
try {
146+
rules = sheet.cssRules;
147+
} catch {
148+
continue;
149+
}
150+
if (rules) visitRules(rules);
151+
}
152+
_hoverPointerSelectorsCache = selectors;
153+
return selectors;
154+
};
105155
var isIgnoredElem = (element) => {
106156
const rect = element.getBoundingClientRect();
107157
const isNotVisible = rect.width === 0 || rect.height === 0;

src/context-providers/dom/inject/build-dom-view.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,58 @@ export const buildDomViewJs = `(() => {
100100
if (isDraggable) {
101101
return { isInteractive: true, reason: "Is draggable" };
102102
}
103+
for (const selector of getHoverPointerSelectors()) {
104+
try {
105+
if (element.matches(selector)) {
106+
return { isInteractive: true, reason: "Has cursor: pointer on hover" };
107+
}
108+
} catch {
109+
}
110+
}
103111
return { isInteractive: false, reason: "Not interactive" };
104112
};
113+
var _hoverPointerSelectorsCache = null;
114+
var getHoverPointerSelectors = () => {
115+
if (_hoverPointerSelectorsCache !== null) {
116+
return _hoverPointerSelectorsCache;
117+
}
118+
const selectors = [];
119+
const visitRules = (rules) => {
120+
for (let i = 0; i < rules.length; i++) {
121+
const rule = rules[i];
122+
const groupingRules = rule.cssRules;
123+
if (groupingRules) {
124+
try {
125+
visitRules(groupingRules);
126+
} catch {
127+
}
128+
}
129+
const styleRule = rule;
130+
if (!styleRule.selectorText || !styleRule.style || styleRule.style.cursor !== "pointer") {
131+
continue;
132+
}
133+
const segments = styleRule.selectorText.split(",");
134+
for (const raw of segments) {
135+
const segment = raw.trim();
136+
if (!segment.includes(":hover")) continue;
137+
const base = segment.replace(/:hover\\b/g, "").trim();
138+
if (base) selectors.push(base);
139+
}
140+
}
141+
};
142+
for (let i = 0; i < document.styleSheets.length; i++) {
143+
const sheet = document.styleSheets[i];
144+
let rules = null;
145+
try {
146+
rules = sheet.cssRules;
147+
} catch {
148+
continue;
149+
}
150+
if (rules) visitRules(rules);
151+
}
152+
_hoverPointerSelectorsCache = selectors;
153+
return selectors;
154+
};
105155
var isIgnoredElem = (element) => {
106156
const rect = element.getBoundingClientRect();
107157
const isNotVisible = rect.width === 0 || rect.height === 0;

0 commit comments

Comments
 (0)