diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts index 57d253de..87f48bdb 100644 --- a/demo/e2e/animation.spec.ts +++ b/demo/e2e/animation.spec.ts @@ -60,7 +60,7 @@ test.describe('Animation Tests', () => { }); test('runs and completes the iOS page transition', async ({ page }) => { - const shade = page.locator('.ios27-transition-shade'); + const shade = page.locator('.ios-transition-shade'); const checkDimming = async (back: boolean) => { const opacity: number[] = []; for (const progress of [0.2, 0.4]) { diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index feef1e95..1e1742c0 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -1,9 +1,11 @@ import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { compile } from 'sass'; +import { compile, NodePackageImporter } from 'sass'; import { resolve } from 'node:path'; import * as overlayTypes from '../src/app/overlay-types'; +const importer = new NodePackageImporter(resolve(__dirname, '../../')); + const mockNative = async (page: Page, fail = false) => { await page.addInitScript((fail) => { const state = { @@ -1730,7 +1732,9 @@ for (const theme of ['light', 'class', 'system', 'always'] as const) { document.documentElement.style.colorScheme = 'normal'; }); if (theme !== 'light') { - await page.addStyleTag({ content: compile(resolve(__dirname, `../../src/styles/ionic-theme-ios27-dark-${theme}.scss`)).css }); + await page.addStyleTag({ + content: compile(resolve(__dirname, `../../src/styles/ionic-theme-ios27-dark-${theme}.scss`), { importers: [importer] }).css, + }); } for (const dark of [true, false, true]) { if (theme === 'class') { diff --git a/demo/e2e/popover.spec.ts b/demo/e2e/popover.spec.ts index 5882c837..a7d6e513 100644 --- a/demo/e2e/popover.spec.ts +++ b/demo/e2e/popover.spec.ts @@ -136,7 +136,7 @@ for (const width of [390, 1210]) { const measure = el.getBoundingClientRect; el.getBoundingClientRect = () => { const rect = measure.call(el); - if (!el.classList.contains('ios27-replace-element')) el.presentationBounds = rect; + if (!el.classList.contains('ios-theme-replace-element')) el.presentationBounds = rect; return rect; }; el.restoreMeasurement = () => { @@ -249,7 +249,7 @@ for (const tag of ['button', 'ion-button']) { originY: rect.top + origin[1], expected: horizontal ? 320.5 : 520.5, callout: root.querySelectorAll('[part="callout-glass"]').length, - replacing: anchor.classList.contains('ios27-replace-element'), + replacing: anchor.classList.contains('ios-theme-replace-element'), }; await popover.dismiss(); popover.remove(); diff --git a/demo/e2e/tab-bar-preferences.spec.ts b/demo/e2e/tab-bar-preferences.spec.ts index ee597327..1a0ef3bc 100644 --- a/demo/e2e/tab-bar-preferences.spec.ts +++ b/demo/e2e/tab-bar-preferences.spec.ts @@ -1,14 +1,18 @@ import { expect, test } from '@playwright/test'; -import { compile } from 'sass'; +import { compile, NodePackageImporter } from 'sass'; import { resolve } from 'node:path'; test.use({ viewport: { width: 402, height: 874 }, hasTouch: true }); +const importer = new NodePackageImporter(resolve(__dirname, '../../')); + for (const theme of ['light', 'class', 'system', 'always']) { test(`tab selection color fallbacks in ${theme} mode`, async ({ page }) => { const styles = resolve(__dirname, '../../src/styles'); - const css = compile(`${styles}/default-variables.scss`).css + compile(`${styles}/ionic-theme-ios27.scss`).css; - const darkCss = theme === 'light' ? '' : compile(`${styles}/ionic-theme-ios27-dark-${theme}.scss`).css; + const css = + compile(`${styles}/default-variables.scss`, { importers: [importer] }).css + + compile(`${styles}/ionic-theme-ios27.scss`, { importers: [importer] }).css; + const darkCss = theme === 'light' ? '' : compile(`${styles}/ionic-theme-ios27-dark-${theme}.scss`, { importers: [importer] }).css; await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' }); const html = ` diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index 02892146..5b1a3f3f 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -7,6 +7,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', fullyParallel: true, + workers: process.env['CI'] ? 4 : undefined, forbidOnly: !!process.env['CI'], retries: process.env['CI'] ? 2 : 0, reporter: 'html', diff --git a/package-lock.json b/package-lock.json index ed5aa79c..63ded223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@rdlabo/ionic-theme-ios27", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c" + }, "devDependencies": { "@capacitor/core": "^8.5.2", "@ionic/angular": "^9.0.0", @@ -2216,6 +2219,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@rdlabo/ionic-theme-utils": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c", + "integrity": "sha512-0/aRkysY012ByvBJFaKBNiU5BFeR7uHCxuQ/BBprq1eHjOgS2AoyZwcBqhqP3SZDDDhnGKLK/SfLQYRySeFAng==", + "license": "MIT", + "peerDependencies": { + "@ionic/core": ">=8.8.1 <10" + } + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", diff --git a/package.json b/package.json index 64783e9c..d7b75f0f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ ], "scripts": { "build": "npm run build:css && npm run build:ts", - "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map", + "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map --pkg-importer=node", "build:ts": "tsc", "build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production", "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"", @@ -53,6 +53,9 @@ "url": "https://github.com/rdlabo-dev/ionic-theme-ios27/issues" }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27", + "dependencies": { + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c" + }, "devDependencies": { "@capacitor/core": "^8.5.2", "@ionic/angular": "^9.0.0", diff --git a/src/index.ts b/src/index.ts index db5d4d25..4a90cebe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ import { registeredEffect } from './sheets-of-glass/interfaces'; import { registerEffect } from './sheets-of-glass'; export * from './sheets-of-glass/interfaces'; -export { iosEnterAnimation as popoverEnterAnimation } from './popover/animations/ios.enter'; -export { iosLeaveAnimation as popoverLeaveAnimation } from './popover/animations/ios.leave'; +export { + iosPopoverEnterAnimation as popoverEnterAnimation, + iosPopoverLeaveAnimation as popoverLeaveAnimation, +} from '@rdlabo/ionic-theme-utils'; export * from './tab-bar-searchable'; export * from './searchbar'; export * from './transition/ios.transition'; diff --git a/src/popover/animations/ios.enter.ts b/src/popover/animations/ios.enter.ts deleted file mode 100644 index e1cfde71..00000000 --- a/src/popover/animations/ios.enter.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { createAnimation } from '@ionic/core'; -import type { Animation } from '@ionic/core'; -import { getElementRoot } from '../../utils'; -import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils'; -import { createCalloutSurface } from '../callout-surface'; - -const POPOVER_IOS_BODY_PADDING = 5; -export const POPOVER_IOS_BODY_MARGIN = 8; - -/** - * iOS Popover Enter Animation - */ -// TODO(FW-2832): types -export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => { - const { event: ev, size, trigger, reference, side, align } = opts; - const doc = baseEl.ownerDocument as any; - const isRTL = doc.dir === 'rtl'; - const bodyWidth = doc.defaultView.innerWidth; - const bodyHeight = doc.defaultView.innerHeight; - - const root = getElementRoot(baseEl); - const contentEl = root.querySelector('.popover-content') as HTMLElement; - const arrowEl = root.querySelector('.popover-arrow'); - - const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target; - const anchorBounds = referenceSizeEl?.getBoundingClientRect(); - // Overlays live outside the page, but must stay in the pane that opened them. - let paneAnchor = referenceSizeEl as HTMLElement | undefined; - let pane: HTMLElement | null = null; - while (paneAnchor && !pane) { - pane = paneAnchor.closest('ion-content, .ion-page, ion-menu'); - paneAnchor = (paneAnchor.getRootNode() as ShadowRoot).host as HTMLElement | undefined; - } - const paneBounds = pane?.getBoundingClientRect(); - const scroll = pane?.shadowRoot?.querySelector('[part="scroll"]'); - const scrollStyle = scroll ? getComputedStyle(scroll) : null; - let paneLeft = Math.max(0, (paneBounds?.left ?? 0) + (parseFloat(scrollStyle?.paddingLeft ?? '0') || 0)); - let paneRight = Math.min(bodyWidth, (paneBounds?.right ?? bodyWidth) - (parseFloat(scrollStyle?.paddingRight ?? '0') || 0)); - // Toolbars may be siblings of ion-content; exclude the visible menu there too. - const splitPane = pane?.closest('ion-split-pane.split-pane-visible'); - if (splitPane && pane?.closest('.split-pane-main')) { - for (const menu of Array.from(splitPane.querySelectorAll(':scope > ion-menu.menu-pane-visible'))) { - const rect = menu.getBoundingClientRect(); - if (rect.left <= paneLeft && rect.right > paneLeft) paneLeft = rect.right; - if (rect.right >= paneRight && rect.left < paneRight) paneRight = rect.left; - } - } - const paneMargin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN; - const availableWidth = Math.max(0, paneRight - paneLeft - paneMargin * 2); - if (pane && availableWidth > 0 && contentEl.getBoundingClientRect().width > availableWidth) { - contentEl.dataset['previousMaxWidth'] = contentEl.style.maxWidth; - contentEl.style.maxWidth = `${availableWidth}px`; - } - const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl); - - const isReplace = ((): boolean => { - if (reference === 'event' || !referenceSizeEl || !['ion-button', 'ion-buttons'].includes(referenceSizeEl.localName)) { - return false; - } - if (referenceSizeEl.matches('.ios-theme-disabled, .ios26-disabled')) { - return false; - } - return true; - })(); - - const defaultPosition = { - top: bodyHeight / 2 - contentHeight / 2, - left: bodyWidth / 2 - contentWidth / 2, - originX: isRTL ? 'right' : 'left', - originY: 'top', - }; - - const results = getPopoverPosition(isRTL, contentWidth, contentHeight, reference, side, align, defaultPosition, trigger, ev); - - // Use the same reference for placement, the callout and the animation origin. - const anchor = reference === 'event' ? results.referenceCoordinates : anchorBounds; - - const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING; - const margin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN; - - const { - originX, - originY, - top, - left: windowLeft, - bottom, - checkSafeAreaLeft, - checkSafeAreaRight, - addPopoverBottomClass, - } = calculateWindowAdjustment( - side, - results.top, - results.left, - padding, - bodyWidth, - bodyHeight, - contentWidth, - contentHeight, - margin, - results.originX, - results.originY, - results.referenceCoordinates, - referenceSizeEl?.getBoundingClientRect(), - isReplace, - ); - // A replacing surface grows inward from the button's edge, not its center. - const preferredLeft = - isReplace && anchorBounds - ? anchorBounds.left + anchorBounds.width / 2 <= (paneLeft + paneRight) / 2 - ? anchorBounds.left - : anchorBounds.right - contentWidth - : windowLeft; - const left = pane ? Math.max(paneLeft + paneMargin, Math.min(paneRight - paneMargin - contentWidth, preferredLeft)) : preferredLeft; - const physicalSide = side === 'start' ? (isRTL ? 'right' : 'left') : side === 'end' ? (isRTL ? 'left' : 'right') : side; - const horizontal = physicalSide === 'left' || physicalSide === 'right'; - const above = addPopoverBottomClass || physicalSide === 'top'; - const hasCallout = !!arrowEl && !isReplace && !!anchor && size !== 'cover' && bottom === undefined; - const surfaceTop = hasCallout && !horizontal ? top + (above ? -5 : 5) : top; - const contentOrigin = anchor - ? `${anchor.left + anchor.width / 2 - left}px ${anchor.top + anchor.height / 2 - surfaceTop}px` - : `${originX} ${originY}`; - - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const contentAnimation = createAnimation(); - const targetAnimation = createAnimation(); - const arrowAnimation = createAnimation(); - const surfaceAnimation = createAnimation(); - if (arrowEl) { - arrowAnimation.addElement(arrowEl).delay(300).duration(200).fromTo('opacity', 0, 1); - } - if (hasCallout && anchor) { - const length = horizontal ? contentHeight : contentWidth; - const inset = Math.min(48, length / 2); - const center = Math.max( - inset, - Math.min(length - inset, horizontal ? anchor.top + anchor.height / 2 - top : anchor.left + anchor.width / 2 - left), - ); - const arrowSide = horizontal ? (physicalSide === 'left' ? 'right' : 'left') : above ? 'bottom' : 'top'; - const layers = createCalloutSurface(root, contentWidth, contentHeight, arrowSide, center); - baseEl.classList.add('ios-theme-callout'); - for (const layer of layers) { - layer.style.left = `calc(${left - 32}px + var(--offset-x, 0))`; - layer.style.top = `calc(${surfaceTop - 32}px + var(--offset-y, 0))`; - const [originLeft, originTop] = contentOrigin.split(' ').map(parseFloat); - layer.style.transformOrigin = `${originLeft + 32}px ${originTop + 32}px`; - } - surfaceAnimation - .addElement(layers) - .delay(100) - .duration(400) - .easing('cubic-bezier(0, 1, 0.22, 1)') - .fromTo('transform', 'scale(0)', 'scale(1)') - .fromTo('opacity', 0.01, 1); - } - - backdropAnimation - .delay(100) - .duration(300) - .addElement(root.querySelector('ion-backdrop')!) - .fromTo('opacity', 0.01, 'var(--backdrop-opacity)') - .beforeStyles({ - 'pointer-events': 'none', - }) - .afterClearStyles(['pointer-events']); - - // In Chromium, if the wrapper animates, the backdrop filter doesn't work. - // The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter. - // To get around this, instead of animating the wrapper, animate content. - // https://bugs.chromium.org/p/chromium/issues/detail?id=1148826 - contentAnimation - .easing('cubic-bezier(0, 1, 0.22, 1)') - .delay(100) - .duration(400) - .addElement(root.querySelector('.popover-content')!) - .beforeStyles({ 'transform-origin': contentOrigin }) - .beforeAddWrite(() => { - /** - * 'transformOrigin' use for leave animation. - */ - root.querySelector('.popover-content')!.dataset['transformOrigin'] = contentOrigin; - }) - .fromTo('transform', 'scale(0)', 'scale(1)') - .fromTo('opacity', 0.01, 1); - - if (isReplace) { - targetAnimation - .delay(0) - .duration(200) - .addElement(referenceSizeEl) - .beforeStyles({ 'transform-origin': `${originY} ${originX}` }) - .beforeAddClass('ios27-replace-element') - .fromTo('transform', 'scale(1)', 'scale(1.05)') - .fromTo('opacity', 1, 0); - } - - return baseAnimation - .easing('ease') - .delay(100) - .duration(100) - .beforeAddWrite(() => { - if (size === 'cover') { - baseEl.style.setProperty('--width', `${contentWidth}px`); - } - - if (addPopoverBottomClass) { - baseEl.classList.add('popover-bottom'); - } - - if (bottom !== undefined) { - contentEl.style.setProperty('bottom', `${bottom}px`); - } - - const safeAreaLeft = ' + var(--ion-safe-area-left, 0)'; - const safeAreaRight = ' - var(--ion-safe-area-right, 0)'; - - let leftValue = `${left}px`; - - if (checkSafeAreaLeft && !pane) { - leftValue = `${left}px${safeAreaLeft}`; - } - if (checkSafeAreaRight && !pane) { - leftValue = `${left}px${safeAreaRight}`; - } - - contentEl.style.setProperty('top', `calc(${surfaceTop}px + var(--offset-y, 0))`); - contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`); - contentEl.style.setProperty('transform-origin', contentOrigin); - - // Morphing buttons replace their anchor; ordinary anchored popovers point to it. - if (arrowEl) { - arrowEl.style.display = 'none'; - if (hasCallout && anchor) { - const inset = Math.min(48, (horizontal ? contentHeight : contentWidth) / 2); - const clamp = (value: number, length: number) => Math.max(inset, Math.min(length - inset, value)); - let arrowLeft: number; - let arrowTop: number; - let rotation: number; - if (horizontal) { - arrowLeft = physicalSide === 'left' ? left + contentWidth - 10 : left - 24; - arrowTop = top + clamp(anchor.top + anchor.height / 2 - top, contentHeight) - 7; - rotation = physicalSide === 'left' ? 90 : -90; - } else { - arrowLeft = left + clamp(anchor.left + anchor.width / 2 - left, contentWidth) - 17; - arrowTop = above ? surfaceTop + contentHeight - 1 : surfaceTop - 13; - rotation = above ? 180 : 0; - } - arrowEl.style.setProperty('display', 'block'); - arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`); - arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`); - arrowEl.style.setProperty('bottom', 'auto'); - arrowEl.style.setProperty('transform', `rotate(${rotation}deg)`); - } - } - }) - .addAnimation([backdropAnimation, contentAnimation, targetAnimation, arrowAnimation, surfaceAnimation]); -}; diff --git a/src/popover/animations/ios.leave.ts b/src/popover/animations/ios.leave.ts deleted file mode 100644 index b0ff4436..00000000 --- a/src/popover/animations/ios.leave.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createAnimation } from '@ionic/core'; -import { getElementRoot } from '../../utils'; - -import type { Animation } from '@ionic/core'; - -/** - * iOS Popover Leave Animation - */ -export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => { - const root = getElementRoot(baseEl); - const contentEl = root.querySelector('.popover-content') as HTMLElement; - const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null; - - const baseAnimation = createAnimation(); - const backdropAnimation = createAnimation(); - const contentAnimation = createAnimation(); - const targetAnimation = createAnimation(); - const surfaceAnimation = createAnimation() - .addElement(Array.from(root.querySelectorAll('.ios-theme-callout-layer'))) - .duration(400) - .easing('ease') - .fromTo('opacity', 0.99, 0) - .fromTo('transform', 'scale(1)', 'scale(0)'); - - const doc = baseEl.ownerDocument as any; - const replaceElement = doc.querySelector('.ios27-replace-element') as HTMLElement | null; - - if (replaceElement) { - const ratio = contentEl.getBoundingClientRect().width / contentEl.getBoundingClientRect().height; - const scale = ratio > 1 ? `${Math.min(1.2, 1.05 * ratio)}, 1.05` : `1.05, ${Math.min(1.2, 1.05 * ratio)}`; - - targetAnimation - .addElement(replaceElement) - .delay(100) - .duration(300) - .afterRemoveClass('ios27-replace-element') - .fromTo('transform', `scale(${scale})`, 'scale(1)') - .fromTo('opacity', 0, 0.9); - } - - backdropAnimation.addElement(root.querySelector('ion-backdrop')!).fromTo('opacity', 'var(--backdrop-opacity)', 0); - - contentAnimation - .duration(400) - .easing('ease') - .addElement(root.querySelector('.popover-arrow')!) - .addElement(root.querySelector('.popover-content')!) - .fromTo('opacity', 0.99, 0); - - const popoverContentDataset = root.querySelector('.popover-content')!.dataset['transformOrigin']; - if (popoverContentDataset) { - contentAnimation.beforeStyles({ 'transform-origin': popoverContentDataset }).afterAddRead(() => { - root.querySelector('.popover-content')!.dataset['transformOrigin'] = ''; - }); - - if (replaceElement) { - contentAnimation.fromTo('transform', 'scale(1)', 'scale(0.55)'); - } else { - contentAnimation.fromTo('transform', 'scale(1)', 'scale(0)'); - } - } - - return baseAnimation - .easing('ease') - .afterAddWrite(() => { - baseEl.style.removeProperty('--width'); - baseEl.classList.remove('popover-bottom'); - baseEl.classList.remove('ios-theme-callout'); - root.querySelectorAll('.ios-theme-callout-layer').forEach((layer) => layer.remove()); - - contentEl.style.removeProperty('top'); - contentEl.style.removeProperty('left'); - contentEl.style.removeProperty('bottom'); - contentEl.style.removeProperty('transform-origin'); - if (contentEl.dataset['previousMaxWidth'] !== undefined) { - contentEl.style.maxWidth = contentEl.dataset['previousMaxWidth']; - delete contentEl.dataset['previousMaxWidth']; - } - - if (arrowEl) { - arrowEl.style.removeProperty('top'); - arrowEl.style.removeProperty('left'); - arrowEl.style.removeProperty('display'); - arrowEl.style.removeProperty('bottom'); - arrowEl.style.removeProperty('transform'); - } - }) - .duration(300) - .addAnimation([backdropAnimation, contentAnimation, targetAnimation, surfaceAnimation]); -}; diff --git a/src/popover/callout-surface.ts b/src/popover/callout-surface.ts deleted file mode 100644 index 619604c4..00000000 --- a/src/popover/callout-surface.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Draw one glass silhouette so the arrow cannot sample the body's own border/shadow. */ -export const createCalloutSurface = ( - root: ShadowRoot | HTMLElement, - width: number, - height: number, - side: 'top' | 'right' | 'bottom' | 'left', - center: number, -): HTMLElement[] => { - const inset = 32; - const x = inset; - const y = inset; - const right = x + width; - const bottom = y + height; - const radius = Math.max(0, Math.min(34, width / 2, height / 2, (side === 'left' || side === 'right' ? height : width) / 2 - 17)); - const point = (u: number, v: number): string => { - switch (side) { - case 'top': - return `${x + center - 17 + u} ${y - 13 + v}`; - case 'right': - return `${right + 13 - v} ${y + center - 17 + u}`; - case 'bottom': - return `${x + center + 17 - u} ${bottom + 13 - v}`; - case 'left': - return `${x - 13 + v} ${y + center + 17 - u}`; - } - }; - const arrow = `L ${point(0, 13)} C ${point(3, 13)} ${point(4.5, 12)} ${point(6.5, 10)} L ${point(14.5, 1.5)} C ${point(16, -0.5)} ${point(18, -0.5)} ${point(19.5, 1.5)} L ${point(27.5, 10)} C ${point(29.5, 12)} ${point(31, 13)} ${point(34, 13)}`; - const path = `M ${x + radius} ${y} ${side === 'top' ? arrow : ''} - L ${right - radius} ${y} A ${radius} ${radius} 0 0 1 ${right} ${y + radius} ${side === 'right' ? arrow : ''} - L ${right} ${bottom - radius} A ${radius} ${radius} 0 0 1 ${right - radius} ${bottom} ${side === 'bottom' ? arrow : ''} - L ${x + radius} ${bottom} A ${radius} ${radius} 0 0 1 ${x} ${bottom - radius} ${side === 'left' ? arrow : ''} - L ${x} ${y + radius} A ${radius} ${radius} 0 0 1 ${x + radius} ${y} Z`; - const svg = (body: string) => - `url("data:image/svg+xml,${encodeURIComponent(`${body}`)}")`; - const fill = svg(``); - const stroke = svg(``); - const shadow = svg( - ``, - ); - const wrapper = root.querySelector('.popover-wrapper')!; - return [ - ['callout-shadow', shadow], - ['callout-glass', fill], - ['callout-border', stroke], - ].map(([part, mask]) => { - const element = document.createElement('div'); - element.setAttribute('part', part); - element.className = 'ios-theme-callout-layer'; - element.setAttribute('aria-hidden', 'true'); - element.style.cssText = `position:absolute;pointer-events:none;z-index:9;width:${width + inset * 2}px;height:${height + inset * 2}px;mask-image:${mask};-webkit-mask-image:${mask};mask-repeat:no-repeat;`; - wrapper.append(element); - return element; - }); -}; diff --git a/src/popover/popover-interface.ts b/src/popover/popover-interface.ts deleted file mode 100644 index b633da3f..00000000 --- a/src/popover/popover-interface.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Mode, OverlayInterface } from '@ionic/core'; - -export interface PopoverInterface extends OverlayInterface { - present: (event?: MouseEvent | TouchEvent | PointerEvent) => Promise; -} - -export interface PopoverOptions { - component: T; - componentProps?: ComponentProps; - showBackdrop?: boolean; - backdropDismiss?: boolean; - translucent?: boolean; - cssClass?: string | string[]; - event?: Event; - delegate?: FrameworkDelegate; - animated?: boolean; - focusTrap?: boolean; - - mode?: Mode; - keyboardClose?: boolean; - id?: string; - htmlAttributes?: { [key: string]: any }; - - enterAnimation?: AnimationBuilder; - leaveAnimation?: AnimationBuilder; - - size?: PopoverSize; - dismissOnSelect?: boolean; - reference?: PositionReference; - side?: PositionSide; - alignment?: PositionAlign; - arrow?: boolean; - - trigger?: string; - triggerAction?: string; -} - -export type PopoverSize = 'cover' | 'auto'; - -export type TriggerAction = 'click' | 'hover' | 'context-menu'; - -export type PositionReference = 'trigger' | 'event'; -export type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end'; -export type PositionAlign = 'start' | 'center' | 'end'; diff --git a/src/popover/utils.ts b/src/popover/utils.ts deleted file mode 100644 index e38fe13b..00000000 --- a/src/popover/utils.ts +++ /dev/null @@ -1,696 +0,0 @@ -import { getElementRoot, raf } from '../utils'; - -import type { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from './popover-interface'; -import { POPOVER_IOS_BODY_MARGIN } from './animations/ios.enter'; - -interface InteractionCallback { - eventName: string; - callback: (ev: any) => void; // TODO(FW-2832): type -} - -export interface ReferenceCoordinates { - top: number; - left: number; - width: number; - height: number; -} - -interface PopoverPosition { - top: number; - left: number; - referenceCoordinates?: ReferenceCoordinates; - originX: string; - originY: string; -} - -export interface PopoverStyles { - top: number; - left: number; - bottom?: number; - originX: string; - originY: string; - checkSafeAreaLeft: boolean; - checkSafeAreaRight: boolean; - addPopoverBottomClass: boolean; -} - -/** - * Returns the recommended dimensions of the popover - * that takes into account whether or not the width - * should match the trigger width. - */ -export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => { - const contentDimentions = contentEl.getBoundingClientRect(); - const contentHeight = contentDimentions.height; - let contentWidth = contentDimentions.width; - - if (size === 'cover' && triggerEl) { - const triggerDimensions = triggerEl.getBoundingClientRect(); - contentWidth = triggerDimensions.width; - } - - return { - contentWidth, - contentHeight, - }; -}; - -export const configureDismissInteraction = ( - triggerEl: HTMLElement, - triggerAction: TriggerAction, - popoverEl: HTMLIonPopoverElement, - parentPopoverEl: HTMLIonPopoverElement, -) => { - let dismissCallbacks: InteractionCallback[] = []; - const root = getElementRoot(parentPopoverEl); - const parentContentEl = root.querySelector('.popover-content') as HTMLElement; - - switch (triggerAction) { - case 'hover': - dismissCallbacks = [ - { - /** - * Do not use mouseover here - * as this will causes the event to - * be dispatched on each underlying - * element rather than on the popover - * content as a whole. - */ - eventName: 'mouseenter', - callback: (ev: MouseEvent) => { - /** - * Do not dismiss the popover is we - * are hovering over its trigger. - * This would be easier if we used mouseover - * but this would cause the event to be dispatched - * more often than we would like, potentially - * causing performance issues. - */ - const element = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null; - if (element === triggerEl) { - return; - } - - popoverEl.dismiss(undefined, undefined, false); - }, - }, - ]; - break; - case 'context-menu': - case 'click': - default: - dismissCallbacks = [ - { - eventName: 'click', - callback: (ev: MouseEvent) => { - /** - * Do not dismiss the popover is we - * are hovering over its trigger. - */ - const target = ev.target as HTMLElement; - const closestTrigger = target.closest('[data-ion-popover-trigger]'); - if (closestTrigger === triggerEl) { - /** - * stopPropagation here so if the - * popover has dismissOnSelect="true" - * the popover does not dismiss since - * we just clicked a trigger element. - */ - ev.stopPropagation(); - return; - } - - popoverEl.dismiss(undefined, undefined, false); - }, - }, - ]; - break; - } - - dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.addEventListener(eventName, callback)); - - return () => { - dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.removeEventListener(eventName, callback)); - }; -}; - -/** - * Configures the triggerEl to respond - * to user interaction based upon the triggerAction - * prop that devs have defined. - */ -export const configureTriggerInteraction = (triggerEl: HTMLElement, triggerAction: TriggerAction, popoverEl: HTMLIonPopoverElement) => { - let triggerCallbacks: InteractionCallback[] = []; - - /** - * Based upon the kind of trigger interaction - * the user wants, we setup the correct event - * listeners. - */ - switch (triggerAction) { - case 'hover': - let hoverTimeout: ReturnType | undefined; - - triggerCallbacks = [ - { - eventName: 'mouseenter', - callback: async (ev: Event) => { - ev.stopPropagation(); - - if (hoverTimeout) { - clearTimeout(hoverTimeout); - } - - /** - * Hovering over a trigger should not - * immediately open the next popover. - */ - hoverTimeout = setTimeout(() => { - raf(() => { - popoverEl.presentFromTrigger(ev); - hoverTimeout = undefined; - }); - }, 100); - }, - }, - { - eventName: 'mouseleave', - callback: (ev: MouseEvent) => { - if (hoverTimeout) { - clearTimeout(hoverTimeout); - } - - /** - * If mouse is over another popover - * that is not this popover then we should - * close this popover. - */ - const target = ev.relatedTarget as HTMLElement | null; - if (!target) { - return; - } - - if (target.closest('ion-popover') !== popoverEl) { - popoverEl.dismiss(undefined, undefined, false); - } - }, - }, - { - /** - * stopPropagation here prevents the popover - * from dismissing when dismiss-on-select="true". - */ - eventName: 'click', - callback: (ev: Event) => ev.stopPropagation(), - }, - { - eventName: 'ionPopoverActivateTrigger', - callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true), - }, - ]; - - break; - case 'context-menu': - triggerCallbacks = [ - { - eventName: 'contextmenu', - callback: (ev: Event) => { - /** - * Prevents the platform context - * menu from appearing. - */ - ev.preventDefault(); - popoverEl.presentFromTrigger(ev); - }, - }, - { - eventName: 'click', - callback: (ev: Event) => ev.stopPropagation(), - }, - { - eventName: 'ionPopoverActivateTrigger', - callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true), - }, - ]; - - break; - case 'click': - default: - triggerCallbacks = [ - { - /** - * Do not do a stopPropagation() here - * because if you had two click triggers - * then clicking the first trigger and then - * clicking the second trigger would not cause - * the first popover to dismiss. - */ - eventName: 'click', - callback: (ev: Event) => popoverEl.presentFromTrigger(ev), - }, - { - eventName: 'ionPopoverActivateTrigger', - callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true), - }, - ]; - break; - } - - triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.addEventListener(eventName, callback)); - triggerEl.setAttribute('data-ion-popover-trigger', 'true'); - - return () => { - triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.removeEventListener(eventName, callback)); - triggerEl.removeAttribute('data-ion-popover-trigger'); - }; -}; - -/** - * Returns the index of an ion-item in an array of ion-items. - */ -export const getIndexOfItem = (items: HTMLIonItemElement[], item: HTMLElement | null) => { - if (!item || item.tagName !== 'ION-ITEM') { - return -1; - } - - return items.findIndex((el) => el === item); -}; - -/** - * Given an array of elements and a currently focused ion-item - * returns the next ion-item relative to the focused one or - * undefined. - */ -export const getNextItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => { - const currentItemIndex = getIndexOfItem(items, currentItem); - return items[currentItemIndex + 1]; -}; - -/** - * Given an array of elements and a currently focused ion-item - * returns the previous ion-item relative to the focused one or - * undefined. - */ -export const getPrevItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => { - const currentItemIndex = getIndexOfItem(items, currentItem); - return items[currentItemIndex - 1]; -}; - -/** Focus the internal button of the ion-item */ -const focusItem = (item: HTMLIonItemElement) => { - const root = getElementRoot(item); - const button = root.querySelector('button'); - - if (button) { - raf(() => button.focus()); - } -}; - -/** - * Positions a popover by taking into account - * the reference point, preferred side, alignment - * and viewport dimensions. - */ -export const getPopoverPosition = ( - isRTL: boolean, - contentWidth: number, - contentHeight: number, - reference: PositionReference, - side: PositionSide, - align: PositionAlign, - defaultPosition: PopoverPosition, - triggerEl?: HTMLElement, - event?: MouseEvent | CustomEvent, -): PopoverPosition => { - let referenceCoordinates = { - top: 0, - left: 0, - width: 0, - height: 0, - }; - - /** - * Calculate position relative to the - * x-y coordinates in the event that - * was passed in - */ - switch (reference) { - case 'event': - if (!event) { - return defaultPosition; - } - - const mouseEv = event as MouseEvent; - - referenceCoordinates = { - top: mouseEv.clientY, - left: mouseEv.clientX, - width: 1, - height: 1, - }; - - break; - - /** - * Calculate position relative to the bounding - * box on either the trigger element - * specified via the `trigger` prop or - * the target specified on the event - * that was passed in. - */ - case 'trigger': - default: - const customEv = event as CustomEvent; - - /** - * ionShadowTarget is used when we need to align the - * popover with an element inside of the shadow root - * of an Ionic component. Ex: Presenting a popover - * by clicking on the collapsed indicator inside - * of `ion-breadcrumb` and centering it relative - * to the indicator rather than `ion-breadcrumb` - * as a whole. - */ - const actualTriggerEl = (triggerEl || customEv?.detail?.ionShadowTarget || customEv?.target) as HTMLElement | null; - if (!actualTriggerEl) { - return defaultPosition; - } - const triggerBoundingBox = actualTriggerEl.getBoundingClientRect(); - referenceCoordinates = { - top: triggerBoundingBox.top, - left: triggerBoundingBox.left, - width: triggerBoundingBox.width, - height: triggerBoundingBox.height, - }; - - break; - } - - /** - * Get top/left offset that would allow - * popover to be positioned on the - * preferred side of the reference. - */ - const coordinates = calculatePopoverSide(side, referenceCoordinates, contentWidth, contentHeight, isRTL); - - /** - * Get the top/left adjustments that - * would allow the popover content - * to have the correct alignment. - */ - const alignedCoordinates = calculatePopoverAlign(align, side, referenceCoordinates, contentWidth, contentHeight); - - const top = coordinates.top + alignedCoordinates.top; - const left = coordinates.left + alignedCoordinates.left; - - const { originX, originY } = calculatePopoverOrigin(side, align, isRTL); - - return { top, left, referenceCoordinates, originX, originY }; -}; - -/** - * Determines the transform-origin - * of the popover animation so that it - * is in line with what the side and alignment - * prop values are. Currently only used - * with the MD animation. - */ -const calculatePopoverOrigin = (side: PositionSide, align: PositionAlign, isRTL: boolean) => { - switch (side) { - case 'top': - return { originX: getOriginXAlignment(align), originY: 'bottom' }; - case 'bottom': - return { originX: getOriginXAlignment(align), originY: 'top' }; - case 'left': - return { originX: 'right', originY: getOriginYAlignment(align) }; - case 'right': - return { originX: 'left', originY: getOriginYAlignment(align) }; - case 'start': - return { originX: isRTL ? 'left' : 'right', originY: getOriginYAlignment(align) }; - case 'end': - return { originX: isRTL ? 'right' : 'left', originY: getOriginYAlignment(align) }; - } -}; - -const getOriginXAlignment = (align: PositionAlign) => { - switch (align) { - case 'start': - return 'left'; - case 'center': - return 'center'; - case 'end': - return 'right'; - } -}; - -const getOriginYAlignment = (align: PositionAlign) => { - switch (align) { - case 'start': - return 'top'; - case 'center': - return 'center'; - case 'end': - return 'bottom'; - } -}; - -/** - * Calculates the required top/left - * values needed to position the popover - * content on the side specified in the - * `side` prop. - */ -const calculatePopoverSide = ( - side: PositionSide, - triggerBoundingBox: ReferenceCoordinates, - contentWidth: number, - contentHeight: number, - isRTL: boolean, -) => { - const sideLeft = { - top: triggerBoundingBox.top, - left: triggerBoundingBox.left - contentWidth, - }; - const sideRight = { - top: triggerBoundingBox.top, - left: triggerBoundingBox.left + triggerBoundingBox.width, - }; - - switch (side) { - case 'top': - return { - top: triggerBoundingBox.top - contentHeight, - left: triggerBoundingBox.left, - }; - case 'right': - return sideRight; - case 'bottom': - return { - top: triggerBoundingBox.top + triggerBoundingBox.height, - left: triggerBoundingBox.left, - }; - case 'left': - return sideLeft; - case 'start': - return isRTL ? sideRight : sideLeft; - case 'end': - return isRTL ? sideLeft : sideRight; - } -}; - -/** - * Calculates the required top/left - * offset values needed to provide the - * correct alignment regardless while taking - * into account the side the popover is on. - */ -const calculatePopoverAlign = ( - align: PositionAlign, - side: PositionSide, - triggerBoundingBox: ReferenceCoordinates, - contentWidth: number, - contentHeight: number, -) => { - switch (align) { - case 'center': - return calculatePopoverCenterAlign(side, triggerBoundingBox, contentWidth, contentHeight); - case 'end': - return calculatePopoverEndAlign(side, triggerBoundingBox, contentWidth, contentHeight); - case 'start': - default: - return { top: 0, left: 0 }; - } -}; - -/** - * Calculate the end alignment for - * the popover. If side is on the x-axis - * then the align values refer to the top - * and bottom margins of the content. - * If side is on the y-axis then the - * align values refer to the left and right - * margins of the content. - */ -const calculatePopoverEndAlign = ( - side: PositionSide, - triggerBoundingBox: ReferenceCoordinates, - contentWidth: number, - contentHeight: number, -) => { - switch (side) { - case 'start': - case 'end': - case 'left': - case 'right': - return { - top: -(contentHeight - triggerBoundingBox.height), - left: 0, - }; - case 'top': - case 'bottom': - default: - return { - top: 0, - left: -(contentWidth - triggerBoundingBox.width), - }; - } -}; - -/** - * Calculate the center alignment for - * the popover. If side is on the x-axis - * then the align values refer to the top - * and bottom margins of the content. - * If side is on the y-axis then the - * align values refer to the left and right - * margins of the content. - */ -const calculatePopoverCenterAlign = ( - side: PositionSide, - triggerBoundingBox: ReferenceCoordinates, - contentWidth: number, - contentHeight: number, -) => { - switch (side) { - case 'start': - case 'end': - case 'left': - case 'right': - return { - top: -(contentHeight / 2 - triggerBoundingBox.height / 2), - left: 0, - }; - case 'top': - case 'bottom': - default: - return { - top: 0, - left: -(contentWidth / 2 - triggerBoundingBox.width / 2), - }; - } -}; - -/** - * Adjusts popover positioning coordinates - * such that popover does not appear offscreen - * or overlapping safe area bounds. - */ -export const calculateWindowAdjustment = ( - side: PositionSide, - coordTop: number, - coordLeft: number, - bodyPadding: number, - bodyWidth: number, - bodyHeight: number, - contentWidth: number, - contentHeight: number, - safeAreaMargin: number, - contentOriginX: string, - contentOriginY: string, - triggerCoordinates?: ReferenceCoordinates, - eventElementRect?: DOMRect, - isReplace: boolean = false, -): PopoverStyles => { - const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2; - const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0; - let left = coordLeft; - let top = !isReplace ? coordTop + POPOVER_IOS_BODY_MARGIN : coordTop - triggerHeight; - let bottom; - let originX = contentOriginX; - let originY = contentOriginY; - let checkSafeAreaLeft = false; - let checkSafeAreaRight = false; - let addPopoverBottomClass = false; - - /** - * Adjust popover so it does not - * go off the left of the screen. - */ - if (left < bodyPadding + safeAreaMargin) { - left = !eventElementRect ? bodyPadding : eventElementRect.left; - if (left === 0) { - left = safeAreaMargin; - } - checkSafeAreaLeft = true; - originX = 'left'; - /** - * Adjust popover so it does not - * go off the right of the screen. - */ - } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) { - checkSafeAreaRight = true; - left = !eventElementRect ? bodyWidth - contentWidth - bodyPadding : eventElementRect.right - contentWidth; - if (left + contentWidth === bodyWidth) { - left = left - safeAreaMargin; - } - originX = 'right'; - } - - /** - * Adjust popover so it does not - * go off the top of the screen. - * If popover is on the left or the right of - * the trigger, then we should not adjust top - * margins. - */ - const compareTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height / 2 : bodyHeight / 2 - contentHeight / 2; - if (compareTop > bodyHeight / 2 && (side === 'top' || side === 'bottom')) { - if (triggerTop - contentHeight > 0) { - /** - * While we strive to align the popover with the trigger - * on smaller screens this is not always possible. As a result, - * we adjust the popover up so that it does not hang - * off the bottom of the screen. However, we do not want to move - * the popover up so much that it goes off the top of the screen. - * - * We chose 12 here so that the popover position looks a bit nicer as - * it is not right up against the edge of the screen. - */ - if (!isReplace) { - top = Math.max(12, triggerTop - contentHeight - triggerHeight) - POPOVER_IOS_BODY_MARGIN; - } else { - top = Math.max(12, triggerTop - contentHeight); - } - originY = 'bottom'; - addPopoverBottomClass = true; - - /** - * If not enough room for popover to appear - * above trigger, then cut it off. - */ - } else { - bottom = bodyPadding; - } - } - - return { - top, - left, - bottom, - originX, - originY, - checkSafeAreaLeft, - checkSafeAreaRight, - addPopoverBottomClass, - }; -}; diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss index e20aac09..2b6117bd 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -1,4 +1,4 @@ -@use '../utils/structured-list'; +@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list'; ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) { --ion-border-color: var(--ion-color-step-100, var(--ion-background-color-step-100, #e8e8e8)); @@ -11,7 +11,7 @@ ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) { } ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) { - @include structured-list.layout(20px, 28px); + @include structured-list.layout(20px, 28px, $variant: ios27); ion-radio-group, ion-reorder-group { diff --git a/src/styles/md-ion-list-inset.scss b/src/styles/md-ion-list-inset.scss index 371a4474..05faa68e 100644 --- a/src/styles/md-ion-list-inset.scss +++ b/src/styles/md-ion-list-inset.scss @@ -1,10 +1,10 @@ -@use 'utils/structured-list'; +@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list'; ion-list.md { @include structured-list.support-text; &.list-inset { - @include structured-list.layout(8px, 16px); + @include structured-list.layout(8px, 16px, $variant: ios27); :is(#{structured-list.$groups}) ion-item { & > ion-input[labelplacement='floating'] { transition: transform 200ms ease; diff --git a/src/styles/utils/dark/ion-list.scss b/src/styles/utils/dark/ion-list.scss index d21e12d4..8a88cf50 100644 --- a/src/styles/utils/dark/ion-list.scss +++ b/src/styles/utils/dark/ion-list.scss @@ -1,4 +1,4 @@ -@use '../structured-list'; +@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list'; @mixin ion-list { ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) { diff --git a/src/styles/utils/structured-list.scss b/src/styles/utils/structured-list.scss deleted file mode 100644 index 09bdeaf7..00000000 --- a/src/styles/utils/structured-list.scss +++ /dev/null @@ -1,223 +0,0 @@ -@use './group-heading'; - -$groups: ion-item-group, ion-reorder-group, ion-accordion-group, ion-radio-group; - -// Derive neutral list colors from the application's Ionic medium palette. -@mixin colors($supporting-opacity, $detail-opacity) { - ion-item { - --detail-icon-color: rgb(var(--ion-color-medium-rgb)); - --detail-icon-opacity: #{$detail-opacity}; - } - - &.list-inset { - ion-list-header ion-label:not(.ion-color) { - color: rgba(var(--ion-color-medium-rgb), $supporting-opacity); - } - - ion-list-header ion-note:not(.ion-color), - > ion-note:not(.ion-color) { - --color: rgba(var(--ion-color-medium-rgb), #{$supporting-opacity}); - } - } -} - -@mixin support-text() { - ion-item { - &:has(ion-input.input-fill-outline), - &:has(ion-textarea.textarea-fill-outline), - &:has(ion-select:is([helpertext]:not([helpertext='']), [errortext]:not([errortext='']))), - &:has(ion-input .input-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))), - &:has(ion-textarea .textarea-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))) { - --inner-border-width: 0; - --inner-padding-bottom: 4px; - } - - ion-input .input-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))), - ion-textarea .textarea-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))) { - display: none; - } - } -} - -@mixin layout($outer-note-inset, $group-border-radius) { - background: transparent; - - ion-list-header { - display: flex; - align-items: center; - - ion-label { - font-size: 1rem; - line-height: 1.29rem; - font-weight: 600; - margin: 8px 4px 4px; - - &:not(.ion-color) { - color: var(--ion-color-medium-tint); - } - } - - ion-button { - margin-top: 8px; - } - - ion-note { - font-size: 0.76rem; - line-height: 1.06rem; - font-weight: 400; - margin: 8px 16px 4px 0; - - &:not(.ion-color) { - --color: var(--ion-color-medium-tint); - } - } - } - - ion-item-group.item-group-header { - border-radius: $group-border-radius; - - > ion-item { - --inner-padding-end: 0; - --inner-border-width: 0; - - &::part(native) { - padding-inline: 0; - } - } - - & > ion-item > ion-label { - width: 100%; - margin: 0; - text-align: start; - white-space: normal; - padding: 16px 20px; - - /** - * ion-header.header-collapse-condenseを使って、スクロールによってHeaderを変化させるトリック - */ - ion-header.header-collapse-condense > ion-toolbar { - --min-height: 0; - visibility: hidden; - } - - ion-icon { - font-size: 2.82rem; - border-radius: 20%; - padding: 6px; - vertical-align: top; - - &:not(.ion-color) { - color: #ffffff; - } - } - - h2 { - @include group-heading.typography; - margin: 20px 0 8px; - } - - ion-text { - display: block; - font-size: 1rem; - line-height: 1.29rem; - - &:not(.ion-color) { - color: var(--ion-color-step-500, var(--ion-text-color-step-500, #808080)); - } - } - } - } - - > :is(#{$groups}) { - display: block; - border-radius: $group-border-radius; - overflow: hidden; - - &:has(> .radio-group-top) { - display: flex; - flex-direction: column; - border-radius: 0; - overflow: visible; - - > .radio-group-top { - order: 1; - font-size: 0.9rem; - padding-block-start: 8px; - } - - > ion-item:first-of-type { - --border-radius: #{$group-border-radius} #{$group-border-radius} 0 0; - } - - > ion-item:last-of-type { - --border-radius: 0 0 #{$group-border-radius} #{$group-border-radius}; - } - } - - &:not(:first-of-type) { - margin-top: 16px; - } - - ion-item { - &:has(> ion-label:not([slot])):has(> ion-note:not([slot])) { - &::part(container) { - flex-direction: column; - justify-content: center; - padding-top: 14px; - padding-bottom: 14px; - min-height: 64px; - } - & > ion-label, - & > ion-note { - width: auto; - align-self: flex-start; - } - & > ion-label { - margin: 0; - font-weight: 500; - font-size: 1rem; - line-height: 1.29rem; - } - & > ion-note { - font-size: 0.88rem; - line-height: 1.18rem; - } - } - - ion-text[slot='end'] { - padding-left: 8px; - } - - &.item-disabled { - --detail-icon-opacity: 0.1; - opacity: 1; - - & > * { - opacity: 0.4; - } - } - - ion-button[slot='end'] { - &.ion-align-self-end { - transform: translateY(-7px); - } - ion-icon[slot='icon-only'] { - font-size: 1.2rem; - transform: translateY(4px); - } - } - } - } - - & > ion-note { - font-size: 0.76rem; - line-height: 1.06rem; - font-weight: 400; - display: block; - margin: 8px calc(var(--ion-safe-area-right, 0) + $outer-note-inset) 8px calc(var(--ion-safe-area-left, 0) + $outer-note-inset); - - &:not(.ion-color) { - --color: var(--ion-color-medium-tint); - } - } -} diff --git a/src/tab-bar-searchable/animations/enter.ts b/src/tab-bar-searchable/animations/enter.ts index 9324afdd..0fedd804 100644 --- a/src/tab-bar-searchable/animations/enter.ts +++ b/src/tab-bar-searchable/animations/enter.ts @@ -1,7 +1,6 @@ -import { ElementReferences, ElementSizes } from '../interfaces'; import { Animation, createAnimation } from '@ionic/core'; +import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils'; import { cloneElement } from '../../utils'; -import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils'; export const createEffectAnimation = (references: ElementReferences, sizes: ElementSizes): Animation => { const effectElement = cloneElement('ion-icon'); diff --git a/src/tab-bar-searchable/animations/leave.ts b/src/tab-bar-searchable/animations/leave.ts index 41050a6d..bdfa20f2 100644 --- a/src/tab-bar-searchable/animations/leave.ts +++ b/src/tab-bar-searchable/animations/leave.ts @@ -1,7 +1,6 @@ -import { ElementReferences, ElementSizes } from '../interfaces'; import { Animation, createAnimation } from '@ionic/core'; +import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils'; import { cloneElement } from '../../utils'; -import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils'; export const createReverseEffectAnimation = ( references: ElementReferences, diff --git a/src/tab-bar-searchable/index.ts b/src/tab-bar-searchable/index.ts index 0456d478..fa791fc7 100644 --- a/src/tab-bar-searchable/index.ts +++ b/src/tab-bar-searchable/index.ts @@ -1,5 +1,4 @@ import { createAnimation } from '@ionic/core'; -import { isNativeUIShell, registerNativeSearch, requestNativeSearch, suspendNativeUIShell } from '../native-integration'; import { ANIMATION_DELAY_BASE, ANIMATION_DURATION, @@ -7,8 +6,11 @@ import { getElementReferences, getElementSizes, throwErrorByFailedClickElement, -} from './utils'; -import { SearchableEventCache, TabBarSearchableFunction, TabBarSearchableType } from './interfaces'; + type SearchableEventCache, + type TabBarSearchableFunction, + TabBarSearchableType, +} from '@rdlabo/ionic-theme-utils'; +import { isNativeUIShell, registerNativeSearch, requestNativeSearch, suspendNativeUIShell } from '../native-integration'; import { createCloseButtonsAnimation, createEffectAnimation, @@ -24,7 +26,13 @@ import { createReverseTabBarAnimation, } from './animations/leave'; -export * from './interfaces'; +export { + TabBarSearchableType, + type TabBarSearchableFunction, + type SearchableEventCache, + type ElementSizes, + type ElementReferences, +} from '@rdlabo/ionic-theme-utils'; /** * diff --git a/src/tab-bar-searchable/interfaces.ts b/src/tab-bar-searchable/interfaces.ts deleted file mode 100644 index effaf012..00000000 --- a/src/tab-bar-searchable/interfaces.ts +++ /dev/null @@ -1,28 +0,0 @@ -export enum TabBarSearchableType { - Enter = 'enter', - Leave = 'leave', -} - -export type TabBarSearchableFunction = (event: Event, type: TabBarSearchableType) => Promise; - -export interface SearchableEventCache { - elementSizes: ElementSizes; - colorSelected: string; -} - -// DOM要素とサイズ情報の型定義 -export interface ElementSizes { - tabBar: { width: number; height: number }; - closeButton: { width: number; height: number }; - fabButton: { width: number; height: number }; - searchContainer: { width: number; height: number }; - selectedTabButtonIcon: { width: number; height: number; top: number; left: number }; -} - -export interface ElementReferences { - searchContainer: HTMLElement; - closeButtons: HTMLElement; - selectedTabButton: HTMLElement; - selectedTabButtonIcon: HTMLElement; - closeButtonIcon: HTMLElement; -} diff --git a/src/tab-bar-searchable/utils.ts b/src/tab-bar-searchable/utils.ts deleted file mode 100644 index e4f53631..00000000 --- a/src/tab-bar-searchable/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ElementReferences, ElementSizes } from './interfaces'; - -export const ANIMATION_DURATION = 400; -export const ANIMATION_DELAY_BASE = 140; -export const ANIMATION_DELAY_CLOSE_BUTTONS = 240; -export const ANIMATION_EASING = 'cubic-bezier(0, 1, 0.22, 1)'; -export const OPACITY_TRANSITION = 'opacity 140ms ease'; - -export const throwErrorByFailedClickElement = (selector: string): Error => { - return new Error('Expected click element to be inside `' + selector + '`'); -}; - -export const throwErrorByFailedExistElement = (selector: string): Error => { - return new Error('Expected element `' + selector + '` to exist'); -}; - -export const getElement = (docs: HTMLElement, selector: string): HTMLElement => { - const el = docs.querySelector(selector); - if (!el) { - throw throwErrorByFailedClickElement(selector); - } - return el; -}; - -export const getElementReferences = (ionTabBar: HTMLElement, ionFooter: HTMLElement): ElementReferences => { - const searchContainer = getElement(ionFooter, 'ion-searchbar .searchbar-input-container'); - const closeButtons = getElement(ionFooter, 'ion-buttons[slot=start]'); - const selectedTabButton = ionTabBar.querySelector('ion-tab-button.tab-selected'); - const selectedTabButtonIcon = selectedTabButton?.querySelector('ion-icon'); - const closeButtonIcon = closeButtons.querySelector('ion-icon'); - - if (!selectedTabButton) { - throw throwErrorByFailedExistElement('ion-tab-button.tab-selected'); - } - - if (!selectedTabButtonIcon) { - throw throwErrorByFailedExistElement('ion-tab-button.tab-selected ion-icon'); - } - - if (!closeButtonIcon) { - throw throwErrorByFailedExistElement('ion-buttons[slot=start] ion-button ion-icon'); - } - - return { - searchContainer, - closeButtons, - selectedTabButton, - selectedTabButtonIcon, - closeButtonIcon, - }; -}; - -/** - * 各要素のサイズ情報を取得 - */ -export const getElementSizes = (ionTabBar: HTMLElement, ionFabButton: HTMLElement, references: ElementReferences): ElementSizes => { - const tabBarRect = ionTabBar.getBoundingClientRect(); - const fabButtonRect = ionFabButton.getBoundingClientRect(); - const closeButtonRect = references.closeButtons.getBoundingClientRect(); - const searchContainerRect = references.searchContainer.getBoundingClientRect(); - const selectedTabButtonIconRect = references.selectedTabButtonIcon!.getBoundingClientRect(); - - return { - tabBar: { width: tabBarRect.width, height: tabBarRect.height }, - closeButton: { width: closeButtonRect.width, height: closeButtonRect.height }, - fabButton: { width: fabButtonRect.width, height: fabButtonRect.height }, - searchContainer: { width: searchContainerRect.width, height: searchContainerRect.height }, - selectedTabButtonIcon: { - width: selectedTabButtonIconRect.width, - height: selectedTabButtonIconRect.height, - top: selectedTabButtonIconRect.top, - left: selectedTabButtonIconRect.left, - }, - }; -}; diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts index 6ccc450b..14bd7853 100644 --- a/src/transition/ios.transition.ts +++ b/src/transition/ios.transition.ts @@ -1,534 +1,14 @@ +import { createIosTransitionAnimation, shadow } from '@rdlabo/ionic-theme-utils'; import type { Animation } from '@ionic/core'; -import { createAnimation } from '@ionic/core'; import type { TransitionOptions } from './index'; import { getIonPageElement } from './index'; import { connectNativeUIShellTransition } from '../native-integration'; -const DURATION = 540; +export { shadow }; -const getClonedElement = (tagName: string) => { - return document.querySelector(`${tagName}.ion-cloned-element`); -}; - -export const shadow = (el: T): ShadowRoot | T => { - return el.shadowRoot || el; -}; - -const animateFixedBackButton = ( - root: Animation, - navEl: HTMLElement, - page: HTMLElement, - entering: boolean, - interactive: boolean, - otherPage?: HTMLElement, -) => { - const button = page.querySelector( - ':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled) ion-back-button:not(.ios-theme-disabled, .ios26-disabled)', - ); - if (!button || button.offsetWidth === 0) { - return; - } - - const otherButton = otherPage?.querySelector( - ':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled) ion-back-button:not(.ios-theme-disabled, .ios26-disabled)', - ); - const persistent = !!otherButton && otherButton.offsetWidth > 0; - const buttons = [button, ...(persistent ? [otherButton!] : [])].map((element) => ({ - element, - visibility: element.style.visibility, - })); - const rect = button.getBoundingClientRect(); - const width = button.offsetWidth; - const height = button.offsetHeight; - const clone = getClonedElement('ion-back-button'); - if (!clone) { - return; - } - const cloneParent = clone.parentNode!; - const cloneNextSibling = clone.nextSibling; - const cloneStyle = clone.getAttribute('style'); - clone.icon = button.icon; - clone.text = button.text; - clone.mode = button.mode; - clone.color = button.color; - clone.disabled = button.disabled; - const icon = shadow(clone).querySelector('ion-icon'); - const animation = createAnimation().addElement(clone); - const fadeStart = !entering && interactive ? 0.8 : 0.4; - const fadeEnd = entering ? 0.96 : interactive ? 1 : 0.95; - if (persistent) { - animation.fromTo('transform', 'scale(1)', 'scale(1)').fromTo('opacity', 1, 1); - } else if (entering) { - animation.keyframes([ - { offset: 0, transform: 'scale(1.2)', opacity: 0 }, - { offset: fadeStart, transform: 'scale(1.2)', opacity: 0 }, - { offset: 0.65, transform: 'scale(1.12)', opacity: 0.15 }, - { offset: 0.9, transform: 'scale(1.02)', opacity: 0.75 }, - { offset: fadeEnd, transform: 'scale(1)', opacity: 1 }, - { offset: 1, transform: 'scale(1)', opacity: 1 }, - ]); - } else { - animation.keyframes([ - { offset: 0, transform: 'scale(1)', opacity: 1 }, - { offset: fadeStart, transform: 'scale(1)', opacity: 1 }, - { offset: fadeEnd, transform: 'scale(1.2)', opacity: 0 }, - { offset: 1, transform: 'scale(1.2)', opacity: 0 }, - ]); - } - if (icon && !persistent) { - const from = entering ? 'blur(4px)' : 'blur(0px)'; - const to = entering ? 'blur(0px)' : 'blur(4px)'; - animation.addAnimation( - createAnimation() - .addElement(icon) - .keyframes([ - { offset: 0, filter: from }, - { offset: fadeStart, filter: from }, - { offset: fadeEnd, filter: to }, - { offset: 1, filter: to }, - ]), - ); - } - root.beforeAddWrite(() => { - Object.assign(clone.style, { - position: 'fixed', - left: `${rect.left + (rect.width - width) / 2}px`, - top: `${rect.top + (rect.height - height) / 2}px`, - width: `${width}px`, - height: `${height}px`, - margin: '0', - pointerEvents: 'none', - visibility: 'visible', - display: getComputedStyle(button).display, - zIndex: '1000', - }); - navEl.appendChild(clone); - buttons.forEach(({ element }) => (element.style.visibility = 'hidden')); +export const iosTransitionAnimation: (navEl: HTMLElement, opts: TransitionOptions) => Animation = + createIosTransitionAnimation({ + offLeftPercent: 30, + getIonPageElement, + connectNativeUIShellTransition, }); - root.afterAddWrite(() => { - buttons.forEach(({ element, visibility }) => (element.style.visibility = visibility)); - cloneParent.insertBefore(clone, cloneNextSibling); - if (cloneStyle === null) { - clone.removeAttribute('style'); - } else { - clone.setAttribute('style', cloneStyle); - } - }); - root.addAnimation(animation); -}; - -export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptions): Animation => { - try { - const EASING = 'cubic-bezier(0.32,0.72,0,1)'; - const OPACITY = 'opacity'; - const TRANSFORM = 'transform'; - const CENTER = '0%'; - const OFF_OPACITY = 0.8; - - const isRTL = navEl.ownerDocument.dir === 'rtl'; - const OFF_RIGHT = isRTL ? '-99.5%' : '99.5%'; - const OFF_LEFT = isRTL ? '33%' : '-33%'; - - const enteringEl = opts.enteringEl; - const leavingEl = opts.leavingEl; - - const backDirection = opts.direction === 'back'; - const contentEl = enteringEl.querySelector(':scope > ion-content'); - const headerEls = enteringEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); - const enteringToolBarEls = enteringEl.querySelectorAll(':scope > ion-header > ion-toolbar'); - - const rootAnimation = createAnimation(); - const enteringContentAnimation = createAnimation(); - - rootAnimation - .addElement(enteringEl) - .duration((opts.duration ?? 0) || DURATION) - .easing(opts.easing || EASING) - .fill('both') - .beforeRemoveClass('ion-page-invisible'); - - const topPage = backDirection ? leavingEl : enteringEl; - const translucentTop = topPage?.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)'); - if (topPage && translucentTop) { - // UIKit dims the opaque page underneath, including its header, with 10% black. - const shade = navEl.ownerDocument.createElement('div'); - shade.className = 'ios27-transition-shade'; - shade.setAttribute('aria-hidden', 'true'); - const edge = navEl.ownerDocument.createElement('div'); - edge.setAttribute('aria-hidden', 'true'); - const clipPath = topPage.style.clipPath; - // UIKit's continuous page outline is smaller in tablet-sized panes. - const [corner, control] = topPage.offsetWidth >= 768 ? [36, 13] : [78, 22]; - Object.assign(shade.style, { - position: 'absolute', - inset: '0', - zIndex: topPage.style.zIndex, - pointerEvents: 'none', - background: 'rgba(0, 0, 0, 0.1)', - }); - Object.assign(edge.style, { - position: 'absolute', - top: '0', - bottom: '0', - [isRTL ? 'right' : 'left']: '-100%', - width: '100%', - zIndex: topPage.style.zIndex, - pointerEvents: 'none', - background: `linear-gradient(to ${isRTL ? 'right' : 'left'}, rgba(0, 0, 0, 0.02), transparent 18px)`, - }); - rootAnimation.beforeAddWrite(() => { - topPage.before(shade, edge); - topPage.style.clipPath = `inset(0 round ${corner * 0.82}px)`; - topPage.style.clipPath = `shape(from 0px ${corner}px, - curve to ${corner}px 0px with 0px ${control}px / ${control}px 0px, hline to calc(100% - ${corner}px), - curve to 100% ${corner}px with calc(100% - ${control}px) 0px / 100% ${control}px, vline to calc(100% - ${corner}px), - curve to calc(100% - ${corner}px) 100% with 100% calc(100% - ${control}px) / calc(100% - ${control}px) 100%, hline to ${corner}px, - curve to 0px calc(100% - ${corner}px) with ${control}px 100% / 0px calc(100% - ${control}px), close)`; - }); - rootAnimation.afterAddWrite(() => { - shade.remove(); - edge.remove(); - topPage.style.clipPath = clipPath; - }); - rootAnimation.addAnimation( - createAnimation() - .addElement(shade) - .fromTo(OPACITY, backDirection ? 1 : 0, backDirection ? 0 : 1), - ); - rootAnimation.addAnimation( - createAnimation() - .addElement(edge) - .fromTo( - TRANSFORM, - `translateX(${backDirection ? CENTER : OFF_RIGHT})`, - `translateX(${backDirection ? (isRTL ? '-100%' : '100%') : CENTER})`, - ), - ); - } - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (leavingEl && navEl !== null && navEl !== undefined) { - const navDecorAnimation = createAnimation(); - navDecorAnimation.addElement(navEl); - rootAnimation.addAnimation(navDecorAnimation); - } - - if (enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) { - enteringContentAnimation.addElement(enteringEl); - } else if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) { - enteringContentAnimation.addElement(enteringEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW - } else { - enteringContentAnimation.addElement(contentEl!); // REVIEW - enteringContentAnimation.addElement(headerEls); - } - - rootAnimation.addAnimation(enteringContentAnimation); - - if (backDirection) { - enteringContentAnimation - .beforeClearStyles([OPACITY]) - .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`) - .fromTo(OPACITY, translucentTop ? 1 : OFF_OPACITY, 1); - } else { - // entering content, forward direction - enteringContentAnimation.beforeClearStyles([OPACITY]).fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); - } - - if (contentEl && !enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) { - const enteringTransitionEffectEl = shadow(contentEl).querySelector('.transition-effect'); - if (enteringTransitionEffectEl) { - const enteringTransitionCoverEl = enteringTransitionEffectEl.querySelector('.transition-cover'); - const enteringTransitionShadowEl = enteringTransitionEffectEl.querySelector('.transition-shadow'); - - const enteringTransitionEffect = createAnimation(); - const enteringTransitionCover = createAnimation(); - const enteringTransitionShadow = createAnimation(); - - enteringTransitionEffect - .addElement(enteringTransitionEffectEl) - .beforeStyles({ opacity: '1', display: 'block' }) - .afterStyles({ opacity: '', display: '' }); - - enteringTransitionCover - .addElement(enteringTransitionCoverEl!) // REVIEW - .beforeClearStyles([OPACITY]) - .fromTo(OPACITY, 0, 0.1); - - enteringTransitionShadow - .addElement(enteringTransitionShadowEl!) // REVIEW - .beforeClearStyles([OPACITY]) - .fromTo(OPACITY, 0.03, 0.7); - - enteringTransitionEffect.addAnimation([enteringTransitionCover, enteringTransitionShadow]); - enteringContentAnimation.addAnimation([enteringTransitionEffect]); - } - } - - if (topPage) { - animateFixedBackButton( - rootAnimation, - navEl, - topPage, - !backDirection, - opts.progressCallback !== undefined, - backDirection ? enteringEl : leavingEl, - ); - } - - const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-condense'); - - enteringToolBarEls.forEach((enteringToolBarEl) => { - if (enteringToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) { - return; - } - const enteringToolBar = createAnimation(); - enteringToolBar.addElement(enteringToolBarEl); - rootAnimation.addAnimation(enteringToolBar); - - const enteringTitle = createAnimation(); - enteringTitle.addElement(enteringToolBarEl.querySelector('ion-title')!); // REVIEW - - const enteringToolBarButtons = createAnimation(); - const buttons = Array.from(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); - - const parentHeader = enteringToolBarEl.closest('ion-header'); - const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive'); - - let buttonsToAnimate; - if (backDirection) { - buttonsToAnimate = buttons.filter((button) => { - const isCollapseButton = button.classList.contains('buttons-collapse'); - return (isCollapseButton && !inactiveHeader) || !isCollapseButton; - }); - } else { - buttonsToAnimate = buttons.filter((button) => !button.classList.contains('buttons-collapse')); - } - - enteringToolBarButtons.addElement(buttonsToAnimate); - - const enteringToolBarItems = createAnimation(); - enteringToolBarItems.addElement(enteringToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])')); - - const enteringToolBarBg = createAnimation(); - enteringToolBarBg.addElement(shadow(enteringToolBarEl).querySelector('.toolbar-background')!); // REVIEW - - const enteringBackButton = createAnimation(); - const backButtonEl = enteringToolBarEl.querySelector('ion-back-button'); - - if (backButtonEl) { - enteringBackButton.addElement(backButtonEl); - } - - enteringToolBar.addAnimation([enteringTitle, enteringToolBarButtons, enteringToolBarItems, enteringToolBarBg, enteringBackButton]); - enteringToolBarButtons.fromTo(OPACITY, 0.01, 1); - enteringToolBarItems.fromTo(OPACITY, 0.01, 1); - - if (backDirection) { - if (!inactiveHeader) { - enteringTitle.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1); - } - - enteringToolBarItems.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`); - - // back direction, entering page has a back button - enteringBackButton.fromTo(OPACITY, 0.01, 1); - } else { - // entering toolbar, forward direction - if (!enteringContentHasLargeTitle) { - enteringTitle.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1); - } - - enteringToolBarItems.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); - enteringToolBarBg.beforeClearStyles([OPACITY, 'transform']); - - const translucentHeader = parentHeader?.translucent; - if (!translucentHeader) { - enteringToolBarBg.fromTo(OPACITY, 0.01, 'var(--opacity)'); - } else { - enteringToolBarBg.fromTo('transform', isRTL ? 'translateX(-100%)' : 'translateX(100%)', 'translateX(0px)'); - } - - // forward direction, entering page has a back button - enteringBackButton.fromTo(OPACITY, 0.01, 1); - - if (backButtonEl) { - const enteringBackBtnText = createAnimation(); - enteringBackBtnText - .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW - .fromTo(`transform`, isRTL ? 'translateX(-100px)' : 'translateX(100px)', 'translateX(0px)'); - - enteringToolBar.addAnimation(enteringBackBtnText); - } - } - }); - - // setup leaving view - if (leavingEl) { - const leavingContent = createAnimation(); - const leavingContentEl = leavingEl.querySelector(':scope > ion-content'); - const leavingToolBarEls = leavingEl.querySelectorAll(':scope > ion-header > ion-toolbar'); - const leavingHeaderEls = leavingEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); - - if (leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) { - leavingContent.addElement(leavingEl); - } else if (!leavingContentEl && leavingToolBarEls.length === 0 && leavingHeaderEls.length === 0) { - leavingContent.addElement(leavingEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW - } else { - leavingContent.addElement(leavingContentEl!); // REVIEW - leavingContent.addElement(leavingHeaderEls); - } - - rootAnimation.addAnimation(leavingContent); - - if (backDirection) { - // leaving content, back direction - leavingContent - .beforeClearStyles([OPACITY]) - .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)'); - - const leavingPage = getIonPageElement(leavingEl) as HTMLElement; - rootAnimation.afterAddWrite(() => { - if (rootAnimation.getDirection() === 'normal') { - leavingPage.style.setProperty('display', 'none'); - } - }); - } else { - // leaving content, forward direction - leavingContent - .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) - .fromTo(OPACITY, 1, translucentTop ? 1 : OFF_OPACITY); - } - - if ( - leavingContentEl && - !leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)') - ) { - const leavingTransitionEffectEl = shadow(leavingContentEl).querySelector('.transition-effect'); - - if (leavingTransitionEffectEl) { - const leavingTransitionCoverEl = leavingTransitionEffectEl.querySelector('.transition-cover'); - const leavingTransitionShadowEl = leavingTransitionEffectEl.querySelector('.transition-shadow'); - - const leavingTransitionEffect = createAnimation(); - const leavingTransitionCover = createAnimation(); - const leavingTransitionShadow = createAnimation(); - - leavingTransitionEffect - .addElement(leavingTransitionEffectEl) - .beforeStyles({ opacity: '1', display: 'block' }) - .afterStyles({ opacity: '', display: '' }); - - leavingTransitionCover - .addElement(leavingTransitionCoverEl!) // REVIEW - .beforeClearStyles([OPACITY]) - .fromTo(OPACITY, 0.1, 0); - - leavingTransitionShadow - .addElement(leavingTransitionShadowEl!) // REVIEW - .beforeClearStyles([OPACITY]) - .fromTo(OPACITY, 0.7, 0.03); - - leavingTransitionEffect.addAnimation([leavingTransitionCover, leavingTransitionShadow]); - leavingContent.addAnimation([leavingTransitionEffect]); - } - } - - leavingToolBarEls.forEach((leavingToolBarEl) => { - if (leavingToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) { - return; - } - const leavingToolBar = createAnimation(); - leavingToolBar.addElement(leavingToolBarEl); - - const leavingTitle = createAnimation(); - leavingTitle.addElement(leavingToolBarEl.querySelector('ion-title')!); // REVIEW - - const leavingToolBarButtons = createAnimation(); - const buttons = leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]'); - - const parentHeader = leavingToolBarEl.closest('ion-header'); - const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive'); - - const buttonsToAnimate = Array.from(buttons).filter((button) => { - const isCollapseButton = button.classList.contains('buttons-collapse'); - return (isCollapseButton && !inactiveHeader) || !isCollapseButton; - }); - - leavingToolBarButtons.addElement(buttonsToAnimate); - - const leavingToolBarItems = createAnimation(); - const leavingToolBarItemEls = leavingToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'); - if (leavingToolBarItemEls.length > 0) { - leavingToolBarItems.addElement(leavingToolBarItemEls); - } - - const leavingToolBarBg = createAnimation(); - leavingToolBarBg.addElement(shadow(leavingToolBarEl).querySelector('.toolbar-background')!); // REVIEW - - const leavingBackButton = createAnimation(); - const backButtonEl = leavingToolBarEl.querySelector('ion-back-button'); - if (backButtonEl) { - leavingBackButton.addElement(backButtonEl); - } - - leavingToolBar.addAnimation([leavingTitle, leavingToolBarButtons, leavingToolBarItems, leavingBackButton, leavingToolBarBg]); - rootAnimation.addAnimation(leavingToolBar); - - // fade out leaving toolbar items - leavingBackButton.fromTo(OPACITY, 0.99, 0); - - leavingToolBarButtons.fromTo(OPACITY, 0.99, 0); - leavingToolBarItems.fromTo(OPACITY, 0.99, 0); - - if (backDirection) { - if (!inactiveHeader) { - // leaving toolbar, back direction - leavingTitle - .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)') - .fromTo(OPACITY, 0.99, 0); - } - - leavingToolBarItems.fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)'); - leavingToolBarBg.beforeClearStyles([OPACITY, 'transform']); - // leaving toolbar, back direction, and there's no entering toolbar - // should just slide out, no fading out - const translucentHeader = parentHeader?.translucent; - if (!translucentHeader) { - leavingToolBarBg.fromTo(OPACITY, 'var(--opacity)', 0); - } else { - leavingToolBarBg.fromTo('transform', 'translateX(0px)', isRTL ? 'translateX(-100%)' : 'translateX(100%)'); - } - - if (backButtonEl) { - const leavingBackBtnText = createAnimation(); - leavingBackBtnText - .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW - .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); - leavingToolBar.addAnimation(leavingBackBtnText); - } - } else { - // leaving toolbar, forward direction - if (!inactiveHeader) { - leavingTitle - .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) - .fromTo(OPACITY, 0.99, 0) - .afterClearStyles([TRANSFORM, OPACITY]); - } - - leavingToolBarItems - .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) - .afterClearStyles([TRANSFORM, OPACITY]); - - leavingBackButton.afterClearStyles([OPACITY]); - leavingTitle.afterClearStyles([OPACITY]); - leavingToolBarButtons.afterClearStyles([OPACITY]); - } - }); - } - - connectNativeUIShellTransition(rootAnimation, enteringEl, leavingEl); - return rootAnimation; - } catch (err) { - throw err; - } -};