Skip to content

Commit ceb276d

Browse files
authored
feat: layout direction component (#577)
* feat: layout direction component * chore: use parent context if rtl is not provided * docs: update skill with LayoutDirection
1 parent 0a50373 commit ceb276d

17 files changed

Lines changed: 245 additions & 38 deletions

File tree

CONTEXT.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Core user-facing features:
1818
- `active`, `focus`, `disabled`, RTL, orientation, responsive, data attribute, and platform-aware variants.
1919
- CSS custom property reads and updates from React Native code.
2020
- Scoped themes through `ScopedTheme`.
21+
- Scoped layout direction through `LayoutDirection`.
2122
- Metro and Vite integration.
2223

2324
Supported platforms: iOS, Android, web, Android TV, and Apple TV. Other React Native targets are out of scope until tests and docs explicitly cover them.
@@ -38,6 +39,7 @@ Important paths:
3839
Public exports from `src/index.ts`:
3940

4041
- `Uniwind` runtime/config object.
42+
- `LayoutDirection` component.
4143
- `ScopedTheme` component.
4244
- `withUniwind` HOC and related types.
4345
- `useCSSVariable`, `useResolveClassNames`, `useUniwind` hooks.
@@ -63,7 +65,7 @@ Native runtime:
6365
- Build output injects a generated stylesheet callback into `Uniwind.__reinit(...)`.
6466
- `UniwindStore` holds generated style records, theme variables, scoped variables, runtime state, and per-theme caches.
6567
- `UniwindStore.getStyles(className, props, state, context)` resolves classes into React Native style objects.
66-
- Cache keys include class names, component state, and whether theme is scoped.
68+
- Cache keys include class names, component state, whether theme is scoped, and explicit layout direction context.
6769
- Resolved styles subscribe to only dependencies they use, then invalidate cache entries on change.
6870
- Runtime dependencies are represented by `StyleDependency`: theme, dimensions, orientation, insets, font scale, RTL, adaptive themes, and variables.
6971
- Native style resolution filters rules by screen width, orientation, theme, RTL, active/focus/disabled state, and `data-*` props.
@@ -75,6 +77,7 @@ Web runtime:
7577
- `getWebStyles` uses a hidden DOM element to compute style values when a JS value is needed, such as color extraction or `useResolveClassNames`.
7678
- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change.
7779
- `ScopedTheme` renders a `div` with the theme class and `display: contents` on web.
80+
- `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree.
7881
- Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element.
7982

8083
Shared runtime:
@@ -84,6 +87,7 @@ Shared runtime:
8487
- `Uniwind.updateCSSVariables(theme, variables)` updates theme variables and notifies variable subscribers.
8588
- `Uniwind.updateInsets(insets)` is native-only behavior and updates safe-area-style runtime values.
8689
- `ScopedTheme` sets `UniwindContext.scopedTheme`; scoped subtree ignores global theme changes for style resolution.
90+
- `LayoutDirection` sets `UniwindContext.rtl`; scoped subtree uses that direction for RTL/LTR variant resolution instead of global runtime RTL.
8791

8892
## Build And Bundler Model
8993

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React, { useMemo } from 'react'
2+
import type { ViewStyle } from 'react-native'
3+
import { View } from 'react-native'
4+
import { UniwindContext, useUniwindContext } from '../../core/context'
5+
import type { UniwindContextType } from '../../core/types'
6+
7+
type LayoutDirectionProps = {
8+
rtl?: boolean
9+
}
10+
11+
export const LayoutDirection: React.FC<React.PropsWithChildren<LayoutDirectionProps>> = ({ rtl, children }) => {
12+
const uniwindContext = useUniwindContext()
13+
const value = useMemo<UniwindContextType>(
14+
() => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl },
15+
[uniwindContext, rtl],
16+
)
17+
const style = useMemo<ViewStyle>(() => {
18+
if (rtl === undefined) {
19+
return {
20+
display: 'contents',
21+
}
22+
}
23+
24+
return { display: 'contents', direction: rtl ? 'rtl' : 'ltr' }
25+
}, [rtl])
26+
27+
return (
28+
<View style={style}>
29+
<UniwindContext.Provider value={value}>
30+
{children}
31+
</UniwindContext.Provider>
32+
</View>
33+
)
34+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React, { useMemo } from 'react'
2+
import { UniwindContext, useUniwindContext } from '../../core/context'
3+
import type { UniwindContextType } from '../../core/types'
4+
5+
type LayoutDirectionProps = {
6+
rtl?: boolean
7+
}
8+
9+
export const LayoutDirection: React.FC<React.PropsWithChildren<LayoutDirectionProps>> = ({ rtl, children }) => {
10+
const uniwindContext = useUniwindContext()
11+
const value = useMemo<UniwindContextType>(
12+
() => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl },
13+
[uniwindContext, rtl],
14+
)
15+
const dir = rtl === undefined ? undefined : rtl ? 'rtl' : 'ltr'
16+
17+
return (
18+
<div dir={dir} style={{ display: 'contents' }}>
19+
<UniwindContext.Provider value={value}>
20+
{children}
21+
</UniwindContext.Provider>
22+
</div>
23+
)
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './LayoutDirection'

packages/uniwind/src/components/ScopedTheme/ScopedTheme.native.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import React, { useMemo } from 'react'
2-
import { UniwindContext } from '../../core/context'
2+
import { UniwindContext, useUniwindContext } from '../../core/context'
33
import type { ThemeName, UniwindContextType } from '../../core/types'
44

55
type ScopedThemeProps = {
66
theme: ThemeName
77
}
88

99
export const ScopedTheme: React.FC<React.PropsWithChildren<ScopedThemeProps>> = ({ theme, children }) => {
10-
const value = useMemo<UniwindContextType>(() => ({ scopedTheme: theme }), [theme])
10+
const uniwindContext = useUniwindContext()
11+
const value = useMemo<UniwindContextType>(
12+
() => ({ ...uniwindContext, scopedTheme: theme }),
13+
[theme, uniwindContext],
14+
)
1115

1216
return (
1317
<UniwindContext.Provider value={value}>

packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import React, { useMemo } from 'react'
2-
import { UniwindContext } from '../../core/context'
2+
import { UniwindContext, useUniwindContext } from '../../core/context'
33
import type { ThemeName, UniwindContextType } from '../../core/types'
44

55
type ScopedThemeProps = {
66
theme: ThemeName
77
}
88

99
export const ScopedTheme: React.FC<React.PropsWithChildren<ScopedThemeProps>> = ({ theme, children }) => {
10-
const value = useMemo<UniwindContextType>(() => ({ scopedTheme: theme }), [theme])
10+
const uniwindContext = useUniwindContext()
11+
const value = useMemo<UniwindContextType>(
12+
() => ({ ...uniwindContext, scopedTheme: theme }),
13+
[theme, uniwindContext],
14+
)
1115

1216
return (
1317
<UniwindContext.Provider value={value}>

packages/uniwind/src/core/config/config.common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export class UniwindConfigBuilder {
110110
}
111111

112112
getCSSVariable = ((variableName: string | Array<string>) => {
113-
return getCSSVariable(variableName, { scopedTheme: null })
113+
return getCSSVariable(variableName, { scopedTheme: null, rtl: null })
114114
}) as GetCSSVariable
115115

116116
protected __reinit(_: GenerateStyleSheetsCallback, themes: Array<string>) {

packages/uniwind/src/core/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
3131
}
3232

3333
const existingRules: Record<ThemeName, string | undefined> = Object.fromEntries(
34-
uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme })]),
34+
uniwindRules.map(rule => [rule.theme, getWebVariable(varName, { scopedTheme: rule.theme, rtl: null })]),
3535
)
3636

3737
uniwindRules.forEach(rule => {
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { createContext, useContext } from 'react'
1+
import { createContext, use } from 'react'
22
import type { ThemeName } from './types'
33

44
export const UniwindContext = createContext({
55
scopedTheme: null as ThemeName | null,
6+
rtl: null as boolean | null,
67
})
78

8-
export const useUniwindContext = () => useContext(UniwindContext)
9+
export const useUniwindContext = () => use(UniwindContext)
910

1011
UniwindContext.displayName = 'UniwindContext'

packages/uniwind/src/core/native/store.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { ViewStyle } from 'react-native'
2-
import { Dimensions, Platform, StyleSheet } from 'react-native'
1+
import { Dimensions, Platform } from 'react-native'
32
import { Orientation, Platform as UniwindPlatform, StyleDependency, UNIWIND_PLATFORM_VARIABLES, UNIWIND_THEME_VARIABLES } from '../../common/consts'
43
import { UniwindListener } from '../listener'
54
import type { ComponentState, GenerateStyleSheetsCallback, RNStyle, Style, StyleSheets, ThemeName, UniwindContextType, Var, Vars } from '../types'
@@ -31,7 +30,9 @@ class UniwindStoreBuilder {
3130
}
3231

3332
const isScopedTheme = uniwindContext.scopedTheme !== null
34-
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}`
33+
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${
34+
uniwindContext.rtl ?? ''
35+
}`
3536
const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName]
3637

3738
if (!cache) {
@@ -134,7 +135,7 @@ class UniwindStoreBuilder {
134135
|| style.maxWidth < this.runtime.screen.width
135136
|| (style.theme !== null && theme !== style.theme)
136137
|| (style.orientation !== null && this.runtime.orientation !== style.orientation)
137-
|| (style.rtl !== null && !this.validateDir(style.rtl, componentProps))
138+
|| (style.rtl !== null && !this.validateDir(style.rtl, uniwindContext))
138139
|| (style.active !== null && state?.isPressed !== style.active)
139140
|| (style.focus !== null && state?.isFocused !== style.focus)
140141
|| (style.disabled !== null && state?.isDisabled !== style.disabled)
@@ -251,13 +252,9 @@ class UniwindStoreBuilder {
251252
return true
252253
}
253254

254-
private validateDir(rtl: boolean, props: Record<string, any> = {}) {
255-
const inlineDir = 'style' in props ? (StyleSheet.flatten(props.style) as ViewStyle)?.direction : undefined
256-
257-
if (inlineDir !== undefined && inlineDir !== 'inherit') {
258-
const isInlineRtl = inlineDir === 'rtl'
259-
260-
return isInlineRtl === rtl
255+
private validateDir(rtl: boolean, uniwindContext: UniwindContextType) {
256+
if (uniwindContext.rtl !== null) {
257+
return rtl === uniwindContext.rtl
261258
}
262259

263260
return rtl === this.runtime.rtl

0 commit comments

Comments
 (0)