Skip to content

Commit c30c1bd

Browse files
committed
First web implementation
1 parent 8d15802 commit c30c1bd

6 files changed

Lines changed: 212 additions & 11 deletions

File tree

apps/common-app/src/apps/reanimated/examples/PseudoActiveExample.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ function TargetingExample() {
2929
<View style={styles.section}>
3030
<Text style={styles.sectionTitle}>Bottommost element wins</Text>
3131
<Text style={styles.hint}>
32-
Unlike on the web where <Text style={styles.code}>:active</Text>{' '}
33-
propagates to all ancestors, here only the deepest element with{' '}
34-
<Text style={styles.code}>:active</Text> gets activated.{'\n\n'}
35-
Press a button → only the button activates.{'\n'}
32+
On native, only the deepest element with{' '}
33+
<Text style={styles.code}>:active</Text> gets activated - ancestors are
34+
skipped.{'\n'}
35+
On web it follows the browser model: pressing a button activates the
36+
button and all its <Text style={styles.code}>:active</Text> ancestors up
37+
the tree.{'\n\n'}
38+
Press a button → only the button activates (native) / button + card
39+
activate (web).{'\n'}
3640
Press the card background → only the card activates.
3741
</Text>
3842

apps/common-app/src/apps/reanimated/examples/PseudoFocusExample.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ const styles = StyleSheet.create({
212212
height: 44,
213213
paddingHorizontal: 10,
214214
fontSize: 16,
215+
outlineWidth: 0,
215216
},
216217
bottomPadding: {
217218
height: 40,

packages/react-native-reanimated/src/css/types/interfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
'use strict';
2+
import type { PseudoStylesBySelector } from '../utils';
23
import type { ExistingCSSAnimationProperties } from './animation';
34
import type { CSSStyle } from './props';
45
import type { CSSTransitionProperties } from './transition';
@@ -13,6 +14,11 @@ export interface ICSSTransitionsManager {
1314
unmountCleanup(): void;
1415
}
1516

17+
export interface ICSSPseudoSelectorsManager {
18+
update(pseudoStylesBySelector: PseudoStylesBySelector | null): void;
19+
unmountCleanup(): void;
20+
}
21+
1622
export interface ICSSManager {
1723
update(style: CSSStyle | null): void;
1824
unmountCleanup(): void;

packages/react-native-reanimated/src/css/web/domUtils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ export function insertCSSAnimation(animationName: string, keyframes: string) {
6969
}
7070
}
7171

72+
const pseudoSelectorStyleElements = new Map<string, HTMLStyleElement>();
73+
74+
export function insertPseudoSelectorCSS(name: string, cssText: string): void {
75+
if (!IS_WINDOW_AVAILABLE) {
76+
return;
77+
}
78+
const existing = pseudoSelectorStyleElements.get(name);
79+
if (existing) {
80+
existing.textContent = cssText;
81+
return;
82+
}
83+
const el = document.createElement('style');
84+
el.textContent = cssText;
85+
document.head.append(el);
86+
pseudoSelectorStyleElements.set(name, el);
87+
}
88+
89+
export function removePseudoSelectorCSS(name: string): void {
90+
if (!IS_WINDOW_AVAILABLE) {
91+
return;
92+
}
93+
const el = pseudoSelectorStyleElements.get(name);
94+
if (el) {
95+
el.remove();
96+
pseudoSelectorStyleElements.delete(name);
97+
}
98+
}
99+
72100
export function removeCSSAnimation(animationName: string) {
73101
// Without this check SSR crashes because document is undefined (NextExample on CI)
74102
if (!IS_WINDOW_AVAILABLE) {

packages/react-native-reanimated/src/css/web/managers/CSSManager.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,38 @@ import type { CSSStyle } from '../../types';
55
import type { ICSSManager } from '../../types/interfaces';
66
import { filterCSSAndStyleProperties } from '../../utils';
77
import CSSAnimationsManager from './CSSAnimationsManager';
8+
import CSSPseudoSelectorsManager from './CSSPseudoSelectorsManager';
89
import CSSTransitionsManager from './CSSTransitionsManager';
910

1011
export default class CSSManager implements ICSSManager {
11-
private readonly element: ReanimatedHTMLElement;
12-
1312
private readonly animationsManager: CSSAnimationsManager;
1413
private readonly transitionsManager: CSSTransitionsManager;
14+
private readonly pseudoSelectorsManager: CSSPseudoSelectorsManager;
1515

1616
constructor(viewInfo: ViewInfo) {
17-
this.element = viewInfo.DOMElement as ReanimatedHTMLElement;
17+
const element = viewInfo.DOMElement as ReanimatedHTMLElement;
1818

19-
this.animationsManager = new CSSAnimationsManager(this.element);
20-
this.transitionsManager = new CSSTransitionsManager(this.element);
19+
this.animationsManager = new CSSAnimationsManager(element);
20+
this.transitionsManager = new CSSTransitionsManager(element);
21+
this.pseudoSelectorsManager = new CSSPseudoSelectorsManager(element);
2122
}
2223

2324
update(style: CSSStyle): void {
24-
const [animationProperties, transitionProperties] =
25-
filterCSSAndStyleProperties(style);
25+
const [
26+
animationProperties,
27+
transitionProperties,
28+
,
29+
pseudoStylesBySelector,
30+
] = filterCSSAndStyleProperties(style);
2631

2732
this.animationsManager.update(animationProperties);
2833
this.transitionsManager.update(transitionProperties);
34+
this.pseudoSelectorsManager.update(pseudoStylesBySelector);
2935
}
3036

3137
unmountCleanup(): void {
3238
this.animationsManager.unmountCleanup();
3339
this.transitionsManager.unmountCleanup();
40+
this.pseudoSelectorsManager.unmountCleanup();
3441
}
3542
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
'use strict';
2+
import { webPropsBuilder } from '../../../common/web';
3+
import type { ReanimatedHTMLElement } from '../../../ReanimatedModule/js-reanimated';
4+
import type { ICSSPseudoSelectorsManager } from '../../types/interfaces';
5+
import type { PseudoStylesBySelector } from '../../utils';
6+
import { insertPseudoSelectorCSS, removePseudoSelectorCSS } from '../domUtils';
7+
8+
let pseudoSelectorCounter = 0;
9+
10+
// CSS rules are injected in this order so that later selectors override earlier ones
11+
// when multiple are active simultaneously. Mirrors web convention (LVHA).
12+
const SELECTOR_ORDER = [':focus', ':hover', ':active'] as const;
13+
type KnownSelector = (typeof SELECTOR_ORDER)[number];
14+
15+
export default class CSSPseudoSelectorsManager
16+
implements ICSSPseudoSelectorsManager
17+
{
18+
private readonly element: ReanimatedHTMLElement;
19+
20+
private pseudoSelectorClassName: string | null = null;
21+
private eventListeners: Array<[EventTarget, string, EventListener]> = [];
22+
23+
constructor(element: ReanimatedHTMLElement) {
24+
this.element = element;
25+
}
26+
27+
update(pseudoStylesBySelector: PseudoStylesBySelector | null): void {
28+
this.removeEventListeners();
29+
30+
if (!pseudoStylesBySelector) {
31+
this.detach();
32+
return;
33+
}
34+
35+
if (!this.pseudoSelectorClassName) {
36+
this.pseudoSelectorClassName = `rps-${pseudoSelectorCounter++}`;
37+
}
38+
39+
const className = this.pseudoSelectorClassName;
40+
41+
const orderedSelectors = SELECTOR_ORDER.filter(
42+
(sel) => sel in pseudoStylesBySelector
43+
);
44+
45+
const rules = orderedSelectors
46+
.map((selector) => {
47+
const { selectorStyle } = pseudoStylesBySelector[selector];
48+
const css = webPropsBuilder.build(selectorStyle);
49+
if (!css) {
50+
return null;
51+
}
52+
// Inline styles applied by RNW have higher specificity than class selectors,
53+
// so !important is required for our state classes to win.
54+
const cssWithImportant = css
55+
.split('; ')
56+
.map((decl) => `${decl} !important`)
57+
.join('; ');
58+
return `.${this.stateClass(selector)} { ${cssWithImportant} }`;
59+
})
60+
.filter(Boolean)
61+
.join('\n');
62+
63+
insertPseudoSelectorCSS(className, rules);
64+
65+
for (const selector of orderedSelectors) {
66+
this.attachListenersForSelector(selector);
67+
}
68+
69+
// When transitionDuration is set but transitionProperty was not explicitly
70+
// specified, CSSTransitionsManager leaves transitionProperty as an empty
71+
// string and the browser won't animate anything. Default to 'all'.
72+
if (
73+
!this.element.style.transitionProperty &&
74+
this.element.style.transitionDuration
75+
) {
76+
this.element.style.transitionProperty = 'all';
77+
}
78+
}
79+
80+
unmountCleanup(): void {
81+
this.removeEventListeners();
82+
this.detach();
83+
}
84+
85+
private stateClass(selector: string): string {
86+
// ':hover' → 'rps-0-hover', ':active' → 'rps-0-active', etc.
87+
return `${this.pseudoSelectorClassName}${selector.replace(':', '-')}`;
88+
}
89+
90+
private attachListenersForSelector(selector: KnownSelector): void {
91+
const cls = this.stateClass(selector);
92+
const add = () => this.element.classList.add(cls);
93+
const remove = () => this.element.classList.remove(cls);
94+
95+
switch (selector) {
96+
case ':hover':
97+
this.on(this.element, 'mouseenter', add);
98+
this.on(this.element, 'mouseleave', remove);
99+
break;
100+
101+
case ':active':
102+
this.on(this.element, 'mousedown', add);
103+
this.on(document, 'mouseup', remove);
104+
break;
105+
106+
case ':focus': {
107+
// Mirrors native behavior: :focus only fires for text inputs
108+
const target = this.getFocusTarget();
109+
if (target) {
110+
this.on(target, 'focus', add);
111+
this.on(target, 'blur', remove);
112+
}
113+
break;
114+
}
115+
}
116+
}
117+
118+
private getFocusTarget(): Element | null {
119+
const isFocusable = (el: Element): boolean => {
120+
const input = el as HTMLInputElement;
121+
return !input.disabled && !input.readOnly;
122+
};
123+
124+
const tag = this.element.tagName?.toLowerCase();
125+
if (tag === 'input' || tag === 'textarea') {
126+
return isFocusable(this.element) ? this.element : null;
127+
}
128+
return this.element.querySelector(
129+
'input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly])'
130+
);
131+
}
132+
133+
private on(target: EventTarget, type: string, listener: EventListener): void {
134+
target.addEventListener(type, listener);
135+
this.eventListeners.push([target, type, listener]);
136+
}
137+
138+
private removeEventListeners(): void {
139+
for (const [target, type, listener] of this.eventListeners) {
140+
target.removeEventListener(type, listener);
141+
}
142+
this.eventListeners = [];
143+
}
144+
145+
private detach(): void {
146+
// Remove any state classes that may still be on the element
147+
if (this.pseudoSelectorClassName) {
148+
for (const sel of SELECTOR_ORDER) {
149+
this.element.classList.remove(this.stateClass(sel));
150+
}
151+
removePseudoSelectorCSS(this.pseudoSelectorClassName);
152+
this.pseudoSelectorClassName = null;
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)