@kbach/react 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  "use strict";
2
3
  var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
@@ -401,7 +402,10 @@ var defaultTheme = {
401
402
  "2/3": "66.666667%",
402
403
  "1/4": "25%",
403
404
  "3/4": "75%",
404
- screen: "100vh",
405
+ // Dynamic viewport unit — see the comment on the 'screen' standalone utilities
406
+ // in utilities.ts for why dvh beats vh on mobile. Used by h-screen (w-screen
407
+ // has its own standalone entry so it never reaches this spacing lookup).
408
+ screen: "100dvh",
405
409
  min: "min-content",
406
410
  max: "max-content",
407
411
  fit: "fit-content"
@@ -888,15 +892,19 @@ function buildStandalone() {
888
892
  // Display
889
893
  // React Native only supports display:'flex'|'none'. Setting 'flex' explicitly is
890
894
  // a no-op normally, but is needed to re-show an element that was hidden via `hidden`.
891
- // `grid`, `inline-*`, `contents`, `flow-root` have no native equivalent.
895
+ // `grid`, `contents`, `flow-root` have no native equivalent and stay null there.
896
+ // The inline-* family (inline, inline-block, inline-flex, inline-grid) falls back
897
+ // to 'flex' on native instead — like plain `flex`, this is a no-op most of the
898
+ // time, but without it these utilities couldn't re-show a `hidden` element on
899
+ // native either (null → no style applied → still display:'none').
892
900
  flex: { display: "flex" },
893
901
  block: web ? { display: "block" } : null,
894
- "inline-block": web ? { display: "inline-block" } : null,
895
- inline: web ? { display: "inline" } : null,
902
+ "inline-block": { display: web ? "inline-block" : "flex" },
903
+ inline: { display: web ? "inline" : "flex" },
896
904
  grid: web ? { display: "grid" } : null,
897
905
  grd: web ? { display: "grid" } : null,
898
906
  "inline-flex": { display: web ? "inline-flex" : "flex" },
899
- "inline-grid": web ? { display: "inline-grid" } : null,
907
+ "inline-grid": { display: web ? "inline-grid" : "flex" },
900
908
  hidden: { display: "none" },
901
909
  contents: web ? { display: "contents" } : null,
902
910
  "flow-root": web ? { display: "flow-root" } : null,
@@ -1155,12 +1163,17 @@ function buildStandalone() {
1155
1163
  "text-nowrap": web ? { textWrap: "nowrap" } : null,
1156
1164
  "text-balance": web ? { textWrap: "balance" } : null,
1157
1165
  "text-pretty": web ? { textWrap: "pretty" } : null,
1158
- // Screen sizing (vw values are web-only; vh values work cross-platform via spacing scale for h-*)
1159
- "w-screen": web ? { width: "100vw" } : null,
1160
- "min-h-screen": { minHeight: "100vh" },
1161
- "max-h-screen": { maxHeight: "100vh" },
1162
- "min-w-screen": web ? { minWidth: "100vw" } : null,
1163
- "max-w-screen": web ? { maxWidth: "100vw" } : null,
1166
+ // Screen sizing — dvw/dvh (dynamic viewport units) instead of vw/vh: on mobile
1167
+ // browsers, vh/vw are pinned to the LARGEST viewport size (address bar hidden),
1168
+ // so `h-screen` overflows behind the address bar when it's shown. dvh/dvw track
1169
+ // the actual visible viewport as browser chrome shows/hides. Desktop behavior
1170
+ // is unchanged since there's no dynamic chrome to account for.
1171
+ // (vw/dvw values are web-only; vh/dvh values work cross-platform via spacing scale for h-*)
1172
+ "w-screen": web ? { width: "100dvw" } : null,
1173
+ "min-h-screen": { minHeight: "100dvh" },
1174
+ "max-h-screen": { maxHeight: "100dvh" },
1175
+ "min-w-screen": web ? { minWidth: "100dvw" } : null,
1176
+ "max-w-screen": web ? { maxWidth: "100dvw" } : null,
1164
1177
  // max-w named container sizes (mirrors Tailwind's container scale)
1165
1178
  "max-w-none": { maxWidth: "none" },
1166
1179
  "max-w-xs": { maxWidth: 320 },
@@ -2286,34 +2299,50 @@ var RESOLVERS = {
2286
2299
  const ms = isArbitrary ? value : `${value}ms`;
2287
2300
  return { transitionDelay: ms };
2288
2301
  },
2289
- // ── Ring (web-only: box-shadow outline ring) ──────────────────────────────
2302
+ // ── Ring ───────────────────────────────────────────────────────────────────
2303
+ // Web: box-shadow outline ring (doesn't affect layout).
2304
+ // Native: React Native has no box-shadow, so this falls back to borderWidth/
2305
+ // borderColor — the closest visual approximation (used by other RN Tailwind-
2306
+ // likes for the same reason). Unlike the web ring, this DOES affect layout,
2307
+ // and it shares its properties with the `border` utility — combining
2308
+ // `border-*` and `ring-*` on the same native element means whichever class
2309
+ // comes later wins, since both ultimately set borderWidth/borderColor.
2290
2310
  ring: ({ value, isArbitrary }, { colors }) => {
2291
- if (!getEffectiveIsWeb()) return null;
2311
+ const onWeb = getEffectiveIsWeb();
2292
2312
  const DEFAULT_COLOR = "rgba(59, 130, 246, 0.5)";
2293
2313
  const DEFAULT_WIDTH = 3;
2314
+ const asNative = (w, color2) => w === 0 ? { borderWidth: 0 } : { borderWidth: w, borderColor: color2 };
2294
2315
  if (!value) {
2295
- return { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` };
2316
+ return onWeb ? { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` } : asNative(DEFAULT_WIDTH, DEFAULT_COLOR);
2296
2317
  }
2297
2318
  if (value === "inset") {
2298
- return { boxShadow: `inset 0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` };
2319
+ return onWeb ? { boxShadow: `inset 0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` } : asNative(DEFAULT_WIDTH, DEFAULT_COLOR);
2299
2320
  }
2300
2321
  const widthTokens = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
2301
2322
  if (!isArbitrary && value in widthTokens) {
2302
2323
  const w = widthTokens[value];
2303
- return { boxShadow: w === 0 ? "none" : `0 0 0 ${w}px ${DEFAULT_COLOR}` };
2324
+ return onWeb ? { boxShadow: w === 0 ? "none" : `0 0 0 ${w}px ${DEFAULT_COLOR}` } : asNative(w, DEFAULT_COLOR);
2304
2325
  }
2305
2326
  if (isArbitrary) {
2306
2327
  const numMatch = /^(\d+(?:\.\d+)?)(px|rem|em|vw|vh)?$/.exec(value);
2307
2328
  if (numMatch) {
2308
2329
  const unit = numMatch[2] ?? "px";
2309
- return { boxShadow: `0 0 0 ${numMatch[1]}${unit} ${DEFAULT_COLOR}` };
2330
+ if (onWeb) return { boxShadow: `0 0 0 ${numMatch[1]}${unit} ${DEFAULT_COLOR}` };
2331
+ return unit === "px" ? asNative(parseFloat(numMatch[1]), DEFAULT_COLOR) : null;
2310
2332
  }
2311
- return { boxShadow: value.replace(/_/g, " ") };
2333
+ return onWeb ? { boxShadow: value.replace(/_/g, " ") } : null;
2312
2334
  }
2313
2335
  const color = resolveColor(value, colors, false);
2314
- if (color) return { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${color}` };
2336
+ if (color) {
2337
+ return onWeb ? { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${color}` } : asNative(DEFAULT_WIDTH, color);
2338
+ }
2315
2339
  return null;
2316
2340
  },
2341
+ // Web-only, unlike `ring` above: this stacks a second box-shadow layer to
2342
+ // create a gap between the element and the ring. There's no native
2343
+ // equivalent to approximate that with (a border can't create a gap outside
2344
+ // its own element without an extra wrapper view), so this stays a no-op
2345
+ // on native rather than rendering a misleading half-translation.
2317
2346
  "ring-offset": ({ value, isArbitrary }, _theme) => {
2318
2347
  if (!getEffectiveIsWeb()) return null;
2319
2348
  const DEFAULT_RING_COLOR = "rgba(59, 130, 246, 0.5)";
@@ -1,13 +1,13 @@
1
+ 'use client';
1
2
  import {
2
3
  Fragment,
3
4
  _invalidateDefaultFontCache,
4
5
  jsx,
5
6
  jsxs
6
- } from "./chunk-6ZORO3BP.mjs";
7
+ } from "./chunk-4A24NQ5X.mjs";
7
8
  import {
8
9
  registerWebElement
9
- } from "./chunk-36QRQNPL.mjs";
10
- import "./chunk-MPHPB357.mjs";
10
+ } from "./chunk-ZZWJK33J.mjs";
11
11
  export {
12
12
  Fragment,
13
13
  _invalidateDefaultFontCache,
@@ -1,5 +1,106 @@
1
1
  import { Plugin } from 'vite';
2
- import { F as FrameworkConfig } from './types-BESBQANS.mjs';
2
+
3
+ /**
4
+ * Unified modifier registry — single source of truth for ALL modifier behavior.
5
+ *
6
+ * Adding a new modifier requires editing ONLY this file:
7
+ * 1. Add an entry to BUILTIN_MODIFIERS with its CSS and JS behavior.
8
+ * 2. Done — parser, resolver, CSS generator, and JSX runtime all derive
9
+ * their behavior from this data automatically.
10
+ *
11
+ * Plugin authors can register custom modifiers via registerModifier().
12
+ */
13
+ interface ModifierDef {
14
+ /** CSS pseudo-class or pseudo-element appended to the selector (e.g. ':hover', '::before') */
15
+ pseudo?: string;
16
+ /** Ancestor selector prefix INCLUDING trailing space (e.g. '.group:hover ', '.peer:focus ~ ') */
17
+ ancestorSelector?: string;
18
+ /** Directionality attribute selector prefix INCLUDING trailing space (e.g. '[dir="rtl"] ') */
19
+ dirSelector?: string;
20
+ /** @media query body WITHOUT the '@media ' prefix (e.g. 'print', '(orientation: landscape)') */
21
+ mediaQuery?: string;
22
+ /** Dark/light mode scheme — triggers the configured darkMode strategy in CSS output */
23
+ darkScheme?: 'dark' | 'light';
24
+ /** True for responsive modifiers — wraps in @media (min-width: theme.screens[name]) */
25
+ isResponsive?: boolean;
26
+ /**
27
+ * Forces !important on all declarations in the generated CSS rule.
28
+ * Applied automatically for structural / ancestor / media modifiers that must
29
+ * win over base inline styles.
30
+ */
31
+ forcesImportant?: boolean;
32
+ /**
33
+ * How the JSX runtime routes this modifier:
34
+ * 'interactive' — managed by InteractiveWrapper (hover, focus, pressed, …)
35
+ * 'mode' — managed by DarkWrapper (dark, light, not-dark, …)
36
+ * 'responsive' — managed by DarkWrapper (sm, md, lg, xl, 2xl)
37
+ * 'css-only' — CSS injection only; matchModifier always returns false
38
+ */
39
+ jsBehavior: 'interactive' | 'mode' | 'responsive' | 'css-only';
40
+ /**
41
+ * Evaluates whether this modifier's condition is met at runtime.
42
+ * Omit for 'css-only' modifiers — they never apply as inline styles.
43
+ */
44
+ jsMatch?: (isDark: boolean, state: Record<string, boolean | undefined>, breakpoints: Set<string>) => boolean;
45
+ }
46
+
47
+ interface StyleValue {
48
+ [key: string]: string | number | undefined | null | StyleValue | StyleValue[];
49
+ }
50
+ type ColorShades = Record<string, string>;
51
+ type ThemeColors = Record<string, string | ColorShades>;
52
+ type ThemeSpacing = Record<string, number | string>;
53
+ interface ThemeConfig {
54
+ colors: ThemeColors;
55
+ spacing: ThemeSpacing;
56
+ fontSize: Record<string, number | string>;
57
+ fontFamily: Record<string, string | string[]>;
58
+ fontWeight: Record<string, string | number>;
59
+ borderRadius: Record<string, number | string>;
60
+ borderWidth: Record<string, number>;
61
+ opacity: Record<string, number>;
62
+ lineHeight: Record<string, number | string>;
63
+ letterSpacing: Record<string, number | string>;
64
+ zIndex: Record<string, number | string>;
65
+ flex: Record<string, number | string>;
66
+ shadow: Record<string, StyleValue>;
67
+ screens: Record<string, string | number>;
68
+ [key: string]: unknown;
69
+ }
70
+ /**
71
+ * 'class' — toggles .dark class on <html>
72
+ * 'media' — uses prefers-color-scheme media query
73
+ * 'attribute' — uses data-theme="dark" attribute on <html>
74
+ */
75
+ type DarkMode = 'attribute' | 'class' | 'media';
76
+ interface PluginAPI {
77
+ addUtility(name: string, styles: StyleValue): void;
78
+ /**
79
+ * Register a custom variant.
80
+ *
81
+ * Pass a CSS selector string for simple cases — it is automatically
82
+ * converted into a ModifierDef that generates correct CSS rules:
83
+ * addVariant('hocus', ':hover, :focus') // pseudo
84
+ * addVariant('supports-grid', '@media (display: grid)') // media
85
+ * addVariant('dark-green', '.dark-green') // ancestor selector
86
+ *
87
+ * Pass a full ModifierDef object for advanced control (e.g. JS-trackable
88
+ * interactive variants with custom jsMatch logic).
89
+ */
90
+ addVariant(name: string, selectorOrDef: string | ModifierDef): void;
91
+ theme(path: string, defaultValue?: unknown): unknown;
92
+ e(className: string): string;
93
+ }
94
+ interface FrameworkConfig {
95
+ darkMode?: DarkMode;
96
+ theme?: Partial<ThemeConfig>;
97
+ /** Additive theme extension — accepts either `extend.theme.X` or `extend.X` directly. */
98
+ extend?: {
99
+ theme?: Partial<ThemeConfig>;
100
+ } & Partial<ThemeConfig>;
101
+ plugins?: Array<(api: PluginAPI) => void>;
102
+ content?: string[];
103
+ }
3
104
 
4
105
  interface KbachPluginOptions {
5
106
  framework?: FrameworkConfig;
@@ -1,5 +1,106 @@
1
1
  import { Plugin } from 'vite';
2
- import { F as FrameworkConfig } from './types-BESBQANS.js';
2
+
3
+ /**
4
+ * Unified modifier registry — single source of truth for ALL modifier behavior.
5
+ *
6
+ * Adding a new modifier requires editing ONLY this file:
7
+ * 1. Add an entry to BUILTIN_MODIFIERS with its CSS and JS behavior.
8
+ * 2. Done — parser, resolver, CSS generator, and JSX runtime all derive
9
+ * their behavior from this data automatically.
10
+ *
11
+ * Plugin authors can register custom modifiers via registerModifier().
12
+ */
13
+ interface ModifierDef {
14
+ /** CSS pseudo-class or pseudo-element appended to the selector (e.g. ':hover', '::before') */
15
+ pseudo?: string;
16
+ /** Ancestor selector prefix INCLUDING trailing space (e.g. '.group:hover ', '.peer:focus ~ ') */
17
+ ancestorSelector?: string;
18
+ /** Directionality attribute selector prefix INCLUDING trailing space (e.g. '[dir="rtl"] ') */
19
+ dirSelector?: string;
20
+ /** @media query body WITHOUT the '@media ' prefix (e.g. 'print', '(orientation: landscape)') */
21
+ mediaQuery?: string;
22
+ /** Dark/light mode scheme — triggers the configured darkMode strategy in CSS output */
23
+ darkScheme?: 'dark' | 'light';
24
+ /** True for responsive modifiers — wraps in @media (min-width: theme.screens[name]) */
25
+ isResponsive?: boolean;
26
+ /**
27
+ * Forces !important on all declarations in the generated CSS rule.
28
+ * Applied automatically for structural / ancestor / media modifiers that must
29
+ * win over base inline styles.
30
+ */
31
+ forcesImportant?: boolean;
32
+ /**
33
+ * How the JSX runtime routes this modifier:
34
+ * 'interactive' — managed by InteractiveWrapper (hover, focus, pressed, …)
35
+ * 'mode' — managed by DarkWrapper (dark, light, not-dark, …)
36
+ * 'responsive' — managed by DarkWrapper (sm, md, lg, xl, 2xl)
37
+ * 'css-only' — CSS injection only; matchModifier always returns false
38
+ */
39
+ jsBehavior: 'interactive' | 'mode' | 'responsive' | 'css-only';
40
+ /**
41
+ * Evaluates whether this modifier's condition is met at runtime.
42
+ * Omit for 'css-only' modifiers — they never apply as inline styles.
43
+ */
44
+ jsMatch?: (isDark: boolean, state: Record<string, boolean | undefined>, breakpoints: Set<string>) => boolean;
45
+ }
46
+
47
+ interface StyleValue {
48
+ [key: string]: string | number | undefined | null | StyleValue | StyleValue[];
49
+ }
50
+ type ColorShades = Record<string, string>;
51
+ type ThemeColors = Record<string, string | ColorShades>;
52
+ type ThemeSpacing = Record<string, number | string>;
53
+ interface ThemeConfig {
54
+ colors: ThemeColors;
55
+ spacing: ThemeSpacing;
56
+ fontSize: Record<string, number | string>;
57
+ fontFamily: Record<string, string | string[]>;
58
+ fontWeight: Record<string, string | number>;
59
+ borderRadius: Record<string, number | string>;
60
+ borderWidth: Record<string, number>;
61
+ opacity: Record<string, number>;
62
+ lineHeight: Record<string, number | string>;
63
+ letterSpacing: Record<string, number | string>;
64
+ zIndex: Record<string, number | string>;
65
+ flex: Record<string, number | string>;
66
+ shadow: Record<string, StyleValue>;
67
+ screens: Record<string, string | number>;
68
+ [key: string]: unknown;
69
+ }
70
+ /**
71
+ * 'class' — toggles .dark class on <html>
72
+ * 'media' — uses prefers-color-scheme media query
73
+ * 'attribute' — uses data-theme="dark" attribute on <html>
74
+ */
75
+ type DarkMode = 'attribute' | 'class' | 'media';
76
+ interface PluginAPI {
77
+ addUtility(name: string, styles: StyleValue): void;
78
+ /**
79
+ * Register a custom variant.
80
+ *
81
+ * Pass a CSS selector string for simple cases — it is automatically
82
+ * converted into a ModifierDef that generates correct CSS rules:
83
+ * addVariant('hocus', ':hover, :focus') // pseudo
84
+ * addVariant('supports-grid', '@media (display: grid)') // media
85
+ * addVariant('dark-green', '.dark-green') // ancestor selector
86
+ *
87
+ * Pass a full ModifierDef object for advanced control (e.g. JS-trackable
88
+ * interactive variants with custom jsMatch logic).
89
+ */
90
+ addVariant(name: string, selectorOrDef: string | ModifierDef): void;
91
+ theme(path: string, defaultValue?: unknown): unknown;
92
+ e(className: string): string;
93
+ }
94
+ interface FrameworkConfig {
95
+ darkMode?: DarkMode;
96
+ theme?: Partial<ThemeConfig>;
97
+ /** Additive theme extension — accepts either `extend.theme.X` or `extend.X` directly. */
98
+ extend?: {
99
+ theme?: Partial<ThemeConfig>;
100
+ } & Partial<ThemeConfig>;
101
+ plugins?: Array<(api: PluginAPI) => void>;
102
+ content?: string[];
103
+ }
3
104
 
4
105
  interface KbachPluginOptions {
5
106
  framework?: FrameworkConfig;
@@ -437,7 +437,10 @@ var defaultTheme = {
437
437
  "2/3": "66.666667%",
438
438
  "1/4": "25%",
439
439
  "3/4": "75%",
440
- screen: "100vh",
440
+ // Dynamic viewport unit — see the comment on the 'screen' standalone utilities
441
+ // in utilities.ts for why dvh beats vh on mobile. Used by h-screen (w-screen
442
+ // has its own standalone entry so it never reaches this spacing lookup).
443
+ screen: "100dvh",
441
444
  min: "min-content",
442
445
  max: "max-content",
443
446
  fit: "fit-content"
@@ -748,15 +751,19 @@ function buildStandalone() {
748
751
  // Display
749
752
  // React Native only supports display:'flex'|'none'. Setting 'flex' explicitly is
750
753
  // a no-op normally, but is needed to re-show an element that was hidden via `hidden`.
751
- // `grid`, `inline-*`, `contents`, `flow-root` have no native equivalent.
754
+ // `grid`, `contents`, `flow-root` have no native equivalent and stay null there.
755
+ // The inline-* family (inline, inline-block, inline-flex, inline-grid) falls back
756
+ // to 'flex' on native instead — like plain `flex`, this is a no-op most of the
757
+ // time, but without it these utilities couldn't re-show a `hidden` element on
758
+ // native either (null → no style applied → still display:'none').
752
759
  flex: { display: "flex" },
753
760
  block: web ? { display: "block" } : null,
754
- "inline-block": web ? { display: "inline-block" } : null,
755
- inline: web ? { display: "inline" } : null,
761
+ "inline-block": { display: web ? "inline-block" : "flex" },
762
+ inline: { display: web ? "inline" : "flex" },
756
763
  grid: web ? { display: "grid" } : null,
757
764
  grd: web ? { display: "grid" } : null,
758
765
  "inline-flex": { display: web ? "inline-flex" : "flex" },
759
- "inline-grid": web ? { display: "inline-grid" } : null,
766
+ "inline-grid": { display: web ? "inline-grid" : "flex" },
760
767
  hidden: { display: "none" },
761
768
  contents: web ? { display: "contents" } : null,
762
769
  "flow-root": web ? { display: "flow-root" } : null,
@@ -1015,12 +1022,17 @@ function buildStandalone() {
1015
1022
  "text-nowrap": web ? { textWrap: "nowrap" } : null,
1016
1023
  "text-balance": web ? { textWrap: "balance" } : null,
1017
1024
  "text-pretty": web ? { textWrap: "pretty" } : null,
1018
- // Screen sizing (vw values are web-only; vh values work cross-platform via spacing scale for h-*)
1019
- "w-screen": web ? { width: "100vw" } : null,
1020
- "min-h-screen": { minHeight: "100vh" },
1021
- "max-h-screen": { maxHeight: "100vh" },
1022
- "min-w-screen": web ? { minWidth: "100vw" } : null,
1023
- "max-w-screen": web ? { maxWidth: "100vw" } : null,
1025
+ // Screen sizing — dvw/dvh (dynamic viewport units) instead of vw/vh: on mobile
1026
+ // browsers, vh/vw are pinned to the LARGEST viewport size (address bar hidden),
1027
+ // so `h-screen` overflows behind the address bar when it's shown. dvh/dvw track
1028
+ // the actual visible viewport as browser chrome shows/hides. Desktop behavior
1029
+ // is unchanged since there's no dynamic chrome to account for.
1030
+ // (vw/dvw values are web-only; vh/dvh values work cross-platform via spacing scale for h-*)
1031
+ "w-screen": web ? { width: "100dvw" } : null,
1032
+ "min-h-screen": { minHeight: "100dvh" },
1033
+ "max-h-screen": { maxHeight: "100dvh" },
1034
+ "min-w-screen": web ? { minWidth: "100dvw" } : null,
1035
+ "max-w-screen": web ? { maxWidth: "100dvw" } : null,
1024
1036
  // max-w named container sizes (mirrors Tailwind's container scale)
1025
1037
  "max-w-none": { maxWidth: "none" },
1026
1038
  "max-w-xs": { maxWidth: 320 },
@@ -2146,34 +2158,50 @@ var RESOLVERS = {
2146
2158
  const ms = isArbitrary ? value : `${value}ms`;
2147
2159
  return { transitionDelay: ms };
2148
2160
  },
2149
- // ── Ring (web-only: box-shadow outline ring) ──────────────────────────────
2161
+ // ── Ring ───────────────────────────────────────────────────────────────────
2162
+ // Web: box-shadow outline ring (doesn't affect layout).
2163
+ // Native: React Native has no box-shadow, so this falls back to borderWidth/
2164
+ // borderColor — the closest visual approximation (used by other RN Tailwind-
2165
+ // likes for the same reason). Unlike the web ring, this DOES affect layout,
2166
+ // and it shares its properties with the `border` utility — combining
2167
+ // `border-*` and `ring-*` on the same native element means whichever class
2168
+ // comes later wins, since both ultimately set borderWidth/borderColor.
2150
2169
  ring: ({ value, isArbitrary }, { colors }) => {
2151
- if (!getEffectiveIsWeb()) return null;
2170
+ const onWeb = getEffectiveIsWeb();
2152
2171
  const DEFAULT_COLOR = "rgba(59, 130, 246, 0.5)";
2153
2172
  const DEFAULT_WIDTH = 3;
2173
+ const asNative = (w, color2) => w === 0 ? { borderWidth: 0 } : { borderWidth: w, borderColor: color2 };
2154
2174
  if (!value) {
2155
- return { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` };
2175
+ return onWeb ? { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` } : asNative(DEFAULT_WIDTH, DEFAULT_COLOR);
2156
2176
  }
2157
2177
  if (value === "inset") {
2158
- return { boxShadow: `inset 0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` };
2178
+ return onWeb ? { boxShadow: `inset 0 0 0 ${DEFAULT_WIDTH}px ${DEFAULT_COLOR}` } : asNative(DEFAULT_WIDTH, DEFAULT_COLOR);
2159
2179
  }
2160
2180
  const widthTokens = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
2161
2181
  if (!isArbitrary && value in widthTokens) {
2162
2182
  const w = widthTokens[value];
2163
- return { boxShadow: w === 0 ? "none" : `0 0 0 ${w}px ${DEFAULT_COLOR}` };
2183
+ return onWeb ? { boxShadow: w === 0 ? "none" : `0 0 0 ${w}px ${DEFAULT_COLOR}` } : asNative(w, DEFAULT_COLOR);
2164
2184
  }
2165
2185
  if (isArbitrary) {
2166
2186
  const numMatch = /^(\d+(?:\.\d+)?)(px|rem|em|vw|vh)?$/.exec(value);
2167
2187
  if (numMatch) {
2168
2188
  const unit = numMatch[2] ?? "px";
2169
- return { boxShadow: `0 0 0 ${numMatch[1]}${unit} ${DEFAULT_COLOR}` };
2189
+ if (onWeb) return { boxShadow: `0 0 0 ${numMatch[1]}${unit} ${DEFAULT_COLOR}` };
2190
+ return unit === "px" ? asNative(parseFloat(numMatch[1]), DEFAULT_COLOR) : null;
2170
2191
  }
2171
- return { boxShadow: value.replace(/_/g, " ") };
2192
+ return onWeb ? { boxShadow: value.replace(/_/g, " ") } : null;
2172
2193
  }
2173
2194
  const color = resolveColor(value, colors, false);
2174
- if (color) return { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${color}` };
2195
+ if (color) {
2196
+ return onWeb ? { boxShadow: `0 0 0 ${DEFAULT_WIDTH}px ${color}` } : asNative(DEFAULT_WIDTH, color);
2197
+ }
2175
2198
  return null;
2176
2199
  },
2200
+ // Web-only, unlike `ring` above: this stacks a second box-shadow layer to
2201
+ // create a gap between the element and the ring. There's no native
2202
+ // equivalent to approximate that with (a border can't create a gap outside
2203
+ // its own element without an extra wrapper view), so this stays a no-op
2204
+ // on native rather than rendering a misleading half-translation.
2177
2205
  "ring-offset": ({ value, isArbitrary }, _theme) => {
2178
2206
  if (!getEffectiveIsWeb()) return null;
2179
2207
  const DEFAULT_RING_COLOR = "rgba(59, 130, 246, 0.5)";