Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8195fd7
fix(vendor): reserve separator gap between grid rows in VirtualList
RuudBurger Jul 16, 2026
bc1cf39
fix(vendor): let directional focus enter an enclosing sibling container
DouweBos Jul 27, 2026
dc1053d
fix(vendor): repaint a cleared translate axis and forward destination…
DouweBos Jul 27, 2026
ec7e44c
fix(vendor): register a focusable View as a Lightning spatial-focus t…
DouweBos Jul 27, 2026
791ab06
fix(vendor): exclude directional-only catchers from focus restoration
DouweBos Jul 27, 2026
b5d2e59
fix(vendor): scroll a focused descendant into view in ScrollView
DouweBos Jul 27, 2026
e98f1b6
fix(vendor): anchor EPG directional focus per-airing (RNG-490)
DouweBos Jul 28, 2026
254de6b
fix(vendor): parse alpha-hex, hsl, and space-form rgb colors; drop op…
DouweBos Jul 28, 2026
521f952
fix(vendor): resolve alignSelf auto to parent alignItems and map spac…
DouweBos Jul 28, 2026
74d9005
fix(vendor): reclamp VirtualList scroll and fire onEndReached on cont…
DouweBos Jul 28, 2026
6194793
fix(vendor): default withTiming easing to ease-in-out-quad
DouweBos Jul 28, 2026
e1e3e13
fix(vendor): notify every ancestor group when focus crosses its children
DouweBos Jul 28, 2026
817bd6d
fix(vendor): implement ScrollView.getNativeScrollRef()
DouweBos Jul 28, 2026
c90ef31
fix(vendor): preserve full fontWeight scale and map unsupported textA…
DouweBos Jul 29, 2026
65236d6
fix(vendor): apply textTransform and honor head/middle ellipsize on Text
DouweBos Jul 29, 2026
bd754e0
fix(vendor): honor native withRepeat count <= 0 as infinite on both p…
DouweBos Jul 29, 2026
d02a64f
fix(vendor): restart only the changed keys in useAnimatedStyle
DouweBos Jul 29, 2026
a791ee8
fix(vendor): report per-target geometry in ResizeObserver shim
DouweBos Jul 29, 2026
8dcb06a
fix(vendor): keep center-aligned focus targets from snapping to the h…
DouweBos Jul 29, 2026
ffd0939
fix(vendor): surface momentum scroll callbacks from VirtualList
DouweBos Jul 29, 2026
bac826c
fix(vendor): tighten compat layer types and drop a react-lightning sr…
RuudBurger Aug 12, 2026
ecfdc2f
fix(vendor): virtuallist skips the default-size guess in onlayout (gr…
RuudBurger Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/alignself-auto-and-space-evenly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-flexbox': patch
---

`alignSelf: 'auto'` resolves to the parent's `alignItems` (yoga `ALIGN_AUTO`) instead of being mapped to a fixed alignment, matching RN's type and behavior. `space-evenly` maps to yoga's `ALIGN_SPACE_EVENLY` rather than sharing the `space-between` case.
5 changes: 5 additions & 0 deletions .changeset/ancestor-groups-child-focused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Every ancestor focus group is notified when its own directly-focused child changes, not only the group directly above the leaf. A VirtualList whose cells nest their own focus group never learned that focus had crossed a cell, so its scroll-to-focus stopped following. Matches tvOS, where every ancestor hears about focus crossing its children.
5 changes: 5 additions & 0 deletions .changeset/center-focus-target-header-edge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

A center-aligned focus target stays mid-viewport instead of snapping to the header/footer edge. The edge protection left rows near a header or footer stuck at the top or bottom; center placement now wins (tvOS parity), and the downstream clamp still bounds the resulting target.
7 changes: 7 additions & 0 deletions .changeset/color-parsing-alpha-hex-hsl-space-rgb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-lightning-plugin-css-transform': patch
---

Color parsing handles 8- and 4-digit alpha hex, `hsl()`/`hsla()`, and the modern space-separated `rgb()` form with a `/` alpha delimiter. These previously fell through unparsed.

`PlatformColor`/`OpaqueColorValue` objects have no resolvable string form, so they're dropped with a warning (like unresolvable keyword colors) instead of throwing and taking the screen down.
7 changes: 7 additions & 0 deletions .changeset/compat-layer-types-and-snap-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-native-lightning': patch
---

Typing fixes across the react-native compat layer exports: `View`'s `onFocusCapture` accepts either the RN or the Lightning handler (intersecting them produced a signature nothing could satisfy), `Pressable` tolerates null `onFocus`/`onBlur`, and `ActivityIndicator` handles the asset-id vs URL mismatch on its image source. `LightningViewElementStyle` comes from the package entry instead of a `src/` deep path.

`usesExplicitAlignment` also narrows its argument, so `snapToAlignment: 'item'` falls back to `'start'` in the scroll-into-view math rather than being passed through to alignment code that doesn't implement it.
5 changes: 5 additions & 0 deletions .changeset/directional-anchor-internal-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Anchor directional focus into a nested group and fix internal-redirect self-cycling. Directional nav now beams from the deepest focused leaf and descends by geometry into the chosen sibling, so a narrow-header-beside-wide-group row lands under the source's cross-axis position instead of on the group's first child; a redirect node is returned for the focus manager to forward to its destination. Separately, `_focusNode`'s upward walk now only hands focus off to external redirects: an internal redirect (destinations within the node's own subtree, e.g. an EPG airings guide pointing at its own cells) is already satisfied by the downward-arrival redirect, and re-firing it while walking back up targeted a descendant just visited and self-cycled, aborting the move and stranding focus on the guide's first child.
5 changes: 5 additions & 0 deletions .changeset/directional-focus-enclosing-sibling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Directional focus can enter a sibling that fully encloses the source on the movement axis. A screen scene behind a floating header keeps its focusable content below the source, but the distance was measured to the sibling's top edge — above the source — so the move was rejected and focus stayed stuck in the header. Such a container is now entered from the source's trailing edge instead.
7 changes: 7 additions & 0 deletions .changeset/focus-restoration-excluded-and-translate-reveal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-lightning': patch
---

`focusRestorationExcluded` nodes are skipped by focus restoration. They never become the parent's mount-time default and never inherit focus when the focused sibling unmounts, so a drawer edge guard can't take focus during launch.

Separately, a node withheld until layout now waits one extra layout pass while a pixel translate hasn't been folded into its position yet — otherwise it painted at its untransformed origin for a frame. Bounded to that single extra pass, so a mis-detected translate can't strand the node invisible.
7 changes: 7 additions & 0 deletions .changeset/focusable-view-spatial-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-native-lightning': patch
---

A `View` with `focusable` set is registered as a Lightning spatial-focus target. Native treats any focusable View as a nav target, but react-lightning only knew about `useFocus`/`FocusGroup` elements, so a plain focusable View was unreachable by directional nav and never fired `onFocus`. Only opt-in focusable Views pay for the registration; every other View still renders as a bare node.

Adds `focusRestorationExcluded` for a directional-only catcher: reachable by a deliberate move, but never by restoration or the mount-time default.
7 changes: 7 additions & 0 deletions .changeset/fontweight-scale-textalign-textshadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-lightning-plugin-css-transform': patch
---

`fontWeight` keeps the full 100-900 scale. Weights were collapsed to bold/normal, and a numeric string like `'600'` then fell through to 400 in the renderer; numeric strings are parsed and numbers and keywords pass through untouched. Unsupported `textAlign` values are mapped rather than forwarded as-is.

Text shadow styles (`shadowColor`, `textShadow*`) are dropped with a warning instead of converted: the shipping SDF text renderer has no shadow, and RN's `textShadow*` props were never wired to the renderer's keys.
15 changes: 15 additions & 0 deletions .changeset/lightning-scrollview-native-scroll-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@plextv/react-native-lightning': patch
---

fix(react-native-lightning): implement `ScrollView.getNativeScrollRef()`

Shared RN code that scrolls a selected child into view calls
`scrollRef.current.getNativeScrollRef()` and measures the child against the
returned node (`child.measureLayout(scrollHostNode, ...)`), expecting a
content-relative offset independent of the current scroll position. The Lightning
`ScrollView` never implemented `getNativeScrollRef`, so the call threw
`getNativeScrollRef is not a function`. It now returns the inner scrolled content
container (the node that carries the scroll offset as its own x/y), so
`measureLayout` against it yields the same content-relative coordinates as native
React Native.
6 changes: 6 additions & 0 deletions .changeset/momentum-scroll-callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@plextv/react-lightning-components': patch
'@plextv/react-lightning-plugin-reanimated': patch
---

Surface momentum scroll callbacks. VirtualList now fires `onMomentumScrollBegin`/`onMomentumScrollEnd` once around a focus-driven animated scroll — begin when it starts, end when it settles or is cancelled — mirroring tvOS/AndroidTV. The reanimated `useAnimatedScrollHandler` shim now routes the `onMomentumBegin`/`onMomentumEnd` handler keys by event name instead of only ever calling `onScroll`. Consumers that key off momentum (fast-scroll detection, jump-bar auto-hide) can react to the real end of a scroll instead of an idle timeout.
5 changes: 5 additions & 0 deletions .changeset/resizeobserver-per-target-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

The ResizeObserver shim reports each entry against its own target's rect. A single shared layout handler meant every observed element was handed the last-laid-out element's geometry, and the handler couldn't be removed per target on `unobserve`/`disconnect`.
6 changes: 6 additions & 0 deletions .changeset/scrollview-focus-into-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@plextv/react-native-lightning': patch
'@plextv/react-lightning': patch
---

`ScrollView` reveals a focused descendant the way native TV scroll views do: scroll the minimum needed to bring it fully into view, and leave already-visible items where they are. `snapToAlignment` only counts as deliberate placement for `center` and `end` — `start` and `item` (paging) aren't real focus targets, and `item` has no alignment math behind it at all, so both fall through to ensure-visible rather than snapping a focused row out of view. Exports `FocusManagerContext` from react-lightning so the compat layer can observe focus changes.
5 changes: 5 additions & 0 deletions .changeset/text-transform-and-ellipsize-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-native-lightning': patch
---

`Text` applies `textTransform` from its style, and `ellipsizeMode` `head`/`middle` map to a text overflow instead of being dropped — only `clip` and `tail` were handled before.
8 changes: 8 additions & 0 deletions .changeset/translate-clear-and-destinations-every-arrival.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@plextv/react-lightning-plugin-flexbox': patch
'@plextv/react-lightning': patch
---

A `transform` is a complete snapshot of the node's transform, so an axis it omits has returned to identity. Yoga now writes translate 0 for that axis instead of leaving the previous pixel inset in place, which a partial style push would otherwise never repaint (a cleared focus offset stayed where it was).

A focus group with `destinations` also forwards focus on every arrival, not just the first. `destinations` takes precedence over the remembered child, matching native `TVFocusGuideView`, so a reopened nav drawer returns to its selected item. `autoFocus` remains the separate first-then-remember mechanism.
5 changes: 5 additions & 0 deletions .changeset/useanimatedstyle-restart-changed-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-reanimated': patch
---

`useAnimatedStyle` restarts only the keys whose animation actually changed. Every update previously tore down and restarted all runners, so an unrelated key's animation was restarted mid-flight and visibly jumped.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-grid-separator-gap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

VirtualList reserves the separator size between grid rows. The `ItemSeparatorComponent` size was left out of the multi-column row offsets, so rows sat a separator's height too high and the total content size came up short. Separators sit between rows rather than between columns (matching FlashList), and zero-height empty rows are skipped.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-no-estimate-onlayout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

VirtualList no longer reports the DEFAULT_ITEM_SIZE guess through onLayout. Callers that derive layout from that width (Grid computes numColumns and re-keys the list) would remount the list on every guess-measure-guess cycle, an infinite loop when the list mounts without a laid-out size (e.g. inside a hidden Activity). resolveCrossSize now flags the fallback as an estimate and the onLayout effect skips it.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-reclamp-and-endreached.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

VirtualList reconciles its scroll offset against the latest content size. A shrink reclamps the offset instead of leaving it past the new end, and a list short enough to fit its viewport primes `onEndReached` on mount. The clamp and threshold decision is now shared by the scroll handler and the content-size effect, so both paths agree.
5 changes: 5 additions & 0 deletions .changeset/withrepeat-nonpositive-infinite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-reanimated': patch
---

`withRepeat` treats any count <= 0 as infinite on both the renderer-looped and composed paths, matching native reanimated. Only `-1` was infinite before, so a `0` count stopped immediately instead of looping forever.
5 changes: 5 additions & 0 deletions .changeset/withtiming-default-easing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-reanimated': patch
---

`withTiming` with no easing defaults to reanimated's `Easing.inOut(Easing.quad)`, expressed as the equivalent cubic-bezier the renderer parses. It previously fell back to linear, so every un-eased timing animation ran flat.
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import type { AllStyleProps } from './types/ReactStyle';
import { convertCSSStyleToLightning } from './convertCSSStyleToLightning';

// The return type is the view/image/text union; the text-only keys these specs
// assert on aren't on the base, so read results through a loose record.
const convert = (style: AllStyleProps): Record<string, unknown> =>
convertCSSStyleToLightning(style) as Record<string, unknown>;

describe('convertCSSStyleToLightning border radius', () => {
it('passes a uniform borderRadius through unchanged', () => {
expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe(
Expand Down Expand Up @@ -38,3 +44,67 @@ describe('convertCSSStyleToLightning border radius', () => {
expect(result.borderTopEndRadius).toBeUndefined();
});
});

describe('convertCSSStyleToLightning fontWeight', () => {
it('passes a numeric weight through unchanged', () => {
expect(convert({ fontWeight: 300 }).fontWeight).toBe(300);
});

it('parses a numeric string weight into a number', () => {
expect(convert({ fontWeight: '600' }).fontWeight).toBe(600);
});

it('preserves keyword weights', () => {
expect(convert({ fontWeight: 'bold' }).fontWeight).toBe('bold');
expect(convert({ fontWeight: 'normal' }).fontWeight).toBe('normal');
expect(convert({ fontWeight: 'lighter' }).fontWeight).toBe('lighter');
});
});

describe('convertCSSStyleToLightning textAlign', () => {
afterEach(() => vi.restoreAllMocks());

it('passes renderer-supported alignments through', () => {
for (const value of ['left', 'center', 'right'] as const) {
expect(convert({ textAlign: value }).textAlign).toBe(value);
}
});

it("maps 'auto' to left", () => {
expect(convert({ textAlign: 'auto' }).textAlign).toBe('left');
});

it("warns and falls back to left for 'justify'", () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(convert({ textAlign: 'justify' }).textAlign).toBe('left');
expect(warn).toHaveBeenCalled();
});
});

describe('convertCSSStyleToLightning text shadows', () => {
afterEach(() => vi.restoreAllMocks());

it('drops the RN text-shadow props and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = convertCSSStyleToLightning({
textShadowColor: 'red',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 4,
}) as Record<string, unknown>;

expect(result.textShadowColor).toBeUndefined();
expect(result.textShadowOffset).toBeUndefined();
expect(result.textShadowRadius).toBeUndefined();
expect(warn).toHaveBeenCalled();
});

it('no longer converts shadowColor into a renderer shadow', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = convertCSSStyleToLightning({
shadowColor: 'red',
}) as Record<string, unknown>;

expect(result.shadowColor).toBeUndefined();
expect(warn).toHaveBeenCalled();
});
});
48 changes: 42 additions & 6 deletions packages/plugin-css-transform/src/convertCSSStyleToLightning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export function convertCSSStyleToLightning(
borderWidth,
borderColor,
shadowColor,
textShadowColor,
textShadowOffset,
textShadowRadius,
textAlign,
opacity,
overflow,
overflowX,
Expand Down Expand Up @@ -137,9 +141,19 @@ export function convertCSSStyleToLightning(
}
}

if (shadowColor != null) {
(finalStyle as LightningTextElementStyle).shadowColor =
htmlColorToLightningColor(shadowColor);
// Text shadows are inert: the shipping SDF text renderer has no shadow, and
// RN's textShadow* props were never wired to the Canvas renderer's keys. Drop
// them instead of converting values the renderer ignores.
if (
shadowColor != null ||
textShadowColor != null ||
textShadowOffset != null ||
textShadowRadius != null
) {
console.warn(
'[convertCSSStyleToLightning] Text shadows are not supported by the Lightning renderer; dropping shadow styles',
{ shadowColor, textShadowColor, textShadowOffset, textShadowRadius },
);
}

if (border != null || borderWidth != null || borderColor != null) {
Expand Down Expand Up @@ -197,11 +211,33 @@ export function convertCSSStyleToLightning(
: Number.parseInt(otherStyles.top, 10);
}

// The renderer resolves the full 100-900 scale (and the keyword weights) to
// the nearest font face, but only for numbers — a numeric string like '600'
// falls through to 400. Parse those; pass numbers and keywords through as-is.
if (fontWeight != null) {
(finalStyle as LightningTextElementStyle).fontWeight =
fontWeight === 'bold' || Number.parseInt(fontWeight.toString(), 10) >= 500
? 'bold'
: 'normal';
typeof fontWeight === 'number'
? fontWeight
: /^\d+$/.test(fontWeight)
? Number.parseInt(fontWeight, 10)
: (fontWeight as LightningTextElementStyle['fontWeight']);
}

if (textAlign != null) {
// The merged style type narrows textAlign to left/center/right, but RN still
// passes auto/justify at runtime, so widen before mapping.
const align = textAlign as string;

if (align === 'justify') {
console.warn(
'[convertCSSStyleToLightning] textAlign "justify" is not supported; using "left"',
align,
);
}

// Renderer only knows left/center/right; auto and justify both resolve left.
(finalStyle as LightningTextElementStyle).textAlign =
align === 'center' || align === 'right' ? align : 'left';
}

if (transform != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ColorValue } from 'react-native';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { htmlColorToLightningColor } from './htmlColorToLightningColor';

Expand Down Expand Up @@ -52,6 +52,29 @@ describe('htmlColorToLightningColor', () => {
expect(actual).toBe(expected);
});

it('should convert space-form rgb string to a number', () => {
expect(htmlColorToLightningColor('rgb(244 164 96)')).toBe(0xf4a460ff);
expect(htmlColorToLightningColor('rgb(244 164 96 / 0.4)')).toBe(0xf4a46066);
});

it('should convert an 8-digit alpha hex to a number', () => {
expect(htmlColorToLightningColor('#ff00ff80')).toBe(0xff00ff80);
});

it('should convert a 4-digit alpha hex to a number', () => {
expect(htmlColorToLightningColor('#abcd')).toBe(0xaabbccdd);
});

it('should convert hsl/hsla strings to a number', () => {
expect(htmlColorToLightningColor('hsl(300, 100%, 50%)')).toBe(0xff00ffff);
expect(htmlColorToLightningColor('hsla(300, 100%, 50%, 0.4)')).toBe(
0xff00ff66,
);
expect(htmlColorToLightningColor('hsl(300 100% 50% / 0.4)')).toBe(
0xff00ff66,
);
});

it('should convert html color code to a number', () => {
const value = 'sandybrown';
const expected = 0xf4a460ff;
Expand All @@ -77,16 +100,6 @@ describe('htmlColorToLightningColor', () => {
}
});

it('should throw an error if an invalid hex was given', () => {
const value = '#abcd';

const run = (): void => {
htmlColorToLightningColor(value);
};

expect(run).toThrow('Invalid hex value');
});

it('should throw an error if html color code is not a web X11 color', () => {
const value = 'sandybrownzzzz';
const run = (): void => {
Expand All @@ -97,8 +110,26 @@ describe('htmlColorToLightningColor', () => {
});

it('should return undefined for unresolvable css keyword colors', () => {
for (const value of ['inherit', 'initial', 'unset', 'revert', 'currentColor', 'CurrentColor']) {
for (const value of [
'inherit',
'initial',
'unset',
'revert',
'currentColor',
'CurrentColor',
]) {
expect(htmlColorToLightningColor(value)).toBeUndefined();
}
});

it('should warn and drop opaque/object color values instead of throwing', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Stands in for a PlatformColor / OpaqueColorValue.
const opaque = { semantic: ['label'] } as unknown as ColorValue;

expect(htmlColorToLightningColor(opaque)).toBeUndefined();
expect(warn).toHaveBeenCalled();

warn.mockRestore();
});
});
Loading
Loading