From d29a8ea6bb143c84e3ed9c5c96001f635827bb17 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 | 11 ++- src/styles/components/ion-menu.scss | 27 ++++--- src/styles/components/ion-searchbar.scss | 2 +- src/styles/components/ion-tabs.scss | 24 +++--- src/styles/components/ion-toolbar.scss | 19 +++-- 14 files changed, 153 insertions(+), 48 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 084a1ee8..343fde90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#54c43a91d498a2364215d91f74f678fb696ab00c" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" }, "devDependencies": { "@capacitor/core": "^8.5.2", @@ -2221,7 +2221,8 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#54c43a91d498a2364215d91f74f678fb696ab00c", + "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6", + "integrity": "sha512-0/aRkysY012ByvBJFaKBNiU5BFeR7uHCxuQ/BBprq1eHjOgS2AoyZwcBqhqP3SZDDDhnGKLK/SfLQYRySeFAng==", "license": "MIT", "peerDependencies": { "@ionic/core": ">=8.8.1 <10" diff --git a/package.json b/package.json index 073f24fc..1cf1526a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#54c43a91d498a2364215d91f74f678fb696ab00c" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" }, "devDependencies": { "@capacitor/core": "^8.5.2", diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss index 34dc57f9..5a0cacfb 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -416,12 +416,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 5ea88935..631318a9 100644 --- a/src/styles/components/ion-fab.scss +++ b/src/styles/components/ion-fab.scss @@ -6,7 +6,13 @@ $scaleup-default-icon-only: api.$button-scaleup-default-icon-only; $scaleup-large-icon-only: api.$button-scaleup-large-icon-only; 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: 2.5px; + &: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; } @@ -85,20 +91,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 2b6117bd..f1b55d78 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -23,13 +23,22 @@ 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}) { ion-item { // To draw lines inside --inner-padding-end: 0; &::part(native) { - padding-right: calc(var(--ion-safe-area-right, 0px) + 18px); + padding-inline-end: calc(var(--ion-safe-area-right, 0px) + 18px); + } + + &:dir(rtl)::part(native) { + padding-inline-end: calc(var(--ion-safe-area-left, 0px) + 18px); } --min-height: 52px; diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index 9c1da635..3d437eb9 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -6,13 +6,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) ) @@ -31,7 +31,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 { @@ -45,7 +45,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))); + } } } } @@ -62,7 +65,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 { @@ -88,10 +91,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) { @@ -119,9 +122,13 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-content-box-shadow-rgb: var(--ios26-content-box-shadow-rgb); &::part(scroll) { - margin: calc(var(--ios-theme-floating-safe-area-top, var(--ios26-floating-safe-area-top)) + 6px) - calc(16px + var(--ion-safe-area-right, 0px)) var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom)) - calc(12px + var(--ion-safe-area-left, 0px)); + 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)); + } + + &: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 5e3cec43..b279343c 100644 --- a/src/styles/components/ion-searchbar.scss +++ b/src/styles/components/ion-searchbar.scss @@ -274,6 +274,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 bd8b0335..61622ecf 100644 --- a/src/styles/components/ion-tabs.scss +++ b/src/styles/components/ion-tabs.scss @@ -1,6 +1,12 @@ @use '../utils/api'; 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); + } // Ionic forces content-box on the host. Keep the Glass border in a // separate layer so its physical-pixel rounding cannot resize the bar. border: 0; @@ -29,26 +35,24 @@ 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)); } - &.tab-bar-position-start, - &.tab-bar-position-end:dir(rtl) { - left: calc(16px + var(--ion-safe-area-left, 0px)); - right: auto; + &.tab-bar-position-start { + inset-inline-start: calc(16px + var(--ios-theme-safe-area-inline-start)); + inset-inline-end: auto; margin-inline: 0; } - &.tab-bar-position-end, - &.tab-bar-position-start:dir(rtl) { - left: auto; - right: calc(16px + var(--ion-safe-area-right, 0px)); + &.tab-bar-position-end { + inset-inline-start: auto; + inset-inline-end: calc(16px + var(--ios-theme-safe-area-inline-end)); margin-inline: 0; } diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss index 14071ee1..51564e79 100644 --- a/src/styles/components/ion-toolbar.scss +++ b/src/styles/components/ion-toolbar.scss @@ -57,27 +57,29 @@ 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) { &:not(.searchbar-classic) { padding-inline: 6px 12px; } &.searchbar-classic .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) { &:not(.searchbar-classic) { padding-inline: 12px 6px; } &.searchbar-classic .searchbar-input-container { - margin: 0 6px 0 12px; + margin-block: 0; + margin-inline: 12px 6px; } } } @@ -85,15 +87,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; } }