From b18804290bb57bf1db40c6a4e63e88dd94dd68a6 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 8 Sep 2026 15:45:31 +0200 Subject: [PATCH 1/5] feat(reanimated): support css transitions and focus pseudo selectors --- .changeset/reanimated-css-pseudo-selectors.md | 5 + .../src/css/CSSStyleBinding.test.ts | 225 ++++++++++++++++++ .../src/css/CSSStyleBinding.ts | 199 ++++++++++++++++ .../src/css/filterCSSStyle.test.ts | 86 +++++++ .../src/css/filterCSSStyle.ts | 136 +++++++++++ .../plugin-reanimated/src/css/focusWithin.ts | 99 ++++++++ packages/plugin-reanimated/src/css/index.ts | 7 + .../src/css/normalizeCSSTransition.test.ts | 102 ++++++++ .../src/css/normalizeCSSTransition.ts | 196 +++++++++++++++ .../src/css/resolvePseudoStyle.test.ts | 57 +++++ .../src/css/resolvePseudoStyle.ts | 53 +++++ .../src/css/toLightningTransition.test.ts | 79 ++++++ .../src/css/toLightningTransition.ts | 108 +++++++++ packages/plugin-reanimated/src/css/types.ts | 54 +++++ .../plugin-reanimated/src/css/warnOnce.ts | 15 ++ .../src/exports/createAnimatedComponent.tsx | 90 +++++-- 16 files changed, 1492 insertions(+), 19 deletions(-) create mode 100644 .changeset/reanimated-css-pseudo-selectors.md create mode 100644 packages/plugin-reanimated/src/css/CSSStyleBinding.test.ts create mode 100644 packages/plugin-reanimated/src/css/CSSStyleBinding.ts create mode 100644 packages/plugin-reanimated/src/css/filterCSSStyle.test.ts create mode 100644 packages/plugin-reanimated/src/css/filterCSSStyle.ts create mode 100644 packages/plugin-reanimated/src/css/focusWithin.ts create mode 100644 packages/plugin-reanimated/src/css/index.ts create mode 100644 packages/plugin-reanimated/src/css/normalizeCSSTransition.test.ts create mode 100644 packages/plugin-reanimated/src/css/normalizeCSSTransition.ts create mode 100644 packages/plugin-reanimated/src/css/resolvePseudoStyle.test.ts create mode 100644 packages/plugin-reanimated/src/css/resolvePseudoStyle.ts create mode 100644 packages/plugin-reanimated/src/css/toLightningTransition.test.ts create mode 100644 packages/plugin-reanimated/src/css/toLightningTransition.ts create mode 100644 packages/plugin-reanimated/src/css/types.ts create mode 100644 packages/plugin-reanimated/src/css/warnOnce.ts diff --git a/.changeset/reanimated-css-pseudo-selectors.md b/.changeset/reanimated-css-pseudo-selectors.md new file mode 100644 index 00000000..fa61594e --- /dev/null +++ b/.changeset/reanimated-css-pseudo-selectors.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': minor +--- + +Support reanimated's CSS transitions and pseudo selectors. A `CSSStyle` on an animated component now works: `transitionProperty` / `transitionDuration` / `transitionTimingFunction` / `transitionDelay` (and the `transition` shorthand) become a Lightning transition on the node, and per-property values keyed by `default` / `:focus` / `:focus-within` swap on focus without a re-render. `:hover`, `:active` and `:active-deepest` need pointer or press state that Lightning doesn't have, so they're ignored with a dev warning, as are CSS animations (`animationName` and friends) for now. diff --git a/packages/plugin-reanimated/src/css/CSSStyleBinding.test.ts b/packages/plugin-reanimated/src/css/CSSStyleBinding.test.ts new file mode 100644 index 00000000..c86ee457 --- /dev/null +++ b/packages/plugin-reanimated/src/css/CSSStyleBinding.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PARTIAL_STYLE } from '@plextv/react-lightning'; +import type { FocusManager, LightningElement } from '@plextv/react-lightning'; + +import { CSSStyleBinding } from './CSSStyleBinding'; +import { createCSSStyleParts, filterCSSStyle, finalizeCSSStyleParts } from './filterCSSStyle'; +import { normalizeCSSTransition } from './normalizeCSSTransition'; +import { toLightningTransition } from './toLightningTransition'; +import { resetWarnOnce } from './warnOnce'; + +type Listener = (...args: unknown[]) => void; + +class FakeElement { + public focused = false; + public parent: FakeElement | null = null; + public props: { transition?: Record } = {}; + public pushes: { style: Record; hadTransition: boolean }[] = []; + + private readonly _listeners = new Map>(); + + public on(event: string, listener: Listener): () => void { + const listeners = this._listeners.get(event) ?? new Set(); + + listeners.add(listener); + this._listeners.set(event, listeners); + + return () => listeners.delete(listener); + } + + public setProps(payload: { + style?: Record; + transition?: Record; + }) { + if (payload.transition) { + this.props.transition = { ...this.props.transition, ...payload.transition }; + } + + if (payload.style) { + this.pushes.push({ style: payload.style, hadTransition: this.props.transition != null }); + } + } + + public setFocused(focused: boolean) { + this.focused = focused; + + for (const listener of this._listeners.get('focusChanged') ?? []) { + listener(this, focused); + } + } + + public asElement(): LightningElement { + return this as unknown as LightningElement; + } +} + +class FakeFocusManager { + public focusPath: LightningElement[] = []; + + private readonly _listeners = new Set(); + + public on = (_event: string, listener: Listener): (() => void) => { + this._listeners.add(listener); + + return () => this._listeners.delete(listener); + }; + + public setPath(path: FakeElement[]) { + this.focusPath = path.map((element) => element.asElement()); + + for (const listener of this._listeners) { + listener(this.focusPath); + } + } + + public asFocusManager(): FocusManager { + return this as unknown as FocusManager; + } +} + +function parse(style: Record) { + const parts = finalizeCSSStyleParts( + (() => { + const collected = createCSSStyleParts(); + + filterCSSStyle(style, collected); + + return collected; + })(), + ); + const transitions = parts.transitionProps ? normalizeCSSTransition(parts.transitionProps) : null; + + return { parts, transition: transitions ? toLightningTransition(transitions, parts) : null }; +} + +const focusStyle = { + opacity: { default: 1, ':focus': 0.5 }, + transitionProperty: 'opacity', + transitionDuration: 200, +}; + +describe('CSSStyleBinding', () => { + beforeEach(() => { + resetWarnOnce(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('pushes the selector value on focus and the resting value on blur', () => { + const element = new FakeElement(); + const { parts, transition } = parse(focusStyle); + const binding = new CSSStyleBinding(null); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + + // The resting values aren't rendered, so attaching pushes them once. + expect(element.pushes).toEqual([ + { style: expect.objectContaining({ opacity: 1 }), hadTransition: false }, + ]); + + element.setFocused(true); + expect(element.pushes.at(-1)?.style).toMatchObject({ opacity: 0.5 }); + + element.setFocused(false); + expect(element.pushes.at(-1)?.style).toMatchObject({ opacity: 1 }); + }); + + it('marks its pushes partial so unrelated props survive', () => { + const element = new FakeElement(); + const { parts, transition } = parse(focusStyle); + const binding = new CSSStyleBinding(null); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + element.setFocused(true); + + expect(element.pushes.at(-1)?.style[PARTIAL_STYLE as unknown as string]).toBe(true); + }); + + it('puts the transition on the element', () => { + const element = new FakeElement(); + const { parts, transition } = parse(focusStyle); + const binding = new CSSStyleBinding(null); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + + expect(element.props.transition).toEqual({ + alpha: { duration: 200, delay: 0, easing: 'ease' }, + }); + }); + + it('lands the resting values before the transition, so a focused mount does not animate in', () => { + const element = new FakeElement(); + + element.focused = true; + + const { parts, transition } = parse(focusStyle); + const binding = new CSSStyleBinding(null); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + + expect(element.pushes).toEqual([ + { style: expect.objectContaining({ opacity: 0.5 }), hadTransition: false }, + ]); + }); + + it('activates :focus-within when a descendant takes focus', () => { + const focusManager = new FakeFocusManager(); + const element = new FakeElement(); + const child = new FakeElement(); + + child.parent = element; + + const { parts, transition } = parse({ + transform: { default: [{ translateY: 0 }], ':focus-within': [{ translateY: 10 }] }, + transitionProperty: 'transform', + transitionDuration: 200, + }); + const binding = new CSSStyleBinding(focusManager.asFocusManager()); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + + focusManager.setPath([element, child]); + expect(element.pushes.at(-1)?.style).toMatchObject({ transform: [{ translateY: 10 }] }); + + focusManager.setPath([]); + expect(element.pushes.at(-1)?.style).toMatchObject({ transform: [{ translateY: 0 }] }); + }); + + it('does not push again when a re-render produces an equal value', () => { + const element = new FakeElement(); + const binding = new CSSStyleBinding(null); + const first = parse(focusStyle); + + binding.setElement(element.asElement()); + binding.update(first.parts, first.transition); + element.setFocused(true); + + const pushes = element.pushes.length; + const second = parse(focusStyle); + + binding.update(second.parts, second.transition); + + expect(element.pushes).toHaveLength(pushes); + }); + + it('stops listening once destroyed', () => { + const element = new FakeElement(); + const { parts, transition } = parse(focusStyle); + const binding = new CSSStyleBinding(null); + + binding.setElement(element.asElement()); + binding.update(parts, transition); + binding.destroy(); + + const pushes = element.pushes.length; + + element.setFocused(true); + + expect(element.pushes).toHaveLength(pushes); + }); +}); diff --git a/packages/plugin-reanimated/src/css/CSSStyleBinding.ts b/packages/plugin-reanimated/src/css/CSSStyleBinding.ts new file mode 100644 index 00000000..01876bd8 --- /dev/null +++ b/packages/plugin-reanimated/src/css/CSSStyleBinding.ts @@ -0,0 +1,199 @@ +import { PARTIAL_STYLE } from '@plextv/react-lightning'; +import type { + FocusManager, + LightningElement, + LightningElementStyle, +} from '@plextv/react-lightning'; + +import { createCSSStyleParts } from './filterCSSStyle'; +import { trackFocusWithin } from './focusWithin'; +import { resolvePseudoStyle } from './resolvePseudoStyle'; +import type { CSSStyleParts, LightningTransition, SupportedPseudoSelector } from './types'; +import { warnOnce } from './warnOnce'; + +function isSameValue(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => isSameValue(item, b[index])); + } + + if (a && b && typeof a === 'object' && typeof b === 'object') { + const left = a as Record; + const right = b as Record; + const keys = Object.keys(left); + + return ( + keys.length === Object.keys(right).length && + keys.every((key) => isSameValue(left[key], right[key])) + ); + } + + return false; +} + +/** + * Runs the CSS side of an animated style for one element: keeps the transition + * settings on the node and swaps the pseudo-selector props as focus moves. + */ +export class CSSStyleBinding { + private readonly _focusManager: FocusManager | null; + private _element: LightningElement | null = null; + private _parts: CSSStyleParts = createCSSStyleParts(); + private _transition: LightningTransition | null = null; + private _active = new Set(); + private _applied: Record = {}; + private _hasPushed = false; + private _disposers: (() => void)[] = []; + + public constructor(focusManager: FocusManager | null) { + this._focusManager = focusManager; + } + + /** Called after every commit that produced new style objects. */ + public update(parts: CSSStyleParts, transition: LightningTransition | null): void { + this._parts = parts; + this._transition = transition; + this._resubscribe(); + this._apply(); + } + + public setElement(element: LightningElement | null): void { + if (this._element === element) { + return; + } + + this._element = element; + // A fresh node holds none of our props (and none of them are rendered). + this._applied = {}; + this._hasPushed = false; + this._resubscribe(); + this._apply(); + } + + public destroy(): void { + this._unsubscribe(); + this._element = null; + } + + private _unsubscribe(): void { + for (const dispose of this._disposers) { + dispose(); + } + + this._disposers = []; + } + + private _resubscribe(): void { + this._unsubscribe(); + + const element = this._element; + + if (!element) { + return; + } + + const active = new Set(); + + if (this._parts.pseudoStyles[':focus']) { + if (element.focused) { + active.add(':focus'); + } + + this._disposers.push( + element.on('focusChanged', (_element: LightningElement, focused: boolean) => { + this._setActive(':focus', focused); + }), + ); + } + + if (this._parts.pseudoStyles[':focus-within']) { + if (this._focusManager) { + const { within, dispose } = trackFocusWithin(this._focusManager, element, (isWithin) => { + this._setActive(':focus-within', isWithin); + }); + + if (within) { + active.add(':focus-within'); + } + + this._disposers.push(dispose); + } else { + warnOnce('":focus-within" needs a FocusManagerProvider above the animated component.'); + } + } + + this._active = active; + } + + private _setActive(selector: SupportedPseudoSelector, active: boolean): void { + if (active) { + this._active.add(selector); + } else { + this._active.delete(selector); + } + + this._apply(); + } + + private _apply(): void { + const element = this._element; + + if (!element) { + return; + } + + const resolved = resolvePseudoStyle( + this._parts.base, + this._parts.pseudoStyles, + this._active, + ) as Record; + const changed: Record = {}; + let hasChanges = false; + + for (const prop in resolved) { + if (!isSameValue(resolved[prop], this._applied[prop])) { + changed[prop] = resolved[prop]; + hasChanges = true; + } + } + + this._applied = resolved; + + if (!hasChanges) { + this._syncTransition(element); + + return; + } + + // The resting values land before the transition does, so a node attaching + // in an active state doesn't animate in from the node's defaults. + const deferTransition = !this._hasPushed; + + this._hasPushed = true; + + if (!deferTransition) { + this._syncTransition(element); + } + + this._push(element, changed); + + if (deferTransition) { + this._syncTransition(element); + } + } + + private _syncTransition(element: LightningElement): void { + if (this._transition) { + element.setProps({ transition: this._transition }); + } + } + + private _push(element: LightningElement, style: Record): void { + (style as Record)[PARTIAL_STYLE] = true; + + element.setProps({ style: style as LightningElementStyle }); + } +} diff --git a/packages/plugin-reanimated/src/css/filterCSSStyle.test.ts b/packages/plugin-reanimated/src/css/filterCSSStyle.test.ts new file mode 100644 index 00000000..932063f7 --- /dev/null +++ b/packages/plugin-reanimated/src/css/filterCSSStyle.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createCSSStyleParts, filterCSSStyle, finalizeCSSStyleParts } from './filterCSSStyle'; +import { resetWarnOnce } from './warnOnce'; + +function filter(...styles: Record[]) { + const parts = createCSSStyleParts(); + + for (const style of styles) { + filterCSSStyle(style, parts); + } + + return finalizeCSSStyleParts(parts); +} + +describe('filterCSSStyle', () => { + beforeEach(() => { + resetWarnOnce(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('keeps plain style props in the rendered style', () => { + expect(filter({ opacity: 0.5, padding: 10 }).style).toEqual({ opacity: 0.5, padding: 10 }); + }); + + it('splits a pseudo-selector value into a resting value and an override', () => { + const parts = filter({ + opacity: { default: 1, ':focus': 0.5 }, + padding: 10, + }); + + expect(parts.style).toEqual({ padding: 10 }); + expect(parts.base).toEqual({ opacity: 1 }); + expect(parts.pseudoStyles).toEqual({ ':focus': { opacity: 0.5 } }); + }); + + it('takes the resting value from an earlier style object', () => { + const parts = filter({ opacity: 1 }, { opacity: { ':focus': 0.5 } }); + + expect(parts.style).toEqual({}); + expect(parts.base).toEqual({ opacity: 1 }); + }); + + it('collects transition props', () => { + const parts = filter({ + transitionProperty: 'opacity', + transitionDuration: 200, + }); + + expect(parts.style).toEqual({}); + expect(parts.transitionProps).toEqual({ + transitionProperty: 'opacity', + transitionDuration: 200, + }); + }); + + it('lets the transition shorthand drop everything set before it', () => { + const parts = filter({ transitionDuration: 200 }, { transition: 'opacity 100ms' }); + + expect(parts.transitionProps).toEqual({ transition: 'opacity 100ms' }); + }); + + it('ignores selectors that need pointer or press state', () => { + const parts = filter({ opacity: { default: 1, ':hover': 0.5, ':focus': 0.8 } }); + + expect(parts.pseudoStyles).toEqual({ ':focus': { opacity: 0.8 } }); + expect(console.warn).toHaveBeenCalledOnce(); + }); + + it('ignores css animation props', () => { + const parts = filter({ animationName: { from: { opacity: 0 } }, animationDuration: 100 }); + + expect(parts.style).toEqual({}); + expect(parts.transitionProps).toBeNull(); + }); + + it('treats an explicit undefined as unset', () => { + expect(filter({ opacity: undefined }).style).toEqual({}); + }); + + it('leaves a transform array alone', () => { + const parts = filter({ transform: [{ translateY: 10 }] }); + + expect(parts.style).toEqual({ transform: [{ translateY: 10 }] }); + }); +}); diff --git a/packages/plugin-reanimated/src/css/filterCSSStyle.ts b/packages/plugin-reanimated/src/css/filterCSSStyle.ts new file mode 100644 index 00000000..0feedf05 --- /dev/null +++ b/packages/plugin-reanimated/src/css/filterCSSStyle.ts @@ -0,0 +1,136 @@ +import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; + +import { + type CSSStyleParts, + PSEUDO_SELECTORS, + type SupportedPseudoSelector, + SUPPORTED_PSEUDO_SELECTORS, +} from './types'; +import { warnOnce } from './warnOnce'; + +const TRANSITION_PROPS: ReadonlySet = new Set([ + 'transition', + 'transitionProperty', + 'transitionDuration', + 'transitionTimingFunction', + 'transitionDelay', + 'transitionBehavior', +]); + +const ANIMATION_PROPS: ReadonlySet = new Set([ + 'animation', + 'animationName', + 'animationDuration', + 'animationTimingFunction', + 'animationDelay', + 'animationIterationCount', + 'animationDirection', + 'animationFillMode', + 'animationPlayState', +]); + +const SUPPORTED: ReadonlySet = new Set(SUPPORTED_PSEUDO_SELECTORS); +const KNOWN: ReadonlySet = new Set(PSEUDO_SELECTORS); + +function isPseudoSelectorValue(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const keys = Object.keys(value); + + return keys.length > 0 && keys.every((key) => key === 'default' || key.startsWith(':')); +} + +export function createCSSStyleParts(style: DefaultStyle = {}): CSSStyleParts { + return { style, base: {}, pseudoStyles: {}, transitionProps: null }; +} + +/** + * Moves the resting value of every pseudo-selector prop out of the rendered + * style. Runs once the whole style array is folded, since a later style object + * can turn a plain prop into a pseudo one. + */ +export function finalizeCSSStyleParts(parts: CSSStyleParts): CSSStyleParts { + const style = parts.style as Record; + const base = parts.base as Record; + + for (const selector in parts.pseudoStyles) { + const selectorStyle = parts.pseudoStyles[selector as SupportedPseudoSelector] as Record< + string, + unknown + >; + + for (const prop in selectorStyle) { + if (prop in style) { + base[prop] = style[prop]; + delete style[prop]; + } + } + } + + return parts; +} + +/** + * Splits one style object into resting values, pseudo-selector overrides and + * transition settings, accumulating into `parts` so a style array folds into a + * single result. Mirrors reanimated's filterCSSAndStyleProperties. + */ +export function filterCSSStyle(style: Record, parts: CSSStyleParts): void { + for (const prop in style) { + const value = style[prop]; + + // An explicit undefined reads as "not set", same as reanimated. + if (value === undefined) { + continue; + } + + if (TRANSITION_PROPS.has(prop)) { + // The `transition` shorthand drops everything set before it. + if (prop === 'transition') { + parts.transitionProps = { transition: value }; + } else { + (parts.transitionProps ??= {})[prop] = value; + } + + continue; + } + + if (ANIMATION_PROPS.has(prop)) { + warnOnce(`CSS animations are not supported on Lightning yet, ignoring "${prop}".`); + + continue; + } + + if (isPseudoSelectorValue(value)) { + if (value.default !== undefined) { + (parts.style as Record)[prop] = value.default; + } + + for (const selector in value) { + if (selector === 'default') { + continue; + } + + if (!SUPPORTED.has(selector)) { + warnOnce( + KNOWN.has(selector) + ? `Pseudo selector "${selector}" needs pointer or press state, which Lightning doesn't have. Ignoring it.` + : `Pseudo selector "${selector}" is not supported on Lightning, ignoring it.`, + ); + + continue; + } + + const pseudoStyle = (parts.pseudoStyles[selector as SupportedPseudoSelector] ??= {}); + + (pseudoStyle as Record)[prop] = value[selector]; + } + + continue; + } + + (parts.style as Record)[prop] = value; + } +} diff --git a/packages/plugin-reanimated/src/css/focusWithin.ts b/packages/plugin-reanimated/src/css/focusWithin.ts new file mode 100644 index 00000000..05083ec4 --- /dev/null +++ b/packages/plugin-reanimated/src/css/focusWithin.ts @@ -0,0 +1,99 @@ +import type { FocusManager, LightningElement } from '@plextv/react-lightning'; + +type Listener = (within: boolean) => void; + +/** + * `:focus-within` has to answer "does this element contain the focused one", + * and a wrapper View never appears in the focus path. One tracker per focus + * manager walks up from the focused leaf once per focus change instead of + * every subscriber walking its own ancestors. + */ +class FocusWithinTracker { + private readonly _focusManager: FocusManager; + private readonly _listeners = new Map(); + private _within = new Set(); + private _unsubscribe: (() => void) | null = null; + + public constructor(focusManager: FocusManager) { + this._focusManager = focusManager; + } + + /** Returns whether the element already contains focus. */ + public add(element: LightningElement, listener: Listener): boolean { + this._listeners.set(element, listener); + this._unsubscribe ??= this._focusManager.on('focusPathChanged', this._onFocusPathChanged); + + const within = this._contains(element, this._focusManager.focusPath); + + if (within) { + this._within.add(element); + } + + return within; + } + + public remove(element: LightningElement): void { + this._listeners.delete(element); + this._within.delete(element); + + if (!this._listeners.size) { + this._unsubscribe?.(); + this._unsubscribe = null; + } + } + + private _contains(element: LightningElement, path: LightningElement[]): boolean { + for (let node: LightningElement | null | undefined = path.at(-1); node; node = node.parent) { + if (node === element) { + return true; + } + } + + return false; + } + + private _onFocusPathChanged = (path: LightningElement[]): void => { + const next = new Set(); + + for (let node: LightningElement | null | undefined = path.at(-1); node; node = node.parent) { + if (this._listeners.has(node)) { + next.add(node); + } + } + + const previous = this._within; + + this._within = next; + + for (const element of next) { + if (!previous.has(element)) { + this._listeners.get(element)?.(true); + } + } + + for (const element of previous) { + if (!next.has(element)) { + this._listeners.get(element)?.(false); + } + } + }; +} + +const trackers = new WeakMap, FocusWithinTracker>(); + +export function trackFocusWithin( + focusManager: FocusManager, + element: LightningElement, + listener: Listener, +): { within: boolean; dispose: () => void } { + let tracker = trackers.get(focusManager); + + if (!tracker) { + tracker = new FocusWithinTracker(focusManager); + trackers.set(focusManager, tracker); + } + + const within = tracker.add(element, listener); + + return { within, dispose: () => tracker?.remove(element) }; +} diff --git a/packages/plugin-reanimated/src/css/index.ts b/packages/plugin-reanimated/src/css/index.ts new file mode 100644 index 00000000..0081ae28 --- /dev/null +++ b/packages/plugin-reanimated/src/css/index.ts @@ -0,0 +1,7 @@ +export { CSSStyleBinding } from './CSSStyleBinding'; +export { createCSSStyleParts, filterCSSStyle, finalizeCSSStyleParts } from './filterCSSStyle'; +export { normalizeCSSTransition } from './normalizeCSSTransition'; +export { resolvePseudoStyle } from './resolvePseudoStyle'; +export { toLightningTransition } from './toLightningTransition'; +export { hasPseudoStyles } from './types'; +export type { CSSStyleParts, LightningTransition, PseudoStyles } from './types'; diff --git a/packages/plugin-reanimated/src/css/normalizeCSSTransition.test.ts b/packages/plugin-reanimated/src/css/normalizeCSSTransition.test.ts new file mode 100644 index 00000000..c2f0ee07 --- /dev/null +++ b/packages/plugin-reanimated/src/css/normalizeCSSTransition.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { normalizeCSSTransition, timeToMs } from './normalizeCSSTransition'; +import { resetWarnOnce } from './warnOnce'; + +describe('timeToMs', () => { + it('reads numbers as milliseconds and parses time units', () => { + expect(timeToMs(200)).toBe(200); + expect(timeToMs('200ms')).toBe(200); + expect(timeToMs('0.3s')).toBe(300); + expect(timeToMs('nope', 50)).toBe(50); + }); +}); + +describe('normalizeCSSTransition', () => { + beforeEach(() => { + resetWarnOnce(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('reads a single property', () => { + expect( + normalizeCSSTransition({ + transitionProperty: 'opacity', + transitionDuration: 200, + transitionTimingFunction: 'ease-in', + transitionDelay: '50ms', + }), + ).toEqual(new Map([['opacity', { duration: 200, delay: 50, easing: 'ease-in' }]])); + }); + + it('repeats a shorter settings list over the properties', () => { + const result = normalizeCSSTransition({ + transitionProperty: ['transform', 'opacity', 'backgroundColor'], + transitionDuration: [100, 200], + }); + + expect(result?.get('transform')?.duration).toBe(100); + expect(result?.get('opacity')?.duration).toBe(200); + expect(result?.get('backgroundColor')?.duration).toBe(100); + }); + + it('defaults the timing function to ease', () => { + expect( + normalizeCSSTransition({ transitionProperty: 'opacity', transitionDuration: 1 })?.get( + 'opacity', + )?.easing, + ).toBe('ease'); + }); + + it('spells a reanimated cubicBezier easing the way the renderer parses it', () => { + const easing = { x1: 0.22, y1: 1, x2: 0.36, y2: 1 }; + + expect( + normalizeCSSTransition({ + transitionProperty: 'transform', + transitionDuration: 200, + transitionTimingFunction: easing, + })?.get('transform')?.easing, + ).toBe('cubic-bezier(0.22, 1, 0.36, 1)'); + }); + + it('falls back to linear for a timing function the renderer has no equivalent for', () => { + expect( + normalizeCSSTransition({ + transitionProperty: 'opacity', + transitionDuration: 200, + transitionTimingFunction: 'steps(4, jump-end)', + })?.get('opacity')?.easing, + ).toBe('linear'); + }); + + it('drops "none" and returns null when nothing is left', () => { + expect( + normalizeCSSTransition({ transitionProperty: 'none', transitionDuration: 200 }), + ).toBeNull(); + expect(normalizeCSSTransition({})).toBeNull(); + }); + + it('defaults the property to all when only a duration is given', () => { + expect(normalizeCSSTransition({ transitionDuration: 200 })?.has('all')).toBe(true); + }); + + it('parses the transition shorthand', () => { + expect( + normalizeCSSTransition({ transition: 'transform 200ms ease-in 50ms, opacity 0.3s' }), + ).toEqual( + new Map([ + ['transform', { duration: 200, delay: 50, easing: 'ease-in' }], + ['opacity', { duration: 300, delay: 0, easing: 'ease' }], + ]), + ); + }); + + it('parses a shorthand cubic-bezier without splitting on its commas', () => { + expect( + normalizeCSSTransition({ transition: 'transform 200ms cubic-bezier(0.22, 1, 0.36, 1)' })?.get( + 'transform', + )?.easing, + ).toBe('cubic-bezier(0.22, 1, 0.36, 1)'); + }); +}); diff --git a/packages/plugin-reanimated/src/css/normalizeCSSTransition.ts b/packages/plugin-reanimated/src/css/normalizeCSSTransition.ts new file mode 100644 index 00000000..08d7183d --- /dev/null +++ b/packages/plugin-reanimated/src/css/normalizeCSSTransition.ts @@ -0,0 +1,196 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; + +import { resolveTimingEasing } from '../animation/resolveTimingEasing'; +import type { CSSTransitionProps, PropertyTransitions } from './types'; +import { warnOnce } from './warnOnce'; + +type ShorthandItem = { + property: string; + duration: number; + delay: number; + easing: AnimationSettings['easing']; +}; + +const TIME_PATTERN = /^-?\d*\.?\d+(ms|s)$/; + +const EASING_KEYWORDS: ReadonlySet = new Set([ + 'linear', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'step-start', + 'step-end', +]); + +// CSS default for transition-timing-function. +const DEFAULT_EASING = 'ease'; + +function toArray(value: T | T[] | undefined): T[] { + if (value === undefined) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} + +/** CSS repeats a shorter settings list over the property list. */ +function at(values: T[], index: number, fallback: T): T { + return values.length ? (values[index % values.length] as T) : fallback; +} + +export function timeToMs(value: unknown, fallback = 0): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'string') { + const amount = Number.parseFloat(value); + + if (!Number.isNaN(amount)) { + return value.endsWith('ms') ? amount : amount * 1000; + } + } + + return fallback; +} + +/** + * The renderer parses `ease*` names and `cubic-bezier(...)` itself, and takes a + * function easing directly. Anything else (steps(), linear() with points) has + * no equivalent, so it falls back to linear. + */ +export function resolveCSSTimingFunction(value: unknown): AnimationSettings['easing'] { + if (value == null) { + return DEFAULT_EASING; + } + + if (typeof value === 'string') { + if (EASING_KEYWORDS.has(value) || value.startsWith('cubic-bezier')) { + return value; + } + + warnOnce(`Timing function "${value}" is not supported on Lightning, using linear.`); + + return 'linear'; + } + + if (typeof value === 'object') { + const bezier = value as { x1?: number; y1?: number; x2?: number; y2?: number }; + + if (typeof bezier.x1 === 'number' && typeof bezier.x2 === 'number') { + return `cubic-bezier(${bezier.x1}, ${bezier.y1}, ${bezier.x2}, ${bezier.y2})`; + } + } + + // Easing.* functions and Easing.bezier factories still work. + return resolveTimingEasing(value); +} + +function splitTopLevel(value: string, separator: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + + for (const char of value) { + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + } + + if (depth === 0 && char === separator) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + + parts.push(current); + + return parts.map((part) => part.trim()).filter(Boolean); +} + +function parseShorthandItem(item: string): ShorthandItem | null { + const tokens = splitTopLevel(item, ' '); + const times: number[] = []; + let property: string | null = null; + let easing: unknown; + + for (const token of tokens) { + if (TIME_PATTERN.test(token)) { + times.push(timeToMs(token)); + } else if (EASING_KEYWORDS.has(token) || token.includes('(')) { + easing = token; + } else if (property === null) { + property = token; + } else { + warnOnce(`Ignoring unknown transition shorthand token "${token}".`); + } + } + + if (property === 'none') { + return null; + } + + return { + property: property ?? 'all', + duration: times[0] ?? 0, + delay: times[1] ?? 0, + easing: resolveCSSTimingFunction(easing), + }; +} + +/** + * Turns the `transition*` props (or the `transition` shorthand) into per-property + * animation settings. `all` is kept as a key and expanded by the caller. + */ +export function normalizeCSSTransition(props: CSSTransitionProps): PropertyTransitions | null { + const transitions: PropertyTransitions = new Map(); + + if (typeof props.transition === 'string') { + for (const item of splitTopLevel(props.transition, ',')) { + const parsed = parseShorthandItem(item); + + if (parsed) { + transitions.set(parsed.property, { + duration: parsed.duration, + delay: parsed.delay, + easing: parsed.easing, + }); + } + } + + return transitions.size ? transitions : null; + } + + const properties = toArray(props.transitionProperty as string | string[] | undefined); + const durations = toArray(props.transitionDuration as unknown[]); + const delays = toArray(props.transitionDelay as unknown[]); + const easings = toArray(props.transitionTimingFunction as unknown[]); + + // CSS defaults transition-property to `all`; without any timing there is + // nothing to animate. + if (!properties.length) { + if (!durations.length) { + return null; + } + + properties.push('all'); + } + + properties.forEach((property, index) => { + if (property === 'none') { + return; + } + + transitions.set(property, { + duration: timeToMs(at(durations, index, 0)), + delay: timeToMs(at(delays, index, 0)), + easing: resolveCSSTimingFunction(at(easings, index, undefined)), + }); + }); + + return transitions.size ? transitions : null; +} diff --git a/packages/plugin-reanimated/src/css/resolvePseudoStyle.test.ts b/packages/plugin-reanimated/src/css/resolvePseudoStyle.test.ts new file mode 100644 index 00000000..8c9f544d --- /dev/null +++ b/packages/plugin-reanimated/src/css/resolvePseudoStyle.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolvePseudoStyle } from './resolvePseudoStyle'; +import type { SupportedPseudoSelector } from './types'; +import { resetWarnOnce } from './warnOnce'; + +const active = (...selectors: SupportedPseudoSelector[]) => new Set(selectors); + +describe('resolvePseudoStyle', () => { + beforeEach(() => { + resetWarnOnce(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('resolves to the resting values while nothing is active', () => { + expect(resolvePseudoStyle({ opacity: 1 }, { ':focus': { opacity: 0.5 } }, active())).toEqual({ + opacity: 1, + }); + }); + + it('resolves to the selector value while it is active', () => { + expect( + resolvePseudoStyle({ opacity: 1 }, { ':focus': { opacity: 0.5 } }, active(':focus')), + ).toEqual({ opacity: 0.5 }); + }); + + it('lets :focus win over :focus-within', () => { + expect( + resolvePseudoStyle( + { opacity: 1 }, + { ':focus': { opacity: 0.5 }, ':focus-within': { opacity: 0.8 } }, + active(':focus', ':focus-within'), + ), + ).toEqual({ opacity: 0.5 }); + }); + + it('keeps a lower-priority active value for a prop the winner does not set', () => { + expect( + resolvePseudoStyle( + { opacity: 1, padding: 0 }, + { ':focus': { opacity: 0.5 }, ':focus-within': { padding: 10 } }, + active(':focus', ':focus-within'), + ), + ).toEqual({ opacity: 0.5, padding: 10 }); + }); + + it('only returns props a selector mentions', () => { + expect( + resolvePseudoStyle({ opacity: 1, padding: 10 }, { ':focus': { opacity: 0.5 } }, active()), + ).toEqual({ opacity: 1 }); + }); + + it('drops a prop with no resting value, since it could never be reverted', () => { + expect(resolvePseudoStyle({}, { ':focus': { opacity: 0.5 } }, active())).toEqual({}); + expect(console.warn).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/plugin-reanimated/src/css/resolvePseudoStyle.ts b/packages/plugin-reanimated/src/css/resolvePseudoStyle.ts new file mode 100644 index 00000000..80245b81 --- /dev/null +++ b/packages/plugin-reanimated/src/css/resolvePseudoStyle.ts @@ -0,0 +1,53 @@ +import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; + +import { + type PseudoStyles, + type SupportedPseudoSelector, + SUPPORTED_PSEUDO_SELECTORS, +} from './types'; +import { warnOnce } from './warnOnce'; + +/** + * Resolves the pseudo-selector props for the currently active states: the + * highest-priority active selector that sets a prop wins, anything else falls + * back to the resting value. Only props some selector mentions are returned, so + * the caller can push (and revert) a partial style. + */ +export function resolvePseudoStyle( + base: DefaultStyle, + pseudoStyles: PseudoStyles, + active: ReadonlySet, +): DefaultStyle { + const resolved: Record = {}; + const baseStyle = base as Record; + + for (const selector of SUPPORTED_PSEUDO_SELECTORS) { + const selectorStyle = pseudoStyles[selector] as Record | undefined; + + if (!selectorStyle) { + continue; + } + + for (const prop in selectorStyle) { + if (active.has(selector)) { + resolved[prop] = selectorStyle[prop]; + } else if (!(prop in resolved)) { + resolved[prop] = baseStyle[prop]; + } + } + } + + for (const prop in resolved) { + if (resolved[prop] === undefined) { + // Lightning merges style updates, so a prop that reverts to undefined is + // never repainted and the state would stick. + warnOnce( + `"${prop}" has a pseudo-selector value but no default, so it can't be reverted. Add a "default".`, + ); + + delete resolved[prop]; + } + } + + return resolved as DefaultStyle; +} diff --git a/packages/plugin-reanimated/src/css/toLightningTransition.test.ts b/packages/plugin-reanimated/src/css/toLightningTransition.test.ts new file mode 100644 index 00000000..45544cd5 --- /dev/null +++ b/packages/plugin-reanimated/src/css/toLightningTransition.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { createCSSStyleParts } from './filterCSSStyle'; +import { toLightningTransition } from './toLightningTransition'; +import type { CSSStyleParts, PropertyTransitions } from './types'; + +const settings = { duration: 200, delay: 0, easing: 'ease' as const }; + +function parts(overrides: Partial): CSSStyleParts { + return { ...createCSSStyleParts(), ...overrides }; +} + +describe('toLightningTransition', () => { + it('renames react-native props to their lightning equivalent', () => { + const transitions: PropertyTransitions = new Map([ + ['opacity', settings], + ['backgroundColor', settings], + ]); + + expect(toLightningTransition(transitions, parts({}))).toEqual({ + alpha: settings, + color: settings, + }); + }); + + it('expands a transform onto the axes the style uses', () => { + const transitions: PropertyTransitions = new Map([['transform', settings]]); + const styleParts = parts({ + base: { transform: [{ translateY: 0 }, { scale: 1 }] }, + }); + + expect(toLightningTransition(transitions, styleParts)).toEqual({ + y: settings, + scaleX: settings, + scaleY: settings, + }); + }); + + it('picks up an axis that only a pseudo state uses', () => { + const transitions: PropertyTransitions = new Map([['transform', settings]]); + const styleParts = parts({ + base: { transform: [{ translateY: 0 }] }, + pseudoStyles: { ':focus': { transform: [{ translateY: 10 }, { translateX: 5 }] } }, + }); + + expect(Object.keys(toLightningTransition(transitions, styleParts) ?? {}).sort()).toEqual([ + 'x', + 'y', + ]); + }); + + it('expands "all" over the props the style sets, not the whole element', () => { + const transitions: PropertyTransitions = new Map([['all', settings]]); + const styleParts = parts({ + style: { padding: 10 }, + base: { opacity: 1 }, + pseudoStyles: { ':focus': { opacity: 0.5 } }, + }); + + expect(toLightningTransition(transitions, styleParts)).toEqual({ + padding: settings, + alpha: settings, + }); + }); + + it('lets an explicit property beat "all"', () => { + const transitions: PropertyTransitions = new Map([ + ['all', settings], + ['opacity', { ...settings, duration: 50 }], + ]); + const styleParts = parts({ base: { opacity: 1 } }); + + expect(toLightningTransition(transitions, styleParts)?.alpha?.duration).toBe(50); + }); + + it('returns null when nothing maps', () => { + expect(toLightningTransition(new Map(), parts({}))).toBeNull(); + }); +}); diff --git a/packages/plugin-reanimated/src/css/toLightningTransition.ts b/packages/plugin-reanimated/src/css/toLightningTransition.ts new file mode 100644 index 00000000..a081f00e --- /dev/null +++ b/packages/plugin-reanimated/src/css/toLightningTransition.ts @@ -0,0 +1,108 @@ +import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; + +import type { LightningElementStyle } from '@plextv/react-lightning'; +import { parseTransform } from '@plextv/react-lightning-plugin-css-transform'; + +import { getTransitionProperty } from '../utils/getTransitionProperty'; +import type { CSSStyleParts, LightningTransition, PropertyTransitions } from './types'; + +const TRANSFORM_KEYS = { + translateX: 'x', + translateY: 'y', + scaleX: 'scaleX', + scaleY: 'scaleY', + rotation: 'rotation', +} as const satisfies Record; + +/** Every style object the CSS style drives, resting values and pseudo overrides. */ +function eachStyle(parts: CSSStyleParts): Record[] { + const styles: Record[] = [ + parts.style as Record, + parts.base as Record, + ]; + + for (const selector in parts.pseudoStyles) { + styles.push( + parts.pseudoStyles[selector as keyof CSSStyleParts['pseudoStyles']] as Record< + string, + unknown + >, + ); + } + + return styles; +} + +/** + * A transform lands on the node as x / y / scale / rotation, so the transition + * has to be set on the axes the style actually uses. + */ +function transformKeys(parts: CSSStyleParts): (keyof LightningElementStyle)[] { + const keys = new Set(); + + for (const style of eachStyle(parts)) { + const transform = parseTransform(style.transform as Parameters[0]); + + for (const key in transform) { + const lightningKey = TRANSFORM_KEYS[key as keyof typeof TRANSFORM_KEYS]; + + if (lightningKey) { + keys.add(lightningKey); + } + } + } + + return [...keys]; +} + +function drivenProps(parts: CSSStyleParts): string[] { + const props = new Set(); + + for (const style of eachStyle(parts)) { + for (const prop in style) { + props.add(prop); + } + } + + return [...props]; +} + +/** + * Maps the normalized per-property settings onto Lightning node props. A prop + * change then animates on its own, which is what makes a CSS transition work. + * + * `all` only covers the props this style object sets, not every prop on the + * element: a transition on width or height would animate layout. + */ +export function toLightningTransition( + transitions: PropertyTransitions, + parts: CSSStyleParts, +): LightningTransition | null { + const result: LightningTransition = {}; + const settingsByProp = new Map(transitions); + const all = settingsByProp.get('all'); + + settingsByProp.delete('all'); + + if (all) { + for (const prop of drivenProps(parts)) { + if (!settingsByProp.has(prop)) { + settingsByProp.set(prop, all); + } + } + } + + for (const [prop, settings] of settingsByProp) { + if (prop === 'transform') { + for (const key of transformKeys(parts)) { + result[key] = settings; + } + + continue; + } + + result[getTransitionProperty(prop as keyof DefaultStyle)] = settings; + } + + return Object.keys(result).length ? result : null; +} diff --git a/packages/plugin-reanimated/src/css/types.ts b/packages/plugin-reanimated/src/css/types.ts new file mode 100644 index 00000000..5103fc7a --- /dev/null +++ b/packages/plugin-reanimated/src/css/types.ts @@ -0,0 +1,54 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; + +import type { Animatable, LightningElementStyle } from '@plextv/react-lightning'; + +/** Cascade order, later wins. Mirrors reanimated's NATIVE_PSEUDO_SELECTORS_PRIORITY. */ +export const PSEUDO_SELECTORS = [ + ':focus-within', + ':focus', + ':hover', + ':active', + ':active-deepest', +] as const; + +export type PseudoSelector = (typeof PSEUDO_SELECTORS)[number]; + +/** + * A TV has no pointer and no press state on the element, so only the focus + * selectors can be resolved here. + */ +export const SUPPORTED_PSEUDO_SELECTORS = [':focus-within', ':focus'] as const; + +export type SupportedPseudoSelector = (typeof SUPPORTED_PSEUDO_SELECTORS)[number]; + +export type PseudoStyles = Partial>; + +export type LightningTransition = NonNullable['transition']>; + +/** Raw `transition*` props, exactly as they came off the style object. */ +export type CSSTransitionProps = Record; + +export type CSSStyleParts = { + /** Plain values, rendered as the element's normal style. */ + style: DefaultStyle; + /** + * Resting values for the props a pseudo selector touches. Held back from the + * rendered style: those props are pushed to the node by the binding, so a + * re-render can't clobber an active state. + */ + base: DefaultStyle; + pseudoStyles: PseudoStyles; + transitionProps: CSSTransitionProps | null; +}; + +/** Per-property transition settings, keyed by react-native style prop or `all`. */ +export type PropertyTransitions = Map>; + +export function hasPseudoStyles(parts: CSSStyleParts): boolean { + for (const _selector in parts.pseudoStyles) { + return true; + } + + return false; +} diff --git a/packages/plugin-reanimated/src/css/warnOnce.ts b/packages/plugin-reanimated/src/css/warnOnce.ts new file mode 100644 index 00000000..c997a493 --- /dev/null +++ b/packages/plugin-reanimated/src/css/warnOnce.ts @@ -0,0 +1,15 @@ +const warned = new Set(); + +/** Style objects are rebuilt every render, so a plain warn would spam the log. */ +export function warnOnce(message: string): void { + if (!import.meta.env.DEV || warned.has(message)) { + return; + } + + warned.add(message); + console.warn(`[react-lightning] ${message}`); +} + +export function resetWarnOnce(): void { + warned.clear(); +} diff --git a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx index 82bab72f..9ed0b735 100644 --- a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx +++ b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx @@ -1,6 +1,7 @@ import { Component, type ComponentType, + type ContextType, type ForwardedRef, type ForwardRefExoticComponent, forwardRef, @@ -13,7 +14,7 @@ import type { LayoutAnimationFunction, } from 'react-native-reanimated-original'; -import { PARTIAL_STYLE } from '@plextv/react-lightning'; +import { FocusManagerContext, PARTIAL_STYLE } from '@plextv/react-lightning'; import type { LightningElement, LightningElementProps, @@ -22,6 +23,16 @@ import type { RendererNode, } from '@plextv/react-lightning'; +import { + CSSStyleBinding, + type CSSStyleParts, + createCSSStyleParts, + filterCSSStyle, + finalizeCSSStyleParts, + hasPseudoStyles, + normalizeCSSTransition, + toLightningTransition, +} from '../css'; import { isAnimatedStyle } from '../isAnimatedStyle'; import type { AnimatedStyle } from '../types/AnimatedStyle'; import type { ReanimatedAnimation } from '../types/ReanimatedAnimation'; @@ -29,6 +40,8 @@ import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndS type NativeLightningElement = NativeMethods & LightningElement; +type FocusContext = ContextType; + type AnimatedProps = T & Pick & { style?: StyleProp; @@ -38,19 +51,13 @@ type AnimatedProps = T & exiting?: ReanimatedAnimation; }; -function flattenStyles( +function collectStyles( style: StyleProp, animatedStyles: Set, - flattenedStyles: Partial, -): void; -function flattenStyles(style: StyleProp): [Set, Partial]; -function flattenStyles( - style: StyleProp, - animatedStyles: Set = new Set(), - flattenedStyles: Partial = {}, -) { + parts: CSSStyleParts, +): void { if (!style) { - return [animatedStyles, flattenedStyles]; + return; } if (Array.isArray(style)) { @@ -58,16 +65,23 @@ function flattenStyles( const s = style[i]; if (s != null && s !== false) { - flattenStyles(s as StyleProp, animatedStyles, flattenedStyles); + collectStyles(s as StyleProp, animatedStyles, parts); } } } else if (isAnimatedStyle(style)) { animatedStyles.add(style); - } else if (style != null && style !== false) { - Object.assign(flattenedStyles, style); + } else if (style !== false) { + filterCSSStyle(style as Record, parts); } +} + +function flattenStyles(style: StyleProp): [Set, CSSStyleParts] { + const animatedStyles = new Set(); + const parts = createCSSStyleParts(); - return [animatedStyles, flattenedStyles]; + collectStyles(style, animatedStyles, parts); + + return [animatedStyles, finalizeCSSStyleParts(parts)]; } function isAnimationBuilder( @@ -149,15 +163,22 @@ export function createAnimatedComponent( ): AnimatedComponent { class AnimatedComponentInternal extends Component> { static displayName = `LightningAnimated(${ComponentToAnimate.displayName || ComponentToAnimate.name || 'Component'})`; + // Pseudo selectors need the focus path, which only the manager knows. + static contextType = FocusManagerContext; private _ref: NativeLightningElement | null = null; + private _css: CSSStyleBinding | null = null; + private _focusContext: FocusContext = null; private _animatedStyles: Set = new Set(); private _styles: Partial | null = null; private _cachedBuilders = new WeakMap(); - constructor(props: AnimatedProps) { - super(props); + // React only assigns `this.context` after construction, so take it from the + // constructor argument for the first _transformStyles pass. + constructor(props: AnimatedProps, context?: unknown) { + super(props, context); + this._focusContext = context as FocusContext; this._transformStyles(); } @@ -191,6 +212,9 @@ export function createAnimatedComponent( } componentWillUnmount(): void { + this._css?.destroy(); + this._css = null; + if (!this.props.exiting || !this._ref) { return; } @@ -247,10 +271,11 @@ export function createAnimatedComponent( } this._ref = newRef; + this._css?.setElement(newRef); }; _transformStyles() { - const [newAnimatedStyles, flattenedStyles] = flattenStyles(this.props.style); + const [newAnimatedStyles, parts] = flattenStyles(this.props.style); if (this._ref) { // Remove refs for any animated styles that were removed @@ -265,7 +290,34 @@ export function createAnimatedComponent( } this._animatedStyles = newAnimatedStyles; - this._styles = flattenedStyles; + this._styles = parts.style as Partial; + + this._updateCSSStyle(parts); + } + + /** + * CSS transitions and pseudo selectors are driven off the node instead of a + * render: the transition settings live on the element and the binding swaps + * the pseudo props as focus moves. + */ + private _updateCSSStyle(parts: CSSStyleParts) { + const transitions = parts.transitionProps + ? normalizeCSSTransition(parts.transitionProps) + : null; + const transition = transitions ? toLightningTransition(transitions, parts) : null; + + if (!transition && !hasPseudoStyles(parts)) { + this._css?.destroy(); + this._css = null; + + return; + } + + const focusContext = this._focusContext ?? (this.context as FocusContext); + + this._css ??= new CSSStyleBinding(focusContext?.focusManager ?? null); + this._css.setElement(this._ref); + this._css.update(parts, transition); } private _runAnimation(builder: LayoutAnimationFunction | null, callback?: () => void) { From 9340ef8792438ace1ef95e94f050c42d1b5e741b Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 8 Sep 2026 15:45:37 +0200 Subject: [PATCH 2/5] chore(example): stub react-native-is-edge-to-edge so the app boots --- .../src/polyfills/isEdgeToEdge.ts | 7 +++++++ apps/react-native-lightning-example/vite.config.mjs | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 apps/react-native-lightning-example/src/polyfills/isEdgeToEdge.ts diff --git a/apps/react-native-lightning-example/src/polyfills/isEdgeToEdge.ts b/apps/react-native-lightning-example/src/polyfills/isEdgeToEdge.ts new file mode 100644 index 00000000..6242995e --- /dev/null +++ b/apps/react-native-lightning-example/src/polyfills/isEdgeToEdge.ts @@ -0,0 +1,7 @@ +// reanimated imports react-native-is-edge-to-edge, whose "module" entry points +// at a CJS file that vite serves without named exports. None of it means +// anything on a TV, so the app aliases it to this. +export const isEdgeToEdgeFromLibrary = () => false; +export const isEdgeToEdgeFromProperty = () => false; +export const isEdgeToEdge = () => false; +export const controlEdgeToEdgeValues = () => {}; diff --git a/apps/react-native-lightning-example/vite.config.mjs b/apps/react-native-lightning-example/vite.config.mjs index 287b5252..0e30f560 100644 --- a/apps/react-native-lightning-example/vite.config.mjs +++ b/apps/react-native-lightning-example/vite.config.mjs @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; + import babel from '@rolldown/plugin-babel'; import legacy from '@vitejs/plugin-legacy'; import { reactCompilerPreset } from '@vitejs/plugin-react'; @@ -32,6 +34,13 @@ const config = defineConfig((env) => ({ build: { minify: false, }, + resolve: { + alias: { + 'react-native-is-edge-to-edge': fileURLToPath( + new URL('./src/polyfills/isEdgeToEdge.ts', import.meta.url), + ), + }, + }, server: { host: true, port: 3333, From 67f3a13e102f4ca495c1c6e2bf2625d72ef22518 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 8 Sep 2026 15:45:38 +0200 Subject: [PATCH 3/5] chore(example): add a pseudo selector page --- .../src/index.tsx | 8 +++ .../src/pages/PseudoSelectorTest.tsx | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/react-native-lightning-example/src/pages/PseudoSelectorTest.tsx diff --git a/apps/react-native-lightning-example/src/index.tsx b/apps/react-native-lightning-example/src/index.tsx index 32a4a689..947b21f4 100644 --- a/apps/react-native-lightning-example/src/index.tsx +++ b/apps/react-native-lightning-example/src/index.tsx @@ -22,6 +22,7 @@ import { AnimationTest } from './pages/AnimationTest'; import { ComponentTest } from './pages/ComponentTest'; import { LayoutTest } from './pages/LayoutTest'; import { LibraryTest } from './pages/LibraryTest'; +import { PseudoSelectorTest } from './pages/PseudoSelectorTest'; import { SimpleTest } from './pages/SimpleTest'; import { VirtualizedListTest } from './pages/VirtualizedListTest'; @@ -56,6 +57,7 @@ const screens = { Library: 'library', Simple: 'simple', Components: 'components', + PseudoSelectors: 'pseudoSelectors', NestedLayouts: 'nestedLayouts', VirtualizedList: 'virtualizedList', }; @@ -119,6 +121,11 @@ const MainApp = () => { color={'rgba(55, 55, 22, 1)'} onPress={() => nav.navigate('VirtualizedList')} /> +