diff --git a/CONTEXT.md b/CONTEXT.md index e4827595..09fdcee2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. @@ -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. @@ -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. @@ -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: @@ -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. ## Build And Bundler Model diff --git a/packages/uniwind/src/components/LayoutDirection/LayoutDirection.native.tsx b/packages/uniwind/src/components/LayoutDirection/LayoutDirection.native.tsx new file mode 100644 index 00000000..26a47932 --- /dev/null +++ b/packages/uniwind/src/components/LayoutDirection/LayoutDirection.native.tsx @@ -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> = ({ rtl, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl }, + [uniwindContext, rtl], + ) + const style = useMemo(() => { + if (rtl === undefined) { + return { + display: 'contents', + } + } + + return { display: 'contents', direction: rtl ? 'rtl' : 'ltr' } + }, [rtl]) + + return ( + + + {children} + + + ) +} diff --git a/packages/uniwind/src/components/LayoutDirection/LayoutDirection.tsx b/packages/uniwind/src/components/LayoutDirection/LayoutDirection.tsx new file mode 100644 index 00000000..35bc7c70 --- /dev/null +++ b/packages/uniwind/src/components/LayoutDirection/LayoutDirection.tsx @@ -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> = ({ rtl, children }) => { + const uniwindContext = useUniwindContext() + const value = useMemo( + () => rtl === undefined ? uniwindContext : { ...uniwindContext, rtl }, + [uniwindContext, rtl], + ) + const dir = rtl === undefined ? undefined : rtl ? 'rtl' : 'ltr' + + return ( +
+ + {children} + +
+ ) +} diff --git a/packages/uniwind/src/components/LayoutDirection/index.ts b/packages/uniwind/src/components/LayoutDirection/index.ts new file mode 100644 index 00000000..203d309d --- /dev/null +++ b/packages/uniwind/src/components/LayoutDirection/index.ts @@ -0,0 +1 @@ +export * from './LayoutDirection' diff --git a/packages/uniwind/src/components/ScopedTheme/ScopedTheme.native.tsx b/packages/uniwind/src/components/ScopedTheme/ScopedTheme.native.tsx index f6389629..bea273dd 100644 --- a/packages/uniwind/src/components/ScopedTheme/ScopedTheme.native.tsx +++ b/packages/uniwind/src/components/ScopedTheme/ScopedTheme.native.tsx @@ -1,5 +1,5 @@ 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 = { @@ -7,7 +7,11 @@ type ScopedThemeProps = { } export const ScopedTheme: React.FC> = ({ theme, children }) => { - const value = useMemo(() => ({ scopedTheme: theme }), [theme]) + const uniwindContext = useUniwindContext() + const value = useMemo( + () => ({ ...uniwindContext, scopedTheme: theme }), + [theme, uniwindContext], + ) return ( diff --git a/packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx b/packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx index 9dd01c2c..c87f8de3 100644 --- a/packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx +++ b/packages/uniwind/src/components/ScopedTheme/ScopedTheme.tsx @@ -1,5 +1,5 @@ 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 = { @@ -7,7 +7,11 @@ type ScopedThemeProps = { } export const ScopedTheme: React.FC> = ({ theme, children }) => { - const value = useMemo(() => ({ scopedTheme: theme }), [theme]) + const uniwindContext = useUniwindContext() + const value = useMemo( + () => ({ ...uniwindContext, scopedTheme: theme }), + [theme, uniwindContext], + ) return ( diff --git a/packages/uniwind/src/core/config/config.common.ts b/packages/uniwind/src/core/config/config.common.ts index 0bdaa140..674d903d 100644 --- a/packages/uniwind/src/core/config/config.common.ts +++ b/packages/uniwind/src/core/config/config.common.ts @@ -110,7 +110,7 @@ export class UniwindConfigBuilder { } getCSSVariable = ((variableName: string | Array) => { - return getCSSVariable(variableName, { scopedTheme: null }) + return getCSSVariable(variableName, { scopedTheme: null, rtl: null }) }) as GetCSSVariable protected __reinit(_: GenerateStyleSheetsCallback, themes: Array) { diff --git a/packages/uniwind/src/core/config/config.ts b/packages/uniwind/src/core/config/config.ts index e6f5a7fd..766eb007 100644 --- a/packages/uniwind/src/core/config/config.ts +++ b/packages/uniwind/src/core/config/config.ts @@ -31,7 +31,7 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { } const existingRules: Record = 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 => { diff --git a/packages/uniwind/src/core/context.ts b/packages/uniwind/src/core/context.ts index 6948050c..a62349d5 100644 --- a/packages/uniwind/src/core/context.ts +++ b/packages/uniwind/src/core/context.ts @@ -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' diff --git a/packages/uniwind/src/core/native/store.ts b/packages/uniwind/src/core/native/store.ts index 10693681..08807ec1 100644 --- a/packages/uniwind/src/core/native/store.ts +++ b/packages/uniwind/src/core/native/store.ts @@ -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' @@ -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) { @@ -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) @@ -251,13 +252,9 @@ class UniwindStoreBuilder { return true } - private validateDir(rtl: boolean, props: Record = {}) { - 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 diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index f7ac0580..c4510bf1 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -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 ?? {}) @@ -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) diff --git a/packages/uniwind/src/index.ts b/packages/uniwind/src/index.ts index f267c1a7..9981080c 100644 --- a/packages/uniwind/src/index.ts +++ b/packages/uniwind/src/index.ts @@ -1,3 +1,4 @@ +export * from './components/LayoutDirection' export * from './components/ScopedTheme' export { Uniwind } from './core' export type { ThemeName, UniwindConfig } from './core/types' diff --git a/packages/uniwind/tests/consts.ts b/packages/uniwind/tests/consts.ts index e78fdeb8..0ea07585 100644 --- a/packages/uniwind/tests/consts.ts +++ b/packages/uniwind/tests/consts.ts @@ -14,4 +14,5 @@ export const SCREEN_HEIGHT = 844 export const UNIWIND_CONTEXT_MOCK = { scopedTheme: null, + rtl: null, } satisfies UniwindContextType diff --git a/packages/uniwind/tests/e2e/getWebStyles.test.ts b/packages/uniwind/tests/e2e/getWebStyles.test.ts index e4fed650..88165a22 100644 --- a/packages/uniwind/tests/e2e/getWebStyles.test.ts +++ b/packages/uniwind/tests/e2e/getWebStyles.test.ts @@ -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]) => { @@ -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') diff --git a/packages/uniwind/tests/native/styles-parsing/dir.test.tsx b/packages/uniwind/tests/native/styles-parsing/dir.test.tsx index 80681852..7e8cf8a8 100644 --- a/packages/uniwind/tests/native/styles-parsing/dir.test.tsx +++ b/packages/uniwind/tests/native/styles-parsing/dir.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import { I18nManager } from 'react-native' +import { LayoutDirection } from '../../../src' import View from '../../../src/components/native/View' import { UniwindStore } from '../../../src/core/native' -import { TW_RED_500 } from '../../consts' +import { TW_BLUE_500, TW_RED_500 } from '../../consts' import { renderUniwind } from '../utils' describe('Dir', () => { @@ -19,9 +20,7 @@ describe('Dir', () => { mockRTL(true) const { getStylesFromId } = renderUniwind( - - - , + , ) expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) @@ -31,35 +30,93 @@ describe('Dir', () => { mockRTL(false) const { getStylesFromId } = renderUniwind( - + , + ) + + expect(getStylesFromId('ltr-red').backgroundColor).toBe(TW_RED_500) + }) + + test('LayoutDirection rtl', () => { + mockRTL(false) + + const { getStylesFromId } = renderUniwind( + + + , + ) + + expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) + }) + + test('LayoutDirection ltr', () => { + mockRTL(true) + + const { getStylesFromId } = renderUniwind( + - , + , ) expect(getStylesFromId('ltr-red').backgroundColor).toBe(TW_RED_500) }) - test('inline RTL', () => { + test('Nested LayoutDirection', () => { mockRTL(false) const { getStylesFromId } = renderUniwind( - - - , + + + + + + , ) + expect(getStylesFromId('ltr-red').backgroundColor).toBe(TW_RED_500) expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) }) - test('inline LTR', () => { + test('LayoutDirection without rtl falls back to global rtl', () => { mockRTL(true) + const { getStylesFromId } = renderUniwind( + + + , + ) + + expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) + }) + + test('Nested LayoutDirection without rtl inherits parent rtl', () => { + mockRTL(false) + + const { getStylesFromId } = renderUniwind( + + + + + , + ) + + expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) + }) + + test('LayoutDirection cache separates explicit rtl values', () => { + mockRTL(false) + const { getStylesFromId } = renderUniwind( - + + + + + + , ) - expect(getStylesFromId('ltr-red').backgroundColor).toBe(TW_RED_500) + expect(getStylesFromId('ltr-blue').backgroundColor).toBe(TW_BLUE_500) + expect(getStylesFromId('rtl-red').backgroundColor).toBe(TW_RED_500) }) }) diff --git a/packages/uniwind/tests/web/components/layout-direction.test.tsx b/packages/uniwind/tests/web/components/layout-direction.test.tsx new file mode 100644 index 00000000..b56798e7 --- /dev/null +++ b/packages/uniwind/tests/web/components/layout-direction.test.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react' +import * as React from 'react' +import { LayoutDirection } from '../../../src' + +describe('LayoutDirection', () => { + test('passes dir attribute on web', () => { + const { getByText } = render( + + RTL content + , + ) + + expect(getByText('RTL content').parentElement).toHaveAttribute('dir', 'rtl') + }) + + test('passes ltr dir attribute on web', () => { + const { getByText } = render( + + LTR content + , + ) + + expect(getByText('LTR content').parentElement).toHaveAttribute('dir', 'ltr') + }) +}) diff --git a/skills/uniwind/SKILL.md b/skills/uniwind/SKILL.md index 211aff8c..e6723cc8 100644 --- a/skills/uniwind/SKILL.md +++ b/skills/uniwind/SKILL.md @@ -6,8 +6,9 @@ description: > Covers setup, Metro config, global.css, theming, className props, accent-* color props, platform/data/state/responsive variants, CSS variables, custom utilities, withUniwind for third-party components, cn/tailwind-merge, tailwind-variants, - safe area utilities, gradients, fonts, React Navigation, UI kits, diagnostics, - troubleshooting, and Uniwind Pro features. Does not cover NativeWind migration. + safe area utilities, LayoutDirection, gradients, fonts, React Navigation, + UI kits, diagnostics, troubleshooting, and Uniwind Pro features. Does not + cover NativeWind migration. --- # Uniwind — Complete Reference @@ -16,6 +17,8 @@ description: > If user has lower version, recommend updating to 1.7.0+ (free) / 1.2.1+ (Pro) for best experience. +`LayoutDirection` is available from Uniwind 1.8.0+. + Uniwind brings Tailwind CSS v4 to React Native. All core React Native components support the `className` prop out of the box. Styles are compiled at build time — no runtime overhead. ## Critical Rules @@ -941,6 +944,33 @@ import { ScopedTheme } from 'uniwind'; - `withUniwind`-wrapped components inside the scope also resolve scoped theme values - Custom themes require registration in `extraThemes` +### LayoutDirection (v1.8.0+) + +Scope RTL/LTR variants to a subtree without changing global device RTL state: + +```tsx +import { LayoutDirection } from 'uniwind'; + + + Uses global RTL state + + + Forced RTL subtree + + + + Forced LTR subtree + + +``` + +- Available from `uniwind@1.8.0`. +- `rtl` prop: `true` forces RTL, `false` forces LTR. +- Omit `rtl` to inherit parent `LayoutDirection`; outside any parent it falls back to global RTL state. +- Nearest `LayoutDirection` wins (nested scopes supported). +- Prefer `LayoutDirection` over inline `style={{ direction: 'rtl' }}` for `rtl:`/`ltr:` variant scoping. +- Hooks (`useResolveClassNames`, `useCSSVariable`) and `withUniwind`-wrapped components inside the scope resolve against the nearest layout direction. + ### useCSSVariable Access CSS variable values in JavaScript: