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
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default defineConfig({
{ label: "Colors", slug: "reference/colors" },
{ label: "Typography", slug: "reference/typography" },
{ label: "Borders", slug: "reference/borders" },
{ label: "Outlines", slug: "reference/outlines" },
{ label: "Shadows & Elevation", slug: "reference/shadows" },
{ label: "Aspect Ratio", slug: "reference/aspect-ratio" },
{ label: "Transforms", slug: "reference/transforms" },
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/borders.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,5 @@ Apply colors to individual border sides. See the [Colors reference](/react-nativ
## Related

- [Colors](/react-native-tailwind/reference/colors/) - Border color utilities
- [Outlines](/react-native-tailwind/reference/outlines/) - Outline width, style, and offset utilities
- [Shadows](/react-native-tailwind/reference/shadows/) - Shadow and elevation
59 changes: 59 additions & 0 deletions docs/src/content/docs/reference/outlines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: Outlines
description: Outline width, style, and offset utilities
---

Utilities for controlling the outline style of an element.

> **Note**: Outline support requires React Native 0.73+ (New Architecture) and setting the `outline` style property.

## Outline Width

```tsx
<View className="outline" /> // outlineWidth: 1, outlineStyle: 'solid'
<View className="outline-0" /> // outlineWidth: 0
<View className="outline-2" /> // outlineWidth: 2
<View className="outline-4" /> // outlineWidth: 4
<View className="outline-[2px]" /> // outlineWidth: 2
<View className="outline-none" /> // outlineWidth: 0
```

## Outline Color

```tsx
<View className="outline-blue-500" /> // outlineColor: '#3B82F6'
<View className="outline-[#ff0000]" /> // outlineColor: '#ff0000'
<View className="outline-red-500/50" /> // outlineColor: '#EF4444' (50% opacity)
```

## Outline Style

```tsx
<View className="outline-solid" /> // outlineStyle: 'solid'
<View className="outline-dashed" /> // outlineStyle: 'dashed'
<View className="outline-dotted" /> // outlineStyle: 'dotted'
```

## Outline Offset

Utilities for controlling the offset of an element's outline.

```tsx
<View className="outline-offset-0" /> // outlineOffset: 0
<View className="outline-offset-1" /> // outlineOffset: 1
<View className="outline-offset-2" /> // outlineOffset: 2
<View className="outline-offset-4" /> // outlineOffset: 4
<View className="outline-offset-[3px]" /> // outlineOffset: 3
```

## Example

```tsx
<View className="w-32 h-32 bg-white outline outline-blue-500 outline-offset-2 rounded-lg" />
```

## Related

- [Borders](/react-native-tailwind/reference/borders/) - Border width, radius, and style utilities
- [Colors](/react-native-tailwind/reference/colors/) - Color utilities
- [Shadows](/react-native-tailwind/reference/shadows/) - Shadow and elevation
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
parseBorder,
parseColor,
parseLayout,
parseOutline,
parsePlaceholderClass,
parsePlaceholderClasses,
parseShadow,
Expand Down
32 changes: 29 additions & 3 deletions src/parser/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
* Parse color classes (background, text, border)
* Supports opacity modifier: bg-blue-500/50, text-black/80, border-red-500/30
*/
export function parseColor(cls: string, customColors?: Record<string, string>): StyleObject | null {
export function parseColor(

Check failure on line 15 in src/parser/colors.ts

View workflow job for this annotation

GitHub Actions / Test on node@vlts/*

Replace `⏎··cls:·string,⏎··customColors?:·Record<string,·string>⏎` with `cls:·string,·customColors?:·Record<string,·string>`
cls: string,
customColors?: Record<string, string>
): StyleObject | null {
// Helper to get color with custom override (custom colors take precedence)
const getColor = (key: string): string | undefined => {
return customColors?.[key] ?? COLORS[key];
Expand All @@ -32,7 +35,7 @@
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`,
`[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`

Check failure on line 38 in src/parser/colors.ts

View workflow job for this annotation

GitHub Actions / Test on node@vlts/*

Insert `,`
);
}
return null;
Expand Down Expand Up @@ -65,7 +68,7 @@
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Unsupported arbitrary color value: ${colorKey}. Only hex colors are supported (e.g., [#ff0000], [#f00], or [#ff0000aa]).`,
`[react-native-tailwind] Unsupported arbitrary color value: ${colorKey}. Only hex colors are supported (e.g., [#ff0000], [#f00], or [#ff0000aa]).`

Check failure on line 71 in src/parser/colors.ts

View workflow job for this annotation

GitHub Actions / Test on node@vlts/*

Insert `,`
);
}
return null;
Expand Down Expand Up @@ -116,6 +119,29 @@
}
}

// Outline color: outline-blue-500, outline-blue-500/50, outline-[#ff0000]/80
if (

Check failure on line 123 in src/parser/colors.ts

View workflow job for this annotation

GitHub Actions / Test on node@vlts/*

Replace `⏎····cls.startsWith("outline-")·&&⏎····!cls.match(/^outline-[0-9]/)·&&⏎····!cls.startsWith("outline-offset-")⏎··` with `cls.startsWith("outline-")·&&·!cls.match(/^outline-[0-9]/)·&&·!cls.startsWith("outline-offset-")`
cls.startsWith("outline-") &&
!cls.match(/^outline-[0-9]/) &&
!cls.startsWith("outline-offset-")
) {
const colorKey = cls.substring(8); // "outline-".length = 8

// Skip outline-style values
if (["solid", "dashed", "dotted", "none"].includes(colorKey)) {
return null;
}

// Skip arbitrary values that don't look like colors (e.g., outline-[3px] is width)
if (colorKey.startsWith("[") && !colorKey.startsWith("[#")) {
return null;
}
const color = parseColorWithOpacity(colorKey);
if (color) {
return { outlineColor: color };
}
}

// Directional border colors: border-t-red-500, border-l-blue-500/50, border-r-[#ff0000]
const dirBorderMatch = cls.match(/^border-([trblxy])-(.+)$/);
if (dirBorderMatch) {
Expand Down
3 changes: 3 additions & 0 deletions src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parseAspectRatio } from "./aspectRatio";
import { parseBorder } from "./borders";
import { parseColor } from "./colors";
import { parseLayout } from "./layout";
import { parseOutline } from "./outline";
import { parseShadow } from "./shadows";
import { parseSizing } from "./sizing";
import { parseSpacing } from "./spacing";
Expand Down Expand Up @@ -56,6 +57,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject
const parsers: Array<(cls: string) => StyleObject | null> = [
(cls: string) => parseSpacing(cls, customTheme?.spacing),
(cls: string) => parseBorder(cls, customTheme?.colors),
parseOutline,
(cls: string) => parseColor(cls, customTheme?.colors),
(cls: string) => parseLayout(cls, customTheme?.spacing),
(cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize),
Expand Down Expand Up @@ -86,6 +88,7 @@ export { parseAspectRatio } from "./aspectRatio";
export { parseBorder } from "./borders";
export { parseColor } from "./colors";
export { parseLayout } from "./layout";
export { parseOutline } from "./outline";
export { parsePlaceholderClass, parsePlaceholderClasses } from "./placeholder";
export { parseShadow } from "./shadows";
export { parseSizing } from "./sizing";
Expand Down
57 changes: 57 additions & 0 deletions src/parser/outline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { parseOutline } from "./outline";

describe("parseOutline", () => {
it("should parse outline shorthand", () => {
expect(parseOutline("outline")).toEqual({
outlineWidth: 1,
outlineStyle: "solid",
});
});

it("should parse outline-none", () => {
expect(parseOutline("outline-none")).toEqual({ outlineWidth: 0 });
});

it("should parse outline width with preset values", () => {
expect(parseOutline("outline-0")).toEqual({ outlineWidth: 0 });
expect(parseOutline("outline-2")).toEqual({ outlineWidth: 2 });
expect(parseOutline("outline-4")).toEqual({ outlineWidth: 4 });
expect(parseOutline("outline-8")).toEqual({ outlineWidth: 8 });
});

it("should parse outline width with arbitrary values", () => {
expect(parseOutline("outline-[5px]")).toEqual({ outlineWidth: 5 });
expect(parseOutline("outline-[10]")).toEqual({ outlineWidth: 10 });
});

it("should parse outline style", () => {
expect(parseOutline("outline-solid")).toEqual({ outlineStyle: "solid" });
expect(parseOutline("outline-dashed")).toEqual({ outlineStyle: "dashed" });
expect(parseOutline("outline-dotted")).toEqual({ outlineStyle: "dotted" });
});

it("should parse outline offset with preset values", () => {
expect(parseOutline("outline-offset-0")).toEqual({ outlineOffset: 0 });
expect(parseOutline("outline-offset-2")).toEqual({ outlineOffset: 2 });
expect(parseOutline("outline-offset-4")).toEqual({ outlineOffset: 4 });
expect(parseOutline("outline-offset-8")).toEqual({ outlineOffset: 8 });
});

it("should parse outline offset with arbitrary values", () => {
expect(parseOutline("outline-offset-[3px]")).toEqual({ outlineOffset: 3 });
expect(parseOutline("outline-offset-[5]")).toEqual({ outlineOffset: 5 });
});

it("should return null for invalid outline values", () => {
expect(parseOutline("outline-invalid")).toBeNull();
expect(parseOutline("outline-3")).toBeNull(); // Not in scale
expect(parseOutline("outline-offset-3")).toBeNull(); // Not in scale
expect(parseOutline("outline-[5rem]")).toBeNull(); // Unsupported unit
});

it("should return null for outline colors (handled by parseColor)", () => {
expect(parseOutline("outline-red-500")).toBeNull();
expect(parseOutline("outline-[#ff0000]")).toBeNull();
});
});
101 changes: 101 additions & 0 deletions src/parser/outline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Outline utilities (outline width, style, offset)
*/

import type { StyleObject } from "../types";
import { BORDER_WIDTH_SCALE } from "./borders";

/**
* Parse arbitrary outline width/offset value: [8px], [4]
* Returns number for px values, null for unsupported formats
*/
function parseArbitraryOutlineValue(value: string): number | null {
// Match: [8px] or [8] (pixels only)
const pxMatch = value.match(/^\[(\d+)(?:px)?\]$/);
if (pxMatch) {
return parseInt(pxMatch[1], 10);
}

// Warn about unsupported formats
if (value.startsWith("[") && value.endsWith("]")) {
/* v8 ignore next 5 */
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Unsupported arbitrary outline value: ${value}. Only px values are supported (e.g., [8px] or [8]).`,
);
}
return null;
}

return null;
}

/**
* Parse outline classes
* @param cls - The class name to parse
*/
export function parseOutline(cls: string): StyleObject | null {
// Shorthand: outline (width: 1, style: solid)
if (cls === "outline") {
return { outlineWidth: 1, outlineStyle: "solid" };
}

// Outline none
if (cls === "outline-none") {
return { outlineWidth: 0 };
}

// Outline style
if (cls === "outline-solid") return { outlineStyle: "solid" };
if (cls === "outline-dotted") return { outlineStyle: "dotted" };
if (cls === "outline-dashed") return { outlineStyle: "dashed" };

// Outline offset: outline-offset-2, outline-offset-[3px]
if (cls.startsWith("outline-offset-")) {
const valueStr = cls.substring(15); // "outline-offset-".length = 15

// Try arbitrary value first
if (valueStr.startsWith("[")) {
const arbitraryValue = parseArbitraryOutlineValue(valueStr);
if (arbitraryValue !== null) {
return { outlineOffset: arbitraryValue };
}
return null;
}

// Try preset scale (reuse border width scale for consistency with default Tailwind)
const scaleValue = BORDER_WIDTH_SCALE[valueStr];
if (scaleValue !== undefined) {
return { outlineOffset: scaleValue };
}

return null;
}

// Outline width: outline-0, outline-2, outline-[5px]
// Must handle potential collision with outline-red-500 (colors)
// Logic: if it matches width pattern, return width. If it looks like color, return null (let parseColor handle it)

const widthMatch = cls.match(/^outline-(\d+)$/);
if (widthMatch) {
const value = BORDER_WIDTH_SCALE[widthMatch[1]];
if (value !== undefined) {
return { outlineWidth: value };
}
}

const arbMatch = cls.match(/^outline-(\[.+\])$/);
if (arbMatch) {
// Check if it's a color first? No, colors usually look like [#...] or [rgb(...)]
// parseArbitraryOutlineValue only accepts [123] or [123px]
// If it fails, it might be a color, so we return null
const arbitraryValue = parseArbitraryOutlineValue(arbMatch[1]);
if (arbitraryValue !== null) {
return { outlineWidth: arbitraryValue };
}
return null;
}

// If it's outline-{color}, return null so parseColor (called later in index.ts) handles it
return null;
}
Loading