From 5754d96c0ee547373c98a7b84bcec6838c6fd882 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 30 Sep 2026 23:58:02 +0900 Subject: [PATCH 1/6] fix: anchor popovers to the final projected action --- demo/e2e/vertical-bars-popover.spec.ts | 53 +++++++++++++++++++ .../app/index/pages/popover/popover.page.html | 10 ++-- docs/vertical-bars.md | 6 +++ .../IonicNativeUIShellPlugin.swift | 21 +++++++- src/index.ts | 7 ++- src/native/definitions.ts | 2 + src/native/runtime.ts | 2 +- src/native/shared/dom.ts | 6 ++- src/native/vertical-bars-web.ts | 2 +- src/projection/click.ts | 19 +++++++ src/projection/popover.ts | 32 +++++++++++ 11 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 demo/e2e/vertical-bars-popover.spec.ts create mode 100644 src/projection/click.ts create mode 100644 src/projection/popover.ts diff --git a/demo/e2e/vertical-bars-popover.spec.ts b/demo/e2e/vertical-bars-popover.spec.ts new file mode 100644 index 00000000..b6b0d0f2 --- /dev/null +++ b/demo/e2e/vertical-bars-popover.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; + +for (const { edge, offset } of [ + { edge: 'left', offset: false }, + { edge: 'right', offset: false }, + { edge: 'right', offset: true }, +]) { + for (const reference of ['trigger', 'event']) { + test(`popover uses its ${edge} rail action with ${reference} positioning${offset ? ' in an offset pane' : ''}`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/popover'); + const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]'); + await popover.waitFor({ state: 'attached' }); + if (offset) { + await page.locator('app-popover').evaluate((element: HTMLElement) => { + element.style.left = '160px'; + element.style.top = '40px'; + }); + } + await popover.evaluate((element: HTMLIonPopoverElement, value) => (element.reference = value), reference as 'trigger' | 'event'); + await page.locator('ion-app').evaluate((element, value) => { + // Exercise safe-area handling as well as the ordinary zero-inset browser case. + element.ownerDocument.documentElement.style.setProperty('--ion-safe-area-right', '56px'); + element.classList.add('ios-theme-vertical-bars'); + element.classList.toggle('ios-theme-vertical-bars-left', value === 'left'); + }, edge); + const action = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection'); + await expect(action).toBeVisible(); + const anchor = (await action.boundingBox())!; + await action.click(); + await expect(popover).toBeVisible(); + await expect(popover.getByText('Hello World!')).toBeVisible(); + await expect + .poll(() => + popover.evaluate((element) => { + const content = element.shadowRoot!.querySelector('[part~="content"]')!.getBoundingClientRect(); + const surface = element.shadowRoot!.querySelector('[part~="callout-glass"]')!.getBoundingClientRect(); + return Math.abs(content.x - (surface.x + 32)); + }), + ) + .toBeLessThan(2); + const content = await popover.evaluate((element) => + element.shadowRoot!.querySelector('[part~="content"]')!.getBoundingClientRect().toJSON(), + ); + expect(content.y).toBeGreaterThan(anchor.y); + expect(content.y).toBeLessThan(anchor.y + anchor.height + 20); + if (edge === 'right') expect(content.x).toBeGreaterThan(400); + else expect(content.x).toBeLessThan(100); + await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss()); + await expect(action).toBeVisible(); + }); + } +} diff --git a/demo/src/app/index/pages/popover/popover.page.html b/demo/src/app/index/pages/popover/popover.page.html index 5b04510b..1d50e9f2 100644 --- a/demo/src/app/index/pages/popover/popover.page.html +++ b/demo/src/app/index/pages/popover/popover.page.html @@ -5,6 +5,11 @@ popover + + + + + @@ -23,11 +28,6 @@

popover

- - - - - ion-button/buttons ion-button/ion-buttons has animation to replace ion-popover. diff --git a/docs/vertical-bars.md b/docs/vertical-bars.md index ddb06edb..5a01c440 100644 --- a/docs/vertical-bars.md +++ b/docs/vertical-bars.md @@ -87,6 +87,12 @@ The rule applies to individual buttons and buttons inside `ion-buttons`, on ordi A toolbar whose content has all moved into the rail collapses while projection is active. Toolbars containing a title, direct text, other content or a horizontal-only control remain visible. Removing projection restores the original toolbar. +### Popovers opened from the rail + +Configure this package's `popoverEnterAnimation` as Ionic's `popoverEnter` animation, as shown in the [setup example](../README.md#configure-animations). It positions a popover opened by a projected toolbar button using the visible button's bounds, for both `reference="trigger"` and `reference="event"`. + +The forwarded click still targets the original button. When the final projection's bounds are available, its `ProjectedClickEvent.projectionFrame` contains `x`, `y`, `width` and `height` in Web viewport CSS pixels. If the bounds cannot be obtained, the existing click behavior is retained. Stock Ionic animations and custom animations do not automatically consume this additional information. + ### Choose button appearance `buttonProjection` and the local projection settings below are available in `1.2.0`. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 0bac7c88..494199b5 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -20,6 +20,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private let rendering = ShellRendering() private var revision = 0 private var sequence = 0 + private var projectionScale: CGFloat = 1 private var keyboardVisible = false private var pendingTabSelections: [String: ShellTabBar.PendingSelection] = [:] private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:] @@ -203,6 +204,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele call.resolve(["revision": next]); return } let scale = webView.bounds.width / width + self.projectionScale = scale let retained = Set(snapshots.map(\.id)) for id in Array(self.controls.keys) where !retained.contains(id) { self.removeControl(id, duration: duration) @@ -390,7 +392,24 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private func activate(_ id: String) { sequence += 1 - notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence]) + var data: JSObject = ["id": id, "revision": revision, "sequence": sequence] + if let frame = projectedFrame(id) { data["projectionFrame"] = frame } + notifyListeners("activate", data: data) + } + private func projectedFrame(_ id: String) -> JSObject? { + guard let webView = bridge?.webView else { return nil } + func find(_ view: UIView) -> UIView? { + if view.accessibilityIdentifier == id { return view } + for child in view.subviews { + if let match = find(child) { return match } + } + return nil + } + let roots = (verticalBars.map { [$0.view] } ?? []) + Array(controls.values) + guard let button = roots.lazy.compactMap({ find($0) }).first else { return nil } + let frame = webView.convert(button.bounds, from: button) + return ["x": Double(frame.minX / projectionScale), "y": Double(frame.minY / projectionScale), + "width": Double(frame.width / projectionScale), "height": Double(frame.height / projectionScale)] } } diff --git a/src/index.ts b/src/index.ts index fb1783f6..0107197c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,9 @@ import { registeredEffect } from './sheets-of-glass/interfaces'; import { registerEffect } from './sheets-of-glass'; export * from './sheets-of-glass/interfaces'; -export { - iosPopoverEnterAnimation as popoverEnterAnimation, - iosPopoverLeaveAnimation as popoverLeaveAnimation, -} from '@rdlabo/ionic-theme-utils'; +export { iosPopoverLeaveAnimation as popoverLeaveAnimation } from '@rdlabo/ionic-theme-utils'; +export { popoverEnterAnimation } from './projection/popover'; +export type { ProjectedClickEvent } from './projection/click'; export * from './tab-bar-searchable'; export * from './searchbar'; export * from './transition/ios.transition'; diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f36e5601..1e19ffc1 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -173,6 +173,8 @@ export interface ShellActivation { revision: number; id: string; sequence: number; + /** Final projected control bounds in Web viewport CSS pixels, when available. */ + projectionFrame?: Frame; } export interface WebViewMetrics { diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 927efa11..8427ef58 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -722,7 +722,7 @@ export const createRuntime = async ( candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); if (!searchAction && (!item || item.disabled || item.visible === false)) return; // The original Ionic host owns form submission, routerLink and selection events. - activateProjectedElement(element); + activateProjectedElement(element, event.projectionFrame); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. schedule(); }; diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 13705482..f8bebcb1 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -1,5 +1,6 @@ import { fadeMarker } from './crossfade'; import { modalUsesVerticalBars } from './modal'; +import { projectedClick } from '../../projection/click'; import type { Frame } from '../definitions'; @@ -207,11 +208,12 @@ export const syncToolbarText = (doc: Document): void => { } }; -export const activateProjectedElement = (element: HTMLElement): void => { +export const activateProjectedElement = (element: HTMLElement, frame?: Frame): void => { const target = element.matches('ion-button, ion-back-button, ion-menu-button') ? element.shadowRoot?.querySelector('[part~="native"]') : undefined; - (target ?? element).click(); + if (frame) projectedClick(target ?? element, frame); + else (target ?? element).click(); }; export const unprojected = (elements: Iterable, read: () => T): T => { diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index b7c3023d..f9390020 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -241,7 +241,7 @@ export const createVerticalBarsWebProjection = ( (event) => { event.preventDefault(); event.stopImmediatePropagation(); - if (isCurrentToolbarAction(source)) activateProjectedElement(source); + if (isCurrentToolbarAction(source)) activateProjectedElement(source, clone.getBoundingClientRect()); }, { capture: true }, ); diff --git a/src/projection/click.ts b/src/projection/click.ts new file mode 100644 index 00000000..79a89458 --- /dev/null +++ b/src/projection/click.ts @@ -0,0 +1,19 @@ +import type { Frame } from '../native/definitions'; + +/** Position of the visible projection, in Web viewport CSS pixels. */ +export interface ProjectedClickEvent extends MouseEvent { + readonly projectionFrame: Frame; +} + +export const projectedClick = (target: HTMLElement, frame: Frame): void => { + const win = target.ownerDocument.defaultView!; + const event = new win.MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + clientX: frame.x + frame.width / 2, + clientY: frame.y + frame.height / 2, + }); + Object.defineProperty(event, 'projectionFrame', { value: frame }); + target.dispatchEvent(event); +}; diff --git a/src/projection/popover.ts b/src/projection/popover.ts new file mode 100644 index 00000000..f35eb0e4 --- /dev/null +++ b/src/projection/popover.ts @@ -0,0 +1,32 @@ +import { iosPopoverEnterAnimation } from '@rdlabo/ionic-theme-utils'; +import type { ProjectedClickEvent } from './click'; + +export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, opts = {}) => { + const frame = (opts.event as ProjectedClickEvent | undefined)?.projectionFrame; + if (!frame) return iosPopoverEnterAnimation(baseEl, opts); + + // Give Ionic a real layout reference without changing the source DOM's geometry. + const anchor = baseEl.ownerDocument.createElement('span'); + anchor.setAttribute('aria-hidden', 'true'); + Object.assign(anchor.style, { + position: 'fixed', + pointerEvents: 'none', + opacity: '0', + left: `${frame.x}px`, + top: `${frame.y}px`, + width: `${frame.width}px`, + height: `${frame.height}px`, + }); + const source = opts.trigger ?? opts.event?.target; + const pane = source?.closest?.('.ion-page, ion-content, ion-menu'); + (pane ?? baseEl.ownerDocument.body).append(anchor); + try { + // Ionic pages can contain fixed elements; account for their offset in the viewport. + const placed = anchor.getBoundingClientRect(); + anchor.style.left = `${frame.x + (frame.x - placed.x)}px`; + anchor.style.top = `${frame.y + (frame.y - placed.y)}px`; + return iosPopoverEnterAnimation(baseEl, { ...opts, trigger: anchor }); + } finally { + anchor.remove(); + } +}; From 396bef6ba09a58c59e8a9a4110d482ffa796da1c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 1 Oct 2026 00:20:17 +0900 Subject: [PATCH 2/6] fix: respect disabled sources and scaled popover anchors --- demo/e2e/vertical-bars-popover.spec.ts | 56 +++++++++++++++++++++++--- src/native/shared/dom.ts | 1 + src/projection/click.ts | 1 + src/projection/popover.ts | 8 +++- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/demo/e2e/vertical-bars-popover.spec.ts b/demo/e2e/vertical-bars-popover.spec.ts index b6b0d0f2..db67254c 100644 --- a/demo/e2e/vertical-bars-popover.spec.ts +++ b/demo/e2e/vertical-bars-popover.spec.ts @@ -1,12 +1,15 @@ import { expect, test } from '@playwright/test'; -for (const { edge, offset } of [ - { edge: 'left', offset: false }, - { edge: 'right', offset: false }, - { edge: 'right', offset: true }, +for (const { edge, offset, scale } of [ + { edge: 'left', offset: false, scale: 1 }, + { edge: 'right', offset: false, scale: 1 }, + { edge: 'right', offset: true, scale: 1 }, + { edge: 'right', offset: true, scale: 0.8 }, ]) { for (const reference of ['trigger', 'event']) { - test(`popover uses its ${edge} rail action with ${reference} positioning${offset ? ' in an offset pane' : ''}`, async ({ page }) => { + test(`popover uses its ${edge} rail action with ${reference} positioning${offset ? ' in an offset pane' : ''}${scale !== 1 ? ' with scale' : ''}`, async ({ + page, + }) => { await page.setViewportSize({ width: 700, height: 900 }); await page.goto('/main/index/popover'); const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]'); @@ -17,6 +20,12 @@ for (const { edge, offset } of [ element.style.top = '40px'; }); } + if (scale !== 1) { + await page.locator('app-popover').evaluate((element: HTMLElement, value) => { + element.style.transformOrigin = '0 0'; + element.style.transform = `scale(${value})`; + }, scale); + } await popover.evaluate((element: HTMLIonPopoverElement, value) => (element.reference = value), reference as 'trigger' | 'event'); await page.locator('ion-app').evaluate((element, value) => { // Exercise safe-area handling as well as the ordinary zero-inset browser case. @@ -44,10 +53,45 @@ for (const { edge, offset } of [ ); expect(content.y).toBeGreaterThan(anchor.y); expect(content.y).toBeLessThan(anchor.y + anchor.height + 20); - if (edge === 'right') expect(content.x).toBeGreaterThan(400); + // A scaled pane can clamp the surface inward; its animation must still originate at the rail action. + await expect + .poll(() => + popover.evaluate((element, anchor) => { + const content = element.shadowRoot!.querySelector('[part~="content"]')!; + const [x, y] = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat); + const bounds = content.getBoundingClientRect(); + return Math.max(Math.abs(bounds.x + x - anchor.x - anchor.width / 2), Math.abs(bounds.y + y - anchor.y - anchor.height / 2)); + }, anchor), + ) + .toBeLessThan(1); + if (edge === 'right') expect(content.x).toBeGreaterThan(scale === 1 ? 400 : 300); else expect(content.x).toBeLessThan(100); await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss()); await expect(action).toBeVisible(); }); } } + +test('a projected action respects source disabled state before its clone resynchronizes', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection')).toBeVisible(); + const counts = await page.locator('#click-trigger-right-buttons ion-button').evaluate((source: HTMLIonButtonElement) => { + let clicks = 0; + source.addEventListener('click', () => clicks++); + const clone = document.querySelector('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection')!; + source.disabled = true; + clone.click(); + const hostDisabled = clicks; + source.disabled = false; + const native = source.shadowRoot!.querySelector('[part~="native"]')!; + native.disabled = true; + clone.click(); + const nativeDisabled = clicks; + native.disabled = false; + clone.click(); + return [hostDisabled, nativeDisabled, clicks]; + }); + expect(counts).toEqual([0, 0, 1]); +}); diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index f8bebcb1..6e47359a 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -209,6 +209,7 @@ export const syncToolbarText = (doc: Document): void => { }; export const activateProjectedElement = (element: HTMLElement, frame?: Frame): void => { + if ((element as HTMLElement & { disabled?: boolean }).disabled) return; const target = element.matches('ion-button, ion-back-button, ion-menu-button') ? element.shadowRoot?.querySelector('[part~="native"]') : undefined; diff --git a/src/projection/click.ts b/src/projection/click.ts index 79a89458..fa179635 100644 --- a/src/projection/click.ts +++ b/src/projection/click.ts @@ -6,6 +6,7 @@ export interface ProjectedClickEvent extends MouseEvent { } export const projectedClick = (target: HTMLElement, frame: Frame): void => { + if (target.matches(':disabled')) return; const win = target.ownerDocument.defaultView!; const event = new win.MouseEvent('click', { bubbles: true, diff --git a/src/projection/popover.ts b/src/projection/popover.ts index f35eb0e4..539a80f1 100644 --- a/src/projection/popover.ts +++ b/src/projection/popover.ts @@ -23,8 +23,12 @@ export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, o try { // Ionic pages can contain fixed elements; account for their offset in the viewport. const placed = anchor.getBoundingClientRect(); - anchor.style.left = `${frame.x + (frame.x - placed.x)}px`; - anchor.style.top = `${frame.y + (frame.y - placed.y)}px`; + const scaleX = placed.width / frame.width; + const scaleY = placed.height / frame.height; + anchor.style.left = `${frame.x + (frame.x - placed.x) / scaleX}px`; + anchor.style.top = `${frame.y + (frame.y - placed.y) / scaleY}px`; + anchor.style.width = `${frame.width / scaleX}px`; + anchor.style.height = `${frame.height / scaleY}px`; return iosPopoverEnterAnimation(baseEl, { ...opts, trigger: anchor }); } finally { anchor.remove(); From c1876c63eb0cca4ce89e8fa02d678fe5da7f4e33 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 1 Oct 2026 14:49:40 +0900 Subject: [PATCH 3/6] fix: restore Web rail while overlays retire native controls --- demo/e2e/native-ui-shell.spec.ts | 28 ++++++++++++++++++++++++++++ src/native/index.ts | 6 +++++- src/native/runtime.ts | 5 +++-- src/native/vertical-bars-web.ts | 5 +++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 29e13537..ed501344 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1715,6 +1715,34 @@ test('all demo pages keep projection consistent through consecutive navigation', expect(errors).toEqual([]); }); +test('a native rail popover hands toolbar controls to Web and returns them after dismissal', async ({ page }) => { + await page.setViewportSize({ width: 440, height: 636 }); + await mockNative(page); + await page.goto('/main/index?verticalBarsOnly'); + await page.getByText('iPhone Duo Mode', { exact: true }).click(); + await page.getByText('popover', { exact: true }).click(); + const source = page.locator('#click-trigger-right-buttons ion-button'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + const action = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection'); + await expect(action).toHaveCount(0); + await activate(page, 'Open popover'); + const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]'); + await expect(popover.getByText('Hello World!')).toBeVisible(); + await expect(action).toBeVisible(); + await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible(); + await expect(source.locator('..')).toBeHidden(); + await expect(page.locator('app-popover > ion-header > ion-toolbar').nth(1)).toBeHidden(); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls.length)) + .toBe(0); + await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss()); + await expect(action).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls.length)) + .toBeGreaterThan(0); +}); + test('demo overlay variants retire native controls and restore them after dismissal', async ({ page }) => { test.setTimeout(180000); await mockNative(page); diff --git a/src/native/index.ts b/src/native/index.ts index e9059deb..5b3dd377 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -259,7 +259,11 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); runtime = combine( native, - createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), + createVerticalBarsWebProjection( + document, + options, + () => !nativeVerticalBars() || native.getStatus().state === 'stopped' || native.isOverlayOpen(), + ), ); return manage(runtime, { suspend: () => prehide?.suspend(), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 8427ef58..482d5ce1 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -47,7 +47,7 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, -): Promise => { +): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); const crossfade = createCrossfade(win); @@ -637,7 +637,8 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle = { + const handle: NativeUIShellHandle & { isOverlayOpen(): boolean } = { + isOverlayOpen: () => overlayOpen(false, true), getStatus: (): NativeUIShellStatus => ({ state: stopped ? 'stopped' : sources.size ? 'native' : 'web', projected: sources.size, diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index f9390020..2bc91889 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -369,8 +369,9 @@ export const createVerticalBarsWebProjection = ( for (const name of ['ionViewWillEnter', 'ionViewWillLeave', 'ionViewDidEnter', 'ionViewDidLeave']) doc.addEventListener(name, pageLifecycle, { capture: true, signal: listeners.signal }); 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 }); + for (const overlay of ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast']) + for (const name of [`ion${overlay}WillPresent`, `ion${overlay}DidDismiss`]) + doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal }); win.addEventListener('resize', schedule, { signal: listeners.signal }); win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal }); schedule(); From 39831499d2b75419223365562efbe4367ebecb60 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 1 Oct 2026 15:14:36 +0900 Subject: [PATCH 4/6] fix: retain native action geometry for popover handoff --- demo/e2e/native-ui-shell.spec.ts | 24 +++++++++++- demo/e2e/vertical-bars-popover.spec.ts | 9 +++-- docs/vertical-bars.md | 4 +- .../IonicNativeUIShellPlugin.swift | 26 +++++++------ src/native/definitions.ts | 2 + src/native/index.ts | 1 + src/native/runtime.ts | 13 ++++++- src/native/shared/dom.ts | 10 ++++- src/native/shared/projection-frame.ts | 18 +++++++++ src/native/vertical-bars-web.ts | 37 ++++++++++++++++--- src/projection/click.ts | 4 +- src/projection/popover.ts | 6 ++- src/styles/vertical-bars.scss | 8 ++++ 13 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 src/native/shared/projection-frame.ts diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index ed501344..93c97941 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1725,11 +1725,33 @@ test('a native rail popover hands toolbar controls to Web and returns them after await expect(source).toHaveAttribute('data-native-ui-shell', ''); const action = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection'); await expect(action).toHaveCount(0); - await activate(page, 'Open popover'); + const frames = { action: { x: 390, y: 235, width: 38, height: 38 }, back: { x: 390, y: 170, width: 38, height: 38 } }; + await page.evaluate((frames) => { + const mock = Capacitor.registerPlugin('IonicNativeUIShell'); + const snapshot = mock.updates.at(-1)!; + const action = snapshot.controls.flatMap((control) => control.items).find((item) => item.accessibilityLabel === 'Open popover')!; + const back = snapshot.controls.find((control) => control.kind === 'ion-back-button')!.items[0]; + mock.notifyListeners('activate', { + id: action.id, + revision: snapshot.revision, + sequence: ++mock.sequence, + projectionFrame: frames.action, + projectionFrames: { [action.id]: frames.action, [back.id]: frames.back }, + }); + }, frames); const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]'); await expect(popover.getByText('Hello World!')).toBeVisible(); await expect(action).toBeVisible(); await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible(); + await expect.poll(() => action.boundingBox()).toEqual(frames.action); + await expect.poll(() => action.locator('[part~="native"]').boundingBox()).toEqual(frames.action); + await expect + .poll(() => page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection').boundingBox()) + .toEqual(frames.back); + await expect(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1'); + const content = (await popover.locator('[part~="content"]').boundingBox())!; + expect(content.y + content.height / 2).toBeCloseTo(frames.action.y + frames.action.height / 2, 0); + expect(content.x + content.width).toBeLessThanOrEqual(frames.action.x + 1); await expect(source.locator('..')).toBeHidden(); await expect(page.locator('app-popover > ion-header > ion-toolbar').nth(1)).toBeHidden(); await expect diff --git a/demo/e2e/vertical-bars-popover.spec.ts b/demo/e2e/vertical-bars-popover.spec.ts index db67254c..be3cc0fc 100644 --- a/demo/e2e/vertical-bars-popover.spec.ts +++ b/demo/e2e/vertical-bars-popover.spec.ts @@ -39,6 +39,7 @@ for (const { edge, offset, scale } of [ await action.click(); await expect(popover).toBeVisible(); await expect(popover.getByText('Hello World!')).toBeVisible(); + await expect(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1'); await expect .poll(() => popover.evaluate((element) => { @@ -51,8 +52,7 @@ for (const { edge, offset, scale } of [ const content = await popover.evaluate((element) => element.shadowRoot!.querySelector('[part~="content"]')!.getBoundingClientRect().toJSON(), ); - expect(content.y).toBeGreaterThan(anchor.y); - expect(content.y).toBeLessThan(anchor.y + anchor.height + 20); + expect(Math.abs(content.y + content.height / 2 - anchor.y - anchor.height / 2)).toBeLessThan(1); // A scaled pane can clamp the surface inward; its animation must still originate at the rail action. await expect .poll(() => @@ -64,8 +64,9 @@ for (const { edge, offset, scale } of [ }, anchor), ) .toBeLessThan(1); - if (edge === 'right') expect(content.x).toBeGreaterThan(scale === 1 ? 400 : 300); - else expect(content.x).toBeLessThan(100); + if (edge === 'right') + expect(content.x + content.width).toBeLessThanOrEqual(anchor.x + (reference === 'event' ? anchor.width / 2 : 0) + 1); + else expect(content.x).toBeGreaterThanOrEqual(anchor.x + (reference === 'event' ? anchor.width / 2 : anchor.width) - 1); await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss()); await expect(action).toBeVisible(); }); diff --git a/docs/vertical-bars.md b/docs/vertical-bars.md index 5a01c440..a7b9772d 100644 --- a/docs/vertical-bars.md +++ b/docs/vertical-bars.md @@ -91,7 +91,9 @@ A toolbar whose content has all moved into the rail collapses while projection i Configure this package's `popoverEnterAnimation` as Ionic's `popoverEnter` animation, as shown in the [setup example](../README.md#configure-animations). It positions a popover opened by a projected toolbar button using the visible button's bounds, for both `reference="trigger"` and `reference="event"`. -The forwarded click still targets the original button. When the final projection's bounds are available, its `ProjectedClickEvent.projectionFrame` contains `x`, `y`, `width` and `height` in Web viewport CSS pixels. If the bounds cannot be obtained, the existing click behavior is retained. Stock Ionic animations and custom animations do not automatically consume this additional information. +The default bottom placement opens toward the page from a rail button, with the arrow aligned to the button. Other explicit sides are retained. While the overlay hides native controls, their Web replacements keep the last native button bounds; dismissal restores native projection. + +The forwarded click still targets the original button. When the final projection's bounds are available, its `ProjectedClickEvent.projectionFrame` contains `x`, `y`, `width` and `height` in Web viewport CSS pixels. Rail actions also supply `projectionEdge` (`left` or `right`, in physical coordinates). If the bounds cannot be obtained, the existing click behavior is retained. Stock Ionic animations and custom animations do not automatically consume this additional information. ### Choose button appearance diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 494199b5..9f61f6d7 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -393,23 +393,25 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private func activate(_ id: String) { sequence += 1 var data: JSObject = ["id": id, "revision": revision, "sequence": sequence] - if let frame = projectedFrame(id) { data["projectionFrame"] = frame } + let frames = projectedFrames() + if let frame = frames[id] { data["projectionFrame"] = frame } + data["projectionFrames"] = frames notifyListeners("activate", data: data) } - private func projectedFrame(_ id: String) -> JSObject? { - guard let webView = bridge?.webView else { return nil } - func find(_ view: UIView) -> UIView? { - if view.accessibilityIdentifier == id { return view } - for child in view.subviews { - if let match = find(child) { return match } + private func projectedFrames() -> JSObject { + guard let webView = bridge?.webView else { return [:] } + var frames: JSObject = [:] + func collect(_ view: UIView) { + if let id = view.accessibilityIdentifier, frames[id] == nil { + let frame = webView.convert(view.bounds, from: view) + frames[id] = ["x": Double(frame.minX / projectionScale), "y": Double(frame.minY / projectionScale), + "width": Double(frame.width / projectionScale), "height": Double(frame.height / projectionScale)] } - return nil + view.subviews.forEach(collect) } let roots = (verticalBars.map { [$0.view] } ?? []) + Array(controls.values) - guard let button = roots.lazy.compactMap({ find($0) }).first else { return nil } - let frame = webView.convert(button.bounds, from: button) - return ["x": Double(frame.minX / projectionScale), "y": Double(frame.minY / projectionScale), - "width": Double(frame.width / projectionScale), "height": Double(frame.height / projectionScale)] + roots.forEach(collect) + return frames } } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 1e19ffc1..1ec3abcc 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -175,6 +175,8 @@ export interface ShellActivation { sequence: number; /** Final projected control bounds in Web viewport CSS pixels, when available. */ projectionFrame?: Frame; + /** Native action bounds retained by the Web rail while an overlay is open. */ + projectionFrames?: Record; } export interface WebViewMetrics { diff --git a/src/native/index.ts b/src/native/index.ts index 5b3dd377..d9f729e9 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -263,6 +263,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped' || native.isOverlayOpen(), + native.projectionFrame, ), ); return manage(runtime, { diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 482d5ce1..9f462a7c 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -10,6 +10,7 @@ import type { NativeUIShellOptions, NativeUIShellPlugin, NativeUIShellStatus, + Frame, } from './definitions'; import { readCandidate, selector, shadowSelector, motionSelector, isVerticalBarsCandidate } from './components'; import { @@ -47,7 +48,7 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, -): Promise => { +): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); const crossfade = createCrossfade(win); @@ -65,6 +66,7 @@ export const createRuntime = async ( const observed = new Set(); const listeners = new AbortController(); let actions = new Map(); + let projectionFrames = new WeakMap(); let nextId = 0; let revision = 0; let acceptedRevision = 0; @@ -587,6 +589,7 @@ export const createRuntime = async ( }); on(doc, `ion${name}DidDismiss`, (event) => { presented.delete(event.target as HTMLElement); + if (!overlayOpen(false, true)) projectionFrames = new WeakMap(); schedule(); }); } @@ -637,8 +640,9 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle & { isOverlayOpen(): boolean } = { + const handle: NativeUIShellHandle & { isOverlayOpen(): boolean; projectionFrame(element: HTMLElement): Frame | undefined } = { isOverlayOpen: () => overlayOpen(false, true), + projectionFrame: (element) => (overlayOpen(false, true) ? projectionFrames.get(element) : undefined), getStatus: (): NativeUIShellStatus => ({ state: stopped ? 'stopped' : sources.size ? 'native' : 'web', projected: sources.size, @@ -722,6 +726,11 @@ export const createRuntime = async ( const searchAction = candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); if (!searchAction && (!item || item.disabled || item.visible === false)) return; + projectionFrames = new WeakMap(); + for (const [id, frame] of Object.entries(event.projectionFrames ?? {})) { + const source = actions.get(id); + if (source) projectionFrames.set(source, frame); + } // The original Ionic host owns form submission, routerLink and selection events. activateProjectedElement(element, event.projectionFrame); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 6e47359a..5ae434be 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -213,8 +213,14 @@ export const activateProjectedElement = (element: HTMLElement, frame?: Frame): v const target = element.matches('ion-button, ion-back-button, ion-menu-button') ? element.shadowRoot?.querySelector('[part~="native"]') : undefined; - if (frame) projectedClick(target ?? element, frame); - else (target ?? element).click(); + if (frame) { + const edge = isVerticalBarsSource(element) + ? element.closest('ion-app')?.classList.contains('ios-theme-vertical-bars-left') + ? 'left' + : 'right' + : undefined; + projectedClick(target ?? element, frame, edge); + } else (target ?? element).click(); }; export const unprojected = (elements: Iterable, read: () => T): T => { diff --git a/src/native/shared/projection-frame.ts b/src/native/shared/projection-frame.ts new file mode 100644 index 00000000..ac1ff7fc --- /dev/null +++ b/src/native/shared/projection-frame.ts @@ -0,0 +1,18 @@ +import type { Frame } from '../definitions'; + +/** Keep the Web replacement at the native action's last visible bounds. */ +export const placeProjection = (element: HTMLElement, frame: Frame, position = 'fixed'): void => { + element.setAttribute('data-native-ui-shell-frame', ''); + Object.assign(element.style, { + position, + left: `${frame.x}px`, + top: `${frame.y}px`, + right: 'auto', + bottom: 'auto', + width: `${frame.width}px`, + minWidth: '0', + height: `${frame.height}px`, + minHeight: '0', + margin: '0', + }); +}; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 2bc91889..f0bdcdf9 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -1,6 +1,7 @@ import { createVerticalBarsWebSearch, searchProjectionClass } from './vertical-bars-web-search'; import { inVerticalBarsSurface, topModal } from './shared/modal'; -import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; +import type { Frame, NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; +import { placeProjection } from './shared/projection-frame'; import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration'; import { activateProjectedElement, @@ -44,6 +45,7 @@ export const createVerticalBarsWebProjection = ( doc: Document, options: NativeUIShellOptions, enabled: () => boolean = () => true, + nativeFrame: (source: HTMLElement) => Frame | undefined = () => undefined, ): NativeUIShellHandle => { const win = doc.defaultView!; const toolbarEnabled = options.controls === undefined || options.controls.toolbar === true; @@ -293,11 +295,34 @@ export const createVerticalBarsWebProjection = ( const projectionRoot = topModal(doc) ?? currentRoot; search.update(projectionRoot); if (!nextBack && !groups.length && !search.source) return restore(); - if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) return syncExisting(); - restore(); - project(nextBack, groups); - search.update(projectionRoot); - updates++; + if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) syncExisting(); + else { + restore(); + project(nextBack, groups); + search.update(projectionRoot); + updates++; + } + if (backSource && backProjection) { + const frame = nativeFrame(backSource); + if (frame) placeProjection(backProjection, frame); + } + for (const { projection, actions } of toolbarProjections) { + const frames = actions.map(({ source }) => nativeFrame(source)); + if (!frames.every((frame): frame is Frame => !!frame)) continue; + const x = Math.min(...frames.map((frame) => frame.x)); + const y = Math.min(...frames.map((frame) => frame.y)); + placeProjection(projection, { + x, + y, + width: Math.max(...frames.map((frame) => frame.x + frame.width)) - x, + height: Math.max(...frames.map((frame) => frame.y + frame.height)) - y, + }); + if (actions.length > 1) + actions.forEach(({ projection: child }, index) => { + const frame = frames[index]; + placeProjection(child, { ...frame, x: frame.x - x, y: frame.y - y }, 'absolute'); + }); + } }; const update = () => { try { diff --git a/src/projection/click.ts b/src/projection/click.ts index fa179635..a7e47ff9 100644 --- a/src/projection/click.ts +++ b/src/projection/click.ts @@ -3,9 +3,10 @@ import type { Frame } from '../native/definitions'; /** Position of the visible projection, in Web viewport CSS pixels. */ export interface ProjectedClickEvent extends MouseEvent { readonly projectionFrame: Frame; + readonly projectionEdge?: 'left' | 'right'; } -export const projectedClick = (target: HTMLElement, frame: Frame): void => { +export const projectedClick = (target: HTMLElement, frame: Frame, edge?: 'left' | 'right'): void => { if (target.matches(':disabled')) return; const win = target.ownerDocument.defaultView!; const event = new win.MouseEvent('click', { @@ -16,5 +17,6 @@ export const projectedClick = (target: HTMLElement, frame: Frame): void => { clientY: frame.y + frame.height / 2, }); Object.defineProperty(event, 'projectionFrame', { value: frame }); + if (edge) Object.defineProperty(event, 'projectionEdge', { value: edge }); target.dispatchEvent(event); }; diff --git a/src/projection/popover.ts b/src/projection/popover.ts index 539a80f1..7fbde308 100644 --- a/src/projection/popover.ts +++ b/src/projection/popover.ts @@ -18,6 +18,10 @@ export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, o height: `${frame.height}px`, }); const source = opts.trigger ?? opts.event?.target; + const edge = (opts.event as ProjectedClickEvent).projectionEdge; + // A default callout below an edge control cannot keep its arrow on the button. + // Open into the page instead, using the existing side/align positioning API. + const side = edge && (!opts.side || opts.side === 'bottom') ? (edge === 'left' ? 'right' : 'left') : opts.side; const pane = source?.closest?.('.ion-page, ion-content, ion-menu'); (pane ?? baseEl.ownerDocument.body).append(anchor); try { @@ -29,7 +33,7 @@ export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, o anchor.style.top = `${frame.y + (frame.y - placed.y) / scaleY}px`; anchor.style.width = `${frame.width / scaleX}px`; anchor.style.height = `${frame.height / scaleY}px`; - return iosPopoverEnterAnimation(baseEl, { ...opts, trigger: anchor }); + return iosPopoverEnterAnimation(baseEl, { ...opts, trigger: anchor, side, align: side !== opts.side ? 'center' : opts.align }); } finally { anchor.remove(); } diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 32496c88..369d165f 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -279,6 +279,14 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left left: calc(24px + (48px - var(--ios-theme-side-tab-bar-width)) / 2); } +// Native-sized replacements must not retain Ionic's fixed icon-button size. +.ion-cloned-element[data-native-ui-shell-frame]::part(native) { + width: 100% !important; + height: 100% !important; + min-width: 0 !important; + min-height: 0 !important; +} + .ios-theme-native-ui-shell-prehidden { position: absolute !important; visibility: hidden !important; From 0145aaa47e485791029c87771fcad4255df4c0ee Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 1 Oct 2026 15:29:50 +0900 Subject: [PATCH 5/6] fix: scope horizontal alignment to projected rail popovers --- demo/e2e/native-ui-shell.spec.ts | 7 ++++++- src/projection/popover.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 93c97941..528aabd7 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1751,7 +1751,12 @@ test('a native rail popover hands toolbar controls to Web and returns them after await expect(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1'); const content = (await popover.locator('[part~="content"]').boundingBox())!; expect(content.y + content.height / 2).toBeCloseTo(frames.action.y + frames.action.height / 2, 0); - expect(content.x + content.width).toBeLessThanOrEqual(frames.action.x + 1); + await expect + .poll(async () => { + const bounds = (await popover.locator('[part~="content"]').boundingBox())!; + return bounds.x + bounds.width; + }) + .toBeLessThanOrEqual(frames.action.x + 1); await expect(source.locator('..')).toBeHidden(); await expect(page.locator('app-popover > ion-header > ion-toolbar').nth(1)).toBeHidden(); await expect diff --git a/src/projection/popover.ts b/src/projection/popover.ts index 7fbde308..d54c73f4 100644 --- a/src/projection/popover.ts +++ b/src/projection/popover.ts @@ -33,7 +33,13 @@ export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, o anchor.style.top = `${frame.y + (frame.y - placed.y) / scaleY}px`; anchor.style.width = `${frame.width / scaleX}px`; anchor.style.height = `${frame.height / scaleY}px`; - return iosPopoverEnterAnimation(baseEl, { ...opts, trigger: anchor, side, align: side !== opts.side ? 'center' : opts.align }); + return iosPopoverEnterAnimation(baseEl, { + ...opts, + trigger: anchor, + side, + align: side !== opts.side ? 'center' : opts.align, + preserveHorizontalAlignment: !!edge, + }); } finally { anchor.remove(); } From 731d8b9c21ee985db43eec2ad4d01e09188b5426 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 1 Oct 2026 15:37:05 +0900 Subject: [PATCH 6/6] refactor: propose numeric offset for projected rail popovers --- src/projection/popover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/projection/popover.ts b/src/projection/popover.ts index d54c73f4..e667d685 100644 --- a/src/projection/popover.ts +++ b/src/projection/popover.ts @@ -38,7 +38,7 @@ export const popoverEnterAnimation: typeof iosPopoverEnterAnimation = (baseEl, o trigger: anchor, side, align: side !== opts.side ? 'center' : opts.align, - preserveHorizontalAlignment: !!edge, + verticalOffset: edge && (side === 'left' || side === 'right' || side === 'start' || side === 'end') ? 0 : opts.verticalOffset, }); } finally { anchor.remove();