Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/outline-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@plextv/react-lightning': minor
'@plextv/react-lightning-plugin-css-transform': minor
'@plextv/react-lightning-plugin-reanimated': patch
---

Support css outlines. `outlineWidth` / `outlineColor` / `outlineOffset` now paint a ring outside the node (the border shader can draw outside its bounds with a gap), so a focus ring no longer needs an extra absolutely-positioned view. A border and an outline share the one shader an element gets, so the border still wins and a dev warning says so. Also: a partial style push (reanimated, or an imperative `style.x =`) now resolves its shader against the merged style, so pushing only a `borderColor` or `outlineColor` keeps the width it already had, and an `outlineColor` transition animates the shader.
5 changes: 5 additions & 0 deletions .changeset/reanimated-css-pseudo-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-reanimated': minor
---

Support reanimated's CSS transitions and pseudo selectors. A `CSSStyle` on an animated component now works: `transitionProperty` / `transitionDuration` / `transitionTimingFunction` / `transitionDelay` (and the `transition` shorthand) become a Lightning transition on the node, and per-property values keyed by `default` / `:focus` / `:focus-within` swap on focus without a re-render. `:hover`, `:active` and `:active-deepest` need pointer or press state that Lightning doesn't have, so they're ignored with a dev warning, as are CSS animations (`animationName` and friends) for now.
8 changes: 8 additions & 0 deletions apps/react-native-lightning-example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AnimationTest } from './pages/AnimationTest';
import { ComponentTest } from './pages/ComponentTest';
import { LayoutTest } from './pages/LayoutTest';
import { LibraryTest } from './pages/LibraryTest';
import { PseudoSelectorTest } from './pages/PseudoSelectorTest';
import { SimpleTest } from './pages/SimpleTest';
import { VirtualizedListTest } from './pages/VirtualizedListTest';

Expand Down Expand Up @@ -56,6 +57,7 @@ const screens = {
Library: 'library',
Simple: 'simple',
Components: 'components',
PseudoSelectors: 'pseudoSelectors',
NestedLayouts: 'nestedLayouts',
VirtualizedList: 'virtualizedList',
};
Expand Down Expand Up @@ -119,6 +121,11 @@ const MainApp = () => {
color={'rgba(55, 55, 22, 1)'}
onPress={() => nav.navigate('VirtualizedList')}
/>
<Button
title="Pseudo Selectors"
color={'rgba(55, 55, 22, 1)'}
onPress={() => nav.navigate('PseudoSelectors')}
/>
</Column>

<Column focusable style={{ w: 1670, h: 1080, color: 0x000000ff, clipping: true }}>
Expand All @@ -135,6 +142,7 @@ const MainApp = () => {
<CustomStack.Screen name="Simple" component={SimpleTest} />
<CustomStack.Screen name="Components" component={ComponentTest} />
<CustomStack.Screen name="VirtualizedList" component={VirtualizedListTest} />
<CustomStack.Screen name="PseudoSelectors" component={PseudoSelectorTest} />
</CustomStack.Navigator>
</Column>
</Row>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { FC } from 'react';
import { Text, type ViewStyle } from 'react-native';
import Animated from 'react-native-reanimated';

import { Row } from '@plextv/react-lightning-components';

// This repo pins reanimated 4.3, whose CSSStyle type doesn't know pseudo
// selectors yet, hence the casts below. The shape is what 4.6 ships.
const asStyle = (style: object) => style as unknown as ViewStyle;

// How far the labels drop so they clear the scaled artwork.
const LABEL_SHIFT = 24;

// The column shifts on :focus-within so the labels move with it, and the card
// cancels the shift for itself: only the labels end up moving.
const tileStyle = {
transform: {
default: [{ translateY: 0 }],
':focus-within': [{ translateY: LABEL_SHIFT }],
},
transitionProperty: 'transform',
transitionDuration: 200,
transitionTimingFunction: 'ease-out',
};

const cardStyle = {
transform: {
default: [{ translateY: 0 }, { scale: 1 }],
':focus': [{ translateY: -LABEL_SHIFT }, { scale: 1.08 }],
},
backgroundColor: '#26282c',
// The focus ring is an outline on the card itself, so it needs no extra view.
// Fading from a transparent white keeps it from going through black.
outlineStyle: 'solid' as const,
outlineWidth: 4,
outlineOffset: 2,
outlineColor: { default: 'rgba(255, 255, 255, 0)', ':focus': '#e5a00d' },
transitionProperty: ['transform', 'outlineColor'],
transitionDuration: [200, 120],
transitionTimingFunction: ['cubic-bezier(0.22, 1, 0.36, 1)', 'ease-out'],
};

const Tile = ({ title }: { title: string }) => (
<Animated.View style={[{ width: 220, gap: 16 }, asStyle(tileStyle)]}>
<Animated.View
focusable
style={[{ width: 220, height: 320, borderRadius: 8 }, asStyle(cardStyle)]}
/>
<Text style={{ fontSize: 24 }}>{title}</Text>
</Animated.View>
);

const PseudoSelectorTest: FC = () => (
<Row focusable style={{ gap: 40, padding: 60 }}>
{['One', 'Two', 'Three', 'Four'].map((title) => (
<Tile key={title} title={title} />
))}
</Row>
);

export { PseudoSelectorTest };
export default PseudoSelectorTest;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// reanimated imports react-native-is-edge-to-edge, whose "module" entry points
// at a CJS file that vite serves without named exports. None of it means
// anything on a TV, so the app aliases it to this.
export const isEdgeToEdgeFromLibrary = () => false;
export const isEdgeToEdgeFromProperty = () => false;
export const isEdgeToEdge = () => false;
export const controlEdgeToEdgeValues = () => {};
9 changes: 9 additions & 0 deletions apps/react-native-lightning-example/vite.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';

import babel from '@rolldown/plugin-babel';
import legacy from '@vitejs/plugin-legacy';
import { reactCompilerPreset } from '@vitejs/plugin-react';
Expand Down Expand Up @@ -32,6 +34,13 @@ const config = defineConfig((env) => ({
build: {
minify: false,
},
resolve: {
alias: {
'react-native-is-edge-to-edge': fileURLToPath(
new URL('./src/polyfills/isEdgeToEdge.ts', import.meta.url),
),
},
},
server: {
host: true,
port: 3333,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

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

// 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.
Expand All @@ -10,15 +10,13 @@ const convert = (style: AllStyleProps): Record<string, unknown> =>

describe('convertCSSStyleToLightning border radius', () => {
it('passes a uniform borderRadius through unchanged', () => {
expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe(
8,
);
expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe(8);
});

it('expands a single corner longhand into a [tl, tr, br, bl] array', () => {
expect(
convertCSSStyleToLightning({ borderTopRightRadius: 8 })?.borderRadius,
).toEqual([0, 8, 0, 0]);
expect(convertCSSStyleToLightning({ borderTopRightRadius: 8 })?.borderRadius).toEqual([
0, 8, 0, 0,
]);
});

it('maps logical start/end corners onto physical corners (LTR)', () => {
Expand All @@ -32,8 +30,7 @@ describe('convertCSSStyleToLightning border radius', () => {

it('uses the uniform borderRadius as the base for unspecified corners', () => {
expect(
convertCSSStyleToLightning({ borderRadius: 4, borderTopEndRadius: 8 })
?.borderRadius,
convertCSSStyleToLightning({ borderRadius: 4, borderTopEndRadius: 8 })?.borderRadius,
).toEqual([4, 8, 4, 4]);
});

Expand Down Expand Up @@ -108,3 +105,39 @@ describe('convertCSSStyleToLightning text shadows', () => {
expect(warn).toHaveBeenCalled();
});
});

describe('convertCSSStyleToLightning outline', () => {
it('converts the outline width, color and offset', () => {
expect(
convertCSSStyleToLightning({
outlineWidth: 4,
outlineColor: 'rgba(255, 0, 0, 1)',
outlineOffset: 2,
}),
).toMatchObject({
outlineWidth: 4,
outlineColor: 0xff0000ff,
outlineOffset: 2,
});
});

it('leaves the width alone on a color-only update, so a focus ring can fade', () => {
const result = convertCSSStyleToLightning({ outlineColor: 'rgba(255, 0, 0, 0)' }) as Record<
string,
unknown
>;

expect(result.outlineColor).toBe(0xff000000);
expect('outlineWidth' in result).toBe(false);
});

it('drops outlineStyle, since only a solid ring can be drawn', () => {
const result = convertCSSStyleToLightning({
outlineStyle: 'dotted',
outlineWidth: 2,
}) as Record<string, unknown>;

expect(result.outlineStyle).toBeUndefined();
expect(result.outlineWidth).toBe(2);
});
});
52 changes: 28 additions & 24 deletions packages/plugin-css-transform/src/convertCSSStyleToLightning.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type {
LightningElementStyle,
LightningTextElementStyle,
} from '@plextv/react-lightning';
import type { LightningElementStyle, LightningTextElementStyle } from '@plextv/react-lightning';

import type { AllStyleProps } from './types/ReactStyle';
import { flattenStyles } from './utils/flattenStyles';
import { htmlColorToLightningColor } from './utils/htmlColorToLightningColor';
Expand Down Expand Up @@ -44,19 +42,12 @@ function resolveBorderRadius(

const topLeft = num(borderTopLeftRadius) ?? num(borderTopStartRadius);
const topRight = num(borderTopRightRadius) ?? num(borderTopEndRadius);
const bottomRight =
num(borderBottomRightRadius) ?? num(borderBottomEndRadius);
const bottomLeft =
num(borderBottomLeftRadius) ?? num(borderBottomStartRadius);
const bottomRight = num(borderBottomRightRadius) ?? num(borderBottomEndRadius);
const bottomLeft = num(borderBottomLeftRadius) ?? num(borderBottomStartRadius);

const base = num(borderRadius);

if (
topLeft == null &&
topRight == null &&
bottomRight == null &&
bottomLeft == null
) {
if (topLeft == null && topRight == null && bottomRight == null && bottomLeft == null) {
return base;
}

Expand All @@ -83,6 +74,11 @@ export function convertCSSStyleToLightning(
border,
borderWidth,
borderColor,
outlineWidth,
outlineColor,
outlineOffset,
// Dropped: the shader only draws a solid ring, dashed/dotted have no equivalent.
outlineStyle: _outlineStyle,
shadowColor,
textShadowColor,
textShadowOffset,
Expand Down Expand Up @@ -187,6 +183,21 @@ export function convertCSSStyleToLightning(
}
}

// The element draws the outline with the border shader, outside the node.
// Each prop is copied only when it's there: a color-only update (a focus ring
// fading in) must not reset the width the element already has.
if (typeof outlineWidth === 'number') {
finalStyle.outlineWidth = outlineWidth;
}

if (outlineColor != null) {
finalStyle.outlineColor = htmlColorToLightningColor(outlineColor) ?? 0;
}

if (typeof outlineOffset === 'number') {
finalStyle.outlineOffset = outlineOffset;
}

if (otherStyles.display === 'none') {
finalStyle.alpha = 0;
} else if (opacity != null && typeof opacity === 'number') {
Expand All @@ -206,9 +217,7 @@ export function convertCSSStyleToLightning(

if (otherStyles.top != null) {
finalStyle.y =
typeof otherStyles.top === 'number'
? otherStyles.top
: Number.parseInt(otherStyles.top, 10);
typeof otherStyles.top === 'number' ? otherStyles.top : Number.parseInt(otherStyles.top, 10);
}

// The renderer resolves the full 100-900 scale (and the keyword weights) to
Expand Down Expand Up @@ -241,8 +250,7 @@ export function convertCSSStyleToLightning(
}

if (transform != null) {
const { scaleX, scaleY, rotation, ...translateTransforms } =
parseTransform(transform);
const { scaleX, scaleY, rotation, ...translateTransforms } = parseTransform(transform);

if (scaleX != null) {
finalStyle.scaleX = scaleX;
Expand All @@ -260,11 +268,7 @@ export function convertCSSStyleToLightning(
}

// Disabled for now as some components set overflow to hidden while not having their size correctly calculated
if (
overflow === 'hidden' ||
overflowX === 'hidden' ||
overflowY === 'hidden'
) {
if (overflow === 'hidden' || overflowX === 'hidden' || overflowY === 'hidden') {
finalStyle.clipping = true;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-css-transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const CSS_HANDLED_STYLE_PROPS: ReadonlySet<string> = new Set([
'border',
'borderWidth',
'borderColor',
'outlineWidth',
'outlineColor',
'outlineOffset',
'outlineStyle',
'shadowColor',
'opacity',
'overflow',
Expand Down
Loading
Loading