Skip to content
Open
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
55 changes: 55 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Publish Package

on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:

# id-token: write is required for npm provenance (OIDC)
permissions:
contents: read
id-token: write

jobs:
publish:
runs-on: ubuntu-latest
name: Publish to npm
steps:
- name: Checkout 🛎️
uses: actions/checkout@v5
- name: Setup pnpm 🔧
uses: pnpm/action-setup@v4
- name: Setup Node.js 🔧
uses: actions/setup-node@v6
with:
node-version: "lts/*"
check-latest: true
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Verify tag matches package.json version 🔍
run: |
TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")"
if [ "$TAG" != "$PKG" ]; then
echo "::error::Tag '$TAG' does not match package.json version '$PKG'"
exit 1
fi
echo "Publishing version $PKG"
- name: Install dependencies 🪄
run: pnpm install --frozen-lockfile
- name: Lint 🔍
run: pnpm run lint
- name: TypeScript 🔍
run: pnpm run check
- name: Vitest 🔍
run: pnpm run spec
- name: Build 💎
run: pnpm run build
# Published with npm, not pnpm: pnpm publish accepts --provenance but
# silently ignores it, yielding a publish with no attestation.
- name: Publish to npm 🚀
run: npm publish --access public --provenance --ignore-scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
12 changes: 12 additions & 0 deletions docs/src/content/docs/reference/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ Control layout with flexbox, positioning, and display utilities.
<View className="overflow-scroll" /> // overflow: 'scroll'
```

## Opacity

Named opacity utilities use the existing five-point percentage scale. Use an arbitrary raw value or percentage for an exact value:

```tsx
<View className="opacity-65" /> // opacity: 0.65
<View className="opacity-[.67]" /> // opacity: 0.67
<View className="opacity-[67%]" /> // opacity: 0.67
```

Arbitrary values must resolve to React Native's supported range from `0` through `1`.

## Display

```tsx
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Apply 2D and 3D transformations to views with React Native's transform API. All
```tsx
<View className="scale-0" /> // scale: 0
<View className="scale-50" /> // scale: 0.5
<View className="scale-80" /> // scale: 0.8
<View className="scale-100" /> // scale: 1
<View className="scale-110" /> // scale: 1.1
<View className="scale-150" /> // scale: 1.5
Expand All @@ -22,12 +23,14 @@ Apply 2D and 3D transformations to views with React Native's transform API. All
```tsx
<View className="scale-x-110" /> // scaleX: 1.1
<View className="scale-y-90" /> // scaleY: 0.9
<View className="-scale-x-80" /> // scaleX: -0.8
```

### Arbitrary Values

```tsx
<View className="scale-[1.23]" /> // scale: 1.23
<View className="scale-[80%]" /> // scale: 0.8
<View className="scale-x-[0.5]" /> // scaleX: 0.5
<View className="scale-y-[2.5]" /> // scaleY: 2.5
```
Expand Down
14 changes: 14 additions & 0 deletions src/babel/plugin/visitors/className.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ describe("className visitor - basic transformation", () => {
expect(output).toContain("style:");
});

it("should compile arbitrary opacity values", () => {
const input = `
import { View } from 'react-native';
export function Component() {
return <View className="opacity-[.67]" />;
}
`;

const output = transform(input, undefined, true);

expect(output).not.toContain("className");
expect(output).toContain("opacity: 0.67");
});

it("should work with both tw and className in same file", () => {
const input = `
import { tw } from '@mgcrea/react-native-tailwind';
Expand Down
15 changes: 15 additions & 0 deletions src/parser/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,21 @@ describe("parseLayout - opacity utilities", () => {
expect(parseLayout("opacity-90")).toEqual({ opacity: 0.9 });
expect(parseLayout("opacity-95")).toEqual({ opacity: 0.95 });
});

it("should parse arbitrary raw and percentage opacity values", () => {
expect(parseLayout("opacity-[.67]")).toEqual({ opacity: 0.67 });
expect(parseLayout("opacity-[0.125]")).toEqual({ opacity: 0.125 });
expect(parseLayout("opacity-[1]")).toEqual({ opacity: 1 });
expect(parseLayout("opacity-[67%]")).toEqual({ opacity: 0.67 });
expect(parseLayout("opacity-[12.5%]")).toEqual({ opacity: 0.125 });
});

it("should reject arbitrary opacity outside the native range", () => {
expect(parseLayout("opacity-[1.1]")).toBeNull();
expect(parseLayout("opacity-[101%]")).toBeNull();
expect(parseLayout("opacity-[-.1]")).toBeNull();
expect(parseLayout("opacity-[invalid]")).toBeNull();
});
});

describe("parseLayout - edge cases", () => {
Expand Down
34 changes: 34 additions & 0 deletions src/parser/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ function parseArbitraryGrowShrink(value: string): number | null {
return null;
}

/**
* Parse an arbitrary opacity value: [.67], [0.67], [1], [67%].
* React Native expects a normalized number from 0 to 1.
*/
function parseArbitraryOpacity(value: string): number | null {
const match = value.match(/^\[(\d+(?:\.\d*)?|\.\d+)(%)?\]$/);
if (match) {
const parsed = Number.parseFloat(match[1]);
const opacity = match[2] === "%" ? parsed / 100 : parsed;
if (opacity >= 0 && opacity <= 1) {
return opacity;
}
}

if (value.startsWith("[") && value.endsWith("]")) {
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Invalid arbitrary opacity value: ${value}. ` +
"Use a raw value from 0 to 1 or a percentage from 0% to 100% (e.g., [.67], [0.67], [67%]).",
);
}
}

return null;
}

// Display utilities
const DISPLAY_MAP: Record<string, StyleObject> = {
flex: { display: "flex" },
Expand Down Expand Up @@ -244,6 +271,13 @@ export function parseLayout(cls: string, customSpacing?: Record<string, number>)
// Merge custom spacing with defaults for inset utilities
const insetMap = customSpacing ? { ...INSET_SCALE, ...customSpacing } : INSET_SCALE;

if (cls.startsWith("opacity-")) {
const arbitraryOpacity = parseArbitraryOpacity(cls.substring(8));
if (arbitraryOpacity !== null) {
return { opacity: arbitraryOpacity };
}
}

// Z-index: z-0, z-10, z-20, z-[999], etc.
if (cls.startsWith("z-")) {
const zKey = cls.substring(2);
Expand Down
17 changes: 16 additions & 1 deletion src/parser/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("parseTransform - scale utilities", () => {
expect(parseTransform("scale-0")).toEqual({ transform: [{ scale: 0 }] });
expect(parseTransform("scale-50")).toEqual({ transform: [{ scale: 0.5 }] });
expect(parseTransform("scale-75")).toEqual({ transform: [{ scale: 0.75 }] });
expect(parseTransform("scale-80")).toEqual({ transform: [{ scale: 0.8 }] });
expect(parseTransform("scale-90")).toEqual({ transform: [{ scale: 0.9 }] });
expect(parseTransform("scale-95")).toEqual({ transform: [{ scale: 0.95 }] });
expect(parseTransform("scale-100")).toEqual({ transform: [{ scale: 1 }] });
Expand All @@ -40,38 +41,52 @@ describe("parseTransform - scale utilities", () => {
expect(parseTransform("scale-125")).toEqual({ transform: [{ scale: 1.25 }] });
expect(parseTransform("scale-150")).toEqual({ transform: [{ scale: 1.5 }] });
expect(parseTransform("scale-200")).toEqual({ transform: [{ scale: 2 }] });
expect(parseTransform("scale-102.5")).toEqual({ transform: [{ scale: 1.025 }] });
expect(parseTransform("scale-999")).toEqual({ transform: [{ scale: 9.99 }] });
});

it("should parse scaleX values", () => {
expect(parseTransform("scale-x-0")).toEqual({ transform: [{ scaleX: 0 }] });
expect(parseTransform("scale-x-50")).toEqual({ transform: [{ scaleX: 0.5 }] });
expect(parseTransform("scale-x-80")).toEqual({ transform: [{ scaleX: 0.8 }] });
expect(parseTransform("scale-x-100")).toEqual({ transform: [{ scaleX: 1 }] });
expect(parseTransform("scale-x-150")).toEqual({ transform: [{ scaleX: 1.5 }] });
});

it("should parse scaleY values", () => {
expect(parseTransform("scale-y-0")).toEqual({ transform: [{ scaleY: 0 }] });
expect(parseTransform("scale-y-75")).toEqual({ transform: [{ scaleY: 0.75 }] });
expect(parseTransform("scale-y-80")).toEqual({ transform: [{ scaleY: 0.8 }] });
expect(parseTransform("scale-y-100")).toEqual({ transform: [{ scaleY: 1 }] });
expect(parseTransform("scale-y-125")).toEqual({ transform: [{ scaleY: 1.25 }] });
});

it("should parse arbitrary scale values", () => {
expect(parseTransform("scale-[1.23]")).toEqual({ transform: [{ scale: 1.23 }] });
expect(parseTransform("scale-[80]")).toEqual({ transform: [{ scale: 80 }] });
expect(parseTransform("scale-[0.5]")).toEqual({ transform: [{ scale: 0.5 }] });
expect(parseTransform("scale-[2.5]")).toEqual({ transform: [{ scale: 2.5 }] });
expect(parseTransform("scale-x-[1.5]")).toEqual({ transform: [{ scaleX: 1.5 }] });
expect(parseTransform("scale-y-[0.8]")).toEqual({ transform: [{ scaleY: 0.8 }] });
expect(parseTransform("scale-[80%]")).toEqual({ transform: [{ scale: 0.8 }] });
expect(parseTransform("scale-x-[125%]")).toEqual({ transform: [{ scaleX: 1.25 }] });
expect(parseTransform("scale-y-[80%]")).toEqual({ transform: [{ scaleY: 0.8 }] });
});

it("should parse negative arbitrary scale values", () => {
expect(parseTransform("scale-[-1]")).toEqual({ transform: [{ scale: -1 }] });
expect(parseTransform("scale-x-[-0.5]")).toEqual({ transform: [{ scaleX: -0.5 }] });
});

it("should parse negative numeric scale values", () => {
expect(parseTransform("-scale-80")).toEqual({ transform: [{ scale: -0.8 }] });
expect(parseTransform("-scale-x-125")).toEqual({ transform: [{ scaleX: -1.25 }] });
expect(parseTransform("-scale-y-50")).toEqual({ transform: [{ scaleY: -0.5 }] });
});

it("should return null for invalid scale values", () => {
expect(parseTransform("scale-999")).toBeNull();
expect(parseTransform("scale-invalid")).toBeNull();
expect(parseTransform("scale-10x")).toBeNull();
});
});

Expand Down
92 changes: 49 additions & 43 deletions src/parser/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,22 @@ export const PERSPECTIVE_SCALE: Record<string, number> = {
};

/**
* Parse arbitrary scale value: [1.23], [0.5]
* Parse arbitrary scale value: [1.23], [0.5], [80%]
* Returns number for valid scale, null otherwise
*/
function parseArbitraryScale(value: string): number | null {
const scaleMatch = value.match(/^\[(-?\d+(?:\.\d+)?)\]$/);
const scaleMatch = value.match(/^\[(-?\d+(?:\.\d+)?)(%)?\]$/);
if (scaleMatch) {
return parseFloat(scaleMatch[1]);
const parsedValue = Number.parseFloat(scaleMatch[1]);
return scaleMatch[2] === "%" ? parsedValue / 100 : parsedValue;
}

// Unsupported format
if (value.startsWith("[") && value.endsWith("]")) {
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Invalid arbitrary scale value: ${value}. Only numbers are supported (e.g., [1.5], [0.75]).`,
`[react-native-tailwind] Invalid arbitrary scale value: ${value}. Only numbers and percentages are supported (e.g., [1.5], [0.75], [80%]).`,
);
}
return null;
Expand All @@ -82,6 +83,29 @@ function parseArbitraryScale(value: string): number | null {
return null;
}

/**
* Parse a Tailwind scale value.
* Named numeric utilities are percentages (scale-80 -> 0.8), while bracketed
* arbitrary numbers are raw React Native multipliers (scale-[1.25] -> 1.25).
*/
function parseScaleValue(value: string): number | null {
const arbitraryScale = parseArbitraryScale(value);
if (arbitraryScale !== null) {
return arbitraryScale;
}

const presetScale = SCALE_MAP[value];
if (presetScale !== undefined) {
return presetScale;
}

if (/^\d+(?:\.\d+)?$/.test(value)) {
return Number.parseFloat(value) / 100;
}

return null;
}

/**
* Parse arbitrary rotation value: [37deg], [-15deg]
* Returns string for valid rotation, null otherwise
Expand Down Expand Up @@ -181,51 +205,33 @@ export function parseTransform(cls: string, customSpacing?: Record<string, numbe
return null;
}

// Scale: scale-{value}
if (cls.startsWith("scale-")) {
const scaleKey = cls.substring(6);

// Arbitrary values: scale-[1.23]
const arbitraryScale = parseArbitraryScale(scaleKey);
if (arbitraryScale !== null) {
return { transform: [{ scale: arbitraryScale }] };
}

const scaleValue = SCALE_MAP[scaleKey];
if (scaleValue !== undefined) {
return { transform: [{ scale: scaleValue }] };
// Scale: scale-{value}, -scale-{value}
const scaleMatch = cls.match(/^(-?)scale-(.+)$/);
if (scaleMatch) {
const [, negativePrefix, scaleKey] = scaleMatch;
const scaleValue = parseScaleValue(scaleKey);
if (scaleValue !== null) {
return { transform: [{ scale: negativePrefix === "-" ? -scaleValue : scaleValue }] };
}
}

// Scale X: scale-x-{value}
if (cls.startsWith("scale-x-")) {
const scaleKey = cls.substring(8);

// Arbitrary values: scale-x-[1.5]
const arbitraryScale = parseArbitraryScale(scaleKey);
if (arbitraryScale !== null) {
return { transform: [{ scaleX: arbitraryScale }] };
}

const scaleValue = SCALE_MAP[scaleKey];
if (scaleValue !== undefined) {
return { transform: [{ scaleX: scaleValue }] };
// Scale X: scale-x-{value}, -scale-x-{value}
const scaleXMatch = cls.match(/^(-?)scale-x-(.+)$/);
if (scaleXMatch) {
const [, negativePrefix, scaleKey] = scaleXMatch;
const scaleValue = parseScaleValue(scaleKey);
if (scaleValue !== null) {
return { transform: [{ scaleX: negativePrefix === "-" ? -scaleValue : scaleValue }] };
}
}

// Scale Y: scale-y-{value}
if (cls.startsWith("scale-y-")) {
const scaleKey = cls.substring(8);

// Arbitrary values: scale-y-[2.5]
const arbitraryScale = parseArbitraryScale(scaleKey);
if (arbitraryScale !== null) {
return { transform: [{ scaleY: arbitraryScale }] };
}

const scaleValue = SCALE_MAP[scaleKey];
if (scaleValue !== undefined) {
return { transform: [{ scaleY: scaleValue }] };
// Scale Y: scale-y-{value}, -scale-y-{value}
const scaleYMatch = cls.match(/^(-?)scale-y-(.+)$/);
if (scaleYMatch) {
const [, negativePrefix, scaleKey] = scaleYMatch;
const scaleValue = parseScaleValue(scaleKey);
if (scaleValue !== null) {
return { transform: [{ scaleY: negativePrefix === "-" ? -scaleValue : scaleValue }] };
}
}

Expand Down
Loading