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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions core/src/components/col/col.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
Expand Down Expand Up @@ -235,30 +238,30 @@ 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 (
<Host
class={{
[mode]: true,
}}
style={{
...this.calculateOffset(isRTL),
...this.calculatePull(isRTL),
...this.calculatePush(isRTL),
...this.calculateOffset(rtl),
...this.calculatePull(rtl),
...this.calculatePush(rtl),
...this.calculateSize(),
}}
>
Expand Down
36 changes: 36 additions & 0 deletions core/src/components/col/test/col.spec.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated this one with Claude. I don't get why the parameters to ion-col are needed, but it doesn't work without them.

Original file line number Diff line number Diff line change
@@ -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(`<ion-col offset="3" push="2" pull="1"></ion-col>`);
expect(col.style.marginLeft).not.toBe('');
expect(col.style.marginRight).toBe('');
expect(col.style.left).not.toBe('');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the attributes are needed because calculatePosition bails with a bare return when the prop isn't set, so a plain ion-col emits no style at all and every col.style.* comes back empty. That's the tell that the assertions are the problem though, they check that a style exists rather than which side it's on.

These two can't fail either way. Push and pull are both always written, they just swap between left and right, so the mirroring itself ends up with no coverage even though the test below is named for it. I deleted the mirroring for both and all four tests stayed green.

Asserting the exact calc() value would catch it, along with the rtl-document helper from the label comment.

expect(col.style.right).not.toBe('');
});

it('should mirror offset, push and pull when an ancestor declares rtl', async () => {
const col = await newCol(`<div dir="rtl"><div><ion-col offset="3" push="2" pull="1"></ion-col></div></div>`);
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(`<div dir="ltr"><ion-col offset="3"></ion-col></div>`);
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(`<div dir="rtl"><div dir="ltr"><ion-col offset="3"></ion-col></div></div>`);
expect(col.style.marginLeft).not.toBe('');
expect(col.style.marginRight).toBe('');
});
});
2 changes: 1 addition & 1 deletion core/src/components/item-options/item-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Host
class={{
Expand Down
33 changes: 33 additions & 0 deletions core/src/components/item-options/test/item-options.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { newSpecPage } from '@stencil/core/testing';

import { ItemOptions } from '../item-options';

describe('ion-item-options: rtl', () => {
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(
`<div dir="rtl"><div><ion-item-options side="start"></ion-item-options></div></div>`
);
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(
`<div dir="ltr"><div><ion-item-options side="start"></ion-item-options></div></div>`
);
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(
`<div dir="rtl"><div dir="ltr"><ion-item-options side="start"></ion-item-options></div></div>`
);
expect(itemOptions).toHaveClass('item-options-start');
});
});
7 changes: 4 additions & 3 deletions core/src/components/item-sliding/item-sliding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions core/src/components/item-sliding/test/item-sliding.spec.ts

@OS-jacobbell OS-jacobbell Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated; it seems too dependent on implementation details.

Original file line number Diff line number Diff line change
@@ -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: `<ion-item-sliding>
<ion-item>Item</ion-item>
<ion-item-options ${optionsAttrs}></ion-item-options>
</ion-item-sliding>`,
});

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');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're half right, though the bigger problem is a coverage gap. This PR changed two lines here, the side resolution in open() and the bucket assignment in updateOptions, and the spec only reaches the second one. I put the open() resolution back to reading the document and all four tests still passed.

There isn't a non-class observable to swap in, since open() works the amount out in a rAF from offsetWidth and a spec page has no layout, so getOpenAmount() just comes back NaN or 0.

The class you want is item-sliding-active-options-start or -end, which get set from the resolved side rather than just telling you getOptions found a bucket. A helper returning start, end or none from those gives you one positive assertion per case instead of two renders and an inverted pair, and it catches the open() change too.

};

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: `<div dir="rtl">
<ion-item-sliding>
<ion-item>Item</ion-item>
<ion-item-options side="start"></ion-item-options>
</ion-item-sliding>
</div>`,
});
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');
});
});
3 changes: 2 additions & 1 deletion core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}
Expand Down
22 changes: 22 additions & 0 deletions core/src/components/item/test/item.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div dir="rtl"><div><ion-item>Item</ion-item></div></div>`);
expect(item).toHaveClass('item-rtl');
});

it('should not set item-rtl when an ancestor declares ltr', async () => {
const item = await newItemPage(`<div dir="ltr"><ion-item>Item</ion-item></div>`);
expect(item).not.toHaveClass('item-rtl');
});

it('should use the nearest ancestor that declares a dir', async () => {
const item = await newItemPage(`<div dir="rtl"><div dir="ltr"><ion-item>Item</ion-item></div></div>`);
expect(item).not.toHaveClass('item-rtl');
});
});
});
3 changes: 2 additions & 1 deletion core/src/components/label/label.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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),
})}
>
<slot></slot>
Expand Down
25 changes: 25 additions & 0 deletions core/src/components/label/test/label.spec.ts
Original file line number Diff line number Diff line change
@@ -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(`<div dir="rtl"><div><ion-label>Label</ion-label></div></div>`);
expect(label).toHaveClass('label-rtl');
});

it('should not set label-rtl when an ancestor declares ltr', async () => {
const label = await newLabel(`<div dir="ltr"><ion-label>Label</ion-label></div>`);
expect(label).not.toHaveClass('label-rtl');
});

it('should use the nearest ancestor that declares a dir', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every should use the nearest ancestor that declares a dir test passes on main too. The document has no dir in a spec page, so document.dir === 'rtl' is false and the old code resolves to ltr for the same reason the new code does. Most of the other new spec files have the same test in them.

For the precedence check to mean anything the document has to declare rtl, and setting document.dir before newSpecPage gets thrown away since it installs a fresh mock document. This works:

const newInRtlDocument = async (html: string) => {
  const page = await newSpecPage({ components: [Label], html: `<div></div>` });
  page.doc.documentElement.setAttribute('dir', 'rtl');
  await page.setContent(html);
  return page.body.querySelector('ion-label')!;
};

const label = await newLabel(`<div dir="rtl"><div dir="ltr"><ion-label>Label</ion-label></div></div>`);
expect(label).not.toHaveClass('label-rtl');
});
});
7 changes: 4 additions & 3 deletions core/src/components/popover/animations/ios.enter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading