@@ -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 ( / : h o v e r \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+
80166export const isIgnoredElem = ( element : HTMLElement ) : boolean => {
81167 const rect = element . getBoundingClientRect ( ) ;
82168 const isNotVisible = rect . width === 0 || rect . height === 0 ;
0 commit comments