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; }