diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index a5d3614828f..dc5694f16b0 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; +import { Component, Element, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; import { matchBreakpoint } from '@utils/media'; +import { isRTL } from '@utils/rtl'; import { getIonMode } from '../../global/ionic-global'; @@ -15,6 +16,8 @@ const BREAKPOINTS = ['', 'xs', 'sm', 'md', 'lg', 'xl']; shadow: true, }) export class Col implements ComponentInterface { + @Element() el!: HTMLElement; + /** * The amount to offset the column, in terms of how many columns it should shift to the end * of the total available. @@ -235,20 +238,20 @@ export class Col implements ComponentInterface { }; } - private calculateOffset(isRTL: boolean) { - return this.calculatePosition('offset', isRTL ? 'margin-right' : 'margin-left'); + private calculateOffset(rtl: boolean) { + return this.calculatePosition('offset', rtl ? 'margin-right' : 'margin-left'); } - private calculatePull(isRTL: boolean) { - return this.calculatePosition('pull', isRTL ? 'left' : 'right'); + private calculatePull(rtl: boolean) { + return this.calculatePosition('pull', rtl ? 'left' : 'right'); } - private calculatePush(isRTL: boolean) { - return this.calculatePosition('push', isRTL ? 'right' : 'left'); + private calculatePush(rtl: boolean) { + return this.calculatePosition('push', rtl ? 'right' : 'left'); } render() { - const isRTL = document.dir === 'rtl'; + const rtl = isRTL(this.el); const mode = getIonMode(this); return ( diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts new file mode 100644 index 00000000000..aac418404c2 --- /dev/null +++ b/core/src/components/col/test/col.spec.ts @@ -0,0 +1,36 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Col } from '../col'; + +describe('ion-col: rtl', () => { + const newCol = async (html: string) => { + const page = await newSpecPage({ components: [Col], html }); + return page.body.querySelector('ion-col')!; + }; + + it('should offset, push and pull from the start when no dir is declared', async () => { + const col = await newCol(``); + expect(col.style.marginLeft).not.toBe(''); + expect(col.style.marginRight).toBe(''); + expect(col.style.left).not.toBe(''); + expect(col.style.right).not.toBe(''); + }); + + it('should mirror offset, push and pull when an ancestor declares rtl', async () => { + const col = await newCol(`
`); + expect(col.style.marginRight).not.toBe(''); + expect(col.style.marginLeft).toBe(''); + }); + + it('should not mirror when an ancestor declares ltr', async () => { + const col = await newCol(`
`); + expect(col.style.marginLeft).not.toBe(''); + expect(col.style.marginRight).toBe(''); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const col = await newCol(`
`); + expect(col.style.marginLeft).not.toBe(''); + expect(col.style.marginRight).toBe(''); + }); +}); diff --git a/core/src/components/item-options/item-options.tsx b/core/src/components/item-options/item-options.tsx index d495580d7a5..2a1dd684487 100644 --- a/core/src/components/item-options/item-options.tsx +++ b/core/src/components/item-options/item-options.tsx @@ -36,7 +36,7 @@ export class ItemOptions implements ComponentInterface { render() { const mode = getIonMode(this); - const isEnd = isEndSide(this.side); + const isEnd = isEndSide(this.side, this.el); return ( { + const newItemOptions = async (html: string) => { + const page = await newSpecPage({ components: [ItemOptions], html }); + return page.body.querySelector('ion-item-options')!; + }; + + it('should change sides when an ancestor declares rtl', async () => { + const itemOptions = await newItemOptions( + `
` + ); + expect(itemOptions).toHaveClass('item-options-end'); + expect(itemOptions).not.toHaveClass('item-options-start'); + }); + + it('should not change sides when an ancestor declares ltr', async () => { + const itemOptions = await newItemOptions( + `
` + ); + expect(itemOptions).toHaveClass('item-options-start'); + expect(itemOptions).not.toHaveClass('item-options-end'); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const itemOptions = await newItemOptions( + `
` + ); + expect(itemOptions).toHaveClass('item-options-start'); + }); +}); diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index d2bce0e0dfd..0a4f0bf00c6 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -3,6 +3,7 @@ import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from ' import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; import { componentOnReady, isEndSide } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; +import { isRTL } from '@utils/rtl'; import { watchForOptions } from '@utils/watch-options'; import { getIonMode } from '../../global/ionic-global'; @@ -176,7 +177,7 @@ export class ItemSliding implements ComponentInterface { } // In RTL we want to switch the sides - side = isEndSide(side) ? 'end' : 'start'; + side = isEndSide(side, this.el) ? 'end' : 'start'; const isStartOpen = this.openAmount < 0; const isEndOpen = this.openAmount > 0; @@ -260,7 +261,7 @@ export class ItemSliding implements ComponentInterface { this.leftOptions = this.rightOptions = undefined; for (const option of options) { - const side = isEndSide(option.side ?? option.getAttribute('side')) ? 'end' : 'start'; + const side = isEndSide(option.side ?? option.getAttribute('side'), option) ? 'end' : 'start'; if (side === 'start') { this.leftOptions = option; @@ -280,7 +281,7 @@ export class ItemSliding implements ComponentInterface { * do not open left side so swipe to go * back will still work. */ - const rtl = document.dir === 'rtl'; + const rtl = isRTL(this.el); const atEdge = rtl ? window.innerWidth - gesture.startX < 15 : gesture.startX < 15; if (atEdge) { return false; diff --git a/core/src/components/item-sliding/test/item-sliding.spec.ts b/core/src/components/item-sliding/test/item-sliding.spec.ts new file mode 100644 index 00000000000..c220e5c5ae3 --- /dev/null +++ b/core/src/components/item-sliding/test/item-sliding.spec.ts @@ -0,0 +1,82 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { ItemOptions } from '../../item-options/item-options'; +import { ItemSliding } from '../item-sliding'; + +describe('ion-item-sliding: rtl', () => { + const newItemSliding = async (optionsAttrs: string) => { + const page = await newSpecPage({ + components: [ItemSliding, ItemOptions], + html: ` + Item + + `, + }); + + await page.waitForChanges(); + + return page; + }; + + /** + * Opening only moves the item when the requested side matches the side the + * options were filed under, so it is what reveals the direction that was + * resolved for them. + */ + const opensFrom = async (optionsAttrs: string, side: 'start' | 'end') => { + const page = await newItemSliding(optionsAttrs); + const itemSliding = page.body.querySelector('ion-item-sliding')!; + + await itemSliding.open(side); + await page.waitForChanges(); + + return itemSliding.classList.contains('item-sliding-active-slide'); + }; + + it('should file a start-side option under the start when no dir is declared', async () => { + expect(await opensFrom(`side="start"`, 'start')).toBe(true); + expect(await opensFrom(`side="start"`, 'end')).toBe(false); + }); + + it('should file a start-side option under the end when the options element declares rtl', async () => { + expect(await opensFrom(`side="start" dir="rtl"`, 'end')).toBe(true); + expect(await opensFrom(`side="start" dir="rtl"`, 'start')).toBe(false); + }); + + it('should file a start-side option under the end when an ancestor declares rtl', async () => { + const page = await newSpecPage({ + components: [ItemSliding, ItemOptions], + html: `
+ + Item + + +
`, + }); + await page.waitForChanges(); + + const itemSliding = page.body.querySelector('ion-item-sliding')!; + await itemSliding.open('end'); + await page.waitForChanges(); + + expect(itemSliding).toHaveClass('item-sliding-active-slide'); + }); + + /** + * ion-item-options resolves its own side, so a dir declared on it has to + * resolve the same way here or the options would render on one side while + * opening from the other. + */ + it('should resolve the same side that the options element renders', async () => { + const page = await newItemSliding(`side="start" dir="rtl"`); + const itemSliding = page.body.querySelector('ion-item-sliding')!; + const itemOptions = page.body.querySelector('ion-item-options')!; + + expect(itemOptions).toHaveClass('item-options-end'); + + await itemSliding.open('end'); + await page.waitForChanges(); + + expect(itemSliding).toHaveClass('item-sliding-active-slide'); + }); +}); diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index fa7de421fc2..4f9e1b554b4 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -4,6 +4,7 @@ import type { AttributeController } from '@utils/attribute-controller'; import { createAttributeController } from '@utils/attribute-controller'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; import { raf } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { chevronForward } from 'ionicons/icons'; @@ -473,7 +474,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac 'item-focus-indicator-room': slottedIndicatorNeedsRoom, 'ion-activatable': canActivate, 'ion-focusable': this.focusable, - 'item-rtl': document.dir === 'rtl', + 'item-rtl': isRTL(this.el), }), }} role={inList ? 'listitem' : null} diff --git a/core/src/components/item/test/item.spec.tsx b/core/src/components/item/test/item.spec.tsx index f07b2a58461..1c3ec6bedeb 100644 --- a/core/src/components/item/test/item.spec.tsx +++ b/core/src/components/item/test/item.spec.tsx @@ -74,4 +74,26 @@ describe('item', () => { expect(item).not.toHaveClass('item-focus-indicator-room'); }); }); + + describe('rtl', () => { + const newItemPage = async (html: string) => { + const page = await newSpecPage({ components: [Item], html }); + return page.body.querySelector('ion-item')!; + }; + + it('should set item-rtl when an ancestor declares rtl', async () => { + const item = await newItemPage(`
Item
`); + expect(item).toHaveClass('item-rtl'); + }); + + it('should not set item-rtl when an ancestor declares ltr', async () => { + const item = await newItemPage(`
Item
`); + expect(item).not.toHaveClass('item-rtl'); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const item = await newItemPage(`
Item
`); + expect(item).not.toHaveClass('item-rtl'); + }); + }); }); diff --git a/core/src/components/label/label.tsx b/core/src/components/label/label.tsx index cea7a844ec4..30859a1003d 100644 --- a/core/src/components/label/label.tsx +++ b/core/src/components/label/label.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; +import { isRTL } from '@utils/rtl'; import { createColorClasses, hostContext } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; @@ -112,7 +113,7 @@ export class Label implements ComponentInterface { 'in-item-color': hostContext('ion-item.ion-color', this.el), [`label-${position}`]: position !== undefined, [`label-no-animate`]: this.noAnimate, - 'label-rtl': document.dir === 'rtl', + 'label-rtl': isRTL(this.el), })} > diff --git a/core/src/components/label/test/label.spec.ts b/core/src/components/label/test/label.spec.ts new file mode 100644 index 00000000000..0012b800380 --- /dev/null +++ b/core/src/components/label/test/label.spec.ts @@ -0,0 +1,25 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Label } from '../label'; + +describe('ion-label: rtl', () => { + const newLabel = async (html: string) => { + const page = await newSpecPage({ components: [Label], html }); + return page.body.querySelector('ion-label')!; + }; + + it('should set label-rtl when an ancestor declares rtl', async () => { + const label = await newLabel(`
Label
`); + expect(label).toHaveClass('label-rtl'); + }); + + it('should not set label-rtl when an ancestor declares ltr', async () => { + const label = await newLabel(`
Label
`); + expect(label).not.toHaveClass('label-rtl'); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const label = await newLabel(`
Label
`); + expect(label).not.toHaveClass('label-rtl'); + }); +}); diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index 4273074f6bb..c8143ab098f 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -1,5 +1,6 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; import type { Animation } from '../../../interface'; import { @@ -31,7 +32,7 @@ const POPOVER_IOS_MIN_EDGE_MARGIN = 25; export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => { const { event: ev, size, trigger, reference, side, align } = opts; const doc = baseEl.ownerDocument as any; - const isRTL = doc.dir === 'rtl'; + const rtl = isRTL(baseEl); const root = getElementRoot(baseEl); const contentEl = root.querySelector('.popover-content') as HTMLElement; const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null; @@ -61,12 +62,12 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => const defaultPosition = { top: bodyHeight / 2 - contentHeight / 2, left: bodyWidth / 2 - contentWidth / 2, - originX: isRTL ? 'right' : 'left', + originX: rtl ? 'right' : 'left', originY: 'top', }; const results = getPopoverPosition( - isRTL, + rtl, contentWidth, contentHeight, arrowWidth, diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index 6d37474ceb2..09e59471538 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -1,5 +1,6 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; import type { Animation } from '../../../interface'; import { @@ -19,7 +20,7 @@ const POPOVER_MD_BODY_PADDING = 12; export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => { const { event: ev, size, trigger, reference, side, align } = opts; const doc = baseEl.ownerDocument as any; - const isRTL = doc.dir === 'rtl'; + const rtl = isRTL(baseEl); const root = getElementRoot(baseEl); const contentEl = root.querySelector('.popover-content') as HTMLElement; @@ -48,12 +49,12 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => const defaultPosition = { top: bodyHeight / 2 - contentHeight / 2, left: bodyWidth / 2 - contentWidth / 2, - originX: isRTL ? 'right' : 'left', + originX: rtl ? 'right' : 'left', originY: 'top', }; const results = getPopoverPosition( - isRTL, + rtl, contentWidth, contentHeight, 0, diff --git a/core/src/components/popover/test/rtl/popover.e2e.ts b/core/src/components/popover/test/rtl/popover.e2e.ts new file mode 100644 index 00000000000..fc4689bf4f8 --- /dev/null +++ b/core/src/components/popover/test/rtl/popover.e2e.ts @@ -0,0 +1,52 @@ +import { expect } from '@playwright/test'; +import type { E2EPage, E2EPageOptions } from '@utils/test/playwright'; +import { configs, test, Viewports } from '@utils/test/playwright'; + +import { openPopover } from '../test.utils'; + +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('popover: rtl ancestor'), () => { + const openStartSidePopover = async (page: E2EPage, config: E2EPageOptions, appDir: string) => { + await page.setViewportSize(Viewports.tablet.portrait); + await page.setContent( + ` + + +
+ Open +
+ + Popover + +
+ + `, + config + ); + + await openPopover(page, 'trigger'); + + const content = page.locator('ion-popover .popover-content'); + + return { + trigger: (await page.locator('#trigger').boundingBox())!, + content: (await content.boundingBox())!, + originX: (await content.evaluate((el) => getComputedStyle(el).transformOrigin)).split(' ')[0], + }; + }; + + test('should place a start side popover before the trigger in an ltr app', async ({ page }) => { + const { trigger, content, originX } = await openStartSidePopover(page, config, 'ltr'); + + expect(content.x + content.width).toBeLessThanOrEqual(trigger.x); + expect(originX).toBe(`${content.width}px`); + }); + + test('should place a start side popover after the trigger when the app declares rtl', async ({ page }) => { + const { trigger, content, originX } = await openStartSidePopover(page, config, 'rtl'); + + expect(content.x).toBeGreaterThanOrEqual(trigger.x + trigger.width); + expect(originX).toBe('0px'); + }); + }); +}); diff --git a/core/src/components/progress-bar/progress-bar.tsx b/core/src/components/progress-bar/progress-bar.tsx index 12ccd66dec9..139bc391a32 100644 --- a/core/src/components/progress-bar/progress-bar.tsx +++ b/core/src/components/progress-bar/progress-bar.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Host, Prop, h } from '@stencil/core'; +import { Component, Element, Host, Prop, h } from '@stencil/core'; import { clamp } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; import { config } from '../../global/config'; @@ -24,6 +25,8 @@ import type { Color } from '../../interface'; shadow: true, }) export class ProgressBar implements ComponentInterface { + @Element() el!: HTMLElement; + /** * The state of the progress bar, based on if the time the process takes is known or not. * Default options are: `"determinate"` (no animation), `"indeterminate"` (animate from left to right). @@ -56,7 +59,7 @@ export class ProgressBar implements ComponentInterface { @Prop({ reflect: true }) color?: Color; render() { - const { color, type, reversed, value, buffer } = this; + const { color, type, reversed, value, buffer, el } = this; const paused = config.getBoolean('_testing'); const mode = getIonMode(this); // If the progress is displayed as a solid bar. @@ -71,7 +74,7 @@ export class ProgressBar implements ComponentInterface { [mode]: true, [`progress-bar-${type}`]: true, 'progress-paused': paused, - 'progress-bar-reversed': document.dir === 'rtl' ? !reversed : reversed, + 'progress-bar-reversed': isRTL(el) ? !reversed : reversed, 'progress-bar-solid': progressSolid, })} > diff --git a/core/src/components/progress-bar/test/progress-bar.spec.ts b/core/src/components/progress-bar/test/progress-bar.spec.ts new file mode 100644 index 00000000000..33c53d4ee59 --- /dev/null +++ b/core/src/components/progress-bar/test/progress-bar.spec.ts @@ -0,0 +1,35 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { ProgressBar } from '../progress-bar'; + +describe('ion-progress-bar: rtl', () => { + const newProgressBar = async (html: string) => { + const page = await newSpecPage({ components: [ProgressBar], html }); + return page.body.querySelector('ion-progress-bar')!; + }; + + it('should reverse when an ancestor declares rtl', async () => { + const progressBar = await newProgressBar(`
`); + expect(progressBar).toHaveClass('progress-bar-reversed'); + }); + + it('should not reverse when an ancestor declares ltr', async () => { + const progressBar = await newProgressBar(`
`); + expect(progressBar).not.toHaveClass('progress-bar-reversed'); + }); + + // An rtl ancestor flips `reversed`, so the two cancel out. + it('should not reverse when reversed is set inside an rtl ancestor', async () => { + const progressBar = await newProgressBar( + `
` + ); + expect(progressBar).not.toHaveClass('progress-bar-reversed'); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const progressBar = await newProgressBar( + `
` + ); + expect(progressBar).not.toHaveClass('progress-bar-reversed'); + }); +}); diff --git a/core/src/components/title/test/title.spec.ts b/core/src/components/title/test/title.spec.ts new file mode 100644 index 00000000000..af3080e06b5 --- /dev/null +++ b/core/src/components/title/test/title.spec.ts @@ -0,0 +1,25 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { ToolbarTitle } from '../title'; + +describe('ion-title: rtl', () => { + const newTitle = async (html: string) => { + const page = await newSpecPage({ components: [ToolbarTitle], html }); + return page.body.querySelector('ion-title')!; + }; + + it('should set title-rtl when an ancestor declares rtl', async () => { + const title = await newTitle(`
Title
`); + expect(title).toHaveClass('title-rtl'); + }); + + it('should not set title-rtl when an ancestor declares ltr', async () => { + const title = await newTitle(`
Title
`); + expect(title).not.toHaveClass('title-rtl'); + }); + + it('should use the nearest ancestor that declares a dir', async () => { + const title = await newTitle(`
Title
`); + expect(title).not.toHaveClass('title-rtl'); + }); +}); diff --git a/core/src/components/title/title.tsx b/core/src/components/title/title.tsx index 82a108071ff..40b30b63820 100644 --- a/core/src/components/title/title.tsx +++ b/core/src/components/title/title.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core'; +import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; @@ -64,7 +65,7 @@ export class ToolbarTitle implements ComponentInterface { class={createColorClasses(this.color, { [mode]: true, [`title-${size}`]: true, - 'title-rtl': document.dir === 'rtl', + 'title-rtl': isRTL(this.el), })} >
diff --git a/core/src/utils/helpers.spec.ts b/core/src/utils/helpers.spec.ts index aaa4b7dbdb7..8414bf4cb94 100644 --- a/core/src/utils/helpers.spec.ts +++ b/core/src/utils/helpers.spec.ts @@ -6,16 +6,6 @@ describe('isEndSide', () => { document.body.innerHTML = ''; }); - it('should use document direction when no host element is provided', () => { - document.dir = 'ltr'; - expect(isEndSide('start')).toBe(false); - expect(isEndSide('end')).toBe(true); - - document.dir = 'rtl'; - expect(isEndSide('start')).toBe(true); - expect(isEndSide('end')).toBe(false); - }); - // https://github.com/ionic-team/ionic-framework/issues/30226 it('should use the nearest ancestor dir attribute', () => { document.dir = 'ltr'; @@ -30,6 +20,19 @@ describe('isEndSide', () => { expect(isEndSide('start', menu)).toBe(true); expect(isEndSide('end', menu)).toBe(false); }); + + it('should fall back to the document dir when no ancestor declares one', () => { + const menu = document.createElement('ion-menu'); + document.body.appendChild(menu); + + document.dir = 'ltr'; + expect(isEndSide('start', menu)).toBe(false); + expect(isEndSide('end', menu)).toBe(true); + + document.dir = 'rtl'; + expect(isEndSide('start', menu)).toBe(true); + expect(isEndSide('end', menu)).toBe(false); + }); }); describe('inheritAriaAttributes', () => { diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index cd10c0f0881..65fdf4e950c 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -374,10 +374,9 @@ export const pointerCoord = (ev: any): { x: number; y: number } => { * * @param side The current side before being redefined based on the direction. * @param hostEl The component's host element. The direction is resolved from - * it or its nearest ancestor that declares one. When omitted, the direction - * is resolved from the document. + * it or its nearest ancestor that declares one. */ -export const isEndSide = (side: Side, hostEl?: HTMLElement): boolean => { +export const isEndSide = (side: Side, hostEl: HTMLElement): boolean => { const rtl = isRTL(hostEl); switch (side) { diff --git a/core/src/utils/input-shims/hacks/common.ts b/core/src/utils/input-shims/hacks/common.ts index 7553c07e103..65c922dac04 100644 --- a/core/src/utils/input-shims/hacks/common.ts +++ b/core/src/utils/input-shims/hacks/common.ts @@ -1,3 +1,5 @@ +import { isRTL } from '@utils/rtl'; + const cloneMap = new WeakMap(); export const relocateInput = ( @@ -73,10 +75,9 @@ const addClone = ( * Position the clone at the same horizontal offset as the native input * to prevent the placeholder from overlapping start slot content (e.g., icons). */ - const doc = componentEl.ownerDocument!; - const isRTL = doc.dir === 'rtl'; + const rtl = isRTL(componentEl); - if (isRTL) { + if (rtl) { const parentWidth = (parentEl as HTMLElement).offsetWidth; const startOffset = parentWidth - inputEl.offsetLeft - inputEl.offsetWidth; clonedEl.style.insetInlineStart = `${startOffset}px`; @@ -87,7 +88,7 @@ const addClone = ( parentEl.appendChild(clonedEl); cloneMap.set(componentEl, clonedEl); - const tx = isRTL ? 9999 : -9999; + const tx = rtl ? 9999 : -9999; componentEl.style.pointerEvents = 'none'; inputEl.style.transform = `translate3d(${tx}px,${inputRelativeY}px,0) scale(0)`; }; diff --git a/core/src/utils/rtl/dir.spec.ts b/core/src/utils/rtl/dir.spec.ts index cc3245e0f55..7d65b6c3c79 100644 --- a/core/src/utils/rtl/dir.spec.ts +++ b/core/src/utils/rtl/dir.spec.ts @@ -75,20 +75,8 @@ describe('rtl: dir', () => { // value left behind by another test. expect(document.dir).toBe(''); - expect(isRTL()).toBe(false); - expect(isRTL(null)).toBe(false); expect(isRTL(document.createElement('div'))).toBe(false); expect(isRTL(render('
'))).toBe(false); }); }); - - describe('without a host element', () => { - it('should use the document dir', () => { - document.dir = 'rtl'; - expect(isRTL()).toBe(true); - - document.dir = 'ltr'; - expect(isRTL()).toBe(false); - }); - }); }); diff --git a/core/src/utils/rtl/dir.ts b/core/src/utils/rtl/dir.ts index 1d7de2dd261..4f9ba10a430 100644 --- a/core/src/utils/rtl/dir.ts +++ b/core/src/utils/rtl/dir.ts @@ -9,8 +9,8 @@ * * @param hostEl the element to resolve the direction for. */ -export const isRTL = (hostEl?: Element | null): boolean => { - for (let el = hostEl; el; el = el.parentElement) { +export const isRTL = (hostEl: Element): boolean => { + for (let el: Element | null = hostEl; el; el = el.parentElement) { const dir = el.getAttribute('dir')?.toLowerCase(); if (dir === 'rtl') { @@ -20,5 +20,5 @@ export const isRTL = (hostEl?: Element | null): boolean => { return false; } } - return document?.dir?.toLowerCase() === 'rtl'; + return hostEl.ownerDocument?.dir?.toLowerCase() === 'rtl'; }; diff --git a/core/src/utils/transition/ios.transition.ts b/core/src/utils/transition/ios.transition.ts index 45a9c864b7c..37dce8bfe10 100644 --- a/core/src/utils/transition/ios.transition.ts +++ b/core/src/utils/transition/ios.transition.ts @@ -1,3 +1,5 @@ +import { isRTL } from '@utils/rtl'; + import type { Animation } from '../../interface'; import { createAnimation } from '../animation/animation'; import type { TransitionOptions } from '../transition'; @@ -501,9 +503,9 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio const CENTER = '0%'; const OFF_OPACITY = 0.8; - const isRTL = navEl.ownerDocument.dir === 'rtl'; - const OFF_RIGHT = isRTL ? '-99.5%' : '99.5%'; - const OFF_LEFT = isRTL ? '33%' : '-33%'; + const rtl = isRTL(navEl); + const OFF_RIGHT = rtl ? '-99.5%' : '99.5%'; + const OFF_LEFT = rtl ? '33%' : '-33%'; const enteringEl = opts.enteringEl; const leavingEl = opts.leavingEl; @@ -584,13 +586,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-condense'); - const { forward, backward } = createLargeTitleTransition( - rootAnimation, - isRTL, - backDirection, - enteringEl, - leavingEl - ); + const { forward, backward } = createLargeTitleTransition(rootAnimation, rtl, backDirection, enteringEl, leavingEl); enteringToolBarEls.forEach((enteringToolBarEl) => { const enteringToolBar = createAnimation(); enteringToolBar.addElement(enteringToolBarEl); @@ -668,7 +664,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio if (!translucentHeader) { enteringToolBarBg.fromTo(OPACITY, 0.01, 'var(--opacity)'); } else { - enteringToolBarBg.fromTo('transform', isRTL ? 'translateX(-100%)' : 'translateX(100%)', 'translateX(0px)'); + enteringToolBarBg.fromTo('transform', rtl ? 'translateX(-100%)' : 'translateX(100%)', 'translateX(0px)'); } // forward direction, entering page has a back button @@ -680,7 +676,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio const enteringBackBtnText = createAnimation(); enteringBackBtnText .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW - .fromTo(`transform`, isRTL ? 'translateX(-100px)' : 'translateX(100px)', 'translateX(0px)'); + .fromTo(`transform`, rtl ? 'translateX(-100px)' : 'translateX(100px)', 'translateX(0px)'); enteringToolBar.addAnimation(enteringBackBtnText); } @@ -709,7 +705,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio // leaving content, back direction leavingContent .beforeClearStyles([OPACITY]) - .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)'); + .fromTo('transform', `translateX(${CENTER})`, rtl ? 'translateX(-100%)' : 'translateX(100%)'); const leavingPage = getIonPageElement(leavingEl) as HTMLElement; rootAnimation.afterAddWrite(() => { @@ -811,14 +807,14 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio if (!inactiveHeader) { // leaving toolbar, back direction leavingTitle - .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)') + .fromTo('transform', `translateX(${CENTER})`, rtl ? 'translateX(-100%)' : 'translateX(100%)') .fromTo(OPACITY, 0.99, 0); } leavingToolBarItems.fromTo( 'transform', `translateX(${CENTER})`, - isRTL ? 'translateX(-100%)' : 'translateX(100%)' + rtl ? 'translateX(-100%)' : 'translateX(100%)' ); leavingToolBarBg.beforeClearStyles([OPACITY, 'transform']); // leaving toolbar, back direction, and there's no entering toolbar @@ -827,14 +823,14 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio if (!translucentHeader) { leavingToolBarBg.fromTo(OPACITY, 'var(--opacity)', 0); } else { - leavingToolBarBg.fromTo('transform', 'translateX(0px)', isRTL ? 'translateX(-100%)' : 'translateX(100%)'); + leavingToolBarBg.fromTo('transform', 'translateX(0px)', rtl ? 'translateX(-100%)' : 'translateX(100%)'); } if (backButtonEl && !backward) { const leavingBackBtnText = createAnimation(); leavingBackBtnText .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW - .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); + .fromTo('transform', `translateX(${CENTER})`, `translateX(${(rtl ? -124 : 124) + 'px'})`); leavingToolBar.addAnimation(leavingBackBtnText); } } else { diff --git a/docs/component-guide.md b/docs/component-guide.md index f685fd6163d..1b3094daf59 100644 --- a/docs/component-guide.md +++ b/docs/component-guide.md @@ -806,9 +806,11 @@ These mixins depend on the `:host-context` pseudo-class when used inside of shad To work around this, you should set an RTL class on the host of your component and set your RTL styles by targeting that class: ```tsx +import { isRTL } from '@utils/rtl'; + ...