diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 29e13537..528aabd7 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1715,6 +1715,61 @@ 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); + 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); + 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 + .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/demo/e2e/vertical-bars-popover.spec.ts b/demo/e2e/vertical-bars-popover.spec.ts new file mode 100644 index 00000000..be3cc0fc --- /dev/null +++ b/demo/e2e/vertical-bars-popover.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; + +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' : ''}${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"]'); + await popover.waitFor({ state: 'attached' }); + if (offset) { + await page.locator('app-popover').evaluate((element: HTMLElement) => { + element.style.left = '160px'; + 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. + 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(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1'); + 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(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(() => + 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 + 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(); + }); + } +} + +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/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..a7b9772d 100644 --- a/docs/vertical-bars.md +++ b/docs/vertical-bars.md @@ -87,6 +87,14 @@ 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 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 `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..9f61f6d7 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,26 @@ 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] + let frames = projectedFrames() + if let frame = frames[id] { data["projectionFrame"] = frame } + data["projectionFrames"] = frames + notifyListeners("activate", data: data) + } + 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)] + } + view.subviews.forEach(collect) + } + let roots = (verticalBars.map { [$0.view] } ?? []) + Array(controls.values) + roots.forEach(collect) + return frames } } 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..1ec3abcc 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -173,6 +173,10 @@ export interface ShellActivation { revision: number; id: string; 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 e9059deb..d9f729e9 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -259,7 +259,12 @@ 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(), + native.projectionFrame, + ), ); return manage(runtime, { suspend: () => prehide?.suspend(), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 927efa11..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,7 +640,9 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle = { + 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, @@ -721,8 +726,13 @@ 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); + 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..5ae434be 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,19 @@ export const syncToolbarText = (doc: Document): void => { } }; -export const activateProjectedElement = (element: HTMLElement): 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; - (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 b7c3023d..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; @@ -241,7 +243,7 @@ export const createVerticalBarsWebProjection = ( (event) => { event.preventDefault(); event.stopImmediatePropagation(); - if (isCurrentToolbarAction(source)) activateProjectedElement(source); + if (isCurrentToolbarAction(source)) activateProjectedElement(source, clone.getBoundingClientRect()); }, { capture: 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 { @@ -369,8 +394,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(); diff --git a/src/projection/click.ts b/src/projection/click.ts new file mode 100644 index 00000000..a7e47ff9 --- /dev/null +++ b/src/projection/click.ts @@ -0,0 +1,22 @@ +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, edge?: 'left' | 'right'): void => { + if (target.matches(':disabled')) return; + 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 }); + if (edge) Object.defineProperty(event, 'projectionEdge', { value: edge }); + target.dispatchEvent(event); +}; diff --git a/src/projection/popover.ts b/src/projection/popover.ts new file mode 100644 index 00000000..e667d685 --- /dev/null +++ b/src/projection/popover.ts @@ -0,0 +1,46 @@ +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 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 { + // Ionic pages can contain fixed elements; account for their offset in the viewport. + const placed = anchor.getBoundingClientRect(); + 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, + side, + align: side !== opts.side ? 'center' : opts.align, + verticalOffset: edge && (side === 'left' || side === 'right' || side === 'start' || side === 'end') ? 0 : opts.verticalOffset, + }); + } 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;