diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts
index 8c3349311..c15a8b7de 100644
--- a/demo/e2e/native-ui.spec.ts
+++ b/demo/e2e/native-ui.spec.ts
@@ -4925,6 +4925,197 @@ test('horizontal tabs retain measured native width on Web handoff without feedin
expect((await bar.boundingBox())!.width).toBeCloseTo(337, 0);
});
+for (const native of [true, false]) {
+ for (const grouped of [true, false]) {
+ test(`covered rail buttons remain Web-projected behind modals: native=${native}, grouped=${grouped}`, async ({ page }) => {
+ await page.setViewportSize({ width: grouped ? 1100 : 700, height: 900 });
+ await page.route('**/styles.css', async (route) => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
+ });
+ await mockNative(page, !native);
+ await page.goto('/main/index/modal');
+ await page
+ .locator('app-modal ion-toolbar')
+ .first()
+ .evaluate((toolbar, grouped) => {
+ const group = document.createElement('ion-buttons');
+ group.slot = 'end';
+ for (const label of grouped ? ['Covered action', 'Disabled action'] : ['Covered action']) {
+ const button = document.createElement('ion-button');
+ button.setAttribute('aria-label', label);
+ if (label === 'Covered action') button.id = 'covered-action';
+ button.disabled = label === 'Disabled action';
+ button.innerHTML = '';
+ button.addEventListener('click', () => toolbar.setAttribute('data-activated', label));
+ group.append(button);
+ }
+ toolbar.append(group);
+ }, grouped);
+ await page.locator('ion-app').evaluate((app, grouped) => {
+ app.dir = grouped ? 'ltr' : 'rtl';
+ app.classList.add('ios-theme-vertical-bars');
+ app.classList.toggle('ios-theme-vertical-bars-left', !grouped);
+ }, grouped);
+ const source = page.locator('#covered-action');
+ const projection = page
+ .locator('ion-app > .ios-theme-vertical-bars-toolbar-projection')
+ .filter({ has: page.locator('[aria-label="Covered action"]') });
+ const action = page.locator(
+ 'ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Covered action"], ion-app > ion-buttons.ios-theme-vertical-bars-toolbar-projection > ion-button[aria-label="Covered action"]',
+ );
+ let before;
+ if (native) {
+ await expect(grouped ? source.locator('..') : source).toHaveAttribute('data-native-ui-projected', '');
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Capacitor.registerPlugin('NativeUI')
+ .updates.at(-1)
+ ?.controls.some(
+ (control) =>
+ control.placement === 'vertical-bars' && control.items.some((item) => item.accessibilityLabel === 'Covered action'),
+ ),
+ ),
+ )
+ .toBe(true);
+ await page.evaluate(() => {
+ const plugin = Capacitor.registerPlugin('NativeUI');
+ plugin.geometry = {};
+ for (const control of plugin.updates.at(-1)!.controls) {
+ control.items.forEach((item, index) => {
+ if (item.accessibilityLabel?.includes('action'))
+ plugin.geometry![item.id] = {
+ x: document.querySelector('ion-app')!.dir === 'rtl' ? 19 : innerWidth - 65,
+ y: 300 + index * 46,
+ width: 46,
+ height: 46,
+ };
+ });
+ }
+ });
+ before = { x: grouped ? 1035 : 19, y: 300, width: 46, height: 46 };
+ } else {
+ await expect(action).toBeVisible();
+ before = await action.boundingBox();
+ }
+ await page.getByText('present:normal', { exact: true }).click();
+ const modal = page.locator('ion-modal.show-modal');
+ await expect(action).toBeVisible();
+ const surface = grouped ? projection : action;
+ await expect(surface).toHaveAttribute('inert', '');
+ await expect(surface).toHaveAttribute('aria-hidden', 'true');
+ await expect.poll(() => action.boundingBox()).toEqual(before);
+ if (grouped) await expect(projection.locator('ion-button[aria-label="Disabled action"]')).toHaveJSProperty('disabled', true);
+ await source.evaluate((button: HTMLIonButtonElement) => (button.disabled = true));
+ await expect(action).toHaveJSProperty('disabled', true);
+ await source.evaluate((button: HTMLIonButtonElement) => (button.disabled = false));
+ await expect(action).toHaveJSProperty('disabled', false);
+ // Even delayed or synthetic clicks cannot activate the covered page.
+ await action.dispatchEvent('click');
+ await expect(page.locator('app-modal ion-toolbar').first()).not.toHaveAttribute('data-activated');
+ await modal.getByText('present:normal', { exact: true }).click();
+ await expect(page.locator('ion-modal.show-modal')).toHaveCount(2);
+ await expect(action).toBeVisible();
+ await page
+ .locator('ion-modal.show-modal')
+ .last()
+ .evaluate((element: HTMLIonModalElement) => element.dismiss());
+ await expect(action).toBeVisible();
+ await page
+ .locator('ion-modal.show-modal')
+ .last()
+ .evaluate((element: HTMLIonModalElement) => element.dismiss());
+ if (native) {
+ await expect(action).toHaveCount(0);
+ await expect(grouped ? source.locator('..') : source).toHaveAttribute('data-native-ui-projected', '');
+ } else {
+ await expect(surface).not.toHaveAttribute('inert');
+ await action.click();
+ await expect(page.locator('app-modal ion-toolbar').first()).toHaveAttribute('data-activated', 'Covered action');
+ }
+ });
+ }
+}
+
+for (const native of [true, false]) {
+ test(`covered mixed automatic and explicit toolbar controls keep separate ownership: native=${native}`, async ({ page }) => {
+ await page.setViewportSize({ width: 1100, height: 900 });
+ await page.route('**/styles.css', async (route) => {
+ const response = await route.fetch();
+ await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
+ });
+ await mockNative(page, !native);
+ await page.goto('/main/index/modal');
+ await page
+ .locator('app-modal ion-toolbar')
+ .first()
+ .evaluate((toolbar) => {
+ const icon = '';
+ toolbar.insertAdjacentHTML(
+ 'beforeend',
+ `
+
+ ${icon}
+ ${icon}
+
+ ${icon}
+
+ ${icon}
+
+ `,
+ );
+ for (const button of toolbar.querySelectorAll('[id^="edge-"]:not(ion-buttons)')) {
+ button.setAttribute('data-clicks', '0');
+ button.addEventListener('click', () =>
+ button.setAttribute('data-clicks', String(Number(button.getAttribute('data-clicks')) + 1)),
+ );
+ }
+ });
+ await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
+ const labels = ['Edge group A', 'Edge group B', 'Edge single', 'Edge mixed auto'];
+ const clone = (label: string) =>
+ page.locator(
+ `ion-app > .ios-theme-vertical-bars-toolbar-projection[aria-label="${label}"], ion-app > .ios-theme-vertical-bars-toolbar-projection > ion-button[aria-label="${label}"]`,
+ );
+ if (native) {
+ await expect(page.locator('#edge-group')).toHaveAttribute('data-native-ui-projected', '');
+ await expect(page.locator('#edge-single')).toHaveAttribute('data-native-ui-projected', '');
+ await expect(page.locator('#edge-mixed-auto')).toHaveAttribute('data-native-ui-projected', '');
+ await expect(page.locator('#edge-explicit')).toHaveAttribute('data-native-ui-projected', '');
+ } else for (const label of labels) await expect(clone(label)).toBeVisible();
+ await page.getByText('present:normal', { exact: true }).click();
+ await expect(page.locator('ion-modal.show-modal')).toHaveCount(1);
+ for (const label of labels) {
+ await expect(clone(label)).toHaveCount(1);
+ await expect(clone(label)).toBeVisible();
+ expect(await clone(label).evaluate((element) => !!element.closest('[inert]'))).toBe(true);
+ await clone(label).dispatchEvent('click');
+ }
+ await expect(clone('Edge explicit')).toHaveCount(0);
+ await expect(page.locator('#edge-explicit')).not.toHaveAttribute('data-native-ui-projected');
+ for (const id of ['edge-group-a', 'edge-group-b', 'edge-single', 'edge-mixed-auto', 'edge-explicit'])
+ await expect(page.locator(`#${id}`)).toHaveAttribute('data-clicks', '0');
+ await page.locator('#edge-group-b').evaluate((element: HTMLIonButtonElement) => (element.disabled = true));
+ await expect(clone('Edge group B')).toHaveJSProperty('disabled', true);
+ await page.locator('ion-modal.show-modal').evaluate((modal: HTMLIonModalElement) => modal.dismiss());
+ if (native) {
+ for (const label of labels) await expect(clone(label)).toHaveCount(0);
+ await expect(page.locator('#edge-explicit')).toHaveAttribute('data-native-ui-projected', '');
+ await activate(page, 'Edge group A', true);
+ await activate(page, 'Edge single', true);
+ await activate(page, 'Edge mixed auto', true);
+ await activate(page, 'Edge explicit', true);
+ } else {
+ for (const label of labels.filter((label) => label !== 'Edge group B')) await clone(label).click();
+ await page.locator('#edge-explicit').click();
+ }
+ for (const id of ['edge-group-a', 'edge-single', 'edge-mixed-auto', 'edge-explicit'])
+ await expect(page.locator(`#${id}`)).toHaveAttribute('data-clicks', '1');
+ await expect(page.locator('#edge-group-b')).toHaveAttribute('data-clicks', '0');
+ });
+}
+
test('Duo tab handoff recovers from one update failure without replaying the tab click', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui');
diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts
index 082cee828..68617fc5b 100644
--- a/demo/e2e/vertical-bars-layout.spec.ts
+++ b/demo/e2e/vertical-bars-layout.spec.ts
@@ -194,7 +194,8 @@ test('stacked modals keep rail controls inside the active dialog and restore the
await expect(modals.first().locator(projection)).toBeVisible();
await modals.first().getByText('present:normal', { exact: true }).click();
await expect(modals).toHaveCount(2);
- await expect(modals.first().locator(projection)).toHaveCount(0);
+ await expect(modals.first().locator(projection)).toBeVisible();
+ await expect(modals.first().locator(projection)).toHaveAttribute('inert', '');
await expect(modals.last().locator(projection)).toBeVisible();
await modals.last().getByRole('button', { name: 'Close', exact: true }).focus();
await expect(modals.last().getByRole('button', { name: 'Close', exact: true })).toBeFocused();
diff --git a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts
index 5aaf27c11..ed776a652 100644
--- a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts
+++ b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts
@@ -26,10 +26,11 @@ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, paren
const frame = verticalBarFrame(source);
if (!frame) return;
const origin = parent.getBoundingClientRect();
+ // Absolute children use the padding edge, not the outer edge of a glass group.
Object.assign(clone.style, {
position: 'absolute',
- left: `${frame.x - origin.left}px`,
- top: `${frame.y - origin.top}px`,
+ left: `${frame.x - origin.left - parent.clientLeft}px`,
+ top: `${frame.y - origin.top - parent.clientTop}px`,
right: 'auto',
bottom: 'auto',
width: `${frame.width}px`,
diff --git a/packages/capacitor-native-ui/src/native/vertical-bars-web.ts b/packages/capacitor-native-ui/src/native/vertical-bars-web.ts
index 33422cf2d..978cf8149 100644
--- a/packages/capacitor-native-ui/src/native/vertical-bars-web.ts
+++ b/packages/capacitor-native-ui/src/native/vertical-bars-web.ts
@@ -34,6 +34,7 @@ const toolbarControlGap = 10;
interface ToolbarProjection {
source: HTMLElement;
+ root: HTMLElement;
projection: HTMLElement;
actions: { source: HTMLElement; projection: HTMLElement; previousAriaHidden: string | null }[];
}
@@ -67,6 +68,9 @@ export const createVerticalBarsWebProjection = (
let waiters: (() => void)[] = [];
const listeners = new AbortController();
const verticalBarsRoot = () => doc.querySelector('ion-app.ios-theme-vertical-bars');
+ const surfaceOf = (element: Element) => element.closest('ion-modal') ?? verticalBarsRoot()!;
+ // Covered toolbar controls keep a Web visual on their own surface, below the modal.
+ const isCovered = (element: Element) => !!topModal(doc) && surfaceOf(element) !== topModal(doc);
const projectedSources = () =>
[backSource, search.source, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter(
(source): source is HTMLElement => !!source,
@@ -98,7 +102,7 @@ export const createVerticalBarsWebProjection = (
!!verticalBarsRoot()?.contains(element) &&
(!options.root || options.root.contains(element)) &&
!element.closest('[data-native-ui-placement="element"]') &&
- inVerticalBarsSurface(element) &&
+ (inVerticalBarsSurface(element) || isCovered(element)) &&
verticalBarsActionCandidate(element) &&
!isExcluded(element, verticalBarsEnteringPage(element)) &&
!verticalBarsPages.isDeparted(element);
@@ -106,16 +110,15 @@ export const createVerticalBarsWebProjection = (
!!verticalBarsRoot()?.contains(element) &&
(!options.root || options.root.contains(element)) &&
!element.closest('[data-native-ui-placement="element"]') &&
- inVerticalBarsSurface(element) &&
verticalBarsOwned(element) &&
verticalBarsBackCandidate(element) &&
!isExcluded(element, verticalBarsEnteringPage(element)) &&
!verticalBarsPages.isDeparted(element) &&
unprojected(projectedSources(), () => isRendered(element));
const pageOrder = (element: Element) => Array.from(doc.querySelectorAll('.ion-page')).indexOf(element.closest('.ion-page')!);
- const findBack = () => {
+ const findBack = (surface = topModal(doc) ?? verticalBarsRoot()) => {
const candidates = Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).filter(
- isEligibleBack,
+ (element) => surfaceOf(element) === surface && isEligibleBack(element),
);
return preferredVerticalBarsBack(candidates, doc);
};
@@ -132,10 +135,15 @@ export const createVerticalBarsWebProjection = (
if (action.closest('ion-buttons') || !verticalBarsOwned(action) || !inEligibleToolbar(action)) continue;
if (unprojected(projectedSources(), () => isRendered(action))) candidates.push({ group: action, actions: [action] });
}
- const highestPage = Math.max(...candidates.map(({ group }) => pageOrder(group)));
- return candidates.filter(({ group }) => pageOrder(group) === highestPage);
+ const highestPages = new Map();
+ for (const { group } of candidates) {
+ const surface = surfaceOf(group);
+ highestPages.set(surface, Math.max(highestPages.get(surface) ?? -1, pageOrder(group)));
+ }
+ return candidates.filter(({ group }) => (enabled() || isCovered(group)) && pageOrder(group) === highestPages.get(surfaceOf(group)));
};
- const isCurrentToolbarAction = (source: HTMLElement) => findToolbarGroups().some(({ actions }) => actions.includes(source));
+ const isCurrentToolbarAction = (source: HTMLElement) =>
+ inVerticalBarsSurface(source) && findToolbarGroups().some(({ actions }) => actions.includes(source));
const restore = (retire?: (surface: HTMLElement) => void) => {
search.restore(retire);
if (backProjection) {
@@ -151,7 +159,8 @@ export const createVerticalBarsWebProjection = (
}
backSource = undefined;
previousBackAriaHidden = null;
- for (const { projection, actions } of toolbarProjections) {
+ for (const { projection, actions, root: surface } of toolbarProjections) {
+ surface.classList.remove(readyClass);
if (retire) retire(projection);
else projection.remove();
for (const { source, previousAriaHidden } of actions) {
@@ -216,22 +225,27 @@ export const createVerticalBarsWebProjection = (
});
const syncExisting = () => {
if (backSource && backProjection) syncBack(backProjection, backSource);
- let topOffset = backSource ? toolbarControlSize + toolbarControlGap : 0;
- for (const { projection, source, actions } of toolbarProjections) {
+ const offsets = new Map();
+ for (const { projection, source, actions, root: surface } of toolbarProjections) {
+ const topOffset = offsets.get(surface) ?? (findBack(surface) ? toolbarControlSize + toolbarControlGap : 0);
if (actions.length === 1 && !projection.matches('ion-buttons')) syncAction(projection, actions[0].source);
else copyAttributes(projection, source);
projection.classList.add(toolbarProjectionClass, 'ion-cloned-element');
projection.style.setProperty('--ios-theme-vertical-bars-toolbar-offset', `${topOffset}px`);
+ // Preserve appearance without bypassing the modal's input/focus boundary.
+ projection.inert = isCovered(source);
+ if (projection.inert) projection.setAttribute('aria-hidden', 'true');
+ if (!surface.classList.contains(readyClass)) surface.classList.add(readyClass);
if (projection.matches('ion-buttons')) actions.forEach(({ source: action, projection: clone }) => syncAction(clone, action));
const frames = actions.map(({ source }) => verticalBarFrame(source));
- if (root && frames.every((frame) => !!frame)) {
- if (actions.length === 1) alignVerticalBarClone(projection, actions[0].source, root);
+ if (frames.every((frame) => !!frame)) {
+ if (actions.length === 1) alignVerticalBarClone(projection, actions[0].source, surface);
else {
const left = Math.min(...frames.map((frame) => frame!.x));
const top = Math.min(...frames.map((frame) => frame!.y));
const right = Math.max(...frames.map((frame) => frame!.x + frame!.width));
const bottom = Math.max(...frames.map((frame) => frame!.y + frame!.height));
- const origin = root.getBoundingClientRect();
+ const origin = surface.getBoundingClientRect();
Object.assign(projection.style, {
left: `${left - origin.left}px`,
top: `${top - origin.top}px`,
@@ -240,11 +254,12 @@ export const createVerticalBarsWebProjection = (
width: `${right - left}px`,
height: `${bottom - top}px`,
padding: '0',
+ boxSizing: 'border-box',
});
actions.forEach(({ source, projection: clone }) => alignVerticalBarClone(clone, source, projection));
}
}
- topOffset += actions.length * toolbarControlSize + toolbarControlGap;
+ offsets.set(surface, topOffset + actions.length * toolbarControlSize + toolbarControlGap);
}
};
const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => {
@@ -295,8 +310,9 @@ export const createVerticalBarsWebProjection = (
source.dispatchEvent(new CustomEvent('nativeUIChange'));
return { source, projection: clone, previousAriaHidden };
});
- root.append(projection);
- toolbarProjections.push({ source: group, projection, actions });
+ const surface = surfaceOf(group);
+ surface.append(projection);
+ toolbarProjections.push({ source: group, root: surface, projection, actions });
topOffset += actions.length * toolbarControlSize + toolbarControlGap;
}
syncExisting();
@@ -337,11 +353,12 @@ export const createVerticalBarsWebProjection = (
attributeFilter: observingVerticalBars ? undefined : ['class'],
});
}
- if (stopped || suspended || !currentRoot || !enabled()) return restore();
- const nextBack = toolbarEnabled ? findBack() : undefined;
+ if (stopped || suspended || options.verticalBars?.enabled === false || !currentRoot || (!enabled() && !topModal(doc))) return restore();
+ const nextBack = toolbarEnabled && enabled() ? findBack() : undefined;
const groups = toolbarEnabled ? findToolbarGroups() : [];
const projectionRoot = topModal(doc) ?? currentRoot;
- search.update(projectionRoot);
+ if (enabled()) search.update(projectionRoot);
+ else search.restore();
if (!nextBack && !groups.length && !search.source) return restore();
if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) {
syncExisting();
@@ -350,7 +367,8 @@ export const createVerticalBarsWebProjection = (
}
restore();
project(nextBack, groups);
- search.update(projectionRoot);
+ if (enabled()) search.update(projectionRoot);
+ else search.restore();
reveal();
updates++;
};
@@ -368,7 +386,7 @@ export const createVerticalBarsWebProjection = (
const schedule = (): Promise => {
if (stopped) return Promise.resolve();
// Release Web ownership before the native runtime can acknowledge its next frame.
- if (!enabled()) restore();
+ if (!enabled() && !topModal(doc)) restore();
const done = new Promise((resolve) => waiters.push(resolve));
if (!frame) frame = win.requestAnimationFrame(update);
return done;
@@ -431,6 +449,10 @@ export const createVerticalBarsWebProjection = (
(event) => {
const instant = (event as CustomEvent<{ instant: boolean }>).detail?.instant ?? true;
incomingInstant = undefined;
+ if (topModal(doc) && toolbarProjections.some(({ source }) => isCovered(source))) {
+ void schedule();
+ return;
+ }
restore((surface) => {
// Keep only the outgoing visual at its last rectangle after Web ownership
// is released. The native acknowledgement transfers input exactly once.
diff --git a/packages/theme-ios27/src/styles/vertical-bars.scss b/packages/theme-ios27/src/styles/vertical-bars.scss
index 1f0c94013..f993c6de2 100644
--- a/packages/theme-ios27/src/styles/vertical-bars.scss
+++ b/packages/theme-ios27/src/styles/vertical-bars.scss
@@ -356,7 +356,7 @@ ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-them
:where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *)
)
ion-buttons:is(.ios, .md):has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-projected]):not(
- :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-projected]))
+ :has(> :not([data-native-ui-projected]))
) {
display: none;
}