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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 66 additions & 23 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ interface ShellMock extends ShellMockCore {
retirementDetails: { path: string; tabs: string }[];
}

const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null = 'trailing') => {
const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null]) => {
const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => {
const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null | 'unreported']) => {
const mock = {
updates: [] as ShellSnapshot[],
sequence: 0,
Expand Down Expand Up @@ -84,6 +84,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra

const foldable = {
async getBarPlacement() {
if (nativeEdge === 'unreported') return new Promise(() => {});
return { verticalBarEdge: nativeEdge };
},
async getFoldState() {
Expand Down Expand Up @@ -879,27 +880,69 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif
expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved)).toBe(false);
});

// Apps linked against an SDK older than 27.1 never get a trait-reported edge;
// the DOM strip still owns the layout, so the native rail follows it.
test('verticalBars controls project natively when the OS reports no rail edge', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockNative(page, false, null);
await page.goto('/main/index/native-ui-shell');
await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false));
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.at(-1)
?.controls.some((control: ShellControl) => control.placement === 'vertical-bars'),
),
)
.toBe(true);
await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toHaveCount(0);
await expect(page.locator('ion-app > ion-menu-button.ios-theme-vertical-bars-toolbar-projection')).toHaveCount(0);
await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]')).toHaveCount(0);
});
for (const initialEdge of [null, 'unreported'] as const) {
for (const verticalBarsOnly of [true, false]) {
test(`${initialEdge ?? 'null'} native edge restores ${verticalBarsOnly ? 'Web rail' : 'ordinary Native UI Shell'} and recovers`, async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 900 });
await mockNative(page, false, initialEdge);
await page.goto(`/main/index/native-ui-shell${verticalBarsOnly ? '?verticalBarsOnly' : ''}`);
await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => {
icon.setAttribute('slot', 'icon-only');
icon.parentElement!.querySelector('[data-label]')?.remove();
});
const app = page.locator('ion-app');
const tabs = page.locator('ion-tab-bar');
const save = page.locator('app-native-ui-shell ion-button[type=submit]');
const clone = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]');
const snapshot = () => page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1));
const reportEdge = async (verticalBarEdge: 'leading' | 'trailing' | null) => {
await page.evaluate((edge) => {
Capacitor.registerPlugin<ShellMock>('Foldable').notifyListeners('barPlacementChange', { verticalBarEdge: edge });
}, verticalBarEdge);
};
await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const expectFallback = async () => {
await expect.poll(async () => (await snapshot())?.controls.every((control) => control.placement !== 'vertical-bars')).toBe(true);
if (verticalBarsOnly) {
await expect(app).toHaveClass(/ios-theme-vertical-bars/);
await expect(clone).toBeVisible();
await expect(tabs).not.toHaveAttribute('data-native-ui-shell', '');
await expect(tabs).toBeVisible();
} else {
await expect(app).not.toHaveClass(/(?:^| )ios-theme-vertical-bars(?: |$)/);
await expect(app).toHaveAttribute('data-native-ui-shell-vertical-bars-suspended', '');
await expect(clone).toHaveCount(0);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
await expect(save).toHaveAttribute('data-native-ui-shell', '');
await expect.poll(async () => (await snapshot())?.controls.some((control) => control.kind === 'ion-tab-bar')).toBe(true);
}
};
await expectFallback();
if (verticalBarsOnly) await clone.click();
else await activate(page, 'Save');
await expect(page.locator('[data-save-count]')).toHaveText('1');

await reportEdge('trailing');
await expect(app).toHaveClass(/ios-theme-vertical-bars/);
await expect(app).not.toHaveAttribute('data-native-ui-shell-vertical-bars-suspended', '');
await expect.poll(async () => (await snapshot())?.controls.some((control) => control.placement === 'vertical-bars')).toBe(true);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
await expect(clone).toHaveCount(0);

await reportEdge(null);
await expectFallback();
if (verticalBarsOnly) await clone.click();
else await activate(page, 'Save');
await expect(page.locator('[data-save-count]')).toHaveText('2');

// The last explicit null survives updates and layout changes.
await page.setViewportSize({ width: 740, height: 900 });
await expectFallback();
});
}
}

test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => {
await mockNative(page);
Expand Down
2 changes: 1 addition & 1 deletion demo/src/app/index/index-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class IndexPageComponent {
readonly #document = inject(DOCUMENT);

get verticalBarsModeEnabled() {
return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars');
return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars, ion-app[data-native-ui-shell-vertical-bars-suspended]');
}

async navigateNativeUiShell() {
Expand Down
2 changes: 1 addition & 1 deletion demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations()))
const applyPlacement = ({ verticalBarEdge }: BarPlacement) => {
const app = document.querySelector('ion-app');
if (!app) return;
const enabled = app.classList.contains('ios-theme-vertical-bars');
const enabled = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute('data-native-ui-shell-vertical-bars-suspended');
const rtl = app.closest('[dir]')?.getAttribute('dir') === 'rtl';
const current = app.classList.contains('ios-theme-vertical-bars-left') !== rtl ? 'leading' : 'trailing';
setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge });
Expand Down
12 changes: 6 additions & 6 deletions docs/iphone-duo.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ if (!disposed && !receivedEvent) applyFold(initialFold);

`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed.

The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. Without a reported edge, the runtime follows the application's classes for previews and older SDKs. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled.
The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. An explicit `nativeEdge: null` or an initial unregistered edge prevents native vertical projection: `enableVerticalControlArea()` keeps the requested Web rail, while the full Native UI Shell temporarily restores its ordinary horizontal layout. The requested rail is retained so a later reported edge can restore vertical layout. Native vertical projection starts only after a matching non-null edge is supplied. Omitting `nativeEdge` after supplying it preserves that value, including `null`. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled.

The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`.

Expand Down Expand Up @@ -274,11 +274,11 @@ Stops synchronization, restores Web controls and releases native resources.

#### VerticalBarPlacement

| Prop | Type | Description |
| ---------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **`edge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | |
| **`inset`** | <code>number</code> | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. |
| **`nativeEdge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. |
| Prop | Type | Description |
| ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`edge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | |
| **`inset`** | <code>number</code> | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. |
| **`nativeEdge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. |


#### NativeUIShellStatus
Expand Down
2 changes: 1 addition & 1 deletion src/native/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface VerticalBarPlacement {
edge: VerticalBarEdge;
/** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */
inset?: number;
/** Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. */
/** Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. */
nativeEdge?: VerticalBarEdge;
}

Expand Down
37 changes: 36 additions & 1 deletion src/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,36 @@ const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getA

// Device facts are supplied by the application; the theme only compares placement.
const nativePlacements = new WeakMap<HTMLElement, { edge: VerticalBarEdge; rtl?: boolean }>();
const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended';

// Keep the requested rail while ordinary Native UI Shell temporarily uses its
// horizontal layout. Removing the effective class restores normal measurements,
// toolbar ownership and safe areas throughout the existing rendering pipeline.
const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => {
const reconcile = () => {
const app = doc.querySelector<HTMLElement>('ion-app');
if (!app) return;
const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute);
const suspended = requested && nativePlacements.get(app)?.edge == null;
if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended);
Comment on lines +121 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 ネイティブレール不在時にクラス操作でモードを解除できない

通常モードで nativeEdge が null の間、有効化クラスは消え、退避属性だけが残ります。アプリがクラスを外しても無効化できず、レール復帰時に意図せず再有効化されます。

Learn more

アプリは ion-app の .ios-theme-vertical-bars クラスでもレールを有効化できます。説明 にもこの方法が示されています。通常モードで nativeEdge: null を受けると、監視処理がクラスを外して退避属性だけで要求を保持します。この状態でアプリが classList.remove('ios-theme-vertical-bars') を呼んでもクラスは既になく、監視処理には解除が伝わりません。その後エッジが復帰すると、残った退避属性を要求と見なしてレールが再有効化されます。

Example: ion-app にクラスを付けていたアプリが、レール不在時に設定画面から classList.remove('ios-theme-vertical-bars') を実行します。表示は変わらず退避属性が残り、次のエッジ通知でレールが戻ります。期待されるのはモードの解除です。

Recommended fix: 要求状態と実効クラスを分離し、公開したクラス操作でも解除を検出できる状態管理にしてください。クラスが既にない状態での classList.remove は MutationObserver で検出できないため、クラスを常に要求の目印として維持できる CSS 制御、または要求を変更する明示的 API への契約変更を検討してください。

Devin Review


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

if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) {
app.classList.toggle('ios-theme-vertical-bars', requested && !suspended);
}
};
const observer = new MutationObserver(reconcile);
observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile);
reconcile();
return () => {
observer.disconnect();
doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile);
const app = doc.querySelector<HTMLElement>(`ion-app[${horizontalFallbackAttribute}]`);
if (app) {
app.removeAttribute(horizontalFallbackAttribute);
app.classList.add('ios-theme-vertical-bars');
}
};
};

/**
* Applies one placement to the CSS layout and both Web/native projections.
Expand All @@ -122,6 +152,7 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) {
nativePlacements.set(app, { edge: placement.nativeEdge, rtl });
}
app.removeAttribute(horizontalFallbackAttribute);
app.classList.toggle('ios-theme-vertical-bars', edge !== null);
app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
Expand Down Expand Up @@ -191,15 +222,17 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
let runtime: NativeUIShellHandle | undefined;
let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
let stopVerticalBarsLayout: (() => void) | undefined;
try {
const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
if (!capabilities.supported) return fallback('Requires iOS 26 or later');
if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document);
// The application owns device state and selects the rail through placement classes.
const nativeVerticalBars = () => {
const app = document.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
if (!app) return false;
const reported = nativePlacements.get(app);
if (!reported?.edge) return true;
if (!reported?.edge) return false;
const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app));
};
Expand All @@ -219,13 +252,15 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
release,
destroy: async () => {
await metricsListener?.remove().catch(() => {});
stopVerticalBarsLayout?.();
prehide?.stop();
stopModals();
},
});
} catch (error) {
await runtime?.destroy();
await metricsListener?.remove().catch(() => {});
stopVerticalBarsLayout?.();
return fallback(error instanceof Error ? error.message : String(error));
}
})());
Expand Down