Skip to content

fix(rtl): respect dir of ancestor elements throughout components - #31459

Open
OS-jacobbell wants to merge 6 commits into
mainfrom
FW-7698
Open

OS-jacobbell wants to merge 6 commits into
mainfrom
FW-7698

Conversation

@OS-jacobbell

Copy link
Copy Markdown
Contributor

Issue number: internal


What is the current behavior?

Some components check document.dir === 'rtl' to determine whether to be left-to-right or right-to-left.

What is the new behavior?

  • Components consistently use isRTL(this.el). This way, any element in the parent chain can define RTL, not just the root document element.
  • The host element is a required parameter to isRTL.

Does this introduce a breaking change?

  • Yes
  • No

@OS-jacobbell
OS-jacobbell requested a review from a team as a code owner September 18, 2026 18:47
@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ionic-framework Ready Ready Preview Sep 24, 2026 4:51pm UTC

Request Review

@github-actions github-actions Bot added the package: core @ionic/core package label Sep 18, 2026
@OS-jacobbell OS-jacobbell changed the title fix(i18n): respect parent rtl setting throughout components fix(rtl): respect dir setting of ancestor elements throughout components Sep 21, 2026
@OS-jacobbell OS-jacobbell changed the title fix(rtl): respect dir setting of ancestor elements throughout components fix(rtl): respect dir of ancestor elements throughout components Sep 21, 2026

@ShaneK ShaneK left a comment

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.

Nice sweep of this! Just a few comments inline, though I'd want the test coverage added before it goes in.

expect(isRTL()).toBe(false);
expect(isRTL(null)).toBe(false);
expect(isRTL(document.createElement('div'))).toBe(false);
expect(isRTL(render('<div><div id="target"></div></div>'))).toBe(false);

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.

The two tests going away here are right, they only covered the optional-param path that no longer typechecks. But nine components changed how they resolve direction in this PR and nothing covers any of them, which FW-7698 does ask for.

The existing suite won't catch a regression either, since the e2e harness sets RTL with document.documentElement.setAttribute('dir', 'rtl'). That's an ancestor, so the RTL screenshots would pass identically on main. The datetime "RTL set on component" test is a decent template.

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.

Thanks for adding these. Running them against the old sources, only 10 of the 44 actually fail, so the rest aren't covering the change yet. I've left notes on the individual files.

The gap left in here is that all of dir.spec.ts passes on main too, so the document.dir to hostEl.ownerDocument.dir swap in the util itself still isn't covered.

Comment thread docs/component-guide.md Outdated
Comment thread core/src/components/progress-bar/progress-bar.tsx Outdated

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.

@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.

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.

Also generated. I feel like this should be a screenshot test, but not sure how that would fit into the test suite.

@ShaneK ShaneK left a comment

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.

Thanks for adding the tests and taking the suggestions! Most of them pass on main too though, so they aren't covering the change yet, and CI is red on the new popover one. I've left comments inline.

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 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.

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.

const { trigger, content, originX } = await openStartSidePopover(page, config, 'ltr');

expect(content.x + content.width).toBeLessThanOrEqual(trigger.x);
expect(originX).toBe(`${content.width}px`);

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.

This is what's failing CI, and the placement itself is fine. Both assertions compare against a rect that gets measured through the transform the enter animation leaves behind, so Firefox comes back a fraction of a pixel out and both comparisons are sitting right on the boundary. The two numbers differ in the fifth decimal place.

Comparing the origin as a ratio of offsetWidth instead, with a little tolerance on the geometry, gets it green in all three browsers and it still fails against main, so it keeps catching the regression. The ltr case needs the tolerance too, it only survives right now because the iOS arrow gives it a gap.

I don't think a screenshot works for this one. It's a few pixels of horizontal placement, and it couldn't check transform-origin at all, which is half of what can go wrong since a popover can be positioned correctly and still scale from the wrong edge.

I'd also put it in the existing position directory rather than a new rtl one, and the other popover test dirs all have an index.html so you can open the case in the dev server.

*/
const doc = componentEl.ownerDocument!;
const isRTL = doc.dir === 'rtl';
const rtl = isRTL(componentEl);

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.

This is the one changed spot with a numeric consequence and nothing covers it. The only e2e in that test directory never sets dir and never checks where the clone ends up, it just selects :not(.cloned-input) to exclude it. If this branch goes the wrong way the real input gets parked 9999px off in the wrong direction on keyboard focus.

Comment thread core/src/utils/rtl/dir.ts
}
}
return document?.dir?.toLowerCase() === 'rtl';
return hostEl.ownerDocument?.dir?.toLowerCase() === 'rtl';

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.

Two direct dir reads survived the sweep.

The ion-datetime nav buttons take their direction from the host's own dir attribute and pass it to the chevron icons, so with <div dir="rtl"><ion-datetime> it comes out undefined and the arrows point the wrong way. The stamping is load-bearing since ionicons' own isRTL only reads the icon's own dir before falling back to the document. Switching it to isRTL(this.el) ? 'rtl' : 'ltr' flips them correctly with the existing direct-dir test still passing.

The Angular package's Platform.isRTL still returns this.doc.dir === 'rtl'. That one is document-scoped by design with no host element to work from, so it might be deliberately out of scope, but it's public API that now disagrees with every core component for apps that set dir on an element instead of <html>.

expect(isRTL()).toBe(false);
expect(isRTL(null)).toBe(false);
expect(isRTL(document.createElement('div'))).toBe(false);
expect(isRTL(render('<div><div id="target"></div></div>'))).toBe(false);

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.

Thanks for adding these. Running them against the old sources, only 10 of the 44 actually fail, so the rest aren't covering the change yet. I've left notes on the individual files.

The gap left in here is that all of dir.spec.ts passes on main too, so the document.dir to hostEl.ownerDocument.dir swap in the util itself still isn't covered.

This branch was successfully deployed

1 active deployment
Preview — 287adf8b Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants