From 79c93904817d4d93472846d6c14cc36f2894974c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 16:41:17 +0900 Subject: [PATCH 01/35] feat(native): project foldable controls with SwiftUI --- demo/e2e/native-ui-shell.spec.ts | 35 ++- .../NativeUIShellTests.swift | 33 +++ demo/src/app/docs/docs-content.generated.ts | 2 +- .../native-ui-shell/native-ui-shell.page.html | 16 +- docs/native-ui-shell.md | 10 +- docs/special-markup.md | 6 +- .../Components/ShellFoldableRail.swift | 213 ++++++++++++++++++ .../IonicNativeUIShellPlugin.swift | 25 +- .../Shared/ShellSnapshot.swift | 5 +- .../ShellSnapshotTests.swift | 25 ++ src/native/components/index.ts | 11 +- src/native/components/ion-button.ts | 11 +- src/native/components/ion-buttons.ts | 5 +- src/native/components/ion-tab-bar.ts | 5 - src/native/definitions.ts | 2 + src/native/index.ts | 27 +-- src/native/runtime.ts | 9 +- 17 files changed, 380 insertions(+), 60 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 18bc72cb..1c104bab 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -324,7 +324,7 @@ 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 }) => { +test('foldable tabs request native adaptive rail placement', async ({ page }) => { await mockNative(page); await page.goto('/main/index'); const app = page.locator('ion-app'); @@ -332,20 +332,23 @@ test('foldable tabs stay in the web layer instead of using horizontal native pro 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(bar).toHaveAttribute('data-native-ui-shell', ''); await expect .poll(() => - page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-tab-bar')), + page.evaluate(() => + (window as any).__nativeUIShell.updates + .at(-1) + .controls.some((control: any) => control.kind === 'ion-tab-bar' && control.placement === 'foldable-rail'), + ), ) - .toBe(false); + .toBe(true); 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('foldable back navigation hands ownership between native and Web projection', async ({ page }) => { +test('foldable back navigation requests native rail placement', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); const app = page.locator('ion-app'); @@ -354,14 +357,26 @@ test('foldable back navigation hands ownership between native and Web projection await expect(source).toHaveAttribute('data-native-ui-shell', ''); await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); - await expect(projection).toHaveCount(1); + await expect(projection).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); await expect .poll(() => - page.evaluate(() => - (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-back-button'), + page.evaluate( + () => + (window as any).__nativeUIShell.updates + .at(-1) + .controls.some((control: any) => control.kind === 'ion-back-button' && control.placement === 'foldable-rail') && + (window as any).__nativeUIShell.updates + .at(-1) + .controls.some( + (control: any) => + control.kind === 'ion-buttons' && + control.placement === 'foldable-rail' && + control.items.some((item: any) => item.label === 'Cancel'), + ), ), ) - .toBe(false); + .toBe(true); await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); await expect(projection).toHaveCount(0); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index bd6650ea..007d7fde 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -249,6 +249,39 @@ final class NativeUIShellTests: XCTestCase { capture("native-tab-settings") } + func testNativeFoldableRail() throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + let toggle = app.webViews.switches["Foldable Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + toggle.tap() + + let index = app.buttons["Index"] + let library = app.buttons["Library"] + XCTAssertTrue(index.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(library.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(app.buttons["menu"].waitForExistence(timeout: 10), app.debugDescription) + capture("native-foldable-index") + + library.tap() + XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(library.isSelected) + + index.tap() + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + openPage(app, name: "native-ui-shell") + let save = nativeButton(app, label: "Save") + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + save.tap() + XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) + capture("native-foldable-toolbar") + + let back = nativeButton(app, label: "back") + XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) + back.tap() + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + } + func testNativeShellPageAuditAndRepeatedNavigation() throws { executionTimeAllowance = 900 let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 88e3180a..3c4010e7 100644 --- a/demo/src/app/docs/docs-content.generated.ts +++ b/demo/src/app/docs/docs-content.generated.ts @@ -1,3 +1,3 @@ // Generated from docs/special-markup.md. Do not edit directly. export const docsContentHtml = - '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI is positioned after the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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.

\n

When enableNativeUIShell() is initialized at application startup, foldable mode uses the same document-level projection lifecycle for fixed-toolbar navigation and actions. The active ion-back-button and ion-button or ion-menu-button controls that contain an icon remain the sources of truth, are hidden while owned, and are represented by interactive Web clones in the physical right-side system rail. Buttons in the same ion-buttons group share a vertical rail group. An icon-and-text button is shown as its icon while retaining its accessible label; a text-only button stays in the original toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

These Web projections also work when no ion-tabs exists and when native projection is unavailable. Disabling foldable mode or leaving the page removes the clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; + '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI is positioned after the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard fill="default" and fill="clear" actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index bcdbbd6f..a114062f 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -33,10 +33,14 @@
-

Save count: {{ saves() }}

+

+ Save count: {{ saves() }} +

Experimental: On supported iOS versions, fixed glass controls are rendered natively.

-

Selection: {{ selected() }} / Change count: {{ changes() }}

+

+ Selection: {{ selected() }} / Change count: {{ changes() }} +

Scroll the background to compare transparency and refraction in the top-right button.

@@ -44,13 +48,13 @@ @for (value of fills; track value) { - + }
@for (band of bands; track band) { -
- {{ band }} — GLASS / Glass / 123456789 -
+
+ {{ band }} — GLASS / Glass / 123456789 +
}
diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index afb3f7ac..a154ead6 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -6,7 +6,7 @@ title: Native UI Shell (Experimental) Native UI Shell is experimental. Its API and supported controls may change. -Native UI Shell gives an Ionic app native navigation and action controls around its Web content. The optional Capacitor iOS plugin renders supported fixed Ionic controls with UIKit and the system's Liquid Glass material. Page content, scrolling, application state and routing remain in Ionic's WebView. +Native UI Shell gives an Ionic app native navigation and action controls around its Web content. The optional Capacitor iOS plugin renders supported fixed Ionic controls with UIKit or SwiftUI and the system's Liquid Glass material. Page content, scrolling, application state and routing remain in Ionic's WebView. ## Background @@ -167,6 +167,14 @@ Suspensions are nestable and `resume()` is idempotent. Native projection resumes The native material and control appearance follow the running iOS version; an iOS 26 device does not acquire iOS 27's appearance merely by installing this theme. +## Foldable controls + +On supported iOS versions, adding `.ios-theme-enable-foldable` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers. + +The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. Menus, modals, and popovers remain independent surfaces and are not moved into the main-page rail. + +The foldable exception accepts standard `fill="default"` and `fill="clear"` fixed-toolbar buttons because SwiftUI determines their compact representation. Solid, outline, custom-color, scrolling, and explicitly opted-out controls remain on the Web. On Web, Android, older iOS, or when native projection is unavailable during setup, the existing foldable Web projection remains the fallback. + ## Native UI Shell API The generated reference below documents the handle returned by `enableNativeUIShell()`. The underlying Capacitor bridge and its control-snapshot protocol are implementation details. diff --git a/docs/special-markup.md b/docs/special-markup.md index 5ca190a3..ed19b4a4 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -63,11 +63,11 @@ 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. +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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView` on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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. -When `enableNativeUIShell()` is initialized at application startup, foldable mode uses the same document-level projection lifecycle for fixed-toolbar navigation and actions. The active `ion-back-button` and `ion-button` or `ion-menu-button` controls that contain an icon remain the sources of truth, are hidden while owned, and are represented by interactive Web clones in the physical right-side system rail. Buttons in the same `ion-buttons` group share a vertical rail group. An icon-and-text button is shown as its icon while retaining its accessible label; a text-only button stays in the original toolbar. Menus, modals, and popovers retain their own toolbar layout. +On supported iOS versions, initializing `enableNativeUIShell()` at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard `fill="default"` and `fill="clear"` actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout. -These Web projections also work when no `ion-tabs` exists and when native projection is unavailable. Disabling foldable mode or leaving the page removes the clones and restores their sources. Override `--ios-theme-foldable-toolbar-top` when the simulated system controls use a different vertical layout. +On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no `ion-tabs` exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-foldable-toolbar-top` when the simulated system controls use a different vertical layout. ## Two-line inset list items diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift new file mode 100644 index 00000000..e64e0c9c --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -0,0 +1,213 @@ +import Combine +import SwiftUI +import UIKit + +protocol ShellFoldableRailControlling: AnyObject { + var view: UIView { get } + func attach(to owner: UIViewController, in parent: UIView) + func apply(_ controls: [ShellControl], rendering: ShellRendering) + func detach() +} + +@available(iOS 26.0, *) +final class ShellFoldableRailModel: ObservableObject { + struct Item: Identifiable { + let id: String + let label: String + let accessibilityLabel: String + let image: UIImage? + let disabled: Bool + var selected: Bool + } + + struct Group: Identifiable { + let id: String + let items: [Item] + } + + @Published var back: Item? + @Published var groups: [Group] = [] + @Published var tabs: [Item] = [] + @Published var selection = "" + var activate: (String) -> Void = { _ in } + private var domSelection = "" + private var pendingSelection: ShellTabBar.PendingSelection? + private var pendingExpiryWork: DispatchWorkItem? + + func apply(_ controls: [ShellControl], rendering: ShellRendering, + now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) { + func item(_ source: ShellItem) -> Item { + Item(id: source.id, label: source.content.label, + accessibilityLabel: source.content.accessibilityLabel, + image: rendering.image(source.content), disabled: source.content.disabled, + selected: source.content.selected) + } + back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) + groups = controls.compactMap { control in + guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil } + return Group(id: control.id, items: control.items.map(item)) + } + tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? [] + domSelection = tabs.first(where: \.selected)?.id ?? "" + reconcileSelection(now: now) + } + + func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) { + guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return } + pendingSelection = .init(id: id, until: now + ttl) + selection = id + pendingExpiryWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() } + pendingExpiryWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work) + activate(id) + } + + private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) { + guard let pending = pendingSelection else { selection = domSelection; return } + let item = tabs.first(where: { $0.id == pending.id }) + if item == nil || item!.disabled || now >= pending.until || domSelection == pending.id { + pendingSelection = nil + pendingExpiryWork?.cancel() + pendingExpiryWork = nil + selection = domSelection + } else { + selection = pending.id + } + } +} + +@available(iOS 26.0, *) +private struct ShellFoldableLabel: View { + let item: ShellFoldableRailModel.Item + + var body: some View { + if let image = item.image { + Label { + Text(item.label) + } icon: { + Image(uiImage: image) + } + } else { + Text(item.label) + } + } +} + +@available(iOS 26.0, *) +private struct ShellFoldableRailView: View { + @ObservedObject var model: ShellFoldableRailModel + + var body: some View { + NavigationStack { + Group { + if model.tabs.isEmpty { + Color.clear + .allowsHitTesting(false) + } else { + TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { + ForEach(model.tabs) { item in + Color.clear + .allowsHitTesting(false) + .tag(item.id) + .tabItem { ShellFoldableLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) + } + } + } + } + .toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } + .disabled(back.disabled) + .accessibilityLabel(back.accessibilityLabel) + .accessibilityIdentifier(back.id) + } + } + ForEach(model.groups) { group in + ToolbarItemGroup(placement: .primaryAction) { + ForEach(group.items) { item in + Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) + } + } + } + } + } + .background(Color.clear) + } +} + +@available(iOS 26.0, *) +final class ShellFoldableRailController: ShellFoldableRailControlling { + private final class RailContainer: UIView { + private let railMask = CAShapeLayer() + + private var railWidth: CGFloat { safeAreaInsets.right > 0 ? safeAreaInsets.right : 80 } + + override init(frame: CGRect) { + super.init(frame: frame) + layer.mask = railMask + } + + required init?(coder: NSCoder) { nil } + + override func layoutSubviews() { + super.layoutSubviews() + railMask.path = UIBezierPath(rect: CGRect(x: bounds.maxX - railWidth, y: 0, + width: railWidth, height: bounds.height)).cgPath + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard point.x >= bounds.maxX - railWidth else { return nil } + return super.hitTest(point, with: event) + } + } + + private let model = ShellFoldableRailModel() + private lazy var controller = UIHostingController(rootView: ShellFoldableRailView(model: model)) + private let container = RailContainer() + private weak var owner: UIViewController? + + var view: UIView { container } + + init(activate: @escaping (String) -> Void) { + model.activate = activate + container.backgroundColor = .clear + controller.view.backgroundColor = .clear + controller.view.isOpaque = false + } + + func attach(to owner: UIViewController, in parent: UIView) { + guard self.owner !== owner || container.superview !== parent else { return } + detach() + self.owner = owner + owner.addChild(controller) + container.frame = parent.bounds + container.autoresizingMask = [.flexibleWidth, .flexibleHeight] + controller.view.frame = container.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(controller.view) + parent.addSubview(container) + controller.didMove(toParent: owner) + } + + func apply(_ controls: [ShellControl], rendering: ShellRendering) { + model.apply(controls, rendering: rendering) + controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light + } + + func detach() { + guard controller.parent != nil else { return } + controller.willMove(toParent: nil) + container.removeFromSuperview() + controller.view.removeFromSuperview() + controller.removeFromParent() + owner = nil + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 11cfd077..bb6902c6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -13,6 +13,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) ] private var host: ShellHost? + private var foldableRail: ShellFoldableRailControlling? private var controls: [String: UIView] = [:] private var searchControllers: [String: UIViewController] = [:] private var fingerprints: [String: ShellControl] = [:] @@ -40,7 +41,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0 } } - if !keyboard { self.host?.isHidden = true } + if !keyboard { + self.host?.isHidden = true + self.foldableRail?.view.isHidden = true + } var searchOwnsKeyboard = false if #available(iOS 26.0, *) { self.searchControllers.values.forEach { @@ -146,6 +150,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele Array(controls.keys).forEach { removeControl($0, duration: duration) } host?.removeFromSuperview() host = nil + foldableRail?.detach() + foldableRail = nil rendering.clear() pendingTabSelections.removeAll() pendingTabExpiryWorks.values.forEach { $0.cancel() } @@ -194,10 +200,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) - let snapshots = snapshot.controls + let foldable = snapshot.controls.filter { $0.placement == .foldableRail } + let snapshots = snapshot.controls.filter { $0.placement != .foldableRail } let width = snapshot.viewportWidth self.revision = next - if snapshots.isEmpty { + if snapshots.isEmpty && foldable.isEmpty { self.removeControls(duration: duration) call.resolve(["revision": next]); return } @@ -216,6 +223,18 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele var fabs: [(ShellFab, ShellControl)] = [] var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] var rejectedSearches: [String] = [] + if foldable.isEmpty { + self.foldableRail?.detach() + self.foldableRail = nil + } else if let owner = self.bridge?.viewController { + let rail = self.foldableRail ?? ShellFoldableRailController(activate: { [weak self] id in self?.activate(id) }) + self.foldableRail = rail + rail.attach(to: owner, in: owner.view) + rail.apply(foldable, rendering: self.rendering) + rail.view.isHidden = false + } else { + rejectedControls.append(contentsOf: foldable.map(\.id)) + } UIView.performWithoutAnimation { if host.superview !== parent { parent.addSubview(host) } for node in snapshots { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 75e74adf..d6027c30 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -31,8 +31,10 @@ struct ShellFrame: Decodable, Equatable { } struct ShellControl: Decodable, Equatable { + enum Placement: String, Decodable { case foldableRail = "foldable-rail" } let id: String let kind: ShellComponent + let placement: Placement? let frame: ShellFrame let items: [ShellItem] let dark: Bool @@ -40,12 +42,13 @@ struct ShellControl: Decodable, Equatable { let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? - private enum CodingKeys: String, CodingKey { case id, kind, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { case id, kind, placement, items, dark, rtl, tabBarAnchor, search } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) kind = try values.decode(ShellComponent.self, forKey: .kind) + placement = try values.decodeIfPresent(Placement.self, forKey: .placement) frame = try ShellFrame(from: decoder) items = try values.decode([ShellItem].self, forKey: .items) dark = try values.decode(Bool.self, forKey: .dark) diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index c804b0e8..f3dd08cd 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -37,6 +37,31 @@ final class ShellSnapshotTests: XCTestCase { } } + @MainActor func testFoldableTabOptimismWaitsForWebAndRollsBackWhenStale() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") } + func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] { + var items = [item(["id": "left", "selected": selected == "left"])] + if includeRight { items.append(item(["id": "right", "selected": selected == "right"])) } + return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls + } + let model = ShellFoldableRailModel() + let rendering = ShellRendering() + model.apply(try tabs("left"), rendering: rendering, now: 100) + model.select("right", now: 100, ttl: 10) + model.apply(try tabs("left"), rendering: rendering, now: 101) + XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection") + model.apply(try tabs("right"), rendering: rendering, now: 102) + XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection") + + model.apply(try tabs("left"), rendering: rendering, now: 200) + model.select("right", now: 200, ttl: 10) + model.apply(try tabs("left"), rendering: rendering, now: 211) + XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state") + model.select("right", now: 220, ttl: 10) + model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221) + XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately") + } + @MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws { guard #available(iOS 26.0, *) else { return } func node(_ items: [JSObject]) throws -> ShellControl { diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 2d414108..902b1390 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -27,13 +27,22 @@ export const motionSelector = [ ...components.filter((component) => 'tracksMotion' in component && component.tracksMotion).map((component) => component.tag), ].join(', '); +const foldableRailTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']); + +export const isFoldableRailCandidate = (element: HTMLElement): boolean => + foldableRailTags.has(element.localName) && + (!element.matches('ion-button') || !element.parentElement?.matches('ion-buttons') || isDisabledButtonGroupChild(element)) && + !element.closest('ion-menu, ion-modal, ion-popover') && + !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { if (!element.classList.contains('ios') || !visible(element) || element.closest('ion-modal, ion-popover')) return; const style = getComputedStyle(element); if ( !isDisabledButtonGroupChild(element) && !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && - !style.getPropertyValue('--ios26-glass-background-rgb').trim() + !style.getPropertyValue('--ios26-glass-background-rgb').trim() && + !isFoldableRailCandidate(element) ) return; if (element.contains(element.ownerDocument.activeElement)) return; diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index 369bbc62..2a13e8bb 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -6,7 +6,16 @@ export const tag = 'ion-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonButtonElement; - if (!inFixedToolbar(element) || button.fill !== 'default' || button.classList.contains('ion-color')) return; + const foldable = + !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + const fill = button.fill ?? 'default'; + if ( + !inFixedToolbar(element) || + (!foldable && fill !== 'default') || + (foldable && !['default', 'clear'].includes(fill)) || + button.classList.contains('ion-color') + ) + return; const candidate = createCandidate(element, tag, id); return appendItem(candidate, element, id) ? candidate : undefined; }; diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index 0dd49d66..4b20983b 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -10,12 +10,15 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined if (!inFixedToolbar(element)) return; const children = Array.from(element.children) as HTMLElement[]; if (children.length === 1) return menuButton.read(element, id); + const foldable = + !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); if ( !children.length || children.some( (child) => !child.matches(`${menuButton.tag}.ios`) && - (!child.matches('ion-button.ios.button-clear') || (child as HTMLIonButtonElement).fill !== 'clear'), + (!child.matches(`ion-button.ios${foldable ? '' : '.button-clear'}`) || + !(foldable ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')), ) ) return; diff --git a/src/native/components/ion-tab-bar.ts b/src/native/components/ion-tab-bar.ts index 79893e16..cf4ea438 100644 --- a/src/native/components/ion-tab-bar.ts +++ b/src/native/components/ion-tab-bar.ts @@ -7,11 +7,6 @@ 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(':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. diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 1cd38532..f382c5c7 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -83,6 +83,8 @@ export interface ShellItem extends Frame { export interface ShellControl extends Frame { id: string; kind: NativeUIShellComponent; + /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */ + placement?: 'foldable-rail'; items: ShellItem[]; dark: boolean; rtl: boolean; diff --git a/src/native/index.ts b/src/native/index.ts index 5fb9cfb7..921cf4a5 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -43,13 +43,13 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise } if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); return (active ??= (async () => { - const foldableWeb = createFoldableWebProjection(document, options); - if (Capacitor.getPlatform() !== 'ios') return resetOnDestroy(withReason(foldableWeb, 'Requires Capacitor iOS')); + if (Capacitor.getPlatform() !== 'ios') + return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires Capacitor iOS')); let runtime: NativeUIShellHandle | undefined; try { await configureNativeTransition().catch(() => undefined); if (!(await plugin.configure()).supported) { - return resetOnDestroy(withReason(foldableWeb, 'Requires iOS 26 or later')); + return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires iOS 26 or later')); } runtime = await createRuntime(document, plugin, options); runtime = await bindMetricsLifecycle( @@ -57,29 +57,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), () => (active = undefined), ); - return resetOnDestroy(combine(runtime, foldableWeb)); + return resetOnDestroy(runtime); } catch (error) { await runtime?.destroy(); - return resetOnDestroy(withReason(foldableWeb, error instanceof Error ? error.message : String(error))); + return resetOnDestroy( + withReason(createFoldableWebProjection(document, options), error instanceof Error ? error.message : String(error)), + ); } })()); }; -const combine = (native: NativeUIShellHandle, foldableWeb: NativeUIShellHandle): NativeUIShellHandle => ({ - getStatus: () => { - const nativeStatus = native.getStatus(); - const webStatus = foldableWeb.getStatus(); - return { ...nativeStatus, projected: nativeStatus.projected + webStatus.projected }; - }, - async suspend() { - const [nativeLease, webLease] = await Promise.all([native.suspend(), foldableWeb.suspend()]); - return { resume: async () => void (await Promise.all([nativeLease.resume(), webLease.resume()])) }; - }, - async destroy() { - await Promise.all([native.destroy(), foldableWeb.destroy()]); - }, -}); - const resetOnDestroy = (handle: NativeUIShellHandle): NativeUIShellHandle => ({ getStatus: handle.getStatus, suspend: handle.suspend, diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 4955d114..06cbdef4 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -10,7 +10,7 @@ import type { NativeUIShellPlugin, NativeUIShellStatus, } from './definitions'; -import { readCandidate, selector, shadowSelector, motionSelector } from './components'; +import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; import { marker, unprojected } from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; @@ -125,12 +125,7 @@ export const createRuntime = async ( const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element]; const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { const candidate = readCandidate(element, id); - if ( - candidate && - ['ion-back-button', 'ion-buttons', 'ion-menu-button'].includes(candidate.control.kind) && - element.closest(':is(ion-app, body).ios-theme-enable-foldable') - ) - return undefined; + if (candidate && isFoldableRailCandidate(element)) candidate.control.placement = 'foldable-rail'; return candidate && controlEnabled(candidate) ? candidate : undefined; }; const flush = async () => { From e4f206801c06c23e824be43a15257d51bcfce6bb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 16:49:12 +0900 Subject: [PATCH 02/35] test: align native shell ownership assertions --- demo/e2e/native-ui-shell.spec.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 1c104bab..62eedec0 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -402,24 +402,22 @@ test('native click preserves external form submit, disabled, and duplicate prote expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBe(count); }); -test('ancestor display/theme aliases and non-glass fills restore Web', async ({ page }) => { +test('ancestor display, element opt-out aliases and non-glass fills restore Web', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); const button = page.locator('app-native-ui-shell ion-button[type=submit]'); await expect(button).toHaveAttribute('data-native-ui-shell', ''); - for (const toggle of ['Parent hidden', 'Theme disabled']) { - await page.getByRole('button', { name: `${toggle}: false`, exact: true }).click(); - await expect(button).not.toHaveAttribute('data-native-ui-shell'); - await page.getByRole('button', { name: `${toggle}: true`, exact: true }).click(); - await expect(button).toHaveAttribute('data-native-ui-shell', ''); - } + await page.getByRole('button', { name: 'Parent hidden: false', exact: true }).click(); + await expect(button).not.toHaveAttribute('data-native-ui-shell'); + await page.getByRole('button', { name: 'Parent hidden: true', exact: true }).click(); + await expect(button).toHaveAttribute('data-native-ui-shell', ''); for (const name of ['ios-theme-disabled', 'ios26-disabled', 'ionic-theme-disabled']) { await button.evaluate((element, name) => element.classList.add(name), name); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await button.evaluate((element, name) => element.classList.remove(name), name); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } - for (const fill of ['clear', 'solid', 'outline']) { + for (const fill of ['solid', 'outline']) { await page.getByRole('button', { name: `fill: ${fill}`, exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await expect(button.locator('button')).toHaveCSS('visibility', 'visible'); From b9ff6bbb464c48a625fba9ef186a734322e79aa8 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 17:00:00 +0900 Subject: [PATCH 03/35] fix(native): preserve foldable control ownership --- demo/e2e/native-ui-shell.spec.ts | 19 +++++++++++++++++++ .../Components/ShellFoldableRail.swift | 14 +++++++++++++- .../IonicNativeUIShellPlugin.swift | 3 ++- .../ShellSnapshotTests.swift | 6 +++++- src/native/components/index.ts | 5 ++++- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 62eedec0..d7473ab7 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -378,6 +378,25 @@ test('foldable back navigation requests native rail placement', async ({ page }) ) .toBe(true); + await page + .locator('app-native-ui-shell ion-buttons[slot=end] ion-button') + .filter({ hasText: 'Cancel' }) + .evaluate((element) => element.remove()); + await expect + .poll(() => + page.evaluate(() => + (window as any).__nativeUIShell.updates + .at(-1) + .controls.some( + (control: any) => + control.kind === 'ion-button' && + control.placement === 'foldable-rail' && + control.items.some((item: any) => item.accessibilityLabel === 'Save'), + ), + ), + ) + .toBe(true); + await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); await expect(projection).toHaveCount(0); await expect(source).toHaveAttribute('data-native-ui-shell', ''); diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index e64e0c9c..b70d876c 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -16,6 +16,7 @@ final class ShellFoldableRailModel: ObservableObject { let label: String let accessibilityLabel: String let image: UIImage? + let badge: ShellBadge? let disabled: Bool var selected: Bool } @@ -39,7 +40,7 @@ final class ShellFoldableRailModel: ObservableObject { func item(_ source: ShellItem) -> Item { Item(id: source.id, label: source.content.label, accessibilityLabel: source.content.accessibilityLabel, - image: rendering.image(source.content), disabled: source.content.disabled, + image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled, selected: source.content.selected) } back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) @@ -94,6 +95,16 @@ private struct ShellFoldableLabel: View { } } +@available(iOS 26.0, *) +private struct ShellFoldableBadge: ViewModifier { + let badge: ShellBadge? + + @ViewBuilder func body(content: Content) -> some View { + if let badge { content.badge(badge.value) } + else { content } + } +} + @available(iOS 26.0, *) private struct ShellFoldableRailView: View { @ObservedObject var model: ShellFoldableRailModel @@ -111,6 +122,7 @@ private struct ShellFoldableRailView: View { .allowsHitTesting(false) .tag(item.id) .tabItem { ShellFoldableLabel(item: item) } + .modifier(ShellFoldableBadge(badge: item.badge)) .disabled(item.disabled) .accessibilityLabel(item.accessibilityLabel) .accessibilityIdentifier(item.id) diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index bb6902c6..63a3d42e 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -223,9 +223,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele var fabs: [(ShellFab, ShellControl)] = [] var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] var rejectedSearches: [String] = [] - if foldable.isEmpty { + if foldable.isEmpty || self.keyboardVisible { self.foldableRail?.detach() self.foldableRail = nil + if self.keyboardVisible { rejectedControls.append(contentsOf: foldable.map(\.id)) } } else if let owner = self.bridge?.viewController { let rail = self.foldableRail ?? ShellFoldableRailController(activate: { [weak self] id in self?.activate(id) }) self.foldableRail = rail diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index f3dd08cd..970b7c82 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -41,12 +41,16 @@ final class ShellSnapshotTests: XCTestCase { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") } func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] { var items = [item(["id": "left", "selected": selected == "left"])] - if includeRight { items.append(item(["id": "right", "selected": selected == "right"])) } + if includeRight { + items.append(item(["id": "right", "selected": selected == "right", + "badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]])) + } return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls } let model = ShellFoldableRailModel() let rendering = ShellRendering() model.apply(try tabs("left"), rendering: rendering, now: 100) + XCTAssertEqual(model.tabs.last?.badge?.value, "3") model.select("right", now: 100, ttl: 10) model.apply(try tabs("left"), rendering: rendering, now: 101) XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection") diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 902b1390..e0606fdb 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -31,7 +31,10 @@ const foldableRailTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons' export const isFoldableRailCandidate = (element: HTMLElement): boolean => foldableRailTags.has(element.localName) && - (!element.matches('ion-button') || !element.parentElement?.matches('ion-buttons') || isDisabledButtonGroupChild(element)) && + (!element.matches('ion-button') || + !element.parentElement?.matches('ion-buttons') || + element.parentElement.children.length === 1 || + isDisabledButtonGroupChild(element)) && !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); From 69bdf05c960951784787cc9f8565db0d1a6cdfdc Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 17:19:32 +0900 Subject: [PATCH 04/35] fix(native): gate foldable rail ownership by capability --- demo/e2e/native-ui-shell.spec.ts | 152 +++++++++++------- docs/native-ui-shell.md | 2 +- .../IonicNativeUIShellPlugin.swift | 7 +- src/native/definitions.ts | 2 +- src/native/index.ts | 27 +++- src/native/runtime.ts | 6 +- 6 files changed, 133 insertions(+), 63 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index d7473ab7..775bd8e1 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -6,64 +6,67 @@ import * as overlayTypes from '../src/app/overlay-types'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); -const mockNative = async (page: Page, fail = false) => { - await page.addInitScript((fail) => { - const state = { - updates: [] as any[], - sequence: 0, - delay: 0, - hang: false, - rejectInactiveSearch: false, - rejectAllSearch: false, - activate: (_event: any) => {}, - search: (_event: any) => {}, - metrics: (_event: any) => {}, - }; - Object.assign(window, { - __nativeUIShell: state, - CapacitorCustomPlatform: { name: 'ios' }, - Capacitor: { - PluginHeaders: [ - { - name: 'IonicNativeUIShell', - methods: [ - { name: 'configure', rtype: 'promise' }, - { name: 'getWebViewMetrics', rtype: 'promise' }, - { name: 'update', rtype: 'promise' }, - { name: 'clear', rtype: 'promise' }, - { name: 'addListener' }, - { name: 'removeListener' }, - ], +const mockNative = async (page: Page, fail = false, foldableRail = true) => { + await page.addInitScript( + ({ fail, foldableRail }) => { + const state = { + updates: [] as any[], + sequence: 0, + delay: 0, + hang: false, + rejectInactiveSearch: false, + rejectAllSearch: false, + activate: (_event: any) => {}, + search: (_event: any) => {}, + metrics: (_event: any) => {}, + }; + Object.assign(window, { + __nativeUIShell: state, + CapacitorCustomPlatform: { name: 'ios' }, + Capacitor: { + PluginHeaders: [ + { + name: 'IonicNativeUIShell', + methods: [ + { name: 'configure', rtype: 'promise' }, + { name: 'getWebViewMetrics', rtype: 'promise' }, + { name: 'update', rtype: 'promise' }, + { name: 'clear', rtype: 'promise' }, + { name: 'addListener' }, + { name: 'removeListener' }, + ], + }, + ], + nativePromise: async (_plugin: string, method: string, options: any) => { + if (method === 'configure') return { supported: true, foldableRail }; + if (method === 'getWebViewMetrics') return { radius: 0 }; + state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); + if (state.hang && method === 'update') await new Promise(() => {}); + if (state.delay) await new Promise((resolve) => setTimeout(resolve, state.delay)); + if (fail && method === 'update' && options.controls.length) throw new Error('Test native failure'); + return { + revision: options.revision, + rejectedSearches: + state.rejectInactiveSearch || state.rejectAllSearch + ? options.controls + ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) + .map((control: any) => control.id) + : [], + }; + }, + nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { + if (method === 'addListener') { + if (options.eventName === 'search') state.search = callback; + else if (options.eventName === 'activate') state.activate = callback; + else if (options.eventName === 'webViewMetricsChange') state.metrics = callback; + } + return 'shell-listener'; }, - ], - nativePromise: async (_plugin: string, method: string, options: any) => { - if (method === 'configure') return { supported: true }; - if (method === 'getWebViewMetrics') return { radius: 0 }; - state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); - if (state.hang && method === 'update') await new Promise(() => {}); - if (state.delay) await new Promise((resolve) => setTimeout(resolve, state.delay)); - if (fail && method === 'update' && options.controls.length) throw new Error('Test native failure'); - return { - revision: options.revision, - rejectedSearches: - state.rejectInactiveSearch || state.rejectAllSearch - ? options.controls - ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) - .map((control: any) => control.id) - : [], - }; - }, - nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { - if (method === 'addListener') { - if (options.eventName === 'search') state.search = callback; - else if (options.eventName === 'activate') state.activate = callback; - else if (options.eventName === 'webViewMetricsChange') state.metrics = callback; - } - return 'shell-listener'; }, - }, - }); - }, fail); + }); + }, + { fail, foldableRail }, + ); }; const activate = (page: Page, label: string, duplicate = false) => @@ -402,6 +405,29 @@ test('foldable back navigation requests native rail placement', async ({ page }) await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('foldable controls stay operable on Web when the native side rail is unavailable', async ({ page }) => { + await mockNative(page, false, false); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + await expect(projection).toBeVisible(); + await expect(page.locator('ion-tab-bar')).toBeVisible(); + await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBeGreaterThan(0); + await expect + .poll(() => + page.evaluate(() => + (window as any).__nativeUIShell.updates.every((snapshot: any) => + snapshot.controls.every((control: any) => control.placement !== 'foldable-rail'), + ), + ), + ) + .toBe(true); + + await projection.click(); + await expect(page).toHaveURL(/\/main\/index$/); +}); + test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); @@ -425,18 +451,30 @@ test('ancestor display, element opt-out aliases and non-glass fills restore Web' await mockNative(page); await page.goto('/main/index/native-ui-shell'); const button = page.locator('app-native-ui-shell ion-button[type=submit]'); + const toolbar = button.locator('xpath=ancestor::ion-toolbar[1]'); await expect(button).toHaveAttribute('data-native-ui-shell', ''); await page.getByRole('button', { name: 'Parent hidden: false', exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await page.getByRole('button', { name: 'Parent hidden: true', exact: true }).click(); await expect(button).toHaveAttribute('data-native-ui-shell', ''); + for (const name of ['ios-theme-disabled', 'ios26-disabled', 'ionic-theme-disabled']) { + await toolbar.evaluate((element, name) => element.classList.add(name), name); + await expect(button).not.toHaveAttribute('data-native-ui-shell'); + await toolbar.evaluate((element, name) => element.classList.remove(name), name); + await expect(button).toHaveAttribute('data-native-ui-shell', ''); + } for (const name of ['ios-theme-disabled', 'ios26-disabled', 'ionic-theme-disabled']) { await button.evaluate((element, name) => element.classList.add(name), name); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await button.evaluate((element, name) => element.classList.remove(name), name); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } - for (const fill of ['solid', 'outline']) { + await page + .locator('app-native-ui-shell ion-buttons[slot=end] ion-button') + .filter({ hasText: 'Cancel' }) + .evaluate((element) => element.remove()); + await expect(button).toHaveAttribute('data-native-ui-shell', ''); + for (const fill of ['clear', 'solid', 'outline']) { await page.getByRole('button', { name: `fill: ${fill}`, exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await expect(button.locator('button')).toHaveCSS('visibility', 'visible'); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index a154ead6..25435640 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -169,7 +169,7 @@ The native material and control appearance follow the running iOS version; an iO ## Foldable controls -On supported iOS versions, adding `.ios-theme-enable-foldable` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers. +On supported iOS versions, adding `.ios-theme-enable-foldable` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar only when iOS reports a physical right-side safe area large enough for that rail. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers. The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. Menus, modals, and popovers remain independent surfaces and are not moved into the main-page rail. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 63a3d42e..291a2d41 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -109,6 +109,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self?.removeControls() self?.revision = 0 if #available(iOS 26.0, *) { + self?.bridge?.webView?.layoutIfNeeded() + // A foldable rail exists only when the system reserves enough of + // the physical right edge to host its adaptive controls. Ordinary + // iPhone/iPad safe areas must keep using the Web projection. + let foldableRail = (self?.bridge?.webView?.safeAreaInsets.right ?? 0) >= 70 // Ionic already paints the header edge; a second native effect can // add a dark scrim when the OS and Web themes differ. if let effect = self?.bridge?.webView?.scrollView.topEdgeEffect { @@ -116,7 +121,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele effect.isHidden = true self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } } - call.resolve(["supported": true]) + call.resolve(["supported": true, "foldableRail": foldableRail]) } else { call.resolve(["supported": false]) } } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f382c5c7..013eda6b 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -133,7 +133,7 @@ export interface WebViewMetrics { } export interface NativeUIShellPlugin { - configure(): Promise<{ supported: boolean }>; + configure(): Promise<{ supported: boolean; foldableRail?: boolean }>; getWebViewMetrics(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; diff --git a/src/native/index.ts b/src/native/index.ts index 921cf4a5..e0ac9e4f 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -22,6 +22,27 @@ const web = (reason: string): NativeUIShellHandle => ({ destroy: async () => {}, }); +const combine = (native: NativeUIShellHandle, fallback: NativeUIShellHandle): NativeUIShellHandle => ({ + getStatus() { + const nativeStatus = native.getStatus(); + const fallbackStatus = fallback.getStatus(); + return { + ...nativeStatus, + state: + nativeStatus.state === 'stopped' && fallbackStatus.state === 'stopped' ? 'stopped' : nativeStatus.projected > 0 ? 'native' : 'web', + projected: nativeStatus.projected + fallbackStatus.projected, + updates: nativeStatus.updates + fallbackStatus.updates, + }; + }, + async suspend() { + const [nativeLease, fallbackLease] = await Promise.all([native.suspend(), fallback.suspend()]); + return { resume: async () => void (await Promise.all([nativeLease.resume(), fallbackLease.resume()])) }; + }, + async destroy() { + await Promise.all([native.destroy(), fallback.destroy()]); + }, +}); + /** Reads the current native WebView geometry and applies it to page transitions. */ export const configureNativeTransition = async (): Promise => { const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 }; @@ -48,10 +69,12 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise let runtime: NativeUIShellHandle | undefined; try { await configureNativeTransition().catch(() => undefined); - if (!(await plugin.configure()).supported) { + const capabilities = await plugin.configure(); + if (!capabilities.supported) { return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires iOS 26 or later')); } - runtime = await createRuntime(document, plugin, options); + runtime = await createRuntime(document, plugin, options, capabilities.foldableRail === true); + if (capabilities.foldableRail !== true) runtime = combine(runtime, createFoldableWebProjection(document, options)); runtime = await bindMetricsLifecycle( runtime, () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 06cbdef4..bca537b7 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -31,6 +31,7 @@ export const createRuntime = async ( doc: Document, plugin: NativeUIShellPlugin, options: NativeUIShellOptions = {}, + nativeFoldableRail = true, ): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); @@ -125,7 +126,10 @@ export const createRuntime = async ( const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element]; const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { const candidate = readCandidate(element, id); - if (candidate && isFoldableRailCandidate(element)) candidate.control.placement = 'foldable-rail'; + if (candidate && isFoldableRailCandidate(element)) { + if (!nativeFoldableRail) return undefined; + candidate.control.placement = 'foldable-rail'; + } return candidate && controlEnabled(candidate) ? candidate : undefined; }; const flush = async () => { From df1885dcc69957bdba6ecb4749b3b0df55f9687f Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 18:07:35 +0900 Subject: [PATCH 05/35] fix(demo): refresh native shell for foldable mode --- demo/src/app/index/index-page.component.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index a22a5f3b..448593f4 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,6 +19,13 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; +import { enableNativeUIShell, type NativeUIShellHandle } from '../../../../src/native'; + +declare global { + interface Window { + nativeUIShell?: NativeUIShellHandle; + } +} interface IComponent { name: string; @@ -100,7 +107,9 @@ export class IndexPageComponent { this.#document.documentElement.classList.toggle('ion-palette-dark', event.detail.checked); } - changeFoldableMode(event: ToggleCustomEvent) { + async changeFoldableMode(event: ToggleCustomEvent) { this.#document.querySelector('ion-app')?.classList.toggle('ios-theme-enable-foldable', event.detail.checked); + await window.nativeUIShell?.destroy(); + window.nativeUIShell = await enableNativeUIShell(); } } From 3d8b357302e3e9e7744da20106f2b846999d57a5 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 18:37:23 +0900 Subject: [PATCH 06/35] fix(native): adapt foldable toolbar on mode change --- .../NativeUIShellTests.swift | 8 ++ .../Components/ShellFoldableRail.swift | 123 ++++++++++++++---- 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 007d7fde..5fe9b1ff 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -278,10 +278,18 @@ final class NativeUIShellTests: XCTestCase { let back = nativeButton(app, label: "back") XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) + assertOnFoldableRail(back, in: app) back.tap() XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) } + private func assertOnFoldableRail(_ element: XCUIElement, in app: XCUIApplication, + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertGreaterThan(element.frame.midX, app.frame.width * 0.8, + "Expected native control on the physical right rail, got \(element.frame) in \(app.frame)", + file: file, line: line) + } + func testNativeShellPageAuditAndRepeatedNavigation() throws { executionTimeAllowance = 900 let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index b70d876c..e6ffd33c 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -110,48 +110,115 @@ private struct ShellFoldableRailView: View { @ObservedObject var model: ShellFoldableRailModel var body: some View { - NavigationStack { - Group { - if model.tabs.isEmpty { + Group { + if model.tabs.isEmpty { + NavigationStack { Color.clear .allowsHitTesting(false) - } else { - TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { - ForEach(model.tabs) { item in + .modifier(ShellFoldableToolbarAdapter(model: model)) + } + } else { + TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { + ForEach(model.tabs) { item in + NavigationStack { Color.clear .allowsHitTesting(false) - .tag(item.id) - .tabItem { ShellFoldableLabel(item: item) } - .modifier(ShellFoldableBadge(badge: item.badge)) - .disabled(item.disabled) - .accessibilityLabel(item.accessibilityLabel) - .accessibilityIdentifier(item.id) + .modifier(ShellFoldableToolbarAdapter(model: model)) } + .tag(item.id) + .tabItem { ShellFoldableLabel(item: item) } + .modifier(ShellFoldableBadge(badge: item.badge)) + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) } } } - .toolbar { - if let back = model.back { - ToolbarItem(placement: .navigation) { - Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } - .disabled(back.disabled) - .accessibilityLabel(back.accessibilityLabel) - .accessibilityIdentifier(back.id) + } + .modifier(ShellFoldableCompression()) + .background(Color.clear) + } +} + +@available(iOS 26.0, *) +private struct ShellFoldableCompression: ViewModifier { + @ViewBuilder func body(content: Content) -> some View { + if #available(iOS 27.1, *) { + content.toolbarVerticalCompressionBehavior(.prefersToolbarItems) + } else { + content + } + } +} + +@available(iOS 26.0, *) +private struct ShellFoldableToolbarAdapter: ViewModifier { + @ObservedObject var model: ShellFoldableRailModel + + @ViewBuilder func body(content: Content) -> some View { + if #available(iOS 27.1, *) { + content.modifier(ShellFoldableToolbar(model: model)) + } else { + content.modifier(ShellFoldableLegacyToolbar(model: model)) + } + } +} + +@available(iOS 27.1, *) +private struct ShellFoldableToolbar: ViewModifier { + @ObservedObject var model: ShellFoldableRailModel + + func body(content: Content) -> some View { + content.toolbar { + if let back = model.back { + ToolbarItem(placement: .cancellationAction) { + Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } + .disabled(back.disabled) + .accessibilityLabel(back.accessibilityLabel) + .accessibilityIdentifier(back.id) + } + .axisBehavior(.verticalPreferred) + } + ForEach(model.groups) { group in + ToolbarItemGroup(placement: .automatic) { + ForEach(group.items) { item in + Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) } } - ForEach(model.groups) { group in - ToolbarItemGroup(placement: .primaryAction) { - ForEach(group.items) { item in - Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } - .disabled(item.disabled) - .accessibilityLabel(item.accessibilityLabel) - .accessibilityIdentifier(item.id) - } + .axisBehavior(.verticalPreferred) + } + } + } +} + +@available(iOS 26.0, *) +private struct ShellFoldableLegacyToolbar: ViewModifier { + @ObservedObject var model: ShellFoldableRailModel + + func body(content: Content) -> some View { + content.toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } + .disabled(back.disabled) + .accessibilityLabel(back.accessibilityLabel) + .accessibilityIdentifier(back.id) + } + } + ForEach(model.groups) { group in + ToolbarItemGroup(placement: .primaryAction) { + ForEach(group.items) { item in + Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) } } } } - .background(Color.clear) } } From 48e74886a7982d4a128fe0b29a96bf93acb4918a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 20:28:22 +0900 Subject: [PATCH 07/35] fix(native): project foldable toolbar without restart --- demo/e2e/native-ui-shell.spec.ts | 6 ++++-- .../NativeUIShellTests.swift | 10 +++++++++- demo/src/app/index/index-page.component.ts | 11 +--------- src/native/components/index.ts | 18 +++++------------ src/native/shared/candidate.ts | 8 ++++++-- src/native/shared/dom.ts | 20 ++++++++++++++++--- 6 files changed, 42 insertions(+), 31 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 775bd8e1..aa37f71b 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -341,10 +341,12 @@ test('foldable tabs request native adaptive rail placement', async ({ page }) => page.evaluate(() => (window as any).__nativeUIShell.updates .at(-1) - .controls.some((control: any) => control.kind === 'ion-tab-bar' && control.placement === 'foldable-rail'), + .controls.filter((control: any) => control.placement === 'foldable-rail') + .map((control: any) => control.kind) + .sort(), ), ) - .toBe(true); + .toEqual(['ion-buttons', 'ion-tab-bar']); await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 5fe9b1ff..f79135b6 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -260,7 +260,10 @@ final class NativeUIShellTests: XCTestCase { let library = app.buttons["Library"] XCTAssertTrue(index.waitForExistence(timeout: 10), app.debugDescription) XCTAssertTrue(library.waitForExistence(timeout: 10), app.debugDescription) - XCTAssertTrue(app.buttons["menu"].waitForExistence(timeout: 10), app.debugDescription) + assertOnFoldableRail(index, in: app) + let menu = nativeButton(app, label: "menu") + XCTAssertTrue(menu.waitForExistence(timeout: 10), app.debugDescription) + assertOnFoldableRail(menu, in: app) capture("native-foldable-index") library.tap() @@ -281,6 +284,11 @@ final class NativeUIShellTests: XCTestCase { assertOnFoldableRail(back, in: app) back.tap() XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + + let restoredMenu = nativeButton(app, label: "menu") + XCTAssertTrue(restoredMenu.waitForExistence(timeout: 10), app.debugDescription) + restoredMenu.tap() + XCTAssertTrue(app.webViews.staticTexts["Docs"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) } private func assertOnFoldableRail(_ element: XCUIElement, in app: XCUIApplication, diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 448593f4..a22a5f3b 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,13 +19,6 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; -import { enableNativeUIShell, type NativeUIShellHandle } from '../../../../src/native'; - -declare global { - interface Window { - nativeUIShell?: NativeUIShellHandle; - } -} interface IComponent { name: string; @@ -107,9 +100,7 @@ export class IndexPageComponent { this.#document.documentElement.classList.toggle('ion-palette-dark', event.detail.checked); } - async changeFoldableMode(event: ToggleCustomEvent) { + changeFoldableMode(event: ToggleCustomEvent) { this.#document.querySelector('ion-app')?.classList.toggle('ios-theme-enable-foldable', event.detail.checked); - await window.nativeUIShell?.destroy(); - window.nativeUIShell = await enableNativeUIShell(); } } diff --git a/src/native/components/index.ts b/src/native/components/index.ts index e0606fdb..33a9cf52 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -5,7 +5,7 @@ import * as menuButton from './ion-menu-button'; import * as tabBar from './ion-tab-bar'; import * as segment from './ion-segment'; import * as fab from './ion-fab'; -import { isDisabledButtonGroupChild, visible } from '../shared/dom'; +import { isDisabledButtonGroupChild, isFoldableRailSource, visible } from '../shared/dom'; import type { Candidate, Identify } from '../shared/candidate'; // Static composition only. Each component declares its own tag, discovery and reader. @@ -27,25 +27,17 @@ export const motionSelector = [ ...components.filter((component) => 'tracksMotion' in component && component.tracksMotion).map((component) => component.tag), ].join(', '); -const foldableRailTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']); - -export const isFoldableRailCandidate = (element: HTMLElement): boolean => - foldableRailTags.has(element.localName) && - (!element.matches('ion-button') || - !element.parentElement?.matches('ion-buttons') || - element.parentElement.children.length === 1 || - isDisabledButtonGroupChild(element)) && - !element.closest('ion-menu, ion-modal, ion-popover') && - !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); +export const isFoldableRailCandidate = isFoldableRailSource; export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { - if (!element.classList.contains('ios') || !visible(element) || element.closest('ion-modal, ion-popover')) return; + const foldable = isFoldableRailCandidate(element); + if (!element.classList.contains('ios') || !visible(element, foldable) || element.closest('ion-modal, ion-popover')) return; const style = getComputedStyle(element); if ( !isDisabledButtonGroupChild(element) && !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && !style.getPropertyValue('--ios26-glass-background-rgb').trim() && - !isFoldableRailCandidate(element) + !foldable ) return; if (element.contains(element.ownerDocument.activeElement)) return; diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index 22b00dd7..f5e9839a 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -1,5 +1,5 @@ import type { ShellControl, ShellItem, NativeUIShellComponent } from '../definitions'; -import { frame, isDark, text, visible } from './dom'; +import { frame, isDark, isFoldableRailSource, text, visible } from './dom'; import { iconSource } from './icons'; export interface Candidate { @@ -39,7 +39,11 @@ export const appendItem = ( content: ParentNode = child, label = text(child), ): ShellItem | undefined => { - if (!visible(child) || child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar')) return; + if ( + !visible(child, isFoldableRailSource(candidate.element)) || + child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar') + ) + return; const icons = Array.from(content.querySelectorAll('ion-icon')); const directSVGs = Array.from(content.querySelectorAll('svg')); if (icons.length + directSVGs.length > 1) return undefined; diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 48df88fe..49f92985 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -12,6 +12,17 @@ const shellDisabledSelector = '.ios-theme-shell-disabled'; export const isDisabledButtonGroupChild = (element: HTMLElement): boolean => element.matches('ion-button.ios') && element.parentElement?.matches(disabledButtonGroup) === true; +const foldableRailTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']); + +export const isFoldableRailSource = (element: HTMLElement): boolean => + foldableRailTags.has(element.localName) && + (!element.matches('ion-button') || + !element.parentElement?.matches('ion-buttons') || + element.parentElement.children.length === 1 || + isDisabledButtonGroupChild(element)) && + !element.closest('ion-menu, ion-modal, ion-popover') && + !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + export const isExcluded = (element: HTMLElement): boolean => { const owner = element.closest(excluded); return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element)); @@ -31,7 +42,7 @@ export const unprojected = (elements: Iterable, read: () => T): } }; -export const visible = (element: HTMLElement): boolean => { +export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => { if (!element.isConnected || isExcluded(element) || isShellDisabled(element)) return false; for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); @@ -48,7 +59,9 @@ export const visible = (element: HTMLElement): boolean => { } const rect = element.getBoundingClientRect(); return ( - rect.width > 0 && rect.height > 0 && rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1 + rect.width > 0 && + rect.height > 0 && + (allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1)) ); }; @@ -70,10 +83,11 @@ export const text = (element: Element): string => { export const inFixedToolbar = (element: Element): boolean => { const edge = element.closest('ion-toolbar')?.parentElement; + const foldable = !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); return ( !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && !edge.hasAttribute('collapse') && - !edge.matches('.header-collapse-main, .header-collapse-condense') + (foldable || !edge.matches('.header-collapse-main, .header-collapse-condense')) ); }; From b92cdb78e72a7dd168f1a024c659b6c3de1612bd Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 20:44:23 +0900 Subject: [PATCH 08/35] fix(native): keep foldable rail active with menu --- demo/e2e/native-ui-shell.spec.ts | 17 +++++++++++++++++ .../NativeUIShellTests/NativeUIShellTests.swift | 8 ++++++++ src/native/runtime.ts | 16 ++++++++++------ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index aa37f71b..bb8c019d 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -407,6 +407,23 @@ test('foldable back navigation requests native rail placement', async ({ page }) await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('foldable rail remains native while its Ionic menu is open', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockNative(page); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const menu = page.locator('ion-menu'); + const menuSource = page.locator('index-page ion-buttons[slot=start]'); + const tabs = page.locator('ion-tab-bar'); + await expect(menuSource).toHaveAttribute('data-native-ui-shell', ''); + await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); + + await activate(page, 'menu'); + await expect(menu).toHaveClass(/show-menu/); + await expect(menuSource).toHaveAttribute('data-native-ui-shell', ''); + await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); +}); + test('foldable controls stay operable on Web when the native side rail is unavailable', async ({ page }) => { await mockNative(page, false, false); await page.goto('/main/index/native-ui-shell'); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index f79135b6..7452ef24 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -289,6 +289,14 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(restoredMenu.waitForExistence(timeout: 10), app.debugDescription) restoredMenu.tap() XCTAssertTrue(app.webViews.staticTexts["Docs"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(restoredMenu.waitForExistence(timeout: 5), "Foldable menu must remain native while its Web menu is open\n" + app.debugDescription) + XCTAssertTrue(library.waitForExistence(timeout: 5), "Foldable tabs must remain native while the menu is open\n" + app.debugDescription) + assertOnFoldableRail(restoredMenu, in: app) + assertOnFoldableRail(library, in: app) + library.tap() + XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(library.isSelected, app.debugDescription) + capture("native-foldable-after-menu") } private func assertOnFoldableRail(_ element: XCUIElement, in app: XCUIApplication, diff --git a/src/native/runtime.ts b/src/native/runtime.ts index bca537b7..1ab7c784 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -146,13 +146,16 @@ export const createRuntime = async ( Array.from(pages).some((page) => page.contains(element)) || Array.from(moving.keys()).some((surface) => surface.contains(element)); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); - const overlayOpen = () => { + const overlayOpen = (includeMenu = true) => { for (const element of presented) if (!element.isConnected) presented.delete(element); + const presentedOverlayOpen = Array.from(presented).some((element) => includeMenu || !element.matches('ion-menu')); return ( manualSuspensions.size > 0 || - presented.size > 0 || + presentedOverlayOpen || Array.from(doc.querySelectorAll(overlays)).some( - (element) => (element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu'), + (element) => + (includeMenu || !element.matches('ion-menu')) && + ((element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu')), ) ); }; @@ -163,9 +166,9 @@ export const createRuntime = async ( search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface); - if (doc.hidden || overlayOpen()) return []; + if (doc.hidden || overlayOpen(false)) return []; if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return []; - return unprojected(sources.keys(), () => + const candidates = unprojected(sources.keys(), () => search .decorate( Array.from(doc.querySelectorAll(selector)) @@ -176,6 +179,7 @@ export const createRuntime = async ( ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); + return overlayOpen() ? candidates.filter((candidate) => isFoldableRailCandidate(candidate.element)) : candidates; }; const observe = () => { const wanted = new Set(); @@ -539,7 +543,7 @@ export const createRuntime = async ( event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence || - overlayOpen() + overlayOpen(false) ) return; // Native may send input before update() resolves on the JS bridge. From 9cd18cd83dcc6f62add1d0f42ed9c1758cd8254a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 21:16:38 +0900 Subject: [PATCH 09/35] fix(native): retain foldable rail controls with menu --- demo/e2e/native-ui-shell.spec.ts | 55 +++++++++++-------- .../NativeUIShellTests.swift | 19 +++---- .../native-ui-shell/native-ui-shell.page.html | 2 +- src/native/components/ion-button.ts | 3 +- src/native/components/ion-buttons.ts | 3 +- src/native/foldable-web.ts | 6 +- src/native/runtime.ts | 21 +++++-- src/native/shared/dom.ts | 11 +++- 8 files changed, 75 insertions(+), 45 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index bb8c019d..c246c4f5 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -341,12 +341,10 @@ test('foldable tabs request native adaptive rail placement', async ({ page }) => page.evaluate(() => (window as any).__nativeUIShell.updates .at(-1) - .controls.filter((control: any) => control.placement === 'foldable-rail') - .map((control: any) => control.kind) - .sort(), + .controls.some((control: any) => control.kind === 'ion-tab-bar' && control.placement === 'foldable-rail'), ), ) - .toEqual(['ion-buttons', 'ion-tab-bar']); + .toBe(true); await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); @@ -371,22 +369,12 @@ test('foldable back navigation requests native rail placement', async ({ page }) (window as any).__nativeUIShell.updates .at(-1) .controls.some((control: any) => control.kind === 'ion-back-button' && control.placement === 'foldable-rail') && - (window as any).__nativeUIShell.updates + !(window as any).__nativeUIShell.updates .at(-1) - .controls.some( - (control: any) => - control.kind === 'ion-buttons' && - control.placement === 'foldable-rail' && - control.items.some((item: any) => item.label === 'Cancel'), - ), + .controls.some((control: any) => control.items.some((item: any) => item.label === 'Cancel')), ), ) .toBe(true); - - await page - .locator('app-native-ui-shell ion-buttons[slot=end] ion-button') - .filter({ hasText: 'Cancel' }) - .evaluate((element) => element.remove()); await expect .poll(() => page.evaluate(() => @@ -410,27 +398,40 @@ test('foldable back navigation requests native rail placement', async ({ page }) test('foldable rail remains native while its Ionic menu is open', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page); - await page.goto('/main/index'); + await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); const menu = page.locator('ion-menu'); - const menuSource = page.locator('index-page ion-buttons[slot=start]'); + const menuSource = page.locator('app-native-ui-shell ion-menu-button').locator('..'); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + const saveSource = page.locator('app-native-ui-shell ion-button[type=submit]'); + const cancelSource = page.locator('app-native-ui-shell ion-button').filter({ hasText: 'Cancel' }); const tabs = page.locator('ion-tab-bar'); - await expect(menuSource).toHaveAttribute('data-native-ui-shell', ''); - await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); + for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(cancelSource).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(cancelSource).toBeVisible(); await activate(page, 'menu'); await expect(menu).toHaveClass(/show-menu/); - await expect(menuSource).toHaveAttribute('data-native-ui-shell', ''); - await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); + for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(cancelSource).toBeVisible(); + await activate(page, 'Save'); + await expect(page.locator('[data-save-count]')).toHaveText('1'); }); test('foldable controls stay operable on Web when the native side rail is unavailable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page, false, false); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + const menuProjection = page.locator('ion-app > ion-menu-button.ios-theme-foldable-toolbar-projection'); + const saveProjection = page.locator('ion-app > ion-button.ios-theme-foldable-toolbar-projection[aria-label=Save]'); await expect(projection).toBeVisible(); + await expect(menuProjection).toBeVisible(); + await expect(saveProjection).toBeVisible(); await expect(page.locator('ion-tab-bar')).toBeVisible(); await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBeGreaterThan(0); await expect @@ -443,6 +444,15 @@ test('foldable controls stay operable on Web when the native side rail is unavai ) .toBe(true); + await menuProjection.click(); + await expect(page.locator('ion-menu')).toHaveClass(/show-menu/); + await expect(projection).toBeVisible(); + await expect(menuProjection).toBeVisible(); + await expect(saveProjection).toBeVisible(); + await expect(page.locator('ion-tab-bar')).toBeVisible(); + await saveProjection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + await projection.click(); await expect(page).toHaveURL(/\/main\/index$/); }); @@ -1160,6 +1170,7 @@ test('menu button toggles its Ionic menu and follows autoHide, disabled and spli await page.goto('/main/index/native-ui-shell'); const menu = page.locator('ion-menu'); const button = page.locator('app-native-ui-shell ion-menu-button'); + await button.evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = true)); const surface = button.locator('..'); await expect(surface).toHaveAttribute('data-native-ui-shell', ''); await expect diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 7452ef24..ae3b83f0 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -108,6 +108,8 @@ final class NativeUIShellTests: XCTestCase { entry.tap() let save = nativeButton(app, label: "Save") XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertFalse(nativeButton(app, label: "Cancel").exists, "Text-only toolbar actions must remain in Web") + XCTAssertTrue(app.webViews.buttons["Cancel"].firstMatch.exists, app.debugDescription) XCUIDevice.shared.press(.home) XCTAssertTrue(app.wait(for: .runningBackground, timeout: 5)) app.activate() @@ -282,17 +284,14 @@ final class NativeUIShellTests: XCTestCase { let back = nativeButton(app, label: "back") XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) assertOnFoldableRail(back, in: app) - back.tap() - XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) - - let restoredMenu = nativeButton(app, label: "menu") - XCTAssertTrue(restoredMenu.waitForExistence(timeout: 10), app.debugDescription) - restoredMenu.tap() + let pageMenu = nativeButton(app, label: "menu") + XCTAssertTrue(pageMenu.waitForExistence(timeout: 5), app.debugDescription) + pageMenu.tap() XCTAssertTrue(app.webViews.staticTexts["Docs"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) - XCTAssertTrue(restoredMenu.waitForExistence(timeout: 5), "Foldable menu must remain native while its Web menu is open\n" + app.debugDescription) - XCTAssertTrue(library.waitForExistence(timeout: 5), "Foldable tabs must remain native while the menu is open\n" + app.debugDescription) - assertOnFoldableRail(restoredMenu, in: app) - assertOnFoldableRail(library, in: app) + for control in [pageMenu, back, save, library] { + XCTAssertTrue(control.waitForExistence(timeout: 5), "Every foldable rail control must remain native while the menu is open\n" + app.debugDescription) + assertOnFoldableRail(control, in: app) + } library.tap() XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) XCTAssertTrue(library.isSelected, app.debugDescription) diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index a114062f..93d366b2 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -18,7 +18,7 @@ - + Actions: {{ github() }} / {{ refreshes() }} diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index 2a13e8bb..3ebe8bc5 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { inFixedToolbar, isFoldableToolbarAction } from '../shared/dom'; export const tag = 'ion-button'; @@ -11,6 +11,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined const fill = button.fill ?? 'default'; if ( !inFixedToolbar(element) || + (foldable && !isFoldableToolbarAction(element)) || (!foldable && fill !== 'default') || (foldable && !['default', 'clear'].includes(fill)) || button.classList.contains('ion-color') diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index 4b20983b..c075d1b3 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { inFixedToolbar, isFoldableToolbarAction } from '../shared/dom'; import * as menuButton from './ion-menu-button'; export const tag = 'ion-buttons'; @@ -12,6 +12,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined if (children.length === 1) return menuButton.read(element, id); const foldable = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + if (foldable && children.some((child) => !isFoldableToolbarAction(child))) return; if ( !children.length || children.some( diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index e5336838..4ef94bfe 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -1,5 +1,5 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; -import { isExcluded, isShellDisabled, marker, unprojected } from './shared/dom'; +import { isExcluded, isFoldableToolbarAction, isShellDisabled, marker, unprojected } from './shared/dom'; const backProjectionClass = 'ios-theme-foldable-back-button-projection'; const toolbarProjectionClass = 'ios-theme-foldable-toolbar-projection'; @@ -68,9 +68,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const isEligibleBack = (element: HTMLIonBackButtonElement) => inEligibleToolbar(element) && unprojected(projectedSources(), () => isRendered(element)); const isToolbarAction = (element: HTMLElement) => { - if (!element.matches('ion-button.ios, ion-menu-button.ios') || isExcluded(element) || isShellDisabled(element)) return false; - if (element.matches('ion-menu-button')) return true; - return !!element.querySelector('ion-icon, svg'); + return isFoldableToolbarAction(element); }; const toolbarActions = (element: HTMLIonButtonsElement) => Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isToolbarAction(child)); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 1ab7c784..17d55d2d 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -11,7 +11,7 @@ import type { NativeUIShellStatus, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; -import { marker, unprojected } from './shared/dom'; +import { isFoldableRailSource, marker, unprojected } from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -168,18 +168,19 @@ export const createRuntime = async ( for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface); if (doc.hidden || overlayOpen(false)) return []; if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return []; + const menuOpen = overlayOpen(); const candidates = unprojected(sources.keys(), () => search .decorate( Array.from(doc.querySelectorAll(selector)) - .filter((element) => !blocked(element)) + .filter((element) => !blocked(element) || (menuOpen && isFoldableRailSource(element))) .map(readEnabledCandidate) .filter((candidate): candidate is Candidate => !!candidate), blocked, ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); - return overlayOpen() ? candidates.filter((candidate) => isFoldableRailCandidate(candidate.element)) : candidates; + return menuOpen ? candidates.filter((candidate) => isFoldableRailCandidate(candidate.element)) : candidates; }; const observe = () => { const wanted = new Set(); @@ -442,12 +443,16 @@ export const createRuntime = async ( schedule(); }); } + const eventMenu = (event: Event) => + event.composedPath().find((target): target is HTMLElement => target instanceof HTMLElement && target.matches('ion-menu')); on(doc, 'ionWillOpen', (event) => { - presented.add(event.target as HTMLElement); + const menu = eventMenu(event); + if (menu) presented.add(menu); schedule(); }); on(doc, 'ionDidClose', (event) => { - presented.delete(event.target as HTMLElement); + const menu = eventMenu(event); + if (menu) presented.delete(menu); schedule(); }); for (const name of [ @@ -557,6 +562,12 @@ export const createRuntime = async ( if (!element) return; const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element)); if (!owner) return; + if (overlayOpen() && isFoldableRailSource(owner)) { + element.click(); + lastSnapshot = ''; + schedule(); + return; + } const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 49f92985..11f83a5d 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -19,6 +19,7 @@ export const isFoldableRailSource = (element: HTMLElement): boolean => (!element.matches('ion-button') || !element.parentElement?.matches('ion-buttons') || element.parentElement.children.length === 1 || + isFoldableToolbarAction(element) || isDisabledButtonGroupChild(element)) && !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); @@ -32,6 +33,11 @@ export const isExcluded = (element: HTMLElement): boolean => { export const isShellDisabled = (element: Element): boolean => !!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector); +export const isFoldableToolbarAction = (element: HTMLElement): boolean => + !isExcluded(element) && + !isShellDisabled(element) && + (element.matches('ion-menu-button.ios') || (element.matches('ion-button.ios') && !!element.querySelector('ion-icon, svg'))); + export const unprojected = (elements: Iterable, read: () => T): T => { const hidden = Array.from(elements).filter((element) => element.hasAttribute(marker)); hidden.forEach((element) => element.removeAttribute(marker)); @@ -48,8 +54,11 @@ export const visible = (element: HTMLElement, allowOutsideViewport = false): boo const style = getComputedStyle(current); if (style.display === 'none' || style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker))) return false; - // Moving/collapsing/custom transformed surfaces stay in Web coordinates. + // Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates. + // Foldable rail controls are placed independently of their Web coordinates and must remain + // owned while Ionic transforms the content behind an open menu. if ( + !allowOutsideViewport && style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity && current !== element && From d1d7710bdde253629033377aa35b00ccf3e36adb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 21:37:23 +0900 Subject: [PATCH 10/35] fix(native): avoid duplicate foldable toolbar projection --- demo/e2e/native-ui-shell.spec.ts | 13 +++++++++++++ .../ios/NativeUIShellTests/NativeUIShellTests.swift | 2 ++ src/native/components/ion-button.ts | 3 ++- src/native/components/ion-buttons.ts | 4 ++-- src/native/shared/dom.ts | 11 +++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index c246c4f5..9f8282db 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -410,6 +410,19 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); await expect(cancelSource).not.toHaveAttribute('data-native-ui-shell', ''); await expect(cancelSource).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + const demoActions = (control: any) => + control.items.filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).length; + return { + groups: controls.filter((control: any) => control.kind === 'ion-buttons' && demoActions(control) === 2).length, + individuals: controls.filter((control: any) => control.kind === 'ion-button' && demoActions(control) > 0).length, + }; + }), + ) + .toEqual({ groups: 1, individuals: 0 }); await activate(page, 'menu'); await expect(menu).toHaveClass(/show-menu/); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index ae3b83f0..8e028143 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -279,6 +279,8 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) save.tap() XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) + XCTAssertEqual(app.buttons.matching(identifier: "GitHub").count, 1, app.debugDescription) + XCTAssertEqual(app.buttons.matching(identifier: "Refresh").count, 1, app.debugDescription) capture("native-foldable-toolbar") let back = nativeButton(app, label: "back") diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index 3ebe8bc5..d81cd9eb 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar, isFoldableToolbarAction } from '../shared/dom'; +import { inFixedToolbar, isFoldableToolbarAction, isFoldableToolbarGroup } from '../shared/dom'; export const tag = 'ion-button'; @@ -8,6 +8,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined const button = element as HTMLIonButtonElement; const foldable = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + if (foldable && element.parentElement && isFoldableToolbarGroup(element.parentElement)) return; const fill = button.fill ?? 'default'; if ( !inFixedToolbar(element) || diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index c075d1b3..d7b79cb6 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar, isFoldableToolbarAction } from '../shared/dom'; +import { inFixedToolbar, isFoldableToolbarAction, isFoldableToolbarGroup } from '../shared/dom'; import * as menuButton from './ion-menu-button'; export const tag = 'ion-buttons'; @@ -12,7 +12,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined if (children.length === 1) return menuButton.read(element, id); const foldable = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); - if (foldable && children.some((child) => !isFoldableToolbarAction(child))) return; + if (foldable && !isFoldableToolbarGroup(element)) return; if ( !children.length || children.some( diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 11f83a5d..2bd1d263 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -38,6 +38,17 @@ export const isFoldableToolbarAction = (element: HTMLElement): boolean => !isShellDisabled(element) && (element.matches('ion-menu-button.ios') || (element.matches('ion-button.ios') && !!element.querySelector('ion-icon, svg'))); +export const isFoldableToolbarGroup = (element: HTMLElement): boolean => { + const children = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement); + return ( + element.matches('ion-buttons.ios') && + children.length > 1 && + !element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') && + !isShellDisabled(element) && + children.every(isFoldableToolbarAction) + ); +}; + export const unprojected = (elements: Iterable, read: () => T): T => { const hidden = Array.from(elements).filter((element) => element.hasAttribute(marker)); hidden.forEach((element) => element.removeAttribute(marker)); From eff28328174c9436e006f8d49f37ed925d3afec8 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 22 Sep 2026 21:49:25 +0900 Subject: [PATCH 11/35] test(native): exercise foldable rail interactions --- .../NativeUIShellTests.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 8e028143..bf4ac82b 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -279,13 +279,27 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) save.tap() XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) - XCTAssertEqual(app.buttons.matching(identifier: "GitHub").count, 1, app.debugDescription) - XCTAssertEqual(app.buttons.matching(identifier: "Refresh").count, 1, app.debugDescription) + let github = nativeButton(app, label: "GitHub") + let refresh = nativeButton(app, label: "Refresh") + XCTAssertTrue(github.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(refresh.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label == %@ AND identifier BEGINSWITH %@", "GitHub", "shell-")).count, 1, + app.debugDescription) + XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label == %@ AND identifier BEGINSWITH %@", "Refresh", "shell-")).count, 1, + app.debugDescription) + github.tap() + XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 0"].waitForExistence(timeout: 5), app.debugDescription) + refresh.tap() + XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 1"].waitForExistence(timeout: 5), app.debugDescription) capture("native-foldable-toolbar") let back = nativeButton(app, label: "back") XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) assertOnFoldableRail(back, in: app) + back.tap() + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + openPage(app, name: "native-ui-shell") + XCTAssertTrue(nativeButton(app, label: "back").waitForExistence(timeout: 5), app.debugDescription) let pageMenu = nativeButton(app, label: "menu") XCTAssertTrue(pageMenu.waitForExistence(timeout: 5), app.debugDescription) pageMenu.tap() From a6aad838f178de42ce1503f9134c23edb9d4702a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 00:33:51 +0900 Subject: [PATCH 12/35] fix(native): preserve foldable rail menu interactions --- demo/e2e/foldable-layout.spec.ts | 32 ++++++++++++++----- demo/e2e/native-ui-shell.spec.ts | 31 ++++++++++++++++++ .../NativeUIShellTests.swift | 25 +++++++-------- demo/src/app/docs/docs-content.generated.ts | 2 +- docs/special-markup.md | 2 +- .../Components/ShellFoldableRail.swift | 7 ++-- src/native/foldable-web.ts | 6 ++-- src/native/runtime.ts | 20 +++++++++--- src/native/shared/dom.ts | 7 ++++ src/styles/components/ion-menu.scss | 12 ++++--- 10 files changed, 105 insertions(+), 39 deletions(-) diff --git a/demo/e2e/foldable-layout.spec.ts b/demo/e2e/foldable-layout.spec.ts index 3dcba1ff..14b6919c 100644 --- a/demo/e2e/foldable-layout.spec.ts +++ b/demo/e2e/foldable-layout.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; for (const direction of ['ltr', 'rtl'] as const) { test(`menus respect foldable safe-area insets in ${direction}`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/main/index', { waitUntil: 'networkidle' }); const menu = page.locator('ion-menu'); @@ -11,8 +12,11 @@ for (const direction of ['ltr', 'rtl'] as const) { app.classList.add('ios-theme-enable-foldable'); element.side = direction === 'ltr' ? 'end' : 'start'; await new Promise(requestAnimationFrame); - const defaultBounds = element.getBoundingClientRect(); - const defaultRightOffset = innerWidth - defaultBounds.right; + const container = element.shadowRoot!.querySelector('[part~="container"]')!; + await element.open(false); + const defaultHostBounds = element.getBoundingClientRect(); + const defaultRightOffset = innerWidth - container.getBoundingClientRect().right; + await element.close(false); app.style.setProperty('--ios-theme-foldable-safe-area-left', '76px'); app.style.setProperty('--ios-theme-foldable-safe-area-right', '84px'); app.style.setProperty('--ion-safe-area-left', '76px'); @@ -21,9 +25,17 @@ for (const direction of ['ltr', 'rtl'] as const) { for (const side of ['start', 'end'] as const) { element.side = side; await new Promise(requestAnimationFrame); + await element.open(false); const bounds = element.getBoundingClientRect(); + const containerBounds = container.getBoundingClientRect(); const physicalSide = side === 'start' ? (direction === 'ltr' ? 'left' : 'right') : direction === 'ltr' ? 'right' : 'left'; - offsets.push({ side, physicalSide, offset: physicalSide === 'left' ? bounds.left : innerWidth - bounds.right }); + offsets.push({ + side, + physicalSide, + hostWidthPreserved: bounds.width > 0 && bounds.width === defaultHostBounds.width, + offset: physicalSide === 'left' ? containerBounds.left : innerWidth - containerBounds.right, + }); + await element.close(false); } const contentStyle = getComputedStyle(element.querySelector('ion-content')!); @@ -38,6 +50,7 @@ for (const direction of ['ltr', 'rtl'] as const) { await new Promise(requestAnimationFrame); if (!modalContent.classList.contains('ios')) throw new Error('Expected an iOS ion-content fixture'); return { + defaultHostWidthPositive: defaultHostBounds.width > 0, defaultRightOffset, offsets, safeAreaLeft: contentStyle.getPropertyValue('--ion-safe-area-left').trim(), @@ -46,16 +59,19 @@ for (const direction of ['ltr', 'rtl'] as const) { }; }, direction); - expect(result.defaultRightOffset).toBe(80); + expect({ hostWidthPositive: result.defaultHostWidthPositive, containerRight: result.defaultRightOffset }).toEqual({ + hostWidthPositive: true, + containerRight: 80, + }); expect(result.offsets).toEqual( direction === 'ltr' ? [ - { side: 'start', physicalSide: 'left', offset: 76 }, - { side: 'end', physicalSide: 'right', offset: 84 }, + { side: 'start', physicalSide: 'left', hostWidthPreserved: true, offset: 76 }, + { side: 'end', physicalSide: 'right', hostWidthPreserved: true, offset: 84 }, ] : [ - { side: 'start', physicalSide: 'right', offset: 84 }, - { side: 'end', physicalSide: 'left', offset: 76 }, + { side: 'start', physicalSide: 'right', hostWidthPreserved: true, offset: 84 }, + { side: 'end', physicalSide: 'left', hostWidthPreserved: true, offset: 76 }, ], ); expect(result.safeAreaLeft).toBe('0px'); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 9f8282db..128c121a 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -406,6 +406,7 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page const backSource = page.locator('app-native-ui-shell ion-back-button'); const saveSource = page.locator('app-native-ui-shell ion-button[type=submit]'); const cancelSource = page.locator('app-native-ui-shell ion-button').filter({ hasText: 'Cancel' }); + const actionGroup = page.locator('app-native-ui-shell ion-buttons[data-glass-group]'); const tabs = page.locator('ion-tab-bar'); for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); await expect(cancelSource).not.toHaveAttribute('data-native-ui-shell', ''); @@ -424,8 +425,38 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page ) .toEqual({ groups: 1, individuals: 0 }); + const nativeSaveDisabled = () => + page.evaluate( + () => + (window as any).__nativeUIShell.updates + .at(-1) + .controls.flatMap((control: any) => control.items) + .find((item: any) => item.accessibilityLabel === 'Save')?.disabled, + ); + const nativeActionsDisabled = () => + page.evaluate(() => + (window as any).__nativeUIShell.updates + .at(-1) + .controls.flatMap((control: any) => control.items) + .filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)) + .map((item: any) => item.disabled), + ); + await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await expect.poll(nativeSaveDisabled).toBe(true); + await expect.poll(nativeActionsDisabled).toEqual([true, true]); await activate(page, 'menu'); await expect(menu).toHaveClass(/show-menu/); + await expect + .poll(() => menu.evaluate((element) => element.shadowRoot?.querySelector('[part~="container"]')?.getBoundingClientRect().left)) + .toBe(0); + await expect.poll(nativeSaveDisabled).toBe(true); + await expect.poll(nativeActionsDisabled).toEqual([true, true]); + await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await expect.poll(nativeSaveDisabled).toBe(false); + await expect.poll(nativeActionsDisabled).toEqual([false, false]); + await expect.poll(() => cancelSource.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); await expect(cancelSource).toBeVisible(); await activate(page, 'Save'); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index bf4ac82b..c7c7efe9 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -263,17 +263,12 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(index.waitForExistence(timeout: 10), app.debugDescription) XCTAssertTrue(library.waitForExistence(timeout: 10), app.debugDescription) assertOnFoldableRail(index, in: app) - let menu = nativeButton(app, label: "menu") - XCTAssertTrue(menu.waitForExistence(timeout: 10), app.debugDescription) - assertOnFoldableRail(menu, in: app) - capture("native-foldable-index") - library.tap() - XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) - XCTAssertTrue(library.isSelected) - + XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 5), "The native tab did not project its activation to Web\n" + app.debugDescription) + XCTAssertTrue(library.isSelected, app.debugDescription) index.tap() - XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(toggle.waitForExistence(timeout: 5), app.debugDescription) + capture("native-foldable-index") openPage(app, name: "native-ui-shell") let save = nativeButton(app, label: "Save") XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) @@ -303,14 +298,18 @@ final class NativeUIShellTests: XCTestCase { let pageMenu = nativeButton(app, label: "menu") XCTAssertTrue(pageMenu.waitForExistence(timeout: 5), app.debugDescription) pageMenu.tap() - XCTAssertTrue(app.webViews.staticTexts["Docs"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + let menuLink = app.webViews.links["Docs"] + XCTAssertTrue(menuLink.waitForExistence(timeout: 5), app.debugDescription) + capture("native-foldable-menu-open") for control in [pageMenu, back, save, library] { XCTAssertTrue(control.waitForExistence(timeout: 5), "Every foldable rail control must remain native while the menu is open\n" + app.debugDescription) assertOnFoldableRail(control, in: app) + XCTAssertTrue(control.isEnabled, "Foldable rail controls must remain enabled while Ionic disables the covered page\n" + app.debugDescription) } - library.tap() - XCTAssertTrue(app.webViews.staticTexts["Library"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) - XCTAssertTrue(library.isSelected, app.debugDescription) + save.tap() + pageMenu.tap() + XCTAssertTrue(menuLink.waitForNonExistence(timeout: 5), "The projected Ionic menu button did not close its menu\n" + app.debugDescription) + XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), "The native control stopped projecting actions while the menu was open\n" + app.debugDescription) capture("native-foldable-after-menu") } diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 3c4010e7..30179cf1 100644 --- a/demo/src/app/docs/docs-content.generated.ts +++ b/demo/src/app/docs/docs-content.generated.ts @@ -1,3 +1,3 @@ // Generated from docs/special-markup.md. Do not edit directly. export const docsContentHtml = - '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI is positioned after the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard fill="default" and fill="clear" actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; + '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard fill="default" and fill="clear" actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; diff --git a/docs/special-markup.md b/docs/special-markup.md index ed19b4a4..9958126d 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -59,7 +59,7 @@ The class reserves `80px` on the physical right by default, matching the system This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again. -`ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI is positioned after the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical. +`ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical. 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. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index e6ffd33c..89228bd9 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -114,7 +114,6 @@ private struct ShellFoldableRailView: View { if model.tabs.isEmpty { NavigationStack { Color.clear - .allowsHitTesting(false) .modifier(ShellFoldableToolbarAdapter(model: model)) } } else { @@ -122,7 +121,6 @@ private struct ShellFoldableRailView: View { ForEach(model.tabs) { item in NavigationStack { Color.clear - .allowsHitTesting(false) .modifier(ShellFoldableToolbarAdapter(model: model)) } .tag(item.id) @@ -242,9 +240,8 @@ final class ShellFoldableRailController: ShellFoldableRailControlling { width: railWidth, height: bounds.height)).cgPath } - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - guard point.x >= bounds.maxX - railWidth else { return nil } - return super.hitTest(point, with: event) + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + point.x >= bounds.maxX - railWidth && super.point(inside: point, with: event) } } diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index 4ef94bfe..5e4ab89e 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -1,5 +1,5 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; -import { isExcluded, isFoldableToolbarAction, isShellDisabled, marker, unprojected } from './shared/dom'; +import { activateProjectedElement, isExcluded, isFoldableToolbarAction, isShellDisabled, marker, unprojected } from './shared/dom'; const backProjectionClass = 'ios-theme-foldable-back-button-projection'; const toolbarProjectionClass = 'ios-theme-foldable-toolbar-projection'; @@ -189,7 +189,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel (event) => { event.preventDefault(); event.stopImmediatePropagation(); - if (backSource && backSource === findBack()) backSource.click(); + if (backSource && backSource === findBack()) activateProjectedElement(backSource); }, { capture: true }, ); @@ -213,7 +213,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel (event) => { event.preventDefault(); event.stopImmediatePropagation(); - if (isCurrentToolbarAction(source)) source.click(); + if (isCurrentToolbarAction(source)) activateProjectedElement(source); }, { capture: true }, ); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 17d55d2d..2e5c2ed5 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -11,7 +11,7 @@ import type { NativeUIShellStatus, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; -import { isFoldableRailSource, marker, unprojected } from './shared/dom'; +import { activateProjectedElement, isFoldableRailSource, marker, unprojected } from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -19,6 +19,7 @@ import { createCrossfade, fadeMarker } from './shared/crossfade'; const overlays = 'ion-modal, ion-popover, ion-alert, ion-action-sheet, ion-loading, ion-picker, ion-toast, ion-menu'; const overlayNames = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast']; +const foldableRailMarker = 'data-native-ui-shell-foldable-rail'; // A failed bridge must not leave the source inaccessible indefinitely. const bounded = (promise: Promise): Promise => @@ -96,12 +97,19 @@ export const createRuntime = async ( const style = doc.createElement('style'); const hidden = `[${marker}]:not([${fadeMarker}])`; style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; } - [${marker}], [${marker}] * { pointer-events: none !important; }`; + [${marker}], [${marker}] * { pointer-events: none !important; } + /* Ionic disables the covered page while a menu is open. Foldable rail + controls remain outside that page; zero specificity preserves any + pointer-events rule supplied by the application itself. */ + :where(.menu-content-open) :where([${foldableRailMarker}]) { + pointer-events: auto; + }`; const restore = (element: HTMLElement) => { lastSnapshot = ''; search.release(element); element.removeAttribute(marker); + element.removeAttribute(foldableRailMarker); if (!stopped) crossfade.play(element, false, handoffInstant); if (element.getAttribute('aria-hidden') === 'true') { const previous = sources.get(element); @@ -286,6 +294,9 @@ export const createRuntime = async ( const current = dirty ? new Map(currentCandidates.map((candidate) => [candidate.element, signature(candidate)])) : signatures; const currentSources = new Set(currentCandidates.flatMap(candidateSources)); const accepted = candidates.filter((candidate) => current.get(candidate.element) === signatures.get(candidate.element)); + const acceptedFoldableRailSources = new Set( + accepted.filter((candidate) => candidate.control.placement === 'foldable-rail').flatMap(candidateSources), + ); const invalidated = candidates.length !== accepted.length; acceptedRevision = result.revision; lastSnapshot = invalidated ? '' : serialized; @@ -296,6 +307,7 @@ export const createRuntime = async ( // source needs to return to Web; new sources still require an exact ack. for (const element of sources.keys()) if (!currentSources.has(element)) restore(element); for (const element of accepted.flatMap(candidateSources)) { + element.toggleAttribute(foldableRailMarker, acceptedFoldableRailSources.has(element)); if (!sources.has(element)) { sources.set(element, element.getAttribute('aria-hidden')); crossfade.play(element, true, handoffInstant); @@ -563,7 +575,7 @@ export const createRuntime = async ( const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element)); if (!owner) return; if (overlayOpen() && isFoldableRailSource(owner)) { - element.click(); + activateProjectedElement(element); lastSnapshot = ''; schedule(); return; @@ -575,7 +587,7 @@ export const createRuntime = async ( candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); if (!searchAction && (!item || item.disabled || item.visible === false)) return; // The original Ionic host owns form submission, routerLink and selection events. - element.click(); + activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. schedule(); }; diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 2bd1d263..a1149ade 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -49,6 +49,13 @@ export const isFoldableToolbarGroup = (element: HTMLElement): boolean => { ); }; +export const activateProjectedElement = (element: HTMLElement): void => { + const target = element.matches('ion-button, ion-back-button, ion-menu-button') + ? element.shadowRoot?.querySelector('[part~="native"]') + : undefined; + (target ?? element).click(); +}; + export const unprojected = (elements: Iterable, read: () => T): T => { const hidden = Array.from(elements).filter((element) => element.hasAttribute(marker)); hidden.forEach((element) => element.removeAttribute(marker)); diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index 095145a1..80eeebd1 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -140,14 +140,18 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-menu.ios.menu-side-end:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), ion-menu.ios.menu-side-start:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { --ion-safe-area-right: 0px; - right: var(--ios-theme-foldable-safe-area-right-resolved); - left: auto; + + &::part(container) { + margin-right: var(--ios-theme-foldable-safe-area-right-resolved); + } } ion-menu.ios.menu-side-start:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), ion-menu.ios.menu-side-end:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { --ion-safe-area-left: 0px; - left: var(--ios-theme-foldable-safe-area-left-resolved); - right: auto; + + &::part(container) { + margin-left: var(--ios-theme-foldable-safe-area-left-resolved); + } } } From 7a81d1ec2499493be8d561678b2185f0e8cd7714 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 08:39:47 +0900 Subject: [PATCH 13/35] fix(native): align foldable shell projection --- demo/e2e/native-ui-shell.spec.ts | 43 +++++++++++++++++-- .../Components/ShellFoldableRail.swift | 23 +++++++++- src/native/components/ion-buttons.ts | 6 ++- src/native/foldable-web.ts | 20 +++++---- src/native/runtime.ts | 43 ++++++++++++++++--- src/native/shared/dom.ts | 9 ++-- 6 files changed, 120 insertions(+), 24 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 128c121a..7bd90f53 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -414,7 +414,7 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page await expect .poll(() => page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + const controls = (window as any).__nativeUIShell.updates.at(-1)?.controls ?? []; const demoActions = (control: any) => control.items.filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).length; return { @@ -425,6 +425,35 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page ) .toEqual({ groups: 1, individuals: 0 }); + await actionGroup.evaluate((element) => { + const cancel = document.createElement('ion-button') as HTMLIonButtonElement; + cancel.classList.add('ios'); + cancel.fill = 'clear'; + cancel.textContent = 'Cancel mixed action'; + element.prepend(cancel); + }); + const mixedCancel = actionGroup.locator('ion-button').filter({ hasText: 'Cancel mixed action' }); + const mixedIcons = actionGroup.locator('ion-button').filter({ has: page.locator('ion-icon') }); + await expect(actionGroup).not.toHaveAttribute('data-native-ui-shell'); + await expect(mixedIcons).toHaveCount(2); + await expect(mixedIcons.nth(0)).toHaveAttribute('data-native-ui-shell', ''); + await expect(mixedIcons.nth(1)).toHaveAttribute('data-native-ui-shell', ''); + await expect(mixedCancel).not.toHaveAttribute('data-native-ui-shell'); + await expect(mixedCancel).toBeVisible(); + await expect.poll(() => mixedCancel.evaluate((element) => getComputedStyle(element).visibility)).toBe('visible'); + await expect + .poll(() => + page.evaluate(() => { + const controls = (window as any).__nativeUIShell.updates.at(-1)?.controls ?? []; + const demoActions = (control: any) => + control.items.filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).length; + return { + groups: controls.filter((control: any) => control.kind === 'ion-buttons' && demoActions(control) === 2).length, + individuals: controls.filter((control: any) => control.kind === 'ion-button' && demoActions(control) > 0).length, + }; + }), + ) + .toEqual({ groups: 1, individuals: 0 }); const nativeSaveDisabled = () => page.evaluate( () => @@ -441,11 +470,19 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page .filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)) .map((item: any) => item.disabled), ); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); + await activate(page, 'menu'); + await expect(menu).toHaveClass(/show-menu/); + await expect.poll(nativeActionsDisabled).toEqual([true, true]); + await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); + await expect.poll(nativeActionsDisabled).toEqual([false, false]); + await expect.poll(() => mixedCancel.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); + await mixedCancel.evaluate((element) => element.remove()); + await expect(actionGroup).toHaveAttribute('data-native-ui-shell', ''); await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); await expect.poll(nativeSaveDisabled).toBe(true); await expect.poll(nativeActionsDisabled).toEqual([true, true]); - await activate(page, 'menu'); await expect(menu).toHaveClass(/show-menu/); await expect .poll(() => menu.evaluate((element) => element.shadowRoot?.querySelector('[part~="container"]')?.getBoundingClientRect().left)) @@ -918,7 +955,7 @@ test('theme-disabled ion-buttons project eligible buttons independently', async await expect .poll(() => page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + const controls = (window as any).__nativeUIShell.updates.at(-1)?.controls ?? []; const isDemoAction = (control: any) => control.items.some((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)); return { buttons: controls.filter((control: any) => control.kind === 'ion-button' && isDemoAction(control)).length, diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 89228bd9..f4cecca7 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -222,6 +222,27 @@ private struct ShellFoldableLegacyToolbar: ViewModifier { @available(iOS 26.0, *) final class ShellFoldableRailController: ShellFoldableRailControlling { + private final class TransparentHostingController: UIHostingController { + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + makeFullSizeSurfacesTransparent(in: view) + } + + private func makeFullSizeSurfacesTransparent(in surface: UIView) { + guard !(surface is UIVisualEffectView) else { return } + let frame = surface.convert(surface.bounds, to: view) + let background = surface.backgroundColor?.resolvedColor(with: surface.traitCollection) + let systemBackground = UIColor.systemBackground.resolvedColor(with: surface.traitCollection) + // SwiftUI's hosting containers add opaque system backgrounds behind their bars. Remove only those + // host-sized base surfaces; preserve smaller controls, materials, and application-defined backgrounds. + if frame.insetBy(dx: -1, dy: -1).contains(view.bounds), background == systemBackground { + surface.backgroundColor = .clear + surface.isOpaque = false + } + surface.subviews.forEach(makeFullSizeSurfacesTransparent) + } + } + private final class RailContainer: UIView { private let railMask = CAShapeLayer() @@ -246,7 +267,7 @@ final class ShellFoldableRailController: ShellFoldableRailControlling { } private let model = ShellFoldableRailModel() - private lazy var controller = UIHostingController(rootView: ShellFoldableRailView(model: model)) + private lazy var controller = TransparentHostingController(rootView: ShellFoldableRailView(model: model)) private let container = RailContainer() private weak var owner: UIViewController? diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index d7b79cb6..1912eea0 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -1,6 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar, isFoldableToolbarAction, isFoldableToolbarGroup } from '../shared/dom'; +import { foldableToolbarActions, inFixedToolbar, isFoldableToolbarGroup } from '../shared/dom'; import * as menuButton from './ion-menu-button'; export const tag = 'ion-buttons'; @@ -8,11 +8,12 @@ export const tracksMotion = true; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { if (!inFixedToolbar(element)) return; - const children = Array.from(element.children) as HTMLElement[]; + let children = Array.from(element.children) as HTMLElement[]; if (children.length === 1) return menuButton.read(element, id); const foldable = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); if (foldable && !isFoldableToolbarGroup(element)) return; + if (foldable) children = foldableToolbarActions(element); if ( !children.length || children.some( @@ -24,6 +25,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined ) return; const candidate = createCandidate(element, tag, id); + if (foldable && children.length !== element.children.length) candidate.sources = children; for (const child of children) { const supported = child.matches(menuButton.tag) ? menuButton.append(candidate, child as HTMLIonMenuButtonElement, id) diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index 5e4ab89e..abde4426 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -1,5 +1,13 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; -import { activateProjectedElement, isExcluded, isFoldableToolbarAction, isShellDisabled, marker, unprojected } from './shared/dom'; +import { + activateProjectedElement, + foldableToolbarActions, + isExcluded, + isFoldableToolbarGroup, + isShellDisabled, + marker, + unprojected, +} from './shared/dom'; const backProjectionClass = 'ios-theme-foldable-back-button-projection'; const toolbarProjectionClass = 'ios-theme-foldable-toolbar-projection'; @@ -67,11 +75,6 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel }; const isEligibleBack = (element: HTMLIonBackButtonElement) => inEligibleToolbar(element) && unprojected(projectedSources(), () => isRendered(element)); - const isToolbarAction = (element: HTMLElement) => { - return isFoldableToolbarAction(element); - }; - const toolbarActions = (element: HTMLIonButtonsElement) => - Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isToolbarAction(child)); const pageOrder = (element: Element) => Array.from(doc.querySelectorAll('.ion-page')).indexOf(element.closest('.ion-page')!); const findBack = () => { const candidates = Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).filter( @@ -86,10 +89,9 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const findToolbarGroups = () => { const candidates = Array.from(doc.querySelectorAll(`ion-buttons.ios:not(.${toolbarProjectionClass})`)).flatMap( (group): ToolbarSource[] => { - const actions = toolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action))); + const actions = foldableToolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action))); if (!actions.length) return []; - if (group.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled')) - return actions.filter(inEligibleToolbar).map((action) => ({ group, actions: [action] })); + if (!isFoldableToolbarGroup(group)) return actions.filter(inEligibleToolbar).map((action) => ({ group, actions: [action] })); return inEligibleToolbar(group) && unprojected(projectedSources(), () => isRendered(group)) ? [{ group, actions }] : []; }, ); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 2e5c2ed5..aada7cb5 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -20,6 +20,7 @@ import { createCrossfade, fadeMarker } from './shared/crossfade'; const overlays = 'ion-modal, ion-popover, ion-alert, ion-action-sheet, ion-loading, ion-picker, ion-toast, ion-menu'; const overlayNames = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast']; const foldableRailMarker = 'data-native-ui-shell-foldable-rail'; +const foldableRailMemberMarker = 'data-native-ui-shell-foldable-rail-member'; // A failed bridge must not leave the source inaccessible indefinitely. const bounded = (promise: Promise): Promise => @@ -40,6 +41,8 @@ export const createRuntime = async ( const ids = new WeakMap(); let rejected = new WeakMap(); const sources = new Map(); + const foldableRailOwners = new Set(); + const foldableRailMembers = new Set(); const suspended = new Set(); const pages = new Set(); const presented = new Set(); @@ -103,13 +106,15 @@ export const createRuntime = async ( pointer-events rule supplied by the application itself. */ :where(.menu-content-open) :where([${foldableRailMarker}]) { pointer-events: auto; + } + :where(.menu-content-open) :where([${foldableRailMarker}]) > :where(:not([${foldableRailMemberMarker}])) { + pointer-events: none; }`; const restore = (element: HTMLElement) => { lastSnapshot = ''; search.release(element); element.removeAttribute(marker); - element.removeAttribute(foldableRailMarker); if (!stopped) crossfade.play(element, false, handoffInstant); if (element.getAttribute('aria-hidden') === 'true') { const previous = sources.get(element); @@ -119,7 +124,13 @@ export const createRuntime = async ( sources.delete(element); element.dispatchEvent(new CustomEvent('nativeUIShellChange')); }; - const restoreAll = () => Array.from(sources.keys()).forEach(restore); + const restoreAll = () => { + Array.from(sources.keys()).forEach(restore); + foldableRailOwners.forEach((element) => element.removeAttribute(foldableRailMarker)); + foldableRailOwners.clear(); + foldableRailMembers.forEach((element) => element.removeAttribute(foldableRailMemberMarker)); + foldableRailMembers.clear(); + }; const finishWaiters = () => { const current = waiters; waiters = []; @@ -294,8 +305,13 @@ export const createRuntime = async ( const current = dirty ? new Map(currentCandidates.map((candidate) => [candidate.element, signature(candidate)])) : signatures; const currentSources = new Set(currentCandidates.flatMap(candidateSources)); const accepted = candidates.filter((candidate) => current.get(candidate.element) === signatures.get(candidate.element)); - const acceptedFoldableRailSources = new Set( - accepted.filter((candidate) => candidate.control.placement === 'foldable-rail').flatMap(candidateSources), + const acceptedFoldableRailOwners = new Set( + accepted.filter((candidate) => candidate.control.placement === 'foldable-rail').map((candidate) => candidate.element), + ); + const acceptedFoldableRailMembers = new Set( + accepted + .filter((candidate) => candidate.control.placement === 'foldable-rail') + .flatMap((candidate) => Array.from(candidate.actions.values())), ); const invalidated = candidates.length !== accepted.length; acceptedRevision = result.revision; @@ -306,8 +322,25 @@ export const createRuntime = async ( // Keep an existing cover while its content catches up. Only an ineligible // source needs to return to Web; new sources still require an exact ack. for (const element of sources.keys()) if (!currentSources.has(element)) restore(element); + for (const element of foldableRailOwners) { + if (acceptedFoldableRailOwners.has(element)) continue; + element.removeAttribute(foldableRailMarker); + foldableRailOwners.delete(element); + } + for (const element of acceptedFoldableRailOwners) { + element.setAttribute(foldableRailMarker, ''); + foldableRailOwners.add(element); + } + for (const element of foldableRailMembers) { + if (acceptedFoldableRailMembers.has(element)) continue; + element.removeAttribute(foldableRailMemberMarker); + foldableRailMembers.delete(element); + } + for (const element of acceptedFoldableRailMembers) { + element.setAttribute(foldableRailMemberMarker, ''); + foldableRailMembers.add(element); + } for (const element of accepted.flatMap(candidateSources)) { - element.toggleAttribute(foldableRailMarker, acceptedFoldableRailSources.has(element)); if (!sources.has(element)) { sources.set(element, element.getAttribute('aria-hidden')); crossfade.play(element, true, handoffInstant); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index a1149ade..ddf6e990 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -38,14 +38,15 @@ export const isFoldableToolbarAction = (element: HTMLElement): boolean => !isShellDisabled(element) && (element.matches('ion-menu-button.ios') || (element.matches('ion-button.ios') && !!element.querySelector('ion-icon, svg'))); +export const foldableToolbarActions = (element: HTMLElement): HTMLElement[] => + Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isFoldableToolbarAction(child)); + export const isFoldableToolbarGroup = (element: HTMLElement): boolean => { - const children = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement); return ( element.matches('ion-buttons.ios') && - children.length > 1 && + foldableToolbarActions(element).length > 1 && !element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') && - !isShellDisabled(element) && - children.every(isFoldableToolbarAction) + !isShellDisabled(element) ); }; From 5541ba18692a0fbbdc16e8371ad8bd5531721179 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 09:23:37 +0900 Subject: [PATCH 14/35] fix(native): prehide foldable toolbar sources --- demo/e2e/native-ui-shell.spec.ts | 23 ++++++++++++++ src/native/foldable-web.ts | 7 ++++- src/native/index.ts | 13 +++++--- src/native/prehide.ts | 44 +++++++++++++++++++++++++++ src/native/shared/dom.ts | 19 ++++++++++-- src/styles/components/ion-button.scss | 5 +++ 6 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 src/native/prehide.ts diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 7bd90f53..3841e2f9 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -395,6 +395,29 @@ test('foldable back navigation requests native rail placement', async ({ page }) await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('foldable toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const source = page.locator('app-native-ui-shell ion-button[type=submit]'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(source).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + + const prehidden = await source.evaluate((element) => { + element.removeAttribute('data-native-ui-shell'); + const style = getComputedStyle(element); + return { position: style.position, visibility: style.visibility }; + }); + expect(prehidden).toEqual({ position: 'absolute', visibility: 'hidden' }); + + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); + await expect(source).toHaveCSS('visibility', 'visible'); + + await page.evaluate(() => (window as any).nativeUIShell.destroy()); + await expect(source).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(source).toHaveCSS('visibility', 'visible'); +}); + test('foldable rail remains native while its Ionic menu is open', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page); diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index abde4426..f54ecb39 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -6,6 +6,7 @@ import { isFoldableToolbarGroup, isShellDisabled, marker, + prehiddenClass, unprojected, } from './shared/dom'; @@ -55,7 +56,10 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const isRendered = (element: HTMLElement) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); - return element.isConnected && style.display !== 'none' && style.visibility === 'visible' && rect.width > 0 && rect.height > 0; + const prehidden = !!element.closest(`.${prehiddenClass}`); + return ( + element.isConnected && style.display !== 'none' && (prehidden || style.visibility === 'visible') && rect.width > 0 && rect.height > 0 + ); }; const inEligibleToolbar = (element: HTMLElement) => { const currentRoot = foldableRoot(); @@ -131,6 +135,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel if (!['class', marker].includes(attribute.name) && !original.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); for (const attribute of Array.from(original.attributes)) if (!['id', 'slot', marker, 'aria-hidden'].includes(attribute.name)) target.setAttribute(attribute.name, attribute.value); + target.classList.remove(prehiddenClass); }; const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement) => { copyAttributes(target, original); diff --git a/src/native/index.ts b/src/native/index.ts index e0ac9e4f..fd7d8725 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -4,6 +4,7 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, We import { bindMetricsLifecycle } from './lifecycle'; import { createRuntime } from './runtime'; import { createFoldableWebProjection } from './foldable-web'; +import { prehideFoldableToolbarSources } from './prehide'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -63,15 +64,17 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise : Promise.resolve(web('Disabled')); } if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); + const stopPrehide = + !active && (options.controls === undefined || options.controls.toolbar === true) ? prehideFoldableToolbarSources(document) : undefined; return (active ??= (async () => { if (Capacitor.getPlatform() !== 'ios') - return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires Capacitor iOS')); + return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires Capacitor iOS'), stopPrehide); let runtime: NativeUIShellHandle | undefined; try { await configureNativeTransition().catch(() => undefined); const capabilities = await plugin.configure(); if (!capabilities.supported) { - return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires iOS 26 or later')); + return resetOnDestroy(withReason(createFoldableWebProjection(document, options), 'Requires iOS 26 or later'), stopPrehide); } runtime = await createRuntime(document, plugin, options, capabilities.foldableRail === true); if (capabilities.foldableRail !== true) runtime = combine(runtime, createFoldableWebProjection(document, options)); @@ -80,21 +83,23 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), () => (active = undefined), ); - return resetOnDestroy(runtime); + return resetOnDestroy(runtime, stopPrehide); } catch (error) { await runtime?.destroy(); return resetOnDestroy( withReason(createFoldableWebProjection(document, options), error instanceof Error ? error.message : String(error)), + stopPrehide, ); } })()); }; -const resetOnDestroy = (handle: NativeUIShellHandle): NativeUIShellHandle => ({ +const resetOnDestroy = (handle: NativeUIShellHandle, stopPrehide?: () => void): NativeUIShellHandle => ({ getStatus: handle.getStatus, suspend: handle.suspend, async destroy() { await handle.destroy(); + stopPrehide?.(); active = undefined; }, }); diff --git a/src/native/prehide.ts b/src/native/prehide.ts new file mode 100644 index 00000000..06f0d022 --- /dev/null +++ b/src/native/prehide.ts @@ -0,0 +1,44 @@ +import { foldableToolbarActions, inFixedToolbar, isExcluded, isFoldableToolbarAction, isShellDisabled, prehiddenClass } from './shared/dom'; +const overlays = 'ion-menu, ion-modal, ion-popover'; + +const eligibleContainer = (element: HTMLElement): boolean => + element.matches('.ios') && inFixedToolbar(element) && !isExcluded(element) && !isShellDisabled(element) && !element.closest(overlays); + +export const prehideFoldableToolbarSources = (doc: Document): (() => void) => { + const prehidden = new Set(); + const scan = () => { + const next = new Set(); + const root = doc.querySelector(':is(ion-app, body).ios-theme-enable-foldable'); + if (root) { + root.querySelectorAll('ion-back-button').forEach((back) => { + if (eligibleContainer(back)) next.add(back); + }); + root.querySelectorAll('ion-buttons').forEach((group) => { + if (eligibleContainer(group)) { + const actions = foldableToolbarActions(group); + if (actions.length > 0 && actions.length === group.children.length) next.add(group); + else actions.filter(eligibleContainer).forEach((action) => next.add(action)); + } + }); + root.querySelectorAll('ion-button').forEach((button) => { + if (!button.parentElement?.matches('ion-buttons') && isFoldableToolbarAction(button) && eligibleContainer(button)) next.add(button); + }); + } + prehidden.forEach((element) => { + if (!next.has(element)) element.classList.remove(prehiddenClass); + }); + next.forEach((element) => { + if (!element.classList.contains(prehiddenClass)) element.classList.add(prehiddenClass); + }); + prehidden.clear(); + next.forEach((element) => prehidden.add(element)); + }; + scan(); + const observer = new (doc.defaultView?.MutationObserver ?? MutationObserver)(scan); + observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true }); + return () => { + observer.disconnect(); + prehidden.forEach((element) => element.classList.remove(prehiddenClass)); + prehidden.clear(); + }; +}; diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index ddf6e990..edfbe840 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -3,6 +3,7 @@ import { fadeMarker } from './crossfade'; import type { Frame } from '../definitions'; export const marker = 'data-native-ui-shell'; +export const prehiddenClass = 'ios-theme-native-ui-shell-prehidden'; export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; export const excluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-page-hidden, .ion-page-invisible, .ion-cloned-element, [hidden], [inert]'; @@ -36,7 +37,11 @@ export const isShellDisabled = (element: Element): boolean => export const isFoldableToolbarAction = (element: HTMLElement): boolean => !isExcluded(element) && !isShellDisabled(element) && - (element.matches('ion-menu-button.ios') || (element.matches('ion-button.ios') && !!element.querySelector('ion-icon, svg'))); + (element.matches('ion-menu-button.ios') || + (element.matches('ion-button.ios') && + !!element.querySelector('ion-icon, svg') && + !element.matches('.ion-color, [color]') && + ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default'))); export const foldableToolbarActions = (element: HTMLElement): HTMLElement[] => Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isFoldableToolbarAction(child)); @@ -67,11 +72,17 @@ export const unprojected = (elements: Iterable, read: () => T): } }; -export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => { +const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => { if (!element.isConnected || isExcluded(element) || isShellDisabled(element)) return false; + const prehidden = element.closest(`.${prehiddenClass}`); for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); - if (style.display === 'none' || style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker))) + const hiddenForProjection = !!prehidden?.contains(current); + if ( + style.display === 'none' || + (!hiddenForProjection && style.visibility !== 'visible') || + (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker)) + ) return false; // Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates. // Foldable rail controls are placed independently of their Web coordinates and must remain @@ -93,6 +104,8 @@ export const visible = (element: HTMLElement, allowOutsideViewport = false): boo ); }; +export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => readVisible(element, allowOutsideViewport); + export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({ x: rect.x - (origin?.x ?? 0), y: rect.y - (origin?.y ?? 0), diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss index bb57c26b..0a3b3008 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -437,6 +437,11 @@ ion-back-button.ios:not(.ios-theme-disabled, .ios26-disabled) { @include theme-button($is-back-button: true); } +.ios-theme-native-ui-shell-prehidden { + position: absolute !important; + visibility: hidden !important; +} + :is(ion-app, body).ios-theme-enable-foldable.ios-theme-foldable-toolbar-ready { ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) ion-back-button.ios[data-native-ui-shell]:not(.ios-theme-disabled, .ios26-disabled, .ios-theme-foldable-back-button-projection) { From 62b3b18b62abee4b2bc0789f9ac7f21bdfed1be7 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 14:27:00 +0900 Subject: [PATCH 15/35] fix(foldable): hand off toolbar controls without transition flash --- demo/e2e/foldable-back-button.spec.ts | 16 ++ demo/e2e/native-ui-shell.spec.ts | 88 +++++++ demo/package-lock.json | 4 +- demo/src/app/docs/docs-content.generated.ts | 2 +- docs/special-markup.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/native/foldable-web.ts | 5 +- src/native/index.ts | 15 +- src/native/prehide.ts | 260 +++++++++++++++++--- src/native/runtime.ts | 8 +- src/native/shared/dom.ts | 49 ++-- src/styles/components/ion-button.scss | 20 ++ src/transition/ios.transition.ts | 1 + 14 files changed, 412 insertions(+), 64 deletions(-) diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts index 40020f82..9e06ba68 100644 --- a/demo/e2e/foldable-back-button.spec.ts +++ b/demo/e2e/foldable-back-button.spec.ts @@ -60,6 +60,22 @@ test('Native UI Shell suspension synchronously restores and resumes foldable own await expect(projection).toBeVisible(); }); +test('a stale suspension lease cannot re-hide Web controls after shell teardown', async ({ page }) => { + await page.goto('/main/index/button'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const source = page.locator('app-button ion-header ion-back-button').first(); + await expect(page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection')).toBeVisible(); + + await page.evaluate(async () => { + const shell = (window as any).nativeUIShell; + const lease = await shell.suspend(); + await shell.destroy(); + await lease.resume(); + }); + await expect(source).toBeVisible(); + await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); +}); + test('foldable projection respects shell opt-out and iOS mode', async ({ page }) => { await page.goto('/main/index/button'); const app = page.locator('ion-app'); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 3841e2f9..fab7b739 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -16,6 +16,7 @@ const mockNative = async (page: Page, fail = false, foldableRail = true) => { hang: false, rejectInactiveSearch: false, rejectAllSearch: false, + rejectControlLabel: '', activate: (_event: any) => {}, search: (_event: any) => {}, metrics: (_event: any) => {}, @@ -52,6 +53,11 @@ const mockNative = async (page: Page, fail = false, foldableRail = true) => { ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) .map((control: any) => control.id) : [], + rejectedControls: state.rejectControlLabel + ? options.controls + ?.filter((control: any) => control.items.some((item: any) => item.accessibilityLabel === state.rejectControlLabel)) + .map((control: any) => control.id) + : [], }; }, nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { @@ -410,6 +416,50 @@ test('foldable toolbar sources are hidden before ownership and restored with the }); expect(prehidden).toEqual({ position: 'absolute', visibility: 'hidden' }); + const earlyBack = await page.locator('app-native-ui-shell ion-back-button').evaluate((element) => { + element.removeAttribute('data-native-ui-shell'); + element.classList.remove('ios-theme-native-ui-shell-prehidden'); + const style = getComputedStyle(element); + return { position: style.position, visibility: style.visibility }; + }); + expect(earlyBack).toEqual({ position: 'absolute', visibility: 'hidden' }); + + const customBack = page.locator('app-native-ui-shell ion-back-button'); + await customBack.evaluate((element: HTMLIonBackButtonElement) => (element.color = 'primary')); + await expect(customBack).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(customBack).toHaveCSS('visibility', 'visible'); + const webOnlyBacks = await page.locator('app-native-ui-shell').evaluate((host) => { + const content = host.querySelector('ion-content')!; + const nested = document.createElement('ion-header'); + nested.innerHTML = ''; + content.append(nested); + const condensed = document.createElement('ion-header'); + condensed.setAttribute('collapse', 'condense'); + condensed.innerHTML = ''; + host.append(condensed); + return [nested.querySelector('ion-back-button')!, condensed.querySelector('ion-back-button')!].map( + (back) => getComputedStyle(back).visibility, + ); + }); + expect(webOnlyBacks).toEqual(['visible', 'visible']); + + const lateBackInitially = await page.locator('ion-app').evaluate((root) => { + const header = document.createElement('ion-header'); + header.innerHTML = ''; + root.append(header); + const back = header.querySelector('ion-back-button')!; + back.setAttribute('data-late-back', ''); + return back.classList.contains('ios-theme-foldable-back-web-owned'); + }); + expect(lateBackInitially).toBe(false); + const lateBack = page.locator('ion-back-button[data-late-back]'); + await expect(lateBack).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(lateBack).toHaveAttribute('data-native-ui-shell', ''); + await page.waitForTimeout(1600); // Past the unhydrated readiness timeout. + await expect(lateBack).toHaveAttribute('data-native-ui-shell', ''); + await expect(lateBack).not.toHaveClass(/ios-theme-foldable-back-web-owned/); + await lateBack.evaluate((element) => element.closest('ion-header')?.remove()); + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(source).toHaveCSS('visibility', 'visible'); @@ -418,6 +468,23 @@ test('foldable toolbar sources are hidden before ownership and restored with the await expect(source).toHaveCSS('visibility', 'visible'); }); +test('rejected foldable control returns to an operable Web source', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const save = page.locator('app-native-ui-shell ion-button[type=submit]'); + await expect(save).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + (window as any).__nativeUIShell.rejectControlLabel = 'Save'; + window.dispatchEvent(new Event('nativeUIShellRefresh')); + }); + await expect(save).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(save).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(save).toHaveCSS('visibility', 'visible'); + await save.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); +}); + test('foldable rail remains native while its Ionic menu is open', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page); @@ -521,6 +588,19 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page await expect(cancelSource).toBeVisible(); await activate(page, 'Save'); await expect(page.locator('[data-save-count]')).toHaveText('1'); + // Placement is selected when this page enters, not re-evaluated from later content. + await cancelSource.evaluate((element) => { + element.setAttribute('data-morphed-cancel', ''); + element.textContent = ''; + const icon = document.createElement('ion-icon'); + icon.setAttribute('slot', 'icon-only'); + icon.setAttribute('name', 'checkmark'); + element.append(icon); + }); + const morphedCancel = page.locator('app-native-ui-shell ion-button[data-morphed-cancel]'); + await expect(morphedCancel).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(morphedCancel).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(morphedCancel).toBeVisible(); }); test('foldable controls stay operable on Web when the native side rail is unavailable', async ({ page }) => { @@ -557,8 +637,16 @@ test('foldable controls stay operable on Web when the native side rail is unavai await saveProjection.click(); await expect(page.locator('[data-save-count]')).toHaveText('1'); + await page.evaluate(() => { + const outlet = document.querySelector('ion-tabs ion-router-outlet')!; + (window as any).__foldableBackCloneMoved = false; + new MutationObserver(() => { + if (outlet.querySelector(':scope > ion-back-button.ion-cloned-element')) (window as any).__foldableBackCloneMoved = true; + }).observe(outlet, { childList: true }); + }); await projection.click(); await expect(page).toHaveURL(/\/main\/index$/); + expect(await page.evaluate(() => (window as any).__foldableBackCloneMoved)).toBe(false); }); test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => { diff --git a/demo/package-lock.json b/demo/package-lock.json index 82ac6ad5..d386d48d 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -56,13 +56,13 @@ }, "..": { "name": "@rdlabo/ionic-theme-ios27", - "version": "1.0.1", + "version": "1.0.4", "bundleDependencies": [ "@rdlabo/ionic-theme-utils" ], "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#e82be4389d9209750229f5fee990046b5b1217da" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "devDependencies": { "@capacitor/core": "^8.5.2", diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 30179cf1..dab40a24 100644 --- a/demo/src/app/docs/docs-content.generated.ts +++ b/demo/src/app/docs/docs-content.generated.ts @@ -1,3 +1,3 @@ // Generated from docs/special-markup.md. Do not edit directly. export const docsContentHtml = - '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard fill="default" and fill="clear" actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; + '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

These classes do not reposition a separate ion-fab; leave room for it when choosing the bar's position.

\n

Foldable layouts

\n

To simulate the iPhone Duo layout on the web, add .ios-theme-enable-foldable to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

The class reserves 80px on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to 0px. Override --ios-theme-foldable-safe-area-left or --ios-theme-foldable-safe-area-right when simulating a different foldable layout.

\n

This keeps routers and component backgrounds full-viewport. ion-content moves its scroll foreground, ion-toolbar moves its container foreground, and ion-fab adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

\n

ion-menu, ion-modal, and ion-popover are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's side="start" and side="end" values remain logical.

\n

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.

\n

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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard fill="default" or fill="clear" to be eligible for the side rail. Text-only actions stay in the original Web toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout.

\n

On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no ion-tabs exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; diff --git a/docs/special-markup.md b/docs/special-markup.md index 9958126d..343ec4f6 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -65,7 +65,7 @@ The foldable values are web-layout simulation inputs. They are independent from 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. Without Native UI Shell, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView` on iPhone Duo. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar 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. -On supported iOS versions, initializing `enableNativeUIShell()` at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Standard `fill="default"` and `fill="clear"` actions, including text-only actions, are eligible; SwiftUI decides whether their compact representation belongs in the side rail or native toolbar. Menus, modals, and popovers retain their own toolbar layout. +On supported iOS versions, initializing `enableNativeUIShell()` at application startup hands eligible foldable tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. The Ionic controls remain the sources of labels, icons, selected/disabled state, form submission, routing, and click handlers while SwiftUI owns adaptive placement and interaction. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` to be eligible for the side rail. Text-only actions stay in the original Web toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web tab bar and fixed-toolbar clones remain the fallback. Those projections also work when no `ion-tabs` exists; text-only actions stay in the original Web toolbar. Disabling foldable mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-foldable-toolbar-top` when the simulated system controls use a different vertical layout. diff --git a/package-lock.json b/package-lock.json index 6eb32d17..3d353f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ ], "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "devDependencies": { "@capacitor/core": "^8.5.2", @@ -2259,7 +2259,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855", + "resolved": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2", "integrity": "sha512-W4MFC4reLS4cK23hqWrWEf88d1Gx3mRfkkd2eihzjaGm+jndS+SdBmGmcefuaUXzL0J6lDtBPMWEkn78IOI+LA==", "inBundle": true, "license": "MIT", diff --git a/package.json b/package.json index 4523ee81..abf394ee 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#30acb040153797407a5fe27f0db6c69a3410d855" + "@rdlabo/ionic-theme-utils": "git+https://github.com/rdlabo-dev/ionic-theme-utils.git#44b1535387c326ed69fc239d38dbdb9776b629c2" }, "bundleDependencies": [ "@rdlabo/ionic-theme-utils" diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index f54ecb39..40040d52 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -236,7 +236,10 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel topOffset += actions.length * toolbarControlSize + toolbarControlGap; } if (nextBack || toolbarProjections.length) root.classList.add(readyClass); - sourceObserver = new MutationObserver(schedule); + sourceObserver = new MutationObserver((records) => { + if (records.some((record) => record.type !== 'attributes' || ![marker, 'aria-hidden'].includes(record.attributeName ?? ''))) + schedule(); + }); if (nextBack?.shadowRoot) sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true }); for (const { group } of groups) sourceObserver.observe(group, { subtree: true, childList: true, attributes: true }); }; diff --git a/src/native/index.ts b/src/native/index.ts index fd7d8725..e030ee80 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -94,12 +94,21 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise })()); }; -const resetOnDestroy = (handle: NativeUIShellHandle, stopPrehide?: () => void): NativeUIShellHandle => ({ +const resetOnDestroy = (handle: NativeUIShellHandle, prehide?: ReturnType): NativeUIShellHandle => ({ getStatus: handle.getStatus, - suspend: handle.suspend, + async suspend() { + const lease = await handle.suspend(); + const resumePrehide = prehide?.suspend(); + return { + async resume() { + await lease.resume(); + resumePrehide?.(); + }, + }; + }, async destroy() { await handle.destroy(); - stopPrehide?.(); + prehide?.stop(); active = undefined; }, }); diff --git a/src/native/prehide.ts b/src/native/prehide.ts index 06f0d022..a49a535b 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -1,44 +1,234 @@ -import { foldableToolbarActions, inFixedToolbar, isExcluded, isFoldableToolbarAction, isShellDisabled, prehiddenClass } from './shared/dom'; -const overlays = 'ion-menu, ion-modal, ion-popover'; +import { LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER } from '@ionic/core'; +import { + clearFoldablePlacement, + foldableBackWebClass, + foldableRailOwned, + inFixedToolbar, + isFoldableToolbarActionShape, + isPermanentlyExcluded, + isShellDisabled, + prehiddenClass, + prehideRootClass, + rejectedClass, + setFoldablePlacement, +} from './shared/dom'; -const eligibleContainer = (element: HTMLElement): boolean => - element.matches('.ios') && inFixedToolbar(element) && !isExcluded(element) && !isShellDisabled(element) && !element.closest(overlays); +const overlays = 'ion-menu, ion-modal, ion-popover'; +const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button'; +const backSupported = (element: HTMLElement): boolean => { + const back = element as HTMLIonBackButtonElement; + return back.icon === undefined && back.color === undefined && !!back.shadowRoot; +}; +const eligible = (element: HTMLElement): boolean => + inFixedToolbar(element) && !isPermanentlyExcluded(element) && !isShellDisabled(element) && !element.closest(overlays); -export const prehideFoldableToolbarSources = (doc: Document): (() => void) => { - const prehidden = new Set(); - const scan = () => { - const next = new Set(); - const root = doc.querySelector(':is(ion-app, body).ios-theme-enable-foldable'); - if (root) { - root.querySelectorAll('ion-back-button').forEach((back) => { - if (eligibleContainer(back)) next.add(back); - }); - root.querySelectorAll('ion-buttons').forEach((group) => { - if (eligibleContainer(group)) { - const actions = foldableToolbarActions(group); - if (actions.length > 0 && actions.length === group.children.length) next.add(group); - else actions.filter(eligibleContainer).forEach((action) => next.add(action)); +/** Capture toolbar placement once per routed-page epoch, before Ionic paints its transition. */ +export const prehideFoldableToolbarSources = (doc: Document): { suspend: () => () => void; stop: () => void } => { + doc.documentElement.classList.add(prehideRootClass); + const scopes = new Map>(); + const pendingBacks = new Map }>(); + const listeners = new AbortController(); + const root = () => doc.querySelector(':is(ion-app, body).ios-theme-enable-foldable'); + let foldableWasEnabled = false; + let suspended = 0; + let stopped = false; + const routedPage = (element: HTMLElement) => element.closest('.ion-page:not(ion-app, body)'); + const release = (scope: HTMLElement) => { + for (const [element, pending] of pendingBacks) { + if (pending.scope !== scope) continue; + clearTimeout(pending.timer); + pendingBacks.delete(element); + } + const owned = scopes.get(scope); + if (!owned) return; + owned.forEach((element) => { + element.classList.remove(prehiddenClass); + element.classList.remove(foldableBackWebClass); + clearFoldablePlacement(element); + }); + scopes.delete(scope); + }; + const capture = (scope: HTMLElement) => { + if (!root()?.contains(scope) || scope.closest(overlays)) return; + const owned = scopes.get(scope) ?? new Set(); + const place = (element: HTMLElement, rail: boolean) => { + if (owned.has(element)) return; + setFoldablePlacement(element, rail); + if (element.matches('ion-back-button')) element.classList.toggle(foldableBackWebClass, !rail); + owned.add(element); + }; + scope.querySelectorAll(sourceSelector).forEach((element) => { + if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return; + if (element.matches('ion-buttons')) { + const children = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement); + const actions = children.filter((child) => eligible(child) && isFoldableToolbarActionShape(child)); + const group = + eligible(element) && + actions.length === children.length && + (actions.length > 1 || (actions.length === 1 && actions[0].matches('ion-menu-button'))); + const freshGroup = !owned.has(element); + place(element, group); + children.forEach((child) => { + if (!child.matches('ion-back-button')) place(child, freshGroup && actions.includes(child)); + }); + } else if (element.matches('ion-back-button') || !element.parentElement?.matches('ion-buttons')) { + if (owned.has(element)) return; + if ( + element.matches('ion-back-button') && + eligible(element) && + (element as HTMLIonBackButtonElement).icon === undefined && + (element as HTMLIonBackButtonElement).color === undefined && + !element.shadowRoot && + !element.classList.contains('hydrated') + ) { + if (!pendingBacks.has(element)) { + const timer = setTimeout(() => { + if (pendingBacks.get(element)?.scope !== scope) return; + pendingBacks.delete(element); + // A custom element that never hydrates must remain usable on Web. + place(element, false); + reconcile(); + }, 1500); + pendingBacks.set(element, { scope, timer }); + } + return; + } + const pending = pendingBacks.get(element); + if (pending) { + clearTimeout(pending.timer); + pendingBacks.delete(element); } - }); - root.querySelectorAll('ion-button').forEach((button) => { - if (!button.parentElement?.matches('ion-buttons') && isFoldableToolbarAction(button) && eligibleContainer(button)) next.add(button); - }); + place( + element, + eligible(element) && (element.matches('ion-back-button') ? backSupported(element) : isFoldableToolbarActionShape(element)), + ); + } + }); + scopes.set(scope, owned); + }; + const reconcile = () => { + const foldable = root(); + if (!foldable) { + Array.from(scopes.keys()).forEach(release); + foldableWasEnabled = false; + return; } - prehidden.forEach((element) => { - if (!next.has(element)) element.classList.remove(prehiddenClass); + for (const scope of scopes.keys()) if (!scope.isConnected) release(scope); + for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope); + if (!foldableWasEnabled) { + foldableWasEnabled = true; + foldable + .querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden, .ion-page-invisible)') + .forEach((page) => capture(page)); + } + // Ionic inserts the destination as invisible before WillEnter. Hide its + // sources in that same mutation microtask, before the first transition paint. + foldable.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden)').forEach((page) => { + if (!scopes.has(page)) capture(page); }); - next.forEach((element) => { - if (!element.classList.contains(prehiddenClass)) element.classList.add(prehiddenClass); + // Root toolbars can mount after startup; each new DOM identity is captured once. + foldable.querySelectorAll(sourceSelector).forEach((element) => { + if (routedPage(element) || element.closest(overlays)) return; + const scope = element.closest('ion-toolbar'); + if (scope) capture(scope); }); - prehidden.clear(); - next.forEach((element) => prehidden.add(element)); + for (const owned of scopes.values()) { + for (const element of owned) { + const groupOwnsChildren = + element.parentElement?.matches('ion-buttons') && + foldableRailOwned(element.parentElement) && + Array.from(element.parentElement.children).every((child) => child instanceof HTMLElement && foldableRailOwned(child)); + const hide = + !suspended && + foldableRailOwned(element) && + !groupOwnsChildren && + element.isConnected && + !element.closest(`.${rejectedClass}`) && + !isPermanentlyExcluded(element) && + !isShellDisabled(element) && + (!element.matches('ion-back-button') || backSupported(element) || !element.classList.contains('hydrated')) && + (!element.matches('ion-buttons') || + Array.from(element.children).every((child) => child instanceof HTMLElement && foldableRailOwned(child))); + element.classList.toggle(prehiddenClass, hide); + if (element.matches('ion-back-button')) element.classList.toggle(foldableBackWebClass, !hide); + } + } + }; + const onWillEnter = (event: Event) => { + const page = event.target; + if (page instanceof HTMLElement && page.matches('.ion-page')) { + release(page); // Cached pages begin a new placement epoch. + capture(page); + reconcile(); + } }; - scan(); - const observer = new (doc.defaultView?.MutationObserver ?? MutationObserver)(scan); - observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true }); - return () => { - observer.disconnect(); - prehidden.forEach((element) => element.classList.remove(prehiddenClass)); - prehidden.clear(); + const onDidLeave = (event: Event) => { + const page = event.target; + if (page instanceof HTMLElement) release(page); + }; + doc.addEventListener(LIFECYCLE_WILL_ENTER, onWillEnter, { capture: true, signal: listeners.signal }); + doc.addEventListener(LIFECYCLE_DID_LEAVE, onDidLeave, { capture: true, signal: listeners.signal }); + // The page already on screen when the shell starts has no WillEnter event. + root() + ?.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden, .ion-page-invisible)') + .forEach((page) => capture(page)); + reconcile(); + const mutationRelevant = (record: MutationRecord) => { + if (record.type === 'childList') + return [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some( + (node) => + node instanceof Element && + (node.matches(`${sourceSelector}, .ion-page, ion-toolbar`) || !!node.querySelector(`${sourceSelector}, .ion-page, ion-toolbar`)), + ); + if (record.attributeName !== 'class') return true; + const current = (record.target as Element).className; + const previous = record.oldValue ?? ''; + const withoutOwned = (value: string) => + value + .split(/\s+/) + .filter((name) => name && ![prehiddenClass, prehideRootClass, foldableBackWebClass].includes(name)) + .join(' '); + return typeof current !== 'string' || withoutOwned(previous) !== withoutOwned(current); + }; + let scheduled = false; + const observer = new (doc.defaultView?.MutationObserver ?? MutationObserver)((records) => { + if (scheduled || !records.some(mutationRelevant)) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + reconcile(); + }); + }); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ['class', 'hidden', 'inert', 'icon', 'color'], + }); + return { + suspend: () => { + if (stopped) return () => {}; + suspended++; + doc.documentElement.classList.remove(prehideRootClass); + reconcile(); + let resumed = false; + return () => { + if (resumed || stopped) return; + resumed = true; + if (--suspended === 0) { + doc.documentElement.classList.add(prehideRootClass); + reconcile(); + } + }; + }, + stop: () => { + if (stopped) return; + stopped = true; + observer.disconnect(); + listeners.abort(); + doc.documentElement.classList.remove(prehideRootClass); + Array.from(scopes.keys()).forEach(release); + }, }; }; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index aada7cb5..84a101dd 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -11,7 +11,7 @@ import type { NativeUIShellStatus, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; -import { activateProjectedElement, isFoldableRailSource, marker, unprojected } from './shared/dom'; +import { activateProjectedElement, isFoldableRailSource, marker, rejectedClass, unprojected } from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -143,6 +143,9 @@ export const createRuntime = async ( }; const search = createSearchSupport(doc, id, schedule); const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element]; + const setRejected = (element: HTMLElement, value: boolean) => { + element.classList.toggle(rejectedClass, value); + }; const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { const candidate = readCandidate(element, id); if (candidate && isFoldableRailCandidate(element)) { @@ -297,6 +300,7 @@ export const createRuntime = async ( for (const candidate of candidates) { if (result.rejectedControls?.includes(candidate.control.id)) { rejected.set(candidate.element, signatures.get(candidate.element)!); + setRejected(candidate.element, true); dirty = true; } } @@ -305,6 +309,7 @@ export const createRuntime = async ( const current = dirty ? new Map(currentCandidates.map((candidate) => [candidate.element, signature(candidate)])) : signatures; const currentSources = new Set(currentCandidates.flatMap(candidateSources)); const accepted = candidates.filter((candidate) => current.get(candidate.element) === signatures.get(candidate.element)); + accepted.forEach((candidate) => setRejected(candidate.element, false)); const acceptedFoldableRailOwners = new Set( accepted.filter((candidate) => candidate.control.placement === 'foldable-rail').map((candidate) => candidate.element), ); @@ -575,6 +580,7 @@ export const createRuntime = async ( /* Always restore the Web, even after bridge loss. */ } style.remove(); + doc.querySelectorAll(`.${rejectedClass}`).forEach((element) => setRejected(element, false)); icons.clear(); pages.clear(); presented.clear(); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index edfbe840..7d82ffb7 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -4,9 +4,12 @@ import type { Frame } from '../definitions'; export const marker = 'data-native-ui-shell'; export const prehiddenClass = 'ios-theme-native-ui-shell-prehidden'; +export const prehideRootClass = 'ios-theme-native-ui-shell-prehide'; +export const rejectedClass = 'ios-theme-native-ui-shell-rejected'; +export const foldableBackWebClass = 'ios-theme-foldable-back-web-owned'; export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; -export const excluded = - '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-page-hidden, .ion-page-invisible, .ion-cloned-element, [hidden], [inert]'; +const permanentlyExcluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]'; +export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`; const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)'; const shellDisabledSelector = '.ios-theme-shell-disabled'; @@ -17,31 +20,43 @@ const foldableRailTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons' export const isFoldableRailSource = (element: HTMLElement): boolean => foldableRailTags.has(element.localName) && - (!element.matches('ion-button') || - !element.parentElement?.matches('ion-buttons') || - element.parentElement.children.length === 1 || - isFoldableToolbarAction(element) || - isDisabledButtonGroupChild(element)) && + (element.matches('ion-tab-bar') || foldableRailOwned(element)) && !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); -export const isExcluded = (element: HTMLElement): boolean => { - const owner = element.closest(excluded); +const excludedBy = (element: HTMLElement, selector: string): boolean => { + const owner = element.closest(selector); return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element)); }; +export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded); +export const isExcluded = (element: HTMLElement): boolean => excludedBy(element, excluded); + // A shared native surface must not cover an opted-out descendant either. export const isShellDisabled = (element: Element): boolean => !!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector); +export const isFoldableToolbarActionShape = (element: HTMLElement): boolean => + element.matches('ion-menu-button') || + (element.matches('ion-button') && + !!element.querySelector('ion-icon, svg') && + !Array.from(element.childNodes).some((node) => node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim()) && + !element.matches('.ion-color, [color]') && + ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default')); + +// Placement belongs to the DOM identity for one routed-page epoch. Changes to +// content/disabled state affect rendering, never its chosen surface. +const foldablePlacement = new WeakMap(); +export const setFoldablePlacement = (element: HTMLElement, rail: boolean): void => { + foldablePlacement.set(element, rail); +}; +export const clearFoldablePlacement = (element: HTMLElement): void => { + foldablePlacement.delete(element); +}; +export const foldableRailOwned = (element: HTMLElement): boolean => foldablePlacement.get(element) === true; + export const isFoldableToolbarAction = (element: HTMLElement): boolean => - !isExcluded(element) && - !isShellDisabled(element) && - (element.matches('ion-menu-button.ios') || - (element.matches('ion-button.ios') && - !!element.querySelector('ion-icon, svg') && - !element.matches('.ion-color, [color]') && - ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default'))); + element.matches('.ios') && !isExcluded(element) && !isShellDisabled(element) && foldableRailOwned(element); export const foldableToolbarActions = (element: HTMLElement): HTMLElement[] => Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isFoldableToolbarAction(child)); @@ -49,7 +64,7 @@ export const foldableToolbarActions = (element: HTMLElement): HTMLElement[] => export const isFoldableToolbarGroup = (element: HTMLElement): boolean => { return ( element.matches('ion-buttons.ios') && - foldableToolbarActions(element).length > 1 && + foldableRailOwned(element) && !element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') && !isShellDisabled(element) ); diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss index 0a3b3008..e787423e 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -442,6 +442,26 @@ ion-back-button.ios:not(.ios-theme-disabled, .ios26-disabled) { visibility: hidden !important; } +// Ionic can paint a newly inserted routed page before its WillEnter snapshot. +// Until JS assigns ownership, keep only its foldable back source off the Web +// toolbar. Web-owned/opted-out buttons are released as soon as classified. +html.ios-theme-native-ui-shell-prehide + :is(ion-app, body).ios-theme-enable-foldable + :is(ion-header, ion-footer):not([collapse]):not(:where(ion-content *, ion-menu *, ion-modal *, ion-popover *)) + ion-toolbar + ion-back-button:not( + [icon], + [color], + .ios-theme-foldable-back-web-owned, + .ionic-theme-disabled, + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-foldable-back-button-projection + ):not(:where(.ios-theme-shell-disabled *, .ionic-theme-disabled *, .ios-theme-disabled *, .ios26-disabled *)) { + position: absolute !important; + visibility: hidden !important; +} + :is(ion-app, body).ios-theme-enable-foldable.ios-theme-foldable-toolbar-ready { ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) ion-back-button.ios[data-native-ui-shell]:not(.ios-theme-disabled, .ios26-disabled, .ios-theme-foldable-back-button-projection) { diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts index 44e9f7e9..cb5e655e 100644 --- a/src/transition/ios.transition.ts +++ b/src/transition/ios.transition.ts @@ -14,6 +14,7 @@ const transitionConfig = { offLeftPercent: 30, getIonPageElement, connectNativeUIShellTransition, + shouldAnimateFixedBackButton: (navEl: HTMLElement) => !navEl.closest(':is(ion-app, body).ios-theme-enable-foldable'), radius: 0, }; From bc31ca7bbcff2c118bac2cd4e38da962ce7a2c85 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 16:20:53 +0900 Subject: [PATCH 16/35] fix(foldable): keep departed toolbar sources retired through transitions --- demo/e2e/foldable-back-button.spec.ts | 47 +++++++++++++++++++++ demo/e2e/native-ui-shell.spec.ts | 23 ++++++++++ demo/src/native-ui-shell-transition.spec.ts | 40 +++++++++++++++--- src/native-integration/index.ts | 21 +++++---- src/native/foldable-web.ts | 17 +++++++- src/native/runtime.ts | 24 ++++++++--- 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts index 9e06ba68..04de39d7 100644 --- a/demo/e2e/foldable-back-button.spec.ts +++ b/demo/e2e/foldable-back-button.spec.ts @@ -76,6 +76,53 @@ test('a stale suspension lease cannot re-hide Web controls after shell teardown' await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); }); +test('a departed page cannot regain its Web toolbar projection before Ionic hides it', async ({ page }) => { + await page.goto('/main/index/button'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + await expect(projection).toBeVisible(); + const routedPage = page.locator('app-button.ion-page'); + + await routedPage.evaluate((element) => { + element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })); + element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })); + }); + await expect(projection).toHaveCount(0); + await page.waitForTimeout(150); + await expect(projection).toHaveCount(0); + + await routedPage.evaluate((element) => { + element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); + await expect(projection).toBeVisible(); + + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); + await expect(projection).toBeVisible(); +}); + +test('turning foldable on during a transition honors its success or cancellation', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); + const routedPage = page.locator('app-button.ion-page'); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); + await expect(projection).toBeVisible(); + + await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); + await expect(projection).toHaveCount(0); +}); + test('foldable projection respects shell opt-out and iOS mode', async ({ page }) => { await page.goto('/main/index/button'); const app = page.locator('ion-app'); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index fab7b739..f4bce197 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -401,6 +401,29 @@ test('foldable back navigation requests native rail placement', async ({ page }) await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('a departed page cannot regain native toolbar ownership before Ionic hides it', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); + const source = page.locator('app-native-ui-shell ion-back-button'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + const routedPage = page.locator('app-native-ui-shell.ion-page'); + + await routedPage.evaluate((element) => { + element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })); + element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })); + }); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await page.waitForTimeout(150); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + + await routedPage.evaluate((element) => { + element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); +}); + test('foldable toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); diff --git a/demo/src/native-ui-shell-transition.spec.ts b/demo/src/native-ui-shell-transition.spec.ts index 6dc9b608..61b14a81 100644 --- a/demo/src/native-ui-shell-transition.spec.ts +++ b/demo/src/native-ui-shell-transition.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { connectNativeUIShellTransition, + FOLDABLE_TRANSITION_CANCELED, getNativeSearchBindings, registerNativeSearch, setNativeUIShellIntegration, @@ -16,14 +17,18 @@ const fixture = () => { const pending = new Promise((resolve) => { ready = resolve; }); + let finish!: Parameters[0]; setNativeUIShellIntegration(doc, { suspend: async () => { await pending; - return () => calls.push('resume'); + return (canceled = false) => calls.push(`resume:${canceled}`); }, }); const animation = { - onFinish: () => animation, + onFinish: (callback: typeof finish) => { + finish = callback; + return animation; + }, play: async () => { calls.push('play'); }, @@ -45,7 +50,7 @@ const fixture = () => { }, } as unknown as Animation; connectNativeUIShellTransition(animation, page); - return { animation, calls, ready }; + return { animation, calls, ready, finish: (step: 0 | 1) => finish(step, animation) }; }; test('normal transition waits for native suspension', async () => { @@ -54,11 +59,11 @@ test('normal transition waits for native suspension', async () => { expect(calls).toEqual([]); ready(); await played; - expect(calls).toEqual(['play', 'resume']); + expect(calls).toEqual(['play', 'resume:false']); }); test('interactive cancellation queued during suspension keeps the last progress', async () => { - const { animation, calls, ready } = fixture(); + const { animation, calls, ready, finish } = fixture(); animation.progressStart(true); animation.progressStep(0.2); animation.progressStep(0.4); @@ -66,8 +71,10 @@ test('interactive cancellation queued during suspension keeps the last progress' expect(calls).toEqual([]); ready(); await expect.poll(() => calls).toEqual(['start', 'step:0.4', 'end:0']); + finish(0); + expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume:true']); animation.destroy(); - expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume', 'destroy']); + expect(calls).toEqual(['start', 'step:0.4', 'end:0', 'resume:true', 'destroy']); }); test('destroy during bridge preparation cannot start an abandoned transition', async () => { @@ -76,7 +83,7 @@ test('destroy during bridge preparation cannot start an abandoned transition', a animation.destroy(); ready(); await played; - expect(calls).toEqual(['destroy', 'resume']); + expect(calls).toEqual(['destroy', 'resume:true']); }); test('without native enablement the animation remains untouched', () => { @@ -86,6 +93,25 @@ test('without native enablement the animation remains untouched', () => { expect(animation.play).toBe(play); }); +test('a canceled Web transition releases the still-active leaving page', () => { + const doc = document.implementation.createHTMLDocument(); + const app = doc.createElement('ion-app'); + doc.body.append(app); + const entering = doc.createElement('main'); + const leaving = doc.createElement('main'); + app.append(entering, leaving); + let finish!: Parameters[0]; + const animation = { onFinish: (callback: typeof finish) => (finish = callback) } as unknown as Animation; + let cancellations = 0; + leaving.addEventListener(FOLDABLE_TRANSITION_CANCELED, () => cancellations++); + + connectNativeUIShellTransition(animation, entering, leaving); + finish(1, animation); + expect(cancellations).toBe(0); + finish(0, animation); + expect(cancellations).toBe(1); +}); + test('server rendering has no DOM side effects', async () => { await expect(configureNativeTransition()).resolves.toEqual({ radius: 0 }); const handle = await enableNativeUIShell(); diff --git a/src/native-integration/index.ts b/src/native-integration/index.ts index 84144b2f..5d47bb9e 100644 --- a/src/native-integration/index.ts +++ b/src/native-integration/index.ts @@ -2,7 +2,7 @@ import type { Animation } from '@ionic/core'; export interface NativeUIShellIntegration { - suspend(scopes: HTMLElement[]): Promise<() => void>; + suspend(scopes: HTMLElement[]): Promise<(canceled?: boolean) => void>; search?(binding: NativeSearchBinding, active: boolean, focus?: boolean): Promise; } @@ -32,6 +32,7 @@ export const requestNativeSearch = async (binding: NativeSearchBinding, active: (await runtimes.get(binding.tabBar.ownerDocument)?.search?.(binding, active, focus)) ?? false; const runtimes = new WeakMap(); +export const FOLDABLE_TRANSITION_CANCELED = 'iosThemeFoldableTransitionCanceled'; export const setNativeUIShellIntegration = (doc: Document, runtime?: NativeUIShellIntegration) => { if (runtime) runtimes.set(doc, runtime); @@ -40,11 +41,15 @@ export const setNativeUIShellIntegration = (doc: Document, runtime?: NativeUIShe export const isNativeUIShell = (element: HTMLElement) => element.hasAttribute('data-native-ui-shell'); -export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<() => void> => +export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<(canceled?: boolean) => void> => (await runtimes.get(scopes[0]?.ownerDocument)?.suspend(scopes)) ?? (() => {}); /** Ionic write hooks cannot await the bridge. Gate playback, including interactive playback. */ export const connectNativeUIShellTransition = (animation: Animation, entering: HTMLElement, leaving?: HTMLElement) => { + if (leaving) + animation.onFinish((step) => { + if (step === 0) leaving.dispatchEvent(new Event(FOLDABLE_TRANSITION_CANCELED, { bubbles: true })); + }); if (!runtimes.has(entering.ownerDocument)) return; const scopes = leaving ? [entering, leaving] : [entering]; const play = animation.play.bind(animation); @@ -52,7 +57,7 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H const progressStep = animation.progressStep.bind(animation); const progressEnd = animation.progressEnd.bind(animation); const destroy = animation.destroy.bind(animation); - let release: (() => void) | undefined; + let release: ((canceled?: boolean) => void) | undefined; let preparation: Promise | undefined; let disposed = false; let interactiveReady = false; @@ -60,15 +65,15 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H let end: Parameters | undefined; const prepare = () => (preparation ??= suspendNativeUIShell(scopes).then((resume) => { - if (disposed) resume(); + if (disposed) resume(true); else release = resume; })); - const finish = () => { - release?.(); + const finish = (canceled = false) => { + release?.(canceled); release = undefined; preparation = undefined; }; - animation.onFinish(finish); + animation.onFinish((step) => finish(step === 0)); animation.play = async (options) => { await prepare(); if (disposed) return; @@ -103,7 +108,7 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H }; animation.destroy = (...args) => { disposed = true; - finish(); + finish(true); return destroy(...args); }; }; diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index 40040d52..08dce87d 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -1,4 +1,5 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; +import { FOLDABLE_TRANSITION_CANCELED } from '../native-integration'; import { activateProjectedElement, foldableToolbarActions, @@ -45,6 +46,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel let frame = 0; let updates = 0; let observingFoldable = false; + const departedPages = new WeakSet(); let sourceObserver: MutationObserver | undefined; let waiters: (() => void)[] = []; const listeners = new AbortController(); @@ -65,6 +67,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const currentRoot = foldableRoot(); const toolbar = element.closest('ion-toolbar'); const edge = toolbar?.parentElement; + const page = element.closest('.ion-page'); return ( !!currentRoot?.contains(element) && element.matches('.ios') && @@ -74,6 +77,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel !edge.hasAttribute('collapse') && !isExcluded(element) && !isShellDisabled(element) && + (!page || !departedPages.has(page)) && !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden, .ion-page-invisible') ); }; @@ -307,7 +311,18 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel schedule(); }); observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] }); - for (const name of ['ionViewDidEnter', 'ionViewDidLeave', 'ionModalWillPresent', 'ionModalDidDismiss']) + const pageLifecycle = (event: Event) => { + const page = event.target; + if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; + if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') { + departedPages.add(page); + } else departedPages.delete(page); + if (foldableRoot()) schedule(); + }; + for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave']) + doc.addEventListener(name, pageLifecycle, { capture: true, signal: listeners.signal }); + doc.addEventListener(FOLDABLE_TRANSITION_CANCELED, pageLifecycle, { capture: true, signal: listeners.signal }); + for (const name of ['ionModalWillPresent', 'ionModalDidDismiss']) doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal }); if (options.controls === undefined || options.controls.toolbar === true) schedule(); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 84a101dd..cc1cc69b 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -45,6 +45,7 @@ export const createRuntime = async ( const foldableRailMembers = new Set(); const suspended = new Set(); const pages = new Set(); + const departedPages = new WeakSet(); const presented = new Set(); const manualSuspensions = new Set(); const moving = new Map>(); @@ -163,9 +164,15 @@ export const createRuntime = async ( await fail(error); } }; + const departed = (element: HTMLElement) => { + if (!element.closest(':is(ion-app, body).ios-theme-enable-foldable')) return false; + const page = element.closest('.ion-page'); + return !!page && departedPages.has(page); + }; const blocked = (element: HTMLElement) => Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || - Array.from(pages).some((page) => page.contains(element)) || + Array.from(pages).some((scope) => scope.contains(element)) || + departed(element) || Array.from(moving.keys()).some((surface) => surface.contains(element)); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); const overlayOpen = (includeMenu = true) => { @@ -195,7 +202,7 @@ export const createRuntime = async ( search .decorate( Array.from(doc.querySelectorAll(selector)) - .filter((element) => !blocked(element) || (menuOpen && isFoldableRailSource(element))) + .filter((element) => !departed(element) && (!blocked(element) || (menuOpen && isFoldableRailSource(element)))) .map(readEnabledCandidate) .filter((candidate): candidate is Candidate => !!candidate), blocked, @@ -401,6 +408,7 @@ export const createRuntime = async ( target.addEventListener(name, callback, { capture: true, signal: listeners.signal }); const pageWill: EventListener = (event) => { const page = event.target as HTMLElement; + if (event.type === LIFECYCLE_WILL_ENTER) departedPages.delete(page); getNativeSearchBindings(doc) .filter((binding) => page.contains(binding.footer)) .forEach((binding) => search.retire(binding)); @@ -411,7 +419,10 @@ export const createRuntime = async ( schedule(); }; const pageDid: EventListener = (event) => { - pages.delete(event.target as HTMLElement); + const page = event.target as HTMLElement; + if (event.type === LIFECYCLE_DID_LEAVE) departedPages.add(page); + else departedPages.delete(page); + pages.delete(page); schedule(); if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff(); }; @@ -662,9 +673,12 @@ export const createRuntime = async ( await flush(); // Source DOM has been restored before Ionic starts moving it. await new Promise((resolve) => win.requestAnimationFrame(() => resolve())); - return () => { + return (canceled = false) => { suspended.delete(scopes); - scopes.forEach((scope) => pages.delete(scope)); // interactive cancellation has no didLeave. + if (canceled || !scopes.some((scope) => scope.closest(':is(ion-app, body).ios-theme-enable-foldable'))) { + scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave. + if (canceled && scopes[1]) departedPages.delete(scopes[1]); // The leaving page stays active. + } schedule(); }; }, From 47fed29f6d9295cc319a35363074e5d2bedf21da Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 18:53:44 +0900 Subject: [PATCH 17/35] fix(foldable): project vertical controls at navigation start --- demo/e2e/foldable-back-button.spec.ts | 8 ++++-- demo/e2e/native-ui-shell.spec.ts | 25 +++++++++++++++-- demo/src/native-ui-shell-transition.spec.ts | 7 ++++- src/native-integration/index.ts | 2 +- src/native/foldable-web.ts | 21 ++++++++++++--- src/native/runtime.ts | 30 ++++++++++++++------- src/native/shared/candidate.ts | 9 ++++++- src/native/shared/dom.ts | 19 ++++++++++--- 8 files changed, 97 insertions(+), 24 deletions(-) diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts index 04de39d7..0ad9bef2 100644 --- a/demo/e2e/foldable-back-button.spec.ts +++ b/demo/e2e/foldable-back-button.spec.ts @@ -76,7 +76,7 @@ test('a stale suspension lease cannot re-hide Web controls after shell teardown' await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); }); -test('a departed page cannot regain its Web toolbar projection before Ionic hides it', async ({ page }) => { +test('Web toolbar projection follows foldable WillEnter and WillLeave', async ({ page }) => { await page.goto('/main/index/button'); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); @@ -92,10 +92,14 @@ test('a departed page cannot regain its Web toolbar projection before Ionic hide await expect(projection).toHaveCount(0); await routedPage.evaluate((element) => { + element.classList.add('ion-page-invisible'); element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); - element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); }); await expect(projection).toBeVisible(); + await routedPage.evaluate((element) => { + element.classList.remove('ion-page-invisible'); + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); await expect(projection).toHaveCount(0); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index f4bce197..e81b6416 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -401,7 +401,7 @@ test('foldable back navigation requests native rail placement', async ({ page }) await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); -test('a departed page cannot regain native toolbar ownership before Ionic hides it', async ({ page }) => { +test('native foldable actions follow WillEnter and stay enabled during navigation', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); @@ -418,10 +418,31 @@ test('a departed page cannot regain native toolbar ownership before Ionic hides await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); await routedPage.evaluate((element) => { + element.classList.add('ion-page-invisible'); + element.style.pointerEvents = 'none'; element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); - element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); }); await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate( + () => + (window as any).__nativeUIShell.updates + .findLast((update: any) => + update.controls.some((control: any) => control.items.some((item: any) => item.accessibilityLabel === 'Save')), + ) + .controls.flatMap((control: any) => control.items) + .find((item: any) => item.accessibilityLabel === 'Save').disabled, + ), + ) + .toBe(false); + await activate(page, 'Save'); + await expect(page.locator('app-native-ui-shell [data-save-count]')).toHaveText('1'); + await routedPage.evaluate((element) => { + element.classList.remove('ion-page-invisible'); + element.style.pointerEvents = ''; + element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); + }); }); test('foldable toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { diff --git a/demo/src/native-ui-shell-transition.spec.ts b/demo/src/native-ui-shell-transition.spec.ts index 61b14a81..60cfd220 100644 --- a/demo/src/native-ui-shell-transition.spec.ts +++ b/demo/src/native-ui-shell-transition.spec.ts @@ -103,13 +103,18 @@ test('a canceled Web transition releases the still-active leaving page', () => { let finish!: Parameters[0]; const animation = { onFinish: (callback: typeof finish) => (finish = callback) } as unknown as Animation; let cancellations = 0; - leaving.addEventListener(FOLDABLE_TRANSITION_CANCELED, () => cancellations++); + let canceledEntering: HTMLElement | undefined; + leaving.addEventListener(FOLDABLE_TRANSITION_CANCELED, (event) => { + cancellations++; + canceledEntering = (event as CustomEvent<{ entering: HTMLElement }>).detail.entering; + }); connectNativeUIShellTransition(animation, entering, leaving); finish(1, animation); expect(cancellations).toBe(0); finish(0, animation); expect(cancellations).toBe(1); + expect(canceledEntering).toBe(entering); }); test('server rendering has no DOM side effects', async () => { diff --git a/src/native-integration/index.ts b/src/native-integration/index.ts index 5d47bb9e..1355497c 100644 --- a/src/native-integration/index.ts +++ b/src/native-integration/index.ts @@ -48,7 +48,7 @@ export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<(canc export const connectNativeUIShellTransition = (animation: Animation, entering: HTMLElement, leaving?: HTMLElement) => { if (leaving) animation.onFinish((step) => { - if (step === 0) leaving.dispatchEvent(new Event(FOLDABLE_TRANSITION_CANCELED, { bubbles: true })); + if (step === 0) leaving.dispatchEvent(new CustomEvent(FOLDABLE_TRANSITION_CANCELED, { bubbles: true, detail: { entering } })); }); if (!runtimes.has(entering.ownerDocument)) return; const scopes = leaving ? [entering, leaving] : [entering]; diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index 08dce87d..cabf4140 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -2,12 +2,14 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } f import { FOLDABLE_TRANSITION_CANCELED } from '../native-integration'; import { activateProjectedElement, + foldableEnteringPage, foldableToolbarActions, isExcluded, isFoldableToolbarGroup, isShellDisabled, marker, prehiddenClass, + setFoldableEnteringPage, unprojected, } from './shared/dom'; @@ -75,10 +77,10 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && !edge.hasAttribute('collapse') && - !isExcluded(element) && + !isExcluded(element, foldableEnteringPage(element)) && !isShellDisabled(element) && (!page || !departedPages.has(page)) && - !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden, .ion-page-invisible') + !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden') ); }; const isEligibleBack = (element: HTMLIonBackButtonElement) => @@ -314,9 +316,20 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const pageLifecycle = (event: Event) => { const page = event.target; if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; - if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') { + if (event.type === FOLDABLE_TRANSITION_CANCELED) { + const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering; + if (entering) { + setFoldableEnteringPage(entering, false); + departedPages.add(entering); + } + departedPages.delete(page); + } else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') { + setFoldableEnteringPage(page, false); departedPages.add(page); - } else departedPages.delete(page); + } else { + departedPages.delete(page); + setFoldableEnteringPage(page, event.type === 'ionViewWillEnter'); + } if (foldableRoot()) schedule(); }; for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave']) diff --git a/src/native/runtime.ts b/src/native/runtime.ts index cc1cc69b..b528417e 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -11,7 +11,7 @@ import type { NativeUIShellStatus, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; -import { activateProjectedElement, isFoldableRailSource, marker, rejectedClass, unprojected } from './shared/dom'; +import { activateProjectedElement, isFoldableRailSource, marker, rejectedClass, setFoldableEnteringPage, unprojected } from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -116,7 +116,7 @@ export const createRuntime = async ( lastSnapshot = ''; search.release(element); element.removeAttribute(marker); - if (!stopped) crossfade.play(element, false, handoffInstant); + if (!stopped) crossfade.play(element, false, handoffInstant || isFoldableRailSource(element)); if (element.getAttribute('aria-hidden') === 'true') { const previous = sources.get(element); if (previous == null) element.removeAttribute('aria-hidden'); @@ -170,10 +170,11 @@ export const createRuntime = async ( return !!page && departedPages.has(page); }; const blocked = (element: HTMLElement) => - Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || - Array.from(pages).some((scope) => scope.contains(element)) || departed(element) || - Array.from(moving.keys()).some((surface) => surface.contains(element)); + (!isFoldableRailSource(element) && + (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || + Array.from(pages).some((scope) => scope.contains(element)) || + Array.from(moving.keys()).some((surface) => surface.contains(element)))); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); const overlayOpen = (includeMenu = true) => { for (const element of presented) if (!element.isConnected) presented.delete(element); @@ -278,7 +279,7 @@ export const createRuntime = async ( removed.forEach(restore); // The outgoing tab is no longer visible, so waiting two frames only leaves its // native snapshot over the destination. Stack transitions still need the paint. - if (!handoffInstant) { + if (!handoffInstant && removed.some((element) => !isFoldableRailSource(element))) { await painted(); if (stopped || dirty) return; } @@ -355,7 +356,7 @@ export const createRuntime = async ( for (const element of accepted.flatMap(candidateSources)) { if (!sources.has(element)) { sources.set(element, element.getAttribute('aria-hidden')); - crossfade.play(element, true, handoffInstant); + crossfade.play(element, true, handoffInstant || isFoldableRailSource(element)); element.setAttribute(marker, ''); element.setAttribute('aria-hidden', 'true'); element.dispatchEvent(new CustomEvent('nativeUIShellChange')); @@ -408,7 +409,13 @@ export const createRuntime = async ( target.addEventListener(name, callback, { capture: true, signal: listeners.signal }); const pageWill: EventListener = (event) => { const page = event.target as HTMLElement; - if (event.type === LIFECYCLE_WILL_ENTER) departedPages.delete(page); + if (event.type === LIFECYCLE_WILL_ENTER) { + departedPages.delete(page); + setFoldableEnteringPage(page, true); + } else { + departedPages.add(page); + setFoldableEnteringPage(page, false); + } getNativeSearchBindings(doc) .filter((binding) => page.contains(binding.footer)) .forEach((binding) => search.retire(binding)); @@ -422,6 +429,7 @@ export const createRuntime = async ( const page = event.target as HTMLElement; if (event.type === LIFECYCLE_DID_LEAVE) departedPages.add(page); else departedPages.delete(page); + setFoldableEnteringPage(page, false); pages.delete(page); schedule(); if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff(); @@ -677,7 +685,11 @@ export const createRuntime = async ( suspended.delete(scopes); if (canceled || !scopes.some((scope) => scope.closest(':is(ion-app, body).ios-theme-enable-foldable'))) { scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave. - if (canceled && scopes[1]) departedPages.delete(scopes[1]); // The leaving page stays active. + if (canceled) { + departedPages.add(scopes[0]); // The entering page is abandoned before Ionic hides it. + setFoldableEnteringPage(scopes[0], false); + if (scopes[1]) departedPages.delete(scopes[1]); // The leaving page stays active. + } } schedule(); }; diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index f5e9839a..0be3949c 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -54,6 +54,12 @@ export const appendItem = ( const labelStyle = getComputedStyle(labelElement); const badge = child.querySelector('ion-badge'); const badgeStyle = badge && visible(badge) ? getComputedStyle(badge) : undefined; + const foldable = isFoldableRailSource(candidate.element); + // Ignore inherited page-transition blocking, not a control's own pointer-events rule. + const pointerBlocked = (element: HTMLElement) => + element.style.pointerEvents === 'none' || + (getComputedStyle(element).pointerEvents === 'none' && + (!foldable || !element.parentElement || getComputedStyle(element.parentElement).pointerEvents !== 'none')); const item: ShellItem = { id: id(child), ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), @@ -62,7 +68,8 @@ export const appendItem = ( disabled: !!(child as ItemElement).disabled || !!(candidate.element as ItemElement).disabled || - getComputedStyle(child).pointerEvents === 'none', + pointerBlocked(child) || + (child !== candidate.element && pointerBlocked(candidate.element)), selected: !!(child as ItemElement).selected, fontSize: parseFloat(labelStyle.fontSize), fontWeight: parseInt(labelStyle.fontWeight, 10) || 400, diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 7d82ffb7..724205c7 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -10,6 +10,15 @@ export const foldableBackWebClass = 'ios-theme-foldable-back-web-owned'; export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; const permanentlyExcluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]'; export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`; +const enteringPages = new WeakSet(); +export const setFoldableEnteringPage = (page: HTMLElement, entering: boolean): void => { + if (entering) enteringPages.add(page); + else enteringPages.delete(page); +}; +export const foldableEnteringPage = (element: HTMLElement): HTMLElement | undefined => { + const page = element.closest('.ion-page-invisible'); + return page && enteringPages.has(page) && page.closest(':is(ion-app, body).ios-theme-enable-foldable') ? page : undefined; +}; const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)'; const shellDisabledSelector = '.ios-theme-shell-disabled'; @@ -30,7 +39,8 @@ const excludedBy = (element: HTMLElement, selector: string): boolean => { }; export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded); -export const isExcluded = (element: HTMLElement): boolean => excludedBy(element, excluded); +export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean => + excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage); // A shared native surface must not cover an opted-out descendant either. export const isShellDisabled = (element: Element): boolean => @@ -56,7 +66,7 @@ export const clearFoldablePlacement = (element: HTMLElement): void => { export const foldableRailOwned = (element: HTMLElement): boolean => foldablePlacement.get(element) === true; export const isFoldableToolbarAction = (element: HTMLElement): boolean => - element.matches('.ios') && !isExcluded(element) && !isShellDisabled(element) && foldableRailOwned(element); + element.matches('.ios') && foldableRailOwned(element) && !isExcluded(element, foldableEnteringPage(element)) && !isShellDisabled(element); export const foldableToolbarActions = (element: HTMLElement): HTMLElement[] => Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isFoldableToolbarAction(child)); @@ -88,7 +98,8 @@ export const unprojected = (elements: Iterable, read: () => T): }; const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => { - if (!element.isConnected || isExcluded(element) || isShellDisabled(element)) return false; + const enteringPage = isFoldableRailSource(element) ? foldableEnteringPage(element) : undefined; + if (!element.isConnected || isExcluded(element, enteringPage) || isShellDisabled(element)) return false; const prehidden = element.closest(`.${prehiddenClass}`); for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); @@ -96,7 +107,7 @@ const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boole if ( style.display === 'none' || (!hiddenForProjection && style.visibility !== 'visible') || - (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker)) + (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage) ) return false; // Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates. From ae752a529949ca759bbe1688b6a9e80c05ec8da7 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 19:12:57 +0900 Subject: [PATCH 18/35] fix(foldable): preserve author-disabled actions during entry --- demo/e2e/native-ui-shell.spec.ts | 32 +++++++++++++++++++------------- src/native/runtime.ts | 30 ++++++++++++++++++++++++++++-- src/native/shared/candidate.ts | 9 +-------- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index e81b6416..78ecc63f 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -423,19 +423,25 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true })); }); await expect(source).toHaveAttribute('data-native-ui-shell', ''); - await expect - .poll(() => - page.evaluate( - () => - (window as any).__nativeUIShell.updates - .findLast((update: any) => - update.controls.some((control: any) => control.items.some((item: any) => item.accessibilityLabel === 'Save')), - ) - .controls.flatMap((control: any) => control.items) - .find((item: any) => item.accessibilityLabel === 'Save').disabled, - ), - ) - .toBe(false); + const saveDisabled = () => + page.evaluate( + () => + (window as any).__nativeUIShell.updates + .findLast((update: any) => + update.controls.some((control: any) => control.items.some((item: any) => item.accessibilityLabel === 'Save')), + ) + .controls.flatMap((control: any) => control.items) + .find((item: any) => item.accessibilityLabel === 'Save').disabled, + ); + await expect.poll(saveDisabled).toBe(false); + await page.addStyleTag({ content: '.author-no-pointer { pointer-events: none }' }); + const save = page.locator('app-native-ui-shell ion-button[type=submit]'); + for (const target of [save, save.locator('xpath=..'), save.locator('xpath=../..')]) { + await target.evaluate((element) => element.classList.add('author-no-pointer')); + await expect.poll(saveDisabled).toBe(true); + await target.evaluate((element) => element.classList.remove('author-no-pointer')); + await expect.poll(saveDisabled).toBe(false); + } await activate(page, 'Save'); await expect(page.locator('app-native-ui-shell [data-save-count]')).toHaveText('1'); await routedPage.evaluate((element) => { diff --git a/src/native/runtime.ts b/src/native/runtime.ts index b528417e..c1673b24 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -11,7 +11,15 @@ import type { NativeUIShellStatus, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; -import { activateProjectedElement, isFoldableRailSource, marker, rejectedClass, setFoldableEnteringPage, unprojected } from './shared/dom'; +import { + activateProjectedElement, + foldableEnteringPage, + isFoldableRailSource, + marker, + rejectedClass, + setFoldableEnteringPage, + unprojected, +} from './shared/dom'; import { createIconRenderer } from './shared/icons'; import type { Candidate } from './shared/candidate'; import { CSS_MOTION_EVENTS } from './shared/events'; @@ -147,8 +155,25 @@ export const createRuntime = async ( const setRejected = (element: HTMLElement, value: boolean) => { element.classList.toggle(rejectedClass, value); }; + const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { - const candidate = readCandidate(element, id); + const enteringPage = isFoldableRailCandidate(element) ? foldableEnteringPage(element) : undefined; + let candidate: Candidate | undefined; + if (enteringPage && getComputedStyle(enteringPage).pointerEvents === 'none') { + const previous = enteringPage.style.getPropertyValue('pointer-events'); + const priority = enteringPage.style.getPropertyPriority('pointer-events'); + const hadStyle = enteringPage.hasAttribute('style'); + measuringPointerPages.add(enteringPage); + enteringPage.style.setProperty('pointer-events', 'auto', 'important'); + try { + candidate = readCandidate(element, id); + } finally { + if (previous) enteringPage.style.setProperty('pointer-events', previous, priority); + else enteringPage.style.removeProperty('pointer-events'); + if (!hadStyle && !enteringPage.style.length) enteringPage.removeAttribute('style'); + win.setTimeout(() => measuringPointerPages.delete(enteringPage), 0); + } + } else candidate = readCandidate(element, id); if (candidate && isFoldableRailCandidate(element)) { if (!nativeFoldableRail) return undefined; candidate.control.placement = 'foldable-rail'; @@ -395,6 +420,7 @@ export const createRuntime = async ( (record) => record.attributeName !== marker && record.attributeName !== fadeMarker && + !(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) && !( record.attributeName === 'aria-hidden' && sources.has(record.target as HTMLElement) && diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index 0be3949c..f5e9839a 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -54,12 +54,6 @@ export const appendItem = ( const labelStyle = getComputedStyle(labelElement); const badge = child.querySelector('ion-badge'); const badgeStyle = badge && visible(badge) ? getComputedStyle(badge) : undefined; - const foldable = isFoldableRailSource(candidate.element); - // Ignore inherited page-transition blocking, not a control's own pointer-events rule. - const pointerBlocked = (element: HTMLElement) => - element.style.pointerEvents === 'none' || - (getComputedStyle(element).pointerEvents === 'none' && - (!foldable || !element.parentElement || getComputedStyle(element.parentElement).pointerEvents !== 'none')); const item: ShellItem = { id: id(child), ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), @@ -68,8 +62,7 @@ export const appendItem = ( disabled: !!(child as ItemElement).disabled || !!(candidate.element as ItemElement).disabled || - pointerBlocked(child) || - (child !== candidate.element && pointerBlocked(candidate.element)), + getComputedStyle(child).pointerEvents === 'none', selected: !!(child as ItemElement).selected, fontSize: parseFloat(labelStyle.fontSize), fontWeight: parseInt(labelStyle.fontWeight, 10) || 400, From 76f77ff105712f2b3a3dd996255f820427155b7d Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 20:14:10 +0900 Subject: [PATCH 19/35] fix(foldable): preserve rail eligibility and author visibility --- demo/e2e/native-ui-shell.spec.ts | 35 +++++++++++++++++++++++--- src/native/foldable-web.ts | 43 +++++++++++++++++++++----------- src/native/prehide.ts | 18 ++----------- src/native/runtime.ts | 39 +++++++++++++++-------------- src/native/shared/dom.ts | 37 ++++++++++++++++++++++++--- 5 files changed, 116 insertions(+), 56 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 78ecc63f..e3a7f132 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -446,9 +446,13 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio await expect(page.locator('app-native-ui-shell [data-save-count]')).toHaveText('1'); await routedPage.evaluate((element) => { element.classList.remove('ion-page-invisible'); - element.style.pointerEvents = ''; element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true })); }); + await expect.poll(saveDisabled).toBe(false); + await save.evaluate((element) => element.classList.add('author-no-pointer')); + await expect.poll(saveDisabled).toBe(true); + await save.evaluate((element) => element.classList.remove('author-no-pointer')); + await routedPage.evaluate((element) => (element.style.pointerEvents = '')); }); test('foldable toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { @@ -510,6 +514,25 @@ test('foldable toolbar sources are hidden before ownership and restored with the await expect(lateBack).not.toHaveClass(/ios-theme-foldable-back-web-owned/); await lateBack.evaluate((element) => element.closest('ion-header')?.remove()); + const lateAction = page.locator('app-native-ui-shell ion-button[data-late-action]'); + await page + .locator('app-native-ui-shell ion-toolbar') + .first() + .evaluate((toolbar) => { + const button = document.createElement('ion-button'); + button.setAttribute('data-late-action', ''); + button.setAttribute('aria-label', 'Late action'); + button.innerHTML = ''; + toolbar.append(button); + }); + await expect(lateAction).toHaveAttribute('data-native-ui-shell', ''); + + await page.addStyleTag({ content: '.author-hidden { visibility: hidden !important }' }); + await source.evaluate((element) => element.classList.add('author-hidden')); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await source.evaluate((element) => element.classList.remove('author-hidden')); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(source).toHaveCSS('visibility', 'visible'); @@ -624,11 +647,11 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page await expect.poll(nativeSaveDisabled).toBe(true); await expect.poll(nativeActionsDisabled).toEqual([true, true]); await expect(menu).toHaveClass(/show-menu/); + await activate(page, 'Save'); + await expect(page.locator('[data-save-count]')).toHaveText('0'); await expect .poll(() => menu.evaluate((element) => element.shadowRoot?.querySelector('[part~="container"]')?.getBoundingClientRect().left)) .toBe(0); - await expect.poll(nativeSaveDisabled).toBe(true); - await expect.poll(nativeActionsDisabled).toEqual([true, true]); await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); await expect.poll(nativeSaveDisabled).toBe(false); @@ -667,6 +690,12 @@ test('foldable controls stay operable on Web when the native side rail is unavai await expect(menuProjection).toBeVisible(); await expect(saveProjection).toBeVisible(); await expect(page.locator('ion-tab-bar')).toBeVisible(); + await page.addStyleTag({ content: '.author-hidden { visibility: hidden !important }' }); + const saveSource = page.locator('app-native-ui-shell ion-button[type=submit]'); + await saveSource.evaluate((element) => element.classList.add('author-hidden')); + await expect(saveProjection).toHaveCount(0); + await saveSource.evaluate((element) => element.classList.remove('author-hidden')); + await expect(saveProjection).toBeVisible(); await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBeGreaterThan(0); await expect .poll(() => diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index cabf4140..13e8f482 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -8,9 +8,11 @@ import { isFoldableToolbarGroup, isShellDisabled, marker, + prehideOnlyMutation, prehiddenClass, setFoldableEnteringPage, unprojected, + withoutPrehide, } from './shared/dom'; const backProjectionClass = 'ios-theme-foldable-back-button-projection'; @@ -57,14 +59,12 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel [backSource, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter( (source): source is HTMLElement => !!source, ); - const isRendered = (element: HTMLElement) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - const prehidden = !!element.closest(`.${prehiddenClass}`); - return ( - element.isConnected && style.display !== 'none' && (prehidden || style.visibility === 'visible') && rect.width > 0 && rect.height > 0 - ); - }; + const isRendered = (element: HTMLElement) => + withoutPrehide(element, () => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return element.isConnected && style.display !== 'none' && style.visibility === 'visible' && rect.width > 0 && rect.height > 0; + }); const inEligibleToolbar = (element: HTMLElement) => { const currentRoot = foldableRoot(); const toolbar = element.closest('ion-toolbar'); @@ -243,11 +243,18 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel } if (nextBack || toolbarProjections.length) root.classList.add(readyClass); sourceObserver = new MutationObserver((records) => { - if (records.some((record) => record.type !== 'attributes' || ![marker, 'aria-hidden'].includes(record.attributeName ?? ''))) + if ( + records.some( + (record) => + record.type !== 'attributes' || (![marker, 'aria-hidden'].includes(record.attributeName ?? '') && !prehideOnlyMutation(record)), + ) + ) schedule(); }); - if (nextBack?.shadowRoot) sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true }); - for (const { group } of groups) sourceObserver.observe(group, { subtree: true, childList: true, attributes: true }); + if (nextBack?.shadowRoot) + sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); + for (const { group } of groups) + sourceObserver.observe(group, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); }; const performUpdate = () => { frame = 0; @@ -259,6 +266,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel subtree: true, childList: true, attributes: true, + attributeOldValue: true, attributeFilter: observingFoldable ? undefined : ['class'], }); } @@ -307,12 +315,19 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel )), ); if ( - (observingFoldable && records.some((record) => record.attributeName !== marker && !insideProjection(record.target))) || - (!observingFoldable && foldableChanged) + (observingFoldable && + records.some((record) => record.attributeName !== marker && !prehideOnlyMutation(record) && !insideProjection(record.target))) || + (!observingFoldable && foldableChanged && records.some((record) => !prehideOnlyMutation(record))) ) schedule(); }); - observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] }); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ['class'], + }); const pageLifecycle = (event: Event) => { const page = event.target; if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; diff --git a/src/native/prehide.ts b/src/native/prehide.ts index a49a535b..0687079b 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -29,7 +29,6 @@ export const prehideFoldableToolbarSources = (doc: Document): { suspend: () => ( const pendingBacks = new Map }>(); const listeners = new AbortController(); const root = () => doc.querySelector(':is(ion-app, body).ios-theme-enable-foldable'); - let foldableWasEnabled = false; let suspended = 0; let stopped = false; const routedPage = (element: HTMLElement) => element.closest('.ion-page:not(ion-app, body)'); @@ -110,22 +109,13 @@ export const prehideFoldableToolbarSources = (doc: Document): { suspend: () => ( const foldable = root(); if (!foldable) { Array.from(scopes.keys()).forEach(release); - foldableWasEnabled = false; return; } for (const scope of scopes.keys()) if (!scope.isConnected) release(scope); for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope); - if (!foldableWasEnabled) { - foldableWasEnabled = true; - foldable - .querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden, .ion-page-invisible)') - .forEach((page) => capture(page)); - } // Ionic inserts the destination as invisible before WillEnter. Hide its - // sources in that same mutation microtask, before the first transition paint. - foldable.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden)').forEach((page) => { - if (!scopes.has(page)) capture(page); - }); + // sources in that same mutation microtask; capture new DOM identities on active pages too. + foldable.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden)').forEach(capture); // Root toolbars can mount after startup; each new DOM identity is captured once. foldable.querySelectorAll(sourceSelector).forEach((element) => { if (routedPage(element) || element.closest(overlays)) return; @@ -168,10 +158,6 @@ export const prehideFoldableToolbarSources = (doc: Document): { suspend: () => ( }; doc.addEventListener(LIFECYCLE_WILL_ENTER, onWillEnter, { capture: true, signal: listeners.signal }); doc.addEventListener(LIFECYCLE_DID_LEAVE, onDidLeave, { capture: true, signal: listeners.signal }); - // The page already on screen when the shell starts has no WillEnter event. - root() - ?.querySelectorAll('.ion-page:not(ion-app, body, .ion-page-hidden, .ion-page-invisible)') - .forEach((page) => capture(page)); reconcile(); const mutationRelevant = (record: MutationRecord) => { if (record.type === 'childList') diff --git a/src/native/runtime.ts b/src/native/runtime.ts index c1673b24..472bcd2b 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -13,9 +13,9 @@ import type { import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; import { activateProjectedElement, - foldableEnteringPage, isFoldableRailSource, marker, + prehideOnlyMutation, rejectedClass, setFoldableEnteringPage, unprojected, @@ -157,21 +157,21 @@ export const createRuntime = async ( }; const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { - const enteringPage = isFoldableRailCandidate(element) ? foldableEnteringPage(element) : undefined; + const pointerPage = isFoldableRailCandidate(element) ? element.closest('.ion-page') : undefined; let candidate: Candidate | undefined; - if (enteringPage && getComputedStyle(enteringPage).pointerEvents === 'none') { - const previous = enteringPage.style.getPropertyValue('pointer-events'); - const priority = enteringPage.style.getPropertyPriority('pointer-events'); - const hadStyle = enteringPage.hasAttribute('style'); - measuringPointerPages.add(enteringPage); - enteringPage.style.setProperty('pointer-events', 'auto', 'important'); + if (pointerPage && getComputedStyle(pointerPage).pointerEvents === 'none') { + const previous = pointerPage.style.getPropertyValue('pointer-events'); + const priority = pointerPage.style.getPropertyPriority('pointer-events'); + const hadStyle = pointerPage.hasAttribute('style'); + measuringPointerPages.add(pointerPage); + pointerPage.style.setProperty('pointer-events', 'auto', 'important'); try { candidate = readCandidate(element, id); } finally { - if (previous) enteringPage.style.setProperty('pointer-events', previous, priority); - else enteringPage.style.removeProperty('pointer-events'); - if (!hadStyle && !enteringPage.style.length) enteringPage.removeAttribute('style'); - win.setTimeout(() => measuringPointerPages.delete(enteringPage), 0); + if (previous) pointerPage.style.setProperty('pointer-events', previous, priority); + else pointerPage.style.removeProperty('pointer-events'); + if (!hadStyle && !pointerPage.style.length) pointerPage.removeAttribute('style'); + win.setTimeout(() => measuringPointerPages.delete(pointerPage), 0); } } else candidate = readCandidate(element, id); if (candidate && isFoldableRailCandidate(element)) { @@ -413,13 +413,20 @@ export const createRuntime = async ( else finishWaiters(); } }; - const observation: MutationObserverInit = { subtree: true, childList: true, characterData: true, attributes: true }; + const observation: MutationObserverInit = { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeOldValue: true, + }; const observer = new MutationObserver((records) => { if ( records.some( (record) => record.attributeName !== marker && record.attributeName !== fadeMarker && + !prehideOnlyMutation(record) && !(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) && !( record.attributeName === 'aria-hidden' && @@ -658,12 +665,6 @@ export const createRuntime = async ( if (!element) return; const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element)); if (!owner) return; - if (overlayOpen() && isFoldableRailSource(owner)) { - activateProjectedElement(element); - lastSnapshot = ''; - schedule(); - return; - } const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 724205c7..6b5a9730 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -7,6 +7,36 @@ export const prehiddenClass = 'ios-theme-native-ui-shell-prehidden'; export const prehideRootClass = 'ios-theme-native-ui-shell-prehide'; export const rejectedClass = 'ios-theme-native-ui-shell-rejected'; export const foldableBackWebClass = 'ios-theme-foldable-back-web-owned'; +const prehideClasses = new Set([prehiddenClass, prehideRootClass]); +export const prehideOnlyMutation = (record: MutationRecord): boolean => { + if (record.attributeName !== 'class' || record.oldValue === null) return false; + const withoutPrehide = (value: string) => + value + .split(/\s+/) + .filter((name) => name && !prehideClasses.has(name)) + .join(' '); + return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? ''); +}; + +export const withoutPrehide = (element: HTMLElement, read: () => T): T => { + const changed: HTMLElement[] = []; + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + if (current.classList.contains(prehiddenClass)) { + current.classList.remove(prehiddenClass); + changed.push(current); + } + } + const root = element.ownerDocument.documentElement; + if (element.matches('ion-back-button') && root.classList.contains(prehideRootClass)) { + root.classList.remove(prehideRootClass); + changed.push(root); + } + try { + return read(); + } finally { + changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass)); + } +}; export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; const permanentlyExcluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]'; export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`; @@ -100,13 +130,11 @@ export const unprojected = (elements: Iterable, read: () => T): const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => { const enteringPage = isFoldableRailSource(element) ? foldableEnteringPage(element) : undefined; if (!element.isConnected || isExcluded(element, enteringPage) || isShellDisabled(element)) return false; - const prehidden = element.closest(`.${prehiddenClass}`); for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); - const hiddenForProjection = !!prehidden?.contains(current); if ( style.display === 'none' || - (!hiddenForProjection && style.visibility !== 'visible') || + style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage) ) return false; @@ -130,7 +158,8 @@ const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boole ); }; -export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => readVisible(element, allowOutsideViewport); +export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean => + withoutPrehide(element, () => readVisible(element, allowOutsideViewport)); export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({ x: rect.x - (origin?.x ?? 0), From adb65a42cf9cdc27fc03273fe829ba64231f9abc Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 20:23:30 +0900 Subject: [PATCH 20/35] test(foldable): trim redundant native shell coverage --- demo/e2e/native-ui-shell.spec.ts | 178 +++++++++++++------------------ 1 file changed, 72 insertions(+), 106 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index e3a7f132..6ea81d09 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -7,72 +7,70 @@ import * as overlayTypes from '../src/app/overlay-types'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); const mockNative = async (page: Page, fail = false, foldableRail = true) => { - await page.addInitScript( - ({ fail, foldableRail }) => { - const state = { - updates: [] as any[], - sequence: 0, - delay: 0, - hang: false, - rejectInactiveSearch: false, - rejectAllSearch: false, - rejectControlLabel: '', - activate: (_event: any) => {}, - search: (_event: any) => {}, - metrics: (_event: any) => {}, - }; - Object.assign(window, { - __nativeUIShell: state, - CapacitorCustomPlatform: { name: 'ios' }, - Capacitor: { - PluginHeaders: [ - { - name: 'IonicNativeUIShell', - methods: [ - { name: 'configure', rtype: 'promise' }, - { name: 'getWebViewMetrics', rtype: 'promise' }, - { name: 'update', rtype: 'promise' }, - { name: 'clear', rtype: 'promise' }, - { name: 'addListener' }, - { name: 'removeListener' }, - ], - }, - ], - nativePromise: async (_plugin: string, method: string, options: any) => { - if (method === 'configure') return { supported: true, foldableRail }; - if (method === 'getWebViewMetrics') return { radius: 0 }; - state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); - if (state.hang && method === 'update') await new Promise(() => {}); - if (state.delay) await new Promise((resolve) => setTimeout(resolve, state.delay)); - if (fail && method === 'update' && options.controls.length) throw new Error('Test native failure'); - return { - revision: options.revision, - rejectedSearches: - state.rejectInactiveSearch || state.rejectAllSearch - ? options.controls - ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) - .map((control: any) => control.id) - : [], - rejectedControls: state.rejectControlLabel + const script = ([fail, foldableRail]: readonly [boolean, boolean]) => { + const state = { + updates: [] as any[], + sequence: 0, + delay: 0, + hang: false, + rejectInactiveSearch: false, + rejectAllSearch: false, + rejectControlLabel: '', + activate: (_event: any) => {}, + search: (_event: any) => {}, + metrics: (_event: any) => {}, + }; + Object.assign(window, { + __nativeUIShell: state, + CapacitorCustomPlatform: { name: 'ios' }, + Capacitor: { + PluginHeaders: [ + { + name: 'IonicNativeUIShell', + methods: [ + { name: 'configure', rtype: 'promise' }, + { name: 'getWebViewMetrics', rtype: 'promise' }, + { name: 'update', rtype: 'promise' }, + { name: 'clear', rtype: 'promise' }, + { name: 'addListener' }, + { name: 'removeListener' }, + ], + }, + ], + nativePromise: async (_plugin: string, method: string, options: any) => { + if (method === 'configure') return { supported: true, foldableRail }; + if (method === 'getWebViewMetrics') return { radius: 0 }; + state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); + if (state.hang && method === 'update') await new Promise(() => {}); + if (state.delay) await new Promise((resolve) => setTimeout(resolve, state.delay)); + if (fail && method === 'update' && options.controls.length) throw new Error('Test native failure'); + return { + revision: options.revision, + rejectedSearches: + state.rejectInactiveSearch || state.rejectAllSearch ? options.controls - ?.filter((control: any) => control.items.some((item: any) => item.accessibilityLabel === state.rejectControlLabel)) + ?.filter((control: any) => control.search && (state.rejectAllSearch || control.search.available === false)) .map((control: any) => control.id) : [], - }; - }, - nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { - if (method === 'addListener') { - if (options.eventName === 'search') state.search = callback; - else if (options.eventName === 'activate') state.activate = callback; - else if (options.eventName === 'webViewMetricsChange') state.metrics = callback; - } - return 'shell-listener'; - }, + rejectedControls: state.rejectControlLabel + ? options.controls + ?.filter((control: any) => control.items.some((item: any) => item.accessibilityLabel === state.rejectControlLabel)) + .map((control: any) => control.id) + : [], + }; }, - }); - }, - { fail, foldableRail }, - ); + nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { + if (method === 'addListener') { + if (options.eventName === 'search') state.search = callback; + else if (options.eventName === 'activate') state.activate = callback; + else if (options.eventName === 'webViewMetricsChange') state.metrics = callback; + } + return 'shell-listener'; + }, + }, + }); + }; + await page.addInitScript(script, [fail, foldableRail] as const); }; const activate = (page: Page, label: string, duplicate = false) => @@ -571,22 +569,6 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page const cancelSource = page.locator('app-native-ui-shell ion-button').filter({ hasText: 'Cancel' }); const actionGroup = page.locator('app-native-ui-shell ion-buttons[data-glass-group]'); const tabs = page.locator('ion-tab-bar'); - for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); - await expect(cancelSource).not.toHaveAttribute('data-native-ui-shell', ''); - await expect(cancelSource).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1)?.controls ?? []; - const demoActions = (control: any) => - control.items.filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).length; - return { - groups: controls.filter((control: any) => control.kind === 'ion-buttons' && demoActions(control) === 2).length, - individuals: controls.filter((control: any) => control.kind === 'ion-button' && demoActions(control) > 0).length, - }; - }), - ) - .toEqual({ groups: 1, individuals: 0 }); await actionGroup.evaluate((element) => { const cancel = document.createElement('ion-button') as HTMLIonButtonElement; @@ -598,12 +580,10 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page const mixedCancel = actionGroup.locator('ion-button').filter({ hasText: 'Cancel mixed action' }); const mixedIcons = actionGroup.locator('ion-button').filter({ has: page.locator('ion-icon') }); await expect(actionGroup).not.toHaveAttribute('data-native-ui-shell'); - await expect(mixedIcons).toHaveCount(2); await expect(mixedIcons.nth(0)).toHaveAttribute('data-native-ui-shell', ''); await expect(mixedIcons.nth(1)).toHaveAttribute('data-native-ui-shell', ''); await expect(mixedCancel).not.toHaveAttribute('data-native-ui-shell'); await expect(mixedCancel).toBeVisible(); - await expect.poll(() => mixedCancel.evaluate((element) => getComputedStyle(element).visibility)).toBe('visible'); await expect .poll(() => page.evaluate(() => { @@ -617,45 +597,31 @@ test('foldable rail remains native while its Ionic menu is open', async ({ page }), ) .toEqual({ groups: 1, individuals: 0 }); - const nativeSaveDisabled = () => - page.evaluate( - () => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.flatMap((control: any) => control.items) - .find((item: any) => item.accessibilityLabel === 'Save')?.disabled, - ); - const nativeActionsDisabled = () => - page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.flatMap((control: any) => control.items) - .filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)) - .map((item: any) => item.disabled), - ); + const nativeDisabled = () => + page.evaluate(() => { + const items = (window as any).__nativeUIShell.updates.at(-1).controls.flatMap((control: any) => control.items); + return { + save: items.find((item: any) => item.accessibilityLabel === 'Save')?.disabled, + actions: items.filter((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)).map((item: any) => item.disabled), + }; + }); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); await activate(page, 'menu'); await expect(menu).toHaveClass(/show-menu/); - await expect.poll(nativeActionsDisabled).toEqual([true, true]); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [true, true] }); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); - await expect.poll(nativeActionsDisabled).toEqual([false, false]); - await expect.poll(() => mixedCancel.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [false, false] }); await mixedCancel.evaluate((element) => element.remove()); await expect(actionGroup).toHaveAttribute('data-native-ui-shell', ''); await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); - await expect.poll(nativeSaveDisabled).toBe(true); - await expect.poll(nativeActionsDisabled).toEqual([true, true]); + await expect.poll(nativeDisabled).toEqual({ save: true, actions: [true, true] }); await expect(menu).toHaveClass(/show-menu/); await activate(page, 'Save'); await expect(page.locator('[data-save-count]')).toHaveText('0'); - await expect - .poll(() => menu.evaluate((element) => element.shadowRoot?.querySelector('[part~="container"]')?.getBoundingClientRect().left)) - .toBe(0); await saveSource.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = '')); - await expect.poll(nativeSaveDisabled).toBe(false); - await expect.poll(nativeActionsDisabled).toEqual([false, false]); + await expect.poll(nativeDisabled).toEqual({ save: false, actions: [false, false] }); await expect.poll(() => cancelSource.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); for (const source of [menuSource, backSource, saveSource, tabs]) await expect(source).toHaveAttribute('data-native-ui-shell', ''); await expect(cancelSource).toBeVisible(); From 0bcb6344f8180129c813dffa1ef2041699183b9c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 20:30:43 +0900 Subject: [PATCH 21/35] refactor(native): share foldable toolbar button rendering --- .../Components/ShellFoldableRail.swift | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index f4cecca7..eaf64315 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -95,6 +95,14 @@ private struct ShellFoldableLabel: View { } } +@available(iOS 26.0, *) +private func foldableButton(_ item: ShellFoldableRailModel.Item, model: ShellFoldableRailModel) -> some View { + Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.id) +} + @available(iOS 26.0, *) private struct ShellFoldableBadge: ViewModifier { let badge: ShellBadge? @@ -170,20 +178,14 @@ private struct ShellFoldableToolbar: ViewModifier { content.toolbar { if let back = model.back { ToolbarItem(placement: .cancellationAction) { - Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } - .disabled(back.disabled) - .accessibilityLabel(back.accessibilityLabel) - .accessibilityIdentifier(back.id) + foldableButton(back, model: model) } .axisBehavior(.verticalPreferred) } ForEach(model.groups) { group in ToolbarItemGroup(placement: .automatic) { ForEach(group.items) { item in - Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } - .disabled(item.disabled) - .accessibilityLabel(item.accessibilityLabel) - .accessibilityIdentifier(item.id) + foldableButton(item, model: model) } } .axisBehavior(.verticalPreferred) @@ -200,19 +202,13 @@ private struct ShellFoldableLegacyToolbar: ViewModifier { content.toolbar { if let back = model.back { ToolbarItem(placement: .navigation) { - Button { model.activate(back.id) } label: { ShellFoldableLabel(item: back) } - .disabled(back.disabled) - .accessibilityLabel(back.accessibilityLabel) - .accessibilityIdentifier(back.id) + foldableButton(back, model: model) } } ForEach(model.groups) { group in ToolbarItemGroup(placement: .primaryAction) { ForEach(group.items) { item in - Button { model.activate(item.id) } label: { ShellFoldableLabel(item: item) } - .disabled(item.disabled) - .accessibilityLabel(item.accessibilityLabel) - .accessibilityIdentifier(item.id) + foldableButton(item, model: model) } } } From 3fe257d3633812de80877141a5eb4a4154d6b350 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 20:35:49 +0900 Subject: [PATCH 22/35] refactor: share foldable page projection lifecycle --- src/native/foldable-web.ts | 21 +++++---------------- src/native/runtime.ts | 29 +++++++---------------------- src/native/shared/dom.ts | 24 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts index 13e8f482..823ce9c2 100644 --- a/src/native/foldable-web.ts +++ b/src/native/foldable-web.ts @@ -2,6 +2,7 @@ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } f import { FOLDABLE_TRANSITION_CANCELED } from '../native-integration'; import { activateProjectedElement, + createFoldablePageState, foldableEnteringPage, foldableToolbarActions, isExcluded, @@ -10,7 +11,6 @@ import { marker, prehideOnlyMutation, prehiddenClass, - setFoldableEnteringPage, unprojected, withoutPrehide, } from './shared/dom'; @@ -50,7 +50,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel let frame = 0; let updates = 0; let observingFoldable = false; - const departedPages = new WeakSet(); + const foldablePages = createFoldablePageState(); let sourceObserver: MutationObserver | undefined; let waiters: (() => void)[] = []; const listeners = new AbortController(); @@ -69,7 +69,6 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel const currentRoot = foldableRoot(); const toolbar = element.closest('ion-toolbar'); const edge = toolbar?.parentElement; - const page = element.closest('.ion-page'); return ( !!currentRoot?.contains(element) && element.matches('.ios') && @@ -79,7 +78,7 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel !edge.hasAttribute('collapse') && !isExcluded(element, foldableEnteringPage(element)) && !isShellDisabled(element) && - (!page || !departedPages.has(page)) && + !foldablePages.isDeparted(element) && !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden') ); }; @@ -333,18 +332,8 @@ export const createFoldableWebProjection = (doc: Document, options: NativeUIShel if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; if (event.type === FOLDABLE_TRANSITION_CANCELED) { const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering; - if (entering) { - setFoldableEnteringPage(entering, false); - departedPages.add(entering); - } - departedPages.delete(page); - } else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') { - setFoldableEnteringPage(page, false); - departedPages.add(page); - } else { - departedPages.delete(page); - setFoldableEnteringPage(page, event.type === 'ionViewWillEnter'); - } + foldablePages.cancel(entering, page); + } else foldablePages.lifecycle(event); if (foldableRoot()) schedule(); }; for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave']) diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 472bcd2b..3acdc1b6 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -13,11 +13,11 @@ import type { import { readCandidate, selector, shadowSelector, motionSelector, isFoldableRailCandidate } from './components'; import { activateProjectedElement, + createFoldablePageState, isFoldableRailSource, marker, prehideOnlyMutation, rejectedClass, - setFoldableEnteringPage, unprojected, } from './shared/dom'; import { createIconRenderer } from './shared/icons'; @@ -53,7 +53,7 @@ export const createRuntime = async ( const foldableRailMembers = new Set(); const suspended = new Set(); const pages = new Set(); - const departedPages = new WeakSet(); + const foldablePages = createFoldablePageState(); const presented = new Set(); const manualSuspensions = new Set(); const moving = new Map>(); @@ -189,13 +189,8 @@ export const createRuntime = async ( await fail(error); } }; - const departed = (element: HTMLElement) => { - if (!element.closest(':is(ion-app, body).ios-theme-enable-foldable')) return false; - const page = element.closest('.ion-page'); - return !!page && departedPages.has(page); - }; const blocked = (element: HTMLElement) => - departed(element) || + foldablePages.isDeparted(element) || (!isFoldableRailSource(element) && (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || Array.from(pages).some((scope) => scope.contains(element)) || @@ -228,7 +223,7 @@ export const createRuntime = async ( search .decorate( Array.from(doc.querySelectorAll(selector)) - .filter((element) => !departed(element) && (!blocked(element) || (menuOpen && isFoldableRailSource(element)))) + .filter((element) => !foldablePages.isDeparted(element) && (!blocked(element) || (menuOpen && isFoldableRailSource(element)))) .map(readEnabledCandidate) .filter((candidate): candidate is Candidate => !!candidate), blocked, @@ -442,13 +437,7 @@ export const createRuntime = async ( target.addEventListener(name, callback, { capture: true, signal: listeners.signal }); const pageWill: EventListener = (event) => { const page = event.target as HTMLElement; - if (event.type === LIFECYCLE_WILL_ENTER) { - departedPages.delete(page); - setFoldableEnteringPage(page, true); - } else { - departedPages.add(page); - setFoldableEnteringPage(page, false); - } + foldablePages.lifecycle(event); getNativeSearchBindings(doc) .filter((binding) => page.contains(binding.footer)) .forEach((binding) => search.retire(binding)); @@ -460,9 +449,7 @@ export const createRuntime = async ( }; const pageDid: EventListener = (event) => { const page = event.target as HTMLElement; - if (event.type === LIFECYCLE_DID_LEAVE) departedPages.add(page); - else departedPages.delete(page); - setFoldableEnteringPage(page, false); + foldablePages.lifecycle(event); pages.delete(page); schedule(); if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff(); @@ -713,9 +700,7 @@ export const createRuntime = async ( if (canceled || !scopes.some((scope) => scope.closest(':is(ion-app, body).ios-theme-enable-foldable'))) { scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave. if (canceled) { - departedPages.add(scopes[0]); // The entering page is abandoned before Ionic hides it. - setFoldableEnteringPage(scopes[0], false); - if (scopes[1]) departedPages.delete(scopes[1]); // The leaving page stays active. + foldablePages.cancel(scopes[0], scopes[1]); // The entering page is abandoned; the leaving page stays active. } } schedule(); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 6b5a9730..8c913881 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -49,6 +49,30 @@ export const foldableEnteringPage = (element: HTMLElement): HTMLElement | undefi const page = element.closest('.ion-page-invisible'); return page && enteringPages.has(page) && page.closest(':is(ion-app, body).ios-theme-enable-foldable') ? page : undefined; }; +export const createFoldablePageState = () => { + const departed = new WeakSet(); + return { + isDeparted(element: HTMLElement): boolean { + const page = element.closest('.ion-page'); + return !!page && departed.has(page) && !!element.closest(':is(ion-app, body).ios-theme-enable-foldable'); + }, + lifecycle(event: Event): void { + const page = event.target; + if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; + const entering = event.type === 'ionViewWillEnter'; + if (entering || event.type === 'ionViewDidEnter') departed.delete(page); + else departed.add(page); + setFoldableEnteringPage(page, entering); + }, + cancel(entering?: HTMLElement, leaving?: HTMLElement): void { + if (entering) { + departed.add(entering); + setFoldableEnteringPage(entering, false); + } + if (leaving) departed.delete(leaving); + }, + }; +}; const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)'; const shellDisabledSelector = '.ios-theme-shell-disabled'; From 1fa1c34d8399c0077fa2ac32434b40225b588adb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 21:28:39 +0900 Subject: [PATCH 23/35] feat: preserve foldable toolbar start and end groups --- demo/e2e/native-ui-shell.spec.ts | 16 ++++++++++++++-- .../Components/ShellFoldableRail.swift | 15 ++++++++++++--- .../Shared/ShellSnapshot.swift | 6 +++++- src/native/definitions.ts | 2 ++ src/native/runtime.ts | 4 ++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 6ea81d09..65264b01 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -355,7 +355,7 @@ test('foldable tabs request native adaptive rail placement', async ({ page }) => await expect(bar).toHaveClass(/ios27-enable-gesture/); }); -test('foldable back navigation requests native rail placement', async ({ page }) => { +test('foldable back navigation and toolbar slots request native rail placement', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); const app = page.locator('ion-app'); @@ -372,7 +372,10 @@ test('foldable back navigation requests native rail placement', async ({ page }) () => (window as any).__nativeUIShell.updates .at(-1) - .controls.some((control: any) => control.kind === 'ion-back-button' && control.placement === 'foldable-rail') && + .controls.some( + (control: any) => + control.kind === 'ion-back-button' && control.placement === 'foldable-rail' && control.toolbarSlot === undefined, + ) && !(window as any).__nativeUIShell.updates .at(-1) .controls.some((control: any) => control.items.some((item: any) => item.label === 'Cancel')), @@ -388,11 +391,20 @@ test('foldable back navigation requests native rail placement', async ({ page }) (control: any) => control.kind === 'ion-button' && control.placement === 'foldable-rail' && + control.toolbarSlot === 'end' && control.items.some((item: any) => item.accessibilityLabel === 'Save'), ), ), ) .toBe(true); + await expect + .poll(() => + page.evaluate( + () => + (window as any).__nativeUIShell.updates.at(-1).controls.find((control: any) => control.kind === 'ion-menu-button')?.toolbarSlot, + ), + ) + .toBe('start'); await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); await expect(projection).toHaveCount(0); diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index eaf64315..92c98e6f 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -24,6 +24,7 @@ final class ShellFoldableRailModel: ObservableObject { struct Group: Identifiable { let id: String let items: [Item] + let slot: ShellControl.ToolbarSlot? } @Published var back: Item? @@ -46,7 +47,7 @@ final class ShellFoldableRailModel: ObservableObject { back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) groups = controls.compactMap { control in guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil } - return Group(id: control.id, items: control.items.map(item)) + return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot) } tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? [] domSelection = tabs.first(where: \.selected)?.id ?? "" @@ -182,8 +183,16 @@ private struct ShellFoldableToolbar: ViewModifier { } .axisBehavior(.verticalPreferred) } - ForEach(model.groups) { group in - ToolbarItemGroup(placement: .automatic) { + ForEach(model.groups.filter { $0.slot == .start }) { group in + ToolbarItemGroup(placement: .topBarLeading) { + ForEach(group.items) { item in + foldableButton(item, model: model) + } + } + .axisBehavior(.verticalPreferred) + } + ForEach(model.groups.filter { $0.slot != .start }) { group in + ToolbarItemGroup(placement: .topBarTrailing) { ForEach(group.items) { item in foldableButton(item, model: model) } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index d6027c30..ab211732 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -32,9 +32,11 @@ struct ShellFrame: Decodable, Equatable { struct ShellControl: Decodable, Equatable { enum Placement: String, Decodable { case foldableRail = "foldable-rail" } + enum ToolbarSlot: String, Decodable { case start, end } let id: String let kind: ShellComponent let placement: Placement? + let toolbarSlot: ToolbarSlot? let frame: ShellFrame let items: [ShellItem] let dark: Bool @@ -42,13 +44,14 @@ struct ShellControl: Decodable, Equatable { let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? - private enum CodingKeys: String, CodingKey { case id, kind, placement, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) kind = try values.decode(ShellComponent.self, forKey: .kind) placement = try values.decodeIfPresent(Placement.self, forKey: .placement) + toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot) frame = try ShellFrame(from: decoder) items = try values.decode([ShellItem].self, forKey: .items) dark = try values.decode(Bool.self, forKey: .dark) @@ -60,6 +63,7 @@ struct ShellControl: Decodable, Equatable { var isValid: Bool { ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && (!ShellButton.kinds.contains(kind) || items.count == 1) && + (toolbarSlot == nil || (placement == .foldableRail && [.button, .buttons, .menuButton].contains(kind))) && Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) && (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) && (search.map { kind == .tabBar && $0.isValid } ?? true) diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 013eda6b..0e6d2ce2 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -85,6 +85,8 @@ export interface ShellControl extends Frame { kind: NativeUIShellComponent; /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */ placement?: 'foldable-rail'; + /** Logical Ionic toolbar slot, preserved when projecting to the foldable rail. */ + toolbarSlot?: 'start' | 'end'; items: ShellItem[]; dark: boolean; rtl: boolean; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 3acdc1b6..b100ed8d 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -177,6 +177,10 @@ export const createRuntime = async ( if (candidate && isFoldableRailCandidate(element)) { if (!nativeFoldableRail) return undefined; candidate.control.placement = 'foldable-rail'; + if (['ion-button', 'ion-buttons', 'ion-menu-button'].includes(candidate.control.kind)) { + const slot = (element.matches('ion-buttons') ? element : (element.closest('ion-buttons') ?? element)).getAttribute('slot'); + if (slot === 'start' || slot === 'end') candidate.control.toolbarSlot = slot; + } } return candidate && controlEnabled(candidate) ? candidate : undefined; }; From 6a62390a91e824d18d85486ea0a42d9b19d4a6a6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 22:11:07 +0900 Subject: [PATCH 24/35] feat: use system back navigation in foldable rail --- .../Components/ShellFoldableRail.swift | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 92c98e6f..35f03b04 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -121,17 +121,11 @@ private struct ShellFoldableRailView: View { var body: some View { Group { if model.tabs.isEmpty { - NavigationStack { - Color.clear - .modifier(ShellFoldableToolbarAdapter(model: model)) - } + ShellFoldableNavigation(model: model) } else { TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { ForEach(model.tabs) { item in - NavigationStack { - Color.clear - .modifier(ShellFoldableToolbarAdapter(model: model)) - } + ShellFoldableNavigation(model: model) .tag(item.id) .tabItem { ShellFoldableLabel(item: item) } .modifier(ShellFoldableBadge(badge: item.badge)) @@ -147,6 +141,30 @@ private struct ShellFoldableRailView: View { } } +@available(iOS 26.0, *) +private struct ShellFoldableNavigation: View { + @ObservedObject var model: ShellFoldableRailModel + @State private var path: [String] = [] + + var body: some View { + NavigationStack(path: Binding(get: { path }, set: { next in + if !path.isEmpty && next.isEmpty, let back = model.back { + model.activate(back.id) + } + path = next + })) { + Color.clear + .navigationDestination(for: String.self) { _ in + Color.clear.modifier(ShellFoldableToolbarAdapter(model: model)) + } + .modifier(ShellFoldableToolbarAdapter(model: model)) + } + .onChange(of: model.back?.id, initial: true) { _, id in + path = id.map { [$0] } ?? [] + } + } +} + @available(iOS 26.0, *) private struct ShellFoldableCompression: ViewModifier { @ViewBuilder func body(content: Content) -> some View { @@ -177,12 +195,6 @@ private struct ShellFoldableToolbar: ViewModifier { func body(content: Content) -> some View { content.toolbar { - if let back = model.back { - ToolbarItem(placement: .cancellationAction) { - foldableButton(back, model: model) - } - .axisBehavior(.verticalPreferred) - } ForEach(model.groups.filter { $0.slot == .start }) { group in ToolbarItemGroup(placement: .topBarLeading) { ForEach(group.items) { item in @@ -209,11 +221,6 @@ private struct ShellFoldableLegacyToolbar: ViewModifier { func body(content: Content) -> some View { content.toolbar { - if let back = model.back { - ToolbarItem(placement: .navigation) { - foldableButton(back, model: model) - } - } ForEach(model.groups) { group in ToolbarItemGroup(placement: .primaryAction) { ForEach(group.items) { item in From d45327cfa368cab7dbf5b6bc760c6ffa0e59376b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 23 Sep 2026 22:58:09 +0900 Subject: [PATCH 25/35] fix: restore foldable toolbar and label overflow actions --- demo/e2e/native-ui-shell.spec.ts | 24 +++++++++++++++++++ demo/src/app/index/index-page.component.html | 6 +++-- .../native-ui-shell/native-ui-shell.page.html | 4 ++-- .../Components/ShellFoldableRail.swift | 11 +++------ src/native/shared/candidate.ts | 3 ++- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 65264b01..b51edf9f 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -411,6 +411,30 @@ test('foldable back navigation and toolbar slots request native rail placement', await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('native foldable toolbar returns with Index after a pushed page', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); + const hasIndexActions = () => + page.evaluate(() => { + const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + return controls.some( + (control: any) => control.placement === 'foldable-rail' && control.items.some((item: any) => item.accessibilityLabel === 'GitHub'), + ); + }); + await expect.poll(hasIndexActions).toBe(true); + await page.getByRole('button', { name: 'button', exact: true }).click(); + await expect(page).toHaveURL(/\/main\/index\/button$/); + await page.evaluate(() => { + const state = (window as any).__nativeUIShell; + const snapshot = state.updates.at(-1); + const back = snapshot.controls.find((control: any) => control.kind === 'ion-back-button').items[0]; + state.activate({ id: back.id, revision: snapshot.revision, sequence: ++state.sequence }); + }); + await expect(page).toHaveURL(/\/main\/index$/); + await expect.poll(hasIndexActions).toBe(true); +}); + test('native foldable actions follow WillEnter and stay enabled during navigation', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index 6e5045b4..2ba70cad 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -6,9 +6,11 @@ Index + -
diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index 93d366b2..f405bda9 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -21,8 +21,8 @@ Actions: {{ github() }} / {{ refreshes() }} - - + + diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 35f03b04..e7dabdb5 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -86,7 +86,7 @@ private struct ShellFoldableLabel: View { var body: some View { if let image = item.image { Label { - Text(item.label) + Text(item.label.isEmpty ? item.accessibilityLabel : item.label) } icon: { Image(uiImage: image) } @@ -144,14 +144,12 @@ private struct ShellFoldableRailView: View { @available(iOS 26.0, *) private struct ShellFoldableNavigation: View { @ObservedObject var model: ShellFoldableRailModel - @State private var path: [String] = [] var body: some View { - NavigationStack(path: Binding(get: { path }, set: { next in - if !path.isEmpty && next.isEmpty, let back = model.back { + NavigationStack(path: Binding(get: { model.back.map { [$0.id] } ?? [] }, set: { next in + if next.isEmpty, let back = model.back { model.activate(back.id) } - path = next })) { Color.clear .navigationDestination(for: String.self) { _ in @@ -159,9 +157,6 @@ private struct ShellFoldableNavigation: View { } .modifier(ShellFoldableToolbarAdapter(model: model)) } - .onChange(of: model.back?.id, initial: true) { _, id in - path = id.map { [$0] } ?? [] - } } } diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index f5e9839a..67171934 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -58,7 +58,8 @@ export const appendItem = ( id: id(child), ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), label, - accessibilityLabel: child.getAttribute('aria-label') ?? native?.getAttribute('aria-label') ?? label, + accessibilityLabel: + child.getAttribute('aria-label') ?? native?.getAttribute('aria-label') ?? icons[0]?.getAttribute('aria-label') ?? label, disabled: !!(child as ItemElement).disabled || !!(candidate.element as ItemElement).disabled || From c282662fa93f68608e52fa48616bbc3e83141389 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 07:38:00 +0900 Subject: [PATCH 26/35] test: verify foldable native rail navigation and labels --- demo/e2e/native-ui-shell.spec.ts | 19 +++++++++++++ .../NativeUIShellTests.swift | 28 +++++++++---------- src/native/shared/candidate.ts | 5 +++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index b51edf9f..e70bcaed 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -425,6 +425,15 @@ test('native foldable toolbar returns with Index after a pushed page', async ({ await expect.poll(hasIndexActions).toBe(true); await page.getByRole('button', { name: 'button', exact: true }).click(); await expect(page).toHaveURL(/\/main\/index\/button$/); + await expect + .poll(() => + page.evaluate(() => { + const state = (window as any).__nativeUIShell; + const snapshot = state.updates.at(-1); + return !!snapshot.controls.find((control: any) => control.kind === 'ion-back-button')?.items[0]; + }), + ) + .toBe(true); await page.evaluate(() => { const state = (window as any).__nativeUIShell; const snapshot = state.updates.at(-1); @@ -1091,6 +1100,16 @@ test('clear ion-buttons share one glass surface and keep independent actions', a const native = await projectedGroup(); expect(native.items).toHaveLength(2); expect(native.items.map((item: any) => item.accessibilityLabel)).toEqual(['GitHub', 'Refresh']); + const githubName = () => + page.evaluate( + (id) => + (window as any).__nativeUIShell.updates.at(-1).controls.find((control: any) => control.id === id)?.items[0]?.accessibilityLabel, + native.id, + ); + await github.locator('ion-icon').evaluate((icon) => icon.setAttribute('aria-hidden', 'true')); + await expect.poll(githubName).not.toBe('GitHub'); + await github.locator('ion-icon').evaluate((icon) => icon.removeAttribute('aria-hidden')); + await expect.poll(githubName).toBe('GitHub'); for (const item of native.items) expect(item.icon).toMatch(/^iVBOR/); await activate(page, 'GitHub', true); await expect(page.locator('ion-title').filter({ hasText: 'Actions:' })).toHaveText('Actions: 1 / 0'); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index c7c7efe9..6b350781 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -256,7 +256,7 @@ final class NativeUIShellTests: XCTestCase { app.launch() let toggle = app.webViews.switches["Foldable Mode"] XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) - toggle.tap() + if toggle.value as? String == "0" { toggle.tap() } let index = app.buttons["Index"] let library = app.buttons["Library"] @@ -274,27 +274,27 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) save.tap() XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) - let github = nativeButton(app, label: "GitHub") - let refresh = nativeButton(app, label: "Refresh") - XCTAssertTrue(github.waitForExistence(timeout: 5), app.debugDescription) - XCTAssertTrue(refresh.waitForExistence(timeout: 5), app.debugDescription) - XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label == %@ AND identifier BEGINSWITH %@", "GitHub", "shell-")).count, 1, - app.debugDescription) - XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label == %@ AND identifier BEGINSWITH %@", "Refresh", "shell-")).count, 1, - app.debugDescription) - github.tap() + let more = app.buttons["More"] + XCTAssertTrue(more.waitForExistence(timeout: 5), app.debugDescription) + more.tap() + XCTAssertTrue(app.buttons["GitHub"].waitForExistence(timeout: 5), app.debugDescription) + capture("native-foldable-toolbar-more") + app.buttons["GitHub"].tap() XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 0"].waitForExistence(timeout: 5), app.debugDescription) - refresh.tap() + more.tap() + XCTAssertTrue(app.buttons["Refresh"].waitForExistence(timeout: 5), app.debugDescription) + app.buttons["Refresh"].tap() XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 1"].waitForExistence(timeout: 5), app.debugDescription) capture("native-foldable-toolbar") - let back = nativeButton(app, label: "back") + let back = app.buttons["BackButton"] XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) assertOnFoldableRail(back, in: app) back.tap() XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(nativeButton(app, label: "GitHub").waitForExistence(timeout: 10), "Index toolbar did not return after native back\n" + app.debugDescription) openPage(app, name: "native-ui-shell") - XCTAssertTrue(nativeButton(app, label: "back").waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(back.waitForExistence(timeout: 5), app.debugDescription) let pageMenu = nativeButton(app, label: "menu") XCTAssertTrue(pageMenu.waitForExistence(timeout: 5), app.debugDescription) pageMenu.tap() @@ -307,7 +307,7 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(control.isEnabled, "Foldable rail controls must remain enabled while Ionic disables the covered page\n" + app.debugDescription) } save.tap() - pageMenu.tap() + app.coordinate(withNormalizedOffset: CGVector(dx: 0.81, dy: 0.55)).tap() XCTAssertTrue(menuLink.waitForNonExistence(timeout: 5), "The projected Ionic menu button did not close its menu\n" + app.debugDescription) XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), "The native control stopped projecting actions while the menu was open\n" + app.debugDescription) capture("native-foldable-after-menu") diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index 67171934..7bb93ae5 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -59,7 +59,10 @@ export const appendItem = ( ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), label, accessibilityLabel: - child.getAttribute('aria-label') ?? native?.getAttribute('aria-label') ?? icons[0]?.getAttribute('aria-label') ?? label, + child.getAttribute('aria-label') ?? + native?.getAttribute('aria-label') ?? + (icons[0]?.getAttribute('aria-hidden') === 'true' ? null : icons[0]?.getAttribute('aria-label')) ?? + label, disabled: !!(child as ItemElement).disabled || !!(candidate.element as ItemElement).disabled || From bc7e590d15e533add2933a6bad9dc7a5c0c574f4 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 07:55:31 +0900 Subject: [PATCH 27/35] fix: keep foldable transition shade outside control rail --- demo/e2e/animation.spec.ts | 11 +++++++++++ src/styles/components/ion-content.scss | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 229bed64..15494300 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -104,6 +104,17 @@ test.describe('Animation Tests', () => { await expect(shade).toHaveCount(0); }); + test('foldable page transition does not shade the control rail', async ({ page }) => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); + await page.getByRole('button', { name: 'button', exact: true }).click(); + const shade = page.locator('.ios-transition-shade'); + await expect(shade).toBeVisible(); + await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause())); + await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)'); + await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play())); + }); + test('runs and completes the iOS popover animations', async ({ page }) => { await page.goto('/main/index/popover', { waitUntil: 'networkidle' }); await clearAnimationCalls(page); diff --git a/src/styles/components/ion-content.scss b/src/styles/components/ion-content.scss index 44751566..21919202 100644 --- a/src/styles/components/ion-content.scss +++ b/src/styles/components/ion-content.scss @@ -23,6 +23,12 @@ ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen:not --padding-bottom: calc(60px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom))); } +// The page-transition shade belongs to Web content, not the foldable rail. +// Keep its 10% dimming over the page while leaving the system control area untouched. +:is(ion-app, body).ios-theme-enable-foldable .ios-transition-shade { + clip-path: inset(0 var(--ios-theme-foldable-safe-area-right-resolved) 0 var(--ios-theme-foldable-safe-area-left-resolved)); +} + // Foldable system UI changes foreground geometry, not the page background. // Reset Ionic's matching inset so descendants do not apply it a second time. :is(ion-app, body).ios-theme-enable-foldable From 0c37711fb82dfccc8e1bd4704059d3d343b9f49e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 08:22:56 +0900 Subject: [PATCH 28/35] fix: remove foldable page transition shade --- demo/e2e/animation.spec.ts | 6 +++--- src/styles/components/ion-content.scss | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 15494300..9f60f6f8 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -104,14 +104,14 @@ test.describe('Animation Tests', () => { await expect(shade).toHaveCount(0); }); - test('foldable page transition does not shade the control rail', async ({ page }) => { + test('foldable page transition has no extra shade', async ({ page }) => { await page.goto('/main/index', { waitUntil: 'networkidle' }); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); await page.getByRole('button', { name: 'button', exact: true }).click(); const shade = page.locator('.ios-transition-shade'); - await expect(shade).toBeVisible(); + await expect(shade).toHaveCount(1); await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause())); - await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)'); + await expect(shade).toBeHidden(); await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play())); }); diff --git a/src/styles/components/ion-content.scss b/src/styles/components/ion-content.scss index 21919202..736e8656 100644 --- a/src/styles/components/ion-content.scss +++ b/src/styles/components/ion-content.scss @@ -23,10 +23,10 @@ ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen:not --padding-bottom: calc(60px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom))); } -// The page-transition shade belongs to Web content, not the foldable rail. -// Keep its 10% dimming over the page while leaving the system control area untouched. +// The foldable rail stays visible during native navigation; do not dim its +// surrounding Web page with an additional transition shade. :is(ion-app, body).ios-theme-enable-foldable .ios-transition-shade { - clip-path: inset(0 var(--ios-theme-foldable-safe-area-right-resolved) 0 var(--ios-theme-foldable-safe-area-left-resolved)); + display: none !important; } // Foldable system UI changes foreground geometry, not the page background. From 37e67ccbacc594d4a5c5828dff66e5f5d0dd38e8 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 08:32:59 +0900 Subject: [PATCH 29/35] fix: keep foldable native rail backing clear during navigation --- demo/e2e/animation.spec.ts | 6 +++--- .../Components/ShellFoldableRail.swift | 9 ++++----- src/styles/components/ion-content.scss | 5 ++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 9f60f6f8..88acfd91 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -104,14 +104,14 @@ test.describe('Animation Tests', () => { await expect(shade).toHaveCount(0); }); - test('foldable page transition has no extra shade', async ({ page }) => { + test('foldable page transition keeps its shade outside the control rail', async ({ page }) => { await page.goto('/main/index', { waitUntil: 'networkidle' }); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); await page.getByRole('button', { name: 'button', exact: true }).click(); const shade = page.locator('.ios-transition-shade'); - await expect(shade).toHaveCount(1); + await expect(shade).toBeVisible(); await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause())); - await expect(shade).toBeHidden(); + await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)'); await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play())); }); diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index e7dabdb5..fe4d39cd 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -238,11 +238,10 @@ final class ShellFoldableRailController: ShellFoldableRailControlling { private func makeFullSizeSurfacesTransparent(in surface: UIView) { guard !(surface is UIVisualEffectView) else { return } let frame = surface.convert(surface.bounds, to: view) - let background = surface.backgroundColor?.resolvedColor(with: surface.traitCollection) - let systemBackground = UIColor.systemBackground.resolvedColor(with: surface.traitCollection) - // SwiftUI's hosting containers add opaque system backgrounds behind their bars. Remove only those - // host-sized base surfaces; preserve smaller controls, materials, and application-defined backgrounds. - if frame.insetBy(dx: -1, dy: -1).contains(view.bounds), background == systemBackground { + // NavigationStack may install a dimmed full-size backing surface during push/pop. + // This host has no application content behind its controls, so every full-size + // backing must stay clear while smaller controls and system materials remain intact. + if frame.insetBy(dx: -1, dy: -1).contains(view.bounds) { surface.backgroundColor = .clear surface.isOpaque = false } diff --git a/src/styles/components/ion-content.scss b/src/styles/components/ion-content.scss index 736e8656..fd804a9c 100644 --- a/src/styles/components/ion-content.scss +++ b/src/styles/components/ion-content.scss @@ -23,10 +23,9 @@ ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen:not --padding-bottom: calc(60px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom))); } -// The foldable rail stays visible during native navigation; do not dim its -// surrounding Web page with an additional transition shade. +// Keep the Web page's transition dimming, but never shade the foldable rail. :is(ion-app, body).ios-theme-enable-foldable .ios-transition-shade { - display: none !important; + clip-path: inset(0 var(--ios-theme-foldable-safe-area-right-resolved) 0 var(--ios-theme-foldable-safe-area-left-resolved)); } // Foldable system UI changes foreground geometry, not the page background. From 09ddbc26e7e509c38685fe329350a777413d8f36 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 10:20:14 +0900 Subject: [PATCH 30/35] fix: retain foldable back controls until page departure --- demo/e2e/foldable-back-button.spec.ts | 6 +++--- demo/e2e/native-ui-shell.spec.ts | 11 +++++++---- .../Components/ShellFoldableRail.swift | 2 ++ src/native/shared/dom.ts | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts index 0ad9bef2..f648b6ee 100644 --- a/demo/e2e/foldable-back-button.spec.ts +++ b/demo/e2e/foldable-back-button.spec.ts @@ -76,7 +76,7 @@ test('a stale suspension lease cannot re-hide Web controls after shell teardown' await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); }); -test('Web toolbar projection follows foldable WillEnter and WillLeave', async ({ page }) => { +test('Web toolbar projection stays through WillLeave and retires at DidLeave', async ({ page }) => { await page.goto('/main/index/button'); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); @@ -102,7 +102,7 @@ test('Web toolbar projection follows foldable WillEnter and WillLeave', async ({ }); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); - await expect(projection).toHaveCount(0); + await expect(projection).toBeVisible(); await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); await expect(projection).toBeVisible(); }); @@ -116,7 +116,7 @@ test('turning foldable on during a transition honors its success or cancellation await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); - await expect(projection).toHaveCount(0); + await expect(projection).toBeVisible(); await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); await expect(projection).toBeVisible(); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index e70bcaed..3c54e938 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -452,10 +452,9 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio await expect(source).toHaveAttribute('data-native-ui-shell', ''); const routedPage = page.locator('app-native-ui-shell.ion-page'); - await routedPage.evaluate((element) => { - element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })); - element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })); - }); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); await page.waitForTimeout(150); await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); @@ -496,6 +495,10 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio await expect.poll(saveDisabled).toBe(true); await save.evaluate((element) => element.classList.remove('author-no-pointer')); await routedPage.evaluate((element) => (element.style.pointerEvents = '')); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); test('foldable toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index fe4d39cd..d5b6a33c 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -157,6 +157,8 @@ private struct ShellFoldableNavigation: View { } .modifier(ShellFoldableToolbarAdapter(model: model)) } + // Ionic animates the page; NavigationStack must not add a second dimmed rail transition. + .transaction { $0.disablesAnimations = true } } } diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 8c913881..fa1958a7 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -61,7 +61,7 @@ export const createFoldablePageState = () => { if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; const entering = event.type === 'ionViewWillEnter'; if (entering || event.type === 'ionViewDidEnter') departed.delete(page); - else departed.add(page); + else if (event.type === 'ionViewDidLeave') departed.add(page); setFoldableEnteringPage(page, entering); }, cancel(entering?: HTMLElement, leaving?: HTMLElement): void { From 4f5976502148e2f0fef99b709288740452954ab3 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 11:11:37 +0900 Subject: [PATCH 31/35] fix: suppress foldable rail dimming without disabling glass motion --- .../Components/ShellFoldableRail.swift | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index d5b6a33c..5f76cfdc 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -157,8 +157,6 @@ private struct ShellFoldableNavigation: View { } .modifier(ShellFoldableToolbarAdapter(model: model)) } - // Ionic animates the page; NavigationStack must not add a second dimmed rail transition. - .transaction { $0.disablesAnimations = true } } } @@ -238,14 +236,22 @@ final class ShellFoldableRailController: ShellFoldableRailControlling { } private func makeFullSizeSurfacesTransparent(in surface: UIView) { - guard !(surface is UIVisualEffectView) else { return } let frame = surface.convert(surface.bounds, to: view) - // NavigationStack may install a dimmed full-size backing surface during push/pop. - // This host has no application content behind its controls, so every full-size - // backing must stay clear while smaller controls and system materials remain intact. - if frame.insetBy(dx: -1, dy: -1).contains(view.bounds) { + let railWidth = view.safeAreaInsets.right > 0 ? view.safeAreaInsets.right : 80 + guard !(surface is UIVisualEffectView) else { return } + let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds) + let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 && + frame.minX <= view.bounds.maxX - railWidth + 1 && frame.maxX >= view.bounds.maxX - 1 + var white: CGFloat = 0 + var alpha: CGFloat = 0 + let isDimming = coversRail && surface.backgroundColor?.resolvedColor(with: surface.traitCollection) + .getWhite(&white, alpha: &alpha) == true && white <= 0.05 && alpha > 0 && alpha <= 0.2 + // NavigationStack may add a full-height backing for just the rail during push/pop. + // Keep that backing clear without touching the smaller glass controls or materials. + if coversHost || coversRail { surface.backgroundColor = .clear surface.isOpaque = false + if isDimming { surface.layer.removeAllAnimations() } } surface.subviews.forEach(makeFullSizeSurfacesTransparent) } From b5865202a74ddb8e4a8e671165e3af3667ad2924 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 13:38:48 +0900 Subject: [PATCH 32/35] fix(ios): project foldable back without native navigation pop --- .../Components/ShellFoldableRail.swift | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 5f76cfdc..2836b859 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -121,11 +121,11 @@ private struct ShellFoldableRailView: View { var body: some View { Group { if model.tabs.isEmpty { - ShellFoldableNavigation(model: model) + ShellFoldablePage(model: model) } else { TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) { ForEach(model.tabs) { item in - ShellFoldableNavigation(model: model) + ShellFoldablePage(model: model) .tag(item.id) .tabItem { ShellFoldableLabel(item: item) } .modifier(ShellFoldableBadge(badge: item.badge)) @@ -142,20 +142,12 @@ private struct ShellFoldableRailView: View { } @available(iOS 26.0, *) -private struct ShellFoldableNavigation: View { +private struct ShellFoldablePage: View { @ObservedObject var model: ShellFoldableRailModel var body: some View { - NavigationStack(path: Binding(get: { model.back.map { [$0.id] } ?? [] }, set: { next in - if next.isEmpty, let back = model.back { - model.activate(back.id) - } - })) { - Color.clear - .navigationDestination(for: String.self) { _ in - Color.clear.modifier(ShellFoldableToolbarAdapter(model: model)) - } - .modifier(ShellFoldableToolbarAdapter(model: model)) + NavigationStack { + Color.clear.modifier(ShellFoldableToolbarAdapter(model: model)) } } } @@ -190,6 +182,12 @@ private struct ShellFoldableToolbar: ViewModifier { func body(content: Content) -> some View { content.toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + foldableButton(back, model: model).accessibilityIdentifier("BackButton") + } + .axisBehavior(.verticalPreferred) + } ForEach(model.groups.filter { $0.slot == .start }) { group in ToolbarItemGroup(placement: .topBarLeading) { ForEach(group.items) { item in @@ -216,6 +214,11 @@ private struct ShellFoldableLegacyToolbar: ViewModifier { func body(content: Content) -> some View { content.toolbar { + if let back = model.back { + ToolbarItem(placement: .navigation) { + foldableButton(back, model: model).accessibilityIdentifier("BackButton") + } + } ForEach(model.groups) { group in ToolbarItemGroup(placement: .primaryAction) { ForEach(group.items) { item in @@ -242,16 +245,11 @@ final class ShellFoldableRailController: ShellFoldableRailControlling { let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds) let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 && frame.minX <= view.bounds.maxX - railWidth + 1 && frame.maxX >= view.bounds.maxX - 1 - var white: CGFloat = 0 - var alpha: CGFloat = 0 - let isDimming = coversRail && surface.backgroundColor?.resolvedColor(with: surface.traitCollection) - .getWhite(&white, alpha: &alpha) == true && white <= 0.05 && alpha > 0 && alpha <= 0.2 - // NavigationStack may add a full-height backing for just the rail during push/pop. - // Keep that backing clear without touching the smaller glass controls or materials. + // SwiftUI may add an opaque backing behind the rail controls. + // Keep that backing clear without touching the glass controls or materials. if coversHost || coversRail { surface.backgroundColor = .clear surface.isOpaque = false - if isDimming { surface.layer.removeAllAnimations() } } surface.subviews.forEach(makeFullSizeSurfacesTransparent) } From 6ef8717856f91e9284c132b26b6a1277c2230d30 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 14:06:47 +0900 Subject: [PATCH 33/35] fix(foldable): switch projected controls at will lifecycle --- demo/e2e/foldable-back-button.spec.ts | 8 ++++---- demo/e2e/native-ui-shell.spec.ts | 4 ++-- src/native/runtime.ts | 10 +++++++++- src/native/shared/dom.ts | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts index f648b6ee..357821b9 100644 --- a/demo/e2e/foldable-back-button.spec.ts +++ b/demo/e2e/foldable-back-button.spec.ts @@ -76,7 +76,7 @@ test('a stale suspension lease cannot re-hide Web controls after shell teardown' await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/); }); -test('Web toolbar projection stays through WillLeave and retires at DidLeave', async ({ page }) => { +test('Web toolbar projection switches at WillLeave and restores on cancellation', async ({ page }) => { await page.goto('/main/index/button'); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable')); const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); @@ -85,9 +85,9 @@ test('Web toolbar projection stays through WillLeave and retires at DidLeave', a await routedPage.evaluate((element) => { element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })); - element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })); }); await expect(projection).toHaveCount(0); + await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); await page.waitForTimeout(150); await expect(projection).toHaveCount(0); @@ -102,7 +102,7 @@ test('Web toolbar projection stays through WillLeave and retires at DidLeave', a }); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); - await expect(projection).toBeVisible(); + await expect(projection).toHaveCount(0); await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); await expect(projection).toBeVisible(); }); @@ -116,7 +116,7 @@ test('turning foldable on during a transition honors its success or cancellation await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); - await expect(projection).toBeVisible(); + await expect(projection).toHaveCount(0); await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); await expect(projection).toBeVisible(); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 3c54e938..a2cd7d08 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -453,7 +453,7 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio const routedPage = page.locator('app-native-ui-shell.ion-page'); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); - await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); await page.waitForTimeout(150); @@ -496,7 +496,7 @@ test('native foldable actions follow WillEnter and stay enabled during navigatio await save.evaluate((element) => element.classList.remove('author-no-pointer')); await routedPage.evaluate((element) => (element.style.pointerEvents = '')); await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }))); - await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true }))); await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index b100ed8d..61c205dc 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,6 +1,6 @@ import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core'; -import { getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration'; +import { FOLDABLE_TRANSITION_CANCELED, getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration'; import { createSearchSupport } from './components/searchable-tabs'; import type { ShellActivation, @@ -514,6 +514,14 @@ export const createRuntime = async ( for (const name of Object.values(CSS_MOTION_EVENTS)) on(doc, name, motion); for (const name of [LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE]) on(doc, name, pageWill); for (const name of [LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE]) on(doc, name, pageDid); + on(doc, FOLDABLE_TRANSITION_CANCELED, (event) => { + const leaving = event.target as HTMLElement; + const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering; + foldablePages.cancel(entering, leaving); + pages.delete(leaving); + if (entering) pages.delete(entering); + schedule(); + }); on(doc, 'ionTabsWillChange', () => { // Vanilla ion-tabs dispatches DOM events; @ionic/angular uses EventEmitters instead. armTabSwitchHandoff(); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index fa1958a7..53b7acaf 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -61,7 +61,7 @@ export const createFoldablePageState = () => { if (!(page instanceof HTMLElement) || !page.matches('.ion-page')) return; const entering = event.type === 'ionViewWillEnter'; if (entering || event.type === 'ionViewDidEnter') departed.delete(page); - else if (event.type === 'ionViewDidLeave') departed.add(page); + else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') departed.add(page); setFoldableEnteringPage(page, entering); }, cancel(entering?: HTMLElement, leaving?: HTMLElement): void { From bfaf42836d24e0a7f0f69c96de4ad1b6fa2c6714 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 14:30:53 +0900 Subject: [PATCH 34/35] fix(ios): animate projected foldable toolbar changes --- .../NativeUIShellTests/NativeUIShellTests.swift | 15 ++++++--------- .../Components/ShellFoldableRail.swift | 10 ++++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 6b350781..cd3fcfd8 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -274,16 +274,13 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) save.tap() XCTAssertTrue(savedOnce(app).waitForExistence(timeout: 5), app.debugDescription) - let more = app.buttons["More"] - XCTAssertTrue(more.waitForExistence(timeout: 5), app.debugDescription) - more.tap() - XCTAssertTrue(app.buttons["GitHub"].waitForExistence(timeout: 5), app.debugDescription) - capture("native-foldable-toolbar-more") - app.buttons["GitHub"].tap() + let github = app.buttons["GitHub"] + XCTAssertTrue(github.waitForExistence(timeout: 5), app.debugDescription) + github.tap() XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 0"].waitForExistence(timeout: 5), app.debugDescription) - more.tap() - XCTAssertTrue(app.buttons["Refresh"].waitForExistence(timeout: 5), app.debugDescription) - app.buttons["Refresh"].tap() + let refresh = app.buttons["Refresh"] + XCTAssertTrue(refresh.waitForExistence(timeout: 5), app.debugDescription) + refresh.tap() XCTAssertTrue(app.webViews.staticTexts["Actions: 1 / 1"].waitForExistence(timeout: 5), app.debugDescription) capture("native-foldable-toolbar") diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 2836b859..0831cf3d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -44,10 +44,12 @@ final class ShellFoldableRailModel: ObservableObject { image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled, selected: source.content.selected) } - back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) - groups = controls.compactMap { control in - guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil } - return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot) + withAnimation(.smooth(duration: 0.3)) { + back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item) + groups = controls.compactMap { control in + guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil } + return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot) + } } tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? [] domSelection = tabs.first(where: \.selected)?.id ?? "" From d71fe0de053a94bccfb7c5b7fe93e445ea983347 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 14:55:34 +0900 Subject: [PATCH 35/35] fix(ios): use system back symbol in foldable rail --- .../Components/ShellFoldableRail.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift index 0831cf3d..e49f904d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellFoldableRail.swift @@ -106,6 +106,14 @@ private func foldableButton(_ item: ShellFoldableRailModel.Item, model: ShellFol .accessibilityIdentifier(item.id) } +@available(iOS 26.0, *) +private func foldableBackButton(_ item: ShellFoldableRailModel.Item, model: ShellFoldableRailModel) -> some View { + Button { model.activate(item.id) } label: { Image(systemName: "chevron.backward") } + .disabled(item.disabled) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier("BackButton") +} + @available(iOS 26.0, *) private struct ShellFoldableBadge: ViewModifier { let badge: ShellBadge? @@ -186,7 +194,7 @@ private struct ShellFoldableToolbar: ViewModifier { content.toolbar { if let back = model.back { ToolbarItem(placement: .navigation) { - foldableButton(back, model: model).accessibilityIdentifier("BackButton") + foldableBackButton(back, model: model) } .axisBehavior(.verticalPreferred) } @@ -218,7 +226,7 @@ private struct ShellFoldableLegacyToolbar: ViewModifier { content.toolbar { if let back = model.back { ToolbarItem(placement: .navigation) { - foldableButton(back, model: model).accessibilityIdentifier("BackButton") + foldableBackButton(back, model: model) } } ForEach(model.groups) { group in