@hzblj/kara-sx 0.0.0-stage → 0.2.0

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.
Files changed (79) hide show
  1. package/README.md +7 -2
  2. package/lib/module/flatten-sx.js +59 -0
  3. package/lib/module/flatten-sx.js.map +1 -0
  4. package/lib/module/index.js +19 -0
  5. package/lib/module/index.js.map +1 -0
  6. package/lib/module/package.json +1 -0
  7. package/lib/module/resolve-cache.js +79 -0
  8. package/lib/module/resolve-cache.js.map +1 -0
  9. package/lib/module/responsive-sx.js +66 -0
  10. package/lib/module/responsive-sx.js.map +1 -0
  11. package/lib/module/responsive.types.js +4 -0
  12. package/lib/module/responsive.types.js.map +1 -0
  13. package/lib/module/sx-key.js +25 -0
  14. package/lib/module/sx-key.js.map +1 -0
  15. package/lib/module/sx-keys.js +46 -0
  16. package/lib/module/sx-keys.js.map +1 -0
  17. package/lib/module/sx-padding.js +26 -0
  18. package/lib/module/sx-padding.js.map +1 -0
  19. package/lib/module/sx-to-modifiers.android.js +168 -0
  20. package/lib/module/sx-to-modifiers.android.js.map +1 -0
  21. package/lib/module/sx-to-modifiers.ios.js +266 -0
  22. package/lib/module/sx-to-modifiers.ios.js.map +1 -0
  23. package/lib/module/sx-to-style.js +137 -0
  24. package/lib/module/sx-to-style.js.map +1 -0
  25. package/lib/module/sx.types.js +4 -0
  26. package/lib/module/sx.types.js.map +1 -0
  27. package/lib/module/use-sx-modifiers.android.js +30 -0
  28. package/lib/module/use-sx-modifiers.android.js.map +1 -0
  29. package/lib/module/use-sx-modifiers.ios.js +30 -0
  30. package/lib/module/use-sx-modifiers.ios.js.map +1 -0
  31. package/lib/module/use-sx.js +30 -0
  32. package/lib/module/use-sx.js.map +1 -0
  33. package/lib/module/value-key.js +16 -0
  34. package/lib/module/value-key.js.map +1 -0
  35. package/lib/module/view-sx-to-style.js +159 -0
  36. package/lib/module/view-sx-to-style.js.map +1 -0
  37. package/lib/module/view-sx.types.js +4 -0
  38. package/lib/module/view-sx.types.js.map +1 -0
  39. package/lib/module/with-sx.js +50 -0
  40. package/lib/module/with-sx.js.map +1 -0
  41. package/lib/typescript/flatten-sx.d.ts +15 -0
  42. package/lib/typescript/index.d.ts +16 -0
  43. package/lib/typescript/resolve-cache.d.ts +14 -0
  44. package/lib/typescript/responsive-sx.d.ts +80 -0
  45. package/lib/typescript/responsive.types.d.ts +23 -0
  46. package/lib/typescript/sx-key.d.ts +7 -0
  47. package/lib/typescript/sx-keys.d.ts +6 -0
  48. package/lib/typescript/sx-padding.d.ts +5 -0
  49. package/lib/typescript/sx-to-modifiers.d.ts +1 -0
  50. package/lib/typescript/sx-to-modifiers.ios.d.ts +4 -0
  51. package/lib/typescript/sx-to-style.d.ts +4 -0
  52. package/lib/typescript/sx.types.d.ts +182 -0
  53. package/lib/typescript/use-sx-modifiers.d.ts +1 -0
  54. package/lib/typescript/use-sx-modifiers.ios.d.ts +14 -0
  55. package/lib/typescript/use-sx.d.ts +15 -0
  56. package/lib/typescript/value-key.d.ts +7 -0
  57. package/lib/typescript/view-sx-to-style.d.ts +17 -0
  58. package/lib/typescript/view-sx.types.d.ts +80 -0
  59. package/lib/typescript/with-sx.d.ts +28 -0
  60. package/package.json +78 -4
  61. package/src/flatten-sx.ts +70 -0
  62. package/src/index.ts +16 -0
  63. package/src/resolve-cache.ts +105 -0
  64. package/src/responsive-sx.ts +75 -0
  65. package/src/responsive.types.ts +23 -0
  66. package/src/sx-key.ts +32 -0
  67. package/src/sx-keys.ts +47 -0
  68. package/src/sx-padding.ts +27 -0
  69. package/src/sx-to-modifiers.android.ts +224 -0
  70. package/src/sx-to-modifiers.ios.ts +322 -0
  71. package/src/sx-to-style.ts +142 -0
  72. package/src/sx.types.ts +215 -0
  73. package/src/use-sx-modifiers.android.ts +29 -0
  74. package/src/use-sx-modifiers.ios.ts +29 -0
  75. package/src/use-sx.ts +34 -0
  76. package/src/value-key.ts +18 -0
  77. package/src/view-sx-to-style.ts +180 -0
  78. package/src/view-sx.types.ts +87 -0
  79. package/src/with-sx.tsx +51 -0
@@ -0,0 +1,215 @@
1
+ import type {ModifierConfig as AndroidModifier} from '@expo/ui/jetpack-compose/modifiers'
2
+ import type {ModifierConfig as IosModifier} from '@expo/ui/swift-ui/modifiers'
3
+ import type {ColorToken, OpacitySize, RadiiSize, ShadowName, SpaceSize, ZIndexSize} from '@hzblj/kara-theme'
4
+
5
+ /** A length React Native accepts: absolute points, or a percentage of the parent. */
6
+ export type SxDimension = `${number}%` | number
7
+
8
+ /**
9
+ * The keys all three targets — SwiftUI modifiers, Compose modifiers and React Native styles — can
10
+ * express with the same meaning. A key only belongs here if all three honour it; anything one of them
11
+ * has to fake or ignore goes to `IosSx`, `AndroidSx` or `ViewSx` instead.
12
+ */
13
+ export type SxCore = {
14
+ bg?: ColorToken
15
+ blur?: number
16
+ borderColor?: ColorToken
17
+ borderRadius?: RadiiSize
18
+ borderWidth?: number
19
+ h?: number
20
+ minH?: number
21
+ minW?: number
22
+ offsetX?: number
23
+ offsetY?: number
24
+ opacity?: OpacitySize
25
+ p?: SpaceSize
26
+ pb?: SpaceSize
27
+ pl?: SpaceSize
28
+ pr?: SpaceSize
29
+ pt?: SpaceSize
30
+ px?: SpaceSize
31
+ py?: SpaceSize
32
+ /** Clockwise, in degrees. */
33
+ rotate?: number
34
+ /** Uniform, or per axis — an omitted axis stays at 1. */
35
+ scale?: number | {x?: number; y?: number}
36
+ shadow?: ShadowName
37
+ tall?: boolean
38
+ w?: number
39
+ wide?: boolean
40
+ zIndex?: ZIndexSize
41
+ }
42
+
43
+ /** Liquid Glass, as SwiftUI's `glassEffect` names its variants. */
44
+ export type GlassVariant = 'clear' | 'identity' | 'regular'
45
+
46
+ /**
47
+ * Every declared value in a platform bucket is a primitive — or one flat object of primitives, for
48
+ * modifiers that take several settings at once — because `sxKey` identifies an `sx` by flattening
49
+ * its values into a string, one level deep. Anything deeper would collapse to `[object Object]` and
50
+ * two different configs would share a cache entry. `modifiers` is the sole exception, keyed by
51
+ * identity.
52
+ */
53
+ export type SxBucketValue = boolean | number | string | undefined
54
+
55
+ /** One modifier's settings: a single flat level, so `sxKey`'s one-level walk captures all of it. */
56
+ export type SxBucketConfig = {[setting: string]: SxBucketValue}
57
+
58
+ /** Holds a bucket to `SxBucketValue`/`SxBucketConfig`, so a key breaking that rule refuses to compile. */
59
+ type Bucket<T extends {[K in keyof T]: K extends 'modifiers' ? T[K] : SxBucketConfig | SxBucketValue}> = T
60
+
61
+ /** What gets redacted: skeleton placeholder, privacy blur, or the "pending update" restyle. */
62
+ export type RedactionReason = 'invalidated' | 'placeholder' | 'privacy'
63
+
64
+ /**
65
+ * SwiftUI modifiers with no honest Compose counterpart. Setting these on Android is inert rather than
66
+ * a type error — a design system degrades gracefully instead of forcing every call site to branch.
67
+ */
68
+ export type IosSx = Bucket<{
69
+ /** Width over height, fitted within the proposed size. The escape hatch covers `fill`. */
70
+ aspectRatio?: number
71
+ brightness?: number
72
+ clipped?: boolean
73
+ colorInvert?: boolean
74
+ /**
75
+ * Sizes the box relative to the nearest container: split it into `count` columns `spacing` apart
76
+ * and span `span` of them — SwiftUI's stand-in for a fractional width, which its `frame` lacks.
77
+ */
78
+ containerRelativeFrame?: {
79
+ alignment?: FrameAlignment
80
+ axes: 'both' | 'horizontal' | 'vertical'
81
+ count?: number
82
+ span?: number
83
+ spacing?: number
84
+ }
85
+ /** How a content change animates when one is driven natively. `countsDown` is `numericText`'s. */
86
+ contentTransition?: {
87
+ countsDown?: boolean
88
+ type: 'identity' | 'interpolate' | 'numericText' | 'opacity'
89
+ }
90
+ /** Cascades to every control in the subtree, the SwiftUI environment way. */
91
+ controlSize?: 'extraLarge' | 'large' | 'mini' | 'regular' | 'small'
92
+ contrast?: number
93
+ fixedSize?: 'both' | 'horizontal' | 'vertical'
94
+ /**
95
+ * Liquid Glass: a bare variant, or the full config. `tint` takes a theme token, and without a
96
+ * `shape` the glass traces the box's own `borderRadius`, the way strokes and shadows do.
97
+ */
98
+ glass?:
99
+ | GlassVariant
100
+ | {
101
+ cornerRadius?: number
102
+ interactive?: boolean
103
+ shape?: 'capsule' | 'circle' | 'containerRelativeShape' | 'ellipse' | 'rectangle' | 'roundedRectangle'
104
+ tint?: ColorToken
105
+ variant: GlassVariant
106
+ }
107
+ grayscale?: number
108
+ /** Invisible, but still taking up its layout space — unlike not rendering the box at all. */
109
+ hidden?: boolean
110
+ /** In degrees. */
111
+ hueRotation?: number
112
+ /** The height SwiftUI prefers when nothing constrains it — what `fixedSize` resolves to. */
113
+ idealH?: number
114
+ /** The width SwiftUI prefers when nothing constrains it — what `fixedSize` resolves to. */
115
+ idealW?: number
116
+ /** `true` extends into every safe area; the config narrows which regions and edges. */
117
+ ignoreSafeArea?:
118
+ | boolean
119
+ | {
120
+ edges?: 'all' | 'bottom' | 'horizontal' | 'leading' | 'top' | 'trailing' | 'vertical'
121
+ regions?: 'all' | 'container' | 'keyboard'
122
+ }
123
+ /** Who gets space first when the parent runs short — *not* a proportional flex weight. */
124
+ layoutPriority?: number
125
+ luminanceToAlpha?: boolean
126
+ /** Escape hatch, appended last. Pass a stable array — a fresh one every render defeats the memo. */
127
+ modifiers?: IosModifier[]
128
+ /** Redacts only when an ancestor applies the `privacy` reason — inert on its own. */
129
+ privacySensitive?: boolean
130
+ /**
131
+ * Redacts this subtree — `placeholder` is SwiftUI's built-in skeleton. One reason here;
132
+ * combining several is escape-hatch territory.
133
+ */
134
+ redacted?: RedactionReason
135
+ saturation?: number
136
+ /** Cascades to the controls and icons in the subtree. */
137
+ tint?: ColorToken
138
+ /** Exempts this subtree from an ancestor's `redacted`. */
139
+ unredacted?: boolean
140
+ }>
141
+
142
+ /** Compose modifiers with no honest SwiftUI counterpart. Inert on iOS, by the same argument. */
143
+ export type AndroidSx = Bucket<{
144
+ /** This box's own placement inside a `ZStack` parent — Compose's per-child `Modifier.align`. */
145
+ align?:
146
+ | 'bottom'
147
+ | 'bottomCenter'
148
+ | 'bottomEnd'
149
+ | 'bottomStart'
150
+ | 'center'
151
+ | 'centerEnd'
152
+ | 'centerHorizontally'
153
+ | 'centerStart'
154
+ | 'centerVertically'
155
+ | 'end'
156
+ | 'start'
157
+ | 'top'
158
+ | 'topCenter'
159
+ | 'topEnd'
160
+ | 'topStart'
161
+ animateContentSize?: boolean
162
+ /** Fraction of the parent's height (0–1). The whole of it is `tall`, which works on iOS too. */
163
+ fillMaxHeight?: number
164
+ /** Fraction of the parent's width (0–1). The whole of it is `wide`, which works on iOS too. */
165
+ fillMaxWidth?: number
166
+ imePadding?: boolean
167
+ matchParentSize?: boolean
168
+ /** Escape hatch, appended last. Pass a stable array — a fresh one every render defeats the memo. */
169
+ modifiers?: AndroidModifier[]
170
+ /** Proportional share of the leftover space in a Row or Column, the way `flexGrow` behaves. */
171
+ weight?: number
172
+ wrapContentHeight?: 'bottom' | 'centerVertically' | 'top' | boolean
173
+ wrapContentWidth?: 'centerHorizontally' | 'end' | 'start' | boolean
174
+ }>
175
+
176
+ /**
177
+ * The native modifier surface: what SwiftUI and Compose can both express, plus a bucket each for what
178
+ * only one of them can. Both buckets exist on both platforms so a component's shared `*.types.ts`
179
+ * contract — and the `contract.check.ts` that holds the two implementations to it — stays one type.
180
+ */
181
+ export type Sx = SxCore & {
182
+ android?: AndroidSx
183
+ ios?: IosSx
184
+ }
185
+
186
+ /**
187
+ * `SxCore` for the React Native style path, which — unlike the SwiftUI/Compose modifiers, where a
188
+ * frame needs concrete points — can size a view as a percentage of its parent. Deliberately built
189
+ * from `SxCore` and not `Sx`: React Native styles are identical on both platforms, so the `ios` and
190
+ * `android` buckets would have nothing to resolve into and would silently swallow whatever was passed.
191
+ */
192
+ export type StyleSx = Omit<SxCore, 'h' | 'minH' | 'minW' | 'w'> & {
193
+ h?: SxDimension
194
+ minH?: SxDimension
195
+ minW?: SxDimension
196
+ w?: SxDimension
197
+ }
198
+
199
+ export type FrameAlignment =
200
+ | 'bottom'
201
+ | 'bottomLeading'
202
+ | 'bottomTrailing'
203
+ | 'center'
204
+ | 'leading'
205
+ | 'top'
206
+ | 'topLeading'
207
+ | 'topTrailing'
208
+ | 'trailing'
209
+
210
+ export type SxPadding = {
211
+ bottom?: number
212
+ leading?: number
213
+ top?: number
214
+ trailing?: number
215
+ }
@@ -0,0 +1,29 @@
1
+ import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers'
2
+ import {useAppTheme, useBreakpoint} from '@hzblj/kara-theme'
3
+ import {useMemo} from 'react'
4
+ import {flattenSx} from './flatten-sx'
5
+ import {type ResponsiveSx, type ResponsiveSxKey, SX_RESPONSIVE_KEYS} from './responsive-sx'
6
+ import type {FrameAlignment, Sx} from './sx.types'
7
+ import {sxKey} from './sx-key'
8
+ import {sxToModifiers} from './sx-to-modifiers'
9
+
10
+ /**
11
+ * `sxToModifiers` behind a `useMemo`, so a re-render hands the native view the modifier array it is
12
+ * already holding instead of an equal-but-new one to diff.
13
+ *
14
+ * The responsive flattening happens before `sxKey`, so the key describes the values actually applied
15
+ * at this breakpoint — a rotation to a wider window changes it, and nothing else does.
16
+ *
17
+ * `android.modifiers` is depended on by identity, because `sxKey` cannot flatten opaque modifier
18
+ * configs into a string. A call site that builds that array inline therefore re-resolves every render.
19
+ */
20
+ export const useSxModifiers = (sx: ResponsiveSx, alignment?: FrameAlignment): ModifierConfig[] => {
21
+ const theme = useAppTheme()
22
+ const breakpoint = useBreakpoint()
23
+ const flat = flattenSx<Sx, ResponsiveSxKey>(sx, SX_RESPONSIVE_KEYS, breakpoint)
24
+ const key = sxKey(flat)
25
+ const escapeHatch = sx.android?.modifiers
26
+
27
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `key` stands in for `flat` — see `sxKey`
28
+ return useMemo(() => sxToModifiers(flat, theme, alignment), [alignment, escapeHatch, key, theme])
29
+ }
@@ -0,0 +1,29 @@
1
+ import type {ModifierConfig} from '@expo/ui/swift-ui/modifiers'
2
+ import {useAppTheme, useBreakpoint} from '@hzblj/kara-theme'
3
+ import {useMemo} from 'react'
4
+ import {flattenSx} from './flatten-sx'
5
+ import {type ResponsiveSx, type ResponsiveSxKey, SX_RESPONSIVE_KEYS} from './responsive-sx'
6
+ import type {FrameAlignment, Sx} from './sx.types'
7
+ import {sxKey} from './sx-key'
8
+ import {sxToModifiers} from './sx-to-modifiers'
9
+
10
+ /**
11
+ * `sxToModifiers` behind a `useMemo`, so a re-render hands the native view the modifier array it is
12
+ * already holding instead of an equal-but-new one to diff.
13
+ *
14
+ * The responsive flattening happens before `sxKey`, so the key describes the values actually applied
15
+ * at this breakpoint — a rotation to a wider window changes it, and nothing else does.
16
+ *
17
+ * `ios.modifiers` is depended on by identity, because `sxKey` cannot flatten opaque modifier configs
18
+ * into a string. A call site that builds that array inline therefore re-resolves every render.
19
+ */
20
+ export const useSxModifiers = (sx: ResponsiveSx, alignment?: FrameAlignment): ModifierConfig[] => {
21
+ const theme = useAppTheme()
22
+ const breakpoint = useBreakpoint()
23
+ const flat = flattenSx<Sx, ResponsiveSxKey>(sx, SX_RESPONSIVE_KEYS, breakpoint)
24
+ const key = sxKey(flat)
25
+ const escapeHatch = sx.ios?.modifiers
26
+
27
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `key` stands in for `flat` — see `sxKey`
28
+ return useMemo(() => sxToModifiers(flat, theme, alignment), [alignment, escapeHatch, key, theme])
29
+ }
package/src/use-sx.ts ADDED
@@ -0,0 +1,34 @@
1
+ import {useAppTheme, useBreakpoint} from '@hzblj/kara-theme'
2
+ import type {ViewStyle} from 'react-native'
3
+
4
+ import {flattenSx} from './flatten-sx'
5
+ import {type ResponsiveViewSx, type ResponsiveViewSxKey, VIEW_RESPONSIVE_KEYS} from './responsive-sx'
6
+ import type {ViewSx} from './view-sx.types'
7
+ import {viewSxCache} from './view-sx-to-style'
8
+
9
+ /**
10
+ * Resolves an `sx` object into a React Native style. Needs no `useMemo`: the cache keys on the
11
+ * flattened sx values, so the same sx at the same breakpoint hands back the very same object every
12
+ * render — and across components.
13
+ */
14
+ export const useSx = (sx: ResponsiveViewSx): ViewStyle => {
15
+ const theme = useAppTheme()
16
+ const breakpoint = useBreakpoint()
17
+
18
+ return viewSxCache.resolve(flattenSx<ViewSx, ResponsiveViewSxKey>(sx, VIEW_RESPONSIVE_KEYS, breakpoint), theme)
19
+ }
20
+
21
+ /**
22
+ * `useSx` for a component whose own props arrive mixed in with the `sx` ones: the `sx` keys are split
23
+ * off and resolved, and the rest is handed back untouched. What `sx()` does internally, exposed for the
24
+ * components that cannot be built on it because they own their `style` prop — an animated one, or a
25
+ * `ScrollView` styling two boxes at once.
26
+ */
27
+ export const useSplitSx = <TProps extends Record<string, unknown>>(
28
+ props: TProps
29
+ ): [ViewStyle, Record<string, unknown>] => {
30
+ const [own, rest] = viewSxCache.split(props)
31
+ const style = useSx(own)
32
+
33
+ return [style, rest]
34
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Flattens a value into a cache-key fragment. Objects are walked one level — with keys sorted, so
3
+ * two configs holding the same values key alike — rather than left to `String`, which turns every
4
+ * object into the same `[object Object]` and would have two different ones share a cache entry.
5
+ * One level is all the `Bucket` constraint admits, so deeper nesting cannot reach this.
6
+ */
7
+ export const valueKey = (value: unknown): string => {
8
+ if (typeof value !== 'object' || value === null) {
9
+ return String(value)
10
+ }
11
+
12
+ const entries = value as Record<string, unknown>
13
+
14
+ return `{${Object.keys(entries)
15
+ .sort()
16
+ .map(name => `${name}:${String(entries[name])}`)
17
+ .join(',')}}`
18
+ }
@@ -0,0 +1,180 @@
1
+ import {radii, spacing, type Theme} from '@hzblj/kara-theme'
2
+ import type {ViewStyle} from 'react-native'
3
+
4
+ import {createSxCache} from './resolve-cache'
5
+ import type {ResponsiveViewSx} from './responsive-sx'
6
+ import type {StyleSx} from './sx.types'
7
+ import {SX_CORE_KEYS} from './sx-keys'
8
+ import {sxToStyle} from './sx-to-style'
9
+ import type {ViewRadiiSx, ViewSx} from './view-sx.types'
10
+
11
+ /**
12
+ * `Record`s rather than bare arrays, for the same reason as `SX_CORE_KEY_RECORD`: `satisfies` on an
13
+ * array only checks membership, while these refuse to compile when a key is added to `ViewSx` but
14
+ * missing here — which `split` would then leak into the wrapped component's own props.
15
+ */
16
+ const CORNER_KEY_RECORD = {
17
+ borderBottomLeftRadius: true,
18
+ borderBottomRadius: true,
19
+ borderBottomRightRadius: true,
20
+ borderLeftRadius: true,
21
+ borderRightRadius: true,
22
+ borderTopLeftRadius: true,
23
+ borderTopRadius: true,
24
+ borderTopRightRadius: true,
25
+ } as const satisfies Record<keyof ViewRadiiSx, true>
26
+
27
+ const CORNER_KEYS = Object.keys(CORNER_KEY_RECORD) as readonly (keyof ViewRadiiSx)[]
28
+
29
+ type ViewOnlyKey = Exclude<keyof ViewSx, keyof StyleSx>
30
+
31
+ const VIEW_ONLY_KEY_RECORD = {
32
+ ...CORNER_KEY_RECORD,
33
+ align: true,
34
+ alignContent: true,
35
+ alignSelf: true,
36
+ aspectRatio: true,
37
+ basis: true,
38
+ borderBottomWidth: true,
39
+ borderLeftWidth: true,
40
+ borderRightWidth: true,
41
+ borderStyle: true,
42
+ borderTopWidth: true,
43
+ bottom: true,
44
+ columnGap: true,
45
+ display: true,
46
+ flex: true,
47
+ flexDirection: true,
48
+ gap: true,
49
+ grow: true,
50
+ justify: true,
51
+ left: true,
52
+ m: true,
53
+ maxH: true,
54
+ maxW: true,
55
+ mb: true,
56
+ ml: true,
57
+ mr: true,
58
+ mt: true,
59
+ mx: true,
60
+ my: true,
61
+ overflow: true,
62
+ position: true,
63
+ right: true,
64
+ rowGap: true,
65
+ shrink: true,
66
+ top: true,
67
+ wrap: true,
68
+ } as const satisfies Record<ViewOnlyKey, true>
69
+
70
+ const VIEW_ONLY_KEYS = Object.keys(VIEW_ONLY_KEY_RECORD) as readonly ViewOnlyKey[]
71
+
72
+ /**
73
+ * Every key the RN style engine owns — what `sx()` and `useSx` split off a component's props. Built
74
+ * from `SX_CORE_KEYS` rather than `SX_KEYS` on purpose: including the `ios`/`android` buckets would
75
+ * have `Box` strip them off its props and drop them, since a React Native style has nothing to
76
+ * resolve them into.
77
+ */
78
+ export const VIEW_SX_KEYS: readonly (keyof ViewSx & string)[] = [...SX_CORE_KEYS, ...VIEW_ONLY_KEYS]
79
+
80
+ type SpaceKey = NonNullable<ViewSx['m']>
81
+
82
+ type RadiiToken = NonNullable<ViewSx['borderRadius']>
83
+
84
+ const space = (token: SpaceKey | undefined) => (token === undefined ? undefined : spacing[token])
85
+
86
+ const radius = (token: RadiiToken | undefined) => (token === undefined ? undefined : radii[token])
87
+
88
+ /**
89
+ * Left to `sxToStyle`'s single `borderRadius` unless a corner or edge prop is present —
90
+ * emitting per-corner keys unconditionally would shadow it with `undefined`.
91
+ */
92
+ const cornerRadiiStyle = (sx: ViewSx): ViewStyle => {
93
+ if (!CORNER_KEYS.some(key => sx[key] !== undefined)) {
94
+ return {}
95
+ }
96
+
97
+ const all = radius(sx.borderRadius)
98
+ const bottom = radius(sx.borderBottomRadius)
99
+ const left = radius(sx.borderLeftRadius)
100
+ const right = radius(sx.borderRightRadius)
101
+ const top = radius(sx.borderTopRadius)
102
+
103
+ return {
104
+ borderBottomLeftRadius: radius(sx.borderBottomLeftRadius) ?? bottom ?? left ?? all,
105
+ borderBottomRightRadius: radius(sx.borderBottomRightRadius) ?? bottom ?? right ?? all,
106
+ borderTopLeftRadius: radius(sx.borderTopLeftRadius) ?? top ?? left ?? all,
107
+ borderTopRightRadius: radius(sx.borderTopRightRadius) ?? top ?? right ?? all,
108
+ }
109
+ }
110
+
111
+ const marginStyle = (sx: ViewSx): ViewStyle => {
112
+ const all = space(sx.m)
113
+ const x = space(sx.mx)
114
+ const y = space(sx.my)
115
+
116
+ return {
117
+ marginBottom: space(sx.mb) ?? y ?? all,
118
+ marginLeft: space(sx.ml) ?? x ?? all,
119
+ marginRight: space(sx.mr) ?? x ?? all,
120
+ marginTop: space(sx.mt) ?? y ?? all,
121
+ }
122
+ }
123
+
124
+ const borderStyle = (sx: ViewSx): ViewStyle => ({
125
+ borderBottomWidth: sx.borderBottomWidth,
126
+ borderLeftWidth: sx.borderLeftWidth,
127
+ borderRightWidth: sx.borderRightWidth,
128
+ borderStyle: sx.borderStyle,
129
+ borderTopWidth: sx.borderTopWidth,
130
+ })
131
+
132
+ /** `minH`/`minW` are `sxToStyle`'s, and naming them here would shadow it with `undefined`. */
133
+ const sizingStyle = (sx: ViewSx): ViewStyle => ({
134
+ aspectRatio: sx.aspectRatio,
135
+ maxHeight: sx.maxH,
136
+ maxWidth: sx.maxW,
137
+ })
138
+
139
+ const flexStyle = (sx: ViewSx): ViewStyle => ({
140
+ alignContent: sx.alignContent,
141
+ alignItems: sx.align,
142
+ alignSelf: sx.alignSelf,
143
+ columnGap: space(sx.columnGap),
144
+ flex: sx.flex,
145
+ flexBasis: sx.basis,
146
+ flexDirection: sx.flexDirection,
147
+ flexGrow: sx.grow,
148
+ flexShrink: sx.shrink,
149
+ flexWrap: sx.wrap,
150
+ gap: space(sx.gap),
151
+ justifyContent: sx.justify,
152
+ rowGap: space(sx.rowGap),
153
+ })
154
+
155
+ /** `zIndex` is `sxToStyle`'s too, for the same reason as `minH`/`minW` above. */
156
+ const layoutStyle = (sx: ViewSx): ViewStyle => ({
157
+ bottom: sx.bottom,
158
+ display: sx.display,
159
+ left: sx.left,
160
+ overflow: sx.overflow,
161
+ position: sx.position,
162
+ right: sx.right,
163
+ top: sx.top,
164
+ })
165
+
166
+ export const viewSxToStyle = (sx: ViewSx, theme: Theme): ViewStyle => ({
167
+ ...sxToStyle(sx, theme),
168
+ ...cornerRadiiStyle(sx),
169
+ ...marginStyle(sx),
170
+ ...borderStyle(sx),
171
+ ...sizingStyle(sx),
172
+ ...flexStyle(sx),
173
+ ...layoutStyle(sx),
174
+ })
175
+
176
+ /**
177
+ * `split` hands back a `ResponsiveViewSx`, because that is what a component's props hold — `resolve`
178
+ * only accepts the flat `ViewSx` that `flattenSx` turns those into, so the two cannot be crossed.
179
+ */
180
+ export const viewSxCache = createSxCache<ViewSx, ViewStyle, ResponsiveViewSx>(VIEW_SX_KEYS, viewSxToStyle)
@@ -0,0 +1,87 @@
1
+ import type {RadiiSize, SpaceSize} from '@hzblj/kara-theme'
2
+ import type {ViewStyle} from 'react-native'
3
+
4
+ import type {StyleSx, SxDimension} from './sx.types'
5
+
6
+ /**
7
+ * Corner radii cascade the way padding does: a single corner beats its edge pair,
8
+ * which beats `borderRadius`. So `borderRadius="medium" borderBottomRadius="none"`
9
+ * rounds the top two corners only.
10
+ *
11
+ * Exported so `CORNER_KEYS` can be held to exactly these keys.
12
+ */
13
+ export type ViewRadiiSx = {
14
+ borderBottomLeftRadius?: RadiiSize
15
+ borderBottomRadius?: RadiiSize
16
+ borderBottomRightRadius?: RadiiSize
17
+ borderLeftRadius?: RadiiSize
18
+ borderRightRadius?: RadiiSize
19
+ borderTopLeftRadius?: RadiiSize
20
+ borderTopRadius?: RadiiSize
21
+ borderTopRightRadius?: RadiiSize
22
+ }
23
+
24
+ /** Per-side widths draw only the edges you name — a one-sided hairline divider needs just one. */
25
+ type ViewBorderSx = {
26
+ borderBottomWidth?: number
27
+ borderLeftWidth?: number
28
+ borderRightWidth?: number
29
+ borderStyle?: ViewStyle['borderStyle']
30
+ borderTopWidth?: number
31
+ }
32
+
33
+ /** `minW`/`minH` live in `StyleSx` — every target can express those, so they are not view-only. */
34
+ type ViewSizingSx = {
35
+ aspectRatio?: number
36
+ maxH?: SxDimension
37
+ maxW?: SxDimension
38
+ }
39
+
40
+ /**
41
+ * `grow` is deliberately React Native only. Compose's `weight` is the same proportional share of the
42
+ * leftover space, but SwiftUI has no per-child weight at all — its nearest move is an infinite `maxWidth`,
43
+ * which splits the space equally and ignores the ratio. So the modifier paths expose it as
44
+ * `android={{weight}}` and `ios={{layoutPriority}}`, which is what each of them actually does.
45
+ */
46
+ type ViewFlexSx = {
47
+ align?: ViewStyle['alignItems']
48
+ alignContent?: ViewStyle['alignContent']
49
+ alignSelf?: ViewStyle['alignSelf']
50
+ basis?: SxDimension
51
+ columnGap?: SpaceSize
52
+ flex?: number
53
+ flexDirection?: ViewStyle['flexDirection']
54
+ gap?: SpaceSize
55
+ grow?: number
56
+ justify?: ViewStyle['justifyContent']
57
+ rowGap?: SpaceSize
58
+ shrink?: number
59
+ wrap?: ViewStyle['flexWrap']
60
+ }
61
+
62
+ /** Margins mirror the padding scale: `m` all round, `mx`/`my` per axis, `mt`/`mr`/`mb`/`ml` per side. */
63
+ type ViewMarginSx = {
64
+ m?: SpaceSize
65
+ mb?: SpaceSize
66
+ ml?: SpaceSize
67
+ mr?: SpaceSize
68
+ mt?: SpaceSize
69
+ mx?: SpaceSize
70
+ my?: SpaceSize
71
+ }
72
+
73
+ type ViewLayoutSx = {
74
+ bottom?: SxDimension
75
+ display?: ViewStyle['display']
76
+ left?: SxDimension
77
+ overflow?: ViewStyle['overflow']
78
+ position?: ViewStyle['position']
79
+ right?: SxDimension
80
+ top?: SxDimension
81
+ }
82
+
83
+ /**
84
+ * The widest `sx` surface: everything a React Native view can be styled with. `Sx` is the subset the
85
+ * SwiftUI and Compose modifier paths can also express, so only this one reaches the RN style engine.
86
+ */
87
+ export type ViewSx = StyleSx & ViewBorderSx & ViewFlexSx & ViewLayoutSx & ViewMarginSx & ViewRadiiSx & ViewSizingSx
@@ -0,0 +1,51 @@
1
+ import {type ComponentPropsWithRef, type ComponentType, type ElementType, memo} from 'react'
2
+ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'
3
+
4
+ import type {ResponsiveViewSx} from './responsive-sx'
5
+ import {useSplitSx} from './use-sx'
6
+
7
+ /**
8
+ * `sx()` resolves a view style; React Native's text and image styles accept those keys too, so this
9
+ * covers every flavour of `style` a wrapped component might declare.
10
+ */
11
+ type SxStyle = StyleProp<TextStyle | ViewStyle>
12
+
13
+ /** The wrapped component's own props — ref included — with the whole `sx` surface added. */
14
+ export type SxProps<TProps> = Omit<TProps, 'style' | keyof ResponsiveViewSx> &
15
+ ResponsiveViewSx & {
16
+ style?: SxStyle
17
+ }
18
+
19
+ const nameOf = (Component: ElementType): string => {
20
+ if (typeof Component === 'string') {
21
+ return Component
22
+ }
23
+
24
+ const {displayName, name} = Component as ComponentType<never>
25
+
26
+ return displayName || name || 'Component'
27
+ }
28
+
29
+ /**
30
+ * Turns any style-taking component into an `sx` one: the `sx` props are split off, resolved against
31
+ * the theme and handed over as `style`. An explicit `style` still wins — it is merged on top.
32
+ *
33
+ * Props come from `ComponentPropsWithRef`, so a `ref` survives the wrapping. The component has to
34
+ * accept a `style`; that cannot be a constraint here, because narrowing `TComponent` past
35
+ * `ElementType` is what breaks the ref inference.
36
+ *
37
+ * Memoised because the point of the cache is a stable style object — without `memo` a parent
38
+ * re-render would walk the child anyway and throw that stability away.
39
+ */
40
+ export const sx = <TComponent extends ElementType>(Component: TComponent) => {
41
+ const Sxed = ({style, ...props}: SxProps<ComponentPropsWithRef<TComponent>>) => {
42
+ const [resolved, rest] = useSplitSx(props as Record<string, unknown>)
43
+ const Styled = Component as ComponentType<Record<string, unknown>>
44
+
45
+ return <Styled {...rest} style={style ? [resolved, style] : resolved} />
46
+ }
47
+
48
+ Sxed.displayName = `sx(${nameOf(Component)})`
49
+
50
+ return memo(Sxed)
51
+ }