diff --git a/.changeset/alignself-auto-and-space-evenly.md b/.changeset/alignself-auto-and-space-evenly.md new file mode 100644 index 00000000..5e7b4f71 --- /dev/null +++ b/.changeset/alignself-auto-and-space-evenly.md @@ -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. diff --git a/.changeset/ancestor-groups-child-focused.md b/.changeset/ancestor-groups-child-focused.md new file mode 100644 index 00000000..8848689d --- /dev/null +++ b/.changeset/ancestor-groups-child-focused.md @@ -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. diff --git a/.changeset/center-focus-target-header-edge.md b/.changeset/center-focus-target-header-edge.md new file mode 100644 index 00000000..6a00ed5e --- /dev/null +++ b/.changeset/center-focus-target-header-edge.md @@ -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. diff --git a/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md b/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md new file mode 100644 index 00000000..020fa006 --- /dev/null +++ b/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md @@ -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. diff --git a/.changeset/compat-layer-types-and-snap-alignment.md b/.changeset/compat-layer-types-and-snap-alignment.md new file mode 100644 index 00000000..cca371c1 --- /dev/null +++ b/.changeset/compat-layer-types-and-snap-alignment.md @@ -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. diff --git a/.changeset/directional-anchor-internal-redirect.md b/.changeset/directional-anchor-internal-redirect.md new file mode 100644 index 00000000..4abeb479 --- /dev/null +++ b/.changeset/directional-anchor-internal-redirect.md @@ -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. diff --git a/.changeset/directional-focus-enclosing-sibling.md b/.changeset/directional-focus-enclosing-sibling.md new file mode 100644 index 00000000..bf930f07 --- /dev/null +++ b/.changeset/directional-focus-enclosing-sibling.md @@ -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. diff --git a/.changeset/focus-restoration-excluded-and-translate-reveal.md b/.changeset/focus-restoration-excluded-and-translate-reveal.md new file mode 100644 index 00000000..a390834e --- /dev/null +++ b/.changeset/focus-restoration-excluded-and-translate-reveal.md @@ -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. diff --git a/.changeset/focusable-view-spatial-target.md b/.changeset/focusable-view-spatial-target.md new file mode 100644 index 00000000..c3587a23 --- /dev/null +++ b/.changeset/focusable-view-spatial-target.md @@ -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. diff --git a/.changeset/fontweight-scale-textalign-textshadow.md b/.changeset/fontweight-scale-textalign-textshadow.md new file mode 100644 index 00000000..49119c69 --- /dev/null +++ b/.changeset/fontweight-scale-textalign-textshadow.md @@ -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. diff --git a/.changeset/lightning-scrollview-native-scroll-ref.md b/.changeset/lightning-scrollview-native-scroll-ref.md new file mode 100644 index 00000000..58b600e3 --- /dev/null +++ b/.changeset/lightning-scrollview-native-scroll-ref.md @@ -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. diff --git a/.changeset/momentum-scroll-callbacks.md b/.changeset/momentum-scroll-callbacks.md new file mode 100644 index 00000000..6b24519e --- /dev/null +++ b/.changeset/momentum-scroll-callbacks.md @@ -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. diff --git a/.changeset/resizeobserver-per-target-geometry.md b/.changeset/resizeobserver-per-target-geometry.md new file mode 100644 index 00000000..87751d11 --- /dev/null +++ b/.changeset/resizeobserver-per-target-geometry.md @@ -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`. diff --git a/.changeset/scrollview-focus-into-view.md b/.changeset/scrollview-focus-into-view.md new file mode 100644 index 00000000..666217e7 --- /dev/null +++ b/.changeset/scrollview-focus-into-view.md @@ -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. diff --git a/.changeset/text-transform-and-ellipsize-modes.md b/.changeset/text-transform-and-ellipsize-modes.md new file mode 100644 index 00000000..6ba2bc70 --- /dev/null +++ b/.changeset/text-transform-and-ellipsize-modes.md @@ -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. diff --git a/.changeset/translate-clear-and-destinations-every-arrival.md b/.changeset/translate-clear-and-destinations-every-arrival.md new file mode 100644 index 00000000..ddb8544c --- /dev/null +++ b/.changeset/translate-clear-and-destinations-every-arrival.md @@ -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. diff --git a/.changeset/useanimatedstyle-restart-changed-keys.md b/.changeset/useanimatedstyle-restart-changed-keys.md new file mode 100644 index 00000000..3dfcf096 --- /dev/null +++ b/.changeset/useanimatedstyle-restart-changed-keys.md @@ -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. diff --git a/.changeset/virtuallist-grid-separator-gap.md b/.changeset/virtuallist-grid-separator-gap.md new file mode 100644 index 00000000..fdfe8400 --- /dev/null +++ b/.changeset/virtuallist-grid-separator-gap.md @@ -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. diff --git a/.changeset/virtuallist-no-estimate-onlayout.md b/.changeset/virtuallist-no-estimate-onlayout.md new file mode 100644 index 00000000..8105536c --- /dev/null +++ b/.changeset/virtuallist-no-estimate-onlayout.md @@ -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. diff --git a/.changeset/virtuallist-reclamp-and-endreached.md b/.changeset/virtuallist-reclamp-and-endreached.md new file mode 100644 index 00000000..2fe3eff5 --- /dev/null +++ b/.changeset/virtuallist-reclamp-and-endreached.md @@ -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. diff --git a/.changeset/withrepeat-nonpositive-infinite.md b/.changeset/withrepeat-nonpositive-infinite.md new file mode 100644 index 00000000..a53a5c9a --- /dev/null +++ b/.changeset/withrepeat-nonpositive-infinite.md @@ -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. diff --git a/.changeset/withtiming-default-easing.md b/.changeset/withtiming-default-easing.md new file mode 100644 index 00000000..7e7c44cf --- /dev/null +++ b/.changeset/withtiming-default-easing.md @@ -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. diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts index 4c72d8ae..770b488a 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts @@ -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 => + convertCSSStyleToLightning(style) as Record; + describe('convertCSSStyleToLightning border radius', () => { it('passes a uniform borderRadius through unchanged', () => { expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe( @@ -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; + + 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; + + expect(result.shadowColor).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts index 6c4673c7..f158ace6 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts @@ -84,6 +84,10 @@ export function convertCSSStyleToLightning( borderWidth, borderColor, shadowColor, + textShadowColor, + textShadowOffset, + textShadowRadius, + textAlign, opacity, overflow, overflowX, @@ -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) { @@ -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) { diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts index a4ebc2ca..4e7dbcf6 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts @@ -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'; @@ -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; @@ -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 => { @@ -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(); + }); }); diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts index ffd8dbf4..4877ef84 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts @@ -2,20 +2,55 @@ import type { ColorValue } from 'react-native'; import { htmlColorCodes } from './htmlColorCodes'; +const hexRgbaRegex = /^#?([a-f0-9]{8})$/i; const hexRgbRegex = /^#?([a-f0-9]{6})$/i; +const hexShortRgbaRegex = /^#?([a-f0-9]{4})$/i; const hexShortRgbRegex = /^#?([a-f0-9]{3})$/i; -const rgbRegex = /^rgba?\(([0-9.]+)[, ]+([0-9.]+)[, ]+([0-9.]+)[, ]*([0-9.]+)?\)$/i; +// Accept both legacy comma form and modern space form with a `/` alpha delimiter. +const rgbRegex = + /^rgba?\(([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)[,\s/]*([0-9.]+)?\)$/i; +const hslRegex = + /^hsla?\(([0-9.]+)(?:deg)?[,\s]+([0-9.]+)%[,\s]+([0-9.]+)%[,\s/]*([0-9.]+)?\)$/i; // Keyword colors (inherit, currentColor, …) have no fixed value to resolve, so // they reach the throw below and take the whole screen down. Drop them instead. const cssKeywordColorRegex = /^(inherit|initial|unset|revert|currentcolor)$/i; -function withAlphaOverride(color: number, overrideAlpha?: number | string): number { +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + s /= 100; + l /= 100; + const k = (n: number): number => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number): number => + l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + + return [ + Math.round(f(0) * 255), + Math.round(f(8) * 255), + Math.round(f(4) * 255), + ]; +} + +function packRgba(r: number, g: number, b: number, alpha?: string): number { + return ( + ((r << 24) >>> 0) + + (g << 16) + + (b << 8) + + (alpha != null ? Math.round(Number.parseFloat(alpha) * 255) : 255) + ); +} + +function withAlphaOverride( + color: number, + overrideAlpha?: number | string, +): number { if (overrideAlpha == null) { return color; } const alphaInt = - typeof overrideAlpha === 'string' ? Number.parseInt(overrideAlpha, 16) : overrideAlpha; + typeof overrideAlpha === 'string' + ? Number.parseInt(overrideAlpha, 16) + : overrideAlpha; // Create a bitmask for the alpha value const alphaMask = 0xffffff00 | alphaInt; @@ -36,6 +71,13 @@ export function htmlColorToLightningColor( return withAlphaOverride(color, overrideAlpha); } + // PlatformColor / OpaqueColorValue have no resolvable string form. Drop them + // like unresolvable keywords rather than throwing and killing the screen. + if (typeof color === 'object') { + console.warn('[htmlColorToLightningColor] Unsupported color value:', color); + return undefined; + } + const colorLower = String(color).toLowerCase(); const colorFromCode = htmlColorCodes[colorLower]; @@ -46,28 +88,74 @@ export function htmlColorToLightningColor( const rgbResult = rgbRegex.exec(colorLower); if (rgbResult) { - const parts = rgbResult.slice() as [string, string, string, string, string?]; - - const rgbColor = - ((Number.parseInt(parts[1], 10) << 24) >>> 0) + - (Number.parseInt(parts[2], 10) << 16) + - (Number.parseInt(parts[3], 10) << 8) + - (parts[4] != null ? Math.round(Number.parseFloat(parts[4]) * 255) : 255); + const [, r, g, b, alpha] = rgbResult.slice() as [ + string, + string, + string, + string, + string?, + ]; + const rgbColor = packRgba( + Number.parseInt(r, 10), + Number.parseInt(g, 10), + Number.parseInt(b, 10), + alpha, + ); return withAlphaOverride(rgbColor, overrideAlpha); } + const hslResult = hslRegex.exec(colorLower); + + if (hslResult) { + const [, h, s, l, alpha] = hslResult.slice() as [ + string, + string, + string, + string, + string?, + ]; + const [r, g, b] = hslToRgb( + Number.parseFloat(h), + Number.parseFloat(s), + Number.parseFloat(l), + ); + + return withAlphaOverride(packRgba(r, g, b, alpha), overrideAlpha); + } + + const hexRgbaResult = hexRgbaRegex.exec(colorLower); + + if (hexRgbaResult?.[1]) { + return withAlphaOverride( + Number.parseInt(hexRgbaResult[1], 16), + overrideAlpha, + ); + } + const hexRgbResult = hexRgbRegex.exec(colorLower); if (hexRgbResult?.[1]) { - return withAlphaOverride(Number.parseInt(`${hexRgbResult[1]}ff`, 16), overrideAlpha); + return withAlphaOverride( + Number.parseInt(`${hexRgbResult[1]}ff`, 16), + overrideAlpha, + ); + } + + const hexShortRgbaResult = hexShortRgbaRegex.exec(colorLower); + + if (hexShortRgbaResult?.[1]) { + const short = hexShortRgbaResult[1]; + const rgbaText = [...short].map((c) => `${c}${c}`).join(''); + + return withAlphaOverride(Number.parseInt(rgbaText, 16), overrideAlpha); } const hexShortRgbResult = hexShortRgbRegex.exec(colorLower); if (hexShortRgbResult?.[1]) { - const shortRgbText = hexShortRgbResult[1]; - const rgbText = `${shortRgbText[0]}${shortRgbText[0]}${shortRgbText[1]}${shortRgbText[1]}${shortRgbText[2]}${shortRgbText[2]}ff`; + const short = hexShortRgbResult[1]; + const rgbText = `${[...short].map((c) => `${c}${c}`).join('')}ff`; return withAlphaOverride(Number.parseInt(rgbText, 16), overrideAlpha); } @@ -76,5 +164,7 @@ export function htmlColorToLightningColor( return undefined; } - throw new Error(`Invalid hex value specified for conversion: ${color.toString()}`); + throw new Error( + `Invalid hex value specified for conversion: ${color.toString()}`, + ); } diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index ab6bcd0c..a99a5259 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -550,6 +550,41 @@ describe('YogaManager', () => { ); // y + translateY }); + it('resets a translate inset when a present transform omits the axis', async () => { + const { applyFlexPropToYoga } = await import('./util/applyReactPropsToYoga'); + const elementId = 321; + + yogaManager.addNode(elementId); + + // Closed: translateX shifts the node off-screen to the left. + yogaManager.applyStyle(elementId, { x: 0, transform: { translateX: -452 } }); + expect(applyFlexPropToYoga).toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', -452); + + vi.mocked(applyFlexPropToYoga).mockClear(); + + // Open: a transform is a complete snapshot, so an empty one (EaseView emits + // `[]` once translateX hits 0) means translate returned to identity. This + // holds even for a partial (resetMissing=false) push — the transform key is + // authoritative for its own axes — so the -452 inset must clear, not stick. + yogaManager.applyStyle(elementId, { x: 0, transform: {} }, false, false); + expect(applyFlexPropToYoga).toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', 0); + }); + + it('keeps a translate inset when the push omits transform entirely', async () => { + const { applyFlexPropToYoga } = await import('./util/applyReactPropsToYoga'); + const elementId = 654; + + yogaManager.addNode(elementId); + yogaManager.applyStyle(elementId, { x: 0, transform: { translateX: -452 } }); + + vi.mocked(applyFlexPropToYoga).mockClear(); + + // No transform key in this push: the translate is not part of the update + // and must be left alone, not reset to 0. + yogaManager.applyStyle(elementId, { w: 100 }, false, false); + expect(applyFlexPropToYoga).not.toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', 0); + }); + it('should apply multiple styles', async () => { const styles = { 123: { w: 100, h: 50 }, diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index a0b47897..337d1ad6 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -495,6 +495,12 @@ export class YogaManager { yogaNode.translatePercent = undefined; yogaNode.resolvedTranslate = undefined; + // A `transform` is a complete snapshot of the node's transform, so an + // axis it omits has returned to identity. If a previous push wrote a + // pixel inset for that axis, clear it (translate 0) rather than leave the + // stale offset, which a partial push would otherwise never repaint. + const pixelTranslate = (yogaNode.pixelTranslate ??= {}); + if (typeof translateX === 'string') { const pct = Number.parseFloat(translateX); @@ -503,16 +509,18 @@ export class YogaManager { if (!Number.isNaN(pct)) { (yogaNode.translatePercent ??= {}).x = pct; } - } else if (translateX != null) { + pixelTranslate.x = false; + } else if (translateX != null || pixelTranslate.x) { const right = node.getPosition(yoga.EDGE_RIGHT); const { edge, value } = resolveHorizontalTranslate( right.unit === yoga.UNIT_POINT, x ?? 0, right.value, - translateX, + translateX ?? 0, ); applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); + pixelTranslate.x = translateX != null; } if (typeof translateY === 'string') { @@ -521,16 +529,18 @@ export class YogaManager { if (!Number.isNaN(pct)) { (yogaNode.translatePercent ??= {}).y = pct; } - } else if (translateY != null) { + pixelTranslate.y = false; + } else if (translateY != null || pixelTranslate.y) { const bottom = node.getPosition(yoga.EDGE_BOTTOM); const { edge, value } = resolveVerticalTranslate( bottom.unit === yoga.UNIT_POINT, y ?? 0, bottom.value, - translateY, + translateY ?? 0, ); applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); + pixelTranslate.y = translateY != null; } } } diff --git a/packages/plugin-flexbox/src/types/FlexStyles.ts b/packages/plugin-flexbox/src/types/FlexStyles.ts index b72cb054..d49ba0e8 100644 --- a/packages/plugin-flexbox/src/types/FlexStyles.ts +++ b/packages/plugin-flexbox/src/types/FlexStyles.ts @@ -95,7 +95,8 @@ export interface FlexContainer { } export interface FlexItem { - alignSelf?: AlignItems; + // `auto` inherits the parent's alignItems, matching RN's alignSelf type. + alignSelf?: AlignItems | 'auto'; flex?: string | number; flexBasis?: AutoDimensionValue; flexGrow?: number; diff --git a/packages/plugin-flexbox/src/types/ManagerNode.ts b/packages/plugin-flexbox/src/types/ManagerNode.ts index 351bf094..86558cfc 100644 --- a/packages/plugin-flexbox/src/types/ManagerNode.ts +++ b/packages/plugin-flexbox/src/types/ManagerNode.ts @@ -17,4 +17,6 @@ export type ManagerNode = { translatePercent?: { x?: number; y?: number }; /** Last emitted resolved position for a percent node, to dedupe readback writes. */ resolvedTranslate?: { left: number; top: number }; + /** Axes with a pixel translate inset currently written to yoga, so an omit can clear it. */ + pixelTranslate?: { x?: boolean; y?: boolean }; }; diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts new file mode 100644 index 00000000..2409f2d9 --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts @@ -0,0 +1,118 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { ManagerNode } from '../types/ManagerNode'; +import type { YogaOptions } from '../types/YogaOptions'; +import applyReactPropsToYoga, { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// Yoga resolves `alignSelf: auto` to the parent's alignItems, and treats +// space-evenly as its own distribution. react-lightning collapsed both, so a +// child under a non-stretch parent stretched and wrapped lines lost their even +// spacing. These pin the mappings against real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +describe('applyFlexPropToYoga alignSelf', () => { + // Column parent → cross axis is horizontal, so alignItems drives child width. + function childWidthUnderFlexStartParent(childStyle: Partial): number { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + parent.setAlignItems(yoga.ALIGN_FLEX_START); + + const child = yoga.Node.create(); + child.setHeight(20); + apply(child, childStyle); + parent.insertChild(child, 0); + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return child.getComputedWidth(); + } + + it("resolves alignSelf 'auto' to the parent's alignItems, not stretch", () => { + // flex-start parent + content-less child => width 0, not stretched to 200. + expect(childWidthUnderFlexStartParent({ alignSelf: 'auto' })).toBe(0); + }); + + it("still honors an explicit alignSelf 'stretch'", () => { + expect(childWidthUnderFlexStartParent({ alignSelf: 'stretch' })).toBe(200); + }); +}); + +describe('applyReactPropsToYoga alignSelf reset', () => { + it('reverts to inherit (auto) when alignSelf is dropped on a later render', () => { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + parent.setAlignItems(yoga.ALIGN_FLEX_START); + + const node = yoga.Node.create(); + parent.insertChild(node, 0); + + const managerNode: ManagerNode = { id: 1, node, children: [], props: {} }; + + // First render stretches the child across the parent's cross axis. + applyReactPropsToYoga(yoga, options, managerNode, { h: 20, alignSelf: 'stretch' }, true); + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + expect(node.getComputedWidth()).toBe(200); + + // Later render omits alignSelf → reset path must restore inherit, not stretch. + applyReactPropsToYoga(yoga, options, managerNode, { h: 20 }, true); + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + expect(node.getComputedWidth()).toBe(0); + }); +}); + +describe('applyFlexPropToYoga alignContent', () => { + // Two wrapped lines in a taller container: space-evenly pads the outer edges, + // space-between does not. + function firstLineTop(alignContent: LightningViewElementStyle['alignContent']): number { + const parent = yoga.Node.create(); + parent.setWidth(100); + parent.setHeight(100); + apply(parent, { flexDirection: 'row', flexWrap: 'wrap', alignContent }); + + for (let i = 0; i < 4; i++) { + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + parent.insertChild(child, i); + } + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return parent.getChild(0).getComputedTop(); + } + + it("distributes wrapped lines evenly for alignContent 'space-evenly'", () => { + // 2 lines of 20 in 100 tall => 60 free / 3 gaps = 20 top inset. + expect(firstLineTop('space-evenly')).toBe(20); + }); + + it("keeps space-between distinct (no outer inset)", () => { + expect(firstLineTop('space-between')).toBe(0); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index 3673bbcc..d20219a2 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -56,12 +56,23 @@ function mapAlignItems(yoga: Yoga, value?: number | string): Align { } } +// alignSelf `auto`/unset inherits the parent's alignItems; only concrete +// values map to a fixed alignment. +function mapAlignSelf(yoga: Yoga, value?: number | string): Align { + if (value == null || value === 'auto') { + return yoga.ALIGN_AUTO; + } + + return mapAlignItems(yoga, value); +} + function mapAlignContent(yoga: Yoga, value?: number | string): Align { switch (value) { case 'space-around': return yoga.ALIGN_SPACE_AROUND; - case 'space-between': case 'space-evenly': + return yoga.ALIGN_SPACE_EVENLY; + case 'space-between': return yoga.ALIGN_SPACE_BETWEEN; case 'center': return yoga.ALIGN_CENTER; @@ -142,7 +153,11 @@ function applyFlexBasis(node: Node, value?: AutoDimensionValue | string) { } } -function applyFlex(node: Node, value?: string | number, expandToAutoFlexBasis = false) { +function applyFlex( + node: Node, + value?: string | number, + expandToAutoFlexBasis = false, +) { if (value == null) { return; } @@ -187,7 +202,7 @@ function resetFlexPropToDefault(yoga: Yoga, node: Node, prop: FlexProps): void { node.setAlignItems(mapAlignItems(yoga)); return; case 'alignSelf': - node.setAlignSelf(mapAlignItems(yoga)); + node.setAlignSelf(mapAlignSelf(yoga)); return; case 'alignContent': node.setAlignContent(mapAlignContent(yoga)); @@ -391,7 +406,9 @@ export function applyFlexPropToYoga( switch (key) { case 'display': - node.setDisplay(mapDisplay(yoga, value as LightningViewElementStyle['display'])); + node.setDisplay( + mapDisplay(yoga, value as LightningViewElementStyle['display']), + ); return true; case 'w': node.setWidth(value as LightningViewElementStyle['w']); @@ -423,62 +440,116 @@ export function applyFlexPropToYoga( return true; } case 'margin': - node.setMargin(yoga.EDGE_ALL, value as LightningViewElementStyle['margin']); + node.setMargin( + yoga.EDGE_ALL, + value as LightningViewElementStyle['margin'], + ); return true; case 'marginBottom': - node.setMargin(yoga.EDGE_BOTTOM, value as LightningViewElementStyle['marginBottom']); + node.setMargin( + yoga.EDGE_BOTTOM, + value as LightningViewElementStyle['marginBottom'], + ); return true; case 'marginEnd': - node.setMargin(yoga.EDGE_END, value as LightningViewElementStyle['marginEnd']); + node.setMargin( + yoga.EDGE_END, + value as LightningViewElementStyle['marginEnd'], + ); return true; case 'marginLeft': - node.setMargin(yoga.EDGE_LEFT, value as LightningViewElementStyle['marginLeft']); + node.setMargin( + yoga.EDGE_LEFT, + value as LightningViewElementStyle['marginLeft'], + ); return true; case 'marginRight': - node.setMargin(yoga.EDGE_RIGHT, value as LightningViewElementStyle['marginRight']); + node.setMargin( + yoga.EDGE_RIGHT, + value as LightningViewElementStyle['marginRight'], + ); return true; case 'marginStart': - node.setMargin(yoga.EDGE_START, value as LightningViewElementStyle['marginStart']); + node.setMargin( + yoga.EDGE_START, + value as LightningViewElementStyle['marginStart'], + ); return true; case 'marginTop': - node.setMargin(yoga.EDGE_TOP, value as LightningViewElementStyle['marginTop']); + node.setMargin( + yoga.EDGE_TOP, + value as LightningViewElementStyle['marginTop'], + ); return true; case 'marginHorizontal': case 'marginInline': - node.setMargin(yoga.EDGE_HORIZONTAL, value as LightningViewElementStyle['marginInline']); + node.setMargin( + yoga.EDGE_HORIZONTAL, + value as LightningViewElementStyle['marginInline'], + ); return true; case 'marginVertical': case 'marginBlock': - node.setMargin(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['marginBlock']); + node.setMargin( + yoga.EDGE_VERTICAL, + value as LightningViewElementStyle['marginBlock'], + ); return true; case 'padding': - node.setPadding(yoga.EDGE_ALL, value as LightningViewElementStyle['padding']); + node.setPadding( + yoga.EDGE_ALL, + value as LightningViewElementStyle['padding'], + ); return true; case 'paddingBottom': - node.setPadding(yoga.EDGE_BOTTOM, value as LightningViewElementStyle['paddingBottom']); + node.setPadding( + yoga.EDGE_BOTTOM, + value as LightningViewElementStyle['paddingBottom'], + ); return true; case 'paddingEnd': - node.setPadding(yoga.EDGE_END, value as LightningViewElementStyle['paddingEnd']); + node.setPadding( + yoga.EDGE_END, + value as LightningViewElementStyle['paddingEnd'], + ); return true; case 'paddingLeft': - node.setPadding(yoga.EDGE_LEFT, value as LightningViewElementStyle['paddingLeft']); + node.setPadding( + yoga.EDGE_LEFT, + value as LightningViewElementStyle['paddingLeft'], + ); return true; case 'paddingRight': - node.setPadding(yoga.EDGE_RIGHT, value as LightningViewElementStyle['paddingRight']); + node.setPadding( + yoga.EDGE_RIGHT, + value as LightningViewElementStyle['paddingRight'], + ); return true; case 'paddingStart': - node.setPadding(yoga.EDGE_START, value as LightningViewElementStyle['paddingStart']); + node.setPadding( + yoga.EDGE_START, + value as LightningViewElementStyle['paddingStart'], + ); return true; case 'paddingTop': - node.setPadding(yoga.EDGE_TOP, value as LightningViewElementStyle['paddingTop']); + node.setPadding( + yoga.EDGE_TOP, + value as LightningViewElementStyle['paddingTop'], + ); return true; case 'paddingHorizontal': case 'paddingInline': - node.setPadding(yoga.EDGE_HORIZONTAL, value as LightningViewElementStyle['paddingInline']); + node.setPadding( + yoga.EDGE_HORIZONTAL, + value as LightningViewElementStyle['paddingInline'], + ); return true; case 'paddingVertical': case 'paddingBlock': - node.setPadding(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['paddingBlock']); + node.setPadding( + yoga.EDGE_VERTICAL, + value as LightningViewElementStyle['paddingBlock'], + ); return true; case 'border': node.setBorder( @@ -511,7 +582,7 @@ export function applyFlexPropToYoga( node.setAlignItems(mapAlignItems(yoga, value)); return true; case 'alignSelf': - node.setAlignSelf(mapAlignItems(yoga, value)); + node.setAlignSelf(mapAlignSelf(yoga, value)); return true; case 'justifyContent': node.setJustifyContent(mapJustify(yoga, value)); @@ -526,37 +597,66 @@ export function applyFlexPropToYoga( node.setFlexGrow((value as LightningViewElementStyle['flexGrow']) ?? 1); return true; case 'flexShrink': - node.setFlexShrink((value as LightningViewElementStyle['flexShrink']) ?? 0); + node.setFlexShrink( + (value as LightningViewElementStyle['flexShrink']) ?? 0, + ); return true; case 'gap': - node.setGap(yoga.GUTTER_ALL, (value as LightningViewElementStyle['gap']) ?? 0); + node.setGap( + yoga.GUTTER_ALL, + (value as LightningViewElementStyle['gap']) ?? 0, + ); return true; case 'columnGap': - node.setGap(yoga.GUTTER_COLUMN, (value as LightningViewElementStyle['columnGap']) ?? 0); + node.setGap( + yoga.GUTTER_COLUMN, + (value as LightningViewElementStyle['columnGap']) ?? 0, + ); return true; case 'rowGap': - node.setGap(yoga.GUTTER_ROW, (value as LightningViewElementStyle['rowGap']) ?? 0); + node.setGap( + yoga.GUTTER_ROW, + (value as LightningViewElementStyle['rowGap']) ?? 0, + ); return true; case 'position': node.setPositionType(mapPosition(yoga, value)); return true; case 'right': - node.setPosition(yoga.EDGE_RIGHT, (value as LightningViewElementStyle['right']) ?? 0); + node.setPosition( + yoga.EDGE_RIGHT, + (value as LightningViewElementStyle['right']) ?? 0, + ); return true; case 'bottom': - node.setPosition(yoga.EDGE_BOTTOM, (value as LightningViewElementStyle['bottom']) ?? 0); + node.setPosition( + yoga.EDGE_BOTTOM, + (value as LightningViewElementStyle['bottom']) ?? 0, + ); return true; case 'left': - node.setPosition(yoga.EDGE_LEFT, (value as LightningViewElementStyle['left']) ?? 0); + node.setPosition( + yoga.EDGE_LEFT, + (value as LightningViewElementStyle['left']) ?? 0, + ); return true; case 'top': - node.setPosition(yoga.EDGE_TOP, (value as LightningViewElementStyle['top']) ?? 0); + node.setPosition( + yoga.EDGE_TOP, + (value as LightningViewElementStyle['top']) ?? 0, + ); return true; case 'start': - node.setPosition(yoga.EDGE_START, (value as LightningViewElementStyle['left']) ?? 0); + node.setPosition( + yoga.EDGE_START, + (value as LightningViewElementStyle['left']) ?? 0, + ); return true; case 'end': - node.setPosition(yoga.EDGE_END, (value as LightningViewElementStyle['right']) ?? 0); + node.setPosition( + yoga.EDGE_END, + (value as LightningViewElementStyle['right']) ?? 0, + ); return true; } } catch (err) { diff --git a/packages/plugin-reanimated/src/animation/animationProgram.test.ts b/packages/plugin-reanimated/src/animation/animationProgram.test.ts index b5a6f545..ffe6d8da 100644 --- a/packages/plugin-reanimated/src/animation/animationProgram.test.ts +++ b/packages/plugin-reanimated/src/animation/animationProgram.test.ts @@ -6,6 +6,7 @@ import { delayProgram, firstLeaf, leafProgram, + programsEqual, repeatProgram, mapProgram, restingValue, @@ -97,3 +98,55 @@ describe('animationProgram', () => { expect(mapped.kind).toBe('sequence'); }); }); + +describe('programsEqual', () => { + it('true for structurally identical trees sharing leaf settings', () => { + const s = settings(); + const build = () => + repeatProgram( + sequenceProgram([ + leafProgram({ toValue: 1, lngAnimation: s }), + leafProgram({ toValue: 0, lngAnimation: s }), + ]), + -1, + true, + ); + + expect(programsEqual(build(), build())).toBe(true); + }); + + it('false when a leaf target differs', () => { + const s = settings(); + + expect( + programsEqual( + leafProgram({ toValue: 1, lngAnimation: s }), + leafProgram({ toValue: 2, lngAnimation: s }), + ), + ).toBe(false); + }); + + it('false when repeat count or reverse differ', () => { + const child = leaf(1); + + expect(programsEqual(repeatProgram(child, -1, false), repeatProgram(child, 2, false))).toBe( + false, + ); + expect(programsEqual(repeatProgram(child, -1, false), repeatProgram(child, -1, true))).toBe( + false, + ); + }); + + it('false for different kinds', () => { + expect(programsEqual(leaf(1), sequenceProgram([leaf(1)]))).toBe(false); + }); + + it('compares leaf settings by identity (a fresh settings object is a change)', () => { + expect( + programsEqual( + leafProgram({ toValue: 1, lngAnimation: settings() }), + leafProgram({ toValue: 1, lngAnimation: settings() }), + ), + ).toBe(false); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/animationProgram.ts b/packages/plugin-reanimated/src/animation/animationProgram.ts index ddd1baea..1cc5036a 100644 --- a/packages/plugin-reanimated/src/animation/animationProgram.ts +++ b/packages/plugin-reanimated/src/animation/animationProgram.ts @@ -1,24 +1,29 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import type { AnimatableValue } from 'react-native-reanimated-original'; -export type ProgramLeaf = { +export interface ProgramLeaf { toValue: AnimatableValue; lngAnimation: AnimationSettings; -}; +} // A program is the composition tree for withSequence / withRepeat / withDelay. // A single withTiming/withSpring stays off this path (see AnimatedValue); the // tree only exists once steps are chained. delay folds onto the first leaf. export type AnimationProgram = - | { kind: 'leaf'; leaf: ProgramLeaf } - | { kind: 'sequence'; children: AnimationProgram[] } - | { kind: 'repeat'; child: AnimationProgram; count: number; reverse: boolean }; + { + kind: 'repeat'; + child: AnimationProgram; + count: number; + reverse: boolean; + } | { kind: 'leaf'; leaf: ProgramLeaf } | { kind: 'sequence'; children: AnimationProgram[] }; export function leafProgram(leaf: ProgramLeaf): AnimationProgram { return { kind: 'leaf', leaf }; } -export function sequenceProgram(children: AnimationProgram[]): AnimationProgram { +export function sequenceProgram( + children: AnimationProgram[], +): AnimationProgram { return { kind: 'sequence', children }; } @@ -32,7 +37,10 @@ export function repeatProgram( // Prepend a delay by overriding the first leaf's delay (clones so a cached // lngAnimation, e.g. spring's, is never mutated). -export function delayProgram(program: AnimationProgram, delayMs: number): AnimationProgram { +export function delayProgram( + program: AnimationProgram, + delayMs: number, +): AnimationProgram { switch (program.kind) { case 'leaf': return leafProgram({ @@ -48,8 +56,13 @@ export function delayProgram(program: AnimationProgram, delayMs: number): Animat return sequenceProgram([delayProgram(head, delayMs), ...rest]); } + case 'repeat': - return repeatProgram(delayProgram(program.child, delayMs), program.count, program.reverse); + return repeatProgram( + delayProgram(program.child, delayMs), + program.count, + program.reverse, + ); } } @@ -62,12 +75,15 @@ export function firstLeaf(program: AnimationProgram): ProgramLeaf | undefined { return first ? firstLeaf(first) : undefined; } + case 'repeat': return firstLeaf(program.child); } } -export function restingValue(program: AnimationProgram): AnimatableValue | undefined { +export function restingValue( + program: AnimationProgram, +): AnimatableValue | undefined { switch (program.kind) { case 'leaf': return program.leaf.toValue; @@ -76,11 +92,65 @@ export function restingValue(program: AnimationProgram): AnimatableValue | undef return last ? restingValue(last) : undefined; } + case 'repeat': return restingValue(program.child); } } +// Structural equality of two programs, used to decide whether a running +// animation still matches a freshly computed schedule. Conservative on purpose: +// leaf settings are compared by identity (an unchanged shared value hands back +// the same AnimationSettings ref), so anything uncertain reads as changed and +// gets restarted rather than left stale. +export function programsEqual( + a: AnimationProgram, + b: AnimationProgram, +): boolean { + if (a === b) { + return true; + } + + if (a.kind !== b.kind) { + return false; + } + + switch (a.kind) { + case 'leaf': { + const other = (b as Extract).leaf; + + return ( + Object.is(a.leaf.toValue, other.toValue) && + a.leaf.lngAnimation === other.lngAnimation + ); + } + + case 'sequence': { + const other = (b as Extract) + .children; + + return ( + a.children.length === other.length && + a.children.every((child, i) => { + const otherChild = other[i]; + + return otherChild !== undefined && programsEqual(child, otherChild); + }) + ); + } + + case 'repeat': { + const other = b as Extract; + + return ( + a.count === other.count && + a.reverse === other.reverse && + programsEqual(a.child, other.child) + ); + } + } +} + // Map every leaf target through fn (e.g. translateX px stays as the x value), // keeping the tree shape and each leaf's animation settings. export function mapProgram( @@ -94,8 +164,14 @@ export function mapProgram( lngAnimation: program.leaf.lngAnimation, }); case 'sequence': - return sequenceProgram(program.children.map((child) => mapProgram(child, fn))); + return sequenceProgram( + program.children.map((child) => mapProgram(child, fn)), + ); case 'repeat': - return repeatProgram(mapProgram(program.child, fn), program.count, program.reverse); + return repeatProgram( + mapProgram(program.child, fn), + program.count, + program.reverse, + ); } } diff --git a/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts b/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts new file mode 100644 index 00000000..18c5d0c3 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts @@ -0,0 +1,135 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it, vi } from 'vitest'; +import type { LightningElementStyle } from '@plextv/react-lightning'; +import type { ScheduledAnimation } from '../utils/toLightningAnimationAndStyles'; +import { leafProgram, sequenceProgram } from './animationProgram'; +import { type RunningProgram, reconcileRunners } from './reconcileRunners'; + +const settings = (): AnimationSettings => ({ + duration: 100, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, +}); + +type Prop = keyof LightningElementStyle; + +// A looping pulse held by one shared value: the same program object comes back +// on every recompute. +const pulseSettings = settings(); +const pulse = sequenceProgram([ + leafProgram({ toValue: 1, lngAnimation: pulseSettings }), + leafProgram({ toValue: 0, lngAnimation: pulseSettings }), +]); + +const schedule = ( + prop: Prop, + program: ScheduledAnimation['program'], +): ScheduledAnimation => ({ + prop, + program, +}); + +describe('reconcileRunners', () => { + it('leaves a running program untouched when its key is unchanged', () => { + const pulseCancel = vi.fn(); + const current = new Map([ + ['scaleX', { program: pulse, cancel: pulseCancel }], + ]); + const start = vi.fn(() => vi.fn()); + + const next = reconcileRunners(current, [schedule('scaleX', pulse)], start); + + expect(pulseCancel).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + expect(next.get('scaleX')?.cancel).toBe(pulseCancel); + }); + + it('restarts only the changed key, leaving an unrelated pulse running', () => { + const pulseCancel = vi.fn(); + const scrollCancel = vi.fn(); + const current = new Map([ + ['scaleX', { program: pulse, cancel: pulseCancel }], + [ + 'x', + { + program: leafProgram({ toValue: 0, lngAnimation: settings() }), + cancel: scrollCancel, + }, + ], + ]); + const restartedCancel = vi.fn(); + const start = vi.fn(() => restartedCancel); + + // Same pulse for scaleX, but x moved — mirrors a scroll tick that must not + // reset the pulse. + const next = reconcileRunners( + current, + [ + schedule('scaleX', pulse), + schedule('x', leafProgram({ toValue: 120, lngAnimation: settings() })), + ], + start, + ); + + expect(pulseCancel).not.toHaveBeenCalled(); + expect(scrollCancel).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleX')?.cancel).toBe(pulseCancel); + expect(next.get('x')?.cancel).toBe(restartedCancel); + }); + + it('cancels a program whose key disappears from the schedule', () => { + const cancel = vi.fn(); + const current = new Map([ + [ + 'x', + { + program: leafProgram({ toValue: 0, lngAnimation: settings() }), + cancel, + }, + ], + ]); + + const next = reconcileRunners(current, [], vi.fn()); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(next.size).toBe(0); + }); + + it('restarts a program whose leaf settings ref changed (never leaves it stale)', () => { + const oldCancel = vi.fn(); + // Same target and tree shape, but a fresh settings object — must count as + // changed so a real update never keeps playing the stale program. + const before = leafProgram({ toValue: 1, lngAnimation: settings() }); + const after = leafProgram({ toValue: 1, lngAnimation: settings() }); + const current = new Map([ + ['scaleX', { program: before, cancel: oldCancel }], + ]); + const restarted = vi.fn(); + const start = vi.fn(() => restarted); + + const next = reconcileRunners(current, [schedule('scaleX', after)], start); + + expect(oldCancel).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleX')?.cancel).toBe(restarted); + }); + + it('starts a program for a newly scheduled key', () => { + const started = vi.fn(); + const start = vi.fn(() => started); + const program = leafProgram({ toValue: 1, lngAnimation: settings() }); + + const next = reconcileRunners( + new Map(), + [schedule('scaleY', program)], + start, + ); + + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleY')?.cancel).toBe(started); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/reconcileRunners.ts b/packages/plugin-reanimated/src/animation/reconcileRunners.ts new file mode 100644 index 00000000..c2e98458 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/reconcileRunners.ts @@ -0,0 +1,49 @@ +import type { LightningElementStyle } from '@plextv/react-lightning'; +import type { ScheduledAnimation } from '../utils/toLightningAnimationAndStyles'; +import { type AnimationProgram, programsEqual } from './animationProgram'; +import type { CancelAnimation } from './runAnimationProgram'; + +type PropKey = keyof LightningElementStyle; + +export interface RunningProgram { + program: AnimationProgram; + cancel: CancelAnimation; +} + +// Reconcile the programs playing on a view against a freshly computed schedule +// set. A key whose program is unchanged keeps its runner; only added, removed, +// or changed keys are (re)started. Restarting every program on any dependency +// change resets unrelated loops (a pulse restarting on every scroll tick). +export function reconcileRunners( + running: Map, + schedules: ScheduledAnimation[], + start: (schedule: ScheduledAnimation) => CancelAnimation, +): Map { + const next = new Map(); + const scheduled = new Set(); + + for (const schedule of schedules) { + scheduled.add(schedule.prop); + + const existing = running.get(schedule.prop); + + if (existing && programsEqual(existing.program, schedule.program)) { + next.set(schedule.prop, existing); + continue; + } + + existing?.cancel(); + next.set(schedule.prop, { + program: schedule.program, + cancel: start(schedule), + }); + } + + for (const [prop, run] of running) { + if (!scheduled.has(prop)) { + run.cancel(); + } + } + + return next; +} diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts index db953480..77ad081f 100644 --- a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts @@ -16,8 +16,9 @@ describe('resolveTimingEasing', () => { expect(resolveTimingEasing(factoryObj)).toBe(produced); }); - it('falls back to linear when easing is missing', () => { - expect(resolveTimingEasing(undefined)).toBe('linear'); + it('defaults an unset easing to ease-in-out-quad', () => { + expect(resolveTimingEasing(undefined)).toBe('cubic-bezier(0.455, 0.03, 0.515, 0.955)'); + expect(resolveTimingEasing(null)).toBe('cubic-bezier(0.455, 0.03, 0.515, 0.955)'); }); it('falls back to linear for an unrecognized easing value', () => { diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts index c7888d8d..2e36aea5 100644 --- a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts @@ -10,11 +10,18 @@ function hasFactory(value: unknown): value is EasingFactory { ); } +// reanimated defaults withTiming to Easing.inOut(Easing.quad); the renderer has +// no named token for it, so use the equivalent cubic-bezier its parser accepts. +const DEFAULT_EASING = 'cubic-bezier(0.455, 0.03, 0.515, 0.955)'; + // reanimated Easing.* are functions; Easing.bezier(...) returns a { factory } // object. The renderer's CoreAnimation takes a function easing directly and // resolves a string via getTimingFunction, so pass functions through, unwrap -// the factory, and fall back to linear for anything else. -export function resolveTimingEasing(easing: unknown): AnimationSettings['easing'] { +// the factory, default the unset case to inOut-quad, and treat anything else as +// linear. +export function resolveTimingEasing( + easing: unknown, +): AnimationSettings['easing'] { if (typeof easing === 'function') { return easing as AnimationSettings['easing']; } @@ -23,5 +30,9 @@ export function resolveTimingEasing(easing: unknown): AnimationSettings['easing' return easing.factory(); } + if (easing == null) { + return DEFAULT_EASING; + } + return 'linear'; } diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts index 896d8846..d6039f97 100644 --- a/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts @@ -1,7 +1,7 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import { describe, expect, it } from 'vitest'; import type { LightningElement } from '@plextv/react-lightning'; -import { leafProgram, sequenceProgram } from './animationProgram'; +import { leafProgram, repeatProgram, sequenceProgram } from './animationProgram'; import { runAnimationProgram } from './runAnimationProgram'; const settings = ( @@ -95,4 +95,46 @@ describe('runAnimationProgram', () => { // Cancelled before the first leaf resolved, so no further steps run. expect(view.applied.length).toBeLessThanOrEqual(1); }); + + it('repeats a finite positive count that many times', async () => { + const view = makeView(); + + runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), 3, false), + ); + await flush(); + + expect(view.applied.length).toBe(3); + }); + + it('treats repeat count 0 as infinite, not zero plays (native semantics)', async () => { + const view = makeView(); + + const cancel = runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), 0, false), + ); + await flush(); + cancel(); + + // count 0 must loop, not play zero times. + expect(view.applied.length).toBeGreaterThan(1); + }); + + it('treats a negative count as infinite', async () => { + const view = makeView(); + + const cancel = runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), -1, false), + ); + await flush(); + cancel(); + + expect(view.applied.length).toBeGreaterThan(1); + }); }); diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts index 50b3ff73..c9e3cb10 100644 --- a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts @@ -1,14 +1,15 @@ import type { IAnimationController } from '@lightningjs/renderer'; - -import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElement, + LightningElementStyle, +} from '@plextv/react-lightning'; import type { AnimationProgram, ProgramLeaf } from './animationProgram'; export type CancelAnimation = () => void; // Play a composed program against one node prop: register each step's transition, // animate to its target, wait for the node to report it stopped, then advance. -// Sequences chain, repeats loop (count < 0 = forever). Reverse isn't needed by +// Sequences chain, repeats loop (count <= 0 = forever). Reverse isn't needed by // any current consumer, so it plays forward. export function runAnimationProgram( view: LightningElement, @@ -62,9 +63,10 @@ export function runAnimationProgram( await play(child); } + break; case 'repeat': { - const infinite = node.count < 0; + const infinite = node.count <= 0; for (let i = 0; (infinite || i < node.count) && !cancelled; i++) { await play(node.child); diff --git a/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts new file mode 100644 index 00000000..6adc7876 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type ScrollHandlers, dispatchAnimatedScrollEvent } from './dispatchAnimatedScrollEvent'; + +const scrollEvent = (offset: number) => ({ + nativeEvent: { contentOffset: { x: 0, y: offset } }, +}); + +describe('dispatchAnimatedScrollEvent', () => { + it('routes a plain scroll to onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent(handlers, scrollEvent(10), {}); + + expect(handlers.onScroll).toHaveBeenCalledTimes(1); + expect(handlers.onMomentumBegin).not.toHaveBeenCalled(); + expect(handlers.onMomentumEnd).not.toHaveBeenCalled(); + }); + + it('routes onMomentumScrollBegin to onMomentumBegin, not onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent( + handlers, + { ...scrollEvent(0), eventName: 'onMomentumScrollBegin' }, + {}, + ); + + expect(handlers.onMomentumBegin).toHaveBeenCalledTimes(1); + expect(handlers.onScroll).not.toHaveBeenCalled(); + expect(handlers.onMomentumEnd).not.toHaveBeenCalled(); + }); + + it('routes onMomentumScrollEnd to onMomentumEnd, not onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent( + handlers, + { ...scrollEvent(120), eventName: 'onMomentumScrollEnd' }, + {}, + ); + + expect(handlers.onMomentumEnd).toHaveBeenCalledTimes(1); + expect(handlers.onScroll).not.toHaveBeenCalled(); + expect(handlers.onMomentumBegin).not.toHaveBeenCalled(); + }); + + it('tags the dispatched event with its RN event name', () => { + const onMomentumEnd = vi.fn(); + + dispatchAnimatedScrollEvent( + { onMomentumEnd }, + { ...scrollEvent(120), eventName: 'onMomentumScrollEnd' }, + {}, + ); + + expect(onMomentumEnd).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'onMomentumScrollEnd', + contentOffset: { x: 0, y: 120 }, + }), + {}, + ); + }); + + it('drops momentum for the function (onScroll-only) form', () => { + const onScroll = vi.fn(); + + dispatchAnimatedScrollEvent( + onScroll, + { ...scrollEvent(0), eventName: 'onMomentumScrollBegin' }, + {}, + ); + + expect(onScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts new file mode 100644 index 00000000..d609c1e5 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts @@ -0,0 +1,51 @@ +// oxlint-disable typescript/no-explicit-any -- mirrors reanimated's loosely-typed scroll handlers + +type ScrollHandlerFn = (event: any, context: any) => void; + +export interface ScrollHandlers { + onScroll?: ScrollHandlerFn; + onBeginDrag?: ScrollHandlerFn; + onEndDrag?: ScrollHandlerFn; + onMomentumBegin?: ScrollHandlerFn; + onMomentumEnd?: ScrollHandlerFn; +} + +export interface WrappedScrollEvent { + nativeEvent: Record; + /** RN ScrollView event name; defaults to a plain scroll. */ + eventName?: string; +} + +// Route a wrapped scroll event to the matching reanimated handler key. Lightning +// only emits scroll + focus-driven momentum (no touch drag on TV), so the begin/ +// end-drag keys are never reached here. +export function dispatchAnimatedScrollEvent( + scrollHandlers: ScrollHandlers | ScrollHandlerFn, + event: WrappedScrollEvent, + context: unknown, +): void { + const eventName = event.eventName ?? 'onScroll'; + const reanimatedEvent = { eventName, ...event.nativeEvent }; + + // Function form is the onScroll-only shorthand in reanimated's API. + if (typeof scrollHandlers === 'function') { + if (eventName === 'onScroll') { + scrollHandlers(reanimatedEvent, context); + } + + return; + } + + if (!scrollHandlers) { + return; + } + + const handler = + eventName === 'onMomentumScrollBegin' + ? scrollHandlers.onMomentumBegin + : eventName === 'onMomentumScrollEnd' + ? scrollHandlers.onMomentumEnd + : scrollHandlers.onScroll; + + handler?.(reanimatedEvent, context); +} diff --git a/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx b/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx index 61dd57e1..8f755980 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx +++ b/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx @@ -4,6 +4,12 @@ import type { useAnimatedScrollHandler as useAnimatedScrollHandlerRN, } from 'react-native-reanimated-original'; +import { + type ScrollHandlers, + type WrappedScrollEvent, + dispatchAnimatedScrollEvent, +} from './dispatchAnimatedScrollEvent'; + type UseAnimatedScrollHandlerFn = ( ...args: Parameters ) => ScrollHandlerProcessed; @@ -21,21 +27,12 @@ export const useAnimatedScrollHandler: UseAnimatedScrollHandlerFn = ( const contextRef = useRef({}); return useCallback((event) => { - const context = contextRef.current; - // Only allow onScroll event - const reanimatedEvent = { - eventName: 'onScroll', - ...event.nativeEvent, - }; - - if (typeof scrollHandlers === 'function') { - scrollHandlers(reanimatedEvent, context); - - return; - } - - if (scrollHandlers && typeof scrollHandlers.onScroll === 'function') { - scrollHandlers.onScroll(reanimatedEvent, context); - } + // The FlashList.lng mapping tags momentum events with an `eventName`; a + // plain scroll has none and defaults to onScroll. + dispatchAnimatedScrollEvent( + scrollHandlers as ScrollHandlers, + event as unknown as WrappedScrollEvent, + contextRef.current, + ); }, inputs); }; diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index fe0d09c4..d40a0d5f 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -1,16 +1,18 @@ import type { DependencyList } from 'react'; import { useEffect, useRef, useState } from 'react'; -import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanimated-original'; import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; - +import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanimated-original'; import { PARTIAL_STYLE } from '@plextv/react-lightning'; -import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElement, + LightningElementStyle, +} from '@plextv/react-lightning'; import { - type CancelAnimation, - runAnimationProgram, -} from '../animation/runAnimationProgram'; + type RunningProgram, + reconcileRunners, +} from '../animation/reconcileRunners'; +import { runAnimationProgram } from '../animation/runAnimationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import type { AnimatedStyle } from '../types/AnimatedStyle'; import { @@ -19,9 +21,14 @@ import { } from '../utils/toLightningAnimationAndStyles'; import { useTrackedReaction } from './useTrackedReaction'; -type UseAnimatedStyleFn = (...args: Parameters) => AnimatedStyle; +type UseAnimatedStyleFn = ( + ...args: Parameters +) => AnimatedStyle; -type Runners = WeakMap; +type Runners = WeakMap< + LightningElement, + Map +>; function setStyles( view: LightningElement, @@ -30,11 +37,6 @@ function setStyles( schedules: ScheduledAnimation[], runners: Runners, ): void { - // Cancel any program still playing on this view before re-applying, so a - // reset (e.g. a shared value set back to a static value) stops the old one. - runners.get(view)?.forEach((cancel) => cancel()); - runners.delete(view); - // Animated styles only carry the keys the updater computed; mark them so // the flexbox plugin doesn't reset the element's other flex props. (style as Record)[PARTIAL_STYLE] = true; @@ -47,13 +49,18 @@ function setStyles( style: style as LightningElementStyle, }); - if (schedules.length) { - runners.set( - view, - schedules.map((schedule) => - runAnimationProgram(view, schedule.prop, schedule.program), - ), - ); + // Only (re)start programs whose key changed; unchanged loops keep running and + // a removed key (e.g. a reset to a static value) is cancelled. + const next = reconcileRunners( + runners.get(view) ?? new Map(), + schedules, + (schedule) => runAnimationProgram(view, schedule.prop, schedule.program), + ); + + if (next.size) { + runners.set(view, next); + } else { + runners.delete(view); } } @@ -69,7 +76,8 @@ function applyComputedStyle( lastApplied: { current: AppliedStyles }, runners: Runners, ): void { - const { transition, style, schedules } = toLightningAnimationAndStyles(computedStyle); + const { transition, style, schedules } = + toLightningAnimationAndStyles(computedStyle); lastApplied.current = { transition, style, schedules }; @@ -132,6 +140,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { (dep as Mutable).removeListener(id); } } + applyStyles(); }; }, [autoTrack, inputs, applyStyles]); diff --git a/packages/plugin-reanimated/src/exports/withRepeat.test.ts b/packages/plugin-reanimated/src/exports/withRepeat.test.ts new file mode 100644 index 00000000..2dc2a251 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/withRepeat.test.ts @@ -0,0 +1,37 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it } from 'vitest'; + +import type { AnimatedValue } from '../animation/AnimatedValue'; +import { withRepeat } from './withRepeat'; + +// A plain withTiming value: no composed program, so it takes the single-step +// renderer-loop path. Built as a bare shape to keep the reanimated-original +// alias (pulled in by AnimatedValue's spring import) out of the test env. +const single = () => + ({ program: undefined, lngAnimation: {} as AnimationSettings }) as unknown as AnimatedValue; + +describe('withRepeat (single-step renderer loop)', () => { + it('loops forever for count -1', () => { + expect(withRepeat(single(), -1).lngAnimation.loop).toBe(true); + }); + + it('loops forever for count 0 (native: any count <= 0 is infinite)', () => { + expect(withRepeat(single(), 0).lngAnimation.loop).toBe(true); + }); + + it('loops forever for any negative count', () => { + expect(withRepeat(single(), -5).lngAnimation.loop).toBe(true); + }); + + it('plays a finite positive count without looping', () => { + const a = withRepeat(single(), 3); + + expect(a.lngAnimation.loop).toBe(false); + expect(a.lngAnimation.repeat).toBe(3); + }); + + it('reverse selects the reverse stop method', () => { + expect(withRepeat(single(), -1, true).lngAnimation.stopMethod).toBe('reverse'); + expect(withRepeat(single(), 2, false).lngAnimation.stopMethod).toBe(false); + }); +}); diff --git a/packages/plugin-reanimated/src/exports/withRepeat.ts b/packages/plugin-reanimated/src/exports/withRepeat.ts index e36f4e58..43d2be17 100644 --- a/packages/plugin-reanimated/src/exports/withRepeat.ts +++ b/packages/plugin-reanimated/src/exports/withRepeat.ts @@ -18,9 +18,13 @@ export const withRepeat: WithRepeatFn = ( return animation; } - // Single step: let the renderer loop it directly (cheap, GPU-driven). - animation.lngAnimation.loop = repeatCount === -1; - animation.lngAnimation.repeat = repeatCount; + // Single step: let the renderer loop it directly (cheap, GPU-driven). Native + // repeats forever for any count <= 0. The renderer honors `loop` (infinite); + // finite repeats live on the composed path, so pass 0 when looping. + const infinite = repeatCount <= 0; + + animation.lngAnimation.loop = infinite; + animation.lngAnimation.repeat = infinite ? 0 : repeatCount; animation.lngAnimation.stopMethod = reverse ? 'reverse' : false; return animation; diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts index e528d2cc..d36458fc 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts @@ -208,7 +208,7 @@ describe('LayoutManager', () => { expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 20); }); - it('does not add separator gap between rows in multi column', () => { + it('adds a separator gap between rows in multi column', () => { const lm = new LayoutManager({ data: makeData(5), numColumns: 2, @@ -216,12 +216,38 @@ describe('LayoutManager', () => { separatorSize: 20, }); + // Items in the same row share an offset; each new row adds one gap, + // matching native FlashList (separator between grid rows, not columns). expect(lm.getLayout(0)?.offset).toBe(0); expect(lm.getLayout(1)?.offset).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); - expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE); - expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2); - expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2 + 40); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 40); + }); + + it('adds no trailing gap after the last row and skips empty rows', () => { + // 2 columns, 3 rows (last row partial) → 2 gaps, none after the last row. + const lm = new LayoutManager({ + data: makeData(5), + numColumns: 2, + cellCrossSize: 100, + separatorSize: 20, + }); + + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2 + 40); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 40); + + // A fully empty (zero-height) row adds no gap after it. + const withEmptyRow = new LayoutManager<{ id: number } | null>({ + data: [{ id: 0 }, { id: 1 }, null, null, { id: 4 }], + numColumns: 2, + cellCrossSize: 100, + separatorSize: 20, + }); + + expect(withEmptyRow.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(withEmptyRow.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); }); it('does not add separator gap after a zero-size empty row', () => { @@ -690,4 +716,3 @@ describe('LayoutManager', () => { }); }); }); - diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index 52ac57f4..3e4e152b 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -661,6 +661,12 @@ export class LayoutManager { } offset += rowHeight; + + // Separator sits between rows like native FlashList (between rows, not + // columns); skip the last row and any zero-height (empty) row. + if (rowHeight > 0 && i < count) { + offset += this._separatorSize; + } } this._layoutCount = count; diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 195b305e..4f6035f1 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -134,6 +134,8 @@ function VirtualListInner( onEndReached, onEndReachedThreshold = 0.5, onScroll, + onMomentumScrollBegin, + onMomentumScrollEnd, onViewableItemsChanged, viewabilityConfig, onLoad, @@ -242,8 +244,11 @@ function VirtualListInner( : parentCellBounds?.width; const measuredOuterCross = horizontal ? measuredSize.h : measuredSize.w; - const { viewportCrossSize, isDefinite: crossSizeIsDefinite } = - resolveCrossSize({ + const { + viewportCrossSize, + isDefinite: crossSizeIsDefinite, + isEstimate: crossSizeIsEstimate, + } = resolveCrossSize({ horizontal, explicitCross, parentCross, @@ -436,6 +441,8 @@ function VirtualListInner( initialScrollOffset, onAnimationStart: handleAnimationStart, onAnimationEnd: handleAnimationEnd, + onMomentumScrollBegin, + onMomentumScrollEnd, }); // Backstop for non-focus scrolls (touch/wheel/imperative scrollToOffset). @@ -761,6 +768,13 @@ function VirtualListInner( return; } + // Never report the default-size guess: callers key layout off this width + // (Grid derives numColumns and re-keys the list), so reporting 200 before + // anything measured remounts the list in a loop. + if (crossSizeIsEstimate) { + return; + } + const contentW = horizontal ? totalContentSize : viewportCrossSize; const contentH = horizontal ? viewportCrossSize : totalContentSize; const prev = prevLayoutRef.current; @@ -769,7 +783,7 @@ function VirtualListInner( prevLayoutRef.current = { w: contentW, h: contentH }; onLayout({ w: contentW, h: contentH }); } - }, [onLayout, horizontal, totalContentSize, viewportCrossSize]); + }, [onLayout, horizontal, totalContentSize, viewportCrossSize, crossSizeIsEstimate]); const outerStyle: LightningViewElementStyle = { ...resolveOuterFlex(horizontal), diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 3ecc0990..77d0a10e 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -125,6 +125,10 @@ export interface VirtualListProps { onEndReachedThreshold?: number | null; /** Called on every scroll position change. */ onScroll?: (event: ScrollEvent) => void; + /** Called once when a focus-driven animated scroll begins (tvOS/AndroidTV momentum parity). */ + onMomentumScrollBegin?: (event: ScrollEvent) => void; + /** Called once when the animated scroll settles or is cancelled. */ + onMomentumScrollEnd?: (event: ScrollEvent) => void; /** Called when viewable items change. */ onViewableItemsChanged?: | ((info: { diff --git a/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts new file mode 100644 index 00000000..daee455f --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type MomentumCallbacks, createMomentumTracker } from './createMomentumTracker'; +import type { ScrollEvent } from './VirtualListTypes'; + +const evt = (offset: number): ScrollEvent => ({ + contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, + contentOffset: { x: 0, y: offset }, + contentSize: { width: 0, height: 0 }, + layoutMeasurement: { width: 0, height: 0 }, +}); + +const setup = () => { + const onMomentumScrollBegin = vi.fn(); + const onMomentumScrollEnd = vi.fn(); + const callbacks: { current: MomentumCallbacks } = { + current: { onMomentumScrollBegin, onMomentumScrollEnd }, + }; + + return { + tracker: createMomentumTracker(callbacks), + onMomentumScrollBegin, + onMomentumScrollEnd, + }; +}; + +describe('createMomentumTracker', () => { + it('fires begin once on start and end once on settle', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledWith(evt(120)); + }); + + it('fires end once when an in-flight animation is cancelled', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.cancel(evt(40)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + }); + + it('never emits an unbalanced end (settle/cancel before any start)', () => { + const { tracker, onMomentumScrollEnd } = setup(); + + tracker.settle(evt(0)); + tracker.cancel(evt(0)); + + expect(onMomentumScrollEnd).not.toHaveBeenCalled(); + }); + + it('does not double-fire begin while already animating', () => { + const { tracker, onMomentumScrollBegin } = setup(); + + tracker.start(evt(0)); + tracker.start(evt(10)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + }); + + it('does not double-fire end after it has already settled', () => { + const { tracker, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + tracker.settle(evt(120)); + tracker.cancel(evt(120)); + + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + }); + + it('starts a fresh begin/end cycle after settling', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + tracker.start(evt(200)); + tracker.settle(evt(320)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(2); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts new file mode 100644 index 00000000..26112fc3 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts @@ -0,0 +1,45 @@ +import type { ScrollEvent } from './VirtualListTypes'; + +export interface MomentumCallbacks { + onMomentumScrollBegin?: (event: ScrollEvent) => void; + onMomentumScrollEnd?: (event: ScrollEvent) => void; +} + +export interface MomentumTracker { + /** Fire onMomentumScrollBegin once when an animated scroll starts. */ + start: (event: ScrollEvent) => void; + /** Fire onMomentumScrollEnd once when the animation settles. */ + settle: (event: ScrollEvent) => void; + /** Fire onMomentumScrollEnd once when an in-flight animation is cancelled. */ + cancel: (event: ScrollEvent) => void; +} + +// Guards the momentum begin/end pair so consumers see a balanced lifecycle: +// begin fires once per animated scroll, end fires exactly once (on settle OR +// cancel), and an end never escapes without a matching begin. A missed or +// unbalanced end is how fast-scroll mode sticks on after the scroll has stopped. +export function createMomentumTracker(callbacks: { current: MomentumCallbacks }): MomentumTracker { + let animating = false; + + const finish = (event: ScrollEvent): void => { + if (!animating) { + return; + } + + animating = false; + callbacks.current.onMomentumScrollEnd?.(event); + }; + + return { + start(event) { + if (animating) { + return; + } + + animating = true; + callbacks.current.onMomentumScrollBegin?.(event); + }, + settle: finish, + cancel: finish, + }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts new file mode 100644 index 00000000..fc596cfc --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { reconcileScrollBounds } from './reconcileScrollBounds'; + +// A list scrolled near the end, then filtered down so the content is now +// shorter than the current offset — the offset must reclamp to the new end. +const shrunk = { + scrollOffset: 4000, + maxScroll: 1000, + totalContentSize: 1000 + 1920, // maxScroll + viewportSize + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: false, + hasEndReachedListener: true, +}; + +describe('reconcileScrollBounds', () => { + it('reclamps an offset that now sits past maxScroll', () => { + const result = reconcileScrollBounds(shrunk); + + expect(result.clampedOffset).toBe(1000); + expect(result.didClamp).toBe(true); + }); + + it('leaves an in-bounds offset untouched', () => { + const result = reconcileScrollBounds({ + ...shrunk, + scrollOffset: 500, + maxScroll: 5000, + totalContentSize: 5000 + 1920, + }); + + expect(result.clampedOffset).toBe(500); + expect(result.didClamp).toBe(false); + }); + + it('fires onEndReached on mount when a short list is within threshold', () => { + const result = reconcileScrollBounds({ + scrollOffset: 0, + maxScroll: 0, + totalContentSize: 400, // shorter than the viewport + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: false, + hasEndReachedListener: true, + }); + + expect(result.fireEndReached).toBe(true); + expect(result.endReached).toBe(true); + expect(result.distanceFromEnd).toBe(400 - 0 - 1920); + }); + + it('reports distanceFromEnd 0 and fires when the shrink lands us at the end', () => { + const result = reconcileScrollBounds(shrunk); + + expect(result.distanceFromEnd).toBe(0); + expect(result.fireEndReached).toBe(true); + expect(result.endReached).toBe(true); + }); + + it('does not re-fire while already latched at the end', () => { + const result = reconcileScrollBounds({ ...shrunk, endReached: true }); + + expect(result.fireEndReached).toBe(false); + expect(result.endReached).toBe(true); + }); + + it('re-arms the latch once back outside the threshold', () => { + const result = reconcileScrollBounds({ + scrollOffset: 0, + maxScroll: 8000, + totalContentSize: 8000 + 1920, + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: true, + hasEndReachedListener: true, + }); + + expect(result.fireEndReached).toBe(false); + expect(result.endReached).toBe(false); + }); + + it('never fires without a listener', () => { + const result = reconcileScrollBounds({ + ...shrunk, + hasEndReachedListener: false, + }); + + expect(result.fireEndReached).toBe(false); + }); + + it('defaults the threshold to 0.5 of the viewport when null', () => { + // distanceFromEnd 900 sits just inside 0.5 * 1920 = 960. + const result = reconcileScrollBounds({ + scrollOffset: 100, + maxScroll: 1000, + totalContentSize: 1000 + 1920, + viewportSize: 1920, + onEndReachedThreshold: null, + endReached: false, + hasEndReachedListener: true, + }); + + expect(result.distanceFromEnd).toBe(1000 + 1920 - 100 - 1920); + expect(result.fireEndReached).toBe(true); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts new file mode 100644 index 00000000..81f8b210 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts @@ -0,0 +1,62 @@ +export interface ScrollBoundsParams { + /** Current scroll offset to reconcile against the latest content size. */ + scrollOffset: number; + maxScroll: number; + totalContentSize: number; + viewportSize: number; + onEndReachedThreshold: number | null; + /** Current onEndReached latch (whether it has already fired at the end). */ + endReached: boolean; + /** Whether an onEndReached callback is wired. */ + hasEndReachedListener: boolean; +} + +export interface ScrollBoundsResult { + clampedOffset: number; + /** True when clampedOffset differs from the input offset. */ + didClamp: boolean; + /** Distance from the end, measured from the clamped offset. */ + distanceFromEnd: number; + /** True when onEndReached should fire this pass (within threshold, not yet latched). */ + fireEndReached: boolean; + /** Next value for the onEndReached latch. */ + endReached: boolean; +} + +// The clamp + onEndReached-threshold decision native ScrollView/FlashList make on +// every layout pass. Shared by the scroll handler and the content-size effect so +// a shrink reclamps the offset and a short list still primes onEndReached on mount. +export function reconcileScrollBounds({ + scrollOffset, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached, + hasEndReachedListener, +}: ScrollBoundsParams): ScrollBoundsResult { + const clampedOffset = Math.max(0, Math.min(scrollOffset, maxScroll)); + const distanceFromEnd = totalContentSize - clampedOffset - viewportSize; + + let fireEndReached = false; + let nextEndReached = endReached; + + if (hasEndReachedListener) { + if (distanceFromEnd <= viewportSize * (onEndReachedThreshold ?? 0.5)) { + if (!endReached) { + fireEndReached = true; + nextEndReached = true; + } + } else { + nextEndReached = false; + } + } + + return { + clampedOffset, + didClamp: clampedOffset !== scrollOffset, + distanceFromEnd, + fireEndReached, + endReached: nextEndReached, + }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts index 2fbd3157..886016ef 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -20,7 +20,7 @@ describe('resolveCrossSize', () => { parentCross: 300, }); - expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true, isEstimate: false }); }); it('uses parent cell bounds for a vertical list and marks it definite', () => { @@ -30,7 +30,7 @@ describe('resolveCrossSize', () => { measuredOuterCross: 280, }); - expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true, isEstimate: false }); }); it('uses the measured outer size for a vertical list and marks it definite', () => { @@ -40,7 +40,7 @@ describe('resolveCrossSize', () => { maxContentCross: 120, }); - expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true, isEstimate: false }); }); it('ignores parent/measured cross for a horizontal list in favor of content', () => { @@ -53,7 +53,7 @@ describe('resolveCrossSize', () => { crossPadding: 10, }); - expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false, isEstimate: false }); }); it('ignores parent cross for a horizontal list and falls back to the default', () => { @@ -69,6 +69,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -82,6 +83,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -91,6 +93,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -101,6 +104,22 @@ describe('resolveCrossSize', () => { parentCross: 320, }); - expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true, isEstimate: false }); + }); +}); + +describe('isEstimate', () => { + it('flags the default-size fallback as an estimate', () => { + const result = resolveCrossSize({ ...base }); + + expect(result.viewportCrossSize).toBe(DEFAULT_ITEM_SIZE); + expect(result.isEstimate).toBe(true); + }); + + it('does not flag explicit, parent, measured, or content-derived sizes', () => { + expect(resolveCrossSize({ ...base, explicitCross: 400 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, parentCross: 320 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, measuredOuterCross: 280 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, maxContentCross: 120 }).isEstimate).toBe(false); }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts index a540aa8a..31f4a81f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -23,6 +23,12 @@ export interface ResolvedCrossSize { * content gets a chance to report its real size. */ isDefinite: boolean; + /** + * True only for the DEFAULT_ITEM_SIZE fallback, when nothing (style, + * parent, measure, or content) has informed the size yet. Callers must not + * report or persist this value; it is a placeholder until a real measure. + */ + isEstimate: boolean; } /** @@ -46,33 +52,34 @@ export function resolveCrossSize({ crossPadding, }: ResolveCrossSizeInput): ResolvedCrossSize { if (explicitCross != null && explicitCross > 0) { - return { viewportCrossSize: explicitCross, isDefinite: true }; + return { viewportCrossSize: explicitCross, isDefinite: true, isEstimate: false }; } if (!horizontal && parentCross != null && parentCross > 0) { - return { viewportCrossSize: parentCross, isDefinite: true }; + return { viewportCrossSize: parentCross, isDefinite: true, isEstimate: false }; } if (!horizontal && measuredOuterCross > 0) { - return { viewportCrossSize: measuredOuterCross, isDefinite: true }; + return { viewportCrossSize: measuredOuterCross, isDefinite: true, isEstimate: false }; } if (maxContentCross > 0) { return { viewportCrossSize: maxContentCross + crossPadding, isDefinite: false, + isEstimate: false, }; } // Horizontal cross must not come from parent/self measurement: both equal the // outer VL cell height (header + this list), so it ratchets unbounded. if (!horizontal && parentCross != null && parentCross > 0) { - return { viewportCrossSize: parentCross, isDefinite: false }; + return { viewportCrossSize: parentCross, isDefinite: false, isEstimate: false }; } if (!horizontal && measuredOuterCross > 0) { - return { viewportCrossSize: measuredOuterCross, isDefinite: false }; + return { viewportCrossSize: measuredOuterCross, isDefinite: false, isEstimate: false }; } - return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }; + return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, isEstimate: true }; } diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts index 98bbe933..e65a6acc 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts @@ -81,6 +81,51 @@ describe('resolveFocusScrollTarget', () => { ).toBe(5000); }); + it('centers a near-header row instead of snapping it to the header edge', () => { + // Discover/Trending: a tall hero header with center-aligned sections below + // it. tvOS/AndroidTV center the focused section even when that scrolls the + // header partly off-screen. The header edge-snap (which keeps a small real + // header fully visible for start/end alignment) must not override a center + // target, or early sections settle at the top and never re-center. + const viewportSize = 1080; + const childOffset = 720; + const childSize = 500; + + expect( + resolveFocusScrollTarget({ + viewportSize, + snapToAlignment: 'center', + paddingStart: 48, + paddingEnd: 48, + headerSize: 700, + footerSize: 0, + maxScroll: 5000, + childOffset, + childSize, + }), + ).toBe(childOffset + childSize / 2 - viewportSize / 2); + }); + + it('centers a near-footer row instead of snapping it to the footer edge', () => { + const viewportSize = 1080; + const childOffset = 4700; + const childSize = 500; + + expect( + resolveFocusScrollTarget({ + viewportSize, + snapToAlignment: 'center', + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 700, + maxScroll: 5000, + childOffset, + childSize, + }), + ).toBe(childOffset + childSize / 2 - viewportSize / 2); + }); + it('centers when asked', () => { expect( resolveFocusScrollTarget({ diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts index e1b7794e..e0700da9 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts @@ -4,7 +4,7 @@ export interface FocusScrollTargetParams { /** Focused child's main-axis size. */ childSize: number; viewportSize: number; - snapToAlignment: 'start' | 'center' | 'end'; + snapToAlignment: 'center' | 'end' | 'start'; /** * Per-item pixel offset (`scrollSnapOffset`): land the child's leading edge * at this viewport coordinate. Wins over `snapToAlignment`. @@ -53,10 +53,18 @@ export function resolveFocusScrollTarget({ } else { switch (snapToAlignment) { case 'center': - target = childOffset + childSize / 2 - viewportSize / 2 + (snapToItemPadding ?? 0) / 2; + target = + childOffset + + childSize / 2 - + viewportSize / 2 + + (snapToItemPadding ?? 0) / 2; break; case 'end': - target = childOffset + childSize - viewportSize + (snapToItemPadding ?? paddingEnd); + target = + childOffset + + childSize - + viewportSize + + (snapToItemPadding ?? paddingEnd); break; default: target = childOffset - (snapToItemPadding ?? paddingStart); @@ -64,6 +72,14 @@ export function resolveFocusScrollTarget({ } } + // Center wins over the edge protection: a centered row is meant to sit + // mid-viewport even when that scrolls a real header/footer partly off (tvOS + // parity). Snapping it to the edge instead leaves near-header/footer rows + // stuck at the top/bottom; downstream clamp still bounds the target. + if (snapOffset === undefined && snapToAlignment === 'center') { + return target; + } + if (target > 0 && target <= headerSize) { return 0; } diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index 94362f4f..fdff925d 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -2,20 +2,20 @@ import { type RefObject, useEffect, useRef, useState } from 'react'; import type { LightningElement } from '@plextv/react-lightning'; +import { type MomentumCallbacks, createMomentumTracker } from './createMomentumTracker'; import type { LayoutManager } from './LayoutManager'; -import type { ScrollEvent } from './VirtualListTypes'; - -import { createCriticalSpring } from './scrollSpring'; +import { reconcileScrollBounds } from './reconcileScrollBounds'; import { resolveChildSnapTarget } from './resolveChildSnapAlignment'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; +import { createCriticalSpring } from './scrollSpring'; +import type { ScrollEvent } from './VirtualListTypes'; // Lightning Magic Remote / mouse support (in the host app) installs this hook // while a pointer is driving focus. Read it off globalThis so this subtree stays // free of app imports; undefined (a no-op) on every platform that never loads it. const isPointerFocusScrollSuppressed = (): boolean => { - const fn = ( - globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean } - ).__plexShouldSuppressPointerFocusScroll; + const fn = (globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean }) + .__plexShouldSuppressPointerFocusScroll; return typeof fn === 'function' && fn(); }; @@ -63,6 +63,10 @@ export interface UseScrollHandlerOptions { onAnimationStart?: () => void; /** Fires once when the in-flight animation ends or `resetScroll` cancels it; VL drains the batch. */ onAnimationEnd?: () => void; + /** Momentum-scroll parity: begin once when a focus-driven scroll animates. */ + onMomentumScrollBegin?: (event: ScrollEvent) => void; + /** Momentum-scroll parity: end once when the animated scroll settles or is cancelled. */ + onMomentumScrollEnd?: (event: ScrollEvent) => void; } export interface UseScrollHandlerResult { @@ -105,8 +109,22 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan initialScrollOffset = 0, onAnimationStart, onAnimationEnd, + onMomentumScrollBegin, + onMomentumScrollEnd, } = options; + // Keep the tracker stable across renders (owns the begin/end balance) while + // reading the latest consumer callbacks off a ref updated post-commit — the + // momentum props fire async from the animation loop, never during render. + const momentumCallbacksRef = useRef({}); + + useEffect(() => { + momentumCallbacksRef.current.onMomentumScrollBegin = onMomentumScrollBegin; + momentumCallbacksRef.current.onMomentumScrollEnd = onMomentumScrollEnd; + }, [onMomentumScrollBegin, onMomentumScrollEnd]); + + const [momentum] = useState(() => createMomentumTracker(momentumCallbacksRef)); + const contentRef = useRef(null); const scrollOffsetRef = useRef(initialScrollOffset); const endReachedRef = useRef(false); @@ -181,7 +199,9 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan if (animated && animationDuration > 0) { const thisAnimId = ++animationIdRef.current; - if (!isAnimatingRef.current) { + const justStarted = !isAnimatingRef.current; + + if (justStarted) { isAnimatingRef.current = true; onAnimationStart?.(); } @@ -197,12 +217,15 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan animTargetRef.current = offset; + if (justStarted) { + momentum.start(makeScrollEvent(clamp(from))); + } + // Every animated scroll runs the tvOS spring. A press pumps in the // normalized initial velocity on top of whatever the in-flight // animation carries (UIKit's additive begin-from-current-state), so // chained moves keep their momentum and glide out on the spring tail. - const v0 = - scrollVelocityRef.current + (SPRING_INITIAL_VELOCITY * distance) / 1000; + const v0 = scrollVelocityRef.current + (SPRING_INITIAL_VELOCITY * distance) / 1000; const spring = createCriticalSpring(-distance, v0, SPRING_OMEGA); lastTickRef.current = { pos: from, time: startTime }; @@ -217,8 +240,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const t = now - startTime; const pos = spring.position(t); const done = - (Math.abs(pos) < SPRING_SETTLE_PX && - Math.abs(spring.velocity(t)) < 0.01) || + (Math.abs(pos) < SPRING_SETTLE_PX && Math.abs(spring.velocity(t)) < 0.01) || t >= SPRING_MAX_DURATION_MS; const current = done ? offset : offset + pos; @@ -246,6 +268,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan animTargetRef.current = null; isAnimatingRef.current = false; onAnimationEnd?.(); + momentum.settle(makeScrollEvent(clamp(offset))); } }; @@ -288,17 +311,20 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan // render and the parent's cache write. Same-value setState is free. setCommittedScrollOffset(clamped); - if (onEndReached) { - const distanceFromEnd = totalContentSize - clamped - viewportSize; + const bounds = reconcileScrollBounds({ + scrollOffset: clamped, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached: endReachedRef.current, + hasEndReachedListener: !!onEndReached, + }); - if (distanceFromEnd <= viewportSize * (onEndReachedThreshold ?? 0.5)) { - if (!endReachedRef.current) { - endReachedRef.current = true; - onEndReached({ distanceFromEnd }); - } - } else { - endReachedRef.current = false; - } + endReachedRef.current = bounds.endReached; + + if (bounds.fireEndReached) { + onEndReached?.({ distanceFromEnd: bounds.distanceFromEnd }); } // Animated scrolls stream onScroll from the emit loop instead; emitting @@ -343,8 +369,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const childSize = horizontal ? child.node.w : child.node.h; const headerSize = itemAreaOffset - paddingStart; - const footerSize = - totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; + const footerSize = totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; // A row's own scrollSnapAlign/scrollSnapOffset wins over the list-level alignment, // matching react-native-tvos ("item" defers to rows, markerless rows get 'start'). @@ -385,6 +410,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan if (isAnimatingRef.current) { isAnimatingRef.current = false; onAnimationEnd?.(); + momentum.cancel(makeScrollEvent(offset)); } // Apply directly to lightning so the next paint reflects the new @@ -402,6 +428,38 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } } + // Native ScrollView/FlashList reconcile the offset against the content size on + // every layout pass; react-lightning only did it inside the scroll handler. When + // content shrinks below the current offset the list was left scrolled past the + // new end (blank canvas until the next scroll); a short list already within the + // threshold never primed onEndReached on mount. Re-run the decision whenever the + // size axes change. + // oxlint-disable-next-line react-hooks/exhaustive-deps -- keyed on the size axes; maxScroll derives from them + useEffect(() => { + const bounds = reconcileScrollBounds({ + scrollOffset: scrollOffsetRef.current, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached: endReachedRef.current, + hasEndReachedListener: !!onEndReached, + }); + + if (bounds.didClamp) { + scrollOffsetRef.current = bounds.clampedOffset; + setCommittedScrollOffset(bounds.clampedOffset); + applyPosition(bounds.clampedOffset, false); + onScroll?.(makeScrollEvent(bounds.clampedOffset)); + } + + endReachedRef.current = bounds.endReached; + + if (bounds.fireEndReached) { + onEndReached?.({ distanceFromEnd: bounds.distanceFromEnd }); + } + }, [totalContentSize, viewportSize]); + return { contentRef, scrollOffsetRef, diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index a29b914c..eac0c950 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -36,6 +36,7 @@ import { } from '../types'; import { AllStyleProps } from './AllStyleProps'; import { createFlattenedNode } from './FlattenedRendererNode'; +import { isTranslateSettled } from './isTranslateSettled'; const __bannedProps: Record = {}; let __bannedPropsInitialized = false; @@ -1648,11 +1649,20 @@ export class LightningViewElement< }; private _onLayout = (dimensions: Rect) => { + const hadLayout = this._hasLayout; this._hasLayout = true; - // First layout resolved — reveal a withheld node at its now-correct - // geometry. See {@link withholdPaintUntilLayout}. - if (this._paintWithheld) { + // Reveal a withheld node at its now-correct geometry. A pixel translate + // transform is resolved off the base position and lands a layout pass later, + // so hold the reveal past the first (pre-transform) layout — otherwise the + // node paints at its untransformed origin for a frame. Bounded to that one + // extra layout so a mis-detected translate can never strand it invisible. + // See {@link withholdPaintUntilLayout}. + if ( + this._paintWithheld && + (hadLayout || + isTranslateSettled(this.props.style, this.node.x, this.node.y)) + ) { this._paintWithheld = false; if (this.node.alpha !== this._withheldAlpha) { diff --git a/packages/react-lightning/src/element/isTranslateSettled.spec.ts b/packages/react-lightning/src/element/isTranslateSettled.spec.ts new file mode 100644 index 00000000..a6c407e7 --- /dev/null +++ b/packages/react-lightning/src/element/isTranslateSettled.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { isTranslateSettled } from './isTranslateSettled'; + +describe('isTranslateSettled', () => { + it('settles when there is no transform', () => { + expect(isTranslateSettled(undefined, 0, 0)).toBe(true); + expect(isTranslateSettled({ x: 0 }, 0, 0)).toBe(true); + }); + + it('is unsettled while a pixel translateX is not yet folded into the position', () => { + // Drawer: left 0, translateX -452 → final x -452. First layout is still 0. + const style = { x: 0, transform: { translateX: -452 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(false); + }); + + it('settles once the position reflects the pixel translateX', () => { + const style = { x: 0, transform: { translateX: -452 } }; + expect(isTranslateSettled(style, -452, 0)).toBe(true); + }); + + it('settles for a zero translate', () => { + expect( + isTranslateSettled({ x: 10, transform: { translateX: 0 } }, 10, 0), + ).toBe(true); + }); + + it('does not gate percentage translates (not resolvable from base)', () => { + const style = { x: 0, transform: { translateX: '-100%' as const } }; + expect(isTranslateSettled(style, 0, 0)).toBe(true); + }); + + it('does not gate when the base edge is unknown (e.g. right-anchored)', () => { + const style = { transform: { translateX: -452 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(true); + }); + + it('handles translateY the same way', () => { + const style = { y: 0, transform: { translateY: -100 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(false); + expect(isTranslateSettled(style, 0, -100)).toBe(true); + }); +}); diff --git a/packages/react-lightning/src/element/isTranslateSettled.ts b/packages/react-lightning/src/element/isTranslateSettled.ts new file mode 100644 index 00000000..c7aa9b51 --- /dev/null +++ b/packages/react-lightning/src/element/isTranslateSettled.ts @@ -0,0 +1,54 @@ +type TranslatableStyle = { + x?: number; + y?: number; + transform?: { + translateX?: number | string; + translateY?: number | string; + }; +}; + +/** + * A withheld node reveals on its first layout, but a pixel translate transform + * is resolved off the node's base position and so lands a layout pass later — + * the first layout still holds the pre-transform origin. Returns false while + * such a translate hasn't been folded into the position yet, so the reveal can + * wait for the pass that has (avoids painting the node at its untransformed + * origin for a frame). + * + * Only left/top-anchored pixel translates are detectable here (final position = + * base + delta). Percentage translates and right/bottom anchoring aren't, and + * fall through to the normal first-layout reveal. + */ +export function isTranslateSettled( + style: TranslatableStyle | null | undefined, + nodeX: number, + nodeY: number, +): boolean { + const transform = style?.transform; + + if (!transform) { + return true; + } + + const { translateX, translateY } = transform; + + if ( + typeof translateX === 'number' && + translateX !== 0 && + typeof style?.x === 'number' && + nodeX !== style.x + translateX + ) { + return false; + } + + if ( + typeof translateY === 'number' && + translateY !== 0 && + typeof style?.y === 'number' && + nodeY !== style.y + translateY + ) { + return false; + } + + return true; +} diff --git a/packages/react-lightning/src/focus/FocusKeyManager.ts b/packages/react-lightning/src/focus/FocusKeyManager.ts index c251894c..cd52cd3e 100644 --- a/packages/react-lightning/src/focus/FocusKeyManager.ts +++ b/packages/react-lightning/src/focus/FocusKeyManager.ts @@ -1,6 +1,6 @@ import { Keys } from '../input/Keys'; import type { KeyEvent, LightningElement } from '../types'; -import { findClosestElement } from '../utils/findClosestElement'; +import { findClosestElement, resolveDirectionalTarget } from '../utils/findClosestElement'; import { Direction } from './Direction'; import type { FocusManager, FocusNode } from './FocusManager'; @@ -79,6 +79,8 @@ export class FocusKeyManager { return true; } + // Pick the next sibling from the immediate focused child, so the current + // subtree (and its ancestors) are excluded as candidates. const closestElement = findClosestElement( focusNode.focusedElement.element, childElements(focusNode.children), @@ -88,7 +90,29 @@ export class FocusKeyManager { ); if (closestElement) { - this._focusManager.focus(closestElement as T); + // Descend into the chosen sibling from the deepest focused leaf, not the + // group's immediate child, so a nested layout keeps the real cross-axis + // position of what the user is on (an EPG airing, not its full-width row) + // and lands on the overlapping child instead of the group's first child. + // A redirect node (e.g. a row's airings guide) is returned for the focus + // manager to forward to its anchored destination. + let leafNode: FocusNode = focusNode.focusedElement; + + while (leafNode.focusedElement) { + leafNode = leafNode.focusedElement; + } + + const target = resolveDirectionalTarget( + leafNode.element, + closestElement, + focusNode.parent.element, + direction, + (child) => this._focusableChildElements(child), + (child) => this._isRedirect(child), + (child) => this._getAllowOffscreen(child), + ); + + this._focusManager.focus(target); return false; } @@ -108,4 +132,22 @@ export class FocusKeyManager { return true; }; + + private _focusableChildElements = (element: LightningElement): Iterable => { + const node = this._focusManager.getFocusNode(element); + + return node ? childElements(node.children) : []; + }; + + private _getAllowOffscreen = (element: LightningElement): boolean => { + const node = this._focusManager.getFocusNode(element); + + return !!node?.allowOffscreen; + }; + + private _isRedirect = (element: LightningElement): boolean => { + const node = this._focusManager.getFocusNode(element); + + return !!node?.focusRedirect; + }; } diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index cb092b45..d4d3debf 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockElement, type MockElement } from '../mocks/createMockElement'; +import { + createMockElement, + type MockElement, +} from '../mocks/createMockElement'; import { FocusManager } from './FocusManager'; describe('FocusManager', () => { @@ -285,7 +288,12 @@ describe('FocusManager', () => { focusManager.addElement(modalGrandChild, modalChild); focusManager.addElement(modalChild, modal); - expect(focusManager.focusPath).toEqual([parent, modal, modalChild, modalGrandChild]); + expect(focusManager.focusPath).toEqual([ + parent, + modal, + modalChild, + modalGrandChild, + ]); }); describe('setFocusedChild', () => { @@ -460,7 +468,7 @@ describe('FocusManager', () => { }); describe('destinations on arrival', () => { - it('forwards to a destination on first arrival, then remembers the child', () => { + it('forwards to a destination on every arrival, not just the first', () => { const root = createMockElement(1, 'root'); const group = createMockElement(2, 'group'); const child1 = createMockElement(3, 'child1'); @@ -476,13 +484,15 @@ describe('FocusManager', () => { focusManager.focus(group); expect(focusManager.focusPath).toEqual([root, group, child2]); - // Move focus to child1, then re-enter the group: it now remembers the - // last-focused child instead of redirecting again. + // Move focus to child1, then re-enter the group. `destinations` takes + // precedence over the remembered child on every arrival (matching native + // TVFocusGuideView `destinations`), so focus returns to child2 — this is + // what routes a reopened nav drawer back to its selected item. focusManager.focus(child1); expect(focusManager.focusPath).toEqual([root, group, child1]); focusManager.focus(group); - expect(focusManager.focusPath).toEqual([root, group, child1]); + expect(focusManager.focusPath).toEqual([root, group, child2]); }); it('always redirects with focusRedirect, every visit', () => { @@ -501,6 +511,38 @@ describe('FocusManager', () => { expect(focusManager.focusPath).toEqual([root, real]); }); + it('lands on an internal-redirect destination without self-cycling', () => { + const root = createMockElement(1, 'root'); + const sibling = createMockElement(2, 'sibling'); + const guide = createMockElement(3, 'guide'); + const cellA = createMockElement(4, 'cellA'); + const cellB = createMockElement(5, 'cellB'); + // The guide's destinations point at its own descendants (an EPG airings + // guide anchoring on one of its cells), so the element parent chain runs + // through the guide. + cellA.parent = guide; + cellB.parent = guide; + + focusManager.addElement(root, null); + focusManager.addElement(sibling, root); + focusManager.addElement(guide, root, { + focusRedirect: true, + destinations: [cellB], + }); + focusManager.addElement(cellA, guide); + focusManager.addElement(cellB, guide); + + focusManager.focus(sibling); + + // Entering the guide forwards to its internal destination and stays + // there. The upward-walk redirect must not re-fire on the way back up + // (it targets a descendant we just came from), or it self-cycles and + // aborts the move, stranding focus on the guide's first child. + focusManager.focus(guide); + expect(focusManager.focusPath).toEqual([root, guide, cellB]); + expect(cellB.focused).toBe(true); + }); + it('falls back to normal child focus when the destination is unregistered', () => { const root = createMockElement(1, 'root'); const sibling = createMockElement(2, 'sibling'); @@ -973,4 +1015,114 @@ describe('FocusManager', () => { expect(wrapperBubble).not.toHaveBeenCalled(); }); }); + + describe('restoration-excluded focus targets', () => { + it('does not become the parent preferred child on mount', () => { + const parent = createMockElement(1, 'parent'); + const catcher = createMockElement(2, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + // The catcher is focusable but must never be auto-selected as the + // mount-time default (the drawer-opens-on-launch race). + expect(catcher.focused).toBe(false); + expect(focusManager.focusPath).toEqual([parent]); + }); + + it('is skipped as the fallback when the focused sibling is removed', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + expect(focusManager.focusPath).toEqual([parent, content]); + + // Content unmounts during launch; the catcher must not inherit focus. + focusManager.removeElement(content); + expect(catcher.focused).toBe(false); + expect(focusManager.focusPath).toEqual([parent]); + }); + + it('prefers a real sibling over the excluded node as fallback', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + const other = createMockElement(4, 'other'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + focusManager.addElement(other, parent); + + expect(focusManager.focusPath).toEqual([parent, content]); + + focusManager.removeElement(content); + // Even though the catcher sits before `other` in the child list, the + // fallback skips it and lands on the real sibling. + expect(focusManager.focusPath).toEqual([parent, other]); + expect(catcher.focused).toBe(false); + }); + + it('still accepts an explicit (directional) focus request', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + // A deliberate directional move (findClosestElement -> focus) must still + // land on the catcher so pressing Left into the nav keeps working. + focusManager.focus(catcher); + expect(catcher.focused).toBe(true); + expect(focusManager.focusPath).toEqual([parent, catcher]); + }); + }); + + describe('onChildFocused across intervening groups', () => { + it('notifies an ancestor group when its directly-focused child changes through a nested group', () => { + // Mirrors a VirtualList (vl) whose cells nest their own focus group: a + // focusable tile is two levels below the list. On tvOS the list still + // hears focus cross its cells, so its scroll-to-focus can run. + const vl = createMockElement(1, 'vl'); + const cellA = createMockElement(2, 'cellA'); + const cellB = createMockElement(3, 'cellB'); + const tileA = createMockElement(4, 'tileA'); + const tileB = createMockElement(5, 'tileB'); + vl.isFocusGroup = true; + cellA.isFocusGroup = true; + cellB.isFocusGroup = true; + + focusManager.addElement(vl, null, { autoFocus: false }); + focusManager.addElement(cellA, vl, { autoFocus: false }); + focusManager.addElement(cellB, vl, { autoFocus: false }); + focusManager.addElement(tileA, cellA, { autoFocus: false }); + focusManager.addElement(tileB, cellB, { autoFocus: false }); + + const onChildFocused = vi.fn(); + focusManager.setOnChildFocused(vl, onChildFocused); + + focusManager.focus(tileA); + onChildFocused.mockClear(); + focusManager.focus(tileB); + + // vl's own focused child moved cellA -> cellB; it must be told, with the + // child on its path (the cell), not left starved because the leaf's + // immediate parent is the cell rather than the list. + expect(onChildFocused).toHaveBeenCalledWith(cellB); + }); + }); }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index f841aa2c..1d011da9 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -31,6 +31,14 @@ export type FocusNode = Omit, 'element'> & { hasFocusableChildren: boolean; /** When true, focus navigation can target non-visible children (e.g. clipped items in a virtualized list). */ allowOffscreen: boolean; + /** + * When true, this node is reachable only by a deliberate directional move, + * never by restoration: it's excluded from fallback selection when a focused + * sibling unmounts and from the mount-time default. Mirrors the intent of + * tvOS `isTVFocusRestorationExcluded` (e.g. an edge-guard focus catcher that + * must not steal focus on launch). + */ + focusRestorationExcluded: boolean; }; type FocusLayer = { @@ -146,12 +154,14 @@ export class FocusManager< destinations?: (T | null)[] | null; traps?: Traps; allowOffscreen?: boolean; + focusRestorationExcluded?: boolean; }, ): void { const autoFocus = options?.autoFocus ?? false; const focusRedirect = options?.focusRedirect ?? false; const destinations = options?.destinations ?? null; const allowOffscreen = options?.allowOffscreen ?? false; + const focusRestorationExcluded = options?.focusRestorationExcluded ?? false; const traps = options?.traps ?? { up: false, right: false, @@ -200,6 +210,7 @@ export class FocusManager< childNode.destinations = destinations; childNode.traps = traps; childNode.allowOffscreen = allowOffscreen; + childNode.focusRestorationExcluded = focusRestorationExcluded; // If the child node already exists, we need to remove it from its current parent if (childNode.parent !== parentNode) { @@ -232,6 +243,7 @@ export class FocusManager< destinations, traps, allowOffscreen, + focusRestorationExcluded, ); } @@ -241,7 +253,11 @@ export class FocusManager< this._checkFocusableChildren(parentNode); - if (this._isEffectivelyFocusable(childNode) && !hasExternalRedirect(childNode)) { + if ( + this._isEffectivelyFocusable(childNode) && + !hasExternalRedirect(childNode) && + !childNode.focusRestorationExcluded + ) { if (!parentNode.focusedElement) { // No preferred child yet — take the slot regardless of autoFocus. parentNode.focusedElement = childNode; @@ -552,6 +568,7 @@ export class FocusManager< destinations: (T | null)[] | null = null, traps: Traps = { up: false, right: false, down: false, left: false }, allowOffscreen = false, + focusRestorationExcluded = false, ) { const node: FocusNode = { element, @@ -564,6 +581,7 @@ export class FocusManager< traps, hasFocusableChildren: false, allowOffscreen, + focusRestorationExcluded, focusCommitted: false, }; @@ -681,14 +699,13 @@ export class FocusManager< } private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { - // On arrival, forward to a declared destination. With focusRedirect this - // happens on every visit (a permanent redirect); without it, only on the - // first visit (no remembered child yet) — matching native - // TVFocusGuideView, which forwards focus on arrival then remembers the - // last-focused child for subsequent visits. + // On arrival, forward to a declared destination — on every visit, not just + // the first. `destinations` takes precedence over the remembered child, + // matching native TVFocusGuideView `destinations` (a reopened nav drawer + // returns to its selected item). `autoFocus` is the separate first-then- + // remember mechanism, resolved via the group's `focusedElement` below. if ( childNode.destinations && - (childNode.focusRedirect || !childNode.focusCommitted) && this._redirectToDestination(childNode, visitedRedirects) ) { return; @@ -698,7 +715,7 @@ export class FocusManager< let currChild: FocusNode | RootNode = childNode; if (currChild.children.length && !currChild.focusedElement) { - currChild.focusedElement = this._findNextBestFocus(currChild); + currChild.focusedElement = this._findNextBestFocus(currChild, undefined, true); } // Focus has now explicitly arrived at this node, so mark its subtree as @@ -706,15 +723,30 @@ export class FocusManager< // registration (see addElement / focusCommitted). childNode.focusCommitted = true; + // A group hears onChildFocused whenever its own directly-focused child + // changes — not only the group directly above the leaf. Otherwise a + // VirtualList never learns focus crossed a cell when the cell nests its own + // focus group, and its scroll-to-focus stops following. Matches tvOS, where + // every ancestor is notified as focus crosses its children. + const childFocusChanged: FocusNode[] = []; + while (currChild && !isRootNode(currChild) && currParent) { + // Only hand focus off to an external redirect while walking up. An + // internal redirect (destinations within this node's own subtree, e.g. + // an EPG airings guide pointing at its own cells) is already satisfied by + // the downward-arrival redirect above; re-firing it here would target a + // descendant we just came from and self-cycle, aborting the focus move. if ( - currChild.focusRedirect && - currChild.destinations && + hasExternalRedirect(currChild) && this._redirectToDestination(currChild, visitedRedirects) ) { return; } + if (currParent.focusedElement !== currChild) { + childFocusChanged.push(currChild as FocusNode); + } + currParent.focusedElement = currChild as FocusNode; currParent.focusCommitted = true; currChild = currParent; @@ -722,7 +754,10 @@ export class FocusManager< } this._recalculateFocusPath(); - this._tryEmitChildFocusedEvent(childNode); + + for (const node of childFocusChanged) { + this._tryEmitChildFocusedEvent(node); + } } private _tryEmitChildFocusedEvent(node: FocusNode) { @@ -836,7 +871,7 @@ export class FocusManager< const parent = node.parent; if (this._isEffectivelyFocusable(node)) { - if (!parent.focusedElement && !hasExternalRedirect(node)) { + if (!parent.focusedElement && !hasExternalRedirect(node) && !node.focusRestorationExcluded) { parent.focusedElement = node; } } else if (parent.focusedElement === node) { @@ -863,6 +898,9 @@ export class FocusManager< private _findNextBestFocus( parent: FocusNode | RootNode, relativeNode?: FocusNode, + // Restoration-excluded nodes are skipped for fallback/restoration picks, + // but included when focus arrives at a group deliberately (directional). + includeRestorationExcluded = false, ): FocusNode | null { if (parent.children.length === 0) { return null; @@ -885,6 +923,7 @@ export class FocusManager< newChild && this._isEffectivelyFocusable(newChild) && !hasExternalRedirect(newChild) && + (includeRestorationExcluded || !newChild.focusRestorationExcluded) && newChild !== relativeNode ) { if (i >= relativeIndex) { diff --git a/packages/react-lightning/src/focus/useFocus.tsx b/packages/react-lightning/src/focus/useFocus.tsx index 31566743..cd192bdf 100644 --- a/packages/react-lightning/src/focus/useFocus.tsx +++ b/packages/react-lightning/src/focus/useFocus.tsx @@ -12,6 +12,12 @@ export type FocusOptions = { onChildFocused?: (child: LightningElement) => void; /** When true, focus navigation can target non-visible children (e.g. clipped items in a virtualized list). */ allowOffscreen?: boolean; + /** + * When true, this element is reachable only by a deliberate directional move, + * never by focus restoration (fallback after a sibling unmounts) or the + * mount-time default. Mirrors tvOS `isTVFocusRestorationExcluded`. + */ + focusRestorationExcluded?: boolean; }; export function useFocus( @@ -22,6 +28,7 @@ export function useFocus( destinations, onChildFocused, allowOffscreen, + focusRestorationExcluded, }: FocusOptions = { active: true, autoFocus: false, @@ -62,6 +69,7 @@ export function useFocus( focusRedirect, destinations, allowOffscreen, + focusRestorationExcluded, }); } diff --git a/packages/react-lightning/src/index.ts b/packages/react-lightning/src/index.ts index e93b60ed..8fb96406 100644 --- a/packages/react-lightning/src/index.ts +++ b/packages/react-lightning/src/index.ts @@ -9,6 +9,7 @@ export { PARTIAL_STYLE } from './element/partialStyle'; export { FocusGroup, type FocusGroupProps } from './focus/FocusGroup'; export { FocusGroupContext } from './focus/FocusGroupContext'; export { FocusManager } from './focus/FocusManager'; +export { FocusManagerContext } from './focus/FocusManagerContext'; export { focusable } from './focus/focusable'; export { useFocus } from './focus/useFocus'; export { useFocusManager } from './focus/useFocusManager'; diff --git a/packages/react-lightning/src/shim/resizeObserverShim.spec.ts b/packages/react-lightning/src/shim/resizeObserverShim.spec.ts new file mode 100644 index 00000000..fd6d4604 --- /dev/null +++ b/packages/react-lightning/src/shim/resizeObserverShim.spec.ts @@ -0,0 +1,172 @@ +import { beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import type { Rect } from '../types'; + +// Minimal EventEmitter-backed stand-in so the shim's `instanceof +// LightningViewElement` guard and `on/off/emit('layout')` wiring resolve. +vi.mock('../element/LightningViewElement', () => { + class LightningViewElement { + private _handlers = new Map void>>(); + + public on(event: string, handler: (rect: Rect) => void): () => void { + let set = this._handlers.get(event); + if (!set) { + this._handlers.set(event, (set = new Set())); + } + set.add(handler); + return () => this.off(event, handler); + } + + public off(event: string, handler: (rect: Rect) => void): void { + this._handlers.get(event)?.delete(handler); + } + + public emit(event: string, rect: Rect): void { + this._handlers.get(event)?.forEach((handler) => handler(rect)); + } + + public handlerCount(event: string): number { + return this._handlers.get(event)?.size ?? 0; + } + } + + return { LightningViewElement }; +}); + +type LayoutTarget = { + emit(event: string, rect: Rect): void; + handlerCount(event: string): number; +}; + +let LightningViewElementMock: new () => LayoutTarget; +let ResizeObserverShim: typeof window.ResizeObserver; + +beforeAll(async () => { + class ResizeObserverBase { + public constructor(_callback: ResizeObserverCallback) {} + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} + } + + class DOMRectReadOnlyStub { + public constructor( + public x: number, + public y: number, + public width: number, + public height: number, + ) {} + } + + vi.stubGlobal('window', { ResizeObserver: ResizeObserverBase }); + vi.stubGlobal('ResizeObserver', ResizeObserverBase); + vi.stubGlobal('DOMRectReadOnly', DOMRectReadOnlyStub); + + await import('./resizeObserverShim'); + ResizeObserverShim = window.ResizeObserver; + + ({ LightningViewElement: LightningViewElementMock } = (await import( + '../element/LightningViewElement' + )) as unknown as { LightningViewElement: new () => LayoutTarget }); +}); + +function makeTarget(): LayoutTarget { + return new LightningViewElementMock(); +} + +describe('LightningResizeObserver', () => { + let callback: Mock; + let observer: ResizeObserver; + + beforeEach(() => { + callback = vi.fn(); + observer = new ResizeObserverShim(callback); + }); + + function entriesFor(call: number): ResizeObserverEntry[] { + const args = callback.mock.calls[call]; + if (!args) { + throw new Error(`callback was not invoked ${call + 1} time(s)`); + } + return args[0]; + } + + function soleEntry(entries: ResizeObserverEntry[]): ResizeObserverEntry { + const [entry] = entries; + if (!entry) { + throw new Error('expected at least one entry'); + } + return entry; + } + + it('delivers only the changed target, with its own rect', () => { + const a = makeTarget(); + const b = makeTarget(); + const c = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + observer.observe(c as unknown as Element); + + b.emit('layout', { x: 1, y: 2, w: 3, h: 4 }); + + expect(callback).toHaveBeenCalledTimes(1); + const entries = entriesFor(0); + expect(entries).toHaveLength(1); + expect(entries.some((e) => e.target === (a as unknown as Element))).toBe(false); + const entry = soleEntry(entries); + expect(entry.target).toBe(b); + expect(entry.contentRect).toMatchObject({ x: 1, y: 2, width: 3, height: 4 }); + expect(entry.borderBoxSize[0]).toMatchObject({ inlineSize: 3, blockSize: 4 }); + expect(entry.contentBoxSize[0]).toMatchObject({ inlineSize: 3, blockSize: 4 }); + }); + + it('reports each target with its own rect, not a stale firing target rect', () => { + const a = makeTarget(); + const b = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + + a.emit('layout', { x: 10, y: 20, w: 100, h: 200 }); + b.emit('layout', { x: 5, y: 6, w: 7, h: 8 }); + + const first = soleEntry(entriesFor(0)); + const second = soleEntry(entriesFor(1)); + + expect(first.target).toBe(a); + expect(first.contentRect).toMatchObject({ x: 10, y: 20, width: 100, height: 200 }); + expect(second.target).toBe(b); + expect(second.contentRect).toMatchObject({ x: 5, y: 6, width: 7, height: 8 }); + }); + + it('stops firing for an unobserved target and removes its layout handler', () => { + const a = makeTarget(); + + observer.observe(a as unknown as Element); + expect(a.handlerCount('layout')).toBe(1); + + observer.unobserve(a as unknown as Element); + expect(a.handlerCount('layout')).toBe(0); + + a.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('disconnect removes every target layout handler', () => { + const a = makeTarget(); + const b = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + + observer.disconnect(); + + expect(a.handlerCount('layout')).toBe(0); + expect(b.handlerCount('layout')).toBe(0); + + a.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + b.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-lightning/src/shim/resizeObserverShim.ts b/packages/react-lightning/src/shim/resizeObserverShim.ts index c2734306..28bc2876 100644 --- a/packages/react-lightning/src/shim/resizeObserverShim.ts +++ b/packages/react-lightning/src/shim/resizeObserverShim.ts @@ -3,7 +3,10 @@ import type { Rect } from '../types'; class LightningResizeObserver extends window.ResizeObserver { private _callback: ResizeObserverCallback; - private _targets: Set = new Set(); + // Per-target layout handler so each entry reports its own target's rect and + // the handler ref stays removable on unobserve/disconnect. + private _handlers = + new Map void>(); public constructor(callback: ResizeObserverCallback) { super(callback); @@ -11,10 +14,19 @@ class LightningResizeObserver extends window.ResizeObserver { this._callback = callback; } - public override observe(target: Element, options?: ResizeObserverOptions | undefined): void { + public override observe( + target: Element, + options?: ResizeObserverOptions | undefined, + ): void { if (target instanceof LightningViewElement) { - this._targets.add(target); - target.on('layout', this._fireCallbacks); + if (this._handlers.has(target)) { + return; + } + + const handler = (dimensions: Rect): void => + this._fire(target, dimensions); + this._handlers.set(target, handler); + target.on('layout', handler); return; } @@ -24,8 +36,11 @@ class LightningResizeObserver extends window.ResizeObserver { public override unobserve(target: Element): void { if (target instanceof LightningViewElement) { - this._targets.delete(target); - target.off('layout', this._fireCallbacks); + const handler = this._handlers.get(target); + if (handler) { + target.off('layout', handler); + this._handlers.delete(target); + } return; } @@ -34,39 +49,48 @@ class LightningResizeObserver extends window.ResizeObserver { } public override disconnect(): void { - this._targets.forEach(this.unobserve.bind(this)); + for (const [target, handler] of this._handlers) { + target.off('layout', handler); + } + + this._handlers.clear(); super.disconnect(); } - private _fireCallbacks = (dimensions: Rect): void => { - const entries = Array.from(this._targets).map((target) => { - return { - borderBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - contentBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - devicePixelContentBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - contentRect: new DOMRectReadOnly(dimensions.x, dimensions.y, dimensions.w, dimensions.h), - target: target as unknown as Element, - }; - }); - - this._callback(entries, this); - }; + // Deliver only the target that actually changed, with its own rect — the shim + // has no frame boundary to coalesce multiple targets into one batch. + private _fire(target: LightningViewElement, dimensions: Rect): void { + const entry: ResizeObserverEntry = { + borderBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + contentBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + devicePixelContentBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + contentRect: new DOMRectReadOnly( + dimensions.x, + dimensions.y, + dimensions.w, + dimensions.h, + ), + target: target as unknown as Element, + }; + + this._callback([entry], this); + } } window.ResizeObserver = LightningResizeObserver; diff --git a/packages/react-lightning/src/utils/findClosestElement.spec.ts b/packages/react-lightning/src/utils/findClosestElement.spec.ts index 3aa5b633..39d27680 100644 --- a/packages/react-lightning/src/utils/findClosestElement.spec.ts +++ b/packages/react-lightning/src/utils/findClosestElement.spec.ts @@ -313,6 +313,22 @@ suite('findClosestElement', () => { }); suite('getOverlap', () => { + describe('should enter a sibling container that encloses the source', () => { + /** + * A small item (1, e.g. a pivot) sits at the top of a large sibling + * container (2, e.g. a screen scene behind a floating header) that fully + * encloses it and extends below. Down must enter the container — its + * focusable content is beyond the source, not above it. + */ + const elements = createLayout(500, 500, [ + { x: 0, y: 0, w: 200, h: 40 }, + { x: 0, y: 0, w: 500, h: 500 }, + ]); + const tests: TestCases = [[1, Direction.Down, 2]]; + + runTestsOnElements(elements, tests); + }); + it('should return the correct overlap for two elements', () => { const a = { x: 0, y: 0, w: 100, h: 100, centerX: 50, centerY: 50 }; const b = { diff --git a/packages/react-lightning/src/utils/findClosestElement.ts b/packages/react-lightning/src/utils/findClosestElement.ts index 067c6c0a..76cfde39 100644 --- a/packages/react-lightning/src/utils/findClosestElement.ts +++ b/packages/react-lightning/src/utils/findClosestElement.ts @@ -62,7 +62,14 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension break; case Direction.Down: targetX = clampToSpan(source.centerX, target.x, target.w); - targetY = target.y; + // A sibling that fully encloses the source vertically (a screen scene + // behind a floating header) keeps its focusable content below the source, + // not above it — enter it from the source's bottom edge rather than + // rejecting it on its top edge. + targetY = + target.y <= source.y && target.y + target.h >= source.y + source.h + ? source.y + source.h + : target.y; if (targetY < source.centerY) { return null; @@ -319,3 +326,53 @@ function findClosestNodeInTree( return closest; } + +/** + * Resolve the final directional target after a move lands on `target`. + * + * Native focus engines beam from the currently focused view's geometry, so a + * nested layout (a narrow header beside a wide scrollable group) lands under + * the source's cross-axis position, not on the group's first child. From + * `target`, keep picking the child closest to `source` along `direction`. + * `source` must be the deep focused leaf (not an intermediate group) so its + * real cross position drives the descent. + * + * Stop at a redirect node (`isRedirect`) and return it: the focus manager + * forwards it to its destination (e.g. an EPG airings guide anchoring on the + * overlapping airing). Otherwise descend to a plain focusable leaf. Returns + * `target` unchanged when it has no focusable children. + */ +export function resolveDirectionalTarget( + source: LightningElement, + target: LightningElement, + parentElement: LightningElement | null, + direction: Direction, + getFocusableChildren: (element: LightningElement) => Iterable, + isRedirect: (element: LightningElement) => boolean, + getAllowOffscreen: (element: LightningElement) => boolean, +): LightningElement { + let current = target; + + // Bounded by tree depth; guards against a malformed accessor cycle. + for (let depth = 0; depth < 64; depth++) { + if (isRedirect(current)) { + return current; + } + + const next = findClosestElement( + source, + getFocusableChildren(current), + parentElement, + direction, + getAllowOffscreen(current), + ); + + if (!next || next === current) { + return current; + } + + current = next; + } + + return current; +} diff --git a/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts b/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts new file mode 100644 index 00000000..5b400a05 --- /dev/null +++ b/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { Direction } from '../focus/Direction'; +import type { LightningElement, Rect } from '../types'; +import { resolveDirectionalTarget } from './findClosestElement'; + +type TestElement = LightningElement & { + children: TestElement[]; + redirect: boolean; + allowOffscreen: boolean; +}; + +function el( + id: number, + rect: Rect, + opts: { children?: TestElement[]; redirect?: boolean; allowOffscreen?: boolean } = {}, +): TestElement { + return { + id, + node: { ...rect }, + focusable: true, + focusableIntent: true, + isFocusGroup: (opts.children?.length ?? 0) > 0, + children: opts.children ?? [], + redirect: opts.redirect ?? false, + allowOffscreen: opts.allowOffscreen ?? false, + getRelativePosition(this: LightningElement, relativeElement?: LightningElement) { + return { + x: (relativeElement?.node?.x ?? 0) + this.node.x, + y: (relativeElement?.node?.y ?? 0) + this.node.y, + }; + }, + } as unknown as TestElement; +} + +const getFocusableChildren = (e: LightningElement): Iterable => + (e as TestElement).children; +const isRedirect = (e: LightningElement): boolean => (e as TestElement).redirect; +const getAllowOffscreen = (e: LightningElement): boolean => (e as TestElement).allowOffscreen; + +// EPG-shaped row below the source: a narrow channel header on the left and a +// wide airings guide on the right. The guide is a redirect node (it anchors on +// its own overlapping airing via the focus manager), so the descent should +// stop at it rather than the header or its inner cells. +const ROW_H = 120; +function airingRow(y: number) { + const cellA = el(y + 1, { x: 110, y, w: 300, h: ROW_H }); + const cellB = el(y + 2, { x: 560, y, w: 300, h: ROW_H }); + const guide = el( + y + 3, + { x: 110, y, w: 1390, h: ROW_H }, + { children: [cellA, cellB], redirect: true, allowOffscreen: true }, + ); + const header = el(y + 4, { x: 0, y, w: 104, h: ROW_H }); + const row = el(y + 5, { x: 0, y, w: 1500, h: ROW_H }, { children: [header, guide] }); + return { row, header, guide, cellA, cellB }; +} + +describe('resolveDirectionalTarget', () => { + it('descends to the airings guide (a redirect node), not the row-start header', () => { + const below = airingRow(130); + // Source airing mid-timeline, centerX 660 — over the guide, not the header. + const source = el(999, { x: 560, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + below.row, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(below.guide); + expect(target).not.toBe(below.header); + // Stops at the redirect guide; does not descend into its cells. + expect(target).not.toBe(below.cellA); + expect(target).not.toBe(below.cellB); + }); + + it('anchors on Up the same way', () => { + const above = airingRow(0); + const source = el(999, { x: 560, y: 130, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + above.row, + null, + Direction.Up, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(above.guide); + }); + + it('descends through a non-redirect group to the overlapping leaf', () => { + const leftLeaf = el(1, { x: 0, y: 130, w: 100, h: ROW_H }); + const rightLeaf = el(2, { x: 600, y: 130, w: 300, h: ROW_H }); // 600..900 + const group = el(3, { x: 0, y: 130, w: 900, h: ROW_H }, { + children: [leftLeaf, rightLeaf], + redirect: false, + }); + // Source centerX 700 overlaps the right leaf. + const source = el(999, { x: 600, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + group, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(rightLeaf); + }); + + it('returns a redirect node as-is', () => { + const inner = el(2, { x: 560, y: 130, w: 300, h: ROW_H }); + const guide = el(1, { x: 110, y: 130, w: 1390, h: ROW_H }, { + children: [inner], + redirect: true, + }); + const source = el(999, { x: 560, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + guide, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(guide); + }); + + it('returns a plain focusable leaf unchanged', () => { + const leaf = el(1, { x: 0, y: 130, w: 300, h: ROW_H }); + const source = el(999, { x: 0, y: 0, w: 300, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + leaf, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(leaf); + }); +}); diff --git a/packages/react-native-lightning/src/exports/ActivityIndicator.tsx b/packages/react-native-lightning/src/exports/ActivityIndicator.tsx index 5be777ee..c70a7e40 100644 --- a/packages/react-native-lightning/src/exports/ActivityIndicator.tsx +++ b/packages/react-native-lightning/src/exports/ActivityIndicator.tsx @@ -45,7 +45,9 @@ export const ActivityIndicator: ForwardRefExoticComponent = focusable< ) { const [state, setState] = useState({ focused: false, pressed: false }); - const forwardFocus = useFocusHandler(onFocus); - const forwardBlur = useBlurHandler(onBlur); + const forwardFocus = useFocusHandler(onFocus ?? undefined); + const forwardBlur = useBlurHandler(onBlur ?? undefined); const handleLayout = useLayoutHandler(onLayout); // RN's Pressable exposes `focused` to its function children; mirror that by diff --git a/packages/react-native-lightning/src/exports/ScrollView.tsx b/packages/react-native-lightning/src/exports/ScrollView.tsx index fb23ad28..de5ec0b6 100644 --- a/packages/react-native-lightning/src/exports/ScrollView.tsx +++ b/packages/react-native-lightning/src/exports/ScrollView.tsx @@ -1,46 +1,72 @@ -import { createRef, PureComponent } from 'react'; +import { + type ForwardRefExoticComponent, + PureComponent, + type RefAttributes, + createRef, + forwardRef, + useContext, + useEffect, + useRef, +} from 'react'; +import type { JSX } from 'react/jsx-runtime'; import type { NativeScrollEvent, ScrollView as RNScrollView, ScrollViewProps as RNScrollViewProps, } from 'react-native'; -import type { JSX } from 'react/jsx-runtime'; - import { + FocusManagerContext, type LightningElement, LightningViewElement, type LightningViewElementProps, + type LightningViewElementStyle, + useCombinedRef, } from '@plextv/react-lightning'; - -import type { LightningViewElementStyle } from '../../../react-lightning/src/types'; import { createHandler } from '../hooks/useFocusHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; import { createNativeSyntheticEvent } from '../utils/createNativeSyntheticEvent'; -import { defaultViewStyle, View, type ViewProps } from './View'; +import { View, type ViewProps, defaultViewStyle } from './View'; +import { getEnsureVisibleOffset, usesExplicitAlignment } from './scrollFocus'; + +function isDescendantOf( + ancestor: LightningElement, + node: LightningElement, +): boolean { + let current: LightningElement | null = node.parent; + while (current) { + if (current === ancestor) { + return true; + } + + current = current.parent; + } + + return false; +} export type ScrollViewProps = Omit & Pick & { animated?: boolean; }; -type ScrollViewState = { +interface ScrollViewState { offset: { x: number; y: number }; animated: boolean; -}; +} -type Rect = { +interface Rect { x: number; y: number; w: number; h: number; -}; +} function getAxisOffset( viewportSize: number, containerSize: number, childOffset: number, childSize: number, - snapToAlignment: 'start' | 'center' | 'end', + snapToAlignment: 'center' | 'end' | 'start', ): number { const scrollableSize = containerSize - viewportSize; let offset = 0; @@ -53,11 +79,18 @@ function getAxisOffset( const itemMidPoint = childOffset + childSize / 2; const halfViewportSize = viewportSize / 2; - offset = Math.min(Math.max(itemMidPoint - halfViewportSize, 0), scrollableSize); + offset = Math.min( + Math.max(itemMidPoint - halfViewportSize, 0), + scrollableSize, + ); break; } + case 'end': - offset = Math.max(Math.min(childOffset + childSize - viewportSize, scrollableSize), 0); + offset = Math.max( + Math.min(childOffset + childSize - viewportSize, scrollableSize), + 0, + ); break; } @@ -68,7 +101,7 @@ function getScrollInfo( viewport?: Rect, container?: Rect, child?: Rect | null, - snapToAlignment?: 'start' | 'center' | 'end' | null, + snapToAlignment?: 'center' | 'end' | 'start' | null, horizontal?: boolean | null, ): NativeScrollEvent | null { if (!viewport || !container || !child) { @@ -76,10 +109,22 @@ function getScrollInfo( } const x = horizontal - ? getAxisOffset(viewport.w, container.w, child.x, child.w, snapToAlignment ?? 'start') + ? getAxisOffset( + viewport.w, + container.w, + child.x, + child.w, + snapToAlignment ?? 'start', + ) : container.x; const y = !horizontal - ? getAxisOffset(viewport.h, container.h, child.y, child.h, snapToAlignment ?? 'start') + ? getAxisOffset( + viewport.h, + container.h, + child.y, + child.h, + snapToAlignment ?? 'start', + ) : container.y; return { @@ -91,7 +136,7 @@ function getScrollInfo( }; } -export class ScrollView extends PureComponent { +class ScrollViewBase extends PureComponent { private _containerRef = createRef(); private _viewportRef = createRef(); @@ -137,6 +182,63 @@ export class ScrollView extends PureComponent } }; + // Reveal a focused descendant the way native TV scroll views do: scroll the + // minimum needed to bring it fully into view, leaving already-visible items + // where they are. + public scrollFocusIntoView = (el: LightningElement): void => { + const container = this._containerRef.current; + const viewport = this._viewportRef.current; + + if ( + !(el instanceof LightningViewElement) || + !container || + !viewport || + !isDescendantOf(container, el) + ) { + return; + } + + if (usesExplicitAlignment(this.props.snapToAlignment)) { + this.scrollToElement(el); + return; + } + + const child = el.getBoundingClientRect(container); + const containerBounds = container.getBoundingClientRect(viewport); + const viewportBounds = viewport.getBoundingClientRect(); + const { horizontal } = this.props; + const { x, y } = this.state.offset; + + const nextX = horizontal + ? getEnsureVisibleOffset( + viewportBounds.w, + containerBounds.w, + x, + child.x, + child.w, + child.w / 2, + ) + : x; + const nextY = horizontal + ? y + : getEnsureVisibleOffset( + viewportBounds.h, + containerBounds.h, + y, + child.y, + child.h, + child.h / 2, + ); + + this._doScroll({ + contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, + contentOffset: { x: nextX, y: nextY }, + contentSize: { width: containerBounds.w, height: containerBounds.h }, + layoutMeasurement: { width: viewportBounds.w, height: viewportBounds.h }, + zoomScale: 1, + }); + }; + public scrollToEnd: RNScrollView['scrollToEnd'] = () => { const containerBounds = this._containerRef.current?.getBoundingClientRect( this._viewportRef.current, @@ -172,8 +274,19 @@ export class ScrollView extends PureComponent return this._containerRef.current; } + // RN parity: the host node `measureLayout` measures against. Returns the inner + // scrolled content container so child offsets are content-relative (independent + // of the current scroll offset), matching native ScrollView.getNativeScrollRef. + public getNativeScrollRef(): LightningViewElement< + LightningViewElementStyle, + LightningViewElementProps + > | null { + return this._containerRef.current; + } + public render(): JSX.Element { - const { children, style, contentContainerStyle, horizontal, ...props } = this.props; + const { children, style, contentContainerStyle, horizontal, ...props } = + this.props; const flexDirection = horizontal ? 'row' : 'column'; return ( @@ -185,11 +298,17 @@ export class ScrollView extends PureComponent { overflow: 'hidden', flexDirection, flexGrow: 1, flexShrink: 1 }, ]} {...props} - onFocus={createHandler(this.props.onFocus)} onBlur={createHandler(this.props.onBlur)} + onFocus={createHandler(this.props.onFocus)} > } : undefined } - style={[ - defaultViewStyle, - { display: 'flex', flexDirection }, - contentContainerStyle, - this.state.offset, - ]} > {children} @@ -211,18 +324,25 @@ export class ScrollView extends PureComponent ); } - private _getChildOffset = (child?: LightningElement | null | Rect) => { + private _getChildOffset = (child?: LightningElement | Rect | null) => { const isElement = child instanceof LightningViewElement; - const rect = isElement ? child.getBoundingClientRect(this._containerRef.current) : child; + const rect = isElement + ? child.getBoundingClientRect(this._containerRef.current) + : child; return getScrollInfo( this._viewportRef.current?.getBoundingClientRect(), - this._containerRef.current?.getBoundingClientRect(this._viewportRef.current), + this._containerRef.current?.getBoundingClientRect( + this._viewportRef.current, + ), rect, // If we're getting offset via a positional value, we make sure we don't // use the snapToAlignment to calculate the offset since the offset should // already be taken into account. - isElement ? this.props.snapToAlignment : 'start', + // 'item' (paging) has no alignment math, so it falls back to 'start'. + isElement && usesExplicitAlignment(this.props.snapToAlignment) + ? this.props.snapToAlignment + : 'start', this.props.horizontal, ); }; @@ -260,3 +380,36 @@ export class ScrollView extends PureComponent } } } + +// Wrap the class so it follows focus like a native TV scroll view: when spatial +// nav moves focus to a descendant, scroll it into view. The class stays the ref +// target so imperative callers (scrollTo, scrollToEnd, …) are unaffected. +export const ScrollView: ForwardRefExoticComponent< + RefAttributes & ScrollViewProps +> = forwardRef((props, ref) => { + const focusManager = useContext(FocusManagerContext)?.focusManager; + const instanceRef = useRef(null); + const combinedRef = useCombinedRef(ref, instanceRef); + + useEffect(() => { + if (!focusManager) { + return; + } + + return focusManager.on( + 'focusPathChanged', + (focusPath: LightningElement[]) => { + const leaf = focusPath[focusPath.length - 1]; + if (leaf) { + instanceRef.current?.scrollFocusIntoView(leaf); + } + }, + ); + }, [focusManager]); + + return ; +}); + +ScrollView.displayName = 'ScrollView'; + +export type ScrollView = ScrollViewBase; diff --git a/packages/react-native-lightning/src/exports/Text.tsx b/packages/react-native-lightning/src/exports/Text.tsx index e26ce537..d2bb4f8c 100644 --- a/packages/react-native-lightning/src/exports/Text.tsx +++ b/packages/react-native-lightning/src/exports/Text.tsx @@ -1,10 +1,14 @@ import { type ForwardRefExoticComponent, forwardRef } from 'react'; import type { Text as RNText, TextProps as RNTextProps } from 'react-native'; - -import type { LightningTextElement, LightningTextElementStyle } from '@plextv/react-lightning'; - +import type { + LightningTextElement, + LightningTextElementStyle, +} from '@plextv/react-lightning'; +import { flattenStyles } from '@plextv/react-lightning-plugin-css-transform'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import { useTextLayoutHandler } from '../hooks/useTextLayoutHandler'; +import { applyTextTransform } from '../utils/applyTextTransform'; +import { ellipsizeModeToTextOverflow } from '../utils/ellipsizeModeToTextOverflow'; export type TextProps = RNTextProps; @@ -12,7 +16,7 @@ const defaultTextStyle: Partial = { fontWeight: 'normal', }; -export type Text = RNText & LightningTextElement; +export type Text = LightningTextElement & RNText; export const Text: ForwardRefExoticComponent = forwardRef< LightningTextElement, @@ -40,23 +44,24 @@ export const Text: ForwardRefExoticComponent = forwardRef< const overflowStyle: LightningTextElementStyle = { maxLines: numberOfLines, + textOverflow: ellipsizeModeToTextOverflow(ellipsizeMode), }; - if (ellipsizeMode === 'clip') { - overflowStyle.textOverflow = 'clip'; - } else if (ellipsizeMode === 'tail') { - overflowStyle.textOverflow = 'ellipsis'; - } + const { textTransform } = flattenStyles(style); return ( - {children} + {applyTextTransform(children, textTransform)} ); }, diff --git a/packages/react-native-lightning/src/exports/View.tsx b/packages/react-native-lightning/src/exports/View.tsx index eca4c6c9..19ddde38 100644 --- a/packages/react-native-lightning/src/exports/View.tsx +++ b/packages/react-native-lightning/src/exports/View.tsx @@ -2,22 +2,37 @@ import type { ForwardRefExoticComponent, RefAttributes } from 'react'; import { forwardRef } from 'react'; import type { View as RNView, ViewProps as RNViewProps } from 'react-native'; -import type { - FocusableProps, - LightningElementEventProps, - LightningViewElement, - LightningViewElementProps, +import { + type FocusableProps, + type LightningElementEventProps, + type LightningViewElement, + type LightningViewElementProps, + useCombinedRef, + useFocus, } from '@plextv/react-lightning'; import type { AllStyleProps } from '@plextv/react-lightning-plugin-css-transform'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; +import { isFocusActive, shouldRegisterFocus } from './focusableView'; -type CombinedProps = RNViewProps & - LightningViewElementProps & - RefAttributes & - Omit & - FocusableProps; +type CombinedProps = Omit< + RNViewProps & + LightningViewElementProps & + RefAttributes & + Omit & + FocusableProps, + 'onFocusCapture' +> & { + // RN and Lightning both declare onFocusCapture (RN event vs element), and + // intersecting them yields a signature no handler can satisfy. Accept either. + onFocusCapture?: + | FocusableProps['onFocusCapture'] + | RNViewProps['onFocusCapture']; + // Directional-only focus catcher: reachable by a deliberate move but never + // by restoration or the mount-time default (drawer edge guard). + focusRestorationExcluded?: boolean; +}; export type ViewProps = Omit & { style?: AllStyleProps & RNViewProps['style']; @@ -36,13 +51,38 @@ export const defaultViewStyle = { export type View = RNView & NativeLightningViewElement; +// Native treats any View with `focusable` set as a spatial-nav target, but +// react-lightning only registers useFocus/FocusGroup elements. Register the +// View as a focus leaf so a plain `focusable` View is reachable and fires +// onFocus like it does on tvOS/Android TV. +const FocusableView = forwardRef( + ({ focusRestorationExcluded, ...props }, ref) => { + const { ref: focusRef } = useFocus({ + active: isFocusActive(props), + focusRestorationExcluded, + }); + const combinedRef = useCombinedRef(ref, focusRef); + + return ; + }, +); + +FocusableView.displayName = 'FocusableView'; + export const View: ForwardRefExoticComponent = forwardRef< LightningViewElement, ViewProps >(({ onLayout, ...props }, ref) => { const handleLayout = useLayoutHandler(onLayout); + const viewProps = { ...(props as CombinedProps), onLayout: handleLayout }; + + // Only opt-in focusable Views pay for focus registration; everything else + // renders as a bare node exactly as before. + if (shouldRegisterFocus(viewProps)) { + return ; + } - return ; + return ; }); View.displayName = 'View'; diff --git a/packages/react-native-lightning/src/exports/focusableView.spec.ts b/packages/react-native-lightning/src/exports/focusableView.spec.ts new file mode 100644 index 00000000..75c9ea06 --- /dev/null +++ b/packages/react-native-lightning/src/exports/focusableView.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { isFocusActive, shouldRegisterFocus } from './focusableView'; + +describe('shouldRegisterFocus', () => { + it('registers a View that sets focusable either way', () => { + expect(shouldRegisterFocus({ focusable: true })).toBe(true); + expect(shouldRegisterFocus({ focusable: false })).toBe(true); + }); + + it('registers a directional-only catcher that only excludes restoration', () => { + // The edge guard opts in via focusRestorationExcluded alone, with no + // focusable prop — it must still become a spatial-nav target. + expect(shouldRegisterFocus({ focusRestorationExcluded: true })).toBe(true); + }); + + it('leaves a plain View as a bare node', () => { + expect(shouldRegisterFocus({})).toBe(false); + expect(shouldRegisterFocus({ focusable: null })).toBe(false); + expect(shouldRegisterFocus({ focusRestorationExcluded: false })).toBe( + false, + ); + }); +}); + +describe('isFocusActive', () => { + it('is inactive only when focusable is explicitly false', () => { + expect(isFocusActive({ focusable: false })).toBe(false); + }); + + it('is active for focusable Views and restoration-excluded catchers', () => { + expect(isFocusActive({ focusable: true })).toBe(true); + expect(isFocusActive({ focusRestorationExcluded: true })).toBe(true); + expect(isFocusActive({})).toBe(true); + }); +}); diff --git a/packages/react-native-lightning/src/exports/focusableView.ts b/packages/react-native-lightning/src/exports/focusableView.ts new file mode 100644 index 00000000..1d96ea3e --- /dev/null +++ b/packages/react-native-lightning/src/exports/focusableView.ts @@ -0,0 +1,15 @@ +type FocusRegistrationProps = { + focusable?: boolean | null; + focusRestorationExcluded?: boolean; +}; + +// Native promotes any View with `focusable` set (true or false) to a spatial-nav +// target; a directional-only catcher opts in via focusRestorationExcluded alone. +export function shouldRegisterFocus(props: FocusRegistrationProps): boolean { + return props.focusable != null || Boolean(props.focusRestorationExcluded); +} + +// A registered View is an active focus target unless focusable is explicitly false. +export function isFocusActive(props: FocusRegistrationProps): boolean { + return props.focusable !== false; +} diff --git a/packages/react-native-lightning/src/exports/scrollFocus.spec.ts b/packages/react-native-lightning/src/exports/scrollFocus.spec.ts new file mode 100644 index 00000000..d4bf4148 --- /dev/null +++ b/packages/react-native-lightning/src/exports/scrollFocus.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { getEnsureVisibleOffset, usesExplicitAlignment } from './scrollFocus'; + +describe('usesExplicitAlignment', () => { + it('honors deliberate center/end placements', () => { + expect(usesExplicitAlignment('center')).toBe(true); + expect(usesExplicitAlignment('end')).toBe(true); + }); + + it('reveals (does not snap) for item, start, and unset', () => { + // 'item' (paging) isn't implemented by the alignment math and 'start' isn't + // a real focus target, so a focused row must ensure-visible instead. + expect(usesExplicitAlignment('item')).toBe(false); + expect(usesExplicitAlignment('start')).toBe(false); + expect(usesExplicitAlignment(undefined)).toBe(false); + expect(usesExplicitAlignment(null)).toBe(false); + }); +}); + +// Offsets follow the ScrollView convention: the content container is translated +// by a non-positive offset, so scrolling down toward the end grows more negative. +describe('getEnsureVisibleOffset', () => { + const viewport = 100; + const container = 400; + + it('leaves the offset unchanged when the child is already fully visible', () => { + // Child spans [10, 40] on screen while resting at the top. + expect(getEnsureVisibleOffset(viewport, container, 0, 10, 30)).toBe(0); + }); + + it('scrolls just enough to reveal a child below the fold', () => { + // Child at [150, 190] within the container; bottom must land on the + // viewport edge (100), so offset = 100 - 190 = -90. + expect(getEnsureVisibleOffset(viewport, container, 0, 150, 40)).toBe(-90); + }); + + it('aligns a child above the current window to the top', () => { + // Already scrolled to -200; child at 50 is above the window. + expect(getEnsureVisibleOffset(viewport, container, -200, 50, 30)).toBe(-50); + }); + + it('never scrolls past the end of the content', () => { + // maxScroll = viewport - container = -300; a child at the very end can't + // pull the offset past that clamp. + expect(getEnsureVisibleOffset(viewport, container, 0, 380, 20)).toBe(-300); + }); + + it('never scrolls before the start of the content', () => { + expect(getEnsureVisibleOffset(viewport, container, -50, 0, 20)).toBe(0); + }); + + it('does not scroll when the content fits inside the viewport', () => { + expect(getEnsureVisibleOffset(200, 120, 0, 80, 30)).toBe(0); + }); + + it('leaves a margin below a child revealed at the bottom', () => { + // Child at [150, 190]; with a 20px margin its bottom lands at 100 - 20 = 80, + // so offset = 80 - 190 = -110. + expect(getEnsureVisibleOffset(viewport, container, 0, 150, 40, 20)).toBe(-110); + }); + + it('leaves a margin above a child revealed at the top', () => { + // Scrolled to -200; child at 50 with a 20px margin should sit at y=20, + // so offset = 20 - 50 = -30. + expect(getEnsureVisibleOffset(viewport, container, -200, 50, 30, 20)).toBe(-30); + }); + + it('collapses the margin at the end of the content via the clamp', () => { + // maxScroll = -300; the margin can't push past it. + expect(getEnsureVisibleOffset(viewport, container, 0, 380, 20, 20)).toBe(-300); + }); +}); diff --git a/packages/react-native-lightning/src/exports/scrollFocus.ts b/packages/react-native-lightning/src/exports/scrollFocus.ts new file mode 100644 index 00000000..42c94eee --- /dev/null +++ b/packages/react-native-lightning/src/exports/scrollFocus.ts @@ -0,0 +1,45 @@ +/** + * Whether a focus scroll should honor snapToAlignment as a deliberate placement + * rather than just revealing the item. Only 'center'/'end' qualify: 'start' and + * 'item' (paging) aren't real focus targets — and 'item' isn't even implemented + * by the alignment math — so they should fall through to ensure-visible. + */ +export function usesExplicitAlignment( + snapToAlignment: string | null | undefined, +): snapToAlignment is 'center' | 'end' { + return snapToAlignment === 'center' || snapToAlignment === 'end'; +} + +/** + * Minimal ("nearest") scroll offset that brings a child fully into view, the + * native TV auto-scroll behavior a plain ScrollView lacks. Offsets are + * non-positive: the content container is translated by the returned value, so + * scrolling toward the end grows more negative. A child already in view keeps + * the current offset instead of snapping to an edge. `margin` keeps that much + * breathing room past the child before it counts as revealed, so the focused + * item doesn't sit flush against the viewport edge (naturally collapses at the + * list ends via the clamp). + */ +export function getEnsureVisibleOffset( + viewportSize: number, + containerSize: number, + currentOffset: number, + childOffset: number, + childSize: number, + margin = 0, +): number { + const minOffset = Math.min(0, viewportSize - containerSize); + + const childTop = childOffset + currentOffset; + const childBottom = childOffset + childSize + currentOffset; + + let offset = currentOffset; + if (childTop < margin) { + offset = margin - childOffset; + } else if (childBottom > viewportSize - margin) { + offset = viewportSize - margin - (childOffset + childSize); + } + + // `|| 0` also normalizes -0 to 0. + return Math.max(minOffset, Math.min(0, offset)) || 0; +} diff --git a/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts b/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts new file mode 100644 index 00000000..242ffc50 --- /dev/null +++ b/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { applyTextTransform } from './applyTextTransform'; + +describe('applyTextTransform', () => { + it('uppercases string children', () => { + expect(applyTextTransform('abc', 'uppercase')).toBe('ABC'); + }); + + it('lowercases string children', () => { + expect(applyTextTransform('ABC', 'lowercase')).toBe('abc'); + }); + + it('capitalizes the first letter of each word', () => { + expect(applyTextTransform('hello world', 'capitalize')).toBe('Hello World'); + }); + + it('leaves children unchanged for none/undefined', () => { + expect(applyTextTransform('abc', 'none')).toBe('abc'); + expect(applyTextTransform('abc', undefined)).toBe('abc'); + }); + + it('transforms each string in an array of children', () => { + expect(applyTextTransform(['ab', 'cd'], 'uppercase')).toEqual(['AB', 'CD']); + }); + + it('leaves non-string children (numbers, elements) untouched', () => { + expect(applyTextTransform(5, 'uppercase')).toBe(5); + }); +}); diff --git a/packages/react-native-lightning/src/utils/applyTextTransform.ts b/packages/react-native-lightning/src/utils/applyTextTransform.ts new file mode 100644 index 00000000..b6c5cdf0 --- /dev/null +++ b/packages/react-native-lightning/src/utils/applyTextTransform.ts @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import type { TextStyle } from 'react-native'; + +function transformString( + value: string, + transform: TextStyle['textTransform'], +): string { + switch (transform) { + case 'uppercase': + return value.toUpperCase(); + case 'lowercase': + return value.toLowerCase(); + case 'capitalize': + return value.replace( + /(^|\s)(\S)/g, + (_, lead, char) => lead + char.toUpperCase(), + ); + default: + return value; + } +} + +// Only string (or array-of-string) children are transformed; nested element +// children keep their own textTransform, matching the parity baseline. +export function applyTextTransform( + children: ReactNode, + transform: TextStyle['textTransform'], +): ReactNode { + if (transform == null || transform === 'none') { + return children; + } + + if (typeof children === 'string') { + return transformString(children, transform); + } + + if (Array.isArray(children)) { + return children.map((child) => + typeof child === 'string' ? transformString(child, transform) : child, + ); + } + + return children; +} diff --git a/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts new file mode 100644 index 00000000..e0b542ee --- /dev/null +++ b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { ellipsizeModeToTextOverflow } from './ellipsizeModeToTextOverflow'; + +describe('ellipsizeModeToTextOverflow', () => { + it("maps 'clip' to clip", () => { + expect(ellipsizeModeToTextOverflow('clip')).toBe('clip'); + }); + + it("maps 'tail' to ellipsis", () => { + expect(ellipsizeModeToTextOverflow('tail')).toBe('ellipsis'); + }); + + it("falls back to ellipsis for 'head' and 'middle'", () => { + expect(ellipsizeModeToTextOverflow('head')).toBe('ellipsis'); + expect(ellipsizeModeToTextOverflow('middle')).toBe('ellipsis'); + }); + + it('returns undefined when no mode is given', () => { + expect(ellipsizeModeToTextOverflow(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts new file mode 100644 index 00000000..b4c6bfbb --- /dev/null +++ b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts @@ -0,0 +1,17 @@ +import type { TextProps } from 'react-native'; +import type { LightningTextElementStyle } from '@plextv/react-lightning'; + +export function ellipsizeModeToTextOverflow( + mode: TextProps['ellipsizeMode'], +): LightningTextElementStyle['textOverflow'] { + if (mode === 'clip') { + return 'clip'; + } + + // Renderer has no head/middle truncation, so they fall back to tail ellipsis. + if (mode === 'tail' || mode === 'head' || mode === 'middle') { + return 'ellipsis'; + } + + return undefined; +}