diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 5df770e..d6e7a0b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -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" }, diff --git a/docs/src/content/docs/reference/borders.md b/docs/src/content/docs/reference/borders.md index a63627f..9362a1c 100644 --- a/docs/src/content/docs/reference/borders.md +++ b/docs/src/content/docs/reference/borders.md @@ -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 diff --git a/docs/src/content/docs/reference/outlines.md b/docs/src/content/docs/reference/outlines.md new file mode 100644 index 0000000..7593e08 --- /dev/null +++ b/docs/src/content/docs/reference/outlines.md @@ -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 + // outlineWidth: 1, outlineStyle: 'solid' + // outlineWidth: 0 + // outlineWidth: 2 + // outlineWidth: 4 + // outlineWidth: 2 + // outlineWidth: 0 +``` + +## Outline Color + +```tsx + // outlineColor: '#3B82F6' + // outlineColor: '#ff0000' + // outlineColor: '#EF4444' (50% opacity) +``` + +## Outline Style + +```tsx + // outlineStyle: 'solid' + // outlineStyle: 'dashed' + // outlineStyle: 'dotted' +``` + +## Outline Offset + +Utilities for controlling the offset of an element's outline. + +```tsx + // outlineOffset: 0 + // outlineOffset: 1 + // outlineOffset: 2 + // outlineOffset: 4 + // outlineOffset: 3 +``` + +## Example + +```tsx + +``` + +## 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 diff --git a/src/index.ts b/src/index.ts index 515af19..0bd46f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { parseBorder, parseColor, parseLayout, + parseOutline, parsePlaceholderClass, parsePlaceholderClasses, parseShadow, diff --git a/src/parser/colors.ts b/src/parser/colors.ts index 3966a00..b23e7ab 100644 --- a/src/parser/colors.ts +++ b/src/parser/colors.ts @@ -12,7 +12,10 @@ export { COLORS }; * 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): StyleObject | null { +export function parseColor( + cls: string, + customColors?: Record +): StyleObject | null { // Helper to get color with custom override (custom colors take precedence) const getColor = (key: string): string | undefined => { return customColors?.[key] ?? COLORS[key]; @@ -32,7 +35,7 @@ export function parseColor(cls: string, customColors?: Record): /* 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.` ); } return null; @@ -65,7 +68,7 @@ export function parseColor(cls: string, customColors?: Record): /* 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]).` ); } return null; @@ -116,6 +119,29 @@ export function parseColor(cls: string, customColors?: Record): } } + // Outline color: outline-blue-500, outline-blue-500/50, outline-[#ff0000]/80 + if ( + 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) { diff --git a/src/parser/index.ts b/src/parser/index.ts index ebc143a..0e89fcc 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -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"; @@ -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), @@ -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"; diff --git a/src/parser/outline.test.ts b/src/parser/outline.test.ts new file mode 100644 index 0000000..56e396d --- /dev/null +++ b/src/parser/outline.test.ts @@ -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(); + }); +}); diff --git a/src/parser/outline.ts b/src/parser/outline.ts new file mode 100644 index 0000000..d1e04e7 --- /dev/null +++ b/src/parser/outline.ts @@ -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; +}