From 494436f20430c73372a9a58d4fb15ac48be149d5 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 17:42:30 +0900 Subject: [PATCH 01/17] feat: expose standalone vertical control area --- README.md | 4 + demo/e2e/native-ui-shell.spec.ts | 47 ++- demo/e2e/vertical-bars-standalone.spec.ts | 39 +++ demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/main.ts | 4 +- docs/native-ui-shell.md | 2 + docs/special-markup.md | 16 +- .../IonicNativeUIShellPlugin.swift | 20 +- package.json | 4 + src/native/definitions.ts | 4 +- src/native/index.ts | 23 +- src/native/runtime.ts | 2 + src/native/shared/dom.ts | 7 +- src/styles/components/ion-button.scss | 114 ------- src/styles/components/ion-content.scss | 23 -- src/styles/components/ion-fab.scss | 36 --- src/styles/components/ion-menu.scss | 23 -- src/styles/components/ion-tabs.scss | 93 ------ src/styles/components/ion-toolbar.scss | 18 -- src/styles/default-variables.scss | 7 - src/styles/ionic-theme-ios27.scss | 1 + src/styles/vertical-bars.scss | 285 ++++++++++++++++++ src/vertical-bars.ts | 2 + 23 files changed, 439 insertions(+), 337 deletions(-) create mode 100644 demo/e2e/vertical-bars-standalone.spec.ts create mode 100644 src/styles/vertical-bars.scss create mode 100644 src/vertical-bars.ts diff --git a/README.md b/README.md index e55599a9..80247132 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ Use this markup to preview the inset grouped list look. For the list structure t ## Optional setups +### Support iPhone Duo without the iOS 27 theme + +Import only `@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css`, add `ios-theme-vertical-bars` to `ion-app`, and call `enableVerticalControlArea()` from `@rdlabo/ionic-theme-ios27/vertical-bars` at startup. This uses Ionic's standard appearance outside the Vertical Control Area; on supported iOS, only controls moved into that area are projected natively. See [Support iPhone Duo](./docs/special-markup.md#support-iphone-duo) for the complete setup. + ### Use only the iOS 27 theme Install only `@rdlabo/ionic-theme-ios27` and import its styles unconditionally in your global stylesheet: diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 5e05e2af..71a359e3 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -16,6 +16,8 @@ const mockNative = async (page: Page, fail = false, verticalBars = true) => { rejectInactiveSearch: false, rejectAllSearch: false, rejectControlLabel: '', + configuredWith: undefined as any, + metricsRequested: 0, activate: (_event: any) => {}, search: (_event: any) => {}, metrics: (_event: any) => {}, @@ -38,8 +40,14 @@ const mockNative = async (page: Page, fail = false, verticalBars = true) => { }, ], nativePromise: async (_plugin: string, method: string, options: any) => { - if (method === 'configure') return { supported: true, verticalBars }; - if (method === 'getWebViewMetrics') return { radius: 0 }; + if (method === 'configure') { + state.configuredWith = options; + return { supported: true, verticalBars }; + } + if (method === 'getWebViewMetrics') { + state.metricsRequested++; + 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)); @@ -355,6 +363,41 @@ test('verticalBars tabs request native adaptive rail placement', async ({ page } await expect(bar).toHaveClass(/ios27-enable-gesture/); }); +test('standalone Vertical Control Area never snapshots ordinary Native UI Shell controls', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); + await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index--) { + const rule = sheet.cssRules[index]; + if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); + } + } + }); + const app = page.locator('ion-app'); + await app.evaluate((element) => { + element.classList.add('ios-theme-vertical-bars'); + element.style.setProperty('--ion-background-color-rgb', '0, 0, 0'); + }); + const allVerticalBarsDark = (expected: boolean) => + page.evaluate((expected) => { + const controls = ((window as any).__nativeUIShell.updates.at(-1)?.controls ?? []).filter( + (control: any) => control.placement === 'vertical-bars', + ); + return controls.length > 0 && controls.every((control: any) => control.dark === expected); + }, expected); + await expect.poll(() => allVerticalBarsDark(true)).toBe(true); + const state = await page.evaluate(() => { + const { configuredWith, metricsRequested, updates } = (window as any).__nativeUIShell; + return { configuredWith, metricsRequested, updates }; + }); + expect(state.configuredWith).toEqual({ verticalBarsOnly: true }); + expect(state.metricsRequested).toBe(0); + expect(state.updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars')).toBe(true); + await app.evaluate((element) => element.style.setProperty('--ion-background-color-rgb', '255, 255, 255')); + await expect.poll(() => allVerticalBarsDark(false)).toBe(true); +}); + test('verticalBars back navigation and toolbar slots request native rail placement', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts new file mode 100644 index 00000000..ed4338d1 --- /dev/null +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -0,0 +1,39 @@ +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { compile } from 'sass'; + +const verticalBars = compile(resolve(__dirname, '../../src/styles/vertical-bars.scss')).css; + +test('Vertical Control Area works with Ionic CSS and no iOS 27 theme', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); + await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index--) { + const rule = sheet.cssRules[index]; + if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); + } + } + }); + await page.addStyleTag({ content: verticalBars }); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + + const tabBar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); + const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); + await expect + .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) + .toBe('0px'); + const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); + const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await projection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + + const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(back).toBeVisible(); + await back.click(); + await expect(page).toHaveURL(/\/main\/index$/); +}); diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 5409abb3..6f891dd8 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

Support iPhone Duo

\n

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

\n
<ion-app class="ios-theme-vertical-bars">...</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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, initializing enableNativeUIShell() at application startup hands eligible 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/main.ts b/demo/src/main.ts index 3082d306..4ae64eaf 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,6 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; +import { enableVerticalControlArea } from '../../src/vertical-bars'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -21,4 +22,5 @@ function loadIOSAnimations(): IonicAnimationOptions { // Demo forces mode: 'ios' (including Playwright), so do not gate on isPlatform('ios'). bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())).catch((err) => console.error(err)); -void enableNativeUIShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); +const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; +void startShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 8738524b..afab10c1 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -169,6 +169,8 @@ The native material and control appearance follow the running iOS version; an iO ## Support iPhone Duo +The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/vertical-bars`) and `dist/css/vertical-bars.css` work without loading the iOS 27 theme. Call `enableVerticalControlArea()` for this use case; it projects only controls placed in the vertical area. Apps already calling `enableNativeUIShell()` should keep that single runtime rather than starting both. + On supported iOS versions, adding `.ios-theme-vertical-bars` 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/docs/special-markup.md b/docs/special-markup.md index 2ff1f6bd..2a0d1945 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -49,7 +49,19 @@ These classes do not reposition a separate `ion-fab`; leave room for it when cho ## Support iPhone Duo -To simulate the iPhone Duo layout on the web, add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the application has no `ion-app` root: +Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are **not required**: + +```scss +@use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css'; +``` + +```ts +import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +void enableVerticalControlArea(); +``` + +Add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the application has no `ion-app` root: ```html ... @@ -65,7 +77,7 @@ These values are web-layout simulation inputs. They are independent from Ionic's When the app contains `ion-tabs`, this 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 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; this mode does not convert tabs into a menu. -On supported iOS versions, initializing `enableNativeUIShell()` at application startup hands eligible 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 6e6ee076..a9a17d7a 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -116,7 +116,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let verticalBars = (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 { + if call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect { let hidden = effect.isHidden effect.isHidden = true self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } @@ -213,12 +213,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.removeControls(duration: duration) call.resolve(["revision": next]); return } - let host = self.host ?? ShellHost() - self.host = host - host.frame = parent.bounds - host.autoresizingMask = [.flexibleWidth, .flexibleHeight] - host.backgroundColor = .clear - host.isAccessibilityElement = false let scale = webView.bounds.width / width let retained = Set(snapshots.map(\.id)) for id in Array(self.controls.keys) where !retained.contains(id) { @@ -241,6 +235,18 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } else { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } + if snapshots.isEmpty { + self.host?.removeFromSuperview() + self.host = nil + call.resolve(["revision": next, "rejectedControls": rejectedControls]) + return + } + let host = self.host ?? ShellHost() + self.host = host + host.frame = parent.bounds + host.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.backgroundColor = .clear + host.isAccessibilityElement = false UIView.performWithoutAnimation { if host.superview !== parent { parent.addSubview(host) } for node in snapshots { diff --git a/package.json b/package.json index abf394ee..e7bd807b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "./native": { "types": "./dist/native/index.d.ts", "import": "./dist/native/index.js" + }, + "./vertical-bars": { + "types": "./dist/vertical-bars.d.ts", + "import": "./dist/vertical-bars.js" } }, "files": [ diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 37c3a238..0f8049a9 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -15,6 +15,8 @@ export interface NativeUIShellOptions { enabled?: boolean; /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */ controls?: NativeUIShellControls; + /** Internal: limit native projection to the Vertical Control Area. */ + verticalBarsOnly?: boolean; } export interface NativeUIShellControls { @@ -135,7 +137,7 @@ export interface WebViewMetrics { } export interface NativeUIShellPlugin { - configure(): Promise<{ supported: boolean; verticalBars?: boolean }>; + configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean; verticalBars?: 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 a05d16fe..a5b81567 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -51,6 +51,10 @@ export const configureNativeTransition = async (): Promise => { return metrics; }; +/** Call once at application startup. Ionic markup remains the source of truth. */ +export const enableVerticalControlArea = (): Promise => + enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true }); + /** Call once at application startup. Ionic markup remains the source of truth. */ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise => { if (options.enabled === false) { @@ -73,18 +77,21 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires Capacitor iOS'), stopPrehide); let runtime: NativeUIShellHandle | undefined; try { - await configureNativeTransition().catch(() => undefined); - const capabilities = await plugin.configure(); + if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); + const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) { return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires iOS 26 or later'), stopPrehide); } - runtime = await createRuntime(document, plugin, options, capabilities.verticalBars === true); + runtime = await createRuntime(document, plugin, options, capabilities.verticalBars === true, options.verticalBarsOnly === true); if (capabilities.verticalBars !== true) runtime = combine(runtime, createVerticalBarsWebProjection(document, options)); - runtime = await bindMetricsLifecycle( - runtime, - () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), - () => (active = undefined), - ); + if (!options.verticalBarsOnly) + runtime = await bindMetricsLifecycle( + runtime, + () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), + () => { + active = undefined; + }, + ); return resetOnDestroy(runtime, stopPrehide); } catch (error) { await runtime?.destroy(); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index c5853966..c79d42ae 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -42,6 +42,7 @@ export const createRuntime = async ( plugin: NativeUIShellPlugin, options: NativeUIShellOptions = {}, nativeVerticalBars = true, + verticalBarsOnly = false, ): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); @@ -157,6 +158,7 @@ export const createRuntime = async ( }; const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { + if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; let candidate: Candidate | undefined; if (pointerPage && getComputedStyle(pointerPage).pointerEvents === 'none') { diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index ef0e59da..5b9abad3 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -37,7 +37,12 @@ export const withoutPrehide = (element: HTMLElement, read: () => T): T => { changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass)); } }; -export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; +export const isDark = (style: CSSStyleDeclaration): boolean => { + const themeScheme = style.getPropertyValue('--ios27-color-scheme').trim(); + if (themeScheme) return themeScheme === 'dark'; + const background = style.getPropertyValue('--ion-background-color-rgb').match(/\d+/g)?.slice(0, 3).map(Number); + return !!background && background.length === 3 && background[0] * 0.2126 + background[1] * 0.7152 + background[2] * 0.0722 < 128; +}; 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(); diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss index 82ac66c9..5a0cacfb 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -436,117 +436,3 @@ ion-button.ios:not(.ios-theme-disabled, .ios26-disabled) { 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; -} - -// Ionic can paint a newly inserted routed page before its WillEnter snapshot. -// Until JS assigns ownership, keep only its verticalBars 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-vertical-bars - :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-vertical-bars-back-web-owned, - .ionic-theme-disabled, - .ios-theme-disabled, - .ios26-disabled, - .ios-theme-vertical-bars-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-vertical-bars.ios-theme-vertical-bars-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-vertical-bars-back-button-projection) { - display: none; - } - - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) - ion-buttons.ios - > :is(ion-button, ion-menu-button).ios[data-native-ui-shell]:not( - .ios-theme-disabled, - .ios26-disabled, - .ios-theme-vertical-bars-toolbar-action - ) { - display: none; - } - - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) - ion-buttons.ios:has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( - :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) - ) { - display: none; - } - - > ion-back-button.ios.ios-theme-vertical-bars-back-button-projection { - position: fixed; - z-index: 1001; - top: var(--ios-theme-vertical-bars-toolbar-top, 220px); - right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); - display: block; - width: 46px; - height: 46px; - margin: 0; - } - - > .ios.ios-theme-vertical-bars-toolbar-projection { - position: fixed; - z-index: 1001; - top: calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) + var(--ios-theme-vertical-bars-toolbar-offset, 0px)); - right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); - width: 46px; - min-width: 46px; - height: 46px; - min-height: 46px; - margin: 0; - - :is(ion-icon, svg) { - width: 22px; - height: 22px; - margin: 0; - font-size: 22px; - } - } - - > ion-menu-button.ios.ios-theme-vertical-bars-toolbar-projection { - @include api.glass-control-background; - border-radius: 50%; - } - - > ion-button.ios.ios-theme-vertical-bars-toolbar-projection { - --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); - --border-radius: 50%; - @include glass.light-shadow('--box-shadow'); - - &::part(native) { - @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); - } - } - - > ion-buttons.ios.ios-theme-vertical-bars-toolbar-projection { - display: flex; - flex-direction: column; - height: auto; - min-height: 0; - - > :is(ion-button, ion-menu-button).ios.ios-theme-vertical-bars-toolbar-action { - display: block; - width: 44px; - min-width: 44px; - height: 46px; - min-height: 46px; - margin: 0 1px; - - &::part(native) { - margin-inline: auto; - } - } - } -} diff --git a/src/styles/components/ion-content.scss b/src/styles/components/ion-content.scss index fd16f492..39a0e7c7 100644 --- a/src/styles/components/ion-content.scss +++ b/src/styles/components/ion-content.scss @@ -22,26 +22,3 @@ ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen:not(:has(.ion-content-scroll-host)) { --padding-bottom: calc(60px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom))); } - -// Keep the Web page's transition dimming, but never shade the verticalBars rail. -:is(ion-app, body).ios-theme-vertical-bars .ios-transition-shade { - clip-path: inset(0 var(--ios-theme-vertical-bars-safe-area-right-resolved) 0 var(--ios-theme-vertical-bars-safe-area-left-resolved)); -} - -// VerticalBars 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-vertical-bars - ion-content.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { - --ion-safe-area-left: 0px; - --ion-safe-area-right: 0px; - - &::part(scroll) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - } - - &:dir(rtl)::part(scroll) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - } -} diff --git a/src/styles/components/ion-fab.scss b/src/styles/components/ion-fab.scss index 95a582a3..631318a9 100644 --- a/src/styles/components/ion-fab.scss +++ b/src/styles/components/ion-fab.scss @@ -109,42 +109,6 @@ ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) { } } -// FAB alignment is independent from tab placement. Map the reserved physical -// side to the matching logical FAB edge for the current writing direction. -:is(ion-app, body).ios-theme-vertical-bars - ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { - --ion-safe-area-left: 0px; - --ion-safe-area-right: 0px; - --ios-theme-vertical-bars-fab-edge-gap: 16px; - --ios-theme-vertical-bars-fab-start-offset: var(--ios-theme-menu-width, var(--ios26-menu-width, 0px)); - - &.fab-horizontal-start { - inset-inline-start: calc( - var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + - var(--ios-theme-vertical-bars-safe-area-left-resolved) - ); - } - - &.fab-horizontal-end { - inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - } - - &:dir(rtl).fab-horizontal-start { - inset-inline-start: calc( - var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + - var(--ios-theme-vertical-bars-safe-area-right-resolved) - ); - } - - &:dir(rtl).fab-horizontal-end { - inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - } - - &:has(.fab-button-small) { - --ios-theme-vertical-bars-fab-edge-gap: 10px; - } -} - // A content fixed slot already starts below its page header, including safe area. // Keep the floating gap without adding the screen's top inset a second time. .ion-page:has(> ion-header:not([hidden], .ion-hide)) diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index 723c35f0..187c9982 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -132,26 +132,3 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) { } } } - -// A menu beside verticalBars system UI must end before the reserved region. Its -// own ion-content remains unchanged. The host consumes the matching Ionic -// safe-area variable because its complete surface has already moved inward. -:is(ion-app, body).ios-theme-vertical-bars { - 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; - - &::part(container) { - margin-right: var(--ios-theme-vertical-bars-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; - - &::part(container) { - margin-left: var(--ios-theme-vertical-bars-safe-area-left-resolved); - } - } -} diff --git a/src/styles/components/ion-tabs.scss b/src/styles/components/ion-tabs.scss index 452b6347..61622ecf 100644 --- a/src/styles/components/ion-tabs.scss +++ b/src/styles/components/ion-tabs.scss @@ -163,99 +163,6 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { } } -// iPhone Duo reserves the physical right edge for system navigation. In the -// web simulation, keep tabs in that same rail so the application remains -// usable without introducing a second navigation component. -:is(ion-app, body).ios-theme-vertical-bars - ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) - > ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { - --ios-theme-side-tab-bar-width: 50px; - --ios-theme-side-tab-bar-gap: 8px; - --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-right-resolved); - - contain: layout style; - display: flex; - flex-direction: column; - width: 46px; - min-width: 46px; - max-width: 46px; - height: fit-content; - min-height: 0; - padding: 2px; - border-radius: 25px; - left: auto; - right: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); - margin: 0; - - top: auto; - bottom: calc(var(--ion-safe-area-bottom, 0px) + var(--ios-theme-side-tab-bar-gap)); - - > ion-tab-button:not(.ion-cloned-element) { - display: block; - flex: none; - width: 46px; - min-width: 46px; - height: 52px; - min-height: 52px; - border-radius: 23px; - - &::part(native) { - justify-content: center; - } - - ion-label { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - ion-icon { - margin: 0; - font-size: 28px; - } - } - - > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { - margin-block-start: 0; - margin-inline-start: 0; - } - - // iPhone Duo expands labels while the user drags across the rail. The - // stable four-tab layout remains icon-only, matching SwiftUI's TabView. - &:has(> ion-tab-button:is(.ion-activated, :active)) > ion-tab-button:not(.ion-cloned-element) { - &::part(native) { - flex-direction: column; - padding-block: 4px; - } - - ion-label { - position: static; - width: auto; - height: auto; - max-width: 44px; - padding: 0; - margin: 1px 0 0; - overflow: hidden; - clip: auto; - font-size: 0.5rem; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; - } - - ion-icon { - margin: 0; - font-size: 22px; - } - } -} - ion-tab-button.ios:not(.ios-theme-disabled, .ios26-disabled) { transform-origin: center 65%; font-size: 0.59rem; diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss index 4e4b36e8..96576690 100644 --- a/src/styles/components/ion-toolbar.scss +++ b/src/styles/components/ion-toolbar.scss @@ -107,21 +107,3 @@ ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled) { margin-inline: 0; } } - -// Keep the toolbar background full-width while moving its foreground controls -// away from verticalBars system UI. -:is(ion-app, body).ios-theme-vertical-bars - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { - --ion-safe-area-left: 0px; - --ion-safe-area-right: 0px; - - &::part(container) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - } - - &:dir(rtl)::part(container) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - } -} diff --git a/src/styles/default-variables.scss b/src/styles/default-variables.scss index 8e237ce5..2e336b1e 100644 --- a/src/styles/default-variables.scss +++ b/src/styles/default-variables.scss @@ -37,10 +37,3 @@ */ --ios26-content-box-shadow-rgb: var(--ion-color-base-rgb, var(--ion-background-color-rgb, 255, 255, 255)); } - -// iPhone Duo web simulation: the iOS 27.1 Simulator is 951pt wide while -// its application window is 871pt wide, leaving an 80pt navigation region. -:is(ion-app, body).ios-theme-vertical-bars { - --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); - --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 80px); -} diff --git a/src/styles/ionic-theme-ios27.scss b/src/styles/ionic-theme-ios27.scss index 48dc0af4..65b9351c 100644 --- a/src/styles/ionic-theme-ios27.scss +++ b/src/styles/ionic-theme-ios27.scss @@ -21,3 +21,4 @@ @use 'components/ion-toast'; @use 'components/ion-toggle'; @use 'components/ion-toolbar'; +@use 'vertical-bars'; diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss new file mode 100644 index 00000000..a205ced9 --- /dev/null +++ b/src/styles/vertical-bars.scss @@ -0,0 +1,285 @@ +@use 'utils/api'; +@use 'utils/glass'; + +// iPhone Duo simulation: the system reserves 80pt at the physical right edge. +// This entry point does not load the iOS 27 theme or change ordinary Ionic UI. +:is(ion-app, body).ios-theme-vertical-bars { + --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); + --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 80px); +} + +// Supply material defaults only to Web projections, never to ordinary controls. +:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready + > :is(ion-menu-button, ion-button).ios-theme-vertical-bars-toolbar-projection { + --ios26-glass-background-rgb: var(--ion-background-color-rgb, 255, 255, 255); + --ios26-glass-border-color-rgb: var(--ion-background-color-rgb, 255, 255, 255); + --ios26-glass-box-shadow-color-rgb: var(--ion-text-color-rgb, 0, 0, 0); +} + +// The transition shade belongs to the Web page, never to the control area. +:is(ion-app, body).ios-theme-vertical-bars .ios-transition-shade { + clip-path: inset(0 var(--ios-theme-vertical-bars-safe-area-right-resolved) 0 var(--ios-theme-vertical-bars-safe-area-left-resolved)); +} + +// Keep page backgrounds and the router full width; move only their foregrounds. +:is(ion-app, body).ios-theme-vertical-bars + ion-content.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + + &::part(scroll) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + + &:dir(rtl)::part(scroll) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } +} + +:is(ion-app, body).ios-theme-vertical-bars + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + + &::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + + &:dir(rtl)::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } +} + +:is(ion-app, body).ios-theme-vertical-bars + ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + --ion-safe-area-left: 0px; + --ion-safe-area-right: 0px; + --ios-theme-vertical-bars-fab-edge-gap: 16px; + --ios-theme-vertical-bars-fab-start-offset: var(--ios-theme-menu-width, var(--ios26-menu-width, 0px)); + + &.fab-horizontal-start { + inset-inline-start: calc( + var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + + var(--ios-theme-vertical-bars-safe-area-left-resolved) + ); + } + &.fab-horizontal-end { + inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } + &:dir(rtl).fab-horizontal-start { + inset-inline-start: calc( + var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-fab-start-offset) + + var(--ios-theme-vertical-bars-safe-area-right-resolved) + ); + } + &:dir(rtl).fab-horizontal-end { + inset-inline-end: calc(var(--ios-theme-vertical-bars-fab-edge-gap) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } + &:has(.fab-button-small) { + --ios-theme-vertical-bars-fab-edge-gap: 10px; + } +} + +// Menus retain their own safe-area handling and full-width animation host. +:is(ion-app, body).ios-theme-vertical-bars { + 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; + &::part(container) { + margin-right: var(--ios-theme-vertical-bars-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; + &::part(container) { + margin-left: var(--ios-theme-vertical-bars-safe-area-left-resolved); + } + } +} + +:is(ion-app, body).ios-theme-vertical-bars + ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) + > ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-side-tab-bar-width: 50px; + --ios-theme-side-tab-bar-gap: 8px; + --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-right-resolved); + contain: layout style; + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + width: 46px; + min-width: 46px; + max-width: 46px; + height: fit-content; + min-height: 0; + padding: 2px; + border-radius: 25px; + left: auto; + right: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); + margin: 0; + top: auto; + bottom: calc(var(--ion-safe-area-bottom, 0px) + var(--ios-theme-side-tab-bar-gap)); + + > ion-tab-button:not(.ion-cloned-element) { + display: block; + flex: none; + width: 46px; + min-width: 46px; + height: 52px; + min-height: 52px; + border-radius: 23px; + &::part(native) { + justify-content: center; + } + ion-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + ion-icon { + margin: 0; + font-size: 28px; + } + } + > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { + margin-block-start: 0; + margin-inline-start: 0; + } + &:has(> ion-tab-button:is(.ion-activated, :active)) > ion-tab-button:not(.ion-cloned-element) { + &::part(native) { + flex-direction: column; + padding-block: 4px; + } + ion-label { + position: static; + width: auto; + height: auto; + max-width: 44px; + padding: 0; + margin: 1px 0 0; + overflow: hidden; + clip: auto; + font-size: 0.5rem; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + } + ion-icon { + margin: 0; + font-size: 22px; + } + } +} + +.ios-theme-native-ui-shell-prehidden { + position: absolute !important; + visibility: hidden !important; +} + +// Hide an entering back source until JS decides whether it belongs in the rail. +html.ios-theme-native-ui-shell-prehide + :is(ion-app, body).ios-theme-vertical-bars + :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-vertical-bars-back-web-owned, + .ionic-theme-disabled, + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-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-vertical-bars.ios-theme-vertical-bars-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-vertical-bars-back-button-projection) { + display: none; + } + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons.ios + > :is(ion-button, ion-menu-button).ios[data-native-ui-shell]:not( + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-toolbar-action + ) { + display: none; + } + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons.ios:has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( + :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) + ) { + display: none; + } + > ion-back-button.ios.ios-theme-vertical-bars-back-button-projection { + position: fixed; + z-index: 1001; + top: var(--ios-theme-vertical-bars-toolbar-top, 220px); + right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); + display: block; + width: 46px; + height: 46px; + margin: 0; + } + > .ios.ios-theme-vertical-bars-toolbar-projection { + position: fixed; + z-index: 1001; + top: calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) + var(--ios-theme-vertical-bars-toolbar-offset, 0px)); + right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); + width: 46px; + min-width: 46px; + height: 46px; + min-height: 46px; + margin: 0; + :is(ion-icon, svg) { + width: 22px; + height: 22px; + margin: 0; + font-size: 22px; + } + } + > ion-menu-button.ios.ios-theme-vertical-bars-toolbar-projection { + @include api.glass-control-background; + border-radius: 50%; + } + > ion-button.ios.ios-theme-vertical-bars-toolbar-projection { + --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); + --border-radius: 50%; + @include glass.light-shadow('--box-shadow'); + &::part(native) { + @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); + } + } + > ion-buttons.ios.ios-theme-vertical-bars-toolbar-projection { + display: flex; + flex-direction: column; + height: auto; + min-height: 0; + > :is(ion-button, ion-menu-button).ios.ios-theme-vertical-bars-toolbar-action { + display: block; + width: 44px; + min-width: 44px; + height: 46px; + min-height: 46px; + margin: 0 1px; + &::part(native) { + margin-inline: auto; + } + } + } +} diff --git a/src/vertical-bars.ts b/src/vertical-bars.ts new file mode 100644 index 00000000..f6a0ac15 --- /dev/null +++ b/src/vertical-bars.ts @@ -0,0 +1,2 @@ +export { enableVerticalControlArea } from './native'; +export type { NativeUIShellHandle, NativeUIShellStatus, NativeUIShellSuspension } from './native'; From 57e308fbe5d97d3cbb465646d2651fe941d703c8 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 18:15:12 +0900 Subject: [PATCH 02/17] test: ignore local Ionic Conference App integration --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8d10bdc4..05902379 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist/ +/integration/ionic-conference-app/ # Generated by @rdlabo/ionic-theme-utils before Sass compilation. /src/styles/utils/structured-list.scss From c82bab57ba4108703f069e11b627fbde7fc3ea4d Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 19:06:56 +0900 Subject: [PATCH 03/17] fix(vertical-bars): project back buttons outside fixed toolbars --- demo/e2e/native-ui-shell.spec.ts | 56 ++++++++++------------- demo/e2e/vertical-bars-standalone.spec.ts | 7 +++ src/native/components/ion-back-button.ts | 10 +++- src/native/prehide.ts | 11 ++++- src/native/vertical-bars-web.ts | 9 +++- src/styles/vertical-bars.scss | 15 ++++-- 6 files changed, 68 insertions(+), 40 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 71a359e3..a35aa553 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -366,6 +366,11 @@ test('verticalBars tabs request native adaptive rail placement', async ({ page } test('standalone Vertical Control Area never snapshots ordinary Native UI Shell controls', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); + const back = page.locator('app-native-ui-shell ion-back-button'); + await back.evaluate((element: HTMLIonBackButtonElement) => { + element.text = 'Return'; + element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + }); await page.evaluate(() => { for (const sheet of Array.from(document.styleSheets)) { for (let index = sheet.cssRules.length - 1; index >= 0; index--) { @@ -394,6 +399,8 @@ test('standalone Vertical Control Area never snapshots ordinary Native UI Shell expect(state.configuredWith).toEqual({ verticalBarsOnly: true }); expect(state.metricsRequested).toBe(0); expect(state.updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars')).toBe(true); + await expect(back).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + expect(state.updates.at(-1).controls.find((control: any) => control.kind === 'ion-back-button')?.items[0]?.label).toBe('Return'); await app.evaluate((element) => element.style.setProperty('--ion-background-color-rgb', '255, 255, 255')); await expect.poll(() => allVerticalBarsDark(false)).toBe(true); }); @@ -571,38 +578,6 @@ test('verticalBars toolbar sources are hidden before ownership and restored with 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-vertical-bars-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-vertical-bars-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') @@ -625,6 +600,23 @@ test('verticalBars toolbar sources are hidden before ownership and restored with await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(source).toHaveCSS('visibility', '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-vertical-bars-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-vertical-bars-back-web-owned/); + await lateBack.evaluate((element) => element.closest('ion-header')?.remove()); + 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'); diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index ed4338d1..04d4a0e6 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -17,6 +17,11 @@ test('Vertical Control Area works with Ionic CSS and no iOS 27 theme', async ({ }); await page.addStyleTag({ content: verticalBars }); expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + await backSource.evaluate((element: HTMLIonBackButtonElement) => { + element.text = 'Return'; + element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + }); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const tabBar = page.locator('#tab-bar-bottom'); @@ -34,6 +39,8 @@ test('Vertical Control Area works with Ionic CSS and no iOS 27 theme', async ({ const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); await expect(back).toBeVisible(); + await expect(backSource).toBeHidden(); + expect(await back.evaluate((element: HTMLIonBackButtonElement) => element.text)).toBe(''); await back.click(); await expect(page).toHaveURL(/\/main\/index$/); }); diff --git a/src/native/components/ion-back-button.ts b/src/native/components/ion-back-button.ts index 496c0133..111f7ee4 100644 --- a/src/native/components/ion-back-button.ts +++ b/src/native/components/ion-back-button.ts @@ -1,12 +1,18 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { inFixedToolbar } from '../shared/dom'; +import { inFixedToolbar, isVerticalBarsSource } from '../shared/dom'; export const tag = 'ion-back-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonBackButtonElement; - if (!inFixedToolbar(element) || button.icon !== undefined || button.color !== undefined || !button.shadowRoot) return; + if ( + !button.shadowRoot || + button.icon !== undefined || + button.color !== undefined || + (!isVerticalBarsSource(element) && !inFixedToolbar(element)) + ) + return; const candidate = createCandidate(element, tag, id); const label = button.shadowRoot.querySelector('[part="text"]')?.textContent?.trim() ?? ''; return appendItem(candidate, element, id, button.shadowRoot, label) ? candidate : undefined; diff --git a/src/native/prehide.ts b/src/native/prehide.ts index 92918a6b..b61781fb 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -19,6 +19,11 @@ const backSupported = (element: HTMLElement): boolean => { const back = element as HTMLIonBackButtonElement; return back.icon === undefined && back.color === undefined && !!back.shadowRoot; }; +const eligibleBack = (element: HTMLElement): boolean => + !element.closest('ion-buttons.ios-theme-horizontal-only') && + !isPermanentlyExcluded(element) && + !isShellDisabled(element) && + !element.closest(overlays); const eligible = (element: HTMLElement): boolean => inFixedToolbar(element) && !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') && @@ -78,7 +83,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () if (owned.has(element)) return; if ( element.matches('ion-back-button') && - eligible(element) && + eligibleBack(element) && (element as HTMLIonBackButtonElement).icon === undefined && (element as HTMLIonBackButtonElement).color === undefined && !element.shadowRoot && @@ -103,7 +108,9 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () } place( element, - eligible(element) && (element.matches('ion-back-button') ? backSupported(element) : isVerticalBarsToolbarActionShape(element)), + element.matches('ion-back-button') + ? eligibleBack(element) && backSupported(element) + : eligible(element) && isVerticalBarsToolbarActionShape(element), ); } }); diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index bab472a8..337d0963 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -83,7 +83,14 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI ); }; const isEligibleBack = (element: HTMLIonBackButtonElement) => - inEligibleToolbar(element) && unprojected(projectedSources(), () => isRendered(element)); + !!verticalBarsRoot()?.contains(element) && + element.matches('.ios') && + !element.closest('ion-buttons.ios-theme-horizontal-only') && + !isExcluded(element, verticalBarsEnteringPage(element)) && + !isShellDisabled(element) && + !verticalBarsPages.isDeparted(element) && + !element.closest('ion-menu, ion-modal, ion-popover') && + unprojected(projectedSources(), () => isRendered(element)); 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( diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index a205ced9..51bef3ce 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -191,8 +191,6 @@ // Hide an entering back source until JS decides whether it belongs in the rail. html.ios-theme-native-ui-shell-prehide :is(ion-app, body).ios-theme-vertical-bars - :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], @@ -201,7 +199,18 @@ html.ios-theme-native-ui-shell-prehide .ios-theme-disabled, .ios26-disabled, .ios-theme-vertical-bars-back-button-projection - ):not(:where(.ios-theme-shell-disabled *, .ionic-theme-disabled *, .ios-theme-disabled *, .ios26-disabled *)) { + ):not( + :where( + ion-menu *, + ion-modal *, + ion-popover *, + ion-buttons.ios-theme-horizontal-only *, + .ios-theme-shell-disabled *, + .ionic-theme-disabled *, + .ios-theme-disabled *, + .ios26-disabled * + ) + ) { position: absolute !important; visibility: hidden !important; } From 54c7df81a3c44c4fd9f613ffb2253994fbfc6c47 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 19:50:31 +0900 Subject: [PATCH 04/17] fix(vertical-bars): align back selection across Web and native --- demo/e2e/native-ui-shell.spec.ts | 63 +++++++++++++++++++-- demo/e2e/vertical-bars-back-button.spec.ts | 15 ++++- demo/e2e/vertical-bars-standalone.spec.ts | 3 + demo/src/app/docs/docs-content.generated.ts | 2 +- docs/native-ui-shell.md | 4 +- docs/special-markup.md | 2 +- src/native/components/index.ts | 7 ++- src/native/prehide.ts | 18 +++++- src/native/runtime.ts | 19 ++++++- src/native/shared/dom.ts | 25 ++++++++ src/native/vertical-bars-web.ts | 12 ++-- src/styles/vertical-bars.scss | 4 +- 12 files changed, 150 insertions(+), 24 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index a35aa553..4cb7e991 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -366,11 +366,14 @@ test('verticalBars tabs request native adaptive rail placement', async ({ page } test('standalone Vertical Control Area never snapshots ordinary Native UI Shell controls', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); - const back = page.locator('app-native-ui-shell ion-back-button'); - await back.evaluate((element: HTMLIonBackButtonElement) => { + await page.locator('app-native-ui-shell ion-back-button').evaluate((element: HTMLIonBackButtonElement) => { element.text = 'Return'; - element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + element.mode = 'md'; + element.classList.remove('ios'); + element.classList.add('md'); + element.closest('ion-app')?.append(element); }); + const back = page.locator('ion-app > ion-back-button'); await page.evaluate(() => { for (const sheet of Array.from(document.styleSheets)) { for (let index = sheet.cssRules.length - 1; index >= 0; index--) { @@ -400,7 +403,37 @@ test('standalone Vertical Control Area never snapshots ordinary Native UI Shell expect(state.metricsRequested).toBe(0); expect(state.updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars')).toBe(true); await expect(back).toHaveClass(/ios-theme-native-ui-shell-prehidden/); - expect(state.updates.at(-1).controls.find((control: any) => control.kind === 'ion-back-button')?.items[0]?.label).toBe('Return'); + await expect + .poll(() => + page.evaluate( + () => + (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-back-button')?.items[0] + ?.label, + ), + ) + .toBe('Return'); + await expect(back).toHaveClass(/\bmd\b/); + await page.locator('app-native-ui-shell ion-content').evaluate((content) => { + const pageBack = document.createElement('ion-back-button'); + pageBack.setAttribute('default-href', '/main/index'); + pageBack.setAttribute('data-page-back', ''); + content.prepend(pageBack); + }); + const pageBack = page.locator('ion-back-button[data-page-back]'); + await expect(pageBack).toHaveAttribute('data-native-ui-shell', ''); + await expect(back).not.toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate( + () => (window as any).__nativeUIShell.updates.at(-1)?.controls.filter((control: any) => control.kind === 'ion-back-button').length, + ), + ) + .toBe(1); + await page + .locator('app-native-ui-shell') + .evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); + await expect(back).toHaveClass(/ios-theme-native-ui-shell-prehidden/); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); await app.evaluate((element) => element.style.setProperty('--ion-background-color-rgb', '255, 255, 255')); await expect.poll(() => allVerticalBarsDark(false)).toBe(true); }); @@ -409,13 +442,30 @@ test('verticalBars back navigation and toolbar slots request native rail placeme await mockNative(page); await page.goto('/main/index/native-ui-shell'); const app = page.locator('ion-app'); - const source = page.locator('app-native-ui-shell ion-back-button'); + const source = page.locator('app-native-ui-shell ion-back-button').first(); const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await source.evaluate((element) => { + const second = document.createElement('ion-back-button'); + second.setAttribute('default-href', '/main/index'); + second.setAttribute('data-second-back', ''); + element.closest('ion-toolbar')?.append(second); + }); + const secondBack = page.locator('ion-back-button[data-second-back]'); + await expect(secondBack).toHaveClass(/hydrated/); await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars')); await expect(projection).toHaveCount(0); await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect(secondBack).toBeVisible(); + await expect(secondBack).not.toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate( + () => (window as any).__nativeUIShell.updates.at(-1)?.controls.filter((control: any) => control.kind === 'ion-back-button').length, + ), + ) + .toBe(1); await expect .poll(() => page.evaluate( @@ -595,7 +645,8 @@ test('verticalBars toolbar sources are hidden before ownership and restored with 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 expect(source).toHaveAttribute('data-native-ui-shell-vertical-bars-member', ''); + await expect(source).toHaveClass(/ios-theme-native-ui-shell-prehidden/); await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(source).toHaveCSS('visibility', 'visible'); diff --git a/demo/e2e/vertical-bars-back-button.spec.ts b/demo/e2e/vertical-bars-back-button.spec.ts index bbe0ab60..2044dc95 100644 --- a/demo/e2e/vertical-bars-back-button.spec.ts +++ b/demo/e2e/vertical-bars-back-button.spec.ts @@ -127,7 +127,7 @@ test('turning verticalBars on during a transition honors its success or cancella await expect(projection).toHaveCount(0); }); -test('verticalBars projection respects shell opt-out and iOS mode', async ({ page }) => { +test('verticalBars back projection respects source opt-out regardless of Ionic mode', async ({ page }) => { await page.goto('/main/index/button'); const app = page.locator('ion-app'); const source = page.locator('app-button ion-header ion-back-button').first(); @@ -139,10 +139,19 @@ test('verticalBars projection respects shell opt-out and iOS mode', async ({ pag await expect(projection).toHaveCount(0); await expect(source).toBeVisible(); - await source.evaluate((element) => element.classList.remove('ios-theme-shell-disabled', 'ios')); + await source.evaluate((element) => { + element.classList.remove('ios-theme-shell-disabled'); + (element as HTMLIonBackButtonElement).mode = 'md'; + }); await toolbar.evaluate((element) => element.classList.remove('ios')); - await expect(projection).toHaveCount(0); await expect(source).toBeVisible(); + await page + .locator('app-button.ion-page') + .evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true }))); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + expect(await projection.evaluate((element: HTMLIonBackButtonElement) => element.mode)).toBe('md'); + await expect(projection).toHaveCSS('position', 'fixed'); }); test('verticalBars toolbar projects icon actions and preserves text-only actions', async ({ page }) => { diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index 04d4a0e6..c921d49c 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -41,6 +41,9 @@ test('Vertical Control Area works with Ionic CSS and no iOS 27 theme', async ({ await expect(back).toBeVisible(); await expect(backSource).toBeHidden(); expect(await back.evaluate((element: HTMLIonBackButtonElement) => element.text)).toBe(''); + await backSource.evaluate((element) => element.closest('ion-app')?.append(element)); + await expect(page.locator('ion-app > ion-back-button:not(.ios-theme-vertical-bars-back-button-projection)')).toBeHidden(); + await expect(back).toBeVisible(); await back.click(); await expect(page).toHaveURL(/\/main\/index$/); }); diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 6f891dd8..9772f1c7 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Back navigation can come from outside a fixed toolbar and does not require an ios mode class; the application chooses where to enable Vertical Bars and which Ionic component mode to use. The other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/native-ui-shell.md b/docs/native-ui-shell.md index afab10c1..0af928c3 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -50,7 +50,7 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` | | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group | -Only iOS-mode components with the theme variables installed are eligible. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. +For the ordinary Native UI Shell, only iOS-mode components with the theme variables installed are eligible. Vertical Bars back navigation is the exception described below. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while keeping the Web theme. It excludes the element and all its descendants. Adding or removing the class at runtime automatically restores Web rendering or re-evaluates native eligibility. @@ -62,7 +62,7 @@ Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while kee If a child inside a shared native surface opts out, the entire surface stays on the Web: this includes button groups, tab bars, segments and FAB lists. Opting out of the search FAB or any part of the search footer disables native search integration; the tab bar can still render natively if it remains eligible. -Placement is required even when the appearance is glass. Buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. +Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; the projection does not require the button's `ios` mode class. Overlays, collapsed headers, opted-out controls and departed pages are excluded. Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip. diff --git a/docs/special-markup.md b/docs/special-markup.md index 2a0d1945..dbb4a0f0 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -77,7 +77,7 @@ These values are web-layout simulation inputs. They are independent from Ionic's When the app contains `ion-tabs`, this 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 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; this mode does not convert tabs into a menu. -On supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. Back navigation can come from outside a fixed toolbar and does not require an `ios` mode class; the application chooses where to enable Vertical Bars and which Ionic component mode to use. The other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 4ffac4e0..903917d6 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -31,7 +31,12 @@ export const isVerticalBarsCandidate = isVerticalBarsSource; export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { const verticalBars = isVerticalBarsCandidate(element); - if (!element.classList.contains('ios') || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover')) return; + if ( + (!element.classList.contains('ios') && !(verticalBars && element.matches('ion-back-button'))) || + !visible(element, verticalBars) || + element.closest('ion-modal, ion-popover') + ) + return; const style = getComputedStyle(element); if ( !isDisabledButtonGroupChild(element) && diff --git a/src/native/prehide.ts b/src/native/prehide.ts index b61781fb..da99fbd6 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -6,6 +6,7 @@ import { inFixedToolbar, isVerticalBarsToolbarActionShape, isPermanentlyExcluded, + isVerticalBarsBackPosition, isShellDisabled, prehiddenClass, prehideRootClass, @@ -20,6 +21,7 @@ const backSupported = (element: HTMLElement): boolean => { return back.icon === undefined && back.color === undefined && !!back.shadowRoot; }; const eligibleBack = (element: HTMLElement): boolean => + isVerticalBarsBackPosition(element) && !element.closest('ion-buttons.ios-theme-horizontal-only') && !isPermanentlyExcluded(element) && !isShellDisabled(element) && @@ -35,6 +37,7 @@ const eligible = (element: HTMLElement): boolean => export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () => () => void; stop: () => void } => { doc.documentElement.classList.add(prehideRootClass); const scopes = new Map>(); + const owner = new WeakMap(); const pendingBacks = new Map }>(); const listeners = new AbortController(); const root = () => doc.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); @@ -50,6 +53,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () const owned = scopes.get(scope); if (!owned) return; owned.forEach((element) => { + if (owner.get(element) !== scope) return; + owner.delete(element); element.classList.remove(prehiddenClass); element.classList.remove(verticalBarsBackWebClass); clearVerticalBarsPlacement(element); @@ -61,11 +66,15 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () const owned = scopes.get(scope) ?? new Set(); const place = (element: HTMLElement, rail: boolean) => { if (owned.has(element)) return; + const previousScope = owner.get(element); + if (previousScope) scopes.get(previousScope)?.delete(element); + owner.set(element, scope); setVerticalBarsPlacement(element, rail); if (element.matches('ion-back-button')) element.classList.toggle(verticalBarsBackWebClass, !rail); owned.add(element); }; - scope.querySelectorAll(sourceSelector).forEach((element) => { + const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll(sourceSelector)); + sources.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); @@ -89,6 +98,11 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () !element.shadowRoot && !element.classList.contains('hydrated') ) { + const previousPending = pendingBacks.get(element); + if (previousPending && previousPending.scope !== scope) { + clearTimeout(previousPending.timer); + pendingBacks.delete(element); + } if (!pendingBacks.has(element)) { const timer = setTimeout(() => { if (pendingBacks.get(element)?.scope !== scope) return; @@ -130,7 +144,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () // Root toolbars can mount after startup; each new DOM identity is captured once. verticalBars.querySelectorAll(sourceSelector).forEach((element) => { if (routedPage(element) || element.closest(overlays)) return; - const scope = element.closest('ion-toolbar'); + const scope = element.closest('ion-toolbar') ?? (element.matches('ion-back-button') ? element : null); if (scope) capture(scope); }); for (const owned of scopes.values()) { diff --git a/src/native/runtime.ts b/src/native/runtime.ts index c79d42ae..2ba42cfa 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -15,6 +15,7 @@ import { activateProjectedElement, createVerticalBarsPageState, isVerticalBarsSource, + preferredVerticalBarsBack, marker, prehideOnlyMutation, rejectedClass, @@ -158,6 +159,12 @@ export const createRuntime = async ( }; const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { + if ( + element.matches('ion-back-button') && + element.closest(':is(ion-app, body).ios-theme-vertical-bars') && + !isVerticalBarsCandidate(element) + ) + return; if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; let candidate: Candidate | undefined; @@ -238,7 +245,17 @@ export const createRuntime = async ( ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); - return menuOpen ? candidates.filter((candidate) => isVerticalBarsCandidate(candidate.element)) : candidates; + const back = preferredVerticalBarsBack( + candidates + .filter((candidate) => candidate.control.kind === 'ion-back-button' && isVerticalBarsCandidate(candidate.element)) + .map((candidate) => candidate.element), + doc, + ); + const selected = candidates.filter( + (candidate) => + candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back, + ); + return menuOpen ? selected.filter((candidate) => isVerticalBarsCandidate(candidate.element)) : selected; }; const observe = () => { const wanted = new Set(); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 5b9abad3..e3166294 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -92,6 +92,31 @@ export const isVerticalBarsSource = (element: HTMLElement): boolean => !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-vertical-bars'); +export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => { + if (element.closest('ion-header[collapse], ion-footer[collapse]')) return false; + const page = element.closest('.ion-page'); + if (!page) return true; + const candidates = Array.from(page.querySelectorAll('ion-back-button')).filter( + (back) => + back.closest('.ion-page') === page && + !back.matches('.ion-cloned-element') && + !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-modal, ion-popover'), + ); + const rank = (back: HTMLElement) => + inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2; + const best = candidates.reduce( + (winner, back) => (!winner || rank(back) < rank(winner) ? back : winner), + undefined, + ); + return best === element; +}; + +export const preferredVerticalBarsBack = (elements: T[], doc: Document): T | undefined => { + const pages = Array.from(doc.querySelectorAll('.ion-page')); + const pageOrder = (element: Element) => pages.indexOf(element.closest('.ion-page')!); + return elements.sort((a, b) => pageOrder(b) - pageOrder(a) || Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')))[0]; +}; + const excludedBy = (element: HTMLElement, selector: string): boolean => { const owner = element.closest(selector); return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element)); diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 337d0963..e7648892 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -8,6 +8,9 @@ import { isExcluded, isVerticalBarsToolbarGroup, isShellDisabled, + isVerticalBarsBackPosition, + preferredVerticalBarsBack, + verticalBarsOwned, marker, prehideOnlyMutation, prehiddenClass, @@ -84,7 +87,8 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI }; const isEligibleBack = (element: HTMLIonBackButtonElement) => !!verticalBarsRoot()?.contains(element) && - element.matches('.ios') && + verticalBarsOwned(element) && + isVerticalBarsBackPosition(element) && !element.closest('ion-buttons.ios-theme-horizontal-only') && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element) && @@ -96,11 +100,7 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI const candidates = Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).filter( isEligibleBack, ); - return candidates.sort((a, b) => { - const order = pageOrder(b) - pageOrder(a); - if (order) return order; - return Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')); - })[0]; + return preferredVerticalBarsBack(candidates, doc); }; const findToolbarGroups = () => { const candidates = Array.from(doc.querySelectorAll(`ion-buttons.ios:not(.${toolbarProjectionClass})`)).flatMap( diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 51bef3ce..c167b695 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -204,6 +204,8 @@ html.ios-theme-native-ui-shell-prehide ion-menu *, ion-modal *, ion-popover *, + ion-header[collapse] *, + ion-footer[collapse] *, ion-buttons.ios-theme-horizontal-only *, .ios-theme-shell-disabled *, .ionic-theme-disabled *, @@ -235,7 +237,7 @@ html.ios-theme-native-ui-shell-prehide ) { display: none; } - > ion-back-button.ios.ios-theme-vertical-bars-back-button-projection { + > ion-back-button.ios-theme-vertical-bars-back-button-projection { position: fixed; z-index: 1001; top: var(--ios-theme-vertical-bars-toolbar-top, 220px); From a93c8be8c3d5acc8b9249462d9b85447b639713a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 20:27:13 +0900 Subject: [PATCH 05/17] feat(vertical-bars): support Ionic md mode --- demo/e2e/adaptive-tab-bar.spec.ts | 1 + demo/e2e/vertical-bars-standalone.spec.ts | 82 +++++++++++---------- demo/src/app/app.config.ts | 2 +- demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/global.scss | 1 + docs/native-ui-shell.md | 4 +- docs/special-markup.md | 6 +- src/native/components/index.ts | 6 +- src/native/components/ion-buttons.ts | 4 +- src/native/components/ion-menu-button.ts | 2 +- src/native/shared/dom.ts | 9 +-- src/native/vertical-bars-web.ts | 5 +- src/styles/vertical-bars.scss | 44 ++++++----- 13 files changed, 85 insertions(+), 83 deletions(-) diff --git a/demo/e2e/adaptive-tab-bar.spec.ts b/demo/e2e/adaptive-tab-bar.spec.ts index a53436de..7d3a0d69 100644 --- a/demo/e2e/adaptive-tab-bar.spec.ts +++ b/demo/e2e/adaptive-tab-bar.spec.ts @@ -13,6 +13,7 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while const bar = page.locator('#tab-bar-bottom'); const buttons = bar.locator('ion-tab-button'); + await expect.poll(async () => (await bar.boundingBox())?.x).toBeGreaterThan(620); const barBox = (await bar.boundingBox())!; expect(barBox.x).toBeGreaterThan(620); expect(barBox.width).toBeCloseTo(50, 0); diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index c921d49c..6ead1c63 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -4,46 +4,48 @@ import { compile } from 'sass'; const verticalBars = compile(resolve(__dirname, '../../src/styles/vertical-bars.scss')).css; -test('Vertical Control Area works with Ionic CSS and no iOS 27 theme', async ({ page }) => { - await page.setViewportSize({ width: 700, height: 900 }); - await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); - await page.evaluate(() => { - for (const sheet of Array.from(document.styleSheets)) { - for (let index = sheet.cssRules.length - 1; index >= 0; index--) { - const rule = sheet.cssRules[index]; - if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); +for (const mode of ['ios', 'md'] as const) + test(`Vertical Control Area works in ${mode} mode with Ionic CSS and no iOS 27 theme`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto(`/main/index/native-ui-shell?verticalBarsOnly=1&ionicMode=${mode}`); + await expect(page.locator('ion-app')).toHaveClass(new RegExp(`\\b${mode}\\b`)); + await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index--) { + const rule = sheet.cssRules[index]; + if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); + } } - } - }); - await page.addStyleTag({ content: verticalBars }); - expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); - const backSource = page.locator('app-native-ui-shell ion-back-button'); - await backSource.evaluate((element: HTMLIonBackButtonElement) => { - element.text = 'Return'; - element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); - }); - await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + }); + await page.addStyleTag({ content: verticalBars }); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + await backSource.evaluate((element: HTMLIonBackButtonElement) => { + element.text = 'Return'; + element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + }); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); - const tabBar = page.locator('#tab-bar-bottom'); - await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); - const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); - await expect - .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) - .toBe('0px'); - const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); - const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); - await expect(source).toBeHidden(); - await expect(projection).toBeVisible(); - await projection.click(); - await expect(page.locator('[data-save-count]')).toHaveText('1'); + const tabBar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); + const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); + await expect + .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) + .toBe('0px'); + const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); + const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await projection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); - const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); - await expect(back).toBeVisible(); - await expect(backSource).toBeHidden(); - expect(await back.evaluate((element: HTMLIonBackButtonElement) => element.text)).toBe(''); - await backSource.evaluate((element) => element.closest('ion-app')?.append(element)); - await expect(page.locator('ion-app > ion-back-button:not(.ios-theme-vertical-bars-back-button-projection)')).toBeHidden(); - await expect(back).toBeVisible(); - await back.click(); - await expect(page).toHaveURL(/\/main\/index$/); -}); + const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(back).toBeVisible(); + await expect(backSource).toBeHidden(); + expect(await back.evaluate((element: HTMLIonBackButtonElement) => element.text)).toBe(''); + await backSource.evaluate((element) => element.closest('ion-app')?.append(element)); + await expect(page.locator('ion-app > ion-back-button:not(.ios-theme-vertical-bars-back-button-projection)')).toBeHidden(); + await expect(back).toBeVisible(); + await back.click(); + await expect(page).toHaveURL(/\/main\/index$/); + }); diff --git a/demo/src/app/app.config.ts b/demo/src/app/app.config.ts index de474a42..4d86197f 100644 --- a/demo/src/app/app.config.ts +++ b/demo/src/app/app.config.ts @@ -27,7 +27,7 @@ export const createAppConfig = (animations: IonicAnimationOptions = {}): Applica provideRouter(routes, withComponentInputBinding()), provideIonicAngular({ useSetInputAPI: true, - mode: 'ios', + mode: typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('ionicMode') === 'md' ? 'md' : 'ios', backButtonText: '', animated: !isE2ETesting, ...animations, diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 9772f1c7..6e4d74d6 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Back navigation can come from outside a fixed toolbar and does not require an ios mode class; the application chooses where to enable Vertical Bars and which Ionic component mode to use. The other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/global.scss b/demo/src/global.scss index 8dac0e64..fba628bb 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -37,6 +37,7 @@ @use '@ionic/angular/css/palettes/dark.class.css'; @use 'sass:meta'; +@use '../../src/styles/vertical-bars'; // Adaptive iOS themes: capability checks, not OS version. See README.md. // `overflow-anchor: auto` selects the newer theme; `text-wrap: pretty` without it keeps iOS 26. diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 0af928c3..f434cf35 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -50,7 +50,7 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` | | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group | -For the ordinary Native UI Shell, only iOS-mode components with the theme variables installed are eligible. Vertical Bars back navigation is the exception described below. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. +For the ordinary Native UI Shell, only iOS-mode components with the theme variables installed are eligible. Explicitly enabled Vertical Bars is mode-independent as described below. `ionic-theme-disabled`, `ios-theme-disabled`, and the legacy `ios26-disabled` on an element or ancestor always exclude it. A disabled theme on one tab/segment item keeps its whole group on the Web. Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while keeping the Web theme. It excludes the element and all its descendants. Adding or removing the class at runtime automatically restores Web rendering or re-evaluates native eligibility. @@ -62,7 +62,7 @@ Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while kee If a child inside a shared native surface opts out, the entire surface stays on the Web: this includes button groups, tab bars, segments and FAB lists. Opting out of the search FAB or any part of the search footer disables native search integration; the tab bar can still render natively if it remains eligible. -Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; the projection does not require the button's `ios` mode class. Overlays, collapsed headers, opted-out controls and departed pages are excluded. +Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Overlays, collapsed headers, opted-out controls and departed pages are excluded. Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip. diff --git a/docs/special-markup.md b/docs/special-markup.md index dbb4a0f0..ac2502e4 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -67,6 +67,8 @@ Add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the ... ``` +For example, an app configured with Ionic `mode: 'md'` can use this same `ion-app` class. No component needs to switch to `mode="ios"` for Vertical Bars. + 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-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. 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. @@ -75,9 +77,9 @@ This keeps routers and component backgrounds full-viewport. `ion-content` moves These 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`, this 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 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; this mode does not convert tabs into a menu. +When the app contains `ion-tabs`, this mode moves its 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 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; this mode does not convert tabs into a menu. -On supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. Back navigation can come from outside a fixed toolbar and does not require an `ios` mode class; the application chooses where to enable Vertical Bars and which Ionic component mode to use. The other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. Vertical Bars works with either Ionic `ios` or `md` mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 903917d6..670d865e 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -31,11 +31,7 @@ export const isVerticalBarsCandidate = isVerticalBarsSource; export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { const verticalBars = isVerticalBarsCandidate(element); - if ( - (!element.classList.contains('ios') && !(verticalBars && element.matches('ion-back-button'))) || - !visible(element, verticalBars) || - element.closest('ion-modal, ion-popover') - ) + if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover')) return; const style = getComputedStyle(element); if ( diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index 010b2188..62b307b3 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -18,8 +18,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined !children.length || children.some( (child) => - !child.matches(`${menuButton.tag}.ios`) && - (!child.matches(`ion-button.ios${verticalBars ? '' : '.button-clear'}`) || + !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) && + (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) || !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')), ) ) diff --git a/src/native/components/ion-menu-button.ts b/src/native/components/ion-menu-button.ts index c225aef3..e6296af2 100644 --- a/src/native/components/ion-menu-button.ts +++ b/src/native/components/ion-menu-button.ts @@ -16,7 +16,7 @@ export const append = (candidate: Candidate, button: HTMLIonMenuButtonElement, i export const read = (group: HTMLElement, id: Identify): Candidate | undefined => { if (!inFixedToolbar(group) || !group.matches('ion-buttons') || group.children.length !== 1) return; const button = group.firstElementChild; - if (!button?.matches(`${tag}.ios`)) return; + if (!button?.matches(`${tag}${group.closest(':is(ion-app, body).ios-theme-vertical-bars') ? '' : '.ios'}`)) return; const candidate = createCandidate(group, tag, id); return append(candidate, button as HTMLIonMenuButtonElement, id) ? candidate : undefined; }; diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index e3166294..d949035e 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -82,7 +82,7 @@ const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-di const shellDisabledSelector = '.ios-theme-shell-disabled'; export const isDisabledButtonGroupChild = (element: HTMLElement): boolean => - element.matches('ion-button.ios') && element.parentElement?.matches(disabledButtonGroup) === true; + element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true; const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']); @@ -150,17 +150,14 @@ export const clearVerticalBarsPlacement = (element: HTMLElement): void => { export const verticalBarsOwned = (element: HTMLElement): boolean => verticalBarsPlacement.get(element) === true; export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean => - element.matches('.ios') && - verticalBarsOwned(element) && - !isExcluded(element, verticalBarsEnteringPage(element)) && - !isShellDisabled(element); + verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element); export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] => Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isVerticalBarsToolbarAction(child)); export const isVerticalBarsToolbarGroup = (element: HTMLElement): boolean => { return ( - element.matches('ion-buttons.ios') && + element.matches('ion-buttons') && verticalBarsOwned(element) && !element.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled') && !isShellDisabled(element) diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index e7648892..58e448b6 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -74,8 +74,7 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI const edge = toolbar?.parentElement; return ( !!currentRoot?.contains(element) && - element.matches('.ios') && - !!toolbar?.matches('.ios') && + !!toolbar && !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && !edge.hasAttribute('collapse') && @@ -103,7 +102,7 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI return preferredVerticalBarsBack(candidates, doc); }; const findToolbarGroups = () => { - const candidates = Array.from(doc.querySelectorAll(`ion-buttons.ios:not(.${toolbarProjectionClass})`)).flatMap( + const candidates = Array.from(doc.querySelectorAll(`ion-buttons:not(.${toolbarProjectionClass})`)).flatMap( (group): ToolbarSource[] => { const actions = verticalBarsToolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action))); if (!actions.length) return []; diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index c167b695..e1dd6910 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -23,7 +23,7 @@ // Keep page backgrounds and the router full width; move only their foregrounds. :is(ion-app, body).ios-theme-vertical-bars - ion-content.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -39,7 +39,7 @@ } :is(ion-app, body).ios-theme-vertical-bars - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -55,7 +55,7 @@ } :is(ion-app, body).ios-theme-vertical-bars - ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + ion-fab:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; --ios-theme-vertical-bars-fab-edge-gap: 16px; @@ -86,15 +86,15 @@ // Menus retain their own safe-area handling and full-width animation host. :is(ion-app, body).ios-theme-vertical-bars { - 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-menu:is(.ios, .md).menu-side-end:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), + ion-menu:is(.ios, .md).menu-side-start:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { --ion-safe-area-right: 0px; &::part(container) { margin-right: var(--ios-theme-vertical-bars-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-menu:is(.ios, .md).menu-side-start:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), + ion-menu:is(.ios, .md).menu-side-end:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { --ion-safe-area-left: 0px; &::part(container) { margin-left: var(--ios-theme-vertical-bars-safe-area-left-resolved); @@ -104,7 +104,7 @@ :is(ion-app, body).ios-theme-vertical-bars ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) - > ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { + > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-side-tab-bar-width: 50px; --ios-theme-side-tab-bar-gap: 8px; --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-right-resolved); @@ -218,21 +218,25 @@ html.ios-theme-native-ui-shell-prehide } :is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-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-vertical-bars-back-button-projection) { + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-back-button:is(.ios, .md)[data-native-ui-shell]:not( + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-vertical-bars-back-button-projection + ) { display: none; } - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) - ion-buttons.ios - > :is(ion-button, ion-menu-button).ios[data-native-ui-shell]:not( + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons:is(.ios, .md) + > :is(ion-button, ion-menu-button):is(.ios, .md)[data-native-ui-shell]:not( .ios-theme-disabled, .ios26-disabled, .ios-theme-vertical-bars-toolbar-action ) { display: none; } - ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) - ion-buttons.ios:has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons:is(.ios, .md):has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) ) { display: none; @@ -247,7 +251,7 @@ html.ios-theme-native-ui-shell-prehide height: 46px; margin: 0; } - > .ios.ios-theme-vertical-bars-toolbar-projection { + > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { position: fixed; z-index: 1001; top: calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) + var(--ios-theme-vertical-bars-toolbar-offset, 0px)); @@ -264,11 +268,11 @@ html.ios-theme-native-ui-shell-prehide font-size: 22px; } } - > ion-menu-button.ios.ios-theme-vertical-bars-toolbar-projection { + > ion-menu-button:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { @include api.glass-control-background; border-radius: 50%; } - > ion-button.ios.ios-theme-vertical-bars-toolbar-projection { + > ion-button:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); --border-radius: 50%; @include glass.light-shadow('--box-shadow'); @@ -276,12 +280,12 @@ html.ios-theme-native-ui-shell-prehide @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); } } - > ion-buttons.ios.ios-theme-vertical-bars-toolbar-projection { + > ion-buttons:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { display: flex; flex-direction: column; height: auto; min-height: 0; - > :is(ion-button, ion-menu-button).ios.ios-theme-vertical-bars-toolbar-action { + > :is(ion-button, ion-menu-button):is(.ios, .md).ios-theme-vertical-bars-toolbar-action { display: block; width: 44px; min-width: 44px; From 5124c39e552d1a38213c1f8194b28966fc43c4e7 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 20:34:16 +0900 Subject: [PATCH 06/17] docs(vertical-bars): show app-owned platform opt-in --- demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/main.ts | 2 +- docs/special-markup.md | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 6e4d74d6..dd019f57 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\nvoid enableVerticalControlArea();
\n\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  void enableVerticalControlArea();\n}
\n\n

The platform.is('ios') guard is an application choice, not an Ionic mode requirement. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/main.ts b/demo/src/main.ts index 4ae64eaf..cbb25989 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -20,7 +20,7 @@ function loadIOSAnimations(): IonicAnimationOptions { }; } -// Demo forces mode: 'ios' (including Playwright), so do not gate on isPlatform('ios'). +// Keep the Web fallback available in the demo; applications can choose when to enable it. bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())).catch((err) => console.error(err)); const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; void startShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); diff --git a/docs/special-markup.md b/docs/special-markup.md index ac2502e4..5d7b206f 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -58,9 +58,14 @@ Load the separate stylesheet and start its projection runtime. The iOS 27 theme ```ts import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars'; -void enableVerticalControlArea(); +// `platform` is the app's injected Ionic Platform instance. +if (platform.is('ios')) { + void enableVerticalControlArea(); +} ``` +The `platform.is('ios')` guard is an application choice, not an Ionic `mode` requirement. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. + Add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the application has no `ion-app` root: ```html From 93ee5afffdffe29b9734530314b7ee46e353d1fd Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 21:22:50 +0900 Subject: [PATCH 07/17] test(vertical-bars): focus standalone coverage on unique contracts --- demo/e2e/adaptive-tab-bar.spec.ts | 1 - demo/e2e/native-ui-shell.spec.ts | 84 ++++------------------- demo/e2e/vertical-bars-standalone.spec.ts | 79 ++++++++++----------- 3 files changed, 49 insertions(+), 115 deletions(-) diff --git a/demo/e2e/adaptive-tab-bar.spec.ts b/demo/e2e/adaptive-tab-bar.spec.ts index 7d3a0d69..06efef2e 100644 --- a/demo/e2e/adaptive-tab-bar.spec.ts +++ b/demo/e2e/adaptive-tab-bar.spec.ts @@ -15,7 +15,6 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while const buttons = bar.locator('ion-tab-button'); await expect.poll(async () => (await bar.boundingBox())?.x).toBeGreaterThan(620); const barBox = (await bar.boundingBox())!; - expect(barBox.x).toBeGreaterThan(620); expect(barBox.width).toBeCloseTo(50, 0); await expect(buttons.first().locator('ion-label')).toHaveCSS('position', 'absolute'); await expect(buttons.nth(1).locator('ion-label')).toHaveCSS('position', 'absolute'); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 4cb7e991..dcfe0dc5 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -17,7 +17,6 @@ const mockNative = async (page: Page, fail = false, verticalBars = true) => { rejectAllSearch: false, rejectControlLabel: '', configuredWith: undefined as any, - metricsRequested: 0, activate: (_event: any) => {}, search: (_event: any) => {}, metrics: (_event: any) => {}, @@ -44,10 +43,7 @@ const mockNative = async (page: Page, fail = false, verticalBars = true) => { state.configuredWith = options; return { supported: true, verticalBars }; } - if (method === 'getWebViewMetrics') { - state.metricsRequested++; - return { radius: 0 }; - } + 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)); @@ -366,76 +362,20 @@ test('verticalBars tabs request native adaptive rail placement', async ({ page } test('standalone Vertical Control Area never snapshots ordinary Native UI Shell controls', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1'); - await page.locator('app-native-ui-shell ion-back-button').evaluate((element: HTMLIonBackButtonElement) => { - element.text = 'Return'; - element.mode = 'md'; - element.classList.remove('ios'); - element.classList.add('md'); - element.closest('ion-app')?.append(element); - }); - const back = page.locator('ion-app > ion-back-button'); - await page.evaluate(() => { - for (const sheet of Array.from(document.styleSheets)) { - for (let index = sheet.cssRules.length - 1; index >= 0; index--) { - const rule = sheet.cssRules[index]; - if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); - } - } - }); - const app = page.locator('ion-app'); - await app.evaluate((element) => { - element.classList.add('ios-theme-vertical-bars'); - element.style.setProperty('--ion-background-color-rgb', '0, 0, 0'); - }); - const allVerticalBarsDark = (expected: boolean) => - page.evaluate((expected) => { - const controls = ((window as any).__nativeUIShell.updates.at(-1)?.controls ?? []).filter( - (control: any) => control.placement === 'vertical-bars', - ); - return controls.length > 0 && controls.every((control: any) => control.dark === expected); - }, expected); - await expect.poll(() => allVerticalBarsDark(true)).toBe(true); - const state = await page.evaluate(() => { - const { configuredWith, metricsRequested, updates } = (window as any).__nativeUIShell; - return { configuredWith, metricsRequested, updates }; - }); - expect(state.configuredWith).toEqual({ verticalBarsOnly: true }); - expect(state.metricsRequested).toBe(0); - expect(state.updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars')).toBe(true); - await expect(back).toHaveClass(/ios-theme-native-ui-shell-prehidden/); - await expect - .poll(() => - page.evaluate( - () => - (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-back-button')?.items[0] - ?.label, - ), - ) - .toBe('Return'); - await expect(back).toHaveClass(/\bmd\b/); - await page.locator('app-native-ui-shell ion-content').evaluate((content) => { - const pageBack = document.createElement('ion-back-button'); - pageBack.setAttribute('default-href', '/main/index'); - pageBack.setAttribute('data-page-back', ''); - content.prepend(pageBack); - }); - const pageBack = page.locator('ion-back-button[data-page-back]'); - await expect(pageBack).toHaveAttribute('data-native-ui-shell', ''); - await expect(back).not.toHaveAttribute('data-native-ui-shell', ''); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); await expect .poll(() => - page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1)?.controls.filter((control: any) => control.kind === 'ion-back-button').length, - ), + page.evaluate(() => { + const { configuredWith, updates } = (window as any).__nativeUIShell; + const controls = updates.at(-1)?.controls ?? []; + return ( + configuredWith?.verticalBarsOnly === true && + controls.length > 0 && + updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars') + ); + }), ) - .toBe(1); - await page - .locator('app-native-ui-shell') - .evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true }))); - await expect(back).toHaveClass(/ios-theme-native-ui-shell-prehidden/); - await expect(back).toHaveAttribute('data-native-ui-shell', ''); - await app.evaluate((element) => element.style.setProperty('--ion-background-color-rgb', '255, 255, 255')); - await expect.poll(() => allVerticalBarsDark(false)).toBe(true); + .toBe(true); }); test('verticalBars back navigation and toolbar slots request native rail placement', async ({ page }) => { diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index 6ead1c63..49b09b78 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -4,48 +4,43 @@ import { compile } from 'sass'; const verticalBars = compile(resolve(__dirname, '../../src/styles/vertical-bars.scss')).css; -for (const mode of ['ios', 'md'] as const) - test(`Vertical Control Area works in ${mode} mode with Ionic CSS and no iOS 27 theme`, async ({ page }) => { - await page.setViewportSize({ width: 700, height: 900 }); - await page.goto(`/main/index/native-ui-shell?verticalBarsOnly=1&ionicMode=${mode}`); - await expect(page.locator('ion-app')).toHaveClass(new RegExp(`\\b${mode}\\b`)); - await page.evaluate(() => { - for (const sheet of Array.from(document.styleSheets)) { - for (let index = sheet.cssRules.length - 1; index >= 0; index--) { - const rule = sheet.cssRules[index]; - if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); - } +test('Vertical Control Area works in md mode with Ionic CSS and no iOS 27 theme', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell?verticalBarsOnly=1&ionicMode=md'); + await expect(page.locator('ion-app')).toHaveClass(/\bmd\b/); + await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index--) { + const rule = sheet.cssRules[index]; + if (rule instanceof CSSSupportsRule && rule.cssText.includes('--ios27-color-scheme')) sheet.deleteRule(index); } - }); - await page.addStyleTag({ content: verticalBars }); - expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); - const backSource = page.locator('app-native-ui-shell ion-back-button'); - await backSource.evaluate((element: HTMLIonBackButtonElement) => { - element.text = 'Return'; - element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); - }); - await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + } + }); + await page.addStyleTag({ content: verticalBars }); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ios27-color-scheme').trim())).toBe(''); + const backSource = page.locator('app-native-ui-shell ion-back-button'); + await backSource.evaluate((element: HTMLIonBackButtonElement) => { + element.text = 'Return'; + element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); + }); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); - const tabBar = page.locator('#tab-bar-bottom'); - await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); - const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); - await expect - .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) - .toBe('0px'); - const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); - const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); - await expect(source).toBeHidden(); - await expect(projection).toBeVisible(); - await projection.click(); - await expect(page.locator('[data-save-count]')).toHaveText('1'); + const tabBar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); + const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); + await expect + .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) + .toBe('0px'); + const source = page.locator('app-native-ui-shell ion-button[type="submit"]'); + const projection = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Save"]'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await projection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); - const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); - await expect(back).toBeVisible(); - await expect(backSource).toBeHidden(); - expect(await back.evaluate((element: HTMLIonBackButtonElement) => element.text)).toBe(''); - await backSource.evaluate((element) => element.closest('ion-app')?.append(element)); - await expect(page.locator('ion-app > ion-back-button:not(.ios-theme-vertical-bars-back-button-projection)')).toBeHidden(); - await expect(back).toBeVisible(); - await back.click(); - await expect(page).toHaveURL(/\/main\/index$/); - }); + const back = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(back).toBeVisible(); + await expect(backSource).toBeHidden(); + await back.click(); + await expect(page).toHaveURL(/\/main\/index$/); +}); From 5aa04f1c6f3e650818bbfd4dc15c5985adb2b129 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 24 Sep 2026 23:17:25 +0900 Subject: [PATCH 08/17] feat(vertical-bars): expose native placement with web fallback --- demo/e2e/adaptive-tab-bar.spec.ts | 10 +++ .../vertical-bars-left.png | Bin 0 -> 4309 bytes demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/app/index/index-page.component.ts | 3 +- docs/special-markup.md | 24 ++++-- .../Components/ShellVerticalBars.swift | 24 ++++-- .../IonicNativeUIShellPlugin.swift | 63 +++++++++++++-- .../Shared/ShellSnapshot.swift | 2 + src/native/definitions.ts | 12 ++- src/native/index.ts | 76 ++++++++++++++++-- src/native/runtime.ts | 12 ++- src/native/vertical-bars-web.ts | 9 ++- src/styles/vertical-bars.scss | 29 ++++++- src/vertical-bars.ts | 9 ++- 14 files changed, 238 insertions(+), 37 deletions(-) create mode 100644 demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png diff --git a/demo/e2e/adaptive-tab-bar.spec.ts b/demo/e2e/adaptive-tab-bar.spec.ts index 06efef2e..c9722edd 100644 --- a/demo/e2e/adaptive-tab-bar.spec.ts +++ b/demo/e2e/adaptive-tab-bar.spec.ts @@ -44,3 +44,13 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while }); expect(overlayDirection).toBe('row'); }); + +test('manual classes place the Web rail on the left in Chrome', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((root) => root.classList.add('ios-theme-vertical-bars', 'ios-theme-vertical-bars-left')); + + const bar = page.locator('#tab-bar-bottom'); + await expect.poll(async () => (await bar.boundingBox())?.x).toBeLessThan(35); + await expect(bar).toHaveScreenshot('vertical-bars-left.png', { animations: 'disabled' }); +}); diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png new file mode 100644 index 0000000000000000000000000000000000000000..d3b4f8dd8e8c79a02ded4b1cef79155d10474410 GIT binary patch literal 4309 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 literal 0 HcmV?d00001 diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index dd019f57..3c8f04f0 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import { enableVerticalControlArea } from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  void enableVerticalControlArea();\n}
\n\n

The platform.is('ios') guard is an application choice, not an Ionic mode requirement. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

Add .ios-theme-vertical-bars to the active ion-app. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\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-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its 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 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge));\n  rail.setPlacement((await getVerticalBarPlacement()).edge);\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(edge) instead of starting another runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to the active ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 96a9b294..095cdac6 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,6 +19,7 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; +import { setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; interface IComponent { name: string; @@ -101,6 +102,6 @@ export class IndexPageComponent { } changeVerticalBarsMode(event: ToggleCustomEvent) { - this.#document.querySelector('ion-app')?.classList.toggle('ios-theme-vertical-bars', event.detail.checked); + setVerticalControlAreaPlacement(event.detail.checked ? 'right' : null); } } diff --git a/docs/special-markup.md b/docs/special-markup.md index 5d7b206f..c07bdc64 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -56,17 +56,27 @@ Load the separate stylesheet and start its projection runtime. The iOS 27 theme ``` ```ts -import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { + addVerticalBarPlacementListener, + enableVerticalControlArea, + getVerticalBarPlacement, +} from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +// Start Web projection on Chrome too; it remains idle until the class is present. +const rail = await enableVerticalControlArea(); // `platform` is the app's injected Ionic Platform instance. if (platform.is('ios')) { - void enableVerticalControlArea(); + await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge)); + rail.setPlacement((await getVerticalBarPlacement()).edge); } ``` -The `platform.is('ios')` guard is an application choice, not an Ionic `mode` requirement. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. +The `platform.is('ios')` guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. + +The placement listener only reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(edge)` instead of starting another runtime. -Add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the application has no `ion-app` root: +For Chrome development, no native plugin is needed. Add `.ios-theme-vertical-bars` to the active `ion-app` to simulate the right rail, or add `.ios-theme-vertical-bars-left` as well to simulate the left rail. Use `body` only when the application has no `ion-app` root: ```html ... @@ -74,7 +84,7 @@ Add `.ios-theme-vertical-bars` to the active `ion-app`. Use `body` only when the For example, an app configured with Ionic `mode: 'md'` can use this same `ion-app` class. No component needs to switch to `mode="ios"` for Vertical Bars. -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-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. +The class reserves `80px` on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. 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. @@ -82,11 +92,11 @@ This keeps routers and component backgrounds full-viewport. `ion-content` moves These 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`, this mode moves its 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 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; this mode does not convert tabs into a menu. +When the app contains `ion-tabs`, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic `slot` value does not select a different 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; this mode does not convert tabs into a menu. On supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. Vertical Bars works with either Ionic `ios` or `md` mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. +On Web, Android, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. ## Two-line inset list items diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index df6366ce..f41bf8f6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -5,7 +5,7 @@ import UIKit protocol ShellVerticalBarsControlling: AnyObject { var view: UIView { get } func attach(to owner: UIViewController, in parent: UIView) - func apply(_ controls: [ShellControl], rendering: ShellRendering) + func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) func detach() } @@ -243,6 +243,7 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier { @available(iOS 26.0, *) final class ShellVerticalBarsController: ShellVerticalBarsControlling { private final class TransparentHostingController: UIHostingController { + var railEdge = "right" override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() makeFullSizeSurfacesTransparent(in: view) @@ -250,11 +251,14 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private func makeFullSizeSurfacesTransparent(in surface: UIView) { let frame = surface.convert(surface.bounds, to: view) - let railWidth = view.safeAreaInsets.right > 0 ? view.safeAreaInsets.right : 80 + let inset = railEdge == "left" ? view.safeAreaInsets.left : view.safeAreaInsets.right + let railWidth = inset > 0 ? inset : 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 + (railEdge == "left" + ? frame.minX <= 1 && frame.maxX >= railWidth - 1 + : frame.minX <= view.bounds.maxX - railWidth + 1 && frame.maxX >= view.bounds.maxX - 1) // SwiftUI may add an opaque backing behind the rail controls. // Keep that backing clear without touching the glass controls or materials. if coversHost || coversRail { @@ -267,8 +271,12 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private final class RailContainer: UIView { private let railMask = CAShapeLayer() + var railEdge = "right" { didSet { setNeedsLayout() } } - private var railWidth: CGFloat { safeAreaInsets.right > 0 ? safeAreaInsets.right : 80 } + private var railWidth: CGFloat { + let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right + return inset > 0 ? inset : 80 + } override init(frame: CGRect) { super.init(frame: frame) @@ -279,12 +287,12 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { override func layoutSubviews() { super.layoutSubviews() - railMask.path = UIBezierPath(rect: CGRect(x: bounds.maxX - railWidth, y: 0, + railMask.path = UIBezierPath(rect: CGRect(x: railEdge == "left" ? bounds.minX : bounds.maxX - railWidth, y: 0, width: railWidth, height: bounds.height)).cgPath } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - point.x >= bounds.maxX - railWidth && super.point(inside: point, with: event) + (railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth) && super.point(inside: point, with: event) } } @@ -316,7 +324,9 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { controller.didMove(toParent: owner) } - func apply(_ controls: [ShellControl], rendering: ShellRendering) { + func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { + container.railEdge = edge + controller.railEdge = edge model.apply(controls, rendering: rendering) controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light } diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index a9a17d7a..7477ca6d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -8,6 +8,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getVerticalBarPlacement", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) @@ -25,6 +26,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:] private var restoreTopEdge: (() -> Void)? private var observers: [NSObjectProtocol] = [] + private var lastVerticalBarEdge: String? + private var verticalBarPlacementObserved = false + private weak var observedVerticalBarView: UIView? public override func load() { for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification, @@ -59,6 +63,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } else if name == UIDevice.orientationDidChangeNotification { self.notifyWebViewMetricsChange() + self.notifyVerticalBarPlacementChange() } }) } @@ -68,8 +73,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false } self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}") if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() } + if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() } }) } + DispatchQueue.main.async { [weak self] in self?.observeVerticalBarPlacement() } } deinit { @@ -87,6 +94,53 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele notifyListeners("webViewMetricsChange", data: metrics) } + private func verticalBarEdge() -> String? { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView { + let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft + switch webView.traitCollection.verticalBarEdge { + case .leading: return rtl ? "right" : "left" + case .trailing: return rtl ? "left" : "right" + default: return nil + } + } + #endif + return nil + } + + private func verticalBarPlacement() -> JSObject { + if let edge = verticalBarEdge() { return ["edge": edge] } + return ["edge": NSNull()] + } + + private func notifyVerticalBarPlacementChange() { + let edge = verticalBarEdge() + guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge else { return } + verticalBarPlacementObserved = true + lastVerticalBarEdge = edge + notifyListeners("verticalBarPlacementChange", data: verticalBarPlacement()) + } + + private func observeVerticalBarPlacement() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView { + observedVerticalBarView = webView + let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge + _ = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in + self?.notifyVerticalBarPlacementChange() + } + } + #endif + notifyVerticalBarPlacementChange() + } + + @objc func getVerticalBarPlacement(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + self?.observeVerticalBarPlacement() + call.resolve(self?.verticalBarPlacement() ?? ["edge": NSNull()]) + } + } + @objc func getWebViewMetrics(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard #available(iOS 26.0, *) else { @@ -103,6 +157,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele @objc func configure(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in + self?.observeVerticalBarPlacement() // A new JS context starts revision numbering again (live reload / navigation). self?.restoreTopEdge?() self?.restoreTopEdge = nil @@ -110,10 +165,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self?.revision = 0 if #available(iOS 26.0, *) { self?.bridge?.webView?.layoutIfNeeded() - // A vertical control 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 verticalBars = (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 call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect { @@ -121,7 +172,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele effect.isHidden = true self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } } - call.resolve(["supported": true, "verticalBars": verticalBars]) + call.resolve(["supported": true]) } else { call.resolve(["supported": false]) } } @@ -230,7 +281,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) }) self.verticalBars = rail rail.attach(to: owner, in: owner.view) - rail.apply(verticalBars, rendering: self.rendering) + rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right") rail.view.isHidden = false } else { rejectedControls.append(contentsOf: verticalBars.map(\.id)) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 584e4319..76921bfa 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -11,10 +11,12 @@ struct ShellSnapshot: Decodable { let revision: Int let transitionDuration: Double? let viewportWidth: Double + let verticalBarEdge: String? let controls: [ShellControl] var isValid: Bool { revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 && + (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") && Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid) } } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 0f8049a9..571fe6a1 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -39,6 +39,13 @@ export interface NativeUIShellHandle { destroy(): Promise; } +export type VerticalBarEdge = 'left' | 'right' | null; + +export interface VerticalControlAreaHandle extends NativeUIShellHandle { + /** Applies the application's chosen placement to both Web and native controls. */ + setPlacement(edge: VerticalBarEdge): void; +} + export interface NativeUIShellSuspension { /** Releases this suspension. Native projection resumes after all active suspensions are released. */ resume(): Promise; @@ -122,6 +129,7 @@ export interface ShellSnapshot { revision: number; transitionDuration?: number; viewportWidth: number; + verticalBarEdge?: Exclude; controls: ShellControl[]; } @@ -137,11 +145,13 @@ export interface WebViewMetrics { } export interface NativeUIShellPlugin { - configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean; verticalBars?: boolean }>; + configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; + getVerticalBarPlacement(): Promise<{ edge: VerticalBarEdge }>; getWebViewMetrics(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; + addListener(name: 'verticalBarPlacementChange', listener: (event: { edge: VerticalBarEdge }) => void): Promise; } diff --git a/src/native/index.ts b/src/native/index.ts index a5b81567..59fd0862 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,6 +1,13 @@ import { Capacitor, registerPlugin } from '@capacitor/core'; import { setConfig } from '../transition/ios.transition'; -import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, WebViewMetrics } from './definitions'; +import type { + NativeUIShellHandle, + NativeUIShellOptions, + NativeUIShellPlugin, + VerticalBarEdge, + VerticalControlAreaHandle, + WebViewMetrics, +} from './definitions'; import { bindMetricsLifecycle } from './lifecycle'; import { createRuntime } from './runtime'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; @@ -12,6 +19,8 @@ export type { NativeUIShellOptions, NativeUIShellStatus, NativeUIShellSuspension, + VerticalBarEdge, + VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; @@ -51,9 +60,34 @@ export const configureNativeTransition = async (): Promise => { return metrics; }; +/** Reads the system's current vertical-bar placement without changing the theme. */ +export const getVerticalBarPlacement = (): Promise<{ edge: VerticalBarEdge }> => + typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? plugin.getVerticalBarPlacement() : Promise.resolve({ edge: null }); + +/** Observes placement; the application decides whether to apply each change. */ +export const addVerticalBarPlacementListener = (listener: (placement: { edge: VerticalBarEdge }) => void) => + typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' + ? plugin.addListener('verticalBarPlacementChange', listener) + : Promise.resolve({ remove: async () => {} }); + +/** Applies one placement to the CSS layout and both Web/native projections. */ +export const setVerticalControlAreaPlacement = (edge: VerticalBarEdge): void => { + if (typeof document === 'undefined') return; + const root = document.querySelector('ion-app') ?? document.body; + root.classList.toggle('ios-theme-vertical-bars', edge !== null); + root.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); +}; + /** Call once at application startup. Ionic markup remains the source of truth. */ -export const enableVerticalControlArea = (): Promise => - enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true }); +export const enableVerticalControlArea = async (): Promise => { + const handle = await enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true }); + return { + getStatus: () => handle.getStatus(), + suspend: () => handle.suspend(), + destroy: () => handle.destroy(), + setPlacement: setVerticalControlAreaPlacement, + }; +}; /** Call once at application startup. Ionic markup remains the source of truth. */ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise => { @@ -76,14 +110,28 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise if (Capacitor.getPlatform() !== 'ios') return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires Capacitor iOS'), stopPrehide); let runtime: NativeUIShellHandle | undefined; + let placementListener: Awaited> | undefined; try { if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) { return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires iOS 26 or later'), stopPrehide); } - runtime = await createRuntime(document, plugin, options, capabilities.verticalBars === true, options.verticalBarsOnly === true); - if (capabilities.verticalBars !== true) runtime = combine(runtime, createVerticalBarsWebProjection(document, options)); + let nativeEdge: VerticalBarEdge = null; + const nativeVerticalBars = () => { + const root = document.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + return nativeEdge !== null && !!root && nativeEdge === (root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'); + }; + placementListener = await addVerticalBarPlacementListener(({ edge }) => { + nativeEdge = edge; + document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); + }); + nativeEdge = (await getVerticalBarPlacement()).edge; + runtime = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); + runtime = combine( + runtime, + createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars()), + ); if (!options.verticalBarsOnly) runtime = await bindMetricsLifecycle( runtime, @@ -92,9 +140,10 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise active = undefined; }, ); - return resetOnDestroy(runtime, stopPrehide); + return resetOnDestroy(withPlacementListener(runtime, placementListener), stopPrehide); } catch (error) { await runtime?.destroy(); + await placementListener?.remove().catch(() => {}); return resetOnDestroy( withReason(createVerticalBarsWebProjection(document, options), error instanceof Error ? error.message : String(error)), stopPrehide, @@ -103,6 +152,21 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise })()); }; +const withPlacementListener = ( + handle: NativeUIShellHandle, + listener: Awaited>, +): NativeUIShellHandle => ({ + getStatus: () => handle.getStatus(), + suspend: () => handle.suspend(), + async destroy() { + try { + await handle.destroy(); + } finally { + await listener.remove().catch(() => {}); + } + }, +}); + const resetOnDestroy = ( handle: NativeUIShellHandle, prehide?: ReturnType, diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 2ba42cfa..11c8fc29 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -42,7 +42,7 @@ export const createRuntime = async ( doc: Document, plugin: NativeUIShellPlugin, options: NativeUIShellOptions = {}, - nativeVerticalBars = true, + nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, ): Promise => { const win = doc.defaultView!; @@ -184,7 +184,7 @@ export const createRuntime = async ( } } else candidate = readCandidate(element, id); if (candidate && isVerticalBarsCandidate(element)) { - if (!nativeVerticalBars) return undefined; + if (!nativeVerticalBars()) return undefined; candidate.control.placement = 'vertical-bars'; 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'); @@ -329,7 +329,13 @@ export const createRuntime = async ( if (stopped || dirty) return; } } - const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) }; + const root = doc.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + const verticalBarEdge: 'left' | 'right' | undefined = root + ? root.classList.contains('ios-theme-vertical-bars-left') + ? 'left' + : 'right' + : undefined; + const data = { viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control) }; const serialized = JSON.stringify(data); if (serialized === lastSnapshot && !forceRefresh) return; const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 58e448b6..dddfc9c2 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -35,7 +35,11 @@ interface ToolbarSource { actions: HTMLElement[]; } -export const createVerticalBarsWebProjection = (doc: Document, options: NativeUIShellOptions): NativeUIShellHandle => { +export const createVerticalBarsWebProjection = ( + doc: Document, + options: NativeUIShellOptions, + enabled: () => boolean = () => true, +): NativeUIShellHandle => { const win = doc.defaultView!; if (options.controls !== undefined && options.controls.toolbar !== true) return { @@ -275,7 +279,7 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI attributeFilter: observingVerticalBars ? undefined : ['class'], }); } - if (stopped || suspended || !currentRoot) return restore(); + if (stopped || suspended || !currentRoot || !enabled()) return restore(); const nextBack = findBack(); const groups = findToolbarGroups(); if (!nextBack && !groups.length) return restore(); @@ -347,6 +351,7 @@ export const createVerticalBarsWebProjection = (doc: Document, options: NativeUI doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, pageLifecycle, { capture: true, signal: listeners.signal }); for (const name of ['ionModalWillPresent', 'ionModalDidDismiss']) doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal }); + win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal }); if (options.controls === undefined || options.controls.toolbar === true) schedule(); return { diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index e1dd6910..7f163821 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -5,7 +5,18 @@ // This entry point does not load the iOS 27 theme or change ordinary Ionic UI. :is(ion-app, body).ios-theme-vertical-bars { --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); - --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 80px); + --ios-theme-vertical-bars-safe-area-right-resolved: var( + --ios-theme-vertical-bars-safe-area-right, + max(80px, env(safe-area-inset-right, 0px)) + ); +} + +:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left { + --ios-theme-vertical-bars-safe-area-left-resolved: var( + --ios-theme-vertical-bars-safe-area-left, + max(80px, env(safe-area-inset-left, 0px)) + ); + --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 0px); } // Supply material defaults only to Web projections, never to ordinary controls. @@ -183,6 +194,14 @@ } } +:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left + ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) + > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-left-resolved); + right: auto; + left: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); +} + .ios-theme-native-ui-shell-prehidden { position: absolute !important; visibility: hidden !important; @@ -298,3 +317,11 @@ html.ios-theme-native-ui-shell-prehide } } } + +:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left { + > ion-back-button.ios-theme-vertical-bars-back-button-projection, + > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { + right: auto; + left: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-left-resolved) - 46px) / 2)); + } +} diff --git a/src/vertical-bars.ts b/src/vertical-bars.ts index f6a0ac15..69acac54 100644 --- a/src/vertical-bars.ts +++ b/src/vertical-bars.ts @@ -1,2 +1,7 @@ -export { enableVerticalControlArea } from './native'; -export type { NativeUIShellHandle, NativeUIShellStatus, NativeUIShellSuspension } from './native'; +export { + addVerticalBarPlacementListener, + enableVerticalControlArea, + getVerticalBarPlacement, + setVerticalControlAreaPlacement, +} from './native'; +export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native'; From 739e4db7ec307d8c6273532191c2597eb7191e02 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 02:31:32 +0900 Subject: [PATCH 09/17] fix(vertical-bars): require ion-app root and guard shell configuration --- demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/app/index/index-page.component.ts | 2 +- demo/src/native-ui-shell-lifecycle.spec.ts | 26 +++++++++++++++++ docs/special-markup.md | 6 +++- src/index.ts | 6 ++-- src/native/components/ion-button.ts | 3 +- src/native/components/ion-buttons.ts | 3 +- src/native/components/ion-menu-button.ts | 2 +- src/native/index.ts | 31 ++++++++++++++++----- src/native/prehide.ts | 2 +- src/native/runtime.ts | 10 ++----- src/native/shared/dom.ts | 8 +++--- src/native/vertical-bars-web.ts | 7 ++--- src/styles/vertical-bars.scss | 26 ++++++++--------- src/transition/ios.transition.ts | 2 +- 15 files changed, 88 insertions(+), 48 deletions(-) diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 3c8f04f0..cefd00e9 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge));\n  rail.setPlacement((await getVerticalBarPlacement()).edge);\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(edge) instead of starting another runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to the active ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail. Use body only when the application has no ion-app root:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge));\n  rail.setPlacement((await getVerticalBarPlacement()).edge);\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(edge) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 095cdac6..b61ad180 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -86,7 +86,7 @@ export class IndexPageComponent { readonly #document = inject(DOCUMENT); get verticalBarsModeEnabled() { - return this.#document.querySelector('ion-app')?.classList.contains('ios-theme-vertical-bars') ?? false; + return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars'); } async navigateNativeUiShell() { diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 5dc606d2..5e55a912 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; import type { NativeUIShellHandle } from '../../src/native'; import { bindMetricsLifecycle } from '../../src/native/lifecycle'; +import { enableNativeUIShell, setVerticalControlAreaPlacement } from '../../src/native'; const runtime = (destroy = vi.fn(async () => {})): NativeUIShellHandle => ({ getStatus: () => ({ state: 'native', projected: 1, updates: 1 }), @@ -27,3 +28,28 @@ test('listener removal failure still destroys and deactivates once', async () => expect(destroy).toHaveBeenCalledOnce(); expect(deactivate).toHaveBeenCalledOnce(); }); + +test('placement requires ion-app and clears it when disabled', () => { + document.body.replaceChildren(); + expect(() => setVerticalControlAreaPlacement('right')).toThrow('requires ion-app'); + document.body.innerHTML = ''; + const app = document.querySelector('ion-app')!; + + setVerticalControlAreaPlacement('left'); + expect(app.classList.contains('ios-theme-vertical-bars-left')).toBe(true); + + setVerticalControlAreaPlacement(null); + expect(app.classList.contains('ios-theme-vertical-bars')).toBe(false); + + setVerticalControlAreaPlacement('right'); + expect(app.classList.contains('ios-theme-vertical-bars')).toBe(true); + setVerticalControlAreaPlacement(null); + document.body.replaceChildren(); +}); + +test('a second startup cannot silently replace the active configuration', async () => { + const first = await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true }); + expect(await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true })).toBe(first); + await expect(enableNativeUIShell({ controls: { toolbar: true } })).rejects.toThrow('different controls'); + await first.destroy(); +}); diff --git a/docs/special-markup.md b/docs/special-markup.md index c07bdc64..b380fabd 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -76,12 +76,16 @@ The `platform.is('ios')` guard controls automatic application of native placemen The placement listener only reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(edge)` instead of starting another runtime. -For Chrome development, no native plugin is needed. Add `.ios-theme-vertical-bars` to the active `ion-app` to simulate the right rail, or add `.ios-theme-vertical-bars-left` as well to simulate the left rail. Use `body` only when the application has no `ion-app` root: +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. + +For Chrome development, no native plugin is needed. Add `.ios-theme-vertical-bars` to `ion-app` to simulate the right rail, or add `.ios-theme-vertical-bars-left` as well to simulate the left rail: ```html ... ``` +`setPlacement` requires a mounted `ion-app`. Call it after the app root exists; passing `null` restores the ordinary layout. + For example, an app configured with Ionic `mode: 'md'` can use this same `ion-app` class. No component needs to switch to `mode="ios"` for Vertical Bars. The class reserves `80px` on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. diff --git a/src/index.ts b/src/index.ts index a4e9dd6a..f61ca477 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,12 +14,12 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe const win = targetElement.ownerDocument.defaultView; if (!targetElement.classList.contains('ios') || !win) return undefined; const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)'); - const verticalBarsRoot = targetElement.closest('ion-app') ?? targetElement.ownerDocument.body; + const verticalBarsRoot = targetElement.closest('ion-app'); let effect: registeredEffect | undefined; const update = () => { effect?.destroy(); const isVertical = - verticalBarsRoot.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover'); + !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover'); effect = reducedMotion.matches || isVertical ? undefined @@ -33,7 +33,7 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe update(); reducedMotion.addEventListener('change', update); const placementObserver = new win.MutationObserver(update); - placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] }); + if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] }); return { destroy: () => { reducedMotion.removeEventListener('change', update); diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index 925ea59b..a1f5dc9b 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -6,8 +6,7 @@ export const tag = 'ion-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonButtonElement; - const verticalBars = - !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-vertical-bars'); + const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return; const fill = button.fill ?? 'default'; if ( diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index 62b307b3..8b19cbdf 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -10,8 +10,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined if (!inFixedToolbar(element)) return; let children = Array.from(element.children) as HTMLElement[]; if (children.length === 1) return menuButton.read(element, id); - const verticalBars = - !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest(':is(ion-app, body).ios-theme-vertical-bars'); + const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); if (verticalBars && !isVerticalBarsToolbarGroup(element)) return; if (verticalBars) children = verticalBarsToolbarActions(element); if ( diff --git a/src/native/components/ion-menu-button.ts b/src/native/components/ion-menu-button.ts index e6296af2..94e13908 100644 --- a/src/native/components/ion-menu-button.ts +++ b/src/native/components/ion-menu-button.ts @@ -16,7 +16,7 @@ export const append = (candidate: Candidate, button: HTMLIonMenuButtonElement, i export const read = (group: HTMLElement, id: Identify): Candidate | undefined => { if (!inFixedToolbar(group) || !group.matches('ion-buttons') || group.children.length !== 1) return; const button = group.firstElementChild; - if (!button?.matches(`${tag}${group.closest(':is(ion-app, body).ios-theme-vertical-bars') ? '' : '.ios'}`)) return; + if (!button?.matches(`${tag}${group.closest('ion-app.ios-theme-vertical-bars') ? '' : '.ios'}`)) return; const candidate = createCandidate(group, tag, id); return append(candidate, button as HTMLIonMenuButtonElement, id) ? candidate : undefined; }; diff --git a/src/native/index.ts b/src/native/index.ts index 59fd0862..6bc3dfa5 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -26,6 +26,7 @@ export type { const plugin = registerPlugin('IonicNativeUIShell'); let active: Promise | undefined; +let activeConfiguration: string | undefined; const web = (reason: string): NativeUIShellHandle => ({ getStatus: () => ({ state: 'web', projected: 0, updates: 0, reason }), suspend: async () => ({ resume: async () => {} }), @@ -73,9 +74,10 @@ export const addVerticalBarPlacementListener = (listener: (placement: { edge: Ve /** Applies one placement to the CSS layout and both Web/native projections. */ export const setVerticalControlAreaPlacement = (edge: VerticalBarEdge): void => { if (typeof document === 'undefined') return; - const root = document.querySelector('ion-app') ?? document.body; - root.classList.toggle('ios-theme-vertical-bars', edge !== null); - root.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); + const app = document.querySelector('ion-app'); + if (!app) throw new Error('Vertical Control Area requires ion-app'); + app.classList.toggle('ios-theme-vertical-bars', edge !== null); + app.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); }; /** Call once at application startup. Ionic markup remains the source of truth. */ @@ -94,6 +96,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise if (options.enabled === false) { const current = active; active = undefined; + activeConfiguration = undefined; return current ? current.then(async (handle) => { await handle.destroy(); @@ -102,6 +105,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise : Promise.resolve(web('Disabled')); } if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); + const controls = options.controls; + const configuration = JSON.stringify([ + options.verticalBarsOnly === true, + ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), + ]); + if (active && activeConfiguration !== configuration) + return Promise.reject( + new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'), + ); + activeConfiguration = configuration; const stopPrehide = !active && (options.controls === undefined || options.controls.toolbar === true) ? prehideVerticalBarsToolbarSources(document) @@ -119,7 +132,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise } let nativeEdge: VerticalBarEdge = null; const nativeVerticalBars = () => { - const root = document.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + const root = document.querySelector('ion-app.ios-theme-vertical-bars'); return nativeEdge !== null && !!root && nativeEdge === (root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'); }; placementListener = await addVerticalBarPlacementListener(({ edge }) => { @@ -183,9 +196,13 @@ const resetOnDestroy = ( }; }, async destroy() { - await handle.destroy(); - prehide?.stop(); - active = undefined; + try { + await handle.destroy(); + } finally { + prehide?.stop(); + active = undefined; + activeConfiguration = undefined; + } }, }); diff --git a/src/native/prehide.ts b/src/native/prehide.ts index da99fbd6..a01c8782 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -40,7 +40,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () const owner = new WeakMap(); const pendingBacks = new Map }>(); const listeners = new AbortController(); - const root = () => doc.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + const root = () => doc.querySelector('ion-app.ios-theme-vertical-bars'); let suspended = 0; let stopped = false; const routedPage = (element: HTMLElement) => element.closest('.ion-page:not(ion-app, body)'); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 11c8fc29..70a7a632 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -159,11 +159,7 @@ export const createRuntime = async ( }; const measuringPointerPages = new WeakSet(); const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { - if ( - element.matches('ion-back-button') && - element.closest(':is(ion-app, body).ios-theme-vertical-bars') && - !isVerticalBarsCandidate(element) - ) + if (element.matches('ion-back-button') && element.closest('ion-app.ios-theme-vertical-bars') && !isVerticalBarsCandidate(element)) return; if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; @@ -329,7 +325,7 @@ export const createRuntime = async ( if (stopped || dirty) return; } } - const root = doc.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + const root = doc.querySelector('ion-app.ios-theme-vertical-bars'); const verticalBarEdge: 'left' | 'right' | undefined = root ? root.classList.contains('ios-theme-vertical-bars-left') ? 'left' @@ -736,7 +732,7 @@ export const createRuntime = async ( await new Promise((resolve) => win.requestAnimationFrame(() => resolve())); return (canceled = false) => { suspended.delete(scopes); - if (canceled || !scopes.some((scope) => scope.closest(':is(ion-app, body).ios-theme-vertical-bars'))) { + if (canceled || !scopes.some((scope) => scope.closest('ion-app.ios-theme-vertical-bars'))) { scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave. if (canceled) { verticalBarsPages.cancel(scopes[0], scopes[1]); // The entering page is abandoned; the leaving page stays active. diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index d949035e..ecd02de1 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -52,14 +52,14 @@ export const setVerticalBarsEnteringPage = (page: HTMLElement, entering: boolean }; export const verticalBarsEnteringPage = (element: HTMLElement): HTMLElement | undefined => { const page = element.closest('.ion-page-invisible'); - return page && enteringPages.has(page) && page.closest(':is(ion-app, body).ios-theme-vertical-bars') ? page : undefined; + return page && enteringPages.has(page) && page.closest('ion-app.ios-theme-vertical-bars') ? page : undefined; }; export const createVerticalBarsPageState = () => { 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-vertical-bars'); + return !!page && departed.has(page) && !!element.closest('ion-app.ios-theme-vertical-bars'); }, lifecycle(event: Event): void { const page = event.target; @@ -90,7 +90,7 @@ export const isVerticalBarsSource = (element: HTMLElement): boolean => verticalBarsTags.has(element.localName) && (element.matches('ion-tab-bar') || verticalBarsOwned(element)) && !element.closest('ion-menu, ion-modal, ion-popover') && - !!element.closest(':is(ion-app, body).ios-theme-vertical-bars'); + !!element.closest('ion-app.ios-theme-vertical-bars'); export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => { if (element.closest('ion-header[collapse], ion-footer[collapse]')) return false; @@ -233,7 +233,7 @@ export const text = (element: Element): string => { export const inFixedToolbar = (element: Element): boolean => { const edge = element.closest('ion-toolbar')?.parentElement; - const verticalBars = !!element.closest(':is(ion-app, body).ios-theme-vertical-bars'); + const verticalBars = !!element.closest('ion-app.ios-theme-vertical-bars'); return ( !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index dddfc9c2..9a93e4bc 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -61,7 +61,7 @@ export const createVerticalBarsWebProjection = ( let sourceObserver: MutationObserver | undefined; let waiters: (() => void)[] = []; const listeners = new AbortController(); - const verticalBarsRoot = () => doc.querySelector(':is(ion-app, body).ios-theme-vertical-bars'); + const verticalBarsRoot = () => doc.querySelector('ion-app.ios-theme-vertical-bars'); const projectedSources = () => [backSource, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter( (source): source is HTMLElement => !!source, @@ -314,13 +314,12 @@ export const createVerticalBarsWebProjection = ( }; const verticalBarsChanged = records.some( (record) => - (record.type === 'attributes' && (record.target as Element).matches('ion-app, body')) || + (record.type === 'attributes' && (record.target as Element).matches('ion-app')) || (record.type === 'childList' && Array.from(record.addedNodes).some( (node) => node instanceof Element && - (node.matches(':is(ion-app, body).ios-theme-vertical-bars') || - !!node.querySelector(':is(ion-app, body).ios-theme-vertical-bars')), + (node.matches('ion-app.ios-theme-vertical-bars') || !!node.querySelector('ion-app.ios-theme-vertical-bars')), )), ); if ( diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 7f163821..6667cae0 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -3,7 +3,7 @@ // iPhone Duo simulation: the system reserves 80pt at the physical right edge. // This entry point does not load the iOS 27 theme or change ordinary Ionic UI. -:is(ion-app, body).ios-theme-vertical-bars { +ion-app.ios-theme-vertical-bars { --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); --ios-theme-vertical-bars-safe-area-right-resolved: var( --ios-theme-vertical-bars-safe-area-right, @@ -11,7 +11,7 @@ ); } -:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left { +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { --ios-theme-vertical-bars-safe-area-left-resolved: var( --ios-theme-vertical-bars-safe-area-left, max(80px, env(safe-area-inset-left, 0px)) @@ -20,7 +20,7 @@ } // Supply material defaults only to Web projections, never to ordinary controls. -:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready > :is(ion-menu-button, ion-button).ios-theme-vertical-bars-toolbar-projection { --ios26-glass-background-rgb: var(--ion-background-color-rgb, 255, 255, 255); --ios26-glass-border-color-rgb: var(--ion-background-color-rgb, 255, 255, 255); @@ -28,12 +28,12 @@ } // The transition shade belongs to the Web page, never to the control area. -:is(ion-app, body).ios-theme-vertical-bars .ios-transition-shade { +ion-app.ios-theme-vertical-bars .ios-transition-shade { clip-path: inset(0 var(--ios-theme-vertical-bars-safe-area-right-resolved) 0 var(--ios-theme-vertical-bars-safe-area-left-resolved)); } // Keep page backgrounds and the router full width; move only their foregrounds. -:is(ion-app, body).ios-theme-vertical-bars +ion-app.ios-theme-vertical-bars ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -49,7 +49,7 @@ } } -:is(ion-app, body).ios-theme-vertical-bars +ion-app.ios-theme-vertical-bars ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -65,7 +65,7 @@ } } -:is(ion-app, body).ios-theme-vertical-bars +ion-app.ios-theme-vertical-bars ion-fab:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -96,7 +96,7 @@ } // Menus retain their own safe-area handling and full-width animation host. -:is(ion-app, body).ios-theme-vertical-bars { +ion-app.ios-theme-vertical-bars { ion-menu:is(.ios, .md).menu-side-end:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled), ion-menu:is(.ios, .md).menu-side-start:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) { --ion-safe-area-right: 0px; @@ -113,7 +113,7 @@ } } -:is(ion-app, body).ios-theme-vertical-bars +ion-app.ios-theme-vertical-bars ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-side-tab-bar-width: 50px; @@ -194,7 +194,7 @@ } } -:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-left-resolved); @@ -209,7 +209,7 @@ // Hide an entering back source until JS decides whether it belongs in the rail. html.ios-theme-native-ui-shell-prehide - :is(ion-app, body).ios-theme-vertical-bars + ion-app.ios-theme-vertical-bars ion-back-button:not( [icon], [color], @@ -236,7 +236,7 @@ html.ios-theme-native-ui-shell-prehide visibility: hidden !important; } -:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) ion-back-button:is(.ios, .md)[data-native-ui-shell]:not( .ios-theme-disabled, @@ -318,7 +318,7 @@ html.ios-theme-native-ui-shell-prehide } } -:is(ion-app, body).ios-theme-vertical-bars.ios-theme-vertical-bars-left { +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { > ion-back-button.ios-theme-vertical-bars-back-button-projection, > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { right: auto; diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts index 538f88e4..652c86de 100644 --- a/src/transition/ios.transition.ts +++ b/src/transition/ios.transition.ts @@ -14,7 +14,7 @@ const transitionConfig = { offLeftPercent: 30, getIonPageElement, connectNativeUIShellTransition, - shouldAnimateFixedBackButton: (navEl: HTMLElement) => !navEl.closest(':is(ion-app, body).ios-theme-vertical-bars'), + shouldAnimateFixedBackButton: (navEl: HTMLElement) => !navEl.closest('ion-app.ios-theme-vertical-bars'), radius: 0, }; From db3fc687c2f71c791679d759fda68e25688b2ee6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 08:23:28 +0900 Subject: [PATCH 10/17] fix(demo): follow Duo vertical bar edge --- demo/src/app/index/index-page.component.ts | 6 +++--- demo/src/main.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index b61ad180..70adabd8 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,7 +19,7 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; -import { setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { getVerticalBarPlacement, setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; interface IComponent { name: string; @@ -101,7 +101,7 @@ export class IndexPageComponent { this.#document.documentElement.classList.toggle('ion-palette-dark', event.detail.checked); } - changeVerticalBarsMode(event: ToggleCustomEvent) { - setVerticalControlAreaPlacement(event.detail.checked ? 'right' : null); + async changeVerticalBarsMode(event: ToggleCustomEvent) { + setVerticalControlAreaPlacement(event.detail.checked ? ((await getVerticalBarPlacement()).edge ?? 'right') : null); } } diff --git a/demo/src/main.ts b/demo/src/main.ts index cbb25989..1ae8790d 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,7 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; -import { enableVerticalControlArea } from '../../src/vertical-bars'; +import { addVerticalBarPlacementListener, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -21,6 +21,12 @@ function loadIOSAnimations(): IonicAnimationOptions { } // Keep the Web fallback available in the demo; applications can choose when to enable it. -bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())).catch((err) => console.error(err)); +void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) + .then(() => + addVerticalBarPlacementListener(({ edge }) => { + if (edge && document.querySelector('ion-app.ios-theme-vertical-bars')) setVerticalControlAreaPlacement(edge); + }), + ) + .catch((err) => console.error(err)); const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; void startShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); From 18ef95c88903843acbeab40345b16a241d45c63b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 08:50:59 +0900 Subject: [PATCH 11/17] fix(vertical-bars): use measured native safe-area inset --- demo/e2e/vertical-bars-standalone.spec.ts | 8 ++++++++ demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/app/index/index-page.component.ts | 4 +++- demo/src/main.ts | 8 ++++++-- demo/src/native-ui-shell-lifecycle.spec.ts | 6 ++++++ docs/special-markup.md | 8 ++++---- .../IonicNativeUIShellPlugin.swift | 17 +++++++++++++---- src/native/definitions.ts | 12 +++++++++--- src/native/index.ts | 17 ++++++++++++----- src/styles/vertical-bars.scss | 4 ++-- 10 files changed, 64 insertions(+), 22 deletions(-) diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index 49b09b78..b09c0241 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -27,6 +27,14 @@ test('Vertical Control Area works in md mode with Ionic CSS and no iOS 27 theme' const tabBar = page.locator('#tab-bar-bottom'); await expect.poll(async () => (await tabBar.boundingBox())?.x).toBeGreaterThan(620); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.style.setProperty('--ios-theme-vertical-bars-native-inset', '64px')); + await expect + .poll(() => + app.evaluate((element) => getComputedStyle(element).getPropertyValue('--ios-theme-vertical-bars-safe-area-right-resolved').trim()), + ) + .toBe('64px'); + await app.evaluate((element) => element.style.removeProperty('--ios-theme-vertical-bars-native-inset')); const toolbar = page.locator('app-native-ui-shell ion-toolbar').first(); await expect .poll(() => toolbar.evaluate((element) => getComputedStyle(element).getPropertyValue('--ion-safe-area-right').trim())) diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index cefd00e9..0230b8f1 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge));\n  rail.setPlacement((await getVerticalBarPlacement()).edge);\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(edge) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener((placement) => rail.setPlacement(placement));\n  rail.setPlacement(await getVerticalBarPlacement());\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. The result includes the physical edge and its UIKit safe-area inset. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(placement) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome to simulate iPhone Duo. When setPlacement receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than 80px. The left modifier moves the reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 70adabd8..be4b48a1 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -102,6 +102,8 @@ export class IndexPageComponent { } async changeVerticalBarsMode(event: ToggleCustomEvent) { - setVerticalControlAreaPlacement(event.detail.checked ? ((await getVerticalBarPlacement()).edge ?? 'right') : null); + if (!event.detail.checked) return setVerticalControlAreaPlacement(null); + const placement = await getVerticalBarPlacement(); + setVerticalControlAreaPlacement(placement.edge ? placement : 'right'); } } diff --git a/demo/src/main.ts b/demo/src/main.ts index 1ae8790d..9c84e261 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -23,8 +23,12 @@ function loadIOSAnimations(): IonicAnimationOptions { // Keep the Web fallback available in the demo; applications can choose when to enable it. void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) .then(() => - addVerticalBarPlacementListener(({ edge }) => { - if (edge && document.querySelector('ion-app.ios-theme-vertical-bars')) setVerticalControlAreaPlacement(edge); + addVerticalBarPlacementListener((placement) => { + const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + if (app) + setVerticalControlAreaPlacement( + placement.edge ? placement : app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right', + ); }), ) .catch((err) => console.error(err)); diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 5e55a912..8d212007 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -37,9 +37,15 @@ test('placement requires ion-app and clears it when disabled', () => { setVerticalControlAreaPlacement('left'); expect(app.classList.contains('ios-theme-vertical-bars-left')).toBe(true); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe(''); + + setVerticalControlAreaPlacement({ edge: 'right', inset: 64 }); + expect(app.classList.contains('ios-theme-vertical-bars-left')).toBe(false); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe('64px'); setVerticalControlAreaPlacement(null); expect(app.classList.contains('ios-theme-vertical-bars')).toBe(false); + expect(app.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')).toBe(''); setVerticalControlAreaPlacement('right'); expect(app.classList.contains('ios-theme-vertical-bars')).toBe(true); diff --git a/docs/special-markup.md b/docs/special-markup.md index b380fabd..c40b26d8 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -67,14 +67,14 @@ const rail = await enableVerticalControlArea(); // `platform` is the app's injected Ionic Platform instance. if (platform.is('ios')) { - await addVerticalBarPlacementListener(({ edge }) => rail.setPlacement(edge)); - rail.setPlacement((await getVerticalBarPlacement()).edge); + await addVerticalBarPlacementListener((placement) => rail.setPlacement(placement)); + rail.setPlacement(await getVerticalBarPlacement()); } ``` The `platform.is('ios')` guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. -The placement listener only reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. Placement is read from the WebView's UIKit trait; projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(edge)` instead of starting another runtime. +The placement listener only reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. The result includes the physical edge and its UIKit safe-area inset. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(placement)` instead of starting another runtime. Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. @@ -88,7 +88,7 @@ For Chrome development, no native plugin is needed. Add `.ios-theme-vertical-bar For example, an app configured with Ionic `mode: 'md'` can use this same `ion-app` class. No component needs to switch to `mode="ios"` for Vertical Bars. -The class reserves `80px` on the physical right in Chrome, matching the system navigation region measured in the iPhone Duo Simulator. On iOS it also respects a larger CSS safe-area inset. The left modifier moves that reservation to the physical left. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. +The class reserves `80px` on the physical right in Chrome to simulate iPhone Duo. When `setPlacement` receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than `80px`. The left modifier moves the reservation to the physical left. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. 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. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 7477ca6d..b3503775 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -27,6 +27,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var restoreTopEdge: (() -> Void)? private var observers: [NSObjectProtocol] = [] private var lastVerticalBarEdge: String? + private var lastVerticalBarInset: CGFloat = 0 private var verticalBarPlacementObserved = false private weak var observedVerticalBarView: UIView? @@ -108,16 +109,24 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele return nil } + private func verticalBarInset(for edge: String?) -> CGFloat { + guard let edge, let webView = bridge?.webView else { return 0 } + webView.layoutIfNeeded() + return edge == "left" ? webView.safeAreaInsets.left : webView.safeAreaInsets.right + } + private func verticalBarPlacement() -> JSObject { - if let edge = verticalBarEdge() { return ["edge": edge] } - return ["edge": NSNull()] + if let edge = verticalBarEdge() { return ["edge": edge, "inset": Double(verticalBarInset(for: edge))] } + return ["edge": NSNull(), "inset": 0] } private func notifyVerticalBarPlacementChange() { let edge = verticalBarEdge() - guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge else { return } + let inset = verticalBarInset(for: edge) + guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge || abs(inset - lastVerticalBarInset) > 0.5 else { return } verticalBarPlacementObserved = true lastVerticalBarEdge = edge + lastVerticalBarInset = inset notifyListeners("verticalBarPlacementChange", data: verticalBarPlacement()) } @@ -137,7 +146,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele @objc func getVerticalBarPlacement(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in self?.observeVerticalBarPlacement() - call.resolve(self?.verticalBarPlacement() ?? ["edge": NSNull()]) + call.resolve(self?.verticalBarPlacement() ?? ["edge": NSNull(), "inset": 0]) } } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 571fe6a1..33ad1612 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -41,9 +41,15 @@ export interface NativeUIShellHandle { export type VerticalBarEdge = 'left' | 'right' | null; +export interface VerticalBarPlacement { + edge: VerticalBarEdge; + /** UIKit safe-area inset on the physical vertical-bar edge, in points. */ + inset: number; +} + export interface VerticalControlAreaHandle extends NativeUIShellHandle { /** Applies the application's chosen placement to both Web and native controls. */ - setPlacement(edge: VerticalBarEdge): void; + setPlacement(placement: VerticalBarEdge | VerticalBarPlacement): void; } export interface NativeUIShellSuspension { @@ -146,12 +152,12 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; - getVerticalBarPlacement(): Promise<{ edge: VerticalBarEdge }>; + getVerticalBarPlacement(): Promise; getWebViewMetrics(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; - addListener(name: 'verticalBarPlacementChange', listener: (event: { edge: VerticalBarEdge }) => void): Promise; + addListener(name: 'verticalBarPlacementChange', listener: (event: VerticalBarPlacement) => void): Promise; } diff --git a/src/native/index.ts b/src/native/index.ts index 6bc3dfa5..edbc9440 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -5,6 +5,7 @@ import type { NativeUIShellOptions, NativeUIShellPlugin, VerticalBarEdge, + VerticalBarPlacement, VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; @@ -20,6 +21,7 @@ export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, + VerticalBarPlacement, VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; @@ -62,22 +64,27 @@ export const configureNativeTransition = async (): Promise => { }; /** Reads the system's current vertical-bar placement without changing the theme. */ -export const getVerticalBarPlacement = (): Promise<{ edge: VerticalBarEdge }> => - typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? plugin.getVerticalBarPlacement() : Promise.resolve({ edge: null }); +export const getVerticalBarPlacement = (): Promise => + typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' + ? plugin.getVerticalBarPlacement() + : Promise.resolve({ edge: null, inset: 0 }); /** Observes placement; the application decides whether to apply each change. */ -export const addVerticalBarPlacementListener = (listener: (placement: { edge: VerticalBarEdge }) => void) => +export const addVerticalBarPlacementListener = (listener: (placement: VerticalBarPlacement) => void) => typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? plugin.addListener('verticalBarPlacementChange', listener) : Promise.resolve({ remove: async () => {} }); /** Applies one placement to the CSS layout and both Web/native projections. */ -export const setVerticalControlAreaPlacement = (edge: VerticalBarEdge): void => { +export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | VerticalBarPlacement): void => { if (typeof document === 'undefined') return; - const app = document.querySelector('ion-app'); + const app = document.querySelector('ion-app'); if (!app) throw new Error('Vertical Control Area requires ion-app'); + const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 }; app.classList.toggle('ios-theme-vertical-bars', edge !== null); app.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); + if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`); + else app.style.removeProperty('--ios-theme-vertical-bars-native-inset'); }; /** Call once at application startup. Ionic markup remains the source of truth. */ diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 6667cae0..2b103682 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -7,14 +7,14 @@ ion-app.ios-theme-vertical-bars { --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); --ios-theme-vertical-bars-safe-area-right-resolved: var( --ios-theme-vertical-bars-safe-area-right, - max(80px, env(safe-area-inset-right, 0px)) + var(--ios-theme-vertical-bars-native-inset, max(80px, env(safe-area-inset-right, 0px))) ); } ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { --ios-theme-vertical-bars-safe-area-left-resolved: var( --ios-theme-vertical-bars-safe-area-left, - max(80px, env(safe-area-inset-left, 0px)) + var(--ios-theme-vertical-bars-native-inset, max(80px, env(safe-area-inset-left, 0px))) ); --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 0px); } From ca99f046bbb771be1cf3c086e084c36143f5b07c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 12:26:47 +0900 Subject: [PATCH 12/17] feat(vertical-bars): unify device layout reporting and Duo hinge support Replace separate placement/metrics getters and listeners with a single device-layout API (getDeviceLayout + deviceLayoutChange) so vertical-bar placement, hinge posture, and WebView corner radius stay synchronized. Monitoring is reference-counted, error paths no longer decrement other callers' counts, and destroy is idempotent. The demo split pane now follows the hinge posture (320px fully open, 50vw partially open) via the `when` attribute, side-menu items use routerLink for tab-consistent navigation, and screenshot coverage guards both widths. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/e2e/screenshot.spec.ts | 18 +++ .../duo-split-menu-flat.png | Bin 0 -> 42983 bytes .../duo-split-menu-half-open.png | Bin 0 -> 42986 bytes demo/src/app/docs/docs-content.generated.ts | 2 +- demo/src/app/index/index-page.component.ts | 6 +- demo/src/app/tabs/tabs.page.html | 10 +- demo/src/app/tabs/tabs.page.scss | 11 ++ demo/src/app/tabs/tabs.page.ts | 65 ++++++++- demo/src/main.ts | 13 +- demo/src/vertical-bars-web.spec.ts | 3 + docs/special-markup.md | 39 +++++- .../IonicNativeUIShellPlugin.swift | 132 +++++++++++++++--- src/native/definitions.ts | 21 ++- src/native/index.ts | 70 +++++----- src/styles/vertical-bars.scss | 12 ++ src/vertical-bars.ts | 10 +- 16 files changed, 319 insertions(+), 93 deletions(-) create mode 100644 demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png create mode 100644 demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index a2ca028e..33e66c3b 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -169,6 +169,24 @@ test.describe('Screenshot Tests - VerticalBars Layout', () => { } }); +test('Settings split-menu widths on iPhone Duo', async ({ page }) => { + await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 951, height: 669 }); + await page.goto('/main/index', { waitUntil: 'networkidle' }); + const splitPane = page.locator('ion-split-pane'); + await splitPane.evaluate((element) => { + element.setAttribute('when', '(min-width: 900px)'); + }); + await expect(splitPane).toHaveClass(/split-pane-visible/); + await page.evaluate(() => document.fonts.ready); + await expect(page).toHaveScreenshot('duo-split-menu-flat.png', { animations: 'disabled' }); + + await splitPane.evaluate((element) => { + element.classList.add('ios-theme-split-pane-half-open'); + }); + await expect(page).toHaveScreenshot('duo-split-menu-half-open.png', { animations: 'disabled' }); +}); + test.describe('Screenshot Tests - VerticalBars Back Button', () => { for (const direction of ['ltr', 'rtl'] as const) { test(`should project the active back button into the physical system rail in ${direction.toUpperCase()}`, async ({ page }) => { diff --git a/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-flat.png new file mode 100644 index 0000000000000000000000000000000000000000..4d2e18720961851a30b5a44cb554b9d252e0318f GIT binary patch literal 42983 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 literal 0 HcmV?d00001 diff --git a/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png b/demo/e2e/screenshot.spec.ts-snapshots/duo-split-menu-half-open.png new file mode 100644 index 0000000000000000000000000000000000000000..487d4e69ea20428c9478d5595e27a059ae5d0153 GIT binary patch literal 42986 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 Nc6dkY%427f{sI3b%tHVG literal 0 HcmV?d00001 diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index 0230b8f1..c16e6d57 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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  addVerticalBarPlacementListener,\n  enableVerticalControlArea,\n  getVerticalBarPlacement,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await addVerticalBarPlacementListener((placement) => rail.setPlacement(placement));\n  rail.setPlacement(await getVerticalBarPlacement());\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The placement listener only reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. The result includes the physical edge and its UIKit safe-area inset. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(placement) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome to simulate iPhone Duo. When setPlacement receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than 80px. The left modifier moves the reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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

Support iPhone Duo

\n

Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are not required:

\n
@use \'@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css\';
\n\n
import {\n  IonicNativeUIShell,\n  enableVerticalControlArea,\n} from \'@rdlabo/ionic-theme-ios27/vertical-bars\';\n\n// Start Web projection on Chrome too; it remains idle until the class is present.\nconst rail = await enableVerticalControlArea();\n\n// `platform` is the app\'s injected Ionic Platform instance.\nif (platform.is(\'ios\')) {\n  await IonicNativeUIShell.startDeviceLayoutMonitoring();\n  const listener = await IonicNativeUIShell.addListener(\'deviceLayoutChange\', ({ placement }) => rail.setPlacement(placement));\n  rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement);\n  // When updates are no longer needed:\n  // await listener.remove();\n  // await IonicNativeUIShell.stopDeviceLayoutMonitoring();\n}
\n\n

The platform.is('ios') guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep mode: 'md' on iOS and still enable Vertical Bars.

\n

The device-layout listener reports what iOS chose; the application decides whether to call setPlacement. Passing null restores the ordinary layout. The placement includes the physical edge and its UIKit safe-area inset. The same event also includes hinge status and WebView corner radius. Call stopDeviceLayoutMonitoring() after removing the listener to stop device-layout events. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full enableNativeUIShell(), use setVerticalControlAreaPlacement(placement) instead of starting another runtime.

\n

Start either enableVerticalControlArea() or the full enableNativeUIShell() once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime.

\n

For Chrome development, no native plugin is needed. Add .ios-theme-vertical-bars to ion-app to simulate the right rail, or add .ios-theme-vertical-bars-left as well to simulate the left rail:

\n
<ion-app class="ios-theme-vertical-bars">...</ion-app>
\n\n

setPlacement requires a mounted ion-app. Call it after the app root exists; passing null restores the ordinary layout.

\n

For example, an app configured with Ionic mode: 'md' can use this same ion-app class. No component needs to switch to mode="ios" for Vertical Bars.

\n

The class reserves 80px on the physical right in Chrome to simulate iPhone Duo. When setPlacement receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than 80px. The left modifier moves the reservation to the physical left. Override --ios-theme-vertical-bars-safe-area-left or --ios-theme-vertical-bars-safe-area-right when simulating a different 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

For a side-by-side menu on iPhone Duo, opt the ion-split-pane into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them:

\n
<ion-split-pane\n  [class.ios-theme-split-pane-half-open]="halfOpened"\n  contentId="main-content"\n  when="(min-width: 900px)"\n>\n  <ion-menu contentId="main-content">...</ion-menu>\n  <div id="main-content">...</div>\n</ion-split-pane>
\n\n

Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value:

\n
ion-split-pane {\n  --ios-theme-menu-width: var(--ios-theme-split-pane-width);\n  --side-width: var(--ios-theme-menu-width);\n  --side-max-width: var(--ios-theme-menu-width);\n  transition: --ios-theme-split-pane-width 300ms ease;\n}
\n\n

The registered --ios-theme-split-pane-width defaults to 320px; .ios-theme-split-pane-half-open sets it to 50vw. Set halfOpened from deviceLayoutChange.hingeStatus (and read the initial value with getDeviceLayout). The exported HingeStatus enum has Unavailable, Closed, PartiallyOpen, and FullyOpen; Unavailable means no hinge is available. Ionic's when decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout works without the iOS 27 theme and does not enable Vertical Bars or move an overlay menu.

\n

These 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, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different 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; this mode does not convert tabs into a menu.

\n

On supported iOS versions, enableVerticalControlArea() hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI TabView and toolbar. Vertical Bars works with either Ionic ios or md mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full enableNativeUIShell(), keep that single runtime instead of starting both. 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. Add .ios-theme-horizontal-only to an ion-buttons group or individual ion-button to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override --ios-theme-vertical-bars-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/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index be4b48a1..c20f829d 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,7 +19,8 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; -import { getVerticalBarPlacement, setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { IonicNativeUIShell, setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Capacitor } from '@capacitor/core'; interface IComponent { name: string; @@ -103,7 +104,8 @@ export class IndexPageComponent { async changeVerticalBarsMode(event: ToggleCustomEvent) { if (!event.detail.checked) return setVerticalControlAreaPlacement(null); - const placement = await getVerticalBarPlacement(); + const placement = + Capacitor.getPlatform() === 'ios' ? (await IonicNativeUIShell.getDeviceLayout()).placement : ({ edge: null, inset: 0 } as const); setVerticalControlAreaPlacement(placement.edge ? placement : 'right'); } } diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index d1a2c969..21c8ac8f 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -1,21 +1,21 @@ - + - + Index - + Docs - + Library - + Settings diff --git a/demo/src/app/tabs/tabs.page.scss b/demo/src/app/tabs/tabs.page.scss index bfffad40..ff8049a6 100644 --- a/demo/src/app/tabs/tabs.page.scss +++ b/demo/src/app/tabs/tabs.page.scss @@ -1,3 +1,14 @@ ion-item { --background: transparent; } + +// The demo enables its Duo split pane at 900px; ordinary Ionic split panes keep their own width. +ion-split-pane[when='(min-width: 900px)'] { + --ios-theme-menu-width: var(--ios-theme-split-pane-width); + --side-width: var(--ios-theme-menu-width); + --side-max-width: var(--ios-theme-menu-width); + + @media (prefers-reduced-motion: no-preference) { + transition: --ios-theme-split-pane-width 300ms ease; + } +} diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index 1543d673..6ff5ae5a 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, inject, OnInit } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -14,21 +14,40 @@ import { ViewDidEnter, ViewDidLeave, } from '@demo/ionic'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { filter } from 'rxjs'; // import { registerTabBarEffect } from '@rdlabo/ionic-theme-ios27'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; +import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Capacitor } from '@capacitor/core'; @Component({ selector: 'app-tabs', templateUrl: 'tabs.page.html', styleUrls: ['tabs.page.scss'], - imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonSplitPane, IonMenu, IonContent, IonList, IonItem, IonItemGroup], + imports: [ + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonSplitPane, + IonMenu, + IonContent, + IonList, + IonItem, + IonItemGroup, + RouterLink, + ], }) -export class TabsPage implements OnInit, ViewDidEnter, ViewDidLeave { +export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ViewDidLeave { readonly #router = inject(Router); readonly #el = inject(ElementRef); + private readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); + #hingeListener?: { remove(): Promise }; + #hingeMonitoring = false; + #destroyed = false; readonly registeredGestures: registeredEffect[] = []; ngOnInit() { this.#router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe((params) => { @@ -44,6 +63,44 @@ export class TabsPage implements OnInit, ViewDidEnter, ViewDidLeave { }); } + ngAfterViewInit() { + void this.observeHinge(); + } + + setHingeStatus(status: HingeStatus) { + const splitPane = this.splitPane().nativeElement; + // The width rules key off the `when` attribute, so go through setAttribute. + splitPane.setAttribute('when', status === HingeStatus.Unavailable ? '(min-width: 992px)' : '(min-width: 900px)'); + splitPane.classList.toggle('ios-theme-split-pane-half-open', status === HingeStatus.PartiallyOpen); + } + + async observeHinge() { + if (Capacitor.getPlatform() !== 'ios') return; + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + this.#hingeMonitoring = true; + if (this.#destroyed) return this.releaseHinge(); + this.#hingeListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { + if (!this.#destroyed) this.setHingeStatus(hingeStatus); + }); + const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); + if (this.#destroyed) return this.releaseHinge(); + this.setHingeStatus(hingeStatus); + } + + private releaseHinge() { + void this.#hingeListener?.remove(); + this.#hingeListener = undefined; + if (this.#hingeMonitoring) { + this.#hingeMonitoring = false; + void IonicNativeUIShell.stopDeviceLayoutMonitoring(); + } + } + + ngOnDestroy() { + this.#destroyed = true; + this.releaseHinge(); + } + ionViewDidEnter() { const registerGesture = registerTabBarEffect(document.querySelector('ion-tab-bar')!); if (registerGesture) { diff --git a/demo/src/main.ts b/demo/src/main.ts index 9c84e261..a9b48ca8 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,7 +2,8 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; -import { addVerticalBarPlacementListener, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; +import { IonicNativeUIShell, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; +import { Capacitor } from '@capacitor/core'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -22,15 +23,17 @@ function loadIOSAnimations(): IonicAnimationOptions { // Keep the Web fallback available in the demo; applications can choose when to enable it. void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) - .then(() => - addVerticalBarPlacementListener((placement) => { + .then(async () => { + if (Capacitor.getPlatform() !== 'ios') return; + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => { const app = document.querySelector('ion-app.ios-theme-vertical-bars'); if (app) setVerticalControlAreaPlacement( placement.edge ? placement : app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right', ); - }), - ) + }); + }) .catch((err) => console.error(err)); const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; void startShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); diff --git a/demo/src/vertical-bars-web.spec.ts b/demo/src/vertical-bars-web.spec.ts index 4a6c72e8..dbc35a41 100644 --- a/demo/src/vertical-bars-web.spec.ts +++ b/demo/src/vertical-bars-web.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import { createVerticalBarsWebProjection } from '../../src/native/vertical-bars-web'; +import { setVerticalBarsPlacement } from '../../src/native/shared/dom'; const mountEligibleBackButton = () => { document.body.innerHTML = ` @@ -12,6 +13,8 @@ const mountEligibleBackButton = () => { button.style.display = 'block'; button.style.visibility = 'visible'; button.getBoundingClientRect = () => ({ width: 44, height: 44 }) as DOMRect; + // Eligibility requires prehide's per-page placement capture; mark it directly. + setVerticalBarsPlacement(button, true); }; const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); diff --git a/docs/special-markup.md b/docs/special-markup.md index c40b26d8..a3a2f93f 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -57,9 +57,8 @@ Load the separate stylesheet and start its projection runtime. The iOS 27 theme ```ts import { - addVerticalBarPlacementListener, + IonicNativeUIShell, enableVerticalControlArea, - getVerticalBarPlacement, } from '@rdlabo/ionic-theme-ios27/vertical-bars'; // Start Web projection on Chrome too; it remains idle until the class is present. @@ -67,14 +66,18 @@ const rail = await enableVerticalControlArea(); // `platform` is the app's injected Ionic Platform instance. if (platform.is('ios')) { - await addVerticalBarPlacementListener((placement) => rail.setPlacement(placement)); - rail.setPlacement(await getVerticalBarPlacement()); + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement)); + rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); + // When updates are no longer needed: + // await listener.remove(); + // await IonicNativeUIShell.stopDeviceLayoutMonitoring(); } ``` The `platform.is('ios')` guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. -The placement listener only reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. The result includes the physical edge and its UIKit safe-area inset. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(placement)` instead of starting another runtime. +The device-layout listener reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. The placement includes the physical edge and its UIKit safe-area inset. The same event also includes hinge status and WebView corner radius. Call `stopDeviceLayoutMonitoring()` after removing the listener to stop device-layout events. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(placement)` instead of starting another runtime. Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. @@ -94,6 +97,32 @@ This keeps routers and component backgrounds full-viewport. `ion-content` moves `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. +For a side-by-side menu on iPhone Duo, opt the `ion-split-pane` into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them: + +```html + + ... +
...
+
+``` + +Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value: + +```css +ion-split-pane { + --ios-theme-menu-width: var(--ios-theme-split-pane-width); + --side-width: var(--ios-theme-menu-width); + --side-max-width: var(--ios-theme-menu-width); + transition: --ios-theme-split-pane-width 300ms ease; +} +``` + +The registered `--ios-theme-split-pane-width` defaults to 320px; `.ios-theme-split-pane-half-open` sets it to 50vw. Set `halfOpened` from `deviceLayoutChange.hingeStatus` (and read the initial value with `getDeviceLayout`). The exported `HingeStatus` enum has `Unavailable`, `Closed`, `PartiallyOpen`, and `FullyOpen`; `Unavailable` means no hinge is available. Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout works without the iOS 27 theme and does not enable Vertical Bars or move an overlay menu. + These 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`, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic `slot` value does not select a different 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; this mode does not convert tabs into a menu. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index b3503775..e51971fe 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -8,8 +8,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "getVerticalBarPlacement", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getDeviceLayout", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) ] @@ -30,6 +31,12 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var lastVerticalBarInset: CGFloat = 0 private var verticalBarPlacementObserved = false private weak var observedVerticalBarView: UIView? + private var verticalBarRegistration: AnyObject? + private weak var observedHingeView: UIView? + private var hingeInteraction: AnyObject? + private var hingeStatus: String? + private var deviceLayoutMonitoring = 0 + private var lastDeviceLayout: String? public override func load() { for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification, @@ -75,15 +82,29 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}") if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() } if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() } + if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() } }) } - DispatchQueue.main.async { [weak self] in self?.observeVerticalBarPlacement() } } deinit { observers.forEach(NotificationCenter.default.removeObserver) } + private func stopDeviceLayoutObservation() { + if #available(iOS 17.0, *), let view = observedVerticalBarView, + let registration = verticalBarRegistration as? any UITraitChangeRegistration { + view.unregisterForTraitChanges(registration) + } + verticalBarRegistration = nil + observedVerticalBarView = nil + if let interaction = hingeInteraction as? UIInteraction { + observedHingeView?.removeInteraction(interaction) + } + hingeInteraction = nil + observedHingeView = nil + } + private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() @@ -91,8 +112,59 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func notifyWebViewMetricsChange() { - guard let metrics = webViewMetrics() else { return } - notifyListeners("webViewMetricsChange", data: metrics) + notifyDeviceLayoutChange() + } + + private func deviceLayout() -> JSObject { + var layout: JSObject = ["placement": verticalBarPlacement(), + "webViewMetrics": webViewMetrics() ?? ["radius": 0]] + layout["hingeStatus"] = hingeStatus ?? "unavailable" + return layout + } + + private func notifyDeviceLayoutChange() { + guard deviceLayoutMonitoring > 0 else { return } + let layout = deviceLayout() + let fingerprint = "\(verticalBarEdge() ?? "none"):\(verticalBarInset(for: verticalBarEdge())):\(hingeStatus ?? "none"):\(webViewMetrics()?["radius"] ?? 0)" + guard fingerprint != lastDeviceLayout else { return } + lastDeviceLayout = fingerprint + notifyListeners("deviceLayoutChange", data: layout) + } + + @objc func getDeviceLayout(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + self?.observeVerticalBarPlacement() + self?.observeHingeStatus() + self?.refreshHingeStatus() + DispatchQueue.main.async { + call.resolve(self?.deviceLayout() ?? ["placement": ["edge": NSNull(), "inset": 0], "hingeStatus": "unavailable", "webViewMetrics": ["radius": 0]]) + if self?.deviceLayoutMonitoring == 0 { self?.stopDeviceLayoutObservation() } + } + } + } + + @objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + self?.deviceLayoutMonitoring += 1 + self?.lastDeviceLayout = nil + self?.observeVerticalBarPlacement() + self?.observeHingeStatus() + self?.refreshHingeStatus() + call.resolve() + } + } + + @objc func stopDeviceLayoutMonitoring(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + if let self, self.deviceLayoutMonitoring > 0 { + self.deviceLayoutMonitoring -= 1 + if self.deviceLayoutMonitoring == 0 { + self.lastDeviceLayout = nil + self.stopDeviceLayoutObservation() + } + } + call.resolve() + } } private func verticalBarEdge() -> String? { @@ -127,15 +199,18 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele verticalBarPlacementObserved = true lastVerticalBarEdge = edge lastVerticalBarInset = inset - notifyListeners("verticalBarPlacementChange", data: verticalBarPlacement()) + notifyDeviceLayoutChange() } private func observeVerticalBarPlacement() { #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView { + if let view = observedVerticalBarView, let registration = verticalBarRegistration as? any UITraitChangeRegistration { + view.unregisterForTraitChanges(registration) + } observedVerticalBarView = webView let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge - _ = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in + verticalBarRegistration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in self?.notifyVerticalBarPlacementChange() } } @@ -143,25 +218,38 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele notifyVerticalBarPlacementChange() } - @objc func getVerticalBarPlacement(_ call: CAPPluginCall) { - DispatchQueue.main.async { [weak self] in - self?.observeVerticalBarPlacement() - call.resolve(self?.verticalBarPlacement() ?? ["edge": NSNull(), "inset": 0]) + private func observeHingeStatus() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView { + if let interaction = hingeInteraction as? UIInteraction { + observedHingeView?.removeInteraction(interaction) + } + observedHingeView = webView + let interaction = UIHingeInteraction { [weak self] _, update in + let status: String? + switch update.hinge?.status { + case .closed: status = "closed" + case .partiallyOpen: status = "partially-open" + case .fullyOpen: status = "fully-open" + default: status = nil + } + guard status != self?.hingeStatus else { return } + self?.hingeStatus = status + self?.notifyDeviceLayoutChange() + } + hingeInteraction = interaction + webView.addInteraction(interaction) } + #endif } - @objc func getWebViewMetrics(_ call: CAPPluginCall) { - DispatchQueue.main.async { [weak self] in - guard #available(iOS 26.0, *) else { - call.resolve(["radius": 0]) - return - } - guard let metrics = self?.webViewMetrics() else { - call.reject("WebView unavailable") - return - } - call.resolve(metrics) + private func refreshHingeStatus() { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), let interaction = hingeInteraction as? UIHingeInteraction { + interaction.isEnabled = false + interaction.isEnabled = true } + #endif } @objc func configure(_ call: CAPPluginCall) { diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 33ad1612..6c2dee68 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -47,6 +47,13 @@ export interface VerticalBarPlacement { inset: number; } +export enum HingeStatus { + Unavailable = 'unavailable', + Closed = 'closed', + PartiallyOpen = 'partially-open', + FullyOpen = 'fully-open', +} + export interface VerticalControlAreaHandle extends NativeUIShellHandle { /** Applies the application's chosen placement to both Web and native controls. */ setPlacement(placement: VerticalBarEdge | VerticalBarPlacement): void; @@ -150,14 +157,20 @@ export interface WebViewMetrics { radius: number; } +export interface DeviceLayout { + placement: VerticalBarPlacement; + hingeStatus: HingeStatus; + webViewMetrics: WebViewMetrics; +} + export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; - getVerticalBarPlacement(): Promise; - getWebViewMetrics(): Promise; + getDeviceLayout(): Promise; + startDeviceLayoutMonitoring(): Promise; + stopDeviceLayoutMonitoring(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; - addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; - addListener(name: 'verticalBarPlacementChange', listener: (event: VerticalBarPlacement) => void): Promise; + addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise; } diff --git a/src/native/index.ts b/src/native/index.ts index edbc9440..410a9e13 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -9,7 +9,6 @@ import type { VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; -import { bindMetricsLifecycle } from './lifecycle'; import { createRuntime } from './runtime'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; @@ -20,13 +19,16 @@ export type { NativeUIShellOptions, NativeUIShellStatus, NativeUIShellSuspension, + DeviceLayout, VerticalBarEdge, VerticalBarPlacement, VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; +export { HingeStatus } from './definitions'; const plugin = registerPlugin('IonicNativeUIShell'); +export const IonicNativeUIShell = plugin; let active: Promise | undefined; let activeConfiguration: string | undefined; const web = (reason: string): NativeUIShellHandle => ({ @@ -58,23 +60,12 @@ const combine = (native: NativeUIShellHandle, fallback: NativeUIShellHandle): Na /** 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 }; + const metrics = + typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 }; setConfig({ radius: metrics.radius }); return metrics; }; -/** Reads the system's current vertical-bar placement without changing the theme. */ -export const getVerticalBarPlacement = (): Promise => - typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' - ? plugin.getVerticalBarPlacement() - : Promise.resolve({ edge: null, inset: 0 }); - -/** Observes placement; the application decides whether to apply each change. */ -export const addVerticalBarPlacementListener = (listener: (placement: VerticalBarPlacement) => void) => - typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' - ? plugin.addListener('verticalBarPlacementChange', listener) - : Promise.resolve({ remove: async () => {} }); - /** Applies one placement to the CSS layout and both Web/native projections. */ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | VerticalBarPlacement): void => { if (typeof document === 'undefined') return; @@ -130,7 +121,8 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise if (Capacitor.getPlatform() !== 'ios') return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires Capacitor iOS'), stopPrehide); let runtime: NativeUIShellHandle | undefined; - let placementListener: Awaited> | undefined; + let placementListener: Awaited> | undefined; + let monitoring = false; try { if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); @@ -142,28 +134,24 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise const root = document.querySelector('ion-app.ios-theme-vertical-bars'); return nativeEdge !== null && !!root && nativeEdge === (root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'); }; - placementListener = await addVerticalBarPlacementListener(({ edge }) => { - nativeEdge = edge; + await plugin.startDeviceLayoutMonitoring(); + monitoring = true; + placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => { + nativeEdge = placement.edge; + if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius }); document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); }); - nativeEdge = (await getVerticalBarPlacement()).edge; + nativeEdge = (await plugin.getDeviceLayout()).placement.edge; runtime = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); runtime = combine( runtime, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars()), ); - if (!options.verticalBarsOnly) - runtime = await bindMetricsLifecycle( - runtime, - () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), - () => { - active = undefined; - }, - ); return resetOnDestroy(withPlacementListener(runtime, placementListener), stopPrehide); } catch (error) { await runtime?.destroy(); await placementListener?.remove().catch(() => {}); + if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); return resetOnDestroy( withReason(createVerticalBarsWebProjection(document, options), error instanceof Error ? error.message : String(error)), stopPrehide, @@ -174,18 +162,24 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise const withPlacementListener = ( handle: NativeUIShellHandle, - listener: Awaited>, -): NativeUIShellHandle => ({ - getStatus: () => handle.getStatus(), - suspend: () => handle.suspend(), - async destroy() { - try { - await handle.destroy(); - } finally { - await listener.remove().catch(() => {}); - } - }, -}); + listener: Awaited>, +): NativeUIShellHandle => { + let destroyed = false; + return { + getStatus: () => handle.getStatus(), + suspend: () => handle.suspend(), + async destroy() { + if (destroyed) return; + destroyed = true; + try { + await handle.destroy(); + } finally { + await listener.remove().catch(() => {}); + await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + } + }, + }; +}; const resetOnDestroy = ( handle: NativeUIShellHandle, diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 2b103682..5b51477b 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -3,6 +3,18 @@ // iPhone Duo simulation: the system reserves 80pt at the physical right edge. // This entry point does not load the iOS 27 theme or change ordinary Ionic UI. +// A split menu is independent of the vertical control area. Its width is +// selected by the application from the device posture, not the viewport width. +@property --ios-theme-split-pane-width { + syntax: ''; + inherits: false; + initial-value: 320px; +} + +ion-split-pane.ios-theme-split-pane-half-open { + --ios-theme-split-pane-width: 50vw; +} + ion-app.ios-theme-vertical-bars { --ios-theme-vertical-bars-safe-area-left-resolved: var(--ios-theme-vertical-bars-safe-area-left, 0px); --ios-theme-vertical-bars-safe-area-right-resolved: var( diff --git a/src/vertical-bars.ts b/src/vertical-bars.ts index 69acac54..7aad866c 100644 --- a/src/vertical-bars.ts +++ b/src/vertical-bars.ts @@ -1,7 +1,3 @@ -export { - addVerticalBarPlacementListener, - enableVerticalControlArea, - getVerticalBarPlacement, - setVerticalControlAreaPlacement, -} from './native'; -export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native'; +export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native'; +export { HingeStatus } from './native'; +export type { DeviceLayout, NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native'; From f8a48889db0c3a82b835c01fec42364879fab686 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 15:27:51 +0900 Subject: [PATCH 13/17] fix(vertical-bars): let expanded tab rail draw and receive touches over web view Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Components/ShellVerticalBars.swift | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index f41bf8f6..dc7458c5 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -251,16 +251,13 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private func makeFullSizeSurfacesTransparent(in surface: UIView) { let frame = surface.convert(surface.bounds, to: view) - let inset = railEdge == "left" ? view.safeAreaInsets.left : view.safeAreaInsets.right - let railWidth = inset > 0 ? inset : 80 guard !(surface is UIVisualEffectView) else { return } let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds) + // SwiftUI may add an opaque backing behind the rail controls, sized to + // the rail or to an expanded tab bar. Keep that backing clear without + // touching the glass controls or materials. let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 && - (railEdge == "left" - ? frame.minX <= 1 && frame.maxX >= railWidth - 1 - : frame.minX <= view.bounds.maxX - railWidth + 1 && frame.maxX >= view.bounds.maxX - 1) - // SwiftUI may add an opaque backing behind the rail controls. - // Keep that backing clear without touching the glass controls or materials. + (railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1) if coversHost || coversRail { surface.backgroundColor = .clear surface.isOpaque = false @@ -270,29 +267,38 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { } private final class RailContainer: UIView { - private let railMask = CAShapeLayer() - var railEdge = "right" { didSet { setNeedsLayout() } } + var railEdge = "right" private var railWidth: CGFloat { let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right return inset > 0 ? inset : 80 } - override init(frame: CGRect) { - super.init(frame: frame) - layer.mask = railMask + // The rail is not masked: expanded rail content (a widened tab bar) is + // allowed to draw over the WebView. Inside the base rail touches behave + // as before; beyond it touches are captured only where they land inside + // an actual bar surface so empty overlap still belongs to the WebView. + // The SwiftUI hosting scaffold reports a full-size hosting view for + // every point, so the hit result cannot tell bar content from empty + // space — the bar frames decide instead. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth + if inRail { return hit } + guard hit != nil, containsBarSurface(at: point) else { return nil } + return hit } - required init?(coder: NSCoder) { nil } - - override func layoutSubviews() { - super.layoutSubviews() - railMask.path = UIBezierPath(rect: CGRect(x: railEdge == "left" ? bounds.minX : bounds.maxX - railWidth, y: 0, - width: railWidth, height: bounds.height)).cgPath + private func containsBarSurface(at point: CGPoint) -> Bool { + containsBarSurface(in: self, at: point) } - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - (railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth) && super.point(inside: point, with: event) + private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool { + guard !view.isHidden, view.alpha > 0.05 else { return false } + let name = NSStringFromClass(type(of: view)) + if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")), + convert(view.bounds, from: view).contains(point) { return true } + return view.subviews.contains { containsBarSurface(in: $0, at: point) } } } From 2453f43ceedca04bdfe4067b7b25d53694906516 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 15:45:04 +0900 Subject: [PATCH 14/17] fix(vertical-bars): skip inset padding on collapse toolbars Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/styles/vertical-bars.scss | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 5b51477b..963a083f 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -66,14 +66,17 @@ ion-app.ios-theme-vertical-bars --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; - &::part(container) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - } + // Collapse headers already sit inside the padded ion-content scroll area. + &:not(:where(ion-header.header-collapse-condense *, ion-footer.footer-collapse-fade *)) { + &::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + } - &:dir(rtl)::part(container) { - padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); - padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + &:dir(rtl)::part(container) { + padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-vertical-bars-safe-area-right-resolved)); + padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-vertical-bars-safe-area-left-resolved)); + } } } From 7b7524ba1075c50fc7acfa35a28434ccc0e26cf9 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 19:00:59 +0900 Subject: [PATCH 15/17] refactor(vertical-bars): consolidate shell lifecycle and modernize e2e mocks Merge the withReason/withPlacementListener/resetOnDestroy wrappers into a single manage() helper and drop the dead lifecycle module so handle ownership lives in one place. Reuse inFixedToolbar for toolbar eligibility instead of duplicating the same structural check. E2E mocks now model the plugin implementation returned by registerPlugin for IonicNativeUIShell instead of bridging through window.__nativeUIShell or PluginHeaders, so tests exercise the same registration path as the app and emit events through the plugin listener registry. Test probes move off window onto the ion-app element and document, and the remaining any types are replaced with the public ShellSnapshot/ShellControl/ShellItem surface. Style-value specs that re-verified what screenshot regression already covers are removed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/e2e/animation.spec.ts | 15 +- demo/e2e/card-opt-out.spec.ts | 6 +- demo/e2e/glass.spec.ts | 81 -- demo/e2e/native-shell-mock.ts | 77 ++ demo/e2e/native-ui-shell-edge.spec.ts | 269 ++++--- demo/e2e/native-ui-shell.spec.ts | 874 +++++++++++++-------- demo/e2e/popover.spec.ts | 59 +- demo/e2e/range-interaction.spec.ts | 6 +- demo/e2e/rtl.spec.ts | 75 -- demo/e2e/screenshot.spec.ts | 14 +- demo/e2e/submit-color.spec.ts | 80 -- demo/e2e/tab-bar-position.spec.ts | 65 -- demo/e2e/toggle.spec.ts | 42 - demo/e2e/vertical-bars-back-button.spec.ts | 19 +- demo/src/app/app.config.ts | 10 +- demo/src/app/tabs/tabs.page.html | 8 +- demo/src/app/tabs/tabs.page.ts | 10 +- demo/src/main.ts | 5 +- demo/src/native-ui-shell-lifecycle.spec.ts | 30 +- demo/tsconfig.json | 1 + src/native/index.ts | 121 ++- src/native/lifecycle.ts | 38 - src/native/vertical-bars-web.ts | 24 +- src/transition/index.ts | 17 +- src/utils.ts | 16 +- 25 files changed, 953 insertions(+), 1009 deletions(-) delete mode 100644 demo/e2e/glass.spec.ts create mode 100644 demo/e2e/native-shell-mock.ts delete mode 100644 demo/e2e/rtl.spec.ts delete mode 100644 demo/e2e/submit-color.spec.ts delete mode 100644 demo/e2e/tab-bar-position.spec.ts delete mode 100644 src/native/lifecycle.ts diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 74dcf4d2..3f398472 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -1,10 +1,11 @@ import { expect, test, type Page } from '@playwright/test'; +import type { AnimationCall } from './native-shell-mock'; const installAnimationObserver = async (page: Page) => { await page.addInitScript(() => { const originalAnimate = Element.prototype.animate; - (window as any).__IONIC_ANIMATION_CALLS__ = []; + document.__IONIC_ANIMATION_CALLS__ = []; Element.prototype.animate = function (keyframes, options) { const animation = originalAnimate.call(this, keyframes, options); const properties = Array.isArray(keyframes) @@ -16,7 +17,7 @@ const installAnimationObserver = async (page: Page) => { : Object.keys(keyframes ?? {}).filter((key) => !['offset', 'easing', 'composite'].includes(key)); const duration = typeof options === 'number' ? options : typeof options?.duration === 'number' ? options.duration : 0; - (window as any).__IONIC_ANIMATION_CALLS__.push({ + document.__IONIC_ANIMATION_CALLS__!.push({ animation, duration, properties, @@ -30,13 +31,13 @@ const installAnimationObserver = async (page: Page) => { }; const clearAnimationCalls = async (page: Page) => { - await page.evaluate(() => ((window as any).__IONIC_ANIMATION_CALLS__ = [])); + await page.evaluate(() => (document.__IONIC_ANIMATION_CALLS__ = [])); }; const hasRunningAnimation = (page: Page, targetClass?: string) => { return page.evaluate((expectedClass) => { - return (window as any).__IONIC_ANIMATION_CALLS__.some( - (call: { animation: Animation; duration: number; properties: string[]; targetClass: string }) => + return document.__IONIC_ANIMATION_CALLS__!.some( + (call: AnimationCall) => call.animation.playState === 'running' && call.duration > 0 && call.properties.includes('transform') && @@ -47,8 +48,8 @@ const hasRunningAnimation = (page: Page, targetClass?: string) => { const hasAnimationCall = (page: Page, targetClass: string) => { return page.evaluate((expectedClass) => { - return (window as any).__IONIC_ANIMATION_CALLS__.some( - (call: { duration: number; properties: string[]; targetClass: string }) => + return document.__IONIC_ANIMATION_CALLS__!.some( + (call: AnimationCall) => call.duration > 0 && call.properties.includes('transform') && call.targetClass.split(' ').includes(expectedClass), ); }, targetClass); diff --git a/demo/e2e/card-opt-out.spec.ts b/demo/e2e/card-opt-out.spec.ts index 175564f8..9b4d0083 100644 --- a/demo/e2e/card-opt-out.spec.ts +++ b/demo/e2e/card-opt-out.spec.ts @@ -18,7 +18,11 @@ for (const disabled of ['ios-theme-disabled', 'ios26-disabled']) { return card; }; const cards = [create(false), create(true)]; - await Promise.all(cards.flatMap((card) => [card, ...card.querySelectorAll('*')]).map((el: any) => el.componentOnReady?.())); + await Promise.all( + cards + .flatMap((card) => [card, ...card.querySelectorAll('*')]) + .map((el: Element & { componentOnReady?: () => Promise }) => el.componentOnReady?.()), + ); const result = cards.map((card) => [...card.querySelectorAll(tag)].map((el) => { const s = getComputedStyle(el); diff --git a/demo/e2e/glass.spec.ts b/demo/e2e/glass.spec.ts deleted file mode 100644 index 65d2bd1e..00000000 --- a/demo/e2e/glass.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('fullscreen page chrome is transparent or frosted according to translucent', async ({ page }) => { - await page.goto('/main/album'); - const content = page.locator('app-album-page > ion-content'); - const header = page.locator('app-album-page > ion-header'); - const footer = page.locator('app-album-page > ion-footer'); - const headerToolbar = header.locator(':scope > ion-toolbar'); - const footerToolbar = footer.locator(':scope > ion-toolbar'); - - await expect(header).toHaveClass(/header-translucent/); - await expect(headerToolbar).toHaveCSS('--background', 'transparent'); - expect(await header.evaluate((element) => getComputedStyle(element, '::after').backdropFilter)).toBe('blur(2px)'); - - await expect(footer).toHaveClass(/footer-translucent/); - expect(await footer.evaluate((element) => getComputedStyle(element, '::before').backdropFilter)).toBe('blur(8px)'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::before').backgroundColor)).not.toBe('rgba(0, 0, 0, 0)'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').backdropFilter)).toBe('blur(2px)'); - await expect(footerToolbar).toHaveCSS('--border-width', /^0?\.5px 0 0$/); - - await footer.evaluate((element: HTMLIonFooterElement) => (element.translucent = false)); - await header.evaluate((element: HTMLIonHeaderElement) => (element.translucent = false)); - await expect(header).not.toHaveClass(/header-translucent/); - await expect(footer).not.toHaveClass(/footer-translucent/); - await expect(headerToolbar).toHaveCSS('--background', 'transparent'); - await expect(footerToolbar).toHaveCSS('--background', 'transparent'); - expect(await header.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); - - await content.evaluate((element: HTMLIonContentElement) => (element.fullscreen = false)); - await expect(content).not.toHaveClass(/content-fullscreen/); - await expect(headerToolbar).not.toHaveCSS('--background', 'transparent'); - await expect(footerToolbar).not.toHaveCSS('--background', 'transparent'); - - await footer.evaluate((element: HTMLIonFooterElement) => (element.translucent = true)); - await expect(footer).toHaveClass(/footer-translucent/); - expect(await footer.evaluate((element) => getComputedStyle(element, '::after').content)).toBe('none'); -}); - -test('dark resting glass applies the directional rim to standalone and grouped controls', async ({ page }) => { - await page.goto('/main/index/native-ui-shell'); - await page.evaluate(() => document.documentElement.classList.add('ion-palette-dark')); - const button = page.locator('app-native-ui-shell ion-button[type="submit"]'); - const surface = button.locator('[part="native"]'); - await expect(surface).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(surface).toHaveCSS('border-right-color', 'rgba(0, 0, 0, 0)'); - await expect(surface).toHaveCSS('background-color', 'rgba(62, 62, 62, 0.5)'); - const back = page.locator('app-native-ui-shell ion-back-button').locator('[part="native"]'); - await expect(back).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(back).toHaveCSS('background-color', 'rgba(62, 62, 62, 0.5)'); - const group = page.locator('app-native-ui-shell ion-buttons[data-glass-group]'); - await expect(group).toHaveCSS('border-left-color', 'rgba(0, 0, 0, 0)'); - await expect(group).toHaveCSS('border-right-color', 'rgba(0, 0, 0, 0)'); -}); - -for (const dark of [false, true]) { - test(`Glass preserves Ionic background and shadow overrides in ${dark ? 'dark' : 'light'} mode`, async ({ page }) => { - await page.goto('/main/index/native-ui-shell'); - await page.evaluate((enabled) => document.documentElement.classList.toggle('ion-palette-dark', enabled), dark); - const tabs = page.locator('ion-tab-bar'); - await expect(tabs).toHaveClass(/hydrated/); - const tabFrame = await tabs.boundingBox(); - await tabs.evaluate((el) => el.style.setProperty('--background', 'rgb(12, 34, 56)')); - expect(await tabs.evaluate((el) => getComputedStyle(el, '::before').backgroundColor)).toBe('rgb(12, 34, 56)'); - expect(await tabs.boundingBox()).toEqual(tabFrame); - for (const selector of ['app-native-ui-shell ion-button[type="submit"]', 'app-native-ui-shell ion-back-button']) { - const control = page.locator(selector); - await control.evaluate((el) => { - el.style.setProperty('--background', 'rgb(12, 34, 56)'); - el.style.setProperty('--box-shadow', '0 0 2px rgb(12, 34, 56)'); - }); - await expect(control.locator('[part="native"]')).toHaveCSS('background-color', 'rgb(12, 34, 56)'); - await expect(control.locator('[part="native"]')).toHaveCSS('box-shadow', 'rgb(12, 34, 56) 0px 0px 2px 0px'); - } - await page.goto('/main/index/floating-action-button-fixed'); - await page.evaluate((enabled) => document.documentElement.classList.toggle('ion-palette-dark', enabled), dark); - const fab = page.locator('ion-fab-button').first(); - await fab.evaluate((el) => el.style.setProperty('--box-shadow', 'none')); - await expect(fab.locator('[part="native"]')).toHaveCSS('box-shadow', 'none'); - }); -} diff --git a/demo/e2e/native-shell-mock.ts b/demo/e2e/native-shell-mock.ts new file mode 100644 index 00000000..8c943987 --- /dev/null +++ b/demo/e2e/native-shell-mock.ts @@ -0,0 +1,77 @@ +import type { NativeUIShellHandle, NativeUIShellSuspension, ShellControl, ShellSnapshot } from '../../src/native/definitions'; + +/** + * State carried by the fake native plugin that `mockNative` installs. + * + * The init script intercepts the `window.Capacitor` assignment during + * `@capacitor/core` initialisation and patches `registerPlugin`, so the app's + * `registerPlugin('IonicNativeUIShell')` receives the mock + * object itself. Specs reach it the same way the application does: + * + * Capacitor.registerPlugin('IonicNativeUIShell') + */ +export interface ShellMockCore { + /** update()/clear() snapshots received by the plugin. */ + updates: ShellSnapshot[]; + /** Monotonic sequence counter for emitted events. */ + sequence: number; + /** Listener registry mirroring Capacitor's WebPlugin semantics. */ + listeners: Record void)[]>; + /** Emits a native event to every registered listener. */ + notifyListeners(eventName: string, data: unknown): void; +} + +export interface AnimationCall { + animation: Animation; + duration: number; + properties: string[]; + targetClass: string; + targetTag: string; +} + +/** Test probes attached to the demo's element instead of `window`. */ +export interface TestAppElement extends HTMLElement { + nativeUIShell?: NativeUIShellHandle; + verticalBarsLease?: NativeUIShellSuspension; + verticalBarsBackButtonReads?: number; + verticalBarsBackCloneMoved?: boolean; + fabClicks?: number; + fabRetired?: boolean; + menuExtra?: boolean; + retiredSearchbar?: HTMLIonSearchbarElement; + searchEvents?: [string, unknown][]; + placement?: { + element: Element; + parent: HTMLElement | null; + next: ChildNode | null; + slot: string; + zone: Element; + }; + searchPlacement?: { + page: HTMLElement; + footer: Element; + fab: Element; + buttons: Element; + toolbar: Element; + }; +} + +declare global { + const Capacitor: { + getPlatform(): string; + registerPlugin(name: string, implementations?: Record): T; + Plugins: Record; + [key: string]: unknown; + }; + + interface Window { + Capacitor?: typeof Capacitor; + CapacitorCustomPlatform?: { name: string }; + } + + interface Document { + /** Set by e2e init scripts to force the app into its animation-free mode. */ + IONIC_E2E_TESTING?: boolean; + __IONIC_ANIMATION_CALLS__?: AnimationCall[]; + } +} diff --git a/demo/e2e/native-ui-shell-edge.spec.ts b/demo/e2e/native-ui-shell-edge.spec.ts index e1a49b06..70d795de 100644 --- a/demo/e2e/native-ui-shell-edge.spec.ts +++ b/demo/e2e/native-ui-shell-edge.spec.ts @@ -1,78 +1,128 @@ import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; import { buildSync } from 'esbuild'; +import type { NativeUIShellComponent, ShellControl, ShellItem, ShellSnapshot } from '../../src/native/definitions'; +import type { ShellMockCore } from './native-shell-mock'; + +interface GeometryEvidence { + id: string; + frame: number; + visibleFrame: number; + backReleases: number; + retirements: { frame: number; visibleFrame: number; visible: boolean; backProjected: boolean }[]; +} + +interface ShellMock extends ShellMockCore { + rejectKind?: NativeUIShellComponent; + /** Controls UIKit would still show; a rejected kind is omitted like the real host. */ + rendered: Map; + holdAcknowledgement: boolean; + /** Resolves the held update() acknowledgement. */ + acknowledge?: () => void; + onUpdate?: (snapshot: { controls: ShellControl[] }) => void; + evidence?: GeometryEvidence; +} const mockNative = async (page: Page) => { await page.addInitScript(() => { - const state = { - updates: [] as any[], + const mock = { + updates: [] as ShellSnapshot[], sequence: 0, - rejectKind: undefined as string | undefined, - rendered: new Map(), + rejectKind: undefined as NativeUIShellComponent | undefined, + rendered: new Map(), holdAcknowledgement: false, acknowledge: undefined as (() => void) | undefined, - onUpdate: undefined as ((snapshot: any) => void) | undefined, - activate: (_event: any) => {}, - metrics: (_event: any) => {}, + onUpdate: undefined as ((snapshot: { controls: ShellControl[] }) => void) | undefined, + evidence: undefined as GeometryEvidence | undefined, + listeners: {} as Record void)[]>, + addListener(eventName: string, callback: (event: never) => void) { + const listeners = (this.listeners[eventName] ??= []); + listeners.push(callback); + return Promise.resolve({ remove: async () => listeners.splice(listeners.indexOf(callback), 1) }); + }, + async removeAllListeners() { + this.listeners = {}; + }, + notifyListeners(eventName: string, data: unknown) { + for (const listener of this.listeners[eventName] ?? []) listener(data as never); + }, + async configure() { + return { supported: true }; + }, + async getWebViewMetrics() { + return { radius: 0 }; + }, + async getDeviceLayout() { + return { + placement: { edge: 'right' as const, inset: 84 }, + hingeStatus: 'unavailable' as const, + webViewMetrics: { radius: 0 }, + }; + }, + async startDeviceLayoutMonitoring() {}, + async stopDeviceLayoutMonitoring() {}, + async update(options: ShellSnapshot) { + this.updates.push(options); + return this.settle(options); + }, + async clear(options: { revision: number }) { + this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + return this.settle({ ...options, controls: [] }); + }, + async settle(options: { revision?: number; controls: ShellControl[] }) { + const controls = options.controls; + const rejectedControls = controls.filter((control) => control.kind === this.rejectKind).map((control) => control.id); + const ids = new Set(controls.map((control) => control.id)); + for (const id of this.rendered.keys()) if (!ids.has(id)) this.rendered.delete(id); + for (const control of controls) { + // UIKit retains an existing cover until a later snapshot retires its id. + if (control.kind !== this.rejectKind) this.rendered.set(control.id, control); + } + this.onUpdate?.({ ...options, controls }); + if (this.holdAcknowledgement) await new Promise((resolve) => (this.acknowledge = resolve)); + return { revision: options.revision, rejectedControls }; + }, }; - 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 }; - if (method === 'getWebViewMetrics') return { radius: 0 }; - state.updates.push(method === 'clear' ? { ...options, controls: [] } : options); - const controls = method === 'clear' ? [] : options.controls; - const rejectedControls = controls.filter((control: any) => control.kind === state.rejectKind).map((control: any) => control.id); - const ids = new Set(controls.map((control: any) => control.id)); - for (const id of state.rendered.keys()) if (!ids.has(id)) state.rendered.delete(id); - for (const control of controls) { - // UIKit retains an existing cover until a later snapshot retires its id. - if (control.kind !== state.rejectKind) state.rendered.set(control.id, control); - } - state.onUpdate?.({ ...options, controls }); - if (state.holdAcknowledgement) await new Promise((resolve) => (state.acknowledge = resolve)); - return { - revision: options.revision, - rejectedControls, - }; - }, - nativeCallback: (_plugin: string, method: string, options: any, callback: (event: any) => void) => { - if (method === 'addListener' && options.eventName === 'activate') state.activate = callback; - if (method === 'addListener' && options.eventName === 'webViewMetricsChange') state.metrics = callback; - return 'shell-listener'; - }, + + window.CapacitorCustomPlatform = { name: 'ios' }; + // Substitute the mock as the plugin implementation when @capacitor/core + // initialises its global, before the app registers 'IonicNativeUIShell'. + let capacitor: { registerPlugin: (name: string, implementations?: Record) => unknown } | undefined; + Object.defineProperty(window, 'Capacitor', { + configurable: true, + get: () => capacitor, + set: (instance) => { + const registerPlugin = instance.registerPlugin; + instance.registerPlugin = (name: string, implementations?: Record) => + name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + capacitor = instance; }, }); }); }; const latestControl = (page: Page, kind: string) => - page.evaluate((kind) => (window as any).__nativeUIShell.updates.at(-1)?.controls.find((c: any) => c.kind === kind), kind); + page.evaluate( + (kind) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((c: ShellControl) => c.kind === kind), + kind, + ); const activate = (page: Page, kind: string, label?: string, count = 1) => page.evaluate( ({ kind, label, count }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const control = snapshot.controls.find((c: any) => c.kind === kind); - const item = label ? control.items.find((i: any) => i.label === label) : control.items[0]; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const item = label + ? snapshot.controls + .filter((c: ShellControl) => c.kind === kind) + .flatMap((c: ShellControl) => c.items) + .find((i) => i.label === label) + : snapshot.controls.find((c: ShellControl) => c.kind === kind)!.items[0]; for (let index = 0; index < count; index++) { - state.activate({ id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); + state.notifyListeners('activate', { id: item!.id, revision: snapshot.revision, sequence: ++state.sequence }); } }, { kind, label, count }, @@ -82,14 +132,14 @@ const tabState = (page: Page) => page.locator('ion-tab-bar').evaluate((bar) => ({ frame: bar.getBoundingClientRect().toJSON(), role: bar.getAttribute('role'), - items: Array.from(bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')).map((button: any) => ({ - label: button.querySelector('ion-label')?.textContent.trim(), + items: Array.from(bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')).map((button) => ({ + label: button.querySelector('ion-label')?.textContent?.trim(), frame: button.getBoundingClientRect().toJSON(), selected: button.selected, disabled: button.disabled, - role: button.shadowRoot.querySelector('[part=native]')?.getAttribute('role'), - ariaSelected: button.shadowRoot.querySelector('[part=native]')?.getAttribute('aria-selected'), - color: getComputedStyle(button.shadowRoot.querySelector('[part=native]')).color, + role: button.shadowRoot?.querySelector('[part=native]')?.getAttribute('role'), + ariaSelected: button.shadowRoot?.querySelector('[part=native]')?.getAttribute('aria-selected'), + color: getComputedStyle(button.shadowRoot!.querySelector('[part=native]')!).color, })), })); @@ -128,18 +178,20 @@ for (const direction of ['ltr', 'rtl']) { await expect(page).toHaveURL(`/main/${label.toLowerCase()}`); await expect(web).toHaveURL(`/main/${label.toLowerCase()}`); await expect - .poll(async () => (await latestControl(page, 'ion-tab-bar'))?.items.filter((i: any) => i.selected).map((i: any) => i.label)) + .poll(async () => + (await latestControl(page, 'ion-tab-bar'))?.items.filter((i: ShellItem) => i.selected).map((i: ShellItem) => i.label), + ) .toEqual([label]); // A real Web click runs the press animation; compare after both settle. await web.locator('ion-tab-bar').evaluate(async (bar) => { await Promise.all(bar.getAnimations({ subtree: true }).map((animation) => animation.finished.catch(() => {}))); }); await expect.poll(async () => JSON.stringify(await tabState(page)) === JSON.stringify(await tabState(web))).toBe(true); - const native = await latestControl(page, 'ion-tab-bar'); + const native = (await latestControl(page, 'ion-tab-bar'))!; const actual = await tabState(page); const reference = await tabState(web); expect(actual).toEqual(reference); - for (const key of ['x', 'y', 'width', 'height']) expect(native[key]).toBeCloseTo(reference.frame[key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(native[key]).toBeCloseTo(reference.frame[key], 1); expect(native.rtl).toBe(direction === 'rtl'); for (const [index, item] of native.items.entries()) { const dom = reference.items[index]; @@ -198,11 +250,11 @@ for (const fixed of [false, true]) { } await expect.poll(async () => JSON.stringify(await appearance(page)) === JSON.stringify(await appearance(web))).toBe(true); if (fixed) { - const control = await latestControl(page, 'ion-back-button'); + const control = (await latestControl(page, 'ion-back-button'))!; const original = await appearance(web); expect(control.items[0].label).toBe(original.label); expect(control.items[0].accessibilityLabel).toBe(original.accessibilityLabel); - for (const key of ['x', 'y', 'width', 'height']) expect(control[key]).toBeCloseTo(original.frame[key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(control[key]).toBeCloseTo(original.frame[key], 1); } }; const clickBack = async (p: Page, count = 1) => { @@ -212,11 +264,14 @@ for (const fixed of [false, true]) { for (let i = 0; i < count; i++) button.click(); }, count); }; + // With fixed native headers the toolbar button is projected, so tap it through the native activation path. + const clickPush = (p: Page) => + p === page && fixed ? activate(p, 'ion-button', 'Push') : p.getByRole('button', { name: 'Push', exact: true }).click(); for (let cycle = 0; cycle < 3; cycle++) { await Promise.all([page, web].map((p) => p.getByRole('button', { name: 'button', exact: true }).click())); await Promise.all([page, web].map((p) => expect(p).toHaveURL('/main/index/button'))); await checkBack(); - await Promise.all([page, web].map((p) => p.getByRole('button', { name: 'Push', exact: true }).click())); + await Promise.all([page, web].map((p) => clickPush(p))); await Promise.all([page, web].map((p) => expect(p).toHaveURL('/main/index/action-sheet'))); await checkBack(); await Promise.all([page, web].map((p) => clickBack(p, cycle === 2 ? 3 : 1))); @@ -237,10 +292,10 @@ test('RTL native back icon mirrors the Web chevron', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); await expect(page.locator('app-native-ui-shell ion-back-button')).toHaveAttribute('data-native-ui-shell', ''); - const ltr = (await latestControl(page, 'ion-back-button')).items[0].icon; + const ltr = (await latestControl(page, 'ion-back-button'))!.items[0].icon!; await page.evaluate(() => (document.documentElement.dir = 'rtl')); await expect.poll(async () => (await latestControl(page, 'ion-back-button'))?.rtl).toBe(true); - const rtl = (await latestControl(page, 'ion-back-button')).items[0].icon; + const rtl = (await latestControl(page, 'ion-back-button'))!.items[0].icon!; expect(rtl).not.toBe(ltr); const error = await page.evaluate( async ({ ltr, rtl }) => { @@ -327,27 +382,27 @@ test('native geometry rejection paints Web before retirement and retries after l await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); const bar = document.querySelector('ion-tab-bar')!; const back = document.querySelector('app-native-ui-shell ion-back-button')!; - const id = state.updates.at(-1).controls.find((control: any) => control.kind === 'ion-tab-bar').id; + const id = state.updates.at(-1)!.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.id; state.evidence = { id, frame: 0, visibleFrame: -1, backReleases: 0, retirements: [] }; const tick = () => { - state.evidence.frame++; - if (!back.hasAttribute('data-native-ui-shell')) state.evidence.backReleases++; + state.evidence!.frame++; + if (!back.hasAttribute('data-native-ui-shell')) state.evidence!.backReleases++; requestAnimationFrame(tick); }; requestAnimationFrame(tick); bar.addEventListener('nativeUIShellChange', () => { if (!bar.hasAttribute('data-native-ui-shell') && getComputedStyle(bar).visibility === 'visible') { - state.evidence.visibleFrame = state.evidence.frame; + state.evidence!.visibleFrame = state.evidence!.frame; } }); - state.onUpdate = (snapshot: any) => { - if (!snapshot.controls.some((control: any) => control.id === id)) { - state.evidence.retirements.push({ - frame: state.evidence.frame, - visibleFrame: state.evidence.visibleFrame, + state.onUpdate = (snapshot: { controls: ShellControl[] }) => { + if (!snapshot.controls.some((control: ShellControl) => control.id === id)) { + state.evidence!.retirements.push({ + frame: state.evidence!.frame, + visibleFrame: state.evidence!.visibleFrame, visible: getComputedStyle(bar).visibility === 'visible', backProjected: back.hasAttribute('data-native-ui-shell'), }); @@ -357,31 +412,31 @@ test('native geometry rejection paints Web before retirement and retries after l state.holdAcknowledgement = true; }); await bar.evaluate((bar) => (bar.style.width = '300px')); - await expect.poll(() => page.evaluate(() => !!(window as any).__nativeUIShell.acknowledge)).toBe(true); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').acknowledge)).toBe(true); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); expect( await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - return state.rendered.has(state.evidence.id); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + return state.rendered.has(state.evidence!.id); }), ).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.holdAcknowledgement = false; - state.acknowledge(); + state.acknowledge!(); }); await expect(bar).not.toHaveAttribute('data-native-ui-shell'); await expect(bar).toHaveCSS('visibility', 'visible'); await expect(bar).not.toHaveAttribute('aria-hidden'); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await expect.poll(() => latestControl(page, 'ion-tab-bar')).toBeUndefined(); - const retirement = await page.evaluate(() => (window as any).__nativeUIShell.evidence.retirements[0]); + const retirement = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').evidence!.retirements[0]); expect(retirement.visible).toBe(true); expect(retirement.backProjected).toBe(true); expect(retirement.visibleFrame).toBeGreaterThanOrEqual(0); expect(retirement.frame - retirement.visibleFrame).toBeGreaterThanOrEqual(2); const counts = await page.evaluate(async () => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); // Let the restoration's mutation notification settle, then re-read identical data. for (let frame = 0; frame < 4; frame++) await new Promise(requestAnimationFrame); const before = state.updates.length; @@ -392,24 +447,24 @@ test('native geometry rejection paints Web before retirement and retries after l return { before, after: state.updates.length }; }); expect(counts.after).toBe(counts.before); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectKind = undefined)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = undefined)); // Recovery is driven by a new DOM layout, without recreating the runtime. await bar.evaluate((bar) => bar.style.removeProperty('width')); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBeGreaterThan(counts.after); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBeGreaterThan(counts.after); await page.evaluate(() => { - (window as any).__nativeUIShell.rejectKind = 'ion-tab-bar'; + Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = 'ion-tab-bar'; window.dispatchEvent(new Event('nativeUIShellRefresh')); }); await expect(bar).not.toHaveAttribute('data-native-ui-shell'); await expect.poll(() => latestControl(page, 'ion-tab-bar')).toBeUndefined(); await page.evaluate(() => { - (window as any).__nativeUIShell.rejectKind = undefined; + Capacitor.registerPlugin('IonicNativeUIShell').rejectKind = undefined; window.dispatchEvent(new Event('nativeUIShellRefresh')); }); await expect(bar).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__nativeUIShell.evidence.backReleases)).toBe(0); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').evidence!.backReleases)).toBe(0); }); test('native refresh during a pending rejection retries after the stale acknowledgement', async ({ page }) => { @@ -421,27 +476,27 @@ test('native refresh during a pending rejection retries after the stale acknowle await expect(bar).toHaveAttribute('data-native-ui-shell', ''); await expect(back).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.rejectKind = 'ion-tab-bar'; state.holdAcknowledgement = true; }); await bar.evaluate((bar) => (bar.style.width = '300px')); - await expect.poll(() => page.evaluate(() => !!(window as any).__nativeUIShell.acknowledge)).toBe(true); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').acknowledge)).toBe(true); const pending = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; // UIKit now has usable geometry, but its earlier rejection has not reached JS. state.rejectKind = undefined; window.dispatchEvent(new Event('nativeUIShellRefresh')); state.holdAcknowledgement = false; - state.acknowledge(); - return { revision: snapshot.revision, id: snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar').id }; + state.acknowledge!(); + return { revision: snapshot.revision, id: snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.id }; }); await expect .poll(() => page.evaluate(({ revision, id }) => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.revision > revision && snapshot.controls.some((control: any) => control.id === id); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.revision > revision && snapshot.controls.some((control: ShellControl) => control.id === id); }, pending), ) .toBe(true); @@ -473,7 +528,7 @@ for (const path of ['/main/index', '/main/album']) { } await page.locator('ion-tab-bar').evaluate((bar, variant) => { bar.setAttribute('color', 'light'); - const buttons = [...bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')]; + const buttons = [...bar.querySelectorAll('ion-tab-button:not(.ion-cloned-element)')]; buttons.forEach((button) => button.setAttribute('aria-label', button.textContent!.trim())); if (variant === 'icon-only') buttons.forEach((button) => button.querySelector('ion-label')?.remove()); if (variant === 'label-only') buttons.forEach((button) => button.querySelector('ion-icon')?.remove()); @@ -494,14 +549,14 @@ for (const path of ['/main/index', '/main/album']) { }, variant); const current = () => latestControl(page, 'ion-tab-bar'); if (variant === 'icon-only') { - await expect.poll(async () => (await current())?.items.every((item: any) => item.label === '' && !!item.icon)).toBe(true); - expect((await current()).items.every((item: any) => !!item.accessibilityLabel)).toBe(true); + await expect.poll(async () => (await current())?.items.every((item: ShellItem) => item.label === '' && !!item.icon)).toBe(true); + expect((await current())!.items.every((item: ShellItem) => !!item.accessibilityLabel)).toBe(true); } else if (variant === 'label-only') { - await expect.poll(async () => (await current())?.items.every((item: any) => !!item.label && !item.icon)).toBe(true); + await expect.poll(async () => (await current())?.items.every((item: ShellItem) => !!item.label && !item.icon)).toBe(true); } else { await expect(page.locator('ion-badge').first()).toHaveClass(/hydrated/); await expect - .poll(async () => (await current())?.items.map((item: any) => item.badge?.value ?? null)) + .poll(async () => (await current())?.items.map((item: ShellItem) => item.badge?.value ?? null)) .toEqual([null, null, '47', null]); // Ionic iOS hides an empty badge; a visible empty badge maps to a notification dot. await page @@ -517,7 +572,7 @@ for (const path of ['/main/index', '/main/album']) { return { color: style.backgroundColor, textColor: style.color }; }), ); - const snapshot = await current(); + const snapshot = (await current())!; expect(colors.every(({ color }) => color !== 'transparent' && color !== 'rgba(0, 0, 0, 0)')).toBe(true); expect(snapshot.items[0].badge).toEqual({ value: '', ...colors[0] }); expect(snapshot.items[2].badge).toEqual({ value: '47', ...colors[1] }); @@ -545,10 +600,10 @@ for (const path of ['/main/index', '/main/album']) { await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', ''); // Selection stays owned by the actual Ionic tab button, including icon-only tabs. await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const item = snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar').items[1]; - state.activate({ id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const item = snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!.items[1]; + state.notifyListeners('activate', { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }); }); await expect(page).toHaveURL('/main/docs'); await expect.poll(async () => (await current())?.items[1].selected).toBe(true); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index dcfe0dc5..a3852449 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -3,74 +3,106 @@ import type { Page } from '@playwright/test'; import { compile, NodePackageImporter } from 'sass'; import { resolve } from 'node:path'; import * as overlayTypes from '../src/app/overlay-types'; +import type { ShellControl, ShellItem, ShellSnapshot } from '../../src/native/definitions'; +import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); +interface ShellMock extends ShellMockCore { + delay: number; + hang: boolean; + rejectInactiveSearch: boolean; + rejectAllSearch: boolean; + rejectControlLabel: string; + configuredWith?: { verticalBarsOnly?: boolean }; + lateSearch?: () => void; + tabRetirements: number; + retirementDetails: { path: string; tabs: string }[]; +} + const mockNative = async (page: Page, fail = false, verticalBars = true) => { const script = ([fail, verticalBars]: readonly [boolean, boolean]) => { - const state = { - updates: [] as any[], + const mock = { + updates: [] as ShellSnapshot[], sequence: 0, delay: 0, hang: false, rejectInactiveSearch: false, rejectAllSearch: false, rejectControlLabel: '', - configuredWith: undefined as any, - 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') { - state.configuredWith = options; - return { supported: true, verticalBars }; - } - 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 + configuredWith: undefined as { verticalBarsOnly?: boolean } | undefined, + lateSearch: undefined as (() => void) | undefined, + tabRetirements: 0, + retirementDetails: [] as { path: string; tabs: string }[], + listeners: {} as Record void)[]>, + addListener(eventName: string, callback: (event: never) => void) { + const listeners = (this.listeners[eventName] ??= []); + listeners.push(callback); + return Promise.resolve({ remove: async () => listeners.splice(listeners.indexOf(callback), 1) }); + }, + async removeAllListeners() { + this.listeners = {}; + }, + notifyListeners(eventName: string, data: unknown) { + for (const listener of this.listeners[eventName] ?? []) listener(data as never); + }, + async configure(options: { verticalBarsOnly?: boolean }) { + this.configuredWith = options; + return { supported: true, verticalBars }; + }, + async getWebViewMetrics() { + return { radius: 0 }; + }, + async getDeviceLayout() { + return { + placement: { edge: verticalBars ? ('right' as const) : null, inset: verticalBars ? 84 : 0 }, + hingeStatus: 'unavailable' as const, + webViewMetrics: { radius: 0 }, + }; + }, + async startDeviceLayoutMonitoring() {}, + async stopDeviceLayoutMonitoring() {}, + async update(options: ShellSnapshot) { + this.updates.push(options); + if (this.hang) await new Promise(() => {}); + return this.rejections(options); + }, + async clear(options: { revision: number }) { + this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + return this.rejections(options); + }, + async rejections(options: { revision?: number; controls?: ShellControl[] }) { + if (this.delay) await new Promise((resolve) => setTimeout(resolve, this.delay)); + if (fail && options.controls?.length) throw new Error('Test native failure'); + return { + revision: options.revision, + rejectedSearches: + this.rejectInactiveSearch || this.rejectAllSearch ? options.controls - ?.filter((control: any) => control.items.some((item: any) => item.accessibilityLabel === state.rejectControlLabel)) - .map((control: any) => control.id) + ?.filter((control) => control.search && (this.rejectAllSearch || control.search.available === false)) + .map((control) => 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: this.rejectControlLabel + ? options.controls + ?.filter((control) => control.items.some((item) => item.accessibilityLabel === this.rejectControlLabel)) + .map((control) => control.id) + : [], + }; + }, + }; + + window.CapacitorCustomPlatform = { name: 'ios' }; + // Substitute the mock as the plugin implementation when @capacitor/core + // initialises its global, before the app registers 'IonicNativeUIShell'. + let capacitor: { registerPlugin: (name: string, implementations?: Record) => unknown } | undefined; + Object.defineProperty(window, 'Capacitor', { + configurable: true, + get: () => capacitor, + set: (instance) => { + const registerPlugin = instance.registerPlugin; + instance.registerPlugin = (name: string, implementations?: Record) => + name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + capacitor = instance; }, }); }; @@ -80,16 +112,16 @@ const mockNative = async (page: Page, fail = false, verticalBars = true) => { const activate = (page: Page, label: string, duplicate = false) => page.evaluate( ({ label, duplicate }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.findLast((value: any) => - value.controls.some((control: any) => control.items.some((item: any) => item.label === label || item.accessibilityLabel === label)), - ); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.findLast((value) => + value.controls.some((control) => control.items.some((item) => item.label === label || item.accessibilityLabel === label)), + )!; const item = snapshot.controls - .flatMap((control: any) => control.items) - .find((item: any) => item.label === label || item.accessibilityLabel === label); + .flatMap((control) => control.items) + .find((item) => item.label === label || item.accessibilityLabel === label)!; const event = { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }; - state.activate(event); - if (duplicate) state.activate(event); + state.notifyListeners('activate', event); + if (duplicate) state.notifyListeners('activate', event); }, { label, duplicate }, ); @@ -104,22 +136,22 @@ test('FAB keeps a complete native batch across staggered lists and measures each const state = () => page.evaluate(() => { const fab = document.querySelector('ion-fab[horizontal=center]')!; - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; - return controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7); + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + return controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)!; }); const closed = await state(); - expect(closed.items.filter((i: any) => i.visible)).toHaveLength(1); - expect(closed.items.every((i: any) => i.icon && i.closeIcon)).toBe(true); + expect(closed.items.filter((i: ShellItem) => i.visible)).toHaveLength(1); + expect(closed.items.every((i: ShellItem) => i.icon && i.closeIcon)).toBe(true); await fab.locator(':scope > ion-fab-button').evaluate((b: HTMLIonFabButtonElement) => b.click()); - await expect.poll(async () => (await state()).items.filter((i: any) => i.visible).length).toBe(7); + await expect.poll(async () => (await state()).items.filter((i: ShellItem) => i.visible).length).toBe(7); const opened = await state(); - expect(opened.items.map((i: any) => i.id)).toEqual(closed.items.map((i: any) => i.id)); + expect(opened.items.map((i: ShellItem) => i.id)).toEqual(closed.items.map((i: ShellItem) => i.id)); const rects = await fab.locator('ion-fab-button').evaluateAll((buttons) => buttons.map((b) => b.getBoundingClientRect().toJSON())); for (let index = 0; index < rects.length; index++) { - for (const key of ['x', 'y', 'width', 'height']) expect(opened.items[index][key]).toBeCloseTo(rects[index][key], 1); + for (const key of ['x', 'y', 'width', 'height'] as const) expect(opened.items[index][key]).toBeCloseTo(rects[index][key], 1); } await fab.evaluate((f: HTMLIonFabElement) => f.close()); - await expect.poll(async () => (await state()).items.filter((i: any) => i.visible).length).toBe(1); + await expect.poll(async () => (await state()).items.filter((i: ShellItem) => i.visible).length).toBe(1); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); }); @@ -129,26 +161,27 @@ test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate const fab = page.locator('ion-fab[horizontal=center]'); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); await fab.evaluate((element) => { - (window as any).__fabClicks = 0; - element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (window as any).__fabClicks++); + const app = document.querySelector('ion-app') as TestAppElement; + app.fabClicks = 0; + element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (app.fabClicks = (app.fabClicks ?? 0) + 1)); }); await activate(page, 'Up action'); - expect(await page.evaluate(() => (window as any).__fabClicks)).toBe(0); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(0); await activate(page, 'Center FAB actions', true); await expect.poll(() => fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBe(true); await expect .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7) - .items.filter((i: any) => i.visible).length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)! + .items.filter((i: ShellItem) => i.visible).length, ), ) .toBe(7); await activate(page, 'Up action', true); - expect(await page.evaluate(() => (window as any).__fabClicks)).toBe(1); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(1); await expect.poll(() => fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBe(false); await fab.locator(':scope > ion-fab-button').evaluate((element: HTMLIonFabButtonElement) => (element.disabled = true)); await activate(page, 'Center FAB actions'); @@ -179,7 +212,7 @@ test('FAB reverses during stagger, keeps its cover, and ignores late hidden acti expect(evidence.covered).toBe(true); expect(evidence.active).toBe(false); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 120)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 120)); await fab.evaluate((element: HTMLIonFabElement) => (element.activated = true)); await fab.evaluate((element: HTMLIonFabElement) => { element.style.display = 'none'; @@ -213,8 +246,9 @@ test('FAB restores excluded groups and follows icon, list and theme changes', as .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 6)?.items - .length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 6)?.items.length, ), ) .toBe(6); @@ -281,10 +315,10 @@ test('FAB uses measured positions after RTL and viewport changes', async ({ page await expect .poll(() => page.evaluate(() => { - const control = (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7); - if (!control || control.rtl !== (document.documentElement.dir === 'rtl') || control.items.some((i: any) => !i.visible)) + const control = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7); + if (!control || control.rtl !== (document.documentElement.dir === 'rtl') || control.items.some((i: ShellItem) => !i.visible)) return false; const buttons = Array.from(document.querySelector('ion-fab[horizontal=center]')!.querySelectorAll('ion-fab-button')); return buttons.every((b, index) => { @@ -297,10 +331,11 @@ test('FAB uses measured positions after RTL and viewport changes', async ({ page .toBe(true); const icon = await page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-fab' && c.items.length === 7).items[2] - .icon, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7)!.items[2].icon, ); - if (direction === 'rtl') rtlIcon = icon; + if (direction === 'rtl') rtlIcon = icon!; else expect(icon).not.toBe(rtlIcon); } } @@ -321,9 +356,11 @@ test('unsupported search morph releases and restores a native fixed-slot FAB int await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.some((i: any) => i.accessibilityLabel === 'Search albums')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (c: ShellControl) => c.kind === 'ion-fab' && c.items.some((i: ShellItem) => i.accessibilityLabel === 'Search albums'), + ), ), ) .toBe(true); @@ -347,9 +384,9 @@ test('verticalBars tabs request native adaptive rail placement', async ({ page } await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.kind === 'ion-tab-bar' && control.placement === 'vertical-bars'), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.kind === 'ion-tab-bar' && control.placement === 'vertical-bars'), ), ) .toBe(true); @@ -366,12 +403,14 @@ test('standalone Vertical Control Area never snapshots ordinary Native UI Shell await expect .poll(() => page.evaluate(() => { - const { configuredWith, updates } = (window as any).__nativeUIShell; + const { configuredWith, updates } = Capacitor.registerPlugin('IonicNativeUIShell'); const controls = updates.at(-1)?.controls ?? []; return ( configuredWith?.verticalBarsOnly === true && controls.length > 0 && - updates.flatMap((update: any) => update.controls).every((control: any) => control.placement === 'vertical-bars') + updates + .flatMap((update: ShellSnapshot) => update.controls) + .every((control: ShellControl) => control.placement === 'vertical-bars') ); }), ) @@ -402,7 +441,10 @@ test('verticalBars back navigation and toolbar slots request native rail placeme await expect .poll(() => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1)?.controls.filter((control: any) => control.kind === 'ion-back-button').length, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.filter((control: ShellControl) => control.kind === 'ion-back-button').length, ), ) .toBe(1); @@ -410,29 +452,29 @@ test('verticalBars back navigation and toolbar slots request native rail placeme .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates - .at(-1) + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! .controls.some( - (control: any) => + (control: ShellControl) => control.kind === 'ion-back-button' && control.placement === 'vertical-bars' && control.toolbarSlot === undefined, ) && - !(window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.items.some((item: any) => item.label === 'Cancel')), + !Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === 'Cancel')), ), ) .toBe(true); await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! .controls.some( - (control: any) => + (control: ShellControl) => control.kind === 'ion-button' && control.placement === 'vertical-bars' && control.toolbarSlot === 'end' && - control.items.some((item: any) => item.accessibilityLabel === 'Save'), + control.items.some((item: ShellItem) => item.accessibilityLabel === 'Save'), ), ), ) @@ -441,7 +483,9 @@ test('verticalBars back navigation and toolbar slots request native rail placeme .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((control: any) => control.kind === 'ion-menu-button')?.toolbarSlot, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.kind === 'ion-menu-button')?.toolbarSlot, ), ) .toBe('start'); @@ -457,9 +501,10 @@ test('native verticalBars toolbar returns with Index after a pushed page', async await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); const hasIndexActions = () => page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; return controls.some( - (control: any) => control.placement === 'vertical-bars' && control.items.some((item: any) => item.accessibilityLabel === 'GitHub'), + (control: ShellControl) => + control.placement === 'vertical-bars' && control.items.some((item: ShellItem) => item.accessibilityLabel === 'GitHub'), ); }); await expect.poll(hasIndexActions).toBe(true); @@ -468,17 +513,17 @@ test('native verticalBars toolbar returns with Index after a pushed page', async 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]; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + return !!snapshot.controls.find((control: ShellControl) => control.kind === 'ion-back-button')?.items[0]; }), ) .toBe(true); 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 }); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const back = snapshot.controls.find((control: ShellControl) => control.kind === 'ion-back-button')!.items[0]; + state.notifyListeners('activate', { id: back.id, revision: snapshot.revision, sequence: ++state.sequence }); }); await expect(page).toHaveURL(/\/main\/index$/); await expect.poll(hasIndexActions).toBe(true); @@ -508,12 +553,12 @@ test('native verticalBars actions follow WillEnter and stay enabled during navig 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, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.findLast((update: ShellSnapshot) => + update.controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.accessibilityLabel === 'Save')), + )! + .controls.flatMap((control: ShellControl) => control.items) + .find((item: ShellItem) => item.accessibilityLabel === 'Save')!.disabled, ); await expect.poll(saveDisabled).toBe(false); await page.addStyleTag({ content: '.author-no-pointer { pointer-events: none }' }); @@ -608,7 +653,7 @@ test('verticalBars toolbar sources are hidden before ownership and restored with await expect(lateBack).not.toHaveClass(/ios-theme-vertical-bars-back-web-owned/); await lateBack.evaluate((element) => element.closest('ion-header')?.remove()); - await page.evaluate(() => (window as any).nativeUIShell.destroy()); + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); await expect(source).not.toHaveClass(/ios-theme-native-ui-shell-prehidden/); await expect(source).toHaveCSS('visibility', 'visible'); }); @@ -620,7 +665,7 @@ test('rejected verticalBars control returns to an operable Web source', async ({ 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'; + Capacitor.registerPlugin('IonicNativeUIShell').rejectControlLabel = 'Save'; window.dispatchEvent(new Event('nativeUIShellRefresh')); }); await expect(save).not.toHaveAttribute('data-native-ui-shell', ''); @@ -661,22 +706,26 @@ test('verticalBars rail remains native while its Ionic menu is open', async ({ p 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; + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls ?? []; + const demoActions = (control: ShellControl) => + control.items.filter((item: ShellItem) => ['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, + groups: controls.filter((control: ShellControl) => control.kind === 'ion-buttons' && demoActions(control) === 2).length, + individuals: controls.filter((control: ShellControl) => control.kind === 'ion-button' && demoActions(control) > 0).length, }; }), ) .toEqual({ groups: 1, individuals: 0 }); const nativeDisabled = () => page.evaluate(() => { - const items = (window as any).__nativeUIShell.updates.at(-1).controls.flatMap((control: any) => control.items); + const items = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.flatMap((control: ShellControl) => 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), + save: items.find((item: ShellItem) => item.accessibilityLabel === 'Save')?.disabled, + actions: items + .filter((item: ShellItem) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)) + .map((item: ShellItem) => item.disabled), }; }); await actionGroup.evaluate((element: HTMLElement) => (element.style.pointerEvents = 'none')); @@ -736,12 +785,14 @@ test('verticalBars controls stay operable on Web when the native side rail is un 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(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).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 !== 'vertical-bars'), + Capacitor.registerPlugin('IonicNativeUIShell').updates.every((snapshot: ShellSnapshot) => + snapshot.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'), ), ), ) @@ -758,14 +809,15 @@ test('verticalBars controls stay operable on Web when the native side rail is un await page.evaluate(() => { const outlet = document.querySelector('ion-tabs ion-router-outlet')!; - (window as any).__verticalBarsBackCloneMoved = false; + (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved = false; new MutationObserver(() => { - if (outlet.querySelector(':scope > ion-back-button.ion-cloned-element')) (window as any).__verticalBarsBackCloneMoved = true; + if (outlet.querySelector(':scope > ion-back-button.ion-cloned-element')) + (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved = true; }).observe(outlet, { childList: true }); }); await projection.click(); await expect(page).toHaveURL(/\/main\/index$/); - expect(await page.evaluate(() => (window as any).__verticalBarsBackCloneMoved)).toBe(false); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved)).toBe(false); }); test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => { @@ -780,11 +832,13 @@ test('native click preserves external form submit, disabled, and duplicate prote await expect(button).toHaveJSProperty('disabled', true); await activate(page, 'Save'); await expect(page.locator('[data-save-count]')).toHaveText('1'); - await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBeGreaterThan(0); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected)) + .toBeGreaterThan(0); await page.waitForTimeout(200); - const count = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + const count = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await page.waitForTimeout(250); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBe(count); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBe(count); }); test('ancestor display, element opt-out aliases and non-glass fills restore Web', async ({ page }) => { @@ -891,7 +945,13 @@ test('shell opt-out cannot be bypassed by searchable tab integration', async ({ await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const tabs = page.locator('ion-tab-bar'); - const search = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const search = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect.poll(async () => (await search())?.trigger.accessibilityLabel).toBe('Search'); for (const target of [ footer, @@ -921,22 +981,28 @@ test('shell opt-out retires active search without losing input or accepting late await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const bar = footer.locator('ion-searchbar'); - const search = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const search = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.activate({ - id: snapshot.controls.find((c: any) => c.search).search.trigger.id, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('activate', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.trigger.id, revision: snapshot.revision, sequence: ++state.sequence, }); }); await expect.poll(async () => (await search())?.active).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const event = { id: search.id, valueVersion: search.valueVersion, @@ -945,14 +1011,14 @@ test('shell opt-out retires active search without losing input or accepting late value: '日本', composing: false, }; - state.search({ ...event, sequence: ++state.sequence }); - state.lateSearch = () => state.search({ ...event, value: 'stale', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, sequence: ++state.sequence }); + state.lateSearch = () => state.notifyListeners('search', { ...event, value: 'stale', sequence: ++state.sequence }); }); await expect(bar).toHaveJSProperty('value', '日本'); await bar.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await expect.poll(search).toBeUndefined(); - await page.evaluate(() => (window as any).__nativeUIShell.lateSearch()); + await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').lateSearch!()); await expect(bar).toHaveJSProperty('value', '日本'); await bar.evaluate((element) => element.classList.remove('ios-theme-shell-disabled')); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); @@ -984,7 +1050,7 @@ test('modal suspension, tab hiding and destroy restore ownership', async ({ page await page.getByRole('button', { name: 'Open modal', exact: true }).click(); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1))) + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!)) .toMatchObject({ controls: [], transitionDuration: 0 }); await page.getByRole('button', { name: 'Close modal', exact: true }).click(); await expect(button).toHaveAttribute('data-native-ui-shell', ''); @@ -992,7 +1058,7 @@ test('modal suspension, tab hiding and destroy restore ownership', async ({ page await expect(tabs).not.toHaveAttribute('data-native-ui-shell'); await tabs.evaluate((element) => element.style.removeProperty('display')); await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => (window as any).nativeUIShell.destroy()); + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.locator('[data-save-count]')).toHaveText('1'); @@ -1003,7 +1069,7 @@ test('delayed response cannot reclaim a hidden source', async ({ 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', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 150)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 150)); await button.evaluate((element) => (element.querySelector('[data-label]')!.textContent = '変更')); await page.getByRole('button', { name: 'Parent hidden: false', exact: true }).click(); await expect(button).not.toHaveAttribute('data-native-ui-shell'); @@ -1012,9 +1078,9 @@ test('delayed response cannot reclaim a hidden source', async ({ page }) => { await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.items.some((item: any) => item.label === '変更')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === '変更')), ), ) .toBe(false); @@ -1027,23 +1093,33 @@ test('native refresh during a pending acknowledgement resends unchanged controls await page.locator('ion-tab-bar').evaluate((element) => element.remove()); await expect(page.locator('app-native-ui-shell ion-button[type=submit]')).toHaveAttribute('data-native-ui-shell', ''); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.kind === 'ion-tab-bar'))) + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.kind === 'ion-tab-bar'), + ), + ) .toBe(false); const revision = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.delay = 500; window.dispatchEvent(new Event('nativeUIShellRefresh')); - return state.updates.at(-1).revision; + return state.updates.at(-1)!.revision; }); - await expect.poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision)).toBeGreaterThan(revision); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision)) + .toBeGreaterThan(revision); const pending = await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; window.dispatchEvent(new Event('nativeUIShellRefresh')); return snapshot; }); - await expect.poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision)).toBeGreaterThan(pending.revision); - const refreshed = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1)); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision)) + .toBeGreaterThan(pending.revision); + const refreshed = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!); expect(refreshed.controls).toEqual(pending.controls); await expect(page.locator('app-native-ui-shell ion-button[type=submit]')).toHaveAttribute('data-native-ui-shell', ''); }); @@ -1051,7 +1127,9 @@ test('native refresh during a pending acknowledgement resends unchanged controls test('native failure leaves Web form usable', async ({ page }) => { await mockNative(page, true); await page.goto('/main/index/native-ui-shell'); - await expect.poll(() => page.evaluate(() => (window as any).nativeUIShell?.getStatus().state)).toBe('stopped'); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell?.getStatus().state)) + .toBe('web'); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.locator('[data-save-count]')).toHaveText('1'); }); @@ -1073,19 +1151,21 @@ test('100 hide/show cycles leave stable ownership and destroy stops updates', as 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', ''); - const initial = await page.evaluate(() => (window as any).nativeUIShell.getStatus().projected); + const initial = await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected); for (let index = 0; index < 100; index++) { await button.evaluate((element) => (element.parentElement!.hidden = true)); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await button.evaluate((element) => (element.parentElement!.hidden = false)); await expect(button).toHaveAttribute('data-native-ui-shell', ''); } - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().projected)).toBe(initial); - await page.evaluate(() => (window as any).nativeUIShell.destroy()); - const updates = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().projected)).toBe( + initial, + ); + await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.destroy()); + const updates = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await button.evaluate((element) => element.setAttribute('fill', 'clear')); await page.waitForTimeout(300); - expect(await page.evaluate(() => (window as any).__nativeUIShell.updates.length)).toBe(updates); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length)).toBe(updates); await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); }); @@ -1096,8 +1176,10 @@ test('ion-icon SVGs project and update when their name changes', async ({ page } await expect(button).toHaveAttribute('data-native-ui-shell', ''); const nativeIcon = () => button.evaluate(() => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.controls.flatMap((control: any) => control.items).find((item: any) => item.accessibilityLabel === 'Save')?.icon; + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.controls + .flatMap((control: ShellControl) => control.items) + .find((item: ShellItem) => item.accessibilityLabel === 'Save')?.icon; }); await expect.poll(nativeIcon).toMatch(/^iVBOR/); const original = await nativeIcon(); @@ -1116,23 +1198,27 @@ test('clear ion-buttons share one glass surface and keep independent actions', a const github = group.locator('ion-button').nth(0); const refresh = group.locator('ion-button').nth(1); const projectedGroup = () => - page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find( - (control: any) => control.kind === 'ion-buttons' && control.items.some((item: any) => item.accessibilityLabel === 'GitHub'), - ), + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find( + (control: ShellControl) => + control.kind === 'ion-buttons' && control.items.some((item: ShellItem) => item.accessibilityLabel === 'GitHub'), + )!, ); await expect(group).toHaveAttribute('data-native-ui-shell', ''); await expect(group.locator('[data-native-ui-shell]')).toHaveCount(0); await expect(github.locator('button')).toHaveCSS('visibility', 'hidden'); const native = await projectedGroup(); expect(native.items).toHaveLength(2); - expect(native.items.map((item: any) => item.accessibilityLabel)).toEqual(['GitHub', 'Refresh']); + expect(native.items.map((item: ShellItem) => 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, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.id === id)?.items[0]?.accessibilityLabel, native.id, ); await github.locator('ion-icon').evaluate((icon) => icon.setAttribute('aria-hidden', 'true')); @@ -1195,11 +1281,12 @@ 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 isDemoAction = (control: any) => control.items.some((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)); + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls ?? []; + const isDemoAction = (control: ShellControl) => + control.items.some((item: ShellItem) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)); return { - buttons: controls.filter((control: any) => control.kind === 'ion-button' && isDemoAction(control)).length, - groups: controls.filter((control: any) => control.kind === 'ion-buttons' && isDemoAction(control)).length, + buttons: controls.filter((control: ShellControl) => control.kind === 'ion-button' && isDemoAction(control)).length, + groups: controls.filter((control: ShellControl) => control.kind === 'ion-buttons' && isDemoAction(control)).length, }; }), ) @@ -1234,11 +1321,12 @@ test('all demo pages keep projection consistent through consecutive navigation', await expect .poll(() => page.evaluate(() => { - const status = (window as any).nativeUIShell.getStatus(); - const snapshot = (window as any).__nativeUIShell.updates.at(-1); + const status = (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus(); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; return ( status.state !== 'stopped' && - status.projected === snapshot?.controls.reduce((count: number, control: any) => count + (control.search?.available ? 3 : 1), 0) + status.projected === + snapshot?.controls.reduce((count: number, control: ShellControl) => count + (control.search?.available ? 3 : 1), 0) ); }), ) @@ -1262,7 +1350,9 @@ test('all demo pages keep projection consistent through consecutive navigation', const back = async () => { await settled(); const hasNativeBack = await page.evaluate(() => - (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-back-button'), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.kind === 'ion-back-button'), ); if (hasNativeBack) await activate(page, 'back'); else @@ -1340,7 +1430,9 @@ test('demo overlay variants retire native controls and restore them after dismis await expect(page.locator('[data-native-ui-shell]')).toHaveCount(0); await overlay.evaluate((element: HTMLElement & { dismiss(): Promise }) => element.dismiss()); await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().state)).not.toBe('stopped'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().state)).not.toBe( + 'stopped', + ); }); } } @@ -1354,7 +1446,7 @@ test('shared tabs stay native throughout navigation and delayed page updates', a const tabs = page.locator('ion-tab-bar'); await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); await tabs.evaluate((element) => { - const state = (window as any).__nativeUIShell; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); state.updates = []; state.delay = 100; state.tabRetirements = 0; @@ -1374,9 +1466,9 @@ test('shared tabs stay native throughout navigation and delayed page updates', a await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - ?.controls.some((control: any) => control.items.some((item: any) => item.label === 'Index pending')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + ?.controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === 'Index pending')), ), ) .toBe(true); @@ -1396,9 +1488,9 @@ test('shared tabs stay native throughout navigation and delayed page updates', a await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((control: any) => control.items.some((item: any) => item.label === '更新中')), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((control: ShellControl) => control.items.some((item: ShellItem) => item.label === '更新中')), ), ) .toBe(true); @@ -1414,10 +1506,10 @@ test('shared tabs stay native throughout navigation and delayed page updates', a } await page.waitForTimeout(300); const state = await page.evaluate(() => ({ - retirements: (window as any).__nativeUIShell.tabRetirements, - details: (window as any).__nativeUIShell.retirementDetails, - ids: (window as any).__nativeUIShell.updates.map( - (snapshot: any) => snapshot.controls.find((control: any) => control.kind === 'ion-tab-bar')?.id, + retirements: Capacitor.registerPlugin('IonicNativeUIShell').tabRetirements, + details: Capacitor.registerPlugin('IonicNativeUIShell').retirementDetails, + ids: Capacitor.registerPlugin('IonicNativeUIShell').updates.map( + (snapshot: ShellSnapshot) => snapshot.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.id, ), })); expect(state.retirements, JSON.stringify(state.details)).toBe(0); @@ -1436,7 +1528,7 @@ test('native tabs carry position anchors for both slots and text directions', as for (const position of ['start', 'center', 'end']) { await tabs.evaluate( (element, state) => { - element.dir = state.direction; + (element as HTMLElement).dir = state.direction; element.slot = state.slot; element.classList.remove('tab-bar-position-start', 'tab-bar-position-center', 'tab-bar-position-end'); element.classList.add(`tab-bar-position-${state.position}`); @@ -1448,13 +1540,17 @@ test('native tabs carry position anchors for both slots and text directions', as .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-tab-bar') - ?.tabBarAnchor, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.tabBarAnchor, ), ) .toEqual({ x, y: slot === 'bottom' ? 1 : 0 }); - const native = await page.evaluate(() => - (window as any).__nativeUIShell.updates.at(-1).controls.find((control: any) => control.kind === 'ion-tab-bar'), + const native = await page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')!, ); const dom = await tabs.boundingBox(); expect(native.x).toBeCloseTo(dom!.x, 1); @@ -1471,9 +1567,12 @@ test('tab icons use selection tint only for text-colored SVGs', async ({ page }) await expect(tabs).toHaveAttribute('data-native-ui-shell', ''); const items = () => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-tab-bar')?.items, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-tab-bar')?.items, ); - await expect.poll(async () => (await items())?.every((item: any) => item.iconTemplate)).toBe(true); + await expect.poll(async () => (await items())?.every((item: ShellItem) => item.iconTemplate)).toBe(true); await tabs .locator('ion-icon') .first() @@ -1482,7 +1581,7 @@ test('tab icons use selection tint only for text-colored SVGs', async ({ page }) ''; }); await expect.poll(async () => (await items())?.[0]?.iconTemplate).toBe(false); - expect((await items()).slice(1).every((item: any) => item.iconTemplate)).toBe(true); + expect((await items())!.slice(1).every((item: ShellItem) => item.iconTemplate)).toBe(true); }); test('menu button toggles its Ionic menu and follows autoHide, disabled and split pane', async ({ page }) => { @@ -1498,8 +1597,9 @@ test('menu button toggles its Ionic menu and follows autoHide, disabled and spli .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].icon?.length ?? - 0, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].icon?.length ?? 0, ), ) .toBeGreaterThan(0); @@ -1523,7 +1623,10 @@ test('menu button toggles its Ionic menu and follows autoHide, disabled and spli await expect .poll(() => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].disabled, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].disabled, ), ) .toBe(true); @@ -1567,7 +1670,10 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa const surface = button.locator('..'); await expect(surface).toHaveAttribute('data-native-ui-shell', ''); const original = await page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button').items[0].icon, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')!.items[0].icon, ); await button.evaluate((element) => { element.innerHTML = ''; @@ -1575,7 +1681,10 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa await expect .poll(() => page.evaluate( - () => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === 'ion-menu-button')?.items[0].icon, + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-menu-button')?.items[0].icon, ), ) .not.toBe(original); @@ -1585,7 +1694,7 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa extra.fill = 'clear'; extra.textContent = 'Extra'; extra.addEventListener('click', () => { - (window as any).__menuExtra = true; + (document.querySelector('ion-app') as TestAppElement).menuExtra = true; }); element.append(extra); }); @@ -1593,14 +1702,15 @@ test('menu button projects slot icons and shared glass, restoring excluded surfa .poll(() => page.evaluate( () => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.find((c: any) => c.kind === 'ion-buttons' && c.items.some((i: any) => i.label === 'Extra'))?.items.length, + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === 'ion-buttons' && c.items.some((i: ShellItem) => i.label === 'Extra'))?.items + .length, ), ) .toBe(2); await activate(page, 'Extra'); - await expect.poll(() => page.evaluate(() => (window as any).__menuExtra)).toBe(true); + await expect.poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).menuExtra)).toBe(true); await activate(page, 'menu'); await expect(page.locator('ion-menu')).toHaveClass(/show-menu/); await expect.poll(() => page.locator('ion-menu').evaluate((element: HTMLIonMenuElement) => element.isOpen())).toBe(true); @@ -1644,39 +1754,45 @@ test('replacing a projected searchbar retires old input and binds the new field' await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const bar = footer.locator('ion-searchbar'); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await bar.evaluate((element: HTMLIonSearchbarElement) => element.setFocus()); await expect.poll(async () => (await config())?.active).toBe(true); const previous = await config(); await bar.evaluate((old: HTMLIonSearchbarElement) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const event = { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, phase: 'input', composing: false }; - state.search({ ...event, value: 'old field', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, value: 'old field', sequence: ++state.sequence }); const replacement = document.createElement('ion-searchbar'); for (const attribute of old.attributes) replacement.setAttribute(attribute.name, attribute.value); replacement.value = 'application initial value'; - (window as any).__retiredSearchbar = old; + (document.querySelector('ion-app') as TestAppElement).retiredSearchbar = old; old.replaceWith(replacement); // Arrive before the observer can retire the old state: DOM identity must reject it. - state.search({ ...event, value: 'stale input', sequence: ++state.sequence }); + state.notifyListeners('search', { ...event, value: 'stale input', sequence: ++state.sequence }); }); - expect(await page.evaluate(() => (window as any).__retiredSearchbar.value)).toBe('old field'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).retiredSearchbar!.value)).toBe('old field'); await expect .poll(async () => { const current = await config(); - return !!current && current.id !== previous.id && !current.active && current.value === 'application initial value'; + return !!current && current.id !== previous!.id && !current.active && current.value === 'application initial value'; }) .toBe(true); await bar.evaluate((element: HTMLIonSearchbarElement) => element.setFocus()); await expect.poll(async () => (await config())?.focused).toBe(true); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.search({ + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, @@ -1692,7 +1808,7 @@ test('replacing a projected searchbar retires old input and binds the new field' await expect .poll(async () => { const current = await config(); - return !!current && current.id !== session.id && !current.active && current.value === 'replacement input'; + return !!current && current.id !== session!.id && !current.active && current.value === 'replacement input'; }) .toBe(true); }); @@ -1704,15 +1820,21 @@ test('native search compensates keyboard tab hiding while respecting application await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); const tabBar = page.locator('ion-tab-bar'); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); await page.addStyleTag({ content: 'ion-tab-bar.application-hidden { display: none !important; }' }); for (const mode of ['inline', 'class']) { await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.activate({ - id: snapshot.controls.find((c: any) => c.search).search.trigger.id, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('activate', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.trigger.id, revision: snapshot.revision, sequence: ++state.sequence, }); @@ -1726,10 +1848,10 @@ test('native search compensates keyboard tab hiding while respecting application await expect(tabBar).not.toHaveClass(/tab-bar-hidden/); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.search({ + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, valueVersion: search.valueVersion, @@ -1765,34 +1887,46 @@ test('search group keeps its covers, forwards Ionic events and preserves text on await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - const config = () => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search); - expect((await config()).active).toBe(false); + const config = () => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search, + ); + expect((await config())?.active).toBe(false); await footer.evaluate((element) => { const bar = element.querySelector('ion-searchbar')!; - (window as any).__searchEvents = []; + (document.querySelector('ion-app') as TestAppElement).searchEvents = []; for (const name of ['ionFocus', 'ionInput', 'ionBlur', 'ionChange', 'ionClear', 'ionCancel']) { - bar.addEventListener(name, (event: any) => (window as any).__searchEvents.push([name, event.detail?.value])); + bar.addEventListener(name, (event: Event) => + (document.querySelector('ion-app') as TestAppElement).searchEvents!.push([name, (event as CustomEvent).detail?.value]), + ); } }); const action = async (close = false) => page.evaluate((close) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; - state.activate({ id: close ? search.closeId : search.trigger.id, revision: snapshot.revision, sequence: ++state.sequence }); + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; + state.notifyListeners('activate', { + id: close ? search.closeId : search.trigger.id, + revision: snapshot.revision, + sequence: ++state.sequence, + }); }, close); await action(); - await expect.poll(async () => (await config()).active).toBe(true); + await expect.poll(async () => (await config())?.active).toBe(true); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(page.url()).toContain('/main/album'); const nativeEvent = async (phase: string, value: string, composing = false) => page.evaluate( ({ phase, value, composing }) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - state.search({ - id: snapshot.controls.find((c: any) => c.search).search.id, - valueVersion: snapshot.controls.find((c: any) => c.search).search.valueVersion, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + state.notifyListeners('search', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.id, + valueVersion: snapshot.controls.find((c: ShellControl) => c.search)!.search!.valueVersion, revision: snapshot.revision, sequence: ++state.sequence, phase, @@ -1808,9 +1942,9 @@ test('search group keeps its covers, forwards Ionic events and preserves text on await nativeEvent('input', '日本', false); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', '日本'); await action(true); - await expect.poll(async () => (await config()).active).toBe(false); + await expect.poll(async () => (await config())?.active).toBe(false); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - expect(await page.evaluate(() => (window as any).__searchEvents.map((e: any) => e[0]))).toEqual([ + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).searchEvents!.map((e) => e[0]))).toEqual([ 'ionFocus', 'ionInput', 'ionInput', @@ -1818,16 +1952,20 @@ test('search group keeps its covers, forwards Ionic events and preserves text on 'ionChange', ]); await action(); - await expect.poll(async () => (await config()).active).toBe(true); - expect((await config()).value).toBe('日本'); + await expect.poll(async () => (await config())?.active).toBe(true); + expect((await config())?.value).toBe('日本'); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { bar.value = 'external'; }); - await expect.poll(async () => (await config()).value).toBe('external'); + await expect.poll(async () => (await config())?.value).toBe('external'); await nativeEvent('focus', 'external'); await nativeEvent('clear', 'external'); - await expect.poll(async () => (await config()).value).toBe(''); - expect(await page.evaluate(() => (window as any).__searchEvents.filter((e: any) => e[0] === 'ionClear').length)).toBe(1); + await expect.poll(async () => (await config())?.value).toBe(''); + expect( + await page.evaluate( + () => (document.querySelector('ion-app') as TestAppElement).searchEvents!.filter((e) => e[0] === 'ionClear').length, + ), + ).toBe(1); }); test('search retirement keeps the value, rejects late input and allows a fresh Web session', async ({ page }) => { @@ -1839,7 +1977,14 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.locator('app-album-page ion-fab-button').evaluate((button: HTMLElement) => button.click()); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(true); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { bar.value = 'retained'; @@ -1847,11 +1992,11 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await footer.evaluate((element) => element.classList.add('ionic-theme-disabled')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await page.evaluate(() => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.findLast((s: any) => s.controls.some((c: any) => c.search?.active)); - state.search({ - id: snapshot.controls.find((c: any) => c.search).search.id, - valueVersion: snapshot.controls.find((c: any) => c.search).search.valueVersion, + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.findLast((s: ShellSnapshot) => s.controls.some((c: ShellControl) => c.search?.active))!; + state.notifyListeners('search', { + id: snapshot.controls.find((c: ShellControl) => c.search)!.search!.id, + valueVersion: snapshot.controls.find((c: ShellControl) => c.search)!.search!.valueVersion, revision: snapshot.revision, sequence: ++state.sequence, phase: 'input', @@ -1870,7 +2015,14 @@ test('search retirement keeps the value, rejects late input and allows a fresh W await footer.evaluate((element) => element.classList.remove('ionic-theme-disabled')); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); expect(errors).toEqual([]); }); @@ -1920,13 +2072,20 @@ test('a Leave sent before native Enter completes remains the final state', async const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await page.evaluate(() => { - (window as any).__nativeUIShell.delay = 200; + Capacitor.registerPlugin('IonicNativeUIShell').delay = 200; (document.querySelector('app-album-page ion-fab-button') as HTMLElement).click(); (document.querySelector('app-album-page ion-footer ion-button') as HTMLElement).click(); }); await page.waitForTimeout(600); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(errors).toEqual([]); @@ -1941,14 +2100,21 @@ test('app normalization wins over old native input while blur still terminates f await expect(footer).toHaveAttribute('data-native-ui-shell', ''); await page.locator('app-album-page ion-fab-button').evaluate((button: HTMLElement) => button.click()); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(true); await footer.locator('ion-searchbar').evaluate((bar: HTMLIonSearchbarElement) => { - const state = (window as any).__nativeUIShell; - const snapshot = state.updates.at(-1); - const search = snapshot.controls.find((c: any) => c.search).search; + const state = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = state.updates.at(-1)!; + const search = snapshot.controls.find((c: ShellControl) => c.search)!.search!; const emit = (phase: string, value: string) => - state.search({ + state.notifyListeners('search', { id: search.id, revision: snapshot.revision, sequence: ++state.sequence, @@ -1971,7 +2137,14 @@ test('app normalization wins over old native input while blur still terminates f }); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'NORMALIZED'); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.focused)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.focused, + ), + ) .toBe(false); }); @@ -1985,13 +2158,13 @@ test('a lost search bridge releases Enter and keeps the current value in Web', a await page.evaluate(() => { const page = document.querySelector('app-album-page')!; (page.querySelector('ion-searchbar') as HTMLIonSearchbarElement).value = 'bridge retained'; - (window as any).__nativeUIShell.hang = true; + Capacitor.registerPlugin('IonicNativeUIShell').hang = true; (page.querySelector('ion-fab-button') as HTMLElement).click(); }); await expect(footer).not.toHaveAttribute('data-native-ui-shell', { timeout: 10000 }); await expect(footer).toHaveCSS('opacity', '1'); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'bridge retained'); - expect(await page.evaluate(() => (window as any).nativeUIShell.getStatus().state)).toBe('stopped'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUIShell!.getStatus().state)).toBe('web'); await footer.locator('ion-button').click(); await expect(footer).toHaveCSS('opacity', '0'); }); @@ -2002,15 +2175,17 @@ test('a retained FAB accepts a second click while its current native update awai const fab = page.locator('ion-fab[horizontal=center]'); await expect(fab).toHaveAttribute('data-native-ui-shell', ''); await page.evaluate(() => { - (window as any).__nativeUIShell.delay = 500; + Capacitor.registerPlugin('IonicNativeUIShell').delay = 500; }); await activate(page, 'Center FAB actions'); await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.length === 7 && c.items.filter((i: any) => i.visible).length > 1), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some( + (c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7 && c.items.filter((i: ShellItem) => i.visible).length > 1, + ), ), ) .toBe(true); @@ -2031,23 +2206,23 @@ test('FAB keeps its cover while Stencil show and the rendered host class catch u // Hold the real Stencil intermediate state long enough to exercise a bridge update. Object.defineProperty(child, 'show', { configurable: true, get: () => true }); child.classList.remove('fab-button-show'); - (window as any).__fabRetired = false; + (document.querySelector('ion-app') as TestAppElement).fabRetired = false; element.addEventListener('nativeUIShellChange', () => { - if (!element.hasAttribute('data-native-ui-shell')) (window as any).__fabRetired = true; + if (!element.hasAttribute('data-native-ui-shell')) (document.querySelector('ion-app') as TestAppElement).fabRetired = true; }); }); await expect .poll(() => page.evaluate(() => - (window as any).__nativeUIShell.updates - .at(-1) - .controls.some((c: any) => c.kind === 'ion-fab' && c.items.length === 7 && c.items[1].visible === false), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.kind === 'ion-fab' && c.items.length === 7 && c.items[1].visible === false), ), ) .toBe(true); await activate(page, 'Center FAB actions'); await expect(fab).toHaveJSProperty('activated', false); - expect(await page.evaluate(() => (window as any).__fabRetired)).toBe(false); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabRetired)).toBe(false); }); test('native resume retires search even when WebKit visibility stayed visible', async ({ page }) => { @@ -2061,7 +2236,14 @@ test('native resume retires search even when WebKit visibility stayed visible', }); await page.evaluate(() => window.dispatchEvent(Object.assign(new Event('nativeUIShellRefresh'), { retireSearch: true }))); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.search)?.search.active)) + .poll(() => + page.evaluate( + () => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.search)?.search?.active, + ), + ) .toBe(false); await expect(footer.locator('ion-searchbar')).toHaveJSProperty('value', 'background'); }); @@ -2106,14 +2288,23 @@ for (const [kind, selector] of [ const source = page.locator(`app-native-ui-shell ${selector}`); await expect(source).toHaveAttribute('data-native-ui-shell', ''); const id = await page.evaluate( - (kind) => (window as any).__nativeUIShell.updates.at(-1).controls.find((c: any) => c.kind === kind).id, + (kind) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.find((c: ShellControl) => c.kind === kind)!.id, kind, ); await source.evaluate((element) => { const zone = document.createElement('div'); zone.style.cssText = 'position:fixed;top:330px;left:12px;width:300px;height:140px;z-index:200'; element.closest('app-native-ui-shell')!.append(zone); - (window as any).__placement = { element, parent: element.parentElement, next: element.nextSibling, slot: element.slot, zone }; + (document.querySelector('ion-app') as TestAppElement).placement = { + element, + parent: element.parentElement, + next: element.nextSibling, + slot: element.slot, + zone, + }; }); for (const markup of [ '', @@ -2124,29 +2315,37 @@ for (const [kind, selector] of [ '', ]) { await page.evaluate((markup) => { - const { element, zone } = (window as any).__placement; + const { element, zone } = (document.querySelector('ion-app') as TestAppElement).placement!; // These destinations have no toolbar start slot; keep the Web control assigned. element.slot = ''; zone.innerHTML = markup; - zone.querySelector('[data-target]').append(element); + zone.querySelector('[data-target]')!.append(element); }, markup); await expect(source).not.toHaveAttribute('data-native-ui-shell'); await expect - .poll(() => page.evaluate((id) => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.id === id), id)) + .poll(() => + page.evaluate( + (id) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.id === id), + id, + ), + ) .toBe(false); await expect(source).toHaveCSS('visibility', 'visible'); await page.evaluate(() => { - const { element, parent, next, slot } = (window as any).__placement; - parent.insertBefore(element, next); + const { element, parent, next, slot } = (document.querySelector('ion-app') as TestAppElement).placement!; + parent!.insertBefore(element, next); element.slot = slot; }); await expect(source, `restored from ${markup}`).toHaveAttribute('data-native-ui-shell', ''); } // A footer toolbar is supported too; do not accidentally restrict this to headers. await page.evaluate(() => { - const { element, zone } = (window as any).__placement; + const { element, zone } = (document.querySelector('ion-app') as TestAppElement).placement!; zone.innerHTML = ''; - zone.querySelector('ion-toolbar').append(element); + zone.querySelector('ion-toolbar')!.append(element); }); await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); @@ -2184,11 +2383,11 @@ test('search registration cannot bypass fixed placement restrictions', async ({ const page = footer.parentElement!; const fab = page.querySelector('ion-fab')!; const buttons = footer.querySelector('ion-buttons')!; - (window as any).__searchPlacement = { page, footer, fab, buttons, toolbar: buttons.parentElement }; + (document.querySelector('ion-app') as TestAppElement).searchPlacement = { page, footer, fab, buttons, toolbar: buttons.parentElement! }; }); for (const placement of ['fab-slot', 'fab-wrapper', 'back-outside', 'footer-scroll']) { await page.evaluate((placement) => { - const { page, footer, fab, buttons } = (window as any).__searchPlacement; + const { page, footer, fab, buttons } = (document.querySelector('ion-app') as TestAppElement).searchPlacement!; if (placement === 'fab-slot') fab.removeAttribute('slot'); if (placement === 'fab-wrapper') { const wrapper = document.createElement('div'); @@ -2197,16 +2396,22 @@ test('search registration cannot bypass fixed placement restrictions', async ({ wrapper.append(fab); } if (placement === 'back-outside') footer.append(buttons); - if (placement === 'footer-scroll') page.querySelector('ion-content').append(footer); + if (placement === 'footer-scroll') page.querySelector('ion-content')!.append(footer); }, placement); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); await expect - .poll(() => page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).controls.some((c: any) => c.search?.available))) + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1)! + .controls.some((c: ShellControl) => c.search?.available), + ), + ) .toBe(false); await page.evaluate((placement) => { - const { page, footer, fab, buttons, toolbar } = (window as any).__searchPlacement; + const { page, footer, fab, buttons, toolbar } = (document.querySelector('ion-app') as TestAppElement).searchPlacement!; if (placement === 'fab-slot') fab.setAttribute('slot', 'fixed'); - if (placement === 'fab-wrapper') fab.parentElement.replaceWith(fab); + if (placement === 'fab-wrapper') fab.parentElement!.replaceWith(fab); if (placement === 'back-outside') toolbar.prepend(buttons); if (placement === 'footer-scroll') page.append(footer); }, placement); @@ -2249,8 +2454,10 @@ for (const theme of ['light', 'class', 'system', 'always'] as const) { await expect .poll(() => page.evaluate(() => { - const controls = (window as any).__nativeUIShell.updates.at(-1).controls; - return ['ion-tab-bar', 'ion-fab'].map((kind) => controls.filter((c: any) => c.kind === kind).map((c: any) => c.dark)); + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + return ['ion-tab-bar', 'ion-fab'].map((kind) => + controls.filter((c: ShellControl) => c.kind === kind).map((c: ShellControl) => c.dark), + ); }), ) .toEqual([[expected], [expected, expected, expected, expected]]); @@ -2302,7 +2509,7 @@ test('reduced motion hands off without a crossfade', async ({ page }) => { const segment = page.locator('app-native-ui-shell ion-segment'); await expect(segment).toHaveAttribute('data-native-ui-shell', ''); await expect(segment).not.toHaveAttribute('data-native-ui-shell-fading'); - const duration = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).transitionDuration); + const duration = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.transitionDuration); expect(duration).toBe(0); await segment.evaluate((el) => el.classList.add('ios-theme-shell-disabled')); await expect(segment).not.toHaveAttribute('data-native-ui-shell'); @@ -2314,17 +2521,18 @@ test('tab switches hand off without a crossfade', async ({ page }) => { await page.goto('/main/index/native-ui-shell'); const segment = page.locator('app-native-ui-shell ion-segment'); await expect(segment).toHaveAttribute('data-native-ui-shell', ''); - const before = await page.evaluate(() => (window as any).__nativeUIShell.updates.length); + const before = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); await activate(page, 'Docs'); await expect(page).toHaveURL(/\/main\/docs/); await expect .poll(() => page.evaluate( (start) => - (window as any).__nativeUIShell.updates - .slice(start) + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.slice(start) .some( - (update: any) => update.transitionDuration === 0 && !update.controls.some((control: any) => control.kind === 'ion-segment'), + (update: ShellSnapshot) => + update.transitionDuration === 0 && !update.controls.some((control: ShellControl) => control.kind === 'ion-segment'), ), before, ), @@ -2372,8 +2580,9 @@ for (const direction of ['ltr', 'rtl']) { await expect .poll(() => page.evaluate(() => { - const item = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-button') - ?.items[0]; + const item = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-button')?.items[0]; return item && [item.imagePadding, item.contentInsetLeading, item.contentInsetTrailing]; }), ) @@ -2401,8 +2610,9 @@ for (const direction of ['ltr', 'rtl']) { await expect .poll(() => page.evaluate(() => { - const item = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((control: any) => control.kind === 'ion-back-button') - ?.items[0]; + const item = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((control: ShellControl) => control.kind === 'ion-back-button')?.items[0]; return item && [item.imagePadding, item.contentInsetLeading, item.contentInsetTrailing]; }), ) @@ -2416,13 +2626,15 @@ test('rejected cached search is replaced by ordinary native tabs after navigatio await page.route('https://picsum.photos/**', (route) => route.abort()); await page.goto('/main/album'); await expect(page.locator('app-album-page ion-footer')).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectInactiveSearch = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectInactiveSearch = true)); await activate(page, 'Index'); await expect(page).toHaveURL('/main/index'); await expect .poll(() => page.evaluate(() => { - const control = (window as any).__nativeUIShell.updates.at(-1)?.controls.find((c: any) => c.kind === 'ion-tab-bar'); + const control = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.find((c: ShellControl) => c.kind === 'ion-tab-bar'); return !!control && !control.search; }), ) @@ -2442,22 +2654,22 @@ for (const invalidated of [false, true]) { await expect(button).toHaveAttribute('data-native-ui-shell', ''); await button.evaluate((el) => (el.parentElement!.hidden = true)); await expect(button).not.toHaveAttribute('data-native-ui-shell'); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 1000)); - const previous = await page.evaluate(() => (window as any).__nativeUIShell.updates.at(-1).revision); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 1000)); + const previous = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.revision); await button.evaluate((el) => (el.parentElement!.hidden = false)); await expect .poll(() => page.evaluate((previous) => { - const snapshot = (window as any).__nativeUIShell.updates.at(-1); - return snapshot.revision > previous && snapshot.controls.some((c: any) => c.kind === 'ion-button'); + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.revision > previous && snapshot.controls.some((c: ShellControl) => c.kind === 'ion-button'); }, previous), ) .toBe(true); await expect(button).not.toHaveAttribute('data-native-ui-shell'); await activate(page, (await button.locator('[data-label]').textContent()) as string, true); await expect(page.locator('[data-save-count]')).toHaveText('0'); - if (invalidated) await button.evaluate((el: any) => (el.disabled = true)); - await page.evaluate(() => ((window as any).__nativeUIShell.delay = 0)); + if (invalidated) await button.evaluate((el: HTMLIonButtonElement) => (el.disabled = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').delay = 0)); if (invalidated) { await page.waitForTimeout(1200); await expect(page.locator('[data-save-count]')).toHaveText('0'); @@ -2474,14 +2686,14 @@ test('rejected search retries when tab content changes without resizing', async await page.goto('/main/album'); const footer = page.locator('app-album-page ion-footer'); await expect(footer).toHaveAttribute('data-native-ui-shell', ''); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectAllSearch = true)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectAllSearch = true)); await page .locator('ion-tab-button ion-label') .first() .evaluate((el) => (el.textContent = 'Rejected')); await expect(footer).not.toHaveAttribute('data-native-ui-shell'); const before = await page.locator('ion-tab-bar').boundingBox(); - await page.evaluate(() => ((window as any).__nativeUIShell.rejectAllSearch = false)); + await page.evaluate(() => (Capacitor.registerPlugin('IonicNativeUIShell').rejectAllSearch = false)); await page .locator('ion-tab-button ion-label') .first() diff --git a/demo/e2e/popover.spec.ts b/demo/e2e/popover.spec.ts index a7d6e513..263e5998 100644 --- a/demo/e2e/popover.spec.ts +++ b/demo/e2e/popover.spec.ts @@ -1,5 +1,14 @@ import { expect, test } from '@playwright/test'; +/** Element the spec instruments to capture the bounds the animation measured. */ +interface MeasuredTrigger extends HTMLElement { + presentationBounds?: DOMRect; + restoreMeasurement?: () => void; +} + +/** `presented` exists on the component class but not its public element interface. */ +type PopoverProbe = HTMLIonPopoverElement & { presented: boolean }; + test('ordinary anchors show an arrow and morphing buttons do not', async ({ page }) => { await page.goto('/main/index/popover'); // Reopen the ordinary trigger to verify callout cleanup after a morphing popover. @@ -13,7 +22,7 @@ test('ordinary anchors show an arrow and morphing buttons do not', async ({ page } else { await expect(arrow).toBeVisible(); } - await popover.evaluate(async (el: any) => el.dismiss()); + await popover.evaluate(async (el: HTMLIonPopoverElement) => el.dismiss()); await expect(popover).toBeHidden(); } }); @@ -22,7 +31,7 @@ test('popover can be presented without a trigger', async ({ page }) => { await page.goto('/main/index/popover'); await page.waitForSelector('ion-popover.hydrated', { state: 'attached' }); const result = await page.evaluate(async () => { - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Unanchored content'; document.body.append(popover); @@ -35,7 +44,7 @@ test('popover can be presented without a trigger', async ({ page }) => { expect(result).toBe(true); }); -for (const side of ['top', 'bottom', 'left', 'right']) { +for (const side of ['top', 'bottom', 'left', 'right'] as const) { test(`arrow stays visible for ${side} placement`, async ({ page }) => { await page.setViewportSize({ width: 1210, height: 834 }); await page.goto('/main/index/popover'); @@ -44,7 +53,7 @@ for (const side of ['top', 'bottom', 'left', 'right']) { const anchor = document.createElement('button'); anchor.style.cssText = 'position:fixed;left:540px;top:350px;width:80px;height:44px'; document.body.append(anchor); - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Content'; popover.style.cssText = '--width:240px;--height:180px'; @@ -52,7 +61,7 @@ for (const side of ['top', 'bottom', 'left', 'right']) { popover.side = side; document.body.append(popover); await popover.present(); - const arrow = popover.shadowRoot.querySelector('[part="arrow"]'); + const arrow = popover.shadowRoot!.querySelector('[part="arrow"]')!; const rect = arrow.getBoundingClientRect(); const result = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; await popover.dismiss(); @@ -87,14 +96,14 @@ for (const width of [390, 1210]) { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); const anchorRect = anchor.getBoundingClientRect(); const paneRect = pane.getBoundingClientRect(); - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Content'; popover.event = { target: anchor }; popover.style.cssText = '--width:240px'; document.body.append(popover); await popover.present(); - const content = popover.shadowRoot.querySelector('[part="content"]'); + const content = popover.shadowRoot!.querySelector('[part="content"]')!; const rect = content.getBoundingClientRect(); const origin = parseFloat(getComputedStyle(content).transformOrigin); const result = { @@ -132,7 +141,7 @@ for (const width of [390, 1210]) { await trigger.scrollIntoViewIfNeeded(); // The pressed button can grow before presentation; compare the surface with // the visual bounds read by the animation, rather than the resting button. - await trigger.evaluate((el: any) => { + await trigger.evaluate((el: MeasuredTrigger) => { const measure = el.getBoundingClientRect; el.getBoundingClientRect = () => { const rect = measure.call(el); @@ -149,29 +158,29 @@ for (const width of [390, 1210]) { await expect(popover).toBeVisible(); await expect .poll(() => - popover.evaluate((el: any) => - el.shadowRoot - .querySelector('[part="content"]') + popover.evaluate((el: HTMLIonPopoverElement) => + el + .shadowRoot!.querySelector('[part="content"]')! .getAnimations() .every((a: Animation) => a.playState === 'finished'), ), ) .toBe(true); - const before = await trigger.evaluate((el: any) => { - const rect = el.presentationBounds; - el.restoreMeasurement(); + const before = await trigger.evaluate((el: MeasuredTrigger) => { + const rect = el.presentationBounds!; + el.restoreMeasurement!(); delete el.presentationBounds; return { x: rect.x, width: rect.width }; }); - const geometry = await popover.evaluate((el: any) => { - const trigger = document.getElementById(el.trigger)!; + const geometry = await popover.evaluate((el: HTMLIonPopoverElement) => { + const trigger = document.getElementById(el.trigger!)!; const pane = trigger.closest('ion-content')!; const scroll = pane.shadowRoot!.querySelector('[part="scroll"]')!; const style = getComputedStyle(scroll); const paneRect = pane.getBoundingClientRect(); - const content = el.shadowRoot.querySelector('[part="content"]'); + const content = el.shadowRoot!.querySelector('[part="content"]')!; const rect = content.getBoundingClientRect(); - const arrow = el.shadowRoot.querySelector('[part="arrow"]'); + const arrow = el.shadowRoot!.querySelector('[part="arrow"]')!; const arrowRect = arrow.getBoundingClientRect(); const anchor = trigger.getBoundingClientRect(); return { @@ -205,14 +214,14 @@ for (const width of [390, 1210]) { 1.5, ); } - await popover.evaluate(async (el: any) => el.dismiss()); + await popover.evaluate(async (el: HTMLIonPopoverElement) => el.dismiss()); await expect(popover).toBeHidden(); } }); } for (const tag of ['button', 'ion-button']) { - for (const side of ['top', 'bottom', 'left', 'right']) { + for (const side of ['top', 'bottom', 'left', 'right'] as const) { test(`event reference points to the click on ${tag} for ${side} placement`, async ({ page }) => { await page.setViewportSize({ width: 1210, height: 834 }); await page.goto('/main/index/popover'); @@ -223,7 +232,7 @@ for (const tag of ['button', 'ion-button']) { anchor.style.cssText = 'position:fixed;left:500px;top:300px;width:200px;height:120px'; anchor.textContent = 'Open'; document.body.append(anchor); - const popover = document.createElement('ion-popover') as any; + const popover = document.createElement('ion-popover') as PopoverProbe; popover.component = document.createElement('div'); popover.component.textContent = 'Content'; popover.style.cssText = '--width:240px;--height:180px'; @@ -232,12 +241,12 @@ for (const tag of ['button', 'ion-button']) { popover.side = side; document.body.append(popover); await popover.present(); - const root = popover.shadowRoot; - const content = root.querySelector('.popover-content'); + const root = popover.shadowRoot!; + const content = root.querySelector('.popover-content')!; const rect = content.getBoundingClientRect(); - const arrow = root.querySelector('[part="arrow"]').getBoundingClientRect(); + const arrow = root.querySelector('[part="arrow"]')!.getBoundingClientRect(); const origin = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat); - const layer = root.querySelector('[part="callout-glass"]'); + const layer = root.querySelector('[part="callout-glass"]')!; const layerRect = layer.getBoundingClientRect(); const layerOrigin = getComputedStyle(layer).transformOrigin.split(' ').map(parseFloat); const horizontal = side === 'left' || side === 'right'; diff --git a/demo/e2e/range-interaction.spec.ts b/demo/e2e/range-interaction.spec.ts index 7357fa3d..4c223f42 100644 --- a/demo/e2e/range-interaction.spec.ts +++ b/demo/e2e/range-interaction.spec.ts @@ -13,7 +13,7 @@ for (const direction of ['ltr', 'rtl']) { const range = page.locator('#range-probe'); await expect(range).toHaveClass(/hydrated/); await range.evaluate( - (el: any, { direction, endpoint }) => { + (el: HTMLIonRangeElement, { direction, endpoint }) => { el.dir = direction; el.min = 0; el.max = 100; @@ -33,7 +33,7 @@ for (const direction of ['ltr', 'rtl']) { await page.mouse.move(endX, initial.y + initial.height / 2, { steps: 8 }); await expect(range).toHaveClass(/range-pressed/); await expect.poll(() => knob.evaluate((el) => el.getBoundingClientRect().width)).toBeCloseTo(initial.width, 1); - await expect.poll(() => range.evaluate((el: any) => el.value)).toEqual({ lower: endpoint, upper: endpoint }); + await expect.poll(() => range.evaluate((el: HTMLIonRangeElement) => el.value)).toEqual({ lower: endpoint, upper: endpoint }); await page.mouse.up(); }); } @@ -47,7 +47,7 @@ for (const direction of ['ltr', 'rtl']) { }); const range = page.locator('#range-probe'); await expect(range).toHaveClass(/hydrated/); - await range.evaluate((el: any, direction) => { + await range.evaluate((el: HTMLIonRangeElement, direction) => { el.dir = direction; el.min = 0; el.max = 100; diff --git a/demo/e2e/rtl.spec.ts b/demo/e2e/rtl.spec.ts deleted file mode 100644 index 7c574bce..00000000 --- a/demo/e2e/rtl.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const direction of ['ltr', 'rtl'] as const) { - test(`${direction} mirrors logical spacing with asymmetric safe areas`, async ({ page }) => { - await page.goto('/main/index', { waitUntil: 'networkidle' }); - await page.evaluate((dir) => { - const fixture = document.createElement('div'); - fixture.id = 'rtl-probe'; - fixture.dir = dir; - fixture.style.cssText = - 'position:fixed;inset:0;z-index:99999;--ion-safe-area-left:20px;--ion-safe-area-right:8px;--ios-theme-menu-width:0px'; - - const fab = document.createElement('ion-fab'); - fab.id = 'fab-probe'; - fab.mode = 'ios'; - fab.className = 'fab-horizontal-start'; - fab.innerHTML = '+'; - - const tabs = document.createElement('ion-tab-bar'); - tabs.id = 'tabs-probe'; - tabs.mode = 'ios'; - tabs.slot = 'bottom'; - tabs.className = 'tab-bar-position-start'; - tabs.innerHTML = 'OneTwo'; - - const list = document.createElement('ion-list'); - list.id = 'list-probe'; - list.mode = 'ios'; - list.className = 'list-inset'; - list.innerHTML = - '
Supporting text
Item
Footer'; - - const toolbar = document.createElement('ion-toolbar'); - toolbar.id = 'toolbar-probe'; - toolbar.mode = 'ios'; - toolbar.className = 'toolbar-searchbar'; - toolbar.innerHTML = 'Action'; - - fixture.append(fab, tabs, list, toolbar); - document.body.append(fixture); - }, direction); - - await expect(page.locator('#fab-probe')).toHaveClass(/hydrated/); - await expect(page.locator('#tabs-probe')).toHaveClass(/hydrated/); - await expect(page.locator('#list-probe')).toHaveClass(/hydrated/); - - const values = await page.locator('#rtl-probe').evaluate((fixture) => { - const direction = fixture.getAttribute('dir'); - const style = (selector: string) => getComputedStyle(fixture.querySelector(selector)!); - const fab = fixture.querySelector('#fab-probe')!.getBoundingClientRect(); - const tabs = fixture.querySelector('#tabs-probe')!.getBoundingClientRect(); - const note = style('#list-probe > ion-note'); - const radio = style('.radio-group-top'); - const toolbarButtons = style('#toolbar-probe ion-buttons'); - - return { - fabStart: direction === 'ltr' ? fab.left : innerWidth - fab.right, - tabsStart: direction === 'ltr' ? tabs.left : innerWidth - tabs.right, - noteStart: note.marginInlineStart, - noteEnd: note.marginInlineEnd, - radioStart: radio.paddingInlineStart, - radioEnd: radio.paddingInlineEnd, - toolbarGap: toolbarButtons.marginInlineEnd, - }; - }); - - expect(values.fabStart).toBeCloseTo(16, 1); - expect(values.tabsStart).toBeCloseTo(direction === 'ltr' ? 36 : 24, 1); - expect(values.noteStart).toBe(direction === 'ltr' ? '40px' : '28px'); - expect(values.noteEnd).toBe(direction === 'ltr' ? '28px' : '40px'); - expect(values.radioStart).toBe(direction === 'ltr' ? '40px' : '28px'); - expect(values.radioEnd).toBe(direction === 'ltr' ? '28px' : '40px'); - expect(values.toolbarGap).toBe('6px'); - }); -} diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index 33e66c3b..5bec2e23 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -52,7 +52,7 @@ const prepareScreenShot = async (page: Page, routeName: string) => { await page.waitForSelector('ion-content[role="main"]', { timeout: 10000 }); await page.evaluate(() => document.fonts.ready); if (!routeName.includes(':')) { - const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: any) => { + const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: HTMLIonContentElement) => { const scrollEl = await el.getScrollElement(); return scrollEl.scrollHeight; }); @@ -61,7 +61,7 @@ const prepareScreenShot = async (page: Page, routeName: string) => { }; const prepareVerticalBarsLayout = async (page: Page, direction: 'ltr' | 'rtl', width = 700) => { - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.setViewportSize({ width, height: 900 }); await page.goto('/main/index', { waitUntil: 'networkidle' }); await page.waitForSelector('ion-content[role="main"]'); @@ -94,7 +94,7 @@ const prepareVerticalBarsLayout = async (page: Page, direction: 'ltr' | 'rtl', w }; const prepareVerticalBarsBackButton = async (page: Page, direction: 'ltr' | 'rtl') => { - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.setViewportSize({ width: 700, height: 900 }); await page.goto('/main/index/button', { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); @@ -107,7 +107,7 @@ const prepareVerticalBarsBackButton = async (page: Page, direction: 'ltr' | 'rtl }; const prepareVerticalBarsToolbar = async (page: Page) => { - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.setViewportSize({ width: 700, height: 900 }); await page.goto('/main/index/native-ui-shell', { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); @@ -122,7 +122,7 @@ test.describe('Screenshot Tests - All Routes', () => { for (const route of routes) { test(`should match screenshot for ${route.name}`, async ({ page }) => { // Set E2E testing flag to disable animations - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.goto(route.path, { waitUntil: 'networkidle' }); await prepareScreenShot(page, route.name); await expect(page).toHaveScreenshot(`${route.name}.png`, { @@ -139,7 +139,7 @@ test.describe('Screenshot Tests - Dark Mode', () => { for (const route of routes) { test(`should match dark mode screenshot for ${route.name}`, async ({ page }) => { // Set E2E testing flag to disable animations - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.goto(route.path, { waitUntil: 'networkidle' }); await page.evaluate(async () => { document.documentElement.classList.add('ion-palette-dark'); @@ -170,7 +170,7 @@ test.describe('Screenshot Tests - VerticalBars Layout', () => { }); test('Settings split-menu widths on iPhone Duo', async ({ page }) => { - await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); await page.setViewportSize({ width: 951, height: 669 }); await page.goto('/main/index', { waitUntil: 'networkidle' }); const splitPane = page.locator('ion-split-pane'); diff --git a/demo/e2e/submit-color.spec.ts b/demo/e2e/submit-color.spec.ts deleted file mode 100644 index a2956c56..00000000 --- a/demo/e2e/submit-color.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const dark of [false, true]) { - test(`glass button pressed shading: dark=${dark}`, async ({ page }) => { - await page.goto('/main/index/button'); - await expect(page.locator('ion-tab-bar')).toBeVisible(); - await page.evaluate((dark) => { - document.documentElement.classList.toggle('ion-palette-dark', dark); - const fixture = document.createElement('div'); - fixture.id = 'glass-button-probe'; - fixture.innerHTML = ` - Grouped - Standalone`; - document.body.append(fixture); - }, dark); - const buttons = page.locator('#glass-button-probe ion-button'); - await expect(buttons.locator('[part="native"]')).toHaveCount(2); - const read = () => - page.locator('#glass-button-probe').evaluate((fixture) => - [ - fixture.querySelector('ion-buttons')!, - fixture.querySelector(':scope > ion-button')!.shadowRoot!.querySelector('[part="native"]')!, - ].map((el) => { - const style = getComputedStyle(el); - return { background: style.backgroundColor, shadow: style.boxShadow }; - }), - ); - const rest = await read(); - await buttons.evaluateAll((elements) => elements.forEach((el) => el.classList.add('ion-activated'))); - const pressed = await read(); - if (dark) { - for (const surface of pressed) { - expect(surface.background).toBe('rgba(255, 255, 255, 0.5)'); - expect(surface.shadow).toContain('rgba(0, 0, 0, 0.32)'); - expect(surface.shadow).toContain('inset'); - } - } else { - for (const surface of pressed) expect(surface.background).toBe('rgba(255, 255, 255, 0.96)'); - } - await buttons.evaluateAll((elements) => elements.forEach((el) => el.classList.remove('ion-activated'))); - expect(await read()).toEqual(rest); - }); -} - -for (const color of [undefined, 'primary', 'danger', 'brand']) { - test(`submit preserves ${color ?? 'default'} color and contrast when pressed`, async ({ page }) => { - await page.goto('/main/index/button'); - await page.addStyleTag({ content: '.ion-color-brand { --ion-color-base: #ffee00; --ion-color-contrast: #112233; }' }); - await page.evaluate((color) => { - const button = document.createElement('ion-button'); - button.id = 'submit-probe'; - button.mode = 'ios'; - button.setAttribute('type', 'submit'); - button.color = color; - button.textContent = 'Submit'; - button.style.cssText = 'position:fixed;top:100px;left:20px;z-index:99999'; - document.body.append(button); - }, color); - const button = page.locator('#submit-probe'); - await expect(button).toHaveClass(/hydrated/); - const expected = await button.evaluate((element, color) => { - const style = getComputedStyle(element); - const background = style.getPropertyValue(color ? '--ion-color-base' : '--ion-color-primary').trim(); - const contrast = style.getPropertyValue(color ? '--ion-color-contrast' : '--ion-color-primary-contrast').trim(); - const probe = document.createElement('span'); - probe.style.color = contrast; - document.body.append(probe); - const foreground = getComputedStyle(probe).color; - probe.remove(); - return { background, foreground }; - }, color); - const native = button.locator('button'); - await expect(native).toHaveCSS('color', expected.foreground); - await button.evaluate((element) => element.classList.add('ion-activated')); - await expect - .poll(() => button.evaluate((element) => getComputedStyle(element).getPropertyValue('--background-activated').trim())) - .toBe(expected.background); - await expect(native).toHaveCSS('color', expected.foreground); - }); -} diff --git a/demo/e2e/tab-bar-position.spec.ts b/demo/e2e/tab-bar-position.spec.ts deleted file mode 100644 index 06e32e7f..00000000 --- a/demo/e2e/tab-bar-position.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const direction of ['ltr', 'rtl']) { - for (const slot of ['top', 'bottom']) { - for (const count of [2, 3, 4, 5] as const) { - test(`${direction} ${slot} ${count} tabs support all positions without shifting the press origin`, async ({ page }) => { - await page.setViewportSize({ width: 800, height: 900 }); - await page.goto('/main/index'); - await page.evaluate( - ({ direction, slot, count }) => { - const fixture = document.createElement('div'); - fixture.style.cssText = 'position:fixed;inset:0;z-index:99999;--ion-safe-area-left:20px;--ion-safe-area-right:8px'; - fixture.dir = direction; - const bar = document.createElement('ion-tab-bar'); - bar.id = 'position-probe'; - bar.mode = 'ios'; - bar.slot = slot; - for (let i = 0; i < count; i++) { - const button = document.createElement('ion-tab-button'); - button.mode = 'ios'; - button.textContent = `Tab ${i + 1}`; - bar.append(button); - } - fixture.append(bar); - document.body.append(fixture); - }, - { direction, slot, count }, - ); - const bar = page.locator('#position-probe'); - await expect(bar).toHaveClass(/hydrated/); - for (const position of ['start', 'center', 'end']) { - await bar.evaluate((element, position) => { - element.classList.remove('tab-bar-position-start', 'tab-bar-position-center', 'tab-bar-position-end'); - element.classList.add(`tab-bar-position-${position}`); - }, position); - const normal = (await bar.boundingBox())!; - const nativeWidth = { 2: 188, 3: 274, 4: 336, 5: 414 }[count]!; - // A half-pixel border rounds differently at desktop and device DPR. - expect(Math.abs(normal.width - nativeWidth)).toBeLessThanOrEqual(1); - if (position === 'center') { - expect(normal.x + normal.width / 2).toBeCloseTo(406, 1); - } else if ((position === 'start') === (direction === 'ltr')) { - expect(normal.x).toBeCloseTo(36, 1); - } else { - expect(normal.x + normal.width).toBeCloseTo(776, 1); - } - await bar - .locator('ion-tab-button') - .first() - .evaluate((button) => button.classList.add('ion-activated')); - await expect - .poll(() => bar.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).a)) - .toBeCloseTo(1.038, 3); - const pressed = (await bar.boundingBox())!; - expect(pressed.x + pressed.width / 2).toBeCloseTo(normal.x + normal.width / 2, 1); - await bar - .locator('ion-tab-button') - .first() - .evaluate((button) => button.classList.remove('ion-activated')); - await expect.poll(() => bar.evaluate((element) => element.getBoundingClientRect().width)).toBeCloseTo(normal.width, 1); - } - }); - } - } -} diff --git a/demo/e2e/toggle.spec.ts b/demo/e2e/toggle.spec.ts index 07815463..5a677417 100644 --- a/demo/e2e/toggle.spec.ts +++ b/demo/e2e/toggle.spec.ts @@ -102,30 +102,6 @@ test('reduced motion removes the release transition', async ({ page }) => { expect((await toggle.locator('[part="handle"]').boundingBox())!.height).toBe(24); }); -test('native resting and held dimensions preserve the handle center', async ({ page }) => { - const toggle = page.locator('.section-example ion-toggle').first(); - const track = toggle.locator('[part="track"]'); - const handle = toggle.locator('[part="handle"]'); - await track.scrollIntoViewIfNeeded(); - const rest = (await handle.boundingBox())!; - const bounds = (await track.boundingBox())!; - expect(bounds.width).toBe(63); - expect(bounds.height).toBe(28); - expect(rest.width).toBe(37); - expect(rest.height).toBe(24); - await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await page.mouse.down(); - // Measure the held state, not the same width on the initial spring's way up. - await page.waitForTimeout(400); - await expect.poll(async () => Math.round((await handle.boundingBox())!.width)).toBe(58); - const held = (await handle.boundingBox())!; - expect(held.height).toBeCloseTo(38.333, 0); - expect(held.x + held.width / 2).toBeCloseTo(rest.x + rest.width / 2, 1); - await page.mouse.up(); - await expect.poll(async () => Math.round((await handle.boundingBox())!.width)).toBe(37); - await expect.poll(async () => Math.round((await handle.boundingBox())!.x - rest.x)).toBe(22); -}); - test('custom handle shadow remains overridable', async ({ page }) => { const toggle = page.locator('.section-example ion-toggle').first(); await toggle.evaluate((element) => element.style.setProperty('--handle-box-shadow', 'none')); @@ -190,24 +166,6 @@ for (const duration of [50, 80, 100, 150, 600]) { }); } -test('checked toggle uses its Ionic palette color', async ({ page }) => { - const toggle = page.locator('.section-example ion-toggle').first(); - await toggle.evaluate((el) => { - el.setAttribute('color', 'danger'); - (el as HTMLIonToggleElement).checked = true; - }); - await expect(toggle).toHaveClass(/ion-color-danger/); - const expected = await toggle.evaluate((el) => { - const probe = document.createElement('span'); - probe.style.color = getComputedStyle(el).getPropertyValue('--ion-color-base'); - el.append(probe); - const color = getComputedStyle(probe).color; - probe.remove(); - return color; - }); - await expect(toggle.locator('[part="track"]')).toHaveCSS('background-color', expected); -}); - test('native handle still moves when lens CSS is unavailable', async ({ page }) => { const removed = await page.evaluate(() => { let count = 0; diff --git a/demo/e2e/vertical-bars-back-button.spec.ts b/demo/e2e/vertical-bars-back-button.spec.ts index 2044dc95..f4c3cd38 100644 --- a/demo/e2e/vertical-bars-back-button.spec.ts +++ b/demo/e2e/vertical-bars-back-button.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import type { TestAppElement } from './native-shell-mock'; test('verticalBars mode replaces the toolbar back button with an interactive Web projection', async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); @@ -14,17 +15,18 @@ test('verticalBars mode replaces the toolbar back button with an interactive Web await source.evaluate((element) => { const original = element.getBoundingClientRect.bind(element); - (window as any).verticalBarsBackButtonReads = 0; + const app = document.querySelector('ion-app') as TestAppElement; + app.verticalBarsBackButtonReads = 0; element.getBoundingClientRect = () => { - (window as any).verticalBarsBackButtonReads++; + app.verticalBarsBackButtonReads = (app.verticalBarsBackButtonReads ?? 0) + 1; return original(); }; element.toggleAttribute('data-projection-sync'); }); await page.waitForTimeout(100); - const settledReads = await page.evaluate(() => (window as any).verticalBarsBackButtonReads); + const settledReads = await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackButtonReads); await page.waitForTimeout(150); - expect(await page.evaluate(() => (window as any).verticalBarsBackButtonReads)).toBe(settledReads); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackButtonReads)).toBe(settledReads); await projection.click(); await expect(page).toHaveURL(/\/main\/index$/); @@ -51,11 +53,14 @@ test('Native UI Shell suspension synchronously restores and resumes verticalBars const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); await expect(projection).toBeVisible(); - await page.evaluate(async () => Object.assign(window, { verticalBarsLease: await (window as any).nativeUIShell.suspend() })); + await page.evaluate(async () => { + const app = document.querySelector('ion-app') as TestAppElement; + app.verticalBarsLease = await app.nativeUIShell!.suspend(); + }); await expect(source).toBeVisible(); await expect(projection).toHaveCount(0); - await page.evaluate(async () => (window as any).verticalBarsLease.resume()); + await page.evaluate(async () => (document.querySelector('ion-app') as TestAppElement).verticalBarsLease!.resume()); await expect(source).toBeHidden(); await expect(projection).toBeVisible(); }); @@ -67,7 +72,7 @@ test('a stale suspension lease cannot re-hide Web controls after shell teardown' await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible(); await page.evaluate(async () => { - const shell = (window as any).nativeUIShell; + const shell = (document.querySelector('ion-app') as TestAppElement).nativeUIShell!; const lease = await shell.suspend(); await shell.destroy(); await lease.resume(); diff --git a/demo/src/app/app.config.ts b/demo/src/app/app.config.ts index 4d86197f..fc27fca0 100644 --- a/demo/src/app/app.config.ts +++ b/demo/src/app/app.config.ts @@ -3,7 +3,7 @@ import { provideRouter, withComponentInputBinding } from '@angular/router'; import * as allIcons from 'ionicons/icons'; import { routes } from './app.routes'; -import { IONIC_MAJOR, provideIonicAngular } from '@demo/ionic'; +import { IONIC_MAJOR, provideIonicAngular, type AnimationBuilder } from '@demo/ionic'; import { addIcons } from 'ionicons'; addIcons(allIcons); @@ -13,13 +13,13 @@ if (typeof document !== 'undefined') { } export interface IonicAnimationOptions { - navAnimation?: (...args: any[]) => any; - popoverEnter?: (...args: any[]) => any; - popoverLeave?: (...args: any[]) => any; + navAnimation?: AnimationBuilder; + popoverEnter?: AnimationBuilder; + popoverLeave?: AnimationBuilder; } // Disable animations during E2E tests for consistent screenshots -const isE2ETesting = typeof window !== 'undefined' && (window as any).IONIC_E2E_TESTING === true; +const isE2ETesting = typeof document !== 'undefined' && (document as Document & { IONIC_E2E_TESTING?: boolean }).IONIC_E2E_TESTING === true; export const createAppConfig = (animations: IonicAnimationOptions = {}): ApplicationConfig => ({ providers: [ diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index 21c8ac8f..014ee7b7 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -3,19 +3,19 @@ - + Index - + Docs - + Library - + Settings diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index 6ff5ae5a..89ed0826 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -44,7 +44,7 @@ import { Capacitor } from '@capacitor/core'; export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ViewDidLeave { readonly #router = inject(Router); readonly #el = inject(ElementRef); - private readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); + readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); #hingeListener?: { remove(): Promise }; #hingeMonitoring = false; #destroyed = false; @@ -78,16 +78,16 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, if (Capacitor.getPlatform() !== 'ios') return; await IonicNativeUIShell.startDeviceLayoutMonitoring(); this.#hingeMonitoring = true; - if (this.#destroyed) return this.releaseHinge(); + if (this.#destroyed) return this.#releaseHinge(); this.#hingeListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { if (!this.#destroyed) this.setHingeStatus(hingeStatus); }); const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); - if (this.#destroyed) return this.releaseHinge(); + if (this.#destroyed) return this.#releaseHinge(); this.setHingeStatus(hingeStatus); } - private releaseHinge() { + #releaseHinge() { void this.#hingeListener?.remove(); this.#hingeListener = undefined; if (this.#hingeMonitoring) { @@ -98,7 +98,7 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ngOnDestroy() { this.#destroyed = true; - this.releaseHinge(); + this.#releaseHinge(); } ionViewDidEnter() { diff --git a/demo/src/main.ts b/demo/src/main.ts index a9b48ca8..e76f1057 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -36,4 +36,7 @@ void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) }) .catch((err) => console.error(err)); const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; -void startShell().then((handle) => Object.assign(window, { nativeUIShell: handle })); +void startShell().then((handle) => { + const app = document.querySelector('ion-app'); + if (app) Object.assign(app, { nativeUIShell: handle }); +}); diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 8d212007..de4b8303 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -1,34 +1,6 @@ -import { expect, test, vi } from 'vitest'; -import type { NativeUIShellHandle } from '../../src/native'; -import { bindMetricsLifecycle } from '../../src/native/lifecycle'; +import { expect, test } from 'vitest'; import { enableNativeUIShell, setVerticalControlAreaPlacement } from '../../src/native'; -const runtime = (destroy = vi.fn(async () => {})): NativeUIShellHandle => ({ - getStatus: () => ({ state: 'native', projected: 1, updates: 1 }), - suspend: async () => ({ resume: async () => {} }), - destroy, -}); - -test('listener registration failure destroys the initialized runtime', async () => { - const destroy = vi.fn(async () => {}); - const failure = new Error('listener registration failed'); - - await expect(bindMetricsLifecycle(runtime(destroy), async () => Promise.reject(failure), vi.fn())).rejects.toBe(failure); - expect(destroy).toHaveBeenCalledOnce(); -}); - -test('listener removal failure still destroys and deactivates once', async () => { - const destroy = vi.fn(async () => {}); - const deactivate = vi.fn(); - const failure = new Error('listener removal failed'); - const handle = await bindMetricsLifecycle(runtime(destroy), async () => ({ remove: async () => Promise.reject(failure) }), deactivate); - - await expect(handle.destroy()).rejects.toBe(failure); - await expect(handle.destroy()).rejects.toBe(failure); - expect(destroy).toHaveBeenCalledOnce(); - expect(deactivate).toHaveBeenCalledOnce(); -}); - test('placement requires ion-app and clears it when disabled', () => { document.body.replaceChildren(); expect(() => setVerticalControlAreaPlacement('right')).toThrow('requires ion-app'); diff --git a/demo/tsconfig.json b/demo/tsconfig.json index be67a836..f917e6ca 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -22,6 +22,7 @@ "target": "es2022", "module": "es2020", "useDefineForClassFields": false, + "lib": ["es2023", "dom", "dom.iterable", "webworker.importscripts", "scripthost"], "skipLibCheck": true }, "angularCompilerOptions": { diff --git a/src/native/index.ts b/src/native/index.ts index 410a9e13..d0a714d4 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -58,6 +58,42 @@ const combine = (native: NativeUIShellHandle, fallback: NativeUIShellHandle): Na }, }); +/** Wraps a runtime handle with the shared enable-lifecycle: reason, suspend hooks and idempotent destroy. */ +const manage = ( + handle: NativeUIShellHandle, + lifecycle: { + reason?: string; + suspend?: () => (() => void) | undefined; + destroy?: () => void | Promise; + } = {}, +): NativeUIShellHandle => { + let destroyed = false; + return { + getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()), + async suspend() { + const lease = await handle.suspend(); + const resume = lifecycle.suspend?.(); + return { + async resume() { + await lease.resume(); + resume?.(); + }, + }; + }, + async destroy() { + if (destroyed) return; + destroyed = true; + try { + await handle.destroy(); + } finally { + await lifecycle.destroy?.(); + active = undefined; + activeConfiguration = undefined; + } + }, + }; +}; + /** Reads the current native WebView geometry and applies it to page transitions. */ export const configureNativeTransition = async (): Promise => { const metrics = @@ -113,22 +149,25 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'), ); activeConfiguration = configuration; - const stopPrehide = + const prehide = !active && (options.controls === undefined || options.controls.toolbar === true) ? prehideVerticalBarsToolbarSources(document) : undefined; + const fallback = (reason: string) => + manage(createVerticalBarsWebProjection(document, options), { + reason, + suspend: () => prehide?.suspend(), + destroy: () => prehide?.stop(), + }); return (active ??= (async () => { - if (Capacitor.getPlatform() !== 'ios') - return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires Capacitor iOS'), stopPrehide); + if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; let placementListener: Awaited> | undefined; let monitoring = false; try { if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); - if (!capabilities.supported) { - return resetOnDestroy(withReason(createVerticalBarsWebProjection(document, options), 'Requires iOS 26 or later'), stopPrehide); - } + if (!capabilities.supported) return fallback('Requires iOS 26 or later'); let nativeEdge: VerticalBarEdge = null; const nativeVerticalBars = () => { const root = document.querySelector('ion-app.ios-theme-vertical-bars'); @@ -142,73 +181,23 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); }); nativeEdge = (await plugin.getDeviceLayout()).placement.edge; - runtime = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); runtime = combine( - runtime, + await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true), createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars()), ); - return resetOnDestroy(withPlacementListener(runtime, placementListener), stopPrehide); + return manage(runtime, { + suspend: () => prehide?.suspend(), + destroy: async () => { + await placementListener?.remove().catch(() => {}); + if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + prehide?.stop(); + }, + }); } catch (error) { await runtime?.destroy(); await placementListener?.remove().catch(() => {}); if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); - return resetOnDestroy( - withReason(createVerticalBarsWebProjection(document, options), error instanceof Error ? error.message : String(error)), - stopPrehide, - ); + return fallback(error instanceof Error ? error.message : String(error)); } })()); }; - -const withPlacementListener = ( - handle: NativeUIShellHandle, - listener: Awaited>, -): NativeUIShellHandle => { - let destroyed = false; - return { - getStatus: () => handle.getStatus(), - suspend: () => handle.suspend(), - async destroy() { - if (destroyed) return; - destroyed = true; - try { - await handle.destroy(); - } finally { - await listener.remove().catch(() => {}); - await plugin.stopDeviceLayoutMonitoring().catch(() => {}); - } - }, - }; -}; - -const resetOnDestroy = ( - handle: NativeUIShellHandle, - prehide?: ReturnType, -): NativeUIShellHandle => ({ - getStatus: handle.getStatus, - async suspend() { - const lease = await handle.suspend(); - const resumePrehide = prehide?.suspend(); - return { - async resume() { - await lease.resume(); - resumePrehide?.(); - }, - }; - }, - async destroy() { - try { - await handle.destroy(); - } finally { - prehide?.stop(); - active = undefined; - activeConfiguration = undefined; - } - }, -}); - -const withReason = (handle: NativeUIShellHandle, reason: string): NativeUIShellHandle => ({ - getStatus: () => ({ ...handle.getStatus(), reason }), - suspend: () => handle.suspend(), - destroy: () => handle.destroy(), -}); diff --git a/src/native/lifecycle.ts b/src/native/lifecycle.ts deleted file mode 100644 index a8e18d72..00000000 --- a/src/native/lifecycle.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PluginListenerHandle } from '@capacitor/core'; -import type { NativeUIShellHandle } from './definitions'; - -export const bindMetricsLifecycle = async ( - runtime: NativeUIShellHandle, - listen: () => Promise, - deactivate: () => void, -): Promise => { - let listener: PluginListenerHandle; - try { - listener = await listen(); - } catch (error) { - await runtime.destroy(); - throw error; - } - - let destroying: Promise | undefined; - return { - getStatus: runtime.getStatus, - suspend: runtime.suspend, - destroy() { - return (destroying ??= (async () => { - let removalError: unknown; - try { - await listener.remove(); - } catch (error) { - removalError = error; - } - try { - await runtime.destroy(); - } finally { - deactivate(); - } - if (removalError) throw removalError; - })()); - }, - }; -}; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 9a93e4bc..ec7205b8 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -5,6 +5,7 @@ import { createVerticalBarsPageState, verticalBarsEnteringPage, verticalBarsToolbarActions, + inFixedToolbar, isExcluded, isVerticalBarsToolbarGroup, isShellDisabled, @@ -72,22 +73,13 @@ export const createVerticalBarsWebProjection = ( 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 = verticalBarsRoot(); - const toolbar = element.closest('ion-toolbar'); - const edge = toolbar?.parentElement; - return ( - !!currentRoot?.contains(element) && - !!toolbar && - !!edge?.matches('ion-header, ion-footer') && - !element.closest('ion-content') && - !edge.hasAttribute('collapse') && - !isExcluded(element, verticalBarsEnteringPage(element)) && - !isShellDisabled(element) && - !verticalBarsPages.isDeparted(element) && - !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden') - ); - }; + const inEligibleToolbar = (element: HTMLElement) => + !!verticalBarsRoot()?.contains(element) && + inFixedToolbar(element) && + !isExcluded(element, verticalBarsEnteringPage(element)) && + !isShellDisabled(element) && + !verticalBarsPages.isDeparted(element) && + !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden'); const isEligibleBack = (element: HTMLIonBackButtonElement) => !!verticalBarsRoot()?.contains(element) && verticalBarsOwned(element) && diff --git a/src/transition/index.ts b/src/transition/index.ts index 607ebf40..a092cd30 100644 --- a/src/transition/index.ts +++ b/src/transition/index.ts @@ -170,7 +170,7 @@ const waitForReady = async (opts: TransitionOptions, defaultDeep: boolean) => { await notifyViewReady(opts.viewIsReady, opts.enteringEl); }; -const notifyViewReady = async (viewIsReady: undefined | ((enteringEl: HTMLElement) => Promise), enteringEl: HTMLElement) => { +const notifyViewReady = async (viewIsReady: undefined | ((enteringEl: HTMLElement) => Promise), enteringEl: HTMLElement) => { if (viewIsReady) { await viewIsReady(enteringEl); } @@ -180,7 +180,7 @@ const playTransition = (trans: Animation, opts: TransitionOptions): Promise((resolve) => { - trans.onFinish((currentStep: any) => resolve(currentStep === 1)); + trans.onFinish((currentStep) => resolve(currentStep === 1)); }); // cool, let's do this, start the transition @@ -236,8 +236,13 @@ export const waitForMount = (): Promise => { return new Promise((resolve) => raf(() => raf(() => resolve()))); }; -export const deepReady = async (el: any | undefined): Promise => { - const element = el as any; +type LazyLoadedElement = Element & { + componentOnReady?: () => Promise; + __registerHost?: unknown; +}; + +export const deepReady = async (el: Element | undefined): Promise => { + const element = el as LazyLoadedElement | undefined; if (element) { if (element.componentOnReady != null) { // eslint-disable-next-line custom-rules/no-component-on-ready-method @@ -259,7 +264,7 @@ export const deepReady = async (el: any | undefined): Promise => { return; } - await Promise.all(Array.from(element.children).map(deepReady)); + await Promise.all(Array.from(element.children).map((child) => deepReady(child))); } }; @@ -358,7 +363,7 @@ const getIosIonHeader = (opts: TransitionOptions): HTMLElement | null => { export interface TransitionOptions extends NavOptions { progressCallback?: (ani: Animation | undefined) => void; - baseEl: any; + baseEl: HTMLElement; enteringEl: HTMLElement; leavingEl: HTMLElement | undefined; } diff --git a/src/utils.ts b/src/utils.ts index 126f620c..b9e8a71d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,8 @@ import { AnimationPosition } from './sheets-of-glass/interfaces'; import { IonicConfig } from '@ionic/core'; -declare const __zone_symbol__requestAnimationFrame: any; -declare const requestAnimationFrame: any; +declare const __zone_symbol__requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined; +declare const requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined; export const getElementRoot = (el: HTMLElement, fallback: HTMLElement = el) => { return el.shadowRoot || fallback; @@ -58,15 +58,15 @@ export const changeSelectedElement = ( }; export class Config { - private m = new Map(); + private m = new Map(); reset(configObj: IonicConfig) { - this.m = new Map(Object.entries(configObj) as any); + this.m = new Map(Object.entries(configObj) as [keyof IonicConfig, unknown][]); } - get(key: keyof IonicConfig, fallback?: any): any { + get(key: keyof IonicConfig, fallback?: T): T { const value = this.m.get(key); - return value !== undefined ? value : fallback; + return value !== undefined ? (value as T) : (fallback as T); } getBoolean(key: keyof IonicConfig, fallback = false): boolean { @@ -81,11 +81,11 @@ export class Config { } getNumber(key: keyof IonicConfig, fallback?: number): number { - const val = parseFloat(this.m.get(key)); + const val = parseFloat(String(this.m.get(key))); return isNaN(val) ? (fallback !== undefined ? fallback : NaN) : val; } - set(key: keyof IonicConfig, value: any) { + set(key: keyof IonicConfig, value: unknown) { this.m.set(key, value); } } From 4b0ed35c9ad4fb27487e94a53ff4faef73f35d20 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 19:18:31 +0900 Subject: [PATCH 16/17] refactor(vertical-bars): tighten native plugin types - Store search controllers behind ShellSearchControlling so the UIViewController dictionary stops round-tripping through as? casts. - Replace the erased trait-registration token with an unregister closure and hold the hinge interaction as UIInteraction. - Gate iOS 27.1-only SwiftUI symbols in ShellVerticalBars behind the same canImport check the plugin uses; Xcode 27.0 cannot see them. --- .../Components/ShellSearchController.swift | 16 ++++++- .../Components/ShellVerticalBars.swift | 10 +++++ .../IonicNativeUIShellPlugin.swift | 45 ++++++++----------- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift index d2b71b3a..0a79ea39 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift @@ -45,8 +45,22 @@ private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate { func textFieldShouldClear(_ textField: UITextField) -> Bool { clear?(); return false } } +// Lets the plugin registry retain iOS 26-gated search controllers without +// availability-gated stored properties, mirroring ShellVerticalBarsControlling. +protocol ShellSearchControlling: AnyObject { + var surface: ShellSearchHost { get } + var ownsKeyboard: Bool { get } + var ownsKeyboardChrome: Bool { get } + var transitionCoordinator: UIViewControllerTransitionCoordinator? { get } + var activate: ((String) -> Void)? { get set } + var changed: ((String, ShellSearchPhase, String, Bool, Int) -> Int)? { get set } + func attach(to parent: UIViewController, in container: UIView) + func detach() + func apply(_ snapshot: ShellControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect, rendering: ShellRendering) -> Bool +} + @available(iOS 26.0, *) -final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate { +final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate, ShellSearchControlling { // Wire stays active+focused; local session drives chrome (idle / presented / focused). private enum Session: Equatable { case idle, presented, focused } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index dc7458c5..79522e51 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -165,11 +165,15 @@ private struct ShellVerticalBarsPage: View { @available(iOS 26.0, *) private struct ShellVerticalBarsCompression: ViewModifier { @ViewBuilder func body(content: Content) -> some View { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) if #available(iOS 27.1, *) { content.toolbarVerticalCompressionBehavior(.prefersToolbarItems) } else { content } + #else + content + #endif } } @@ -178,14 +182,19 @@ private struct ShellVerticalBarsToolbarAdapter: ViewModifier { @ObservedObject var model: ShellVerticalBarsModel @ViewBuilder func body(content: Content) -> some View { + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) if #available(iOS 27.1, *) { content.modifier(ShellVerticalBarsToolbar(model: model)) } else { content.modifier(ShellVerticalBarsLegacyToolbar(model: model)) } + #else + content.modifier(ShellVerticalBarsLegacyToolbar(model: model)) + #endif } } +#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) @available(iOS 27.1, *) private struct ShellVerticalBarsToolbar: ViewModifier { @ObservedObject var model: ShellVerticalBarsModel @@ -217,6 +226,7 @@ private struct ShellVerticalBarsToolbar: ViewModifier { } } } +#endif @available(iOS 26.0, *) private struct ShellVerticalBarsLegacyToolbar: ViewModifier { diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index e51971fe..9165b570 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -17,7 +17,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var host: ShellHost? private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] - private var searchControllers: [String: UIViewController] = [:] + private var searchControllers: [String: ShellSearchControlling] = [:] private var fingerprints: [String: ShellControl] = [:] private let rendering = ShellRendering() private var revision = 0 @@ -31,9 +31,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var lastVerticalBarInset: CGFloat = 0 private var verticalBarPlacementObserved = false private weak var observedVerticalBarView: UIView? - private var verticalBarRegistration: AnyObject? + private var unregisterVerticalBarObservation: (() -> Void)? private weak var observedHingeView: UIView? - private var hingeInteraction: AnyObject? + private var hingeInteraction: UIInteraction? private var hingeStatus: String? private var deviceLayoutMonitoring = 0 private var lastDeviceLayout: String? @@ -58,12 +58,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.verticalBars?.view.isHidden = true } var searchOwnsKeyboard = false - if #available(iOS 26.0, *) { - self.searchControllers.values.forEach { - guard let controller = $0 as? ShellSearchController else { return } - if controller.ownsKeyboardChrome { searchOwnsKeyboard = true } - if !keyboard { controller.surface.isHidden = true } - } + self.searchControllers.values.forEach { controller in + if controller.ownsKeyboardChrome { searchOwnsKeyboard = true } + if !keyboard { controller.surface.isHidden = true } } if keyboard { if !searchOwnsKeyboard { @@ -92,13 +89,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func stopDeviceLayoutObservation() { - if #available(iOS 17.0, *), let view = observedVerticalBarView, - let registration = verticalBarRegistration as? any UITraitChangeRegistration { - view.unregisterForTraitChanges(registration) - } - verticalBarRegistration = nil + unregisterVerticalBarObservation?() + unregisterVerticalBarObservation = nil observedVerticalBarView = nil - if let interaction = hingeInteraction as? UIInteraction { + if let interaction = hingeInteraction { observedHingeView?.removeInteraction(interaction) } hingeInteraction = nil @@ -205,14 +199,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private func observeVerticalBarPlacement() { #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView { - if let view = observedVerticalBarView, let registration = verticalBarRegistration as? any UITraitChangeRegistration { - view.unregisterForTraitChanges(registration) - } + unregisterVerticalBarObservation?() observedVerticalBarView = webView let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge - verticalBarRegistration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in + let registration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in self?.notifyVerticalBarPlacementChange() } + unregisterVerticalBarObservation = { [weak webView] in webView?.unregisterForTraitChanges(registration) } } #endif notifyVerticalBarPlacementChange() @@ -221,7 +214,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private func observeHingeStatus() { #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView { - if let interaction = hingeInteraction as? UIInteraction { + if let interaction = hingeInteraction { observedHingeView?.removeInteraction(interaction) } observedHingeView = webView @@ -290,9 +283,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func removeControl(_ id: String, duration: TimeInterval = 0) { - if #available(iOS 26.0, *) { - (searchControllers.removeValue(forKey: id) as? ShellSearchController)?.detach() - } + searchControllers.removeValue(forKey: id)?.detach() if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) } fingerprints.removeValue(forKey: id) pendingTabSelections.removeValue(forKey: id) @@ -368,7 +359,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } var rejectedControls: [String] = [] var fabs: [(ShellFab, ShellControl)] = [] - var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] + var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = [] var rejectedSearches: [String] = [] if verticalBars.isEmpty || self.keyboardVisible { self.verticalBars?.detach() @@ -412,7 +403,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele rejectedControls.append(id) } // Only native search owns its keyboard; other controls return to Web. - if self.keyboardVisible && (self.searchControllers[id] as? ShellSearchController)?.ownsKeyboard != true { + if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true { reject(); continue } let local = node.frame.rect @@ -420,9 +411,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele width: local.width * scale, height: local.height * scale), to: parent) if let search = node.search { guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue } - let controller: ShellSearchController + let controller: ShellSearchControlling let previousCover = self.controls[id] - let replacing = self.searchControllers[id] as? ShellSearchController + let replacing = self.searchControllers[id] if let existing = replacing { controller = existing } else { // Keep the ordinary UITabBar cover until the search controller applies. From 6854516aef822498a8e493b4acbf73d81d7cba9e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 25 Sep 2026 19:27:23 +0900 Subject: [PATCH 17/17] docs: add a dedicated iPhone Duo guide - Move the Duo content out of special-markup into docs/iphone-duo.md so readers who only want Duo support have one page: device-layout reporting without a runtime, the vertical rail, and the posture-driven split pane. - Document that device-layout monitoring is reference-counted and independent of configure(), and that the enable* runtimes already hold a monitoring reference while native projection is active. - Note that the plugin reports device facts and never applies them to the DOM; the application owns placement decisions. - Extend docgen to emit the VerticalControlAreaHandle reference and link the new page from the README and native-ui-shell guide. --- README.md | 3 +- docs/iphone-duo.md | 238 ++++++++++++++++++++++++++++++++++++++++ docs/native-ui-shell.md | 2 +- docs/special-markup.md | 82 +------------- package.json | 2 +- 5 files changed, 243 insertions(+), 84 deletions(-) create mode 100644 docs/iphone-duo.md diff --git a/README.md b/README.md index 80247132..4b7aa3cd 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Use this markup to preview the inset grouped list look. For the list structure t ### Support iPhone Duo without the iOS 27 theme -Import only `@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css`, add `ios-theme-vertical-bars` to `ion-app`, and call `enableVerticalControlArea()` from `@rdlabo/ionic-theme-ios27/vertical-bars` at startup. This uses Ionic's standard appearance outside the Vertical Control Area; on supported iOS, only controls moved into that area are projected natively. See [Support iPhone Duo](./docs/special-markup.md#support-iphone-duo) for the complete setup. +Import only `@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css`, add `ios-theme-vertical-bars` to `ion-app`, and call `enableVerticalControlArea()` from `@rdlabo/ionic-theme-ios27/vertical-bars` at startup. This uses Ionic's standard appearance outside the Vertical Control Area; on supported iOS, only controls moved into that area are projected natively. To read only the hinge posture — for example to adapt an `ion-split-pane` — subscribe to the plugin's `deviceLayoutChange` event without starting any runtime. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup. ### Use only the iOS 27 theme @@ -211,6 +211,7 @@ For Ionic 9 Angular, import `isPlatform` and `provideIonicAngular` from `@ionic/ - [ESLint](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/eslint) — check list structure with ESLint rules. - [Features](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/features) — CSS variables, Liquid Glass, selective imports, and dark mode. - [Native UI Shell (Experimental)](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell) — project supported Ionic controls, text, and icons into UIKit. +- [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) — vertical system rail, hinge posture, and split-pane layout; usable without the theme or the shell. - [Animation](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/animation) — tab, segment, and searchable effects. - [Migration from iOS 26](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/migration) — upgrade an existing app, including stylesheet, class, and CSS variable changes. - [iOS 26 migration history](https://docs.rdlabo.dev/projects/ionic-theme-ios26/docs/migration) — earlier major-version changes for the previous package. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md new file mode 100644 index 00000000..0d4a38f1 --- /dev/null +++ b/docs/iphone-duo.md @@ -0,0 +1,238 @@ +--- +title: iPhone Duo support +--- + +# iPhone Duo support + +iPhone Duo folds along a hinge and reserves a physical system rail on one side of the display. On iOS 27.1 and later, the system reports both facts to the app: the rail's edge with its safe-area inset, and the hinge posture while the device opens and closes. + +This package provides three independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**: + +- `dist/css/vertical-bars.css` — opt-in classes that reserve the rail's safe area, plus a registered custom property for a posture-driven split-pane width. +- `enableVerticalControlArea()` — moves eligible tabs and toolbar controls into the reserved area. On Capacitor iOS they are rendered by a native SwiftUI `TabView` and toolbar; everywhere else the same controls appear as Web clones. +- Device-layout reporting — the bundled Capacitor plugin reports rail placement, hinge status and the WebView corner radius through `getDeviceLayout()` and the `deviceLayoutChange` event. + +## Choose what to adopt + +| Goal | Stylesheet | Runtime | +| ------------------------------------------------ | ------------------- | ------------------------------------------------------------------ | +| Hinge posture only (split pane, layout switches) | `vertical-bars.css` | none — subscribe to the plugin directly | +| Vertical rail for tabs and toolbar actions | `vertical-bars.css` | `enableVerticalControlArea()` | +| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — already includes rail and posture support | + +```scss +@use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css'; +``` + +The stylesheet never changes ordinary Ionic UI by itself; every rule requires an opt-in class. Load it unconditionally — these values are simulation and layout inputs, independent from Ionic's normal safe-area variables. + +## Read the device layout + +`npx cap sync ios` registers the plugin automatically; no `configure` call is needed for device layout. An app that only wants the hinge posture — for example to drive a split pane — uses this API alone, with no projection runtime: + +```ts +import { Capacitor } from '@capacitor/core'; +import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +// The plugin has no Web implementation; guard the subscription. +if (Capacitor.getPlatform() === 'ios') { + await IonicNativeUIShell.startDeviceLayoutMonitoring(); + const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { + // apply the posture + }); + const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); // initial value + + // When the consumer goes away: + // await listener.remove(); + // await IonicNativeUIShell.stopDeviceLayoutMonitoring(); +} +``` + +`DeviceLayout` carries: + +| Field | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------------------- | +| `placement` | `{ edge: 'left' \| 'right' \| null, inset }` — the physical rail edge and its UIKit safe-area inset in points; `edge` is `null` on devices without a rail | +| `hingeStatus` | `HingeStatus.Unavailable` (no hinge), `Closed`, `PartiallyOpen`, or `FullyOpen` | +| `webViewMetrics.radius` | the WebView's effective top-left corner radius in points | + +Monitoring is reference-counted: each consumer pairs `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()`, and events stop when the last consumer releases it. `getDeviceLayout()` also works without monitoring for a one-shot read. While `enableVerticalControlArea()` or `enableNativeUIShell()` has native projection active it already holds a monitoring reference, so those users only add a listener and read the initial value — no extra start/stop pair. + +The plugin reports device facts and never applies them to the DOM. The application decides what each value means for its layout — this boundary keeps the native values easy to mock in tests and keeps the theme's responsibility limited to the stylesheets and runtime below. + +## Reserve the vertical rail + +Add `.ios-theme-vertical-bars` to `ion-app` to reserve the rail region on the physical right, or add `.ios-theme-vertical-bars-left` as well to use the physical left: + +```html +... +``` + +For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives a native placement, the measured UIKit inset replaces the simulated width, even when that inset is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. + +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 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 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 mode is component-mode independent: an app can keep Ionic `mode: 'md'` on iOS and still enable Vertical Bars. No component needs `mode="ios"`. + +## Project controls into the rail + +Start the standalone runtime once at application startup: + +```ts +import { Capacitor } from '@capacitor/core'; +import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; + +// Start on Chrome too; the Web projection stays idle until the class is present. +const rail = await enableVerticalControlArea(); + +if (Capacitor.getPlatform() === 'ios') { + // The runtime already monitors device layout; only subscribe. + await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement)); + rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); +} +``` + +`setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. Passing `null` restores the ordinary layout. It requires a mounted `ion-app` — call it after the app root exists. The device-layout listener reports what iOS chose; the application decides whether to apply it. An app that wants to keep its own fixed edge can ignore `placement.edge` and pass `'left'` or `'right'`. + +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(placement)` from its listener. + +On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal 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. + +When the app contains `ion-tabs`, its tab bar moves into the reserved region and aligns above the bottom safe area; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView`. 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; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. + +## Adapt the split pane + +For a side-by-side menu on iPhone Duo, opt the `ion-split-pane` into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them: + +```html + + ... +
...
+
+``` + +Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value: + +```css +ion-split-pane { + --ios-theme-menu-width: var(--ios-theme-split-pane-width); + --side-width: var(--ios-theme-menu-width); + --side-max-width: var(--ios-theme-menu-width); + transition: --ios-theme-split-pane-width 300ms ease; +} +``` + +The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `deviceLayoutChange` reports `HingeStatus.PartiallyOpen` (and read the initial value with `getDeviceLayout`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — `HingeStatus.Unavailable` means the device has no hinge, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. + +## Vertical Control Area API + +The generated reference below documents the handle returned by `enableVerticalControlArea()`. + + + +* [`setPlacement(...)`](#setplacement) +* [`getStatus()`](#getstatus) +* [`suspend()`](#suspend) +* [`destroy()`](#destroy) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) + + + + + + +### setPlacement(...) + +```typescript +setPlacement(placement: VerticalBarEdge | VerticalBarPlacement) => void +``` + +Applies the application's chosen placement to both Web and native controls. + +| Param | Type | +| --------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **`placement`** | VerticalBarEdge \| VerticalBarPlacement | + +-------------------- + + +### getStatus() + +```typescript +getStatus() => NativeUIShellStatus +``` + +Returns the current Web/native projection state. + +**Returns:** NativeUIShellStatus + +-------------------- + + +### suspend() + +```typescript +suspend() => Promise +``` + +Restores projected controls to the Web until the returned lease is resumed. + +**Returns:** Promise<NativeUIShellSuspension> + +-------------------- + + +### destroy() + +```typescript +destroy() => Promise +``` + +Stops synchronization, restores Web controls and releases native resources. + +-------------------- + + +### Interfaces + + +#### VerticalBarPlacement + +| Prop | Type | Description | +| ----------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | +| **`edge`** | VerticalBarEdge | | +| **`inset`** | number | UIKit safe-area inset on the physical vertical-bar edge, in points. | + + +#### NativeUIShellStatus + +| Prop | Type | +| --------------- | ------------------------------------------- | +| **`state`** | 'native' \| 'stopped' \| 'web' | +| **`projected`** | number | +| **`updates`** | number | +| **`reason`** | string | + + +#### NativeUIShellSuspension + +| Method | Signature | Description | +| ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------- | +| **resume** | () => Promise<void> | Releases this suspension. Native projection resumes after all active suspensions are released. | + + +### Type Aliases + + +#### VerticalBarEdge + +'left' | 'right' | null + + diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index f434cf35..e7725d5f 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 ## Support iPhone Duo -The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/vertical-bars`) and `dist/css/vertical-bars.css` work without loading the iOS 27 theme. Call `enableVerticalControlArea()` for this use case; it projects only controls placed in the vertical area. Apps already calling `enableNativeUIShell()` should keep that single runtime rather than starting both. +The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/vertical-bars`) and `dist/css/vertical-bars.css` work without loading the iOS 27 theme. Call `enableVerticalControlArea()` for this use case; it projects only controls placed in the vertical area. Apps already calling `enableNativeUIShell()` should keep that single runtime rather than starting both. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup, including hinge posture and the split-pane layout for apps that do not use this shell at all. On supported iOS versions, adding `.ios-theme-vertical-bars` 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. diff --git a/docs/special-markup.md b/docs/special-markup.md index a3a2f93f..42ade0da 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -49,87 +49,7 @@ These classes do not reposition a separate `ion-fab`; leave room for it when cho ## Support iPhone Duo -Load the separate stylesheet and start its projection runtime. The iOS 27 theme stylesheets are **not required**: - -```scss -@use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css'; -``` - -```ts -import { - IonicNativeUIShell, - enableVerticalControlArea, -} from '@rdlabo/ionic-theme-ios27/vertical-bars'; - -// Start Web projection on Chrome too; it remains idle until the class is present. -const rail = await enableVerticalControlArea(); - -// `platform` is the app's injected Ionic Platform instance. -if (platform.is('ios')) { - await IonicNativeUIShell.startDeviceLayoutMonitoring(); - const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement)); - rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); - // When updates are no longer needed: - // await listener.remove(); - // await IonicNativeUIShell.stopDeviceLayoutMonitoring(); -} -``` - -The `platform.is('ios')` guard controls automatic application of native placement, not the component mode or the Web simulation. An app can keep `mode: 'md'` on iOS and still enable Vertical Bars. - -The device-layout listener reports what iOS chose; the application decides whether to call `setPlacement`. Passing `null` restores the ordinary layout. The placement includes the physical edge and its UIKit safe-area inset. The same event also includes hinge status and WebView corner radius. Call `stopDeviceLayoutMonitoring()` after removing the listener to stop device-layout events. Projected tabs and toolbar controls still render with SwiftUI. If the app already starts the full `enableNativeUIShell()`, use `setVerticalControlAreaPlacement(placement)` instead of starting another runtime. - -Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` once at application startup. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. - -For Chrome development, no native plugin is needed. Add `.ios-theme-vertical-bars` to `ion-app` to simulate the right rail, or add `.ios-theme-vertical-bars-left` as well to simulate the left rail: - -```html -... -``` - -`setPlacement` requires a mounted `ion-app`. Call it after the app root exists; passing `null` restores the ordinary layout. - -For example, an app configured with Ionic `mode: 'md'` can use this same `ion-app` class. No component needs to switch to `mode="ios"` for Vertical Bars. - -The class reserves `80px` on the physical right in Chrome to simulate iPhone Duo. When `setPlacement` receives a native placement, it uses the measured UIKit inset instead of the simulated width, even when that inset is less than `80px`. The left modifier moves the reservation to the physical left. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. - -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 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. - -For a side-by-side menu on iPhone Duo, opt the `ion-split-pane` into the separately measured Settings layout. The sidebar is 320pt when fully unfolded and reaches the display midpoint when half-opened (50vw). The application supplies the posture; both states have the same viewport width, so a width media query cannot distinguish them: - -```html - - ... -
...
-
-``` - -Set the ordinary split-pane width to 320pt in the application's stylesheet, and let the half-open class change only the width value: - -```css -ion-split-pane { - --ios-theme-menu-width: var(--ios-theme-split-pane-width); - --side-width: var(--ios-theme-menu-width); - --side-max-width: var(--ios-theme-menu-width); - transition: --ios-theme-split-pane-width 300ms ease; -} -``` - -The registered `--ios-theme-split-pane-width` defaults to 320px; `.ios-theme-split-pane-half-open` sets it to 50vw. Set `halfOpened` from `deviceLayoutChange.hingeStatus` (and read the initial value with `getDeviceLayout`). The exported `HingeStatus` enum has `Unavailable`, `Closed`, `PartiallyOpen`, and `FullyOpen`; `Unavailable` means no hinge is available. Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout works without the iOS 27 theme and does not enable Vertical Bars or move an overlay menu. - -These 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`, this mode moves its tab bar into the chosen physical-side reserved region and aligns it above the bottom safe area. The Ionic `slot` value does not select a different 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; this mode does not convert tabs into a menu. - -On supported iOS versions, `enableVerticalControlArea()` hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar. Vertical Bars works with either Ionic `ios` or `md` mode; the application chooses both its component mode and where to enable Vertical Bars. Back navigation can come from outside a fixed toolbar; other toolbar actions still require a fixed toolbar. It does not project ordinary Native UI Shell controls outside the vertical area. If the app already uses the full `enableNativeUIShell()`, keep that single runtime instead of starting both. 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. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the 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, or when native projection is unavailable, 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 the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. +iPhone Duo support — the vertical system rail, hinge posture, and the posture-driven split-pane layout — is independent of the iOS 27 theme and the full Native UI Shell. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup, including device-layout monitoring without a projection runtime. ## Two-line inset list items diff --git a/package.json b/package.json index e7bd807b..a9800007 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "prebuild:css": "rdlabo-copy-structured-list src/styles/utils/structured-list.scss", "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map", "build:ts": "tsc", - "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md", + "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/iphone-duo.md", "build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production", "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"", "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"",