Skip to content

Commit 2618a6f

Browse files
Arul1998brandyscarneyAustin-Spriggs
authored
fix(menu): respect ion-app dir attribute for menu animation side (#31246)
Issue number: resolves #30226 --------- ## What is the current behavior? When `dir="rtl"` is set on `<ion-app>` (but not on `document`), the hamburger menu appears on the right side visually, but the **open animation still slides in from the left**. This happens because `isEndSide()` only checked `document.dir`, not the nearest ancestor `dir` attribute. ## What is the new behavior? - `isEndSide()` takes an optional host element and delegates to `isRTL()` instead of reading `document.dir` itself. - `isRTL()` now walks up from the given element to the nearest ancestor that declares a `dir`, so `<ion-app dir="rtl">` applies to everything inside it even when the document is `ltr`. It previously read only the element's own `dir` before falling back to `document.dir`. - `sideChanged()` in `ion-menu` also passes the menu element so the side is correct before the first animation. - Unit tests added for the `ion-app dir="rtl"` case. ## Does this introduce a breaking change? - [ ] Yes - [x] No --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Co-authored-by: Austin-Spriggs <aspriggs@madisoncollege.edu>
1 parent b078361 commit 2618a6f

5 files changed

Lines changed: 147 additions & 31 deletions

File tree

core/src/components/menu/menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export class Menu implements ComponentInterface, MenuI {
150150

151151
@Watch('side')
152152
protected sideChanged() {
153-
this.isEndSide = isEnd(this.side);
153+
this.isEndSide = isEnd(this.side, this.el);
154154
/**
155155
* Menu direction animation is calculated based on the document direction.
156156
* If the document direction changes, we need to create a new animation.
@@ -499,7 +499,7 @@ export class Menu implements ComponentInterface, MenuI {
499499
* Menu direction animation is calculated based on the document direction.
500500
* If the document direction changes, we need to create a new animation.
501501
*/
502-
const isEndSide = isEnd(this.side);
502+
const isEndSide = isEnd(this.side, this.el);
503503
if (width === this.width && this.animation !== undefined && isEndSide === this.isEndSide) {
504504
return;
505505
}

core/src/utils/helpers.spec.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,36 @@
1-
import { inheritAriaAttributes } from './helpers';
1+
import { inheritAriaAttributes, isEndSide } from './helpers';
2+
3+
describe('isEndSide', () => {
4+
afterEach(() => {
5+
document.dir = '';
6+
document.body.innerHTML = '';
7+
});
8+
9+
it('should use document direction when no host element is provided', () => {
10+
document.dir = 'ltr';
11+
expect(isEndSide('start')).toBe(false);
12+
expect(isEndSide('end')).toBe(true);
13+
14+
document.dir = 'rtl';
15+
expect(isEndSide('start')).toBe(true);
16+
expect(isEndSide('end')).toBe(false);
17+
});
18+
19+
// https://github.com/ionic-team/ionic-framework/issues/30226
20+
it('should use the nearest ancestor dir attribute', () => {
21+
document.dir = 'ltr';
22+
23+
const app = document.createElement('ion-app');
24+
app.setAttribute('dir', 'rtl');
25+
26+
const menu = document.createElement('ion-menu');
27+
app.appendChild(menu);
28+
document.body.appendChild(app);
29+
30+
expect(isEndSide('start', menu)).toBe(true);
31+
expect(isEndSide('end', menu)).toBe(false);
32+
});
33+
});
234

335
describe('inheritAriaAttributes', () => {
436
it('should inherit aria attributes', () => {

core/src/utils/helpers.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EventEmitter } from '@stencil/core';
22
import { printIonError } from '@utils/logging';
3+
import { isRTL } from '@utils/rtl';
34

45
import type { Side } from '../components/menu/menu-interface';
56

@@ -316,18 +317,22 @@ export const pointerCoord = (ev: any): { x: number; y: number } => {
316317

317318
/**
318319
* @hidden
319-
* Given a side, return if it should be on the end
320-
* based on the value of dir
321-
* @param side the side
322-
* @param isRTL whether the application dir is rtl
320+
* Given a side, returns whether it resolves to the end side for the current
321+
* direction. In RTL `start` is the end side, and in LTR `end` is.
322+
*
323+
* @param side The current side before being redefined based on the direction.
324+
* @param hostEl The component's host element. The direction is resolved from
325+
* it or its nearest ancestor that declares one. When omitted, the direction
326+
* is resolved from the document.
323327
*/
324-
export const isEndSide = (side: Side): boolean => {
325-
const isRTL = document.dir === 'rtl';
328+
export const isEndSide = (side: Side, hostEl?: HTMLElement): boolean => {
329+
const rtl = isRTL(hostEl);
330+
326331
switch (side) {
327332
case 'start':
328-
return isRTL;
333+
return rtl;
329334
case 'end':
330-
return !isRTL;
335+
return !rtl;
331336
default:
332337
throw new Error(`"${side}" is not a valid value for [side]. Use "start" or "end" instead.`);
333338
}

core/src/utils/rtl/dir.spec.ts

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,93 @@
11
import { isRTL } from './dir';
22

33
describe('rtl: dir', () => {
4-
describe('with host element', () => {
5-
it('should return true', () => {
6-
expect(isRTL({ dir: 'rtl' })).toBe(true);
4+
/**
5+
* Renders the given markup and returns the element with `id="target"`.
6+
*/
7+
const render = (html: string): Element => {
8+
document.body.innerHTML = html;
9+
10+
const target = document.body.querySelector('#target');
11+
if (target === null) {
12+
throw new Error('Test markup must contain an element with id="target".');
13+
}
14+
15+
return target;
16+
};
17+
18+
beforeEach(() => {
19+
/**
20+
* Reset to the state of a document that never set a direction, rather than
21+
* to `ltr`, so that tests relying on the default are not masked.
22+
*/
23+
document.dir = '';
24+
document.body.innerHTML = '';
25+
});
26+
27+
describe('with a host element', () => {
28+
it('should use the dir on the element itself', () => {
29+
expect(isRTL(render('<div id="target" dir="rtl"></div>'))).toBe(true);
30+
expect(isRTL(render('<div id="target" dir="ltr"></div>'))).toBe(false);
31+
});
32+
33+
it('should use the nearest ancestor that declares a dir', () => {
34+
expect(isRTL(render('<div dir="rtl"><div><div id="target"></div></div></div>'))).toBe(true);
35+
expect(isRTL(render('<div dir="ltr"><div><div id="target"></div></div></div>'))).toBe(false);
36+
});
37+
38+
it('should let an inner dir override an outer one', () => {
39+
expect(isRTL(render('<div dir="rtl"><div dir="ltr" id="target"></div></div>'))).toBe(false);
40+
expect(isRTL(render('<div dir="ltr"><div dir="rtl" id="target"></div></div>'))).toBe(true);
41+
});
42+
43+
it('should ignore casing', () => {
44+
expect(isRTL(render('<div id="target" dir="RTL"></div>'))).toBe(true);
45+
expect(isRTL(render('<div dir="RTL"><div id="target" dir="LTR"></div></div>'))).toBe(false);
746
});
847

9-
it('should return false', () => {
10-
expect(isRTL({ dir: 'ltr' })).toBe(false);
11-
expect(isRTL({ dir: '' })).toBe(false);
48+
it('should skip values that do not declare a direction', () => {
49+
/**
50+
* `dir=""`, `dir="auto"` and unknown values are not used as a direction,
51+
* so the nearest ancestor that does declare one still wins.
52+
*/
53+
expect(isRTL(render('<div dir="rtl"><div id="target" dir=""></div></div>'))).toBe(true);
54+
expect(isRTL(render('<div dir="rtl"><div id="target" dir="auto"></div></div>'))).toBe(true);
55+
expect(isRTL(render('<div dir="rtl"><div id="target" dir="sideways"></div></div>'))).toBe(true);
1256
});
1357
});
1458

15-
describe('without host element', () => {
16-
it('should return true', () => {
17-
global.document.dir = 'rtl';
18-
expect(isRTL()).toBe(true);
59+
describe('falling back to the document', () => {
60+
it('should use the document dir when no ancestor declares one', () => {
61+
document.dir = 'rtl';
62+
expect(isRTL(render('<div><div id="target"></div></div>'))).toBe(true);
63+
64+
document.dir = 'ltr';
65+
expect(isRTL(render('<div><div id="target"></div></div>'))).toBe(false);
66+
});
67+
68+
it('should use the document dir for a detached element', () => {
69+
document.dir = 'rtl';
70+
expect(isRTL(document.createElement('div'))).toBe(true);
1971
});
2072

21-
it('should return false', () => {
22-
global.document.dir = 'ltr';
73+
it('should default to ltr when no dir is set anywhere', () => {
74+
// Ensure the default is actually being tested rather than a
75+
// value left behind by another test.
76+
expect(document.dir).toBe('');
77+
78+
expect(isRTL()).toBe(false);
79+
expect(isRTL(null)).toBe(false);
80+
expect(isRTL(document.createElement('div'))).toBe(false);
81+
expect(isRTL(render('<div><div id="target"></div></div>'))).toBe(false);
82+
});
83+
});
84+
85+
describe('without a host element', () => {
86+
it('should use the document dir', () => {
87+
document.dir = 'rtl';
88+
expect(isRTL()).toBe(true);
89+
90+
document.dir = 'ltr';
2391
expect(isRTL()).toBe(false);
2492
});
2593
});

core/src/utils/rtl/dir.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
/**
2-
* Returns `true` if the document or host element
3-
* has a `dir` set to `rtl`. The host value will always
4-
* take priority over the root document value.
2+
* Returns `true` if the direction resolves to `rtl` for the given element.
3+
*
4+
* The direction comes from the nearest ancestor that declares one, starting
5+
* with `hostEl` itself, and falls back to the root document value. Setting
6+
* `dir="auto"` or setting `dir` to an empty string are skipped rather than
7+
* treated as `ltr`. When nothing declares a direction, including the document,
8+
* the direction is `ltr`.
9+
*
10+
* @param hostEl the element to resolve the direction for.
511
*/
6-
export const isRTL = (hostEl?: Pick<HTMLElement, 'dir'>) => {
7-
if (hostEl) {
8-
if (hostEl.dir !== '') {
9-
return hostEl.dir.toLowerCase() === 'rtl';
12+
export const isRTL = (hostEl?: Element | null): boolean => {
13+
for (let el = hostEl; el; el = el.parentElement) {
14+
const dir = el.getAttribute('dir')?.toLowerCase();
15+
16+
if (dir === 'rtl') {
17+
return true;
18+
}
19+
if (dir === 'ltr') {
20+
return false;
1021
}
1122
}
12-
return document?.dir.toLowerCase() === 'rtl';
23+
return document?.dir?.toLowerCase() === 'rtl';
1324
};

0 commit comments

Comments
 (0)