diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 297a6d4d..08e661e6 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -223,6 +223,11 @@ export interface WithPreferences { export type Config = BaseConfig; export interface ThemePreferences { + /** + * The text direction for the UI. + * @default 'ltr' + */ + direction?: 'ltr' | 'rtl'; /** * Inherit from Branding from WSO2 Identity Server or Asgardeo. */ diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index 1dd5ae7e..9e12a581 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -149,6 +149,11 @@ export interface ThemeConfig { small: string; }; colors: ThemeColors; + /** + * The text direction for the UI. + * @default 'ltr' + */ + direction?: 'ltr' | 'rtl'; shadows: { large: string; medium: string; diff --git a/packages/react/src/components/presentation/OrganizationList/OrganizationList.styles.ts b/packages/react/src/components/presentation/OrganizationList/OrganizationList.styles.ts index e5757328..e23270d0 100644 --- a/packages/react/src/components/presentation/OrganizationList/OrganizationList.styles.ts +++ b/packages/react/src/components/presentation/OrganizationList/OrganizationList.styles.ts @@ -48,10 +48,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { &__loading-overlay { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; background-color: color-mix(in srgb, ${theme.vars.colors.background.surface} 80%, transparent); display: flex; align-items: center; @@ -77,10 +74,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { `, loadingOverlay: css` position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; background-color: color-mix(in srgb, ${theme.vars.colors.background.surface} 80%, transparent); display: flex; align-items: center; diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.styles.ts b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.styles.ts index c03ccd2d..81b296f1 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.styles.ts +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.styles.ts @@ -122,7 +122,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { const manageButton = css` min-width: auto; - margin-left: auto; + margin-inline-start: auto; `; const menu = css` @@ -144,7 +144,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { background-color: transparent; cursor: pointer; font-size: 0.875rem; - text-align: left; + text-align: start; border-radius: ${theme.vars.borderRadius.medium}; transition: background-color 0.15s ease-in-out; diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx new file mode 100644 index 00000000..e9beff5d --- /dev/null +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {render, screen, waitFor} from '@testing-library/react'; +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {BaseOrganizationSwitcher, Organization} from './BaseOrganizationSwitcher'; +import React from 'react'; + +// Mock the dependencies +vi.mock('../../../contexts/Theme/useTheme', () => ({ + default: () => ({ + theme: { + vars: { + colors: { + text: {primary: '#000', secondary: '#666'}, + background: {surface: '#fff'}, + border: '#ccc', + action: {hover: '#f0f0f0'}, + }, + spacing: {unit: '8px'}, + borderRadius: {medium: '4px', large: '8px'}, + shadows: {medium: '0 2px 4px rgba(0,0,0,0.1)'}, + }, + }, + colorScheme: 'light', + direction: (document.documentElement.getAttribute('dir') as 'ltr' | 'rtl') || 'ltr', + }), +})); + +vi.mock('../../../hooks/useTranslation', () => ({ + default: () => ({ + t: (key: string) => key, + currentLanguage: 'en', + setLanguage: vi.fn(), + availableLanguages: ['en'], + }), +})); + +const mockOrganizations: Organization[] = [ + { + id: '1', + name: 'Organization 1', + avatar: 'https://example.com/avatar1.jpg', + memberCount: 10, + role: 'admin', + }, + { + id: '2', + name: 'Organization 2', + avatar: 'https://example.com/avatar2.jpg', + memberCount: 5, + role: 'member', + }, +]; + +describe('BaseOrganizationSwitcher RTL Support', () => { + beforeEach(() => { + document.documentElement.removeAttribute('dir'); + }); + + afterEach(() => { + document.documentElement.removeAttribute('dir'); + }); + + it('should render correctly in LTR mode', () => { + document.documentElement.setAttribute('dir', 'ltr'); + const handleSwitch = vi.fn(); + + render( + , + ); + + expect(screen.getByText('Organization 1')).toBeInTheDocument(); + }); + + it('should render correctly in RTL mode', () => { + document.documentElement.setAttribute('dir', 'rtl'); + const handleSwitch = vi.fn(); + + render( + , + ); + + expect(screen.getByText('Organization 1')).toBeInTheDocument(); + }); + + it('should flip chevron icon in RTL mode', async () => { + document.documentElement.setAttribute('dir', 'rtl'); + const handleSwitch = vi.fn(); + + const {container} = render( + , + ); + + await waitFor(() => { + const chevronIcon = container.querySelector('svg'); + expect(chevronIcon).toBeTruthy(); + if (chevronIcon) { + const style = window.getComputedStyle(chevronIcon); + // In RTL mode, the transform should be scaleX(-1) + expect(chevronIcon.style.transform).toContain('scaleX(-1)'); + } + }); + }); + + it('should not flip chevron icon in LTR mode', async () => { + document.documentElement.setAttribute('dir', 'ltr'); + const handleSwitch = vi.fn(); + + const {container} = render( + , + ); + + await waitFor(() => { + const chevronIcon = container.querySelector('svg'); + expect(chevronIcon).toBeTruthy(); + if (chevronIcon) { + // In LTR mode, the transform should be none + expect(chevronIcon.style.transform).toBe('none'); + } + }); + }); + + it('should update icon flip when direction changes', async () => { + document.documentElement.setAttribute('dir', 'ltr'); + const handleSwitch = vi.fn(); + + const {container, rerender} = render( + , + ); + + // Initially LTR + let chevronIcon = container.querySelector('svg'); + expect(chevronIcon?.style.transform).toBe('none'); + + // Change to RTL + document.documentElement.setAttribute('dir', 'rtl'); + + // Force re-render + rerender( + , + ); + + await waitFor(() => { + chevronIcon = container.querySelector('svg'); + expect(chevronIcon?.style.transform).toContain('scaleX(-1)'); + }); + }); +}); diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx index 0a0a6357..601121cd 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx @@ -186,11 +186,12 @@ export const BaseOrganizationSwitcher: FC = ({ avatarSize = 24, fallback = null, }): ReactElement => { - const {theme, colorScheme} = useTheme(); + const {theme, colorScheme, direction} = useTheme(); const styles = useStyles(theme, colorScheme); const [isOpen, setIsOpen] = useState(false); const [hoveredItemIndex, setHoveredItemIndex] = useState(null); const {t} = useTranslation(); + const isRTL = direction === 'rtl'; const {refs, floatingStyles, context} = useFloating({ open: isOpen, @@ -308,7 +309,9 @@ export const BaseOrganizationSwitcher: FC = ({ )} )} - + + + {isOpen && ( diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts index 858fd8b6..b778412b 100644 --- a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts +++ b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts @@ -95,7 +95,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { border: none; cursor: pointer; font-size: 0.875rem; - text-align: left; + text-align: start; border-radius: ${theme.vars.borderRadius.medium}; transition: none; box-shadow: none; @@ -125,7 +125,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { background: none; cursor: pointer; font-size: 0.875rem; - text-align: left; + text-align: start; border-radius: ${theme.vars.borderRadius.medium}; transition: background-color 0.15s ease-in-out; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 0d5c9efd..a5c69385 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -55,7 +55,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { display: flex; gap: calc(${theme.vars.spacing.unit} / 2); align-items: center; - margin-left: calc(${theme.vars.spacing.unit} * 4); + margin-inline-start: calc(${theme.vars.spacing.unit} * 4); `; const complexTextarea = css` @@ -135,7 +135,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { width: 120px; flex-shrink: 0; line-height: 28px; - text-align: left; + text-align: start; `; const value = css` @@ -151,7 +151,7 @@ const useStyles = (theme: Theme, colorScheme: string) => { text-overflow: ellipsis; white-space: nowrap; max-width: 350px; - text-align: left; + text-align: start; .${withVendorCSSClassPrefix('form-control')} { margin-bottom: 0; diff --git a/packages/react/src/components/primitives/Checkbox/Checkbox.styles.ts b/packages/react/src/components/primitives/Checkbox/Checkbox.styles.ts index 4354e969..66d12d7b 100644 --- a/packages/react/src/components/primitives/Checkbox/Checkbox.styles.ts +++ b/packages/react/src/components/primitives/Checkbox/Checkbox.styles.ts @@ -38,7 +38,7 @@ const useStyles = (theme: Theme, colorScheme: string, hasError: boolean, require const inputStyles = css` width: calc(${theme.vars.spacing.unit} * 2.5); height: calc(${theme.vars.spacing.unit} * 2.5); - margin-right: ${theme.vars.spacing.unit}; + margin-inline-end: ${theme.vars.spacing.unit}; accent-color: ${theme.vars.colors.primary.main}; cursor: pointer; diff --git a/packages/react/src/components/primitives/Divider/Divider.styles.ts b/packages/react/src/components/primitives/Divider/Divider.styles.ts index 8aae54f4..bb4813e2 100644 --- a/packages/react/src/components/primitives/Divider/Divider.styles.ts +++ b/packages/react/src/components/primitives/Divider/Divider.styles.ts @@ -54,8 +54,9 @@ const useStyles = ( height: 100%; min-height: calc(${theme.vars.spacing.unit} * 2); width: 1px; - border-left: 1px ${borderStyle} ${baseColor}; - margin: 0 calc(${theme.vars.spacing.unit} * 1); + border-inline-start: 1px ${borderStyle} ${baseColor}; + margin-block: 0; + margin-inline: calc(${theme.vars.spacing.unit} * 1); `; const horizontalDivider = css` diff --git a/packages/react/src/components/primitives/FormControl/FormControl.styles.ts b/packages/react/src/components/primitives/FormControl/FormControl.styles.ts index d5ef13a5..fa78472e 100644 --- a/packages/react/src/components/primitives/FormControl/FormControl.styles.ts +++ b/packages/react/src/components/primitives/FormControl/FormControl.styles.ts @@ -40,14 +40,14 @@ const useStyles = ( ) => { return useMemo(() => { const formControl = css` - text-align: left; + text-align: start; margin-bottom: calc(${theme.vars.spacing.unit} * 2); `; const helperText = css` margin-top: calc(${theme.vars.spacing.unit} / 2); - text-align: ${helperTextAlign}; - ${helperTextMarginLeft && `margin-left: ${helperTextMarginLeft};`} + text-align: ${helperTextAlign === 'left' ? 'start' : helperTextAlign}; + ${helperTextMarginLeft && `margin-inline-start: ${helperTextMarginLeft};`} `; const helperTextError = css` diff --git a/packages/react/src/components/primitives/TextField/TextField.styles.ts b/packages/react/src/components/primitives/TextField/TextField.styles.ts index 99225e0b..2c9f83b2 100644 --- a/packages/react/src/components/primitives/TextField/TextField.styles.ts +++ b/packages/react/src/components/primitives/TextField/TextField.styles.ts @@ -39,10 +39,10 @@ const useStyles = ( hasEndIcon: boolean, ) => { return useMemo(() => { - const leftPadding = hasStartIcon + const inlineStartPadding = hasStartIcon ? `calc(${theme.vars.spacing.unit} * 5)` : `calc(${theme.vars.spacing.unit} * 1.5)`; - const rightPadding = hasEndIcon ? `calc(${theme.vars.spacing.unit} * 5)` : `calc(${theme.vars.spacing.unit} * 1.5)`; + const inlineEndPadding = hasEndIcon ? `calc(${theme.vars.spacing.unit} * 5)` : `calc(${theme.vars.spacing.unit} * 1.5)`; const inputContainer = css` position: relative; @@ -52,7 +52,9 @@ const useStyles = ( const input = css` width: 100%; - padding: ${theme.vars.spacing.unit} ${rightPadding} ${theme.vars.spacing.unit} ${leftPadding}; + padding-block: ${theme.vars.spacing.unit}; + padding-inline-start: ${inlineStartPadding}; + padding-inline-end: ${inlineEndPadding}; border: 1px solid ${hasError ? theme.vars.colors.error.main : theme.vars.colors.border}; border-radius: ${theme.vars.components?.Field?.root?.borderRadius || theme.vars.borderRadius.medium}; font-size: ${theme.vars.typography.fontSizes.md}; @@ -127,12 +129,12 @@ const useStyles = ( const startIcon = css` ${icon}; - left: ${theme.vars.spacing.unit}; + inset-inline-start: ${theme.vars.spacing.unit}; `; const endIcon = css` ${icon}; - right: ${theme.vars.spacing.unit}; + inset-inline-end: ${theme.vars.spacing.unit}; `; return { diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index aa33c9ca..4109e248 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -501,7 +501,10 @@ const AsgardeoProvider: FC> = ({ > diff --git a/packages/react/src/contexts/Theme/ThemeContext.ts b/packages/react/src/contexts/Theme/ThemeContext.ts index 599b2917..5c6688e7 100644 --- a/packages/react/src/contexts/Theme/ThemeContext.ts +++ b/packages/react/src/contexts/Theme/ThemeContext.ts @@ -22,6 +22,10 @@ import {Theme} from '@asgardeo/browser'; export interface ThemeContextValue { theme: Theme; colorScheme: 'light' | 'dark'; + /** + * The text direction for the UI. + */ + direction: 'ltr' | 'rtl'; toggleTheme: () => void; /** * Whether branding theme is currently loading diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 752e307a..2ec48309 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -213,6 +213,9 @@ const ThemeProvider: FC> = ({ const theme = useMemo(() => createTheme(finalThemeConfig, colorScheme === 'dark'), [finalThemeConfig, colorScheme]); + // Get direction from theme config or default to 'ltr' + const direction = (finalThemeConfig as any)?.direction || 'ltr'; + const handleThemeChange = useCallback((isDark: boolean) => { setColorScheme(isDark ? 'dark' : 'light'); }, []); @@ -262,9 +265,17 @@ const ThemeProvider: FC> = ({ applyThemeToDOM(theme); }, [theme]); + // Apply direction to document + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.dir = direction; + } + }, [direction]); + const value = { theme, colorScheme, + direction, toggleTheme, isBrandingLoading, brandingError, diff --git a/packages/react/src/contexts/Theme/types.ts b/packages/react/src/contexts/Theme/types.ts index d02caa39..a278ae93 100644 --- a/packages/react/src/contexts/Theme/types.ts +++ b/packages/react/src/contexts/Theme/types.ts @@ -58,6 +58,11 @@ export interface ThemeConfig { small: string; }; colors: ThemeColors; + /** + * The text direction for the UI. + * @default 'ltr' + */ + direction?: 'ltr' | 'rtl'; shadows: { large: string; medium: string;