From 4918d4a1fc2b1bd1d52ea926a9d2e09d75006519 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 16 Sep 2026 09:38:31 +0900 Subject: [PATCH] fix: support RTL layout --- demo/e2e/rtl.spec.ts | 102 ++++++++++++++++++ demo/src/app/album/album-page.component.scss | 2 +- .../app/index/pages/button/button.page.scss | 2 +- demo/src/app/index/pages/tabs/tabs.page.scss | 7 +- .../app/settings/settings-page.component.scss | 4 +- package-lock.json | 4 +- package.json | 2 +- src/styles/components/ion-list.scss | 6 +- src/styles/components/ion-range.scss | 43 +++++--- src/styles/components/ion-toolbar.scss | 7 +- src/transition/md.transition.ts | 11 +- 11 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 demo/e2e/rtl.spec.ts diff --git a/demo/e2e/rtl.spec.ts b/demo/e2e/rtl.spec.ts new file mode 100644 index 0000000..b4ec920 --- /dev/null +++ b/demo/e2e/rtl.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; + +for (const direction of ['ltr', 'rtl'] as const) { + test(`${direction} mirrors range geometry`, async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.evaluate((dir) => { + const fixture = document.createElement('div'); + fixture.id = 'rtl-range-probe'; + fixture.dir = dir; + + const range = document.createElement('ion-range'); + range.id = 'single-range'; + range.mode = 'md'; + range.min = 0; + range.max = 100; + range.value = 50; + + const dual = document.createElement('ion-range'); + dual.id = 'dual-range'; + dual.mode = 'md'; + dual.dualKnobs = true; + dual.min = 0; + dual.max = 100; + dual.value = { lower: 0, upper: 100 }; + + fixture.append(range, dual); + document.body.append(fixture); + }, direction); + + await expect(page.locator('#single-range')).toHaveClass(/hydrated/); + await expect(page.locator('#dual-range')).toHaveClass(/hydrated/); + + const normalOffset = await page.locator('#single-range').evaluate((range) => { + const knob = range.shadowRoot!.querySelector('[part~="knob"]')!; + return new DOMMatrixReadOnly(getComputedStyle(knob).transform).e; + }); + expect(normalOffset).toBeCloseTo(direction === 'ltr' ? 20 : -20, 1); + + await page.locator('#single-range').evaluate((range) => range.classList.add('range-pressed')); + await expect + .poll(() => + page.locator('#single-range').evaluate((range) => { + const knob = range.shadowRoot!.querySelector('[part~="knob"]')!; + return new DOMMatrixReadOnly(getComputedStyle(knob).transform).e; + }), + ) + .toBeCloseTo(direction === 'ltr' ? 21 : -21, 1); + + const dualMargins = await page.locator('#dual-range').evaluate((range) => { + const bar = range.shadowRoot!.querySelector('[part~="bar"]')!; + const style = getComputedStyle(bar); + return { start: style.marginInlineStart, end: style.marginInlineEnd }; + }); + expect(dualMargins).toEqual({ start: '6px', end: '6px' }); + }); + + test(`${direction} mirrors Material forward and back transitions`, async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.evaluate((dir) => { + document.documentElement.dir = dir; + const originalAnimate = Element.prototype.animate; + (window as any).__RTL_ANIMATIONS__ = []; + Element.prototype.animate = function (keyframes, options) { + const frames = Array.isArray(keyframes) ? keyframes : []; + (window as any).__RTL_ANIMATIONS__.push({ + tag: this.localName, + transforms: frames.map((frame) => frame.transform).filter(Boolean), + }); + return originalAnimate.call(this, keyframes, options); + }; + }, direction); + await page.getByRole('button', { name: 'button', exact: true }).click(); + + const expected = direction === 'ltr' ? 'translateX(40px)' : 'translateX(-40px)'; + const stacked = direction === 'ltr' ? 'translateX(-40px)' : 'translateX(40px)'; + await expect + .poll(() => + page.evaluate( + ({ expected, stacked }) => { + const calls = (window as any).__RTL_ANIMATIONS__ as { tag: string; transforms: string[] }[]; + return { + entering: calls.some((call) => call.tag === 'app-button' && call.transforms.includes(expected)), + stacked: calls.some((call) => call.transforms.includes(stacked)), + }; + }, + { expected, stacked }, + ), + ) + .toEqual({ entering: true, stacked: true }); + + await page.evaluate(() => ((window as any).__RTL_ANIMATIONS__ = [])); + await page.locator('app-button > ion-header ion-back-button').click(); + await expect + .poll(() => + page.evaluate((expectedTransform) => { + const calls = (window as any).__RTL_ANIMATIONS__ as { tag: string; transforms: string[] }[]; + return calls.some((call) => call.tag === 'app-button' && call.transforms.includes(expectedTransform)); + }, expected), + ) + .toBe(true); + }); +} diff --git a/demo/src/app/album/album-page.component.scss b/demo/src/app/album/album-page.component.scss index 4da32cd..884ebed 100644 --- a/demo/src/app/album/album-page.component.scss +++ b/demo/src/app/album/album-page.component.scss @@ -1,7 +1,7 @@ ion-header { position: absolute; top: 0; - left: 0; + inset-inline-start: 0; } ion-content { diff --git a/demo/src/app/index/pages/button/button.page.scss b/demo/src/app/index/pages/button/button.page.scss index 27efdf1..fcea56b 100644 --- a/demo/src/app/index/pages/button/button.page.scss +++ b/demo/src/app/index/pages/button/button.page.scss @@ -1,3 +1,3 @@ ion-item *[slot='end'] { - margin-right: 6px; + margin-inline-end: 6px; } diff --git a/demo/src/app/index/pages/tabs/tabs.page.scss b/demo/src/app/index/pages/tabs/tabs.page.scss index 1fa678c..0676826 100644 --- a/demo/src/app/index/pages/tabs/tabs.page.scss +++ b/demo/src/app/index/pages/tabs/tabs.page.scss @@ -1,6 +1,9 @@ :host { ion-tab-bar { - margin-left: calc(16px + var(--ion-safe-area-left, 0px)); - margin-right: calc(16px + var(--ion-safe-area-right, 0px)); + margin-inline: calc(16px + var(--ion-safe-area-left, 0px)) calc(16px + var(--ion-safe-area-right, 0px)); + + &:dir(rtl) { + margin-inline: calc(16px + var(--ion-safe-area-right, 0px)) calc(16px + var(--ion-safe-area-left, 0px)); + } } } diff --git a/demo/src/app/settings/settings-page.component.scss b/demo/src/app/settings/settings-page.component.scss index bad2a8c..b2989ad 100644 --- a/demo/src/app/settings/settings-page.component.scss +++ b/demo/src/app/settings/settings-page.component.scss @@ -14,7 +14,7 @@ ion-item > ion-icon[slot='start'] { ion-item > ion-icon[slot='start'] { padding: 4px; font-size: 1.3rem; - margin-right: 14px; + margin-inline-end: 14px; border-radius: 8px; } } @@ -24,7 +24,7 @@ ion-item > ion-icon[slot='start'] { padding: 8px; width: calc(40px - 16px); height: calc(40px - 16px); - margin-right: 16px; + margin-inline-end: 16px; border-radius: 50%; background: var(--ion-color-light-shade); } diff --git a/package-lock.json b/package-lock.json index 10f9342..9f70cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "9.1.1", "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#main" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" }, "devDependencies": { "@ionic/angular": "^9.0.0", @@ -2237,7 +2237,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#2e94a70d45fea28aec0e49407ebaffdfbc7c17f7", + "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6", "license": "MIT", "peerDependencies": { "@ionic/core": ">=8.8.1 <10" diff --git a/package.json b/package.json index 93ef3e3..d338635 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-md3", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#main" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" }, "devDependencies": { "@ionic/angular": "^9.0.0", diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss index 91457c9..5319393 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -58,5 +58,9 @@ ion-list.md:not(.md3-disabled).list-inset { } ion-list-header.md:not(.md3-disabled) { - padding-left: calc(var(--ion-safe-area-left, 0px) + 4px); + padding-inline-start: calc(var(--ion-safe-area-left, 0px) + 4px); + + &:dir(rtl) { + padding-inline-start: calc(var(--ion-safe-area-right, 0px) + 4px); + } } diff --git a/src/styles/components/ion-range.scss b/src/styles/components/ion-range.scss index 6ef843f..1110708 100644 --- a/src/styles/components/ion-range.scss +++ b/src/styles/components/ion-range.scss @@ -27,6 +27,10 @@ ion-range.md:not(.md3-disabled) { } } + &:dir(rtl)::part(knob) { + transform: translateX(calc((var(--knob-size) / 2 - 2px) * -1)); + } + &.ion-color { --knob-background: var(--ion-color-base, var(--ion-color-primary)); } @@ -42,6 +46,10 @@ ion-range.md:not(.md3-disabled) { transform: translateX(calc(var(--knob-size) / 2 - 1px)); } + &.range-pressed:dir(rtl)::part(knob) { + transform: translateX(calc((var(--knob-size) / 2 - 1px) * -1)); + } + &:not(.range-value-min):not(.range-value-max)::part(knob) { outline: 6px solid var(--ion-background-color, #ffffff); } @@ -50,27 +58,27 @@ ion-range.md:not(.md3-disabled) { } &:not(.range-value-max)::part(bar-active) { - border-top-right-radius: calc(var(--knob-border-radius) / 2); - border-bottom-right-radius: calc(var(--knob-border-radius) / 2); - margin-right: 6px; + border-start-end-radius: calc(var(--knob-border-radius) / 2); + border-end-end-radius: calc(var(--knob-border-radius) / 2); + margin-inline-end: 6px; } &.range-value-min { &::part(bar) { - border-top-left-radius: calc(var(--knob-border-radius) / 2); - border-bottom-left-radius: calc(var(--knob-border-radius) / 2); + border-start-start-radius: calc(var(--knob-border-radius) / 2); + border-end-start-radius: calc(var(--knob-border-radius) / 2); width: calc(100% - 6px); - right: 0; + inset-inline-end: 0; } } &.range-value-max { &::part(bar), &::part(bar-active) { - border-top-right-radius: calc(var(--knob-border-radius) / 2); - border-bottom-right-radius: calc(var(--knob-border-radius) / 2); + border-start-end-radius: calc(var(--knob-border-radius) / 2); + border-end-end-radius: calc(var(--knob-border-radius) / 2); width: calc(100% - 6px); - left: 0; + inset-inline-start: 0; } } } @@ -82,12 +90,17 @@ ion-range.md:not(.md3-disabled) { transform: translateX(calc(var(--knob-size) / 2 - 1px)); } + &.range-pressed-a:dir(rtl)::part(knob-a), + &.range-pressed-b:dir(rtl)::part(knob-b) { + transform: translateX(calc((var(--knob-size) / 2 - 1px) * -1)); + } + &.range-value-min { &::part(bar), &::part(bar-active) { - border-top-left-radius: calc(var(--knob-border-radius) / 2); - border-bottom-left-radius: calc(var(--knob-border-radius) / 2); - margin-left: 6px; + border-start-start-radius: calc(var(--knob-border-radius) / 2); + border-end-start-radius: calc(var(--knob-border-radius) / 2); + margin-inline-start: 6px; } &::part(bar) { width: calc(100% - 6px); @@ -96,9 +109,9 @@ ion-range.md:not(.md3-disabled) { &.range-value-max { &::part(bar), &::part(bar-active) { - border-top-right-radius: calc(var(--knob-border-radius) / 2); - border-bottom-right-radius: calc(var(--knob-border-radius) / 2); - margin-right: 6px; + border-start-end-radius: calc(var(--knob-border-radius) / 2); + border-end-end-radius: calc(var(--knob-border-radius) / 2); + margin-inline-end: 6px; } &::part(bar) { width: calc(100% - 6px); diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss index 7649521..252df0a 100644 --- a/src/styles/components/ion-toolbar.scss +++ b/src/styles/components/ion-toolbar.scss @@ -6,16 +6,15 @@ ion-footer.md:not(.md3-disabled) { ion-toolbar.md:not(.md3-disabled) { ion-buttons[slot='start'], ion-back-button[slot='start'] { - margin-left: 16px; + margin-inline-start: 16px; } ion-buttons[slot='end'], ion-back-button[slot='end'] { - margin-right: 16px; + margin-inline-end: 16px; } /* fallback ion-buttons[slot=end], ion-back-button[slot=end] */ ion-buttons > ion-back-button { - margin-left: 0; - margin-right: 0; + margin-inline: 0; } &:has(> [slot='start']) ion-title { diff --git a/src/transition/md.transition.ts b/src/transition/md.transition.ts index 9c75771..2879b12 100644 --- a/src/transition/md.transition.ts +++ b/src/transition/md.transition.ts @@ -4,7 +4,8 @@ import type { TransitionOptions } from './index'; import { getIonPageElement } from './index'; export const mdTransitionAnimation = (_: HTMLElement, opts: TransitionOptions): Animation => { - const OFF_RIGHT = 40; + const isRTL = opts.enteringEl.ownerDocument.dir === 'rtl'; + const OFF_END = isRTL ? -40 : 40; const CENTER = 0; const backDirection = opts.direction === 'back'; @@ -22,12 +23,12 @@ export const mdTransitionAnimation = (_: HTMLElement, opts: TransitionOptions): rootTransition .duration((opts.duration ?? 0) || 200) .easing('cubic-bezier(0.47,0,0.745,0.715)') - .fromTo('transform', `translateX(${OFF_RIGHT * -1}px)`, `translateX(${CENTER}px)`); + .fromTo('transform', `translateX(${OFF_END * -1}px)`, `translateX(${CENTER}px)`); } else { rootTransition .duration((opts.duration ?? 0) || 280) .easing('cubic-bezier(0.36,0.66,0.04,1)') - .fromTo('transform', `translateX(${OFF_RIGHT}px)`, `translateX(${CENTER}px)`) + .fromTo('transform', `translateX(${OFF_END}px)`, `translateX(${CENTER}px)`) .fromTo('opacity', 0.01, 1); } @@ -46,7 +47,7 @@ export const mdTransitionAnimation = (_: HTMLElement, opts: TransitionOptions): stackedTransition .duration((opts.duration ?? 0) || 280) .easing('cubic-bezier(0.36,0.66,0.04,1)') - .fromTo('transform', `translateX(${CENTER}px)`, `translateX(${OFF_RIGHT * -1}px)`); + .fromTo('transform', `translateX(${CENTER}px)`, `translateX(${OFF_END * -1}px)`); rootTransition.addAnimation(stackedTransition); } @@ -63,7 +64,7 @@ export const mdTransitionAnimation = (_: HTMLElement, opts: TransitionOptions): leavingPage.elements[0].style.setProperty('display', 'none'); } }) - .fromTo('transform', `translateX(${CENTER}px)`, `translateX(${OFF_RIGHT}px)`) + .fromTo('transform', `translateX(${CENTER}px)`, `translateX(${OFF_END}px)`) .fromTo('opacity', 1, 0); rootTransition.addAnimation(leavingPage);