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..cbb90371768 --- /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, documentDir?: 'rtl') => { + const page = await newSpecPage({ components: [Col], html: `
` }); + if (documentDir) { + page.doc.documentElement.setAttribute('dir', documentDir); + } + await page.setContent(html); + return page.body.querySelector('ion-col')!; + }; + + const cols = (n: number) => `calc(calc(${n} / var(--ion-grid-columns, 12)) * 100%)`; + + const COL_PARAMS = `offset="3" push="2" pull="1"`; + + it('should mirror push and pull when it declares rtl', async () => { + const col = await newCol(``); + expect(col.style.right).toBe(cols(2)); + expect(col.style.left).toBe(cols(1)); + }); + + it('should mirror push and pull when an ancestor declares rtl', async () => { + const col = await newCol(`
`); + expect(col.style.right).toBe(cols(2)); + expect(col.style.left).toBe(cols(1)); + }); + + it('should not mirror push and pull when an ancestor declares ltr in an rtl document', async () => { + const col = await newCol(`
`, 'rtl'); + expect(col.style.right).toBe(cols(1)); + expect(col.style.left).toBe(cols(2)); + }); +}); diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e9bb5d47086..8bc4b001630 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -2259,8 +2259,7 @@ export class Datetime implements ComponentInterface { const prevMonthDisabled = disabled || isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts); const nextMonthDisabled = disabled || isNextMonthDisabled(this.workingParts, this.maxParts); - // don't use the inheritAttributes util because it removes dir from the host, and we still need that - const hostDir = this.el.getAttribute('dir') || undefined; + const hostDir = isRTL(this.el) ? 'rtl' : 'ltr'; return (
@@ -2280,6 +2279,7 @@ export class Datetime implements ComponentInterface { {getMonthAndYear(this.locale, this.workingParts)}
diff --git a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts index 66797b3ad4e..d0711729aca 100644 --- a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts +++ b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts @@ -264,3 +264,30 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => }); }); }); + +configs({ directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: basic'), () => { + test.beforeEach(async ({ page }) => { + await page.goto(`/src/components/item-sliding/test/basic`, config); + }); + test.describe('direction', () => { + test('should be ltr when ancestor defines dir="ltr"', async ({ page }) => { + const item = page.locator('#item-ltr-start'); + await item.scrollIntoViewIfNeeded(); + await dragElementBy(item, page, 150); + await page.waitForChanges(); + + await expect(item).toHaveClass(/item-sliding-active-options-start/); + }); + + test('should be rtl when ancestor defines dir="rtl"', async ({ page }) => { + const item = page.locator('#item-rtl-start'); + await item.scrollIntoViewIfNeeded(); + await dragElementBy(item, page, -150); + await page.waitForChanges(); + + await expect(item).toHaveClass(/item-sliding-active-options-end/); + }); + }); + }); +}); diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index fa7de421fc2..7beb05b0b26 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'; @@ -367,6 +368,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac routerAnimation, routerDirection, multipleInputs, + el, } = this; const inheritedAriaAttributes = this.ariaController?.attributes ?? {}; const childStyles = {} as StyleEventDetail; @@ -473,7 +475,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(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..6038b41d112 100644 --- a/core/src/components/item/test/item.spec.tsx +++ b/core/src/components/item/test/item.spec.tsx @@ -74,4 +74,30 @@ describe('item', () => { expect(item).not.toHaveClass('item-focus-indicator-room'); }); }); + + describe('rtl', () => { + const newItemPage = async (html: string, documentDir?: 'rtl') => { + const page = await newSpecPage({ components: [Item], html: `
` }); + if (documentDir) { + page.doc.documentElement.setAttribute('dir', documentDir); + } + await page.setContent(html); + return page.body.querySelector('ion-item')!; + }; + + it('should set item-rtl when it declares rtl', async () => { + const item = await newItemPage(`Item`); + expect(item).toHaveClass('item-rtl'); + }); + + 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 in an rtl document', async () => { + const item = await newItemPage(`
Item
`, 'rtl'); + 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..fa4076fd790 --- /dev/null +++ b/core/src/components/label/test/label.spec.ts @@ -0,0 +1,29 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Label } from '../label'; + +describe('ion-label: rtl', () => { + const newLabel = async (html: string, documentDir?: 'rtl') => { + const page = await newSpecPage({ components: [Label], html: `
` }); + if (documentDir) { + page.doc.documentElement.setAttribute('dir', documentDir); + } + await page.setContent(html); + return page.body.querySelector('ion-label')!; + }; + + it('should set label-rtl when it declares rtl', async () => { + const label = await newLabel(`Label`); + expect(label).toHaveClass('label-rtl'); + }); + + 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 in an rtl document', async () => { + const label = await newLabel(`
Label
`, 'rtl'); + 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/popover.tsx b/core/src/components/popover/popover.tsx index ecfcee2c3b8..caa3cd3d7a3 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -17,6 +17,7 @@ import { setOverlayId, } from '@utils/overlays'; import { isPlatform } from '@utils/platform'; +import { isRTL } from '@utils/rtl'; import { getClassMap } from '@utils/theme'; import { deepReady, waitForMount } from '@utils/transition'; @@ -72,6 +73,7 @@ export class Popover implements ComponentInterface, PopoverInterface { private workingDelegate?: FrameworkDelegate; private focusDescendantOnPresent = false; + private dirSetOnPresent = false; lastFocus?: HTMLElement; @@ -496,6 +498,16 @@ export class Popover implements ComponentInterface, PopoverInterface { const { inline, delegate } = this.getDelegate(true); + /** + * When presented, ion-popover is hoisted from its place in the element + * hierarchy. Copy the current direction onto this element so that child + * elements can match. + */ + if (!el.hasAttribute('dir')) { + el.setAttribute('dir', isRTL(el) ? 'rtl' : 'ltr'); + this.dirSetOnPresent = true; + } + /** * Emit ionMount so JS Frameworks have an opportunity * to add the child component to the DOM. The child @@ -650,6 +662,11 @@ export class Popover implements ComponentInterface, PopoverInterface { */ const { delegate } = this.getDelegate(); await detachComponent(delegate, this.usersElement); + + if (this.dirSetOnPresent) { + this.el.removeAttribute('dir'); + this.dirSetOnPresent = false; + } } unlock(); diff --git a/core/src/components/popover/test/position/index.html b/core/src/components/popover/test/position/index.html index ffb8e6fce30..49130553462 100644 --- a/core/src/components/popover/test/position/index.html +++ b/core/src/components/popover/test/position/index.html @@ -293,6 +293,37 @@

End, End

+ +
+

LTR, Start

+ + + + +
+ + +
+ +
+

RTL, Start

+ + + + +
diff --git a/core/src/components/popover/test/position/popover.e2e.ts b/core/src/components/popover/test/position/popover.e2e.ts index 12103639da7..84e0abefbcd 100644 --- a/core/src/components/popover/test/position/popover.e2e.ts +++ b/core/src/components/popover/test/position/popover.e2e.ts @@ -12,7 +12,9 @@ configs().forEach(({ title, screenshot, config }) => { */ test('should not have visual regressions', async ({ page }) => { await page.goto('/src/components/popover/test/position', config); - await page.setViewportSize(Viewports.tablet.portrait); // avoid extra-long viewport screenshots + + // Set viewport large enough for all buttons and popovers to be visible at once. + await page.setViewportSize({ width: 768, height: 1024 }); const sides = ['top', 'right', 'bottom', 'left', 'start', 'end']; const alignments = ['start', 'center', 'end']; @@ -23,7 +25,40 @@ configs().forEach(({ title, screenshot, config }) => { } } + await openPopover(page, `ltr-start`, true); + await openPopover(page, `rtl-start`, true); + await expect(page).toHaveScreenshot(screenshot(`popover-position`)); }); }); }); + +configs({ directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => { + test.describe(title('popover: position'), async () => { + test('should be on the left when side is start and an ancestor declares ltr', async ({ page }) => { + await page.goto('/src/components/popover/test/position', config); + await page.setViewportSize(Viewports.tablet.portrait); + await openPopover(page, `ltr-start`); + + const button = page.locator('#ltr-start'); + const popoverContent = page.locator('.ltr-start-popover').locator('.popover-content'); + const buttonBox = await button.boundingBox(); + const popoverBox = await popoverContent.boundingBox(); + + expect(popoverBox!.x).toBeLessThan(buttonBox!.x); + }); + + test('should be on the right when side is start and an ancestor declares rtl', async ({ page }) => { + await page.goto('/src/components/popover/test/position', config); + await page.setViewportSize(Viewports.tablet.portrait); + await openPopover(page, `rtl-start`); + + const button = page.locator('#rtl-start'); + const popoverContent = page.locator('.rtl-start-popover').locator('.popover-content'); + const buttonBox = await button.boundingBox(); + const popoverBox = await popoverContent.boundingBox(); + + expect(popoverBox!.x).toBeGreaterThan(buttonBox!.x); + }); + }); +}); diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Chrome-linux.png index 2aa6dbb7e32..71994c09b3d 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Firefox-linux.png index aa5e19ccbfc..96010539ba9 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Safari-linux.png index 6327a6eeccf..a71ffcf418a 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Chrome-linux.png index cec9798fd44..192183c6e5b 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Firefox-linux.png index 5d79a466c98..8c49b5747f6 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Safari-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Safari-linux.png index 7f89dcc6cc1..565e6522a00 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Chrome-linux.png index d7160356206..8fcf9eabcfe 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Firefox-linux.png index 0b54931809c..eff049ef4b0 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Safari-linux.png index c712fc4d260..9079e768664 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Chrome-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Chrome-linux.png index 14c42c49493..4546a52ee4d 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Firefox-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Firefox-linux.png index 8908c6d83fd..da80c62825b 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Safari-linux.png b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Safari-linux.png index 9cce1705a15..1e1c48789aa 100644 Binary files a/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Safari-linux.png and b/core/src/components/popover/test/position/popover.e2e.ts-snapshots/popover-position-md-rtl-Mobile-Safari-linux.png differ 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..37cdc8d1556 --- /dev/null +++ b/core/src/components/progress-bar/test/progress-bar.spec.ts @@ -0,0 +1,32 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { ProgressBar } from '../progress-bar'; + +describe('ion-progress-bar: rtl', () => { + const newProgressBar = async (html: string, documentDir?: 'rtl') => { + const page = await newSpecPage({ components: [ProgressBar], html: `
` }); + if (documentDir) { + page.doc.documentElement.setAttribute('dir', documentDir); + } + await page.setContent(html); + return page.body.querySelector('ion-progress-bar')!; + }; + + it('should reverse when it declares rtl', async () => { + const progressBar = await newProgressBar(``); + expect(progressBar).toHaveClass('progress-bar-reversed'); + }); + + 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 in an rtl document', async () => { + const progressBar = await newProgressBar( + `
`, + 'rtl' + ); + 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..03c7d39bcc6 --- /dev/null +++ b/core/src/components/title/test/title.spec.ts @@ -0,0 +1,29 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { ToolbarTitle } from '../title'; + +describe('ion-title: rtl', () => { + const newTitle = async (html: string, documentDir?: 'rtl') => { + const page = await newSpecPage({ components: [ToolbarTitle], html: `
` }); + if (documentDir) { + page.doc.documentElement.setAttribute('dir', documentDir); + } + await page.setContent(html); + return page.body.querySelector('ion-title')!; + }; + + it('should set title-rtl when it declares rtl', async () => { + const title = await newTitle(`Title`); + expect(title).toHaveClass('title-rtl'); + }); + + 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 in an rtl document', async () => { + const title = await newTitle(`
Title
`, 'rtl'); + 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..a0168c80eae 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, falling back to the document. */ -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.spec.ts b/core/src/utils/input-shims/hacks/common.spec.ts new file mode 100644 index 00000000000..9b4debc2ced --- /dev/null +++ b/core/src/utils/input-shims/hacks/common.spec.ts @@ -0,0 +1,42 @@ +import { relocateInput } from './common'; + +describe('relocateInput', () => { + const render = (html: string) => { + document.body.innerHTML = html; + + const componentEl = document.body.querySelector('ion-input') as HTMLElement; + const inputEl = componentEl.querySelector('input') as HTMLInputElement; + + Object.defineProperty(inputEl.parentElement, 'offsetWidth', { value: 300 }); + Object.defineProperty(inputEl, 'offsetLeft', { value: 40 }); + Object.defineProperty(inputEl, 'offsetWidth', { value: 200 }); + + return { componentEl, inputEl }; + }; + + const INPUT = `
`; + + afterEach(() => { + document.dir = ''; + document.body.innerHTML = ''; + }); + + it('should move the input off the right when an ancestor declares rtl', () => { + const { componentEl, inputEl } = render(`
${INPUT}
`); + + relocateInput(componentEl, inputEl, true); + + expect(inputEl.style.transform).toBe('translate3d(9999px,0px,0) scale(0)'); + expect((componentEl.querySelector('.cloned-input') as HTMLElement).style.insetInlineStart).toBe('60px'); + }); + + it('should move input off the left when an ancestor declares ltr in an rtl document', () => { + document.dir = 'rtl'; + const { componentEl, inputEl } = render(`
${INPUT}
`); + + relocateInput(componentEl, inputEl, true); + + expect(inputEl.style.transform).toBe('translate3d(-9999px,0px,0) scale(0)'); + expect((componentEl.querySelector('.cloned-input') as HTMLElement).style.insetInlineStart).toBe('40px'); + }); +}); 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..ababa44c277 100644 --- a/core/src/utils/rtl/dir.spec.ts +++ b/core/src/utils/rtl/dir.spec.ts @@ -70,25 +70,27 @@ describe('rtl: dir', () => { expect(isRTL(document.createElement('div'))).toBe(true); }); + it('should use the dir of the document that owns the element', () => { + const otherDoc = { dir: 'rtl' }; + const el = document.createElement('div'); + Object.defineProperty(el, 'ownerDocument', { value: otherDoc }); + + otherDoc.dir = 'rtl'; + document.dir = 'ltr'; + expect(isRTL(el)).toBe(true); + + otherDoc.dir = 'ltr'; + document.dir = 'rtl'; + expect(isRTL(el)).toBe(false); + }); + it('should default to ltr when no dir is set anywhere', () => { // Ensure the default is actually being tested rather than a // 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 83698480020..f562fbd1c14 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'; + ...