Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Core user-facing features:
- `active`, `focus`, `disabled`, RTL, orientation, responsive, data attribute, and platform-aware variants.
- CSS custom property reads and updates from React Native code.
- Scoped themes through `ScopedTheme`.
- Scoped layout direction through `LayoutDirection`.
- Metro and Vite integration.

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.
Expand All @@ -38,6 +39,7 @@ Important paths:
Public exports from `src/index.ts`:

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

Shared runtime:
Expand All @@ -84,6 +87,7 @@ Shared runtime:
- `Uniwind.updateCSSVariables(theme, variables)` updates theme variables and notifies variable subscribers.
- `Uniwind.updateInsets(insets)` is native-only behavior and updates safe-area-style runtime values.
- `ScopedTheme` sets `UniwindContext.scopedTheme`; scoped subtree ignores global theme changes for style resolution.
- `LayoutDirection` sets `UniwindContext.rtl`; scoped subtree uses that direction for RTL/LTR variant resolution instead of global runtime RTL.
Comment thread
Brentlok marked this conversation as resolved.

## Build And Bundler Model

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { useMemo } from 'react'
import type { ViewStyle } from 'react-native'
import { View } from 'react-native'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { UniwindContextType } from '../../core/types'

type LayoutDirectionProps = {
rtl?: boolean
}

export const LayoutDirection: React.FC<React.PropsWithChildren<LayoutDirectionProps>> = ({ rtl, children }) => {
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl },
[uniwindContext, rtl],
)
const style = useMemo<ViewStyle>(() => {
if (rtl === undefined) {
return {
display: 'contents',
}
}

return { display: 'contents', direction: rtl ? 'rtl' : 'ltr' }
}, [rtl])

return (
<View style={style}>
<UniwindContext.Provider value={value}>
{children}
</UniwindContext.Provider>
</View>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React, { useMemo } from 'react'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { UniwindContextType } from '../../core/types'

type LayoutDirectionProps = {
rtl?: boolean
}

export const LayoutDirection: React.FC<React.PropsWithChildren<LayoutDirectionProps>> = ({ rtl, children }) => {
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl },
[uniwindContext, rtl],
)
const dir = rtl === undefined ? undefined : rtl ? 'rtl' : 'ltr'

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return (
<div dir={dir} style={{ display: 'contents' }}>
<UniwindContext.Provider value={value}>
{children}
</UniwindContext.Provider>
</div>
)
}
1 change: 1 addition & 0 deletions packages/uniwind/src/components/LayoutDirection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './LayoutDirection'
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import React, { useMemo } from 'react'
import { UniwindContext } from '../../core/context'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { ThemeName, UniwindContextType } from '../../core/types'

type ScopedThemeProps = {
theme: ThemeName
}

export const ScopedTheme: React.FC<React.PropsWithChildren<ScopedThemeProps>> = ({ theme, children }) => {
const value = useMemo<UniwindContextType>(() => ({ scopedTheme: theme }), [theme])
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => ({ ...uniwindContext, scopedTheme: theme }),
[theme, uniwindContext],
)

return (
<UniwindContext.Provider value={value}>
Expand Down
8 changes: 6 additions & 2 deletions packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import React, { useMemo } from 'react'
import { UniwindContext } from '../../core/context'
import { UniwindContext, useUniwindContext } from '../../core/context'
import type { ThemeName, UniwindContextType } from '../../core/types'

type ScopedThemeProps = {
theme: ThemeName
}

export const ScopedTheme: React.FC<React.PropsWithChildren<ScopedThemeProps>> = ({ theme, children }) => {
const value = useMemo<UniwindContextType>(() => ({ scopedTheme: theme }), [theme])
const uniwindContext = useUniwindContext()
const value = useMemo<UniwindContextType>(
() => ({ ...uniwindContext, scopedTheme: theme }),
[theme, uniwindContext],
)

return (
<UniwindContext.Provider value={value}>
Expand Down
2 changes: 1 addition & 1 deletion packages/uniwind/src/core/config/config.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class UniwindConfigBuilder {
}

getCSSVariable = ((variableName: string | Array<string>) => {
return getCSSVariable(variableName, { scopedTheme: null })
return getCSSVariable(variableName, { scopedTheme: null, rtl: null })
}) as GetCSSVariable

protected __reinit(_: GenerateStyleSheetsCallback, themes: Array<string>) {
Expand Down
2 changes: 1 addition & 1 deletion packages/uniwind/src/core/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
}

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

uniwindRules.forEach(rule => {
Expand Down
5 changes: 3 additions & 2 deletions packages/uniwind/src/core/context.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createContext, useContext } from 'react'
import { createContext, use } from 'react'
import type { ThemeName } from './types'

export const UniwindContext = createContext({
scopedTheme: null as ThemeName | null,
rtl: null as boolean | null,
})

export const useUniwindContext = () => useContext(UniwindContext)
export const useUniwindContext = () => use(UniwindContext)

UniwindContext.displayName = 'UniwindContext'
19 changes: 8 additions & 11 deletions packages/uniwind/src/core/native/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ViewStyle } from 'react-native'
import { Dimensions, Platform, StyleSheet } from 'react-native'
import { Dimensions, Platform } from 'react-native'
import { Orientation, Platform as UniwindPlatform, StyleDependency, UNIWIND_PLATFORM_VARIABLES, UNIWIND_THEME_VARIABLES } from '../../common/consts'
import { UniwindListener } from '../listener'
import type { ComponentState, GenerateStyleSheetsCallback, RNStyle, Style, StyleSheets, ThemeName, UniwindContextType, Var, Vars } from '../types'
Expand Down Expand Up @@ -31,7 +30,9 @@ class UniwindStoreBuilder {
}

const isScopedTheme = uniwindContext.scopedTheme !== null
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}`
const cacheKey = `${className}${state?.isDisabled ?? false}${state?.isFocused ?? false}${state?.isPressed ?? false}${isScopedTheme}${
uniwindContext.rtl ?? ''
}`
const cache = this.cache[uniwindContext.scopedTheme ?? this.runtime.currentThemeName]

if (!cache) {
Expand Down Expand Up @@ -134,7 +135,7 @@ class UniwindStoreBuilder {
|| style.maxWidth < this.runtime.screen.width
|| (style.theme !== null && theme !== style.theme)
|| (style.orientation !== null && this.runtime.orientation !== style.orientation)
|| (style.rtl !== null && !this.validateDir(style.rtl, componentProps))
|| (style.rtl !== null && !this.validateDir(style.rtl, uniwindContext))
|| (style.active !== null && state?.isPressed !== style.active)
|| (style.focus !== null && state?.isFocused !== style.focus)
|| (style.disabled !== null && state?.isDisabled !== style.disabled)
Expand Down Expand Up @@ -251,13 +252,9 @@ class UniwindStoreBuilder {
return true
}

private validateDir(rtl: boolean, props: Record<string, any> = {}) {
const inlineDir = 'style' in props ? (StyleSheet.flatten(props.style) as ViewStyle)?.direction : undefined

if (inlineDir !== undefined && inlineDir !== 'inherit') {
const isInlineRtl = inlineDir === 'rtl'

return isInlineRtl === rtl
private validateDir(rtl: boolean, uniwindContext: UniwindContextType) {
if (uniwindContext.rtl !== null) {
return rtl === uniwindContext.rtl
}

return rtl === this.runtime.rtl
Expand Down
12 changes: 12 additions & 0 deletions packages/uniwind/src/core/web/getWebStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export const getWebStyles = (
dummyParent?.removeAttribute('class')
}

if (uniwindContext.rtl !== null) {
dummyParent?.setAttribute('dir', uniwindContext.rtl ? 'rtl' : 'ltr')
} else {
dummyParent?.removeAttribute('dir')
}

dummy.className = className

const dataSet = generateDataSet(componentProps ?? {})
Expand Down Expand Up @@ -116,6 +122,12 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType)
dummyParent.removeAttribute('class')
}

if (uniwindContext.rtl !== null) {
dummyParent.setAttribute('dir', uniwindContext.rtl ? 'rtl' : 'ltr')
} else {
dummyParent.removeAttribute('dir')
}

const variable = window.getComputedStyle(dummyParent).getPropertyValue(name)

return parseCSSValue(variable)
Expand Down
1 change: 1 addition & 0 deletions packages/uniwind/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './components/LayoutDirection'
export * from './components/ScopedTheme'
export { Uniwind } from './core'
export type { ThemeName, UniwindConfig } from './core/types'
Expand Down
1 change: 1 addition & 0 deletions packages/uniwind/tests/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export const SCREEN_HEIGHT = 844

export const UNIWIND_CONTEXT_MOCK = {
scopedTheme: null,
rtl: null,
} satisfies UniwindContextType
18 changes: 15 additions & 3 deletions packages/uniwind/tests/e2e/getWebStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const bundle = readFileSync(BUNDLE_PATH, 'utf-8')
async function getWebStyles(
page: import('@playwright/test').Page,
className: string,
context: UniwindContextType = { scopedTheme: null },
context: UniwindContextType = { scopedTheme: null, rtl: null },
) {
return page.evaluate(
([cls, ctx]) => {
Expand Down Expand Up @@ -53,16 +53,28 @@ test.describe('getWebStyles — basic cases', () => {

test.describe('getWebStyles — scoped theme', () => {
test('bg-background in dark theme → backgroundColor black', async ({ page }) => {
const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark' })
const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'dark', rtl: null })
expect(styles.backgroundColor).toBe('#000000')
})

test('bg-background in light theme → backgroundColor white', async ({ page }) => {
const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light' })
const styles = await getWebStyles(page, 'bg-background', { scopedTheme: 'light', rtl: null })
expect(styles.backgroundColor).toBe('#ffffff')
})
})

test.describe('getWebStyles — layout direction', () => {
test('rtl variant resolves inside rtl context', async ({ page }) => {
const styles = await getWebStyles(page, 'rtl:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: true })
expect(styles.backgroundColor).toBe(TW_RED_500)
})

test('ltr variant resolves inside ltr context', async ({ page }) => {
const styles = await getWebStyles(page, 'ltr:bg-red-500 bg-blue-500', { scopedTheme: null, rtl: false })
expect(styles.backgroundColor).toBe(TW_RED_500)
})
})

test.describe('getWebStyles - html default styles', () => {
test('bg-red-500 -> should only include backgroundColor', async ({ page }) => {
const styles = await getWebStyles(page, 'bg-red-500')
Expand Down
Loading