From d6ff3e9b99c5db095105441da38c698606346d40 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 | 75 +++++++++++++++++++ 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 | 5 +- package.json | 2 +- src/styles/components/ion-button.scss | 7 +- src/styles/components/ion-fab.scss | 14 +++- src/styles/components/ion-list.scss | 9 +++ src/styles/components/ion-menu.scss | 27 ++++--- src/styles/components/ion-searchbar.scss | 2 +- src/styles/components/ion-tabs.scss | 10 ++- src/styles/components/ion-toolbar.scss | 19 +++-- 14 files changed, 145 insertions(+), 40 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 00000000..7c574bce --- /dev/null +++ b/demo/e2e/rtl.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; + +for (const direction of ['ltr', 'rtl'] as const) { + test(`${direction} mirrors logical spacing with asymmetric safe areas`, async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.evaluate((dir) => { + const fixture = document.createElement('div'); + fixture.id = 'rtl-probe'; + fixture.dir = dir; + fixture.style.cssText = + 'position:fixed;inset:0;z-index:99999;--ion-safe-area-left:20px;--ion-safe-area-right:8px;--ios-theme-menu-width:0px'; + + const fab = document.createElement('ion-fab'); + fab.id = 'fab-probe'; + fab.mode = 'ios'; + fab.className = 'fab-horizontal-start'; + fab.innerHTML = '+'; + + const tabs = document.createElement('ion-tab-bar'); + tabs.id = 'tabs-probe'; + tabs.mode = 'ios'; + tabs.slot = 'bottom'; + tabs.className = 'tab-bar-position-start'; + tabs.innerHTML = 'OneTwo'; + + const list = document.createElement('ion-list'); + list.id = 'list-probe'; + list.mode = 'ios'; + list.className = 'list-inset'; + list.innerHTML = + '
Supporting text
Item
Footer'; + + const toolbar = document.createElement('ion-toolbar'); + toolbar.id = 'toolbar-probe'; + toolbar.mode = 'ios'; + toolbar.className = 'toolbar-searchbar'; + toolbar.innerHTML = 'Action'; + + fixture.append(fab, tabs, list, toolbar); + document.body.append(fixture); + }, direction); + + await expect(page.locator('#fab-probe')).toHaveClass(/hydrated/); + await expect(page.locator('#tabs-probe')).toHaveClass(/hydrated/); + await expect(page.locator('#list-probe')).toHaveClass(/hydrated/); + + const values = await page.locator('#rtl-probe').evaluate((fixture) => { + const direction = fixture.getAttribute('dir'); + const style = (selector: string) => getComputedStyle(fixture.querySelector(selector)!); + const fab = fixture.querySelector('#fab-probe')!.getBoundingClientRect(); + const tabs = fixture.querySelector('#tabs-probe')!.getBoundingClientRect(); + const note = style('#list-probe > ion-note'); + const radio = style('.radio-group-top'); + const toolbarButtons = style('#toolbar-probe ion-buttons'); + + return { + fabStart: direction === 'ltr' ? fab.left : innerWidth - fab.right, + tabsStart: direction === 'ltr' ? tabs.left : innerWidth - tabs.right, + noteStart: note.marginInlineStart, + noteEnd: note.marginInlineEnd, + radioStart: radio.paddingInlineStart, + radioEnd: radio.paddingInlineEnd, + toolbarGap: toolbarButtons.marginInlineEnd, + }; + }); + + expect(values.fabStart).toBeCloseTo(16, 1); + expect(values.tabsStart).toBeCloseTo(direction === 'ltr' ? 36 : 24, 1); + expect(values.noteStart).toBe(direction === 'ltr' ? '40px' : '28px'); + expect(values.noteEnd).toBe(direction === 'ltr' ? '28px' : '40px'); + expect(values.radioStart).toBe(direction === 'ltr' ? '40px' : '28px'); + expect(values.radioEnd).toBe(direction === 'ltr' ? '28px' : '40px'); + expect(values.toolbarGap).toBe('6px'); + }); +} diff --git a/demo/src/app/album/album-page.component.scss b/demo/src/app/album/album-page.component.scss index 3fb8f93c..238d15d7 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 27efdf19..fcea56bf 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 1fa678c4..0676826b 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 bad2a8ca..b2989ad9 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 1f61ed03..b8764ad2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "9.2.0", "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#1937b175b2498d2f272ae697190300e94f367213" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" }, "devDependencies": { "@ionic/angular": "^9.0.0", @@ -2204,8 +2204,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#1937b175b2498d2f272ae697190300e94f367213", - "integrity": "sha512-e4Gf7jzlXmMZn2ZxDXfzJucyKO9MtlxcePV4hVz5xH9tZsPRgTCoD7DEqeFpnADHF2iVaRtBlDOe7ozreMaDNA==", + "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 9cbc7b9d..014040db 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios26", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#1937b175b2498d2f272ae697190300e94f367213" + "@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-button.scss b/src/styles/components/ion-button.scss index e2c1c9b5..0543cafa 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -459,12 +459,13 @@ ion-buttons.ios:not(.ios-theme-disabled, .ios26-disabled):not( ion-buttons.ios:not(.ios-theme-disabled, .ios26-disabled):not(:has(ion-back-button, ion-button.button-clear)) { ion-button { - margin: 0 6px; + margin-block: 0; + margin-inline: 6px; &:first-child { - margin-left: 0; + margin-inline-start: 0; } &:last-child { - margin-right: 0; + margin-inline-end: 0; } &:only-child { margin: 0; diff --git a/src/styles/components/ion-fab.scss b/src/styles/components/ion-fab.scss index ec187c3c..492fb601 100644 --- a/src/styles/components/ion-fab.scss +++ b/src/styles/components/ion-fab.scss @@ -6,7 +6,13 @@ $scaleup-default-icon-only: 1.2; $scaleup-large-icon-only: 1.12; ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-safe-area-inline-start: var(--ion-safe-area-left, 0px); + --ios-theme-safe-area-inline-end: var(--ion-safe-area-right, 0px); --transform-value: 3px; + &:dir(rtl) { + --ios-theme-safe-area-inline-start: var(--ion-safe-area-right, 0px); + --ios-theme-safe-area-inline-end: var(--ion-safe-area-left, 0px); + } &:has(> ion-fab-button.fab-button-small) { --transform-value: 4px; } @@ -92,20 +98,20 @@ ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) { } &.fab-horizontal-start { - left: calc(16px + var(--ios-theme-menu-width, var(--ios26-menu-width, var(--ion-safe-area-left), 0px))); + inset-inline-start: calc(16px + var(--ios-theme-menu-width, var(--ios26-menu-width, var(--ios-theme-safe-area-inline-start)))); } &.fab-horizontal-end { - right: calc(16px + var(--ion-safe-area-right, 0px)); + inset-inline-end: calc(16px + var(--ios-theme-safe-area-inline-end)); } &:has(.fab-button-small) { &.fab-horizontal-start { - left: calc(10px + var(--ios-theme-menu-width, var(--ios26-menu-width, var(--ion-safe-area-left), 0px))); + inset-inline-start: calc(10px + var(--ios-theme-menu-width, var(--ios26-menu-width, var(--ios-theme-safe-area-inline-start)))); } &.fab-horizontal-end { - right: calc(10px + var(--ion-safe-area-right, 0px)); + inset-inline-end: calc(10px + var(--ios-theme-safe-area-inline-end)); } } } diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss index e5cbc412..e4a2a984 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -15,6 +15,10 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) { @include structured-list.layout(20px, 28px, $disabled-selector: $ios26-disabled); margin-inline: calc(20px + var(--ion-safe-area-left, 0px)) calc(20px + var(--ion-safe-area-right, 0px)); + &:dir(rtl) { + margin-inline: calc(20px + var(--ion-safe-area-right, 0px)) calc(20px + var(--ion-safe-area-left, 0px)); + } + ion-radio-group, ion-reorder-group { display: block; @@ -25,6 +29,11 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) { padding-inline-end: calc(var(--ion-safe-area-right, 0) + 20px); } + &:dir(rtl) ion-radio-group > .radio-group-top { + padding-inline-start: calc(var(--ion-safe-area-right, 0) + 20px); + padding-inline-end: calc(var(--ion-safe-area-left, 0) + 20px); + } + > :is(#{structured-list.$groups}):not(.ios-theme-disabled, .ios26-disabled) { ion-item:not(.ios-theme-disabled, .ios26-disabled) { // To draw lines inside diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index aa4683b0..36d3d575 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -8,13 +8,13 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible: .split-pane-main { ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { width: calc( - 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-left, 0px)) - 60px - + 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) ); &:has(:nth-child(5)) { width: calc( - 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-left, 0px)) - var( + 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - var( --ios-theme-menu-width, var(--ios26-menu-width) ) @@ -33,7 +33,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible: /* * 90px: https://github.com/ionic-team/ionic-framework/blob/main/core/src/components/title/title.ios.scss#L8 */ - padding-left: calc(90px + var(--ios-theme-menu-width, var(--ios26-menu-width))); + padding-inline-start: calc(90px + var(--ios-theme-menu-width, var(--ios26-menu-width))); } } ion-content { @@ -47,7 +47,10 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible: } ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { &[slot='bottom'] { - left: calc(16px + var(--ion-safe-area-left, 0px) + var(--ios-theme-menu-width, var(--ios26-menu-width))); + inset-inline-start: calc(16px + var(--ion-safe-area-left, 0px) + var(--ios-theme-menu-width, var(--ios26-menu-width))); + &:dir(rtl) { + inset-inline-start: calc(16px + var(--ion-safe-area-right, 0px) + var(--ios-theme-menu-width, var(--ios26-menu-width))); + } } } } @@ -64,7 +67,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible: /* * 90px: https://github.com/ionic-team/ionic-framework/blob/main/core/src/components/title/title.ios.scss#L8 */ - padding-right: calc(90px + var(--ios-theme-menu-width, var(--ios26-menu-width))); + padding-inline-end: calc(90px + var(--ios-theme-menu-width, var(--ios26-menu-width))); } } ion-content { @@ -89,10 +92,10 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) { z-index: 999; &.menu-side-start { - left: 0; + inset-inline-start: 0; } &.menu-side-end { - left: calc(100vw - var(--ios-theme-menu-width, var(--ios26-menu-width))); + inset-inline-end: 0; } &::part(backdrop) { @@ -106,10 +109,14 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) { &::part(scroll) { @include api.glass-background; - margin: calc(var(--ios-theme-floating-safe-area-top, var(--ios26-floating-safe-area-top)) + 6px) - calc(16px + var(--ion-safe-area-right)) var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom)) - calc(12px + var(--ion-safe-area-left)); + margin-block: calc(var(--ios-theme-floating-safe-area-top, var(--ios26-floating-safe-area-top)) + 6px) + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom)); + margin-inline: calc(12px + var(--ion-safe-area-left, 0px)) calc(16px + var(--ion-safe-area-right, 0px)); border-radius: 24px; } + + &:dir(rtl)::part(scroll) { + margin-inline: calc(12px + var(--ion-safe-area-right, 0px)) calc(16px + var(--ion-safe-area-left, 0px)); + } } } diff --git a/src/styles/components/ion-searchbar.scss b/src/styles/components/ion-searchbar.scss index dbb81605..dded0cd9 100644 --- a/src/styles/components/ion-searchbar.scss +++ b/src/styles/components/ion-searchbar.scss @@ -82,6 +82,6 @@ ion-searchbar.ios:not(.ios-theme-disabled, .ios26-disabled).searchbar-classic { width: 1.18rem; } button.searchbar-clear-button { - right: 8px; + inset-inline-end: 8px; } } diff --git a/src/styles/components/ion-tabs.scss b/src/styles/components/ion-tabs.scss index d3b53ce3..15aacafa 100644 --- a/src/styles/components/ion-tabs.scss +++ b/src/styles/components/ion-tabs.scss @@ -10,6 +10,12 @@ } ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-safe-area-inline-start: var(--ion-safe-area-left, 0px); + --ios-theme-safe-area-inline-end: var(--ion-safe-area-right, 0px); + &:dir(rtl) { + --ios-theme-safe-area-inline-start: var(--ion-safe-area-right, 0px); + --ios-theme-safe-area-inline-end: var(--ion-safe-area-left, 0px); + } // UIKit's platter is 62pt, including its 4pt selection inset. Put the // physical-pixel rim on a separate surface so it cannot alter layout. border: 0; @@ -35,13 +41,13 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { &[slot='top'] { position: absolute; top: var(--ios-theme-floating-safe-area-top, var(--ios26-floating-safe-area-top)); - right: calc(16px + var(--ion-safe-area-right, 0px)); + inset-inline-end: calc(16px + var(--ios-theme-safe-area-inline-end)); } &[slot='bottom'] { position: absolute; bottom: var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom)); - left: calc(16px + var(--ion-safe-area-left, 0px)); + inset-inline-start: calc(16px + var(--ios-theme-safe-area-inline-start)); } /** diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss index eca24fdd..995ab460 100644 --- a/src/styles/components/ion-toolbar.scss +++ b/src/styles/components/ion-toolbar.scss @@ -58,21 +58,23 @@ ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled).toolbar-searchbar { } &:has(ion-buttons[slot='start']) { ion-buttons[slot='start'] { - margin-right: 6px; + margin-inline-end: 6px; } ion-searchbar.ios:not(.ios-theme-disabled, .ios26-disabled) { .searchbar-input-container { - margin: 0 12px 0 6px; + margin-block: 0; + margin-inline: 6px 12px; } } } &:has(ion-buttons[slot='end']) { ion-buttons[slot='end'] { - margin-left: 6px; + margin-inline-start: 6px; } ion-searchbar.ios:not(.ios-theme-disabled, .ios26-disabled) { .searchbar-input-container { - margin: 0 6px 0 12px; + margin-block: 0; + margin-inline: 12px 6px; } } } @@ -80,15 +82,12 @@ ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled).toolbar-searchbar { ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-buttons { - margin-left: 12px; - margin-right: 12px; + margin-inline: 12px; } ion-back-button { - margin-left: 12px; - margin-right: 12px; + margin-inline: 12px; } ion-buttons > ion-back-button { - margin-left: 0; - margin-right: 0; + margin-inline: 0; } }