@hzblj/kara-sx 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +12 -0
- package/README.md +7 -2
- package/lib/module/flatten-sx.js +59 -0
- package/lib/module/flatten-sx.js.map +1 -0
- package/lib/module/index.js +19 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/resolve-cache.js +79 -0
- package/lib/module/resolve-cache.js.map +1 -0
- package/lib/module/responsive-sx.js +66 -0
- package/lib/module/responsive-sx.js.map +1 -0
- package/lib/module/responsive.types.js +4 -0
- package/lib/module/responsive.types.js.map +1 -0
- package/lib/module/sx-key.js +25 -0
- package/lib/module/sx-key.js.map +1 -0
- package/lib/module/sx-keys.js +46 -0
- package/lib/module/sx-keys.js.map +1 -0
- package/lib/module/sx-padding.js +26 -0
- package/lib/module/sx-padding.js.map +1 -0
- package/lib/module/sx-to-modifiers.android.js +168 -0
- package/lib/module/sx-to-modifiers.android.js.map +1 -0
- package/lib/module/sx-to-modifiers.ios.js +266 -0
- package/lib/module/sx-to-modifiers.ios.js.map +1 -0
- package/lib/module/sx-to-style.js +137 -0
- package/lib/module/sx-to-style.js.map +1 -0
- package/lib/module/sx.types.js +4 -0
- package/lib/module/sx.types.js.map +1 -0
- package/lib/module/use-sx-modifiers.android.js +30 -0
- package/lib/module/use-sx-modifiers.android.js.map +1 -0
- package/lib/module/use-sx-modifiers.ios.js +30 -0
- package/lib/module/use-sx-modifiers.ios.js.map +1 -0
- package/lib/module/use-sx.js +30 -0
- package/lib/module/use-sx.js.map +1 -0
- package/lib/module/value-key.js +16 -0
- package/lib/module/value-key.js.map +1 -0
- package/lib/module/view-sx-to-style.js +159 -0
- package/lib/module/view-sx-to-style.js.map +1 -0
- package/lib/module/view-sx.types.js +4 -0
- package/lib/module/view-sx.types.js.map +1 -0
- package/lib/module/with-sx.js +50 -0
- package/lib/module/with-sx.js.map +1 -0
- package/lib/typescript/flatten-sx.d.ts +15 -0
- package/lib/typescript/index.d.ts +16 -0
- package/lib/typescript/resolve-cache.d.ts +14 -0
- package/lib/typescript/responsive-sx.d.ts +80 -0
- package/lib/typescript/responsive.types.d.ts +23 -0
- package/lib/typescript/sx-key.d.ts +7 -0
- package/lib/typescript/sx-keys.d.ts +6 -0
- package/lib/typescript/sx-padding.d.ts +5 -0
- package/lib/typescript/sx-to-modifiers.d.ts +1 -0
- package/lib/typescript/sx-to-modifiers.ios.d.ts +4 -0
- package/lib/typescript/sx-to-style.d.ts +4 -0
- package/lib/typescript/sx.types.d.ts +182 -0
- package/lib/typescript/use-sx-modifiers.d.ts +1 -0
- package/lib/typescript/use-sx-modifiers.ios.d.ts +14 -0
- package/lib/typescript/use-sx.d.ts +15 -0
- package/lib/typescript/value-key.d.ts +7 -0
- package/lib/typescript/view-sx-to-style.d.ts +17 -0
- package/lib/typescript/view-sx.types.d.ts +80 -0
- package/lib/typescript/with-sx.d.ts +28 -0
- package/package.json +77 -3
- package/src/flatten-sx.ts +70 -0
- package/src/index.ts +16 -0
- package/src/resolve-cache.ts +105 -0
- package/src/responsive-sx.ts +75 -0
- package/src/responsive.types.ts +23 -0
- package/src/sx-key.ts +32 -0
- package/src/sx-keys.ts +47 -0
- package/src/sx-padding.ts +27 -0
- package/src/sx-to-modifiers.android.ts +224 -0
- package/src/sx-to-modifiers.ios.ts +322 -0
- package/src/sx-to-style.ts +142 -0
- package/src/sx.types.ts +215 -0
- package/src/use-sx-modifiers.android.ts +29 -0
- package/src/use-sx-modifiers.ios.ts +29 -0
- package/src/use-sx.ts +34 -0
- package/src/value-key.ts +18 -0
- package/src/view-sx-to-style.ts +180 -0
- package/src/view-sx.types.ts +87 -0
- package/src/with-sx.tsx +51 -0
package/src/sx.types.ts
ADDED
|
@@ -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
|
+
}
|
package/src/value-key.ts
ADDED
|
@@ -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
|
package/src/with-sx.tsx
ADDED
|
@@ -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
|
+
}
|