Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions demo/e2e/adaptive-tab-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';

const enableFoldable = async (page: import('@playwright/test').Page) => {
const toggle = page.getByText('Foldable Mode').locator('..').locator('ion-toggle');
await toggle.click();
await expect(page.locator('ion-app')).toHaveClass(/ios-theme-enable-foldable/);
};

test('foldable mode moves tabs into the right rail and reveals labels while dragging', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index');
await enableFoldable(page);

const bar = page.locator('#tab-bar-bottom');
const buttons = bar.locator('ion-tab-button');
const barBox = (await bar.boundingBox())!;
expect(barBox.x).toBeGreaterThan(620);
expect(barBox.width).toBeCloseTo(50, 0);
await expect(buttons.first().locator('ion-label')).toHaveCSS('position', 'absolute');
await expect(buttons.nth(1).locator('ion-label')).toHaveCSS('position', 'absolute');

const selectedBox = (await buttons.first().boundingBox())!;
const targetBox = (await buttons.nth(1).boundingBox())!;
await page.mouse.move(selectedBox.x + selectedBox.width / 2, selectedBox.y + selectedBox.height / 2);
await page.mouse.down();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 4 });
await expect.poll(() => buttons.first().evaluate((element) => element.matches(':active'))).toBe(true);

await expect(page).toHaveScreenshot('foldable-tab-drag-labels.png', { animations: 'disabled' });
await page.mouse.up();

const overlayDirection = await page.evaluate(async () => {
const menu = document.querySelector('ion-menu')!;
const tabs = document.createElement('ion-tabs');
const overlayBar = document.createElement('ion-tab-bar');
overlayBar.mode = 'ios';
tabs.append(overlayBar);
menu.append(tabs);
await customElements.whenDefined('ion-tab-bar');
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const direction = getComputedStyle(overlayBar).flexDirection;
tabs.remove();
return direction;
});
expect(overlayDirection).toBe('row');
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,27 @@ test('unsupported search morph releases and restores a native fixed-slot FAB int
await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', '');
});

test('foldable tabs stay in the web layer instead of using horizontal native projection', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index');
const app = page.locator('ion-app');
const bar = page.locator('ion-tab-bar');
await expect(bar).toHaveAttribute('data-native-ui-shell', '');

await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable'));
await expect(bar).not.toHaveAttribute('data-native-ui-shell');
await expect(bar).not.toHaveClass(/ios27-enable-gesture/);
await expect
.poll(() =>
page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-tab-bar')),
)
.toBe(false);

await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable'));
await expect(bar).toHaveAttribute('data-native-ui-shell', '');
await expect(bar).toHaveClass(/ios27-enable-gesture/);
});

test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui-shell');
Expand Down
10 changes: 8 additions & 2 deletions demo/e2e/screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ const prepareScreenShot = async (page: Page, routeName: string) => {
}
};

const prepareFoldableLayout = async (page: Page, direction: 'ltr' | 'rtl') => {
const prepareFoldableLayout = async (page: Page, direction: 'ltr' | 'rtl', width = 700) => {
await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true));
await page.setViewportSize({ width: 700, height: 900 });
await page.setViewportSize({ width, height: 900 });
await page.goto('/main/index', { waitUntil: 'networkidle' });
await page.waitForSelector('ion-content[role="main"]');
await page.evaluate(() => document.fonts.ready);
Expand Down Expand Up @@ -135,5 +135,11 @@ test.describe('Screenshot Tests - Foldable Layout', () => {
await prepareFoldableLayout(page, direction);
await expect(page).toHaveScreenshot(`foldable-layout-${direction}.png`, { animations: 'disabled' });
});

test(`should keep foldable tabs in the system rail beside a visible split pane in ${direction.toUpperCase()}`, async ({ page }) => {
await prepareFoldableLayout(page, direction, 1024);
await expect(page.locator('ion-split-pane')).toHaveClass(/split-pane-visible/);
await expect(page).toHaveScreenshot(`foldable-split-pane-${direction}.png`, { animations: 'disabled' });
});
}
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion demo/src/app/docs/docs-content.generated.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/special-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ This keeps routers and component backgrounds full-viewport. `ion-content` moves

The foldable values are web-layout simulation inputs. They are independent from Ionic's normal iPhone safe-area variables and do not change ordinary iPhone layouts unless the opt-in class is present.

When the app contains `ion-tabs`, foldable mode moves its iOS tab bar into the physical right-side reserved region and aligns it above the bottom safe area. The Ionic `slot` value does not select a different foldable position. The stable rail is icon-only, matching a four-tab SwiftUI `TabView` on iPhone Duo. While the user presses and drags across the rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The tab bar remains in the web layer and receives pointer input in the simulated system region. Use `ion-menu` when navigation should become a sidebar; foldable mode does not convert tabs into a menu.

## Two-line inset list items

Place an unslotted `ion-label` immediately alongside an unslotted `ion-note` to render a two-line item. When using the iOS-style inset-list background, wrap the items in `ion-item-group`; keep `ion-list-header` outside the group.
Expand Down
23 changes: 15 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,30 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
const win = targetElement.ownerDocument.defaultView;
if (!targetElement.classList.contains('ios') || !win) return undefined;
const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
const foldableRoot = targetElement.closest('ion-app') ?? targetElement.ownerDocument.body;
let effect: registeredEffect | undefined;
const update = () => {
effect?.destroy();
effect = reducedMotion.matches
? undefined
: registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
small: 'scale(1.1, 1)',
medium: 'scale(1.2)',
large: 'scale(1.15, 1.4)',
xlarge: 'scale(1.3)',
});
const isVertical =
foldableRoot.classList.contains('ios-theme-enable-foldable') && !targetElement.closest('ion-menu, ion-modal, ion-popover');
effect =
reducedMotion.matches || isVertical
? undefined
: registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
small: 'scale(1.1, 1)',
medium: 'scale(1.2)',
large: 'scale(1.15, 1.4)',
xlarge: 'scale(1.3)',
});
};
update();
reducedMotion.addEventListener('change', update);
const placementObserver = new win.MutationObserver(update);
placementObserver.observe(foldableRoot, { attributes: true, attributeFilter: ['class'] });
return {
destroy: () => {
reducedMotion.removeEventListener('change', update);
placementObserver?.disconnect();
effect?.destroy();
effect = undefined;
},
Expand Down
5 changes: 5 additions & 0 deletions src/native/components/ion-tab-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const tracksMotion = true;

export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
if (element.closest('ion-content')) return;
if (
(element.closest('ion-app') ?? element.ownerDocument.body).classList.contains('ios-theme-enable-foldable') &&
!element.closest('ion-menu, ion-modal, ion-popover')
)
return;
const children = Array.from(element.querySelectorAll<HTMLElement>(':scope > ion-tab-button:not(.ion-cloned-element)'));
if (!children.length) return;
// Only equal items with Ionic's default layout map to UIKit's adaptive tabs.
Expand Down
4 changes: 2 additions & 2 deletions src/styles/components/ion-menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled) {

ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible:has(ion-menu.menu-pane-visible) {
.split-pane-main {
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-enable-foldable *)) {
width: calc(
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))
Expand Down Expand Up @@ -43,7 +43,7 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible:
ion-footer ion-toolbar {
--padding-start: var(--ios-theme-menu-width, var(--ios26-menu-width));
}
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-enable-foldable *)) {
&[slot='bottom'] {
inset-inline-start: calc(16px + var(--ion-safe-area-left, 0px) + var(--ios-theme-menu-width, var(--ios26-menu-width)));
&:dir(rtl) {
Expand Down
93 changes: 93 additions & 0 deletions src/styles/components/ion-tabs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,99 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
}

// iPhone Duo reserves the physical right edge for system navigation. In the
// web simulation, keep tabs in that same rail so the application remains
// usable without introducing a second navigation component.
:is(ion-app, body).ios-theme-enable-foldable
ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *))
> ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
--ios-theme-side-tab-bar-width: 50px;
--ios-theme-side-tab-bar-gap: 8px;
--ios-theme-side-safe-area: var(--ios-theme-foldable-safe-area-right-resolved);

contain: layout style;
display: flex;
flex-direction: column;
width: 46px;
min-width: 46px;
max-width: 46px;
height: fit-content;
min-height: 0;
padding: 2px;
border-radius: 25px;
left: auto;
right: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2));
margin: 0;
Comment on lines +186 to +188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 分割ペイン内の下配置タブが指定した物理側に配置されない

開始側メニュー表示中に slot="bottom" を使うと、メニュー配置が優先されます。両辺が指定され、書字方向によって物理側の指定が無視されるため、バーが予約領域から外れます。

Learn more

分割ペインの既存スタイルは、表示中の開始側メニューを避けるため、下側タブバーに inset-inline-start を設定します。このセレクターは新しい適応型セレクターより詳細度が高いため、ここで設定した left: auto または物理位置より優先されます。絶対配置要素に left、right、固定幅が同時に残ると、LTR では左、RTL では右が優先されます。その結果、物理右配置は LTR で、物理左配置は RTL で壊れます。

Example: LTR の 1024px 幅で開始側メニューが表示され、右配置かつ slot="bottom" の場合、right に加えてメニュー由来の left が残ります。ブラウザーは left を優先し、タブバーを右側の予約領域ではなくメニュー後方へ置きます。

Recommended fix: 適応型の物理配置では論理 inset を明示的にリセットし、分割ペインのメニュールールより確実に優先させてください。左右配置、LTR/RTL、上下スロット、表示中の開始側・終了側メニューを組み合わせたテストも追加してください。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


top: auto;
bottom: calc(var(--ion-safe-area-bottom, 0px) + var(--ios-theme-side-tab-bar-gap));

> ion-tab-button:not(.ion-cloned-element) {
display: block;
flex: none;
width: 46px;
min-width: 46px;
height: 52px;
min-height: 52px;
border-radius: 23px;

&::part(native) {
justify-content: center;
}

ion-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

ion-icon {
margin: 0;
font-size: 28px;
}
}

> ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) {
margin-block-start: 0;
margin-inline-start: 0;
}

// iPhone Duo expands labels while the user drags across the rail. The
// stable four-tab layout remains icon-only, matching SwiftUI's TabView.
&:has(> ion-tab-button:is(.ion-activated, :active)) > ion-tab-button:not(.ion-cloned-element) {
&::part(native) {
flex-direction: column;
padding-block: 4px;
}

ion-label {
position: static;
width: auto;
height: auto;
max-width: 44px;
padding: 0;
margin: 1px 0 0;
overflow: hidden;
clip: auto;
font-size: 0.5rem;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}

ion-icon {
margin: 0;
font-size: 22px;
}
}
}

ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) {
transform-origin: center 65%;
font-size: 0.59rem;
Expand Down