diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 0000000..ceb48da
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -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 }}
diff --git a/docs/src/content/docs/reference/spacing.md b/docs/src/content/docs/reference/spacing.md
index 5366480..86cbb81 100644
--- a/docs/src/content/docs/reference/spacing.md
+++ b/docs/src/content/docs/reference/spacing.md
@@ -72,6 +72,18 @@ Control spacing between flex items:
```
+Use directional gaps to control the horizontal and vertical axes independently:
+
+```tsx
+
+ {/* columnGap: 8, rowGap: 6 */}
+
+
+
+ {/* columnGap: 12, rowGap: 4.5 */}
+
+```
+
## Available Sizes
Spacing values follow a scale where `1` = 4px:
@@ -122,6 +134,8 @@ Use arbitrary values for custom spacing not in the preset scale:
// padding: 20
// marginHorizontal: 24
// gap: 12
+ // columnGap: 12
+ // rowGap: 6
```
:::note
diff --git a/docs/src/content/docs/reference/transforms.md b/docs/src/content/docs/reference/transforms.md
index e7ca254..4309c9c 100644
--- a/docs/src/content/docs/reference/transforms.md
+++ b/docs/src/content/docs/reference/transforms.md
@@ -12,6 +12,7 @@ Apply 2D and 3D transformations to views with React Native's transform API. All
```tsx
// scale: 0
// scale: 0.5
+ // scale: 0.8
// scale: 1
// scale: 1.1
// scale: 1.5
@@ -22,12 +23,14 @@ Apply 2D and 3D transformations to views with React Native's transform API. All
```tsx
// scaleX: 1.1
// scaleY: 0.9
+ // scaleX: -0.8
```
### Arbitrary Values
```tsx
// scale: 1.23
+ // scale: 0.8
// scaleX: 0.5
// scaleY: 2.5
```
diff --git a/src/parser/spacing.test.ts b/src/parser/spacing.test.ts
index 2813a37..17efd74 100644
--- a/src/parser/spacing.test.ts
+++ b/src/parser/spacing.test.ts
@@ -197,6 +197,18 @@ describe("parseSpacing - gap", () => {
expect(parseSpacing("gap-[100px]")).toEqual({ gap: 100 });
expect(parseSpacing("gap-[100]")).toEqual({ gap: 100 });
});
+
+ it("should parse horizontal and vertical gaps", () => {
+ expect(parseSpacing("gap-x-2")).toEqual({ columnGap: 8 });
+ expect(parseSpacing("gap-y-1.5")).toEqual({ rowGap: 6 });
+ expect(parseSpacing("gap-x-px")).toEqual({ columnGap: 1 });
+ expect(parseSpacing("gap-y-0")).toEqual({ rowGap: 0 });
+ });
+
+ it("should parse directional gaps with arbitrary values", () => {
+ expect(parseSpacing("gap-x-[12px]")).toEqual({ columnGap: 12 });
+ expect(parseSpacing("gap-y-[4.5]")).toEqual({ rowGap: 4.5 });
+ });
});
describe("parseSpacing - edge cases", () => {
@@ -507,6 +519,8 @@ describe("parseSpacing - custom spacing", () => {
expect(parseSpacing("gap-xs", customSpacing)).toEqual({ gap: 4 });
expect(parseSpacing("gap-md", customSpacing)).toEqual({ gap: 16 });
expect(parseSpacing("gap-xl", customSpacing)).toEqual({ gap: 64 });
+ expect(parseSpacing("gap-x-sm", customSpacing)).toEqual({ columnGap: 8 });
+ expect(parseSpacing("gap-y-lg", customSpacing)).toEqual({ rowGap: 32 });
});
it("should allow custom spacing to override preset values", () => {
diff --git a/src/parser/spacing.ts b/src/parser/spacing.ts
index 8635db5..c9a3f4e 100644
--- a/src/parser/spacing.ts
+++ b/src/parser/spacing.ts
@@ -70,7 +70,7 @@ function parseArbitrarySpacing(value: string): number | null {
/**
* Parse spacing classes (margin, padding, gap)
- * Examples: m-4, mx-2, mt-8, p-4, px-2, pt-8, gap-4, m-[16px], pl-[4.5px], -m-4, -mt-[10px], ms-4, pe-2, mx-auto
+ * Examples: m-4, mx-2, mt-8, p-4, px-2, pt-8, gap-4, gap-x-2, gap-y-1.5, m-[16px], pl-[4.5px], -m-4, -mt-[10px], ms-4, pe-2, mx-auto
* @param cls - The class name to parse
* @param customSpacing - Optional custom spacing values from tailwind.config
*/
@@ -128,27 +128,37 @@ export function parseSpacing(cls: string, customSpacing?: Record
}
}
- // Gap: gap-4, gap-[16px]
- const gapMatch = cls.match(/^gap-(.+)$/);
+ // Gap: gap-4, gap-x-2, gap-y-1.5, gap-[16px]
+ const gapMatch = cls.match(/^gap(?:-([xy]))?-(.+)$/);
if (gapMatch) {
- const valueStr = gapMatch[1];
+ const [, direction = "", valueStr] = gapMatch;
// Try arbitrary value first (highest priority)
const arbitraryValue = parseArbitrarySpacing(valueStr);
if (arbitraryValue !== null) {
- return { gap: arbitraryValue };
+ return getGapStyle(direction, arbitraryValue);
}
// Try spacing scale (includes custom spacing)
const scaleValue = spacingMap[valueStr];
if (scaleValue !== undefined) {
- return { gap: scaleValue };
+ return getGapStyle(direction, scaleValue);
}
}
return null;
}
+/**
+ * Get gap style object based on Tailwind's gap direction.
+ * gap-x controls spacing between columns; gap-y controls spacing between rows.
+ */
+function getGapStyle(direction: string, value: number): StyleObject {
+ if (direction === "x") return { columnGap: value };
+ if (direction === "y") return { rowGap: value };
+ return { gap: value };
+}
+
/**
* Get margin style object based on direction
*/
diff --git a/src/parser/transforms.test.ts b/src/parser/transforms.test.ts
index b87ae2b..ef3f420 100644
--- a/src/parser/transforms.test.ts
+++ b/src/parser/transforms.test.ts
@@ -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 }] });
@@ -40,11 +41,14 @@ 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 }] });
});
@@ -52,16 +56,21 @@ describe("parseTransform - scale utilities", () => {
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", () => {
@@ -69,9 +78,15 @@ describe("parseTransform - scale utilities", () => {
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();
});
});
diff --git a/src/parser/transforms.ts b/src/parser/transforms.ts
index c75d813..b315e50 100644
--- a/src/parser/transforms.ts
+++ b/src/parser/transforms.ts
@@ -59,13 +59,14 @@ export const PERSPECTIVE_SCALE: Record = {
};
/**
- * 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
@@ -73,7 +74,7 @@ function parseArbitraryScale(value: string): number | null {
/* 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;
@@ -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
@@ -181,51 +205,33 @@ export function parseTransform(cls: string, customSpacing?: Record