@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Theme } from '@hzblj/kara-theme';
|
|
2
|
+
import type { ViewStyle } from 'react-native';
|
|
3
|
+
import type { ResponsiveViewSx } from './responsive-sx';
|
|
4
|
+
import type { ViewSx } from './view-sx.types';
|
|
5
|
+
/**
|
|
6
|
+
* Every key the RN style engine owns — what `sx()` and `useSx` split off a component's props. Built
|
|
7
|
+
* from `SX_CORE_KEYS` rather than `SX_KEYS` on purpose: including the `ios`/`android` buckets would
|
|
8
|
+
* have `Box` strip them off its props and drop them, since a React Native style has nothing to
|
|
9
|
+
* resolve them into.
|
|
10
|
+
*/
|
|
11
|
+
export declare const VIEW_SX_KEYS: readonly (keyof ViewSx & string)[];
|
|
12
|
+
export declare const viewSxToStyle: (sx: ViewSx, theme: Theme) => ViewStyle;
|
|
13
|
+
/**
|
|
14
|
+
* `split` hands back a `ResponsiveViewSx`, because that is what a component's props hold — `resolve`
|
|
15
|
+
* only accepts the flat `ViewSx` that `flattenSx` turns those into, so the two cannot be crossed.
|
|
16
|
+
*/
|
|
17
|
+
export declare const viewSxCache: import("./resolve-cache").SxCache<ViewSx, ViewStyle, ResponsiveViewSx>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { RadiiSize, SpaceSize } from '@hzblj/kara-theme';
|
|
2
|
+
import type { ViewStyle } from 'react-native';
|
|
3
|
+
import type { StyleSx, SxDimension } from './sx.types';
|
|
4
|
+
/**
|
|
5
|
+
* Corner radii cascade the way padding does: a single corner beats its edge pair,
|
|
6
|
+
* which beats `borderRadius`. So `borderRadius="medium" borderBottomRadius="none"`
|
|
7
|
+
* rounds the top two corners only.
|
|
8
|
+
*
|
|
9
|
+
* Exported so `CORNER_KEYS` can be held to exactly these keys.
|
|
10
|
+
*/
|
|
11
|
+
export type ViewRadiiSx = {
|
|
12
|
+
borderBottomLeftRadius?: RadiiSize;
|
|
13
|
+
borderBottomRadius?: RadiiSize;
|
|
14
|
+
borderBottomRightRadius?: RadiiSize;
|
|
15
|
+
borderLeftRadius?: RadiiSize;
|
|
16
|
+
borderRightRadius?: RadiiSize;
|
|
17
|
+
borderTopLeftRadius?: RadiiSize;
|
|
18
|
+
borderTopRadius?: RadiiSize;
|
|
19
|
+
borderTopRightRadius?: RadiiSize;
|
|
20
|
+
};
|
|
21
|
+
/** Per-side widths draw only the edges you name — a one-sided hairline divider needs just one. */
|
|
22
|
+
type ViewBorderSx = {
|
|
23
|
+
borderBottomWidth?: number;
|
|
24
|
+
borderLeftWidth?: number;
|
|
25
|
+
borderRightWidth?: number;
|
|
26
|
+
borderStyle?: ViewStyle['borderStyle'];
|
|
27
|
+
borderTopWidth?: number;
|
|
28
|
+
};
|
|
29
|
+
/** `minW`/`minH` live in `StyleSx` — every target can express those, so they are not view-only. */
|
|
30
|
+
type ViewSizingSx = {
|
|
31
|
+
aspectRatio?: number;
|
|
32
|
+
maxH?: SxDimension;
|
|
33
|
+
maxW?: SxDimension;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* `grow` is deliberately React Native only. Compose's `weight` is the same proportional share of the
|
|
37
|
+
* leftover space, but SwiftUI has no per-child weight at all — its nearest move is an infinite `maxWidth`,
|
|
38
|
+
* which splits the space equally and ignores the ratio. So the modifier paths expose it as
|
|
39
|
+
* `android={{weight}}` and `ios={{layoutPriority}}`, which is what each of them actually does.
|
|
40
|
+
*/
|
|
41
|
+
type ViewFlexSx = {
|
|
42
|
+
align?: ViewStyle['alignItems'];
|
|
43
|
+
alignContent?: ViewStyle['alignContent'];
|
|
44
|
+
alignSelf?: ViewStyle['alignSelf'];
|
|
45
|
+
basis?: SxDimension;
|
|
46
|
+
columnGap?: SpaceSize;
|
|
47
|
+
flex?: number;
|
|
48
|
+
flexDirection?: ViewStyle['flexDirection'];
|
|
49
|
+
gap?: SpaceSize;
|
|
50
|
+
grow?: number;
|
|
51
|
+
justify?: ViewStyle['justifyContent'];
|
|
52
|
+
rowGap?: SpaceSize;
|
|
53
|
+
shrink?: number;
|
|
54
|
+
wrap?: ViewStyle['flexWrap'];
|
|
55
|
+
};
|
|
56
|
+
/** Margins mirror the padding scale: `m` all round, `mx`/`my` per axis, `mt`/`mr`/`mb`/`ml` per side. */
|
|
57
|
+
type ViewMarginSx = {
|
|
58
|
+
m?: SpaceSize;
|
|
59
|
+
mb?: SpaceSize;
|
|
60
|
+
ml?: SpaceSize;
|
|
61
|
+
mr?: SpaceSize;
|
|
62
|
+
mt?: SpaceSize;
|
|
63
|
+
mx?: SpaceSize;
|
|
64
|
+
my?: SpaceSize;
|
|
65
|
+
};
|
|
66
|
+
type ViewLayoutSx = {
|
|
67
|
+
bottom?: SxDimension;
|
|
68
|
+
display?: ViewStyle['display'];
|
|
69
|
+
left?: SxDimension;
|
|
70
|
+
overflow?: ViewStyle['overflow'];
|
|
71
|
+
position?: ViewStyle['position'];
|
|
72
|
+
right?: SxDimension;
|
|
73
|
+
top?: SxDimension;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The widest `sx` surface: everything a React Native view can be styled with. `Sx` is the subset the
|
|
77
|
+
* SwiftUI and Compose modifier paths can also express, so only this one reaches the RN style engine.
|
|
78
|
+
*/
|
|
79
|
+
export type ViewSx = StyleSx & ViewBorderSx & ViewFlexSx & ViewLayoutSx & ViewMarginSx & ViewRadiiSx & ViewSizingSx;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ElementType } from 'react';
|
|
2
|
+
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
|
|
3
|
+
import type { ResponsiveViewSx } from './responsive-sx';
|
|
4
|
+
/**
|
|
5
|
+
* `sx()` resolves a view style; React Native's text and image styles accept those keys too, so this
|
|
6
|
+
* covers every flavour of `style` a wrapped component might declare.
|
|
7
|
+
*/
|
|
8
|
+
type SxStyle = StyleProp<TextStyle | ViewStyle>;
|
|
9
|
+
/** The wrapped component's own props — ref included — with the whole `sx` surface added. */
|
|
10
|
+
export type SxProps<TProps> = Omit<TProps, 'style' | keyof ResponsiveViewSx> & ResponsiveViewSx & {
|
|
11
|
+
style?: SxStyle;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Turns any style-taking component into an `sx` one: the `sx` props are split off, resolved against
|
|
15
|
+
* the theme and handed over as `style`. An explicit `style` still wins — it is merged on top.
|
|
16
|
+
*
|
|
17
|
+
* Props come from `ComponentPropsWithRef`, so a `ref` survives the wrapping. The component has to
|
|
18
|
+
* accept a `style`; that cannot be a constraint here, because narrowing `TComponent` past
|
|
19
|
+
* `ElementType` is what breaks the ref inference.
|
|
20
|
+
*
|
|
21
|
+
* Memoised because the point of the cache is a stable style object — without `memo` a parent
|
|
22
|
+
* re-render would walk the child anyway and throw that stability away.
|
|
23
|
+
*/
|
|
24
|
+
export declare const sx: <TComponent extends ElementType>(Component: TComponent) => import("react").MemoExoticComponent<{
|
|
25
|
+
({ style, ...props }: SxProps<ComponentPropsWithRef<TComponent>>): import("react").JSX.Element;
|
|
26
|
+
displayName: string;
|
|
27
|
+
}>;
|
|
28
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hzblj/kara-sx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The token-typed sx styling engine behind @hzblj/kara-ui: one surface resolved into SwiftUI modifiers, Compose modifiers or a React Native style.",
|
|
5
|
+
"author": "Jan Blazej",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/hzblj/kara-ui.git",
|
|
10
|
+
"directory": "packages/sx"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/hzblj/kara-ui/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/hzblj/kara-ui/tree/main/packages/sx#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"expo",
|
|
19
|
+
"kara-ui"
|
|
20
|
+
],
|
|
21
|
+
"main": "./lib/module/index.js",
|
|
22
|
+
"types": "./lib/typescript/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"source": "./src/index.ts",
|
|
26
|
+
"types": "./lib/typescript/index.d.ts",
|
|
27
|
+
"default": "./lib/module/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"lib",
|
|
34
|
+
"!**/*.stories.tsx",
|
|
35
|
+
"!**/contract.check.ts",
|
|
36
|
+
"!**/story-decorators.tsx",
|
|
37
|
+
"!**/__tests__"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "bob build && tsc -p ./tsconfig.build.json && node ../../scripts/fallback-declarations.mjs",
|
|
41
|
+
"clean": "rm -rf lib",
|
|
42
|
+
"prepack": "yarn run -T turbo run build --filter=$npm_package_name...",
|
|
43
|
+
"lint": "yarn run -T concurrently \"yarn biome:check\" \"yarn tsc:check\"",
|
|
44
|
+
"lint:fix": "yarn run -T biome check --write .",
|
|
45
|
+
"biome:check": "yarn run -T biome check .",
|
|
46
|
+
"tsc:check": "yarn run -T concurrently \"tsc -p ./tsconfig.ios.json\" \"tsc -p ./tsconfig.android.json\""
|
|
47
|
+
},
|
|
48
|
+
"react-native-builder-bob": {
|
|
49
|
+
"source": "src",
|
|
50
|
+
"output": "lib",
|
|
51
|
+
"exclude": "{**/__tests__/**,**/__fixtures__/**,**/__mocks__/**,**/*.stories.tsx,**/contract.check.ts,**/story-decorators.tsx}",
|
|
52
|
+
"targets": [
|
|
53
|
+
[
|
|
54
|
+
"module",
|
|
55
|
+
{
|
|
56
|
+
"esm": true
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@hzblj/kara-theme": "^0.1.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"@expo/ui": "~57.0.0",
|
|
66
|
+
"react": "*",
|
|
67
|
+
"react-native": "*"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@expo/ui": "~57.0.18",
|
|
71
|
+
"@types/react": "~19.2.18",
|
|
72
|
+
"react": "19.2.3",
|
|
73
|
+
"react-native": "0.86.3",
|
|
74
|
+
"react-native-builder-bob": "^0.43.0",
|
|
75
|
+
"typescript": "~7.0.2"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
}
|
|
6
80
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {type ActiveBreakpoint, BREAKPOINT_ORDER, type Breakpoint} from '@hzblj/kara-theme'
|
|
2
|
+
|
|
3
|
+
import type {ResponsiveOf} from './responsive.types'
|
|
4
|
+
|
|
5
|
+
/** The runtime shape of a `Responsive` object — what `isResponsive` narrows an `sx` value to. */
|
|
6
|
+
type ResponsiveValue = {base: unknown} & Partial<Record<Breakpoint, unknown>>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* No responsive-capable key takes an object value — each one is a token, a dimension, a number or a
|
|
10
|
+
* boolean — so `base` being present identifies a per-breakpoint value outright, with nothing it could
|
|
11
|
+
* be confused for. That is exactly why the visual keys stay scalar: `scale` and the platform buckets
|
|
12
|
+
* *are* objects, and admitting them would make this test ambiguous.
|
|
13
|
+
*/
|
|
14
|
+
const isResponsive = (value: unknown): value is ResponsiveValue =>
|
|
15
|
+
typeof value === 'object' && value !== null && 'base' in value
|
|
16
|
+
|
|
17
|
+
/** Mobile-first: the widest breakpoint declared at or below the active one, falling back to `base`. */
|
|
18
|
+
const valueAt = (value: ResponsiveValue, active: ActiveBreakpoint): unknown => {
|
|
19
|
+
let resolved = value.base
|
|
20
|
+
|
|
21
|
+
if (active === 'base') {
|
|
22
|
+
return resolved
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const name of BREAKPOINT_ORDER) {
|
|
26
|
+
const declared = value[name]
|
|
27
|
+
|
|
28
|
+
if (declared !== undefined) {
|
|
29
|
+
resolved = declared
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (name === active) {
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return resolved
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Collapses a responsive `sx` into the flat one the active breakpoint asks for.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately upstream of the caches rather than inside them: `resolve-cache` and `sxKey` only ever
|
|
44
|
+
* see flat values, so neither their one-level key flattening nor their key space has to grow a
|
|
45
|
+
* breakpoint dimension — and two devices that land on the same flat `sx` still share its entry. A
|
|
46
|
+
* breakpoint folded into the cache key instead would have doubled the entries for every `sx` in the
|
|
47
|
+
* app, responsive or not.
|
|
48
|
+
*
|
|
49
|
+
* Hands back the very object it was given when nothing in it is responsive, which is the overwhelming
|
|
50
|
+
* case: `sx()` wraps every view, and those call sites paying nothing is what keeps it affordable.
|
|
51
|
+
*/
|
|
52
|
+
export const flattenSx = <TSx extends object, TKey extends keyof TSx & string>(
|
|
53
|
+
sx: ResponsiveOf<TSx, TKey>,
|
|
54
|
+
keys: readonly TKey[],
|
|
55
|
+
active: ActiveBreakpoint
|
|
56
|
+
): TSx => {
|
|
57
|
+
const source = sx as Record<string, unknown>
|
|
58
|
+
let flattened: Record<string, unknown> | undefined
|
|
59
|
+
|
|
60
|
+
for (const name of keys) {
|
|
61
|
+
const value = source[name]
|
|
62
|
+
|
|
63
|
+
if (isResponsive(value)) {
|
|
64
|
+
flattened ??= {...source}
|
|
65
|
+
flattened[name] = valueAt(value, active)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (flattened ?? source) as TSx
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './flatten-sx'
|
|
2
|
+
export * from './resolve-cache'
|
|
3
|
+
export * from './responsive.types'
|
|
4
|
+
export * from './responsive-sx'
|
|
5
|
+
export * from './sx.types'
|
|
6
|
+
export * from './sx-key'
|
|
7
|
+
export * from './sx-keys'
|
|
8
|
+
export * from './sx-padding'
|
|
9
|
+
export * from './sx-to-modifiers'
|
|
10
|
+
export * from './sx-to-style'
|
|
11
|
+
export * from './use-sx'
|
|
12
|
+
export * from './use-sx-modifiers'
|
|
13
|
+
export * from './value-key'
|
|
14
|
+
export * from './view-sx.types'
|
|
15
|
+
export * from './view-sx-to-style'
|
|
16
|
+
export * from './with-sx'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {Theme} from '@hzblj/kara-theme'
|
|
2
|
+
|
|
3
|
+
import {valueKey} from './value-key'
|
|
4
|
+
|
|
5
|
+
type StyleResolver<TProps, TStyle> = (props: TProps, theme: Theme) => TStyle
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `resolve` takes `TProps`, `split` hands back `TInput`. The same surface, except that props reach
|
|
9
|
+
* `split` straight off a component with responsive values still in them, and reach `resolve` only once
|
|
10
|
+
* `flattenSx` has collapsed those — so a caller who splits has to flatten before it can resolve.
|
|
11
|
+
* Defaults to `TProps`, for a cache whose surface has nothing responsive in it.
|
|
12
|
+
*/
|
|
13
|
+
export type SxCache<TProps, TStyle, TInput = TProps> = {
|
|
14
|
+
resolve: (props: TProps, theme: Theme) => TStyle
|
|
15
|
+
split: <T extends Record<string, unknown>>(props: T) => [TInput, Record<string, unknown>]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Token-typed values keep the key space small, but `sx` also takes free-form numbers — an `sx`
|
|
20
|
+
* driven per frame would otherwise grow the map for the life of the app. Past the cap the whole map
|
|
21
|
+
* resets: re-resolving is cheap, and an LRU would tax every hit to soften a rare misuse.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_CACHE_ENTRIES = 1024
|
|
24
|
+
|
|
25
|
+
const identities = new WeakMap<object, string>()
|
|
26
|
+
|
|
27
|
+
let nextIdentity = 0
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The variant alone does not identify a theme's colours. On Android the palette resolver closes over a Material
|
|
31
|
+
* scheme generated at runtime, which can change while the variant stays put — a new seed, or a wallpaper change on
|
|
32
|
+
* Android 12+ — and on both platforms the app's own overrides decide what a token's hex is. Keying on the identity of
|
|
33
|
+
* the resolver *and* of the palette is what stops a resolved style outliving the colours it was built from. Both are
|
|
34
|
+
* memoised by the provider, so a steady theme costs one entry each.
|
|
35
|
+
*/
|
|
36
|
+
const identity = (value: object): string => {
|
|
37
|
+
const existing = identities.get(value)
|
|
38
|
+
|
|
39
|
+
if (existing) {
|
|
40
|
+
return existing
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
nextIdentity += 1
|
|
44
|
+
|
|
45
|
+
const id = `#${nextIdentity}`
|
|
46
|
+
identities.set(value, id)
|
|
47
|
+
|
|
48
|
+
return id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const createSxCache = <TProps extends Record<string, unknown>, TStyle, TInput = TProps>(
|
|
52
|
+
keys: readonly (keyof TProps & string)[],
|
|
53
|
+
resolver: StyleResolver<TProps, TStyle>
|
|
54
|
+
): SxCache<TProps, TStyle, TInput> => {
|
|
55
|
+
const cache = new Map<string, TStyle>()
|
|
56
|
+
const keySet = new Set<string>(keys)
|
|
57
|
+
|
|
58
|
+
const cacheKey = (props: TProps, theme: Theme) => {
|
|
59
|
+
let key = `${theme.dark ? 'd' : 'l'}${identity(theme.resolver)}${identity(theme.palette)}`
|
|
60
|
+
|
|
61
|
+
for (const name of keys) {
|
|
62
|
+
const value = props[name]
|
|
63
|
+
|
|
64
|
+
if (value !== undefined) {
|
|
65
|
+
key += `|${name}:${valueKey(value)}`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return key
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
resolve: (props, theme) => {
|
|
74
|
+
const key = cacheKey(props, theme)
|
|
75
|
+
const cached = cache.get(key)
|
|
76
|
+
|
|
77
|
+
if (cached) {
|
|
78
|
+
return cached
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
82
|
+
cache.clear()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const style = resolver(props, theme)
|
|
86
|
+
cache.set(key, style)
|
|
87
|
+
|
|
88
|
+
return style
|
|
89
|
+
},
|
|
90
|
+
split: props => {
|
|
91
|
+
const owned: Record<string, unknown> = {}
|
|
92
|
+
const rest: Record<string, unknown> = {}
|
|
93
|
+
|
|
94
|
+
for (const name of Object.keys(props)) {
|
|
95
|
+
if (keySet.has(name)) {
|
|
96
|
+
owned[name] = props[name]
|
|
97
|
+
} else {
|
|
98
|
+
rest[name] = props[name]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [owned as TInput, rest]
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {ResponsiveOf} from './responsive.types'
|
|
2
|
+
import type {Sx, SxCore} from './sx.types'
|
|
3
|
+
import type {ViewSx} from './view-sx.types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Layout keys only, and deliberately so: a wider screen wants a different gap, padding or column
|
|
7
|
+
* count — not a different colour. `bg`, `shadow`, the radii, `opacity`, the transforms and both
|
|
8
|
+
* platform buckets therefore stay scalar. Every key admitted here is one more that `flattenSx` walks
|
|
9
|
+
* on every render, for a kind of change a design system should not be making anyway.
|
|
10
|
+
*
|
|
11
|
+
* `Partial` because this is a subset by design; what the `satisfies` buys is the other direction — a
|
|
12
|
+
* key renamed in `SxCore` stops compiling here instead of silently going non-responsive.
|
|
13
|
+
*/
|
|
14
|
+
const SX_RESPONSIVE_KEY_RECORD = {
|
|
15
|
+
h: true,
|
|
16
|
+
minH: true,
|
|
17
|
+
minW: true,
|
|
18
|
+
p: true,
|
|
19
|
+
pb: true,
|
|
20
|
+
pl: true,
|
|
21
|
+
pr: true,
|
|
22
|
+
pt: true,
|
|
23
|
+
px: true,
|
|
24
|
+
py: true,
|
|
25
|
+
tall: true,
|
|
26
|
+
w: true,
|
|
27
|
+
wide: true,
|
|
28
|
+
} as const satisfies Partial<Record<keyof SxCore, true>>
|
|
29
|
+
|
|
30
|
+
export type ResponsiveSxKey = keyof typeof SX_RESPONSIVE_KEY_RECORD
|
|
31
|
+
|
|
32
|
+
/** What `useSxModifiers` flattens before the SwiftUI and Compose paths see it. */
|
|
33
|
+
export const SX_RESPONSIVE_KEYS = Object.keys(SX_RESPONSIVE_KEY_RECORD) as readonly ResponsiveSxKey[]
|
|
34
|
+
|
|
35
|
+
/** The core keys plus what only the React Native style path has: margins, gaps, flex, sizing maxima. */
|
|
36
|
+
const VIEW_RESPONSIVE_KEY_RECORD = {
|
|
37
|
+
...SX_RESPONSIVE_KEY_RECORD,
|
|
38
|
+
align: true,
|
|
39
|
+
alignContent: true,
|
|
40
|
+
alignSelf: true,
|
|
41
|
+
basis: true,
|
|
42
|
+
columnGap: true,
|
|
43
|
+
display: true,
|
|
44
|
+
flex: true,
|
|
45
|
+
flexDirection: true,
|
|
46
|
+
gap: true,
|
|
47
|
+
grow: true,
|
|
48
|
+
justify: true,
|
|
49
|
+
m: true,
|
|
50
|
+
maxH: true,
|
|
51
|
+
maxW: true,
|
|
52
|
+
mb: true,
|
|
53
|
+
ml: true,
|
|
54
|
+
mr: true,
|
|
55
|
+
mt: true,
|
|
56
|
+
mx: true,
|
|
57
|
+
my: true,
|
|
58
|
+
rowGap: true,
|
|
59
|
+
shrink: true,
|
|
60
|
+
wrap: true,
|
|
61
|
+
} as const satisfies Partial<Record<keyof ViewSx, true>>
|
|
62
|
+
|
|
63
|
+
export type ResponsiveViewSxKey = keyof typeof VIEW_RESPONSIVE_KEY_RECORD
|
|
64
|
+
|
|
65
|
+
/** What `useSx` flattens before the style cache sees it. */
|
|
66
|
+
export const VIEW_RESPONSIVE_KEYS = Object.keys(VIEW_RESPONSIVE_KEY_RECORD) as readonly ResponsiveViewSxKey[]
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The native modifier surface as a component takes it — `Sx` is the same surface once a breakpoint
|
|
70
|
+
* has been picked, which is what every resolver and cache downstream of `flattenSx` works with.
|
|
71
|
+
*/
|
|
72
|
+
export type ResponsiveSx = ResponsiveOf<Sx, ResponsiveSxKey>
|
|
73
|
+
|
|
74
|
+
/** `ViewSx` as a component takes it, standing to it as `ResponsiveSx` does to `Sx`. */
|
|
75
|
+
export type ResponsiveViewSx = ResponsiveOf<ViewSx, ResponsiveViewSxKey>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {Breakpoint} from '@hzblj/kara-theme'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A value that may differ per breakpoint: the plain value, or one keyed by breakpoint.
|
|
5
|
+
*
|
|
6
|
+
* `base` is mandatory. Partly because the cascade needs a floor to fall back to, and partly because
|
|
7
|
+
* an `sx` that applies nothing on the narrowest phone is a bug rather than a style.
|
|
8
|
+
*
|
|
9
|
+
* The cascade runs mobile-first — a breakpoint holds until a wider one overrides it. So
|
|
10
|
+
* `{base: 's8', md: 's16'}` pads by 8 below `md` and by 16 from `md` up, `lg` and `xl` included.
|
|
11
|
+
*/
|
|
12
|
+
export type Responsive<TValue> = TValue | ({base: TValue} & Partial<Record<Breakpoint, TValue>>)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Widens the named keys of a flat `sx` surface to `Responsive`, leaving the rest alone.
|
|
16
|
+
*
|
|
17
|
+
* A transform over the flat type rather than a hand-written twin, so a key added to `Sx` or `ViewSx`
|
|
18
|
+
* needs no second edit here — and `NonNullable`, so the optionality stays on the prop and cannot
|
|
19
|
+
* sneak into `base`, which would defeat the point of requiring it.
|
|
20
|
+
*/
|
|
21
|
+
export type ResponsiveOf<TSx, TKey extends keyof TSx> = Omit<TSx, TKey> & {
|
|
22
|
+
[K in TKey]?: Responsive<NonNullable<TSx[K]>>
|
|
23
|
+
}
|
package/src/sx-key.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {Sx} from './sx.types'
|
|
2
|
+
import {SX_BUCKET_KEYS, SX_CORE_KEYS} from './sx-keys'
|
|
3
|
+
import {valueKey} from './value-key'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keys sorted, because two buckets holding the same values can list them in a different order and
|
|
7
|
+
* still have to key alike. Object configs flatten through `valueKey`, one level — which is all the
|
|
8
|
+
* `Bucket` constraint admits. `modifiers` is skipped: it holds opaque configs, so no string stands
|
|
9
|
+
* in for it — `useSxModifiers` depends on that array itself alongside this key.
|
|
10
|
+
*/
|
|
11
|
+
const bucketKey = (bucket: Record<string, unknown> | undefined): string => {
|
|
12
|
+
if (!bucket) {
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Object.keys(bucket)
|
|
17
|
+
.filter(name => name !== 'modifiers')
|
|
18
|
+
.sort()
|
|
19
|
+
.map(name => `${name}:${valueKey(bucket[name])}`)
|
|
20
|
+
.join(',')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A stable `useMemo` dependency for an `Sx`. Atoms take theirs from a rest spread, so the object is
|
|
25
|
+
* a fresh identity on every render even when nothing in it changed — the values flattened into one
|
|
26
|
+
* string say exactly as much as the object does.
|
|
27
|
+
*/
|
|
28
|
+
export const sxKey = (sx: Sx): string =>
|
|
29
|
+
[
|
|
30
|
+
...SX_CORE_KEYS.map(name => (sx[name] === undefined ? '' : valueKey(sx[name]))),
|
|
31
|
+
...SX_BUCKET_KEYS.map(name => bucketKey(sx[name])),
|
|
32
|
+
].join('|')
|
package/src/sx-keys.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {Sx, SxCore} from './sx.types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `Record` rather than a bare array, because `satisfies` on an array only checks membership —
|
|
5
|
+
* here the compiler holds the list to `SxCore` in both directions, so a key added to the type but
|
|
6
|
+
* missing from this list (which `split` would then leak into `rest`) refuses to compile.
|
|
7
|
+
*/
|
|
8
|
+
const SX_CORE_KEY_RECORD = {
|
|
9
|
+
bg: true,
|
|
10
|
+
blur: true,
|
|
11
|
+
borderColor: true,
|
|
12
|
+
borderRadius: true,
|
|
13
|
+
borderWidth: true,
|
|
14
|
+
h: true,
|
|
15
|
+
minH: true,
|
|
16
|
+
minW: true,
|
|
17
|
+
offsetX: true,
|
|
18
|
+
offsetY: true,
|
|
19
|
+
opacity: true,
|
|
20
|
+
p: true,
|
|
21
|
+
pb: true,
|
|
22
|
+
pl: true,
|
|
23
|
+
pr: true,
|
|
24
|
+
pt: true,
|
|
25
|
+
px: true,
|
|
26
|
+
py: true,
|
|
27
|
+
rotate: true,
|
|
28
|
+
scale: true,
|
|
29
|
+
shadow: true,
|
|
30
|
+
tall: true,
|
|
31
|
+
w: true,
|
|
32
|
+
wide: true,
|
|
33
|
+
zIndex: true,
|
|
34
|
+
} as const satisfies Record<keyof SxCore, true>
|
|
35
|
+
|
|
36
|
+
/** The keys every target shares. The React Native style path takes these and none of the buckets. */
|
|
37
|
+
export const SX_CORE_KEYS = Object.keys(SX_CORE_KEY_RECORD) as readonly (keyof SxCore)[]
|
|
38
|
+
|
|
39
|
+
const SX_BUCKET_KEY_RECORD = {
|
|
40
|
+
android: true,
|
|
41
|
+
ios: true,
|
|
42
|
+
} as const satisfies Record<Exclude<keyof Sx, keyof SxCore>, true>
|
|
43
|
+
|
|
44
|
+
/** Kept apart from `SX_CORE_KEYS` so the React Native path can take the core without them. */
|
|
45
|
+
export const SX_BUCKET_KEYS = Object.keys(SX_BUCKET_KEY_RECORD) as readonly Exclude<keyof Sx, keyof SxCore>[]
|
|
46
|
+
|
|
47
|
+
export const SX_KEYS: readonly (keyof Sx)[] = [...SX_CORE_KEYS, ...SX_BUCKET_KEYS]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {spacing} from '@hzblj/kara-theme'
|
|
2
|
+
|
|
3
|
+
import type {Sx, SxPadding} from './sx.types'
|
|
4
|
+
|
|
5
|
+
const size = (token: SpaceKey | undefined) => (token === undefined ? undefined : spacing[token])
|
|
6
|
+
|
|
7
|
+
type SpaceKey = NonNullable<Sx['p']>
|
|
8
|
+
|
|
9
|
+
/** Padding is all this reads, so both `Sx` and the widened `StyleSx` satisfy it. */
|
|
10
|
+
type SxPaddingInput = Pick<Sx, 'p' | 'pb' | 'pl' | 'pr' | 'pt' | 'px' | 'py'>
|
|
11
|
+
|
|
12
|
+
export const sxPadding = (sx: SxPaddingInput): SxPadding | undefined => {
|
|
13
|
+
const all = size(sx.p)
|
|
14
|
+
const horizontal = size(sx.px)
|
|
15
|
+
const vertical = size(sx.py)
|
|
16
|
+
|
|
17
|
+
const top = size(sx.pt) ?? vertical ?? all
|
|
18
|
+
const bottom = size(sx.pb) ?? vertical ?? all
|
|
19
|
+
const leading = size(sx.pl) ?? horizontal ?? all
|
|
20
|
+
const trailing = size(sx.pr) ?? horizontal ?? all
|
|
21
|
+
|
|
22
|
+
if (top === undefined && bottom === undefined && leading === undefined && trailing === undefined) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {bottom, leading, top, trailing}
|
|
27
|
+
}
|