diff --git a/demo/e2e/adaptive-tab-bar.spec.ts b/demo/e2e/adaptive-tab-bar.spec.ts index 9cda41f3..37c9e206 100644 --- a/demo/e2e/adaptive-tab-bar.spec.ts +++ b/demo/e2e/adaptive-tab-bar.spec.ts @@ -15,7 +15,7 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while const buttons = bar.locator('ion-tab-button'); await expect.poll(async () => (await bar.boundingBox())?.x).toBeGreaterThan(620); const barBox = (await bar.boundingBox())!; - expect(barBox.width).toBeCloseTo(50, 0); + expect(barBox.width).toBeCloseTo(48, 0); await expect(buttons.first().locator('ion-label')).toHaveCSS('position', 'absolute'); await expect(buttons.nth(1).locator('ion-label')).toHaveCSS('position', 'absolute'); diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png index 240ef73a..9d29406d 100644 Binary files a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png differ diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png index bb92950a..67ae959a 100644 Binary files a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-tab-drag-labels.png differ diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 56058a0b..ba61fdbb 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -451,6 +451,10 @@ test('standalone vertical bars keep searchable tabs usable on the Web', async ({ test('verticalBars back navigation and toolbar slots request native rail placement', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); const app = page.locator('ion-app'); const source = page.locator('app-native-ui-shell ion-back-button').first(); const projection = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); @@ -563,6 +567,10 @@ test('native verticalBars toolbar returns with Index after a pushed page', async test('native verticalBars actions follow WillEnter and stay enabled during navigation', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); const source = page.locator('app-native-ui-shell ion-back-button'); await expect(source).toHaveAttribute('data-native-ui-shell', ''); @@ -620,6 +628,10 @@ test('native verticalBars actions follow WillEnter and stay enabled during navig test('verticalBars toolbar sources are hidden before ownership and restored with their lifecycle', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const source = page.locator('app-native-ui-shell ion-button[type=submit]'); await expect(source).toHaveAttribute('data-native-ui-shell', ''); @@ -692,6 +704,10 @@ test('verticalBars toolbar sources are hidden before ownership and restored with test('rejected verticalBars control returns to an operable Web source', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const save = page.locator('app-native-ui-shell ion-button[type=submit]'); await expect(save).toHaveAttribute('data-native-ui-shell', ''); @@ -710,6 +726,10 @@ test('verticalBars rail remains native while its Ionic menu is open', async ({ p await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const menu = page.locator('ion-menu'); @@ -802,6 +822,10 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page, false, 'leading'); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); @@ -2756,3 +2780,201 @@ test('rejected search retries when tab content changes without resizing', async await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before); }); + +for (const type of ['normal', 'card', 'sheet']) { + test(`verticalBars native controls follow the foreground ${type} modal`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.getByText(`present:${type}`, { exact: true }).click(); + const modal = page.locator('ion-modal'); + const close = modal.locator('ion-toolbar ion-button').first(); + await expect(close).toHaveAttribute('data-native-ui-shell', ''); + await expect + .poll(() => + page.evaluate(() => { + const snapshot = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!; + return snapshot.controls.flatMap((control) => control.items.map((item) => item.accessibilityLabel)); + }), + ) + .toEqual(['Close', 'Done']); + await expect(modal.locator('ion-toolbar ion-button').nth(1)).toHaveAttribute('data-native-ui-shell', ''); + if (type === 'sheet') { + await modal.evaluate((element: HTMLIonModalElement) => element.setCurrentBreakpoint(0.5)); + await expect + .poll(() => + page.evaluate(() => { + const frame = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.verticalBarFrame; + const modal = document.querySelector('ion-modal')!; + const top = modal.shadowRoot!.querySelector('[part~="content"]')!.getBoundingClientRect().top; + return !!frame && Math.abs(frame.y - top) < 1 && frame.height < innerHeight; + }), + ) + .toBe(true); + } + await page.setViewportSize({ width: 1100, height: 900 }); + await expect(close).not.toHaveAttribute('data-native-ui-shell'); + await expect(close).toBeVisible(); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls.length)) + .toBe(0); + // A delayed tap from the old native modal surface must not dismiss it. + await activate(page, 'Close'); + await expect(modal).toBeVisible(); + await page.setViewportSize({ width: 700, height: 900 }); + await expect(close).toHaveAttribute('data-native-ui-shell', ''); + await modal.evaluate((element: HTMLIonModalElement) => { + element.canDismiss = false; + }); + await activate(page, 'Close', true); + await expect(modal).toBeVisible(); + await modal.evaluate((element: HTMLIonModalElement) => { + element.canDismiss = true; + }); + await activate(page, 'Done', true); + await expect(modal).toHaveCount(0); + await expect(page.locator('app-modal ion-back-button')).toHaveAttribute('data-native-ui-shell', ''); + }); +} + +test('vertical tab bar fades only when its Web fallback appears', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const bar = page.locator('ion-tab-bar'); + await expect(bar).toHaveAttribute('data-native-ui-shell', ''); + await expect(bar).not.toHaveAttribute('data-native-ui-shell-fading'); + await bar.evaluate((element) => { + const animate = element.animate.bind(element); + element.animate = (frames, options) => { + element.setAttribute('data-fade-frames', JSON.stringify(frames)); + element.setAttribute('data-fade-count', String(Number(element.getAttribute('data-fade-count') ?? 0) + 1)); + return animate(frames, options); + }; + }); + await page.getByText('present:normal', { exact: true }).click(); + await expect(bar).toHaveAttribute('data-fade-frames', JSON.stringify([{ opacity: 0 }, { opacity: 1 }])); + await expect(bar).toHaveAttribute('data-fade-count', '1'); + await expect(page.locator('ion-modal ion-toolbar ion-button').nth(1)).toHaveAttribute('data-native-ui-shell', ''); + await activate(page, 'Done', true); + await expect(page.locator('ion-modal')).toHaveCount(0); + await expect(bar).toHaveAttribute('data-native-ui-shell', ''); + await expect(bar).toHaveAttribute('data-fade-count', '1'); +}); + +for (const { fill, native, grouped } of [ + { fill: 'solid', native: true, grouped: false }, + { fill: 'outline', native: true, grouped: true }, + { fill: 'solid', native: false, grouped: true }, + { fill: 'outline', native: false, grouped: false }, +] as const) { + test(`${fill} icon-only rail button preserves external form submit: native=${native}, grouped=${grouped}`, async ({ page }) => { + await mockNative(page, !native); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.locator('app-native-ui-shell').evaluate( + (root, { fill, grouped }) => { + const button = document.createElement('ion-button'); + button.id = 'rail-submit'; + button.type = 'submit'; + button.fill = fill; + button.color = 'primary'; + button.setAttribute('aria-label', 'Confirm'); + Object.assign(button, { form: root.querySelector('form') }); + const icon = root.querySelector('ion-button[type=submit] ion-icon')!.cloneNode(true) as HTMLElement; + icon.setAttribute('slot', 'icon-only'); + button.append(icon); + const group = document.createElement('ion-buttons'); + group.slot = 'end'; + group.append(button); + if (grouped) { + const peer = button.cloneNode(true) as HTMLIonButtonElement; + peer.removeAttribute('id'); + peer.type = 'button'; + peer.setAttribute('aria-label', 'Peer'); + group.append(peer); + } + root.querySelector('ion-toolbar')!.append(group); + }, + { fill, grouped }, + ); + const button = page.locator('#rail-submit'); + const border = await button.evaluate((element) => { + const style = getComputedStyle(element.shadowRoot!.querySelector('[part=native]')!); + return { color: style.borderTopColor, width: parseFloat(style.borderTopWidth) }; + }); + if (native) { + await expect(grouped ? button.locator('..') : button).toHaveAttribute('data-native-ui-shell', ''); + if (fill === 'solid' || fill === 'outline') + await expect + .poll(() => + page.evaluate( + ({ fill, border }) => { + const item = Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.flatMap((control) => control.items) + .find((item) => item.accessibilityLabel === 'Confirm'); + return fill === 'solid' + ? !!item?.backgroundColor && item.backgroundColor !== 'rgba(0, 0, 0, 0)' + : item?.borderColor === border.color && item?.borderWidth === border.width && border.width > 0; + }, + { fill, border }, + ), + ) + .toBe(true); + await activate(page, 'Confirm', true); + } else { + const projection = page.locator( + grouped + ? 'ion-app > ion-buttons.ios-theme-vertical-bars-toolbar-projection > ion-button[aria-label=Confirm]' + : 'ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Confirm]', + ); + await expect(projection).toHaveClass(new RegExp(`button-${fill}`)); + if (fill === 'outline') { + await expect(projection.locator('[part=native]')).toHaveCSS('border-top-color', border.color); + await expect(projection.locator('[part=native]')).toHaveCSS('border-top-width', `${border.width}px`); + } + await projection.click(); + } + await expect(page.locator('[data-save-count]')).toHaveText('1'); + }); +} + +for (const native of [true, false]) { + test(`verticalBars placement requires the icon-only slot for every fill: native=${native}`, async ({ page }) => { + await mockNative(page, !native); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.locator('app-native-ui-shell').evaluate((root) => { + for (const fill of ['default', 'clear', 'solid', 'outline'] as const) { + const group = document.createElement('ion-buttons'); + group.slot = 'end'; + for (const slot of ['', 'start', 'end', 'icon-only']) { + const button = document.createElement('ion-button'); + button.id = `shape-${fill}-${slot || 'none'}`; + button.fill = fill; + button.setAttribute('aria-label', button.id); + const icon = root.querySelector('ion-icon')!.cloneNode(true) as HTMLElement; + icon.slot = slot; + button.append(icon); + if (slot === 'start' || slot === 'end') { + const span = document.createElement('span'); + span.textContent = 'Save'; + button.append(span); + } + group.append(button); + } + root.querySelector('ion-toolbar')!.append(group); + } + }); + for (const fill of ['default', 'clear', 'solid', 'outline']) { + await expect(page.locator(`#shape-${fill}-icon-only`)).toHaveAttribute('data-native-ui-shell', ''); + for (const slot of ['none', 'start', 'end']) { + await expect(page.locator(`#shape-${fill}-${slot}`)).not.toHaveAttribute('data-native-ui-shell'); + await expect(page.locator(`#shape-${fill}-${slot}`)).toHaveCSS('visibility', 'visible'); + } + } + }); +} diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png index 917f71b2..fd665444 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png index f25394f3..ecc9c883 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png index bd55b5a6..901cd0d1 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png index 8c58b53c..8efb3323 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png index 1f012c58..52d459eb 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png index ca097229..0566d6b2 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png index 96a3abdc..479a9d7b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png differ diff --git a/demo/e2e/vertical-bars-back-button.spec.ts b/demo/e2e/vertical-bars-back-button.spec.ts index f4c3cd38..59173729 100644 --- a/demo/e2e/vertical-bars-back-button.spec.ts +++ b/demo/e2e/vertical-bars-back-button.spec.ts @@ -12,6 +12,9 @@ test('verticalBars mode replaces the toolbar back button with an interactive Web await expect(source).toBeHidden(); await expect(projection).toBeVisible(); await expect(projection).toHaveCount(1); + await app.evaluate((element) => element.style.setProperty('--ios-theme-vertical-bars-toolbar-top', '0px')); + await expect(projection).toHaveCSS('top', '0px'); + await app.evaluate((element) => element.style.removeProperty('--ios-theme-vertical-bars-toolbar-top')); await source.evaluate((element) => { const original = element.getBoundingClientRect.bind(element); @@ -162,6 +165,10 @@ test('verticalBars back projection respects source opt-out regardless of Ionic m test('verticalBars toolbar projects icon actions and preserves text-only actions', async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const sourceGroup = page.locator('app-native-ui-shell ion-header ion-buttons[slot="end"]').first(); diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts index 3d1abd65..a4d2da07 100644 --- a/demo/e2e/vertical-bars-layout.spec.ts +++ b/demo/e2e/vertical-bars-layout.spec.ts @@ -79,3 +79,121 @@ for (const direction of ['ltr', 'rtl'] as const) { expect(result.modalSafeAreaRight).toBe('12px'); }); } + +for (const { type, edge, direction } of [ + { type: 'normal', edge: 'right', direction: 'ltr' }, + { type: 'card', edge: 'left', direction: 'ltr' }, + { type: 'sheet', edge: 'right', direction: 'rtl' }, +]) { + test(`${type} modal respects ${edge} vertical bars in ${direction}`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/modal', { waitUntil: 'networkidle' }); + await page.locator('ion-app').evaluate( + (app, { direction, edge }) => { + app.dir = direction; + app.classList.add('ios-theme-vertical-bars', `ios-theme-vertical-bars-${edge}`); + app.style.setProperty(`--ion-safe-area-${edge}`, '80px'); + }, + { direction, edge }, + ); + await page.getByText(`present:${type}`, { exact: true }).click(); + const modal = page.locator('ion-modal'); + await expect(modal).toBeVisible(); + // Ionic 9 updates inline safe areas on resize; keep the author's override explicit. + await page.addStyleTag({ + content: 'ion-modal { --ion-safe-area-left: 12px !important; --ion-safe-area-right: 18px !important; }', + }); + for (const tag of ['ion-toolbar', 'ion-content']) { + const component = modal.locator(tag).first(); + await component.evaluate((element) => { + element.style.setProperty('--padding-start', '11px'); + element.style.setProperty('--padding-end', '17px'); + }); + await expect + .poll(() => + component.evaluate((element) => { + const host = getComputedStyle(element); + const part = getComputedStyle(element.shadowRoot!.querySelector('[part~="scroll"], [part~="container"]')!); + return { + left: host.getPropertyValue('--ion-safe-area-left').trim(), + right: host.getPropertyValue('--ion-safe-area-right').trim(), + start: part.paddingInlineStart, + end: part.paddingInlineEnd, + }; + }), + ) + .toEqual({ + left: '0px', + right: '0px', + start: (edge === (direction === 'ltr' ? 'left' : 'right') ? 91 : 11) + 'px', + end: (edge === (direction === 'ltr' ? 'right' : 'left') ? 97 : 17) + 'px', + }); + } + const close = modal.locator(':scope > .ios-theme-vertical-bars-toolbar-projection[aria-label=Close]'); + const done = modal.locator(':scope > .ios-theme-vertical-bars-toolbar-projection[aria-label=Done]'); + await expect(done).toBeVisible(); + await expect(done).toHaveAttribute('fill', 'solid'); + await expect(done).toHaveAttribute('color', 'primary'); + await expect(close).toBeVisible(); + await expect(page.locator('ion-app > .ios-theme-vertical-bars-back-button-projection')).toHaveCount(0); + const bounds = await close.boundingBox(); + expect(edge === 'left' ? bounds!.x < 80 : bounds!.x > 620).toBe(true); + // A centered dialog on the open display keeps its own toolbar and width. + await page.setViewportSize({ width: 1100, height: 900 }); + await expect(close).toHaveCount(0); + await expect(done).toHaveCount(0); + await expect(modal.locator('ion-toolbar').getByRole('button', { name: 'Done', exact: true })).toBeVisible(); + await expect(modal.locator('ion-toolbar').getByRole('button', { name: 'Close', exact: true })).toBeVisible(); + for (const tag of ['ion-toolbar', 'ion-content']) { + await expect(modal.locator(tag).first()).toHaveCSS('--ion-safe-area-left', '12px'); + await expect(modal.locator(tag).first()).toHaveCSS('--ion-safe-area-right', '18px'); + } + await expect + .poll(() => + modal + .locator('ion-content') + .evaluate((element) => getComputedStyle(element.shadowRoot!.querySelector('[part~="scroll"]')!).paddingInlineEnd), + ) + .toBe('17px'); + await page.setViewportSize({ width: 700, height: 900 }); + await expect(close).toBeVisible(); + await modal.evaluate((element: HTMLIonModalElement) => { + element.canDismiss = async () => { + element.setAttribute('data-dismiss-attempted', ''); + return false; + }; + }); + await close.click(); + await expect(modal).toHaveAttribute('data-dismiss-attempted', ''); + await expect(modal).toBeVisible(); + await modal.evaluate((element: HTMLIonModalElement) => { + element.canDismiss = true; + }); + await done.click(); + await expect(modal).toHaveCount(0); + await expect(page.locator('ion-app > .ios-theme-vertical-bars-back-button-projection')).toBeVisible(); + }); +} + +test('stacked modals keep rail controls inside the active dialog and restore the previous surface', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.getByText('present:normal', { exact: true }).click(); + const modals = page.locator('ion-modal'); + const projection = '.ios-theme-vertical-bars-toolbar-projection[aria-label=Close]'; + await expect(modals.first().locator(projection)).toBeVisible(); + await modals.first().getByText('present:normal', { exact: true }).click(); + await expect(modals).toHaveCount(2); + await expect(modals.first().locator(projection)).toHaveCount(0); + await expect(modals.last().locator(projection)).toBeVisible(); + await modals.last().getByRole('button', { name: 'Close', exact: true }).focus(); + await expect(modals.last().getByRole('button', { name: 'Close', exact: true })).toBeFocused(); + await modals.last().getByRole('button', { name: 'Close', exact: true }).press('Enter'); + await expect(modals).toHaveCount(1); + await expect(modals.first().locator(projection)).toBeVisible(); + await modals.first().evaluate((modal) => modal.classList.add('ios-theme-shell-disabled')); + await expect(modals.first().locator(projection)).toHaveCount(0); + await modals.first().getByRole('button', { name: 'Close', exact: true }).click(); + await expect(modals).toHaveCount(0); +}); diff --git a/demo/e2e/vertical-bars-standalone.spec.ts b/demo/e2e/vertical-bars-standalone.spec.ts index b09c0241..89a9fc41 100644 --- a/demo/e2e/vertical-bars-standalone.spec.ts +++ b/demo/e2e/vertical-bars-standalone.spec.ts @@ -23,6 +23,10 @@ test('Vertical Control Area works in md mode with Ionic CSS and no iOS 27 theme' element.text = 'Return'; element.closest('app-native-ui-shell')?.querySelector('ion-content')?.prepend(element); }); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); const tabBar = page.locator('#tab-bar-bottom'); diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index 43b3c241..f83caf3f 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -106,9 +106,9 @@ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-ba const rail = await enableVerticalControlArea(); ``` -**What you should see:** your existing tab bar moves to the side, and supported icon-based fixed-toolbar actions appear there too. Content keeps its existing theme and leaves room for the controls. The Web tab rail displays icons; pressing and dragging reveals tab labels. +**What you should see:** your existing tab bar moves to the side, and fixed-toolbar buttons with an `ion-icon` or SVG using `slot="icon-only"` appear there too. Content keeps its existing theme and leaves room for the controls. The Web tab rail displays icons; pressing and dragging reveals tab labels. -Use your existing Ionic click handlers and routing. Text-only toolbar actions remain horizontal. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep an action in the horizontal toolbar. +Use your existing Ionic click handlers, routing, and form associations. All button fills (`default`, `clear`, `solid`, and `outline`) use the same `icon-only` rule, including submit buttons. Actions without that slot remain horizontal. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep an action in the horizontal toolbar. When the application owner is disposed, call `await rail.destroy()` to restore the original controls and release the runtime. If you already use `enableNativeUIShell()`, keep that runtime and follow the [shared placement guide](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#project-controls-into-the-rail). @@ -117,8 +117,8 @@ When the application owner is disposed, call `await rail.destroy()` to restore t | What you see | What to check | | --- | --- | | No space at the side | Load `vertical-bars.css` and put the class on `ion-app`. | -| Space appears, but controls stay horizontal | Start `enableVerticalControlArea()` after mounting the app root. Use existing tabs or supported icon-based actions in a fixed toolbar. | -| One action stays horizontal | Text-only actions, custom fills, and explicitly excluded controls keep their original presentation. See [control requirements](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#project-controls-into-the-rail). | +| Space appears, but controls stay horizontal | Start `enableVerticalControlArea()` after mounting the app root. Use existing tabs or `slot="icon-only"` actions in a fixed header/footer toolbar. | +| One action stays horizontal | Check for `slot="icon-only"` on the icon and a fixed toolbar outside scrolling content. Explicitly excluded controls and controls in centered modals stay horizontal; `fill` and `type="submit"` do not prevent movement. See [control requirements](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#toolbar-actions). | ## Connect an iPhone Duo diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 7e91e056..d3047568 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -87,7 +87,7 @@ For Chrome development, no native plugin is needed — the class alone reserves This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again. -`ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical. +`ion-modal` applies the same foreground correction when its visible dialog spans the viewport width. With the Vertical Control Area runtime enabled, the topmost full-width modal also projects eligible toolbar buttons into its own rail; centered dialogs keep their toolbar buttons and receive no page-rail inset. This includes full-width sheet modals: their rail follows the visible sheet bounds as the breakpoint changes. Eligibility follows the visible dialog width, not the hinge posture or modal type. `ion-menu` and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical. The mode is component-mode independent: an app can keep Ionic `mode: 'md'` on iOS and still enable Vertical Bars. No component needs `mode="ios"`. @@ -124,9 +124,39 @@ const stopVerticalArea = async () => { Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(placement)` from its listener. -On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout. +On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; menu buttons and other toolbar actions still require one. -When the app contains `ion-tabs`, its tab bar moves into the reserved region and aligns above the bottom safe area; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView`. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Use `ion-menu` when navigation should become a sidebar; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. +### Toolbar actions + +An `ion-button` moves into the rail when it contains an `ion-icon` or SVG with `slot="icon-only"`. The button must be in a fixed `ion-toolbar` directly inside `ion-header` or `ion-footer`, outside scrolling `ion-content`. + +| Icon markup | Placement | +| --- | --- | +| `slot="icon-only"` | Vertical rail | +| `slot="start"`, `slot="end"`, or no slot | Original horizontal toolbar | +| No icon | Original horizontal toolbar | + +This rule applies to `fill="default"`, `clear`, `solid`, and `outline`, including buttons with an Ionic `color`. Solid actions retain their background color through a prominent native button; outline actions retain their border color and width. The runtime preserves the original click or form-submit behavior. `type="submit"` and `.button-submit` do not select a different placement. + +```html + + + + + + + + + +``` + +Give icon-only actions an accessible name, such as `aria-label="Done"`. Keep the original Ionic event handler or form association on the source button. + +The same rule applies to individual buttons and buttons inside `ion-buttons`, on ordinary pages and in the topmost full-width modal. Centered modals, menus, and popovers keep their own toolbar layout. Add `.ios-theme-horizontal-only` to a group or individual button to keep it horizontal. Placement is chosen when a routed page enters; changing an existing button's content or icon slot does not move it between the toolbar and rail until the page leaves and re-enters. + +### Tab bar + +When the app contains `ion-tabs`, its tab bar moves into the reserved region and uses the native Duo edge spacing; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching the native resting presentation. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Native tabs and a restored Web tab bar fade in over 180ms; disappearance remains immediate. Reduced motion disables this fade. Use `ion-menu` when navigation should become a sidebar; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout. ## Adapt the split pane diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 72290a43..18bb3e8c 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -40,6 +40,8 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy ## Supported markup +The table below describes the ordinary Native UI Shell. Vertical Bars uses the separate [toolbar action rules](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#toolbar-actions). + | Ionic component | Supported appearance and placement | Native rendering | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `ion-button` | `fill="default"`, standard glass, in a fixed header/footer toolbar | Glass `UIButton` | @@ -62,11 +64,11 @@ Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while kee If a child inside a shared native surface opts out, the entire surface stays on the Web: this includes button groups, tab bars, segments and FAB lists. Opting out of the search FAB or any part of the search footer disables native search integration; the tab bar can still render natively if it remains eligible. -Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Overlays, collapsed headers, opted-out controls and departed pages are excluded. +Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Collapsed headers, opted-out controls and departed pages are excluded. Full-width foreground modals can participate in Vertical Bars as described below; other overlay surfaces keep their own layout. Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip. -Standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles. +Outside Vertical Bars, standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles. Native glass samples the Web content actually drawn behind it. Existing toolbar backgrounds and header blur still affect that content. For content to scroll beneath a header, use Ionic's normal translucent-header/fullscreen-content layout; the plugin does not move page content or override an application-owned opaque toolbar background. @@ -175,9 +177,9 @@ The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/ver On supported iOS versions, adding `.ios-theme-vertical-bars` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar once the class is applied. When the OS reports a rail edge — on iPhone Duo linked against iOS 27.1 or later — it must agree with the applied placement; a disagreeing report keeps the rail on the Web. Older toolchains that cannot report an edge trust the DOM placement directly. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers. -The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. Menus, modals, and popovers remain independent surfaces and are not moved into the main-page rail. +The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. A topmost full-width modal supplies its own eligible toolbar actions and rail bounds, including when it is a sheet. Centered modals, menus, and popovers keep their own toolbar layout. Modal placement follows the visible dialog width rather than its type or hinge posture. -This mode accepts standard `fill="default"` and `fill="clear"` fixed-toolbar buttons because SwiftUI determines their compact representation. Solid, outline, custom-color, scrolling, and explicitly opted-out controls remain on the Web. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal Web toolbar. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web projection remains the fallback. +In Vertical Bars, fixed-toolbar `ion-button` actions use an `ion-icon` or SVG with `slot="icon-only"`. All fills (`default`, `clear`, `solid`, and `outline`) and Ionic colors are eligible; submit buttons follow the same placement rule. Solid actions retain their background color and outline actions retain their border color and width. Actions without `icon-only` stay horizontal. Scrolling and explicitly opted-out controls retain their Web presentation. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal Web toolbar. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web projection remains the fallback. For standalone setup while keeping your existing theme, see [iPhone Duo with your existing theme](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo-with-original-theme). diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 79522e51..7acce49f 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -5,7 +5,7 @@ import UIKit protocol ShellVerticalBarsControlling: AnyObject { var view: UIView { get } func attach(to owner: UIViewController, in parent: UIView) - func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) + @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) func detach() } @@ -16,6 +16,9 @@ final class ShellVerticalBarsModel: ObservableObject { let label: String let accessibilityLabel: String let image: UIImage? + let background: Color? + let borderColor: Color? + let borderWidth: CGFloat let badge: ShellBadge? let disabled: Bool var selected: Bool @@ -41,7 +44,11 @@ final class ShellVerticalBarsModel: ObservableObject { func item(_ source: ShellItem) -> Item { Item(id: source.id, label: source.content.label, accessibilityLabel: source.content.accessibilityLabel, - image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled, + image: rendering.image(source.content), + background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) }, + borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) }, + borderWidth: source.content.borderWidth ?? 0, + badge: source.content.badge, disabled: source.content.disabled, selected: source.content.selected) } withAnimation(.smooth(duration: 0.3)) { @@ -99,11 +106,21 @@ private struct ShellVerticalBarsLabel: View { } @available(iOS 26.0, *) +@ViewBuilder private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View { - Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) } + let button = Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) } .disabled(item.disabled) .accessibilityLabel(item.accessibilityLabel) .accessibilityIdentifier(item.id) + if let background = item.background { + button.buttonStyle(.glassProminent).tint(background) + } else { + button.overlay { + if let borderColor = item.borderColor { + Capsule().strokeBorder(borderColor, lineWidth: item.borderWidth).allowsHitTesting(false) + } + } + } } @available(iOS 26.0, *) @@ -340,10 +357,12 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { controller.didMove(toParent: owner) } - func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { + @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { container.railEdge = edge controller.railEdge = edge + let tabsAppear = model.tabs.isEmpty && controls.contains { $0.kind == .tabBar && !$0.items.isEmpty } model.apply(controls, rendering: rendering) + if tabsAppear { ShellCrossfade.enter(container, duration: ShellCrossfade.duration(180)) } controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light } diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index d894a760..c7e4473e 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -379,6 +379,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) }) self.verticalBars = rail rail.attach(to: owner, in: owner.view) + if let frame = snapshot.verticalBarFrame { + rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view) + } else { + rail.view.frame = owner.view.bounds + } rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right") rail.view.isHidden = false } else { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 76921bfa..eeb8edd5 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -11,11 +11,13 @@ struct ShellSnapshot: Decodable { let revision: Int let transitionDuration: Double? let viewportWidth: Double + let verticalBarFrame: ShellFrame? let verticalBarEdge: String? let controls: [ShellControl] var isValid: Bool { revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 && + (verticalBarFrame.map { $0.isValid } ?? true) && (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") && Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid) } @@ -105,6 +107,9 @@ struct ShellItemContent: Decodable, Equatable { let fontSize: Double let fontWeight: Double let color: String + let backgroundColor: String? + let borderColor: String? + let borderWidth: CGFloat? let badge: ShellBadge? var icon: String? var iconWidth: Double? diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index 1514ae31..6494dd42 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -22,6 +22,18 @@ final class ShellSnapshotTests: XCTestCase { try JSValueDecoder().decode(ShellSnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls]) } + func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws { + var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]] + let page = try JSValueDecoder().decode(ShellSnapshot.self, from: payload) + XCTAssertNil(page.verticalBarFrame) + for height in [339.0, 0.0, -1.0] { + payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height] + let modal = try JSValueDecoder().decode(ShellSnapshot.self, from: payload) + XCTAssertEqual(modal.isValid, height > 0) + XCTAssertEqual(modal.verticalBarFrame?.y, 339) + } + } + @MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws { let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black] let tab = UITabBarItem() @@ -37,6 +49,24 @@ final class ShellSnapshotTests: XCTestCase { } } + @MainActor func testVerticalBarsPreserveButtonAppearance() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") } + let controls = try decode([control(["kind": "ion-buttons", "items": [ + item(["backgroundColor": "rgb(0, 122, 255)"]), + item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0]) + ]])]).controls + XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)") + let model = ShellVerticalBarsModel() + model.apply(controls, rendering: ShellRendering()) + XCTAssertNotNil(model.groups.first?.items.first?.background) + XCTAssertNotNil(model.groups.first?.items.last?.borderColor) + XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2) + model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: ShellRendering()) + XCTAssertNil(model.groups.first?.items.first?.background) + XCTAssertNil(model.groups.first?.items.first?.borderColor) + XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0) + } + @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") } func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] { diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 670d865e..441fd9f6 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -31,7 +31,12 @@ export const isVerticalBarsCandidate = isVerticalBarsSource; export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { const verticalBars = isVerticalBarsCandidate(element); - if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover')) + if ( + (!element.classList.contains('ios') && !verticalBars) || + !visible(element, verticalBars) || + element.closest('ion-popover') || + (element.closest('ion-modal') && !verticalBars) + ) return; const style = getComputedStyle(element); if ( diff --git a/src/native/components/ion-button.ts b/src/native/components/ion-button.ts index a1f5dc9b..1ab28ddb 100644 --- a/src/native/components/ion-button.ts +++ b/src/native/components/ion-button.ts @@ -1,3 +1,4 @@ +import { modalUsesVerticalBars } from '../shared/modal'; import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom'; @@ -6,15 +7,15 @@ export const tag = 'ion-button'; export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { const button = element as HTMLIonButtonElement; - const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); + const verticalBars = + !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars'); if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return; const fill = button.fill ?? 'default'; if ( !inFixedToolbar(element) || (verticalBars && !isVerticalBarsToolbarAction(element)) || (!verticalBars && fill !== 'default') || - (verticalBars && !['default', 'clear'].includes(fill)) || - button.classList.contains('ion-color') + (!verticalBars && button.classList.contains('ion-color')) ) return; const candidate = createCandidate(element, tag, id); diff --git a/src/native/components/ion-buttons.ts b/src/native/components/ion-buttons.ts index acdfba2c..d4fefff0 100644 --- a/src/native/components/ion-buttons.ts +++ b/src/native/components/ion-buttons.ts @@ -1,3 +1,4 @@ +import { modalUsesVerticalBars } from '../shared/modal'; import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom'; @@ -10,7 +11,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined if (!inFixedToolbar(element)) return; let children = childElements(element); if (children.length === 1) return menuButton.read(element, id); - const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars'); + const verticalBars = + !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars'); if (verticalBars && !isVerticalBarsToolbarGroup(element)) return; if (verticalBars) children = verticalBarsToolbarActions(element); if ( @@ -19,7 +21,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined (child) => !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) && (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) || - !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')), + (!verticalBars && (child as HTMLIonButtonElement).fill !== 'clear')), ) ) return; diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 3846d039..a77da128 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -86,6 +86,9 @@ export interface ShellItem extends Frame { fontSize: number; fontWeight: number; color: string; + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; badge?: ShellBadge; icon?: string; iconWidth?: number; @@ -144,6 +147,8 @@ export interface ShellSnapshot { viewportWidth: number; /** Physical side the native rail is drawn on; the runtime resolves the logical edge through the document direction. */ verticalBarEdge?: 'left' | 'right'; + /** Visible foreground modal bounds; omitted for the full page. */ + verticalBarFrame?: Frame; controls: ShellControl[]; } diff --git a/src/native/index.ts b/src/native/index.ts index 92d3bf6b..ab3aadb9 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -12,6 +12,7 @@ import type { import { createRuntime } from './runtime'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; +import { observeVerticalBarsModals } from './shared/modal'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -160,6 +161,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise ); if (active) return active; activeConfiguration = configuration; + const stopModals = observeVerticalBarsModals(document); const prehide = options.controls === undefined || options.controls.toolbar === true ? prehideVerticalBarsToolbarSources(document) : undefined; // A slow destroy of a superseded activation must not release a newer one. @@ -174,7 +176,10 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise manage(createVerticalBarsWebProjection(document, options), { reason, suspend: () => prehide?.suspend(), - destroy: () => prehide?.stop(), + destroy: () => { + prehide?.stop(); + stopModals(); + }, release, }); return (start = active = @@ -218,6 +223,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise await placementListener?.remove().catch(() => {}); if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); prehide?.stop(); + stopModals(); }, }); } catch (error) { diff --git a/src/native/prehide.ts b/src/native/prehide.ts index 883ac6cf..6fa5fe72 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -1,3 +1,4 @@ +import { modalUsesVerticalBars } from './shared/modal'; import { LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE } from '@ionic/core'; import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration'; import { @@ -17,7 +18,7 @@ import { setVerticalBarsPlacement, } from './shared/dom'; -const overlays = 'ion-menu, ion-modal, ion-popover'; +const overlays = 'ion-menu, ion-popover'; const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button'; // Realm- and teardown-safe: globals such as Element/HTMLElement may be gone // when a queued mutation microtask or a stale listener still runs. @@ -60,7 +61,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () scopes.delete(scope); }; const capture = (scope: HTMLElement) => { - if (!root()?.contains(scope) || scope.closest(overlays) || verticalBarsPages.isDeparted(scope)) return; + if (!root()?.contains(scope) || scope.closest(overlays) || !modalUsesVerticalBars(scope) || verticalBarsPages.isDeparted(scope)) return; const owned = scopes.get(scope) ?? new Set(); const place = (element: HTMLElement, rail: boolean) => { if (owned.has(element)) return; @@ -73,7 +74,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () }; const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll(sourceSelector)); sources.forEach((element) => { - if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return; + if (!modalUsesVerticalBars(element) || element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) + return; if (element.matches('ion-buttons')) { const children = childElements(element); const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child)); @@ -134,7 +136,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () Array.from(scopes.keys()).forEach(release); return; } - for (const scope of scopes.keys()) if (!scope.isConnected) release(scope); + for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope); for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope); // Ionic inserts the destination as invisible before WillEnter. Hide its // sources in that same mutation microtask; capture new DOM identities on active pages too. @@ -193,6 +195,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () for (const name of [LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE]) doc.addEventListener(name, onPageLifecycle, { capture: true, signal: listeners.signal }); doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, onTransitionCanceled, { capture: true, signal: listeners.signal }); + doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile, { signal: listeners.signal }); reconcile(); const mutationRelevant = (record: MutationRecord) => { if (record.type === 'childList') diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 528c0e43..08cb79ad 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core'; import { VERTICAL_BARS_TRANSITION_CANCELED, getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration'; @@ -126,7 +127,7 @@ export const createRuntime = async ( lastSnapshot = ''; search.release(element); element.removeAttribute(marker); - if (!stopped) crossfade.play(element, false, handoffInstant || isVerticalBarsSource(element)); + if (!stopped) crossfade.play(element, false, isVerticalBarsSource(element) ? !element.matches('ion-tab-bar') : handoffInstant); if (element.getAttribute('aria-hidden') === 'true') { const previous = sources.get(element); if (previous == null) element.removeAttribute('aria-hidden'); @@ -205,14 +206,19 @@ export const createRuntime = async ( Array.from(pages).some((scope) => scope.contains(element)) || Array.from(moving.keys()).some((surface) => surface.contains(element)))); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); - const overlayOpen = (includeMenu = true) => { + const overlayOpen = (includeMenu = true, allowModal = false) => { + const modal = topModal(doc); + const ignored = (element: Element) => allowModal && !!modal && modalUsesVerticalBars(modal) && element.matches('ion-modal'); for (const element of presented) if (!element.isConnected) presented.delete(element); - const presentedOverlayOpen = Array.from(presented).some((element) => includeMenu || !element.matches('ion-menu')); + const presentedOverlayOpen = Array.from(presented).some( + (element) => !ignored(element) && (includeMenu || !element.matches('ion-menu')), + ); return ( manualSuspensions.size > 0 || presentedOverlayOpen || Array.from(doc.querySelectorAll(overlays)).some( (element) => + !ignored(element) && (includeMenu || !element.matches('ion-menu')) && ((element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu')), ) @@ -225,7 +231,7 @@ export const createRuntime = async ( search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface); - if (doc.hidden || overlayOpen(false)) return []; + if (doc.hidden || overlayOpen(false, true)) return []; if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return []; const menuOpen = overlayOpen(); const candidates = unprojected(sources.keys(), () => @@ -233,7 +239,10 @@ export const createRuntime = async ( .decorate( Array.from(doc.querySelectorAll(selector)) .filter( - (element) => !verticalBarsPages.isDeparted(element) && (!blocked(element) || (menuOpen && isVerticalBarsSource(element))), + (element) => + inVerticalBarsSurface(element) && + !verticalBarsPages.isDeparted(element) && + (!blocked(element) || (menuOpen && isVerticalBarsSource(element))), ) .map(readEnabledCandidate) .filter((candidate): candidate is Candidate => !!candidate), @@ -333,7 +342,14 @@ export const createRuntime = async ( ? 'left' : 'right' : undefined; - const data = { viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control) }; + const modal = topModal(doc); + const verticalBarFrame = modal && modalUsesVerticalBars(modal) ? modalVerticalBarFrame(modal) : undefined; + const data = { + verticalBarFrame, + viewportWidth: win.innerWidth, + verticalBarEdge, + controls: candidates.map((candidate) => candidate.control), + }; const serialized = JSON.stringify(data); if (serialized === lastSnapshot && !forceRefresh) return; const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; @@ -675,7 +691,7 @@ export const createRuntime = async ( event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence || - overlayOpen(false) + overlayOpen(false, true) ) return; // Native may send input before update() resolves on the JS bridge. @@ -688,7 +704,7 @@ export const createRuntime = async ( const element = actions.get(event.id); if (!element) return; const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element)); - if (!owner) return; + if (!owner || !inVerticalBarsSurface(owner)) return; const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); diff --git a/src/native/shared/candidate.ts b/src/native/shared/candidate.ts index e9739cc9..146857be 100644 --- a/src/native/shared/candidate.ts +++ b/src/native/shared/candidate.ts @@ -54,6 +54,10 @@ export const appendItem = ( const labelStyle = getComputedStyle(labelElement); const badge = child.querySelector('ion-badge'); const badgeStyle = badge && visible(badge) ? getComputedStyle(badge) : undefined; + const outline = + isVerticalBarsSource(child) && child.matches('ion-button') && (child as HTMLIonButtonElement).fill === 'outline' + ? getComputedStyle(native ?? child) + : undefined; const item: ShellItem = { id: id(child), ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()), @@ -71,6 +75,12 @@ export const appendItem = ( fontSize: parseFloat(labelStyle.fontSize), fontWeight: parseInt(labelStyle.fontWeight, 10) || 400, color: getComputedStyle(native ?? child).color, + backgroundColor: + isVerticalBarsSource(child) && child.matches('ion-button') && (child as HTMLIonButtonElement).fill === 'solid' + ? getComputedStyle(native ?? child).backgroundColor + : undefined, + borderColor: outline?.borderTopColor, + borderWidth: outline ? parseFloat(outline.borderTopWidth) : undefined, badge: badgeStyle ? { value: badge!.textContent?.trim() ?? '', color: badgeStyle.backgroundColor, textColor: badgeStyle.color } : undefined, diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index ef78f38c..6bd1e21b 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -1,4 +1,5 @@ import { fadeMarker } from './crossfade'; +import { modalUsesVerticalBars } from './modal'; import type { Frame } from '../definitions'; @@ -98,7 +99,8 @@ const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons' export const isVerticalBarsSource = (element: HTMLElement): boolean => verticalBarsTags.has(element.localName) && (element.matches('ion-tab-bar') || verticalBarsOwned(element)) && - !element.closest('ion-menu, ion-modal, ion-popover') && + !element.closest('ion-menu, ion-popover') && + modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars'); export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => { @@ -109,7 +111,8 @@ export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => { (back) => back.closest('.ion-page') === page && !back.matches('.ion-cloned-element') && - !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-modal, ion-popover'), + !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-popover') && + modalUsesVerticalBars(back), ); const rank = (back: HTMLElement) => inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2; @@ -141,11 +144,7 @@ export const isShellDisabled = (element: Element): boolean => export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean => element.matches('ion-menu-button') || - (element.matches('ion-button') && - !!element.querySelector('ion-icon, svg') && - !childNodesOf(element).some((node) => node.nodeType === 3 && !!node.textContent?.trim()) && - !element.matches('.ion-color, [color]') && - ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default')); + (element.matches('ion-button') && !!element.querySelector('ion-icon[slot="icon-only"], svg[slot="icon-only"]')); // Placement belongs to the DOM identity for one routed-page epoch. Changes to // content/disabled state affect rendering, never its chosen surface. @@ -161,7 +160,7 @@ export const verticalBarsOwned = (element: HTMLElement): boolean => verticalBars export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean => verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element); -const overlays = 'ion-menu, ion-modal, ion-popover'; +const overlays = 'ion-menu, ion-popover'; /** Shared eligibility for a toolbar action candidate, before placement is decided (prehide) or consumed (projection). */ export const verticalBarsActionCandidate = (element: HTMLElement): boolean => @@ -169,7 +168,8 @@ export const verticalBarsActionCandidate = (element: HTMLElement): boolean => !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') && !isPermanentlyExcluded(element) && !isShellDisabled(element) && - !element.closest(overlays); + !element.closest(overlays) && + modalUsesVerticalBars(element); /** Shared eligibility for a back-button candidate; the back button may sit outside a fixed toolbar. */ export const verticalBarsBackCandidate = (element: HTMLElement): boolean => @@ -177,7 +177,8 @@ export const verticalBarsBackCandidate = (element: HTMLElement): boolean => !element.closest('ion-buttons.ios-theme-horizontal-only') && !isPermanentlyExcluded(element) && !isShellDisabled(element) && - !element.closest(overlays); + !element.closest(overlays) && + modalUsesVerticalBars(element); export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] => // Back buttons use the dedicated rail slot; they are never toolbar actions. diff --git a/src/native/shared/modal.ts b/src/native/shared/modal.ts new file mode 100644 index 00000000..8f058884 --- /dev/null +++ b/src/native/shared/modal.ts @@ -0,0 +1,157 @@ +import type { Frame } from '../definitions'; + +/** Modal hosts cover the viewport even when their visible dialog is centered. */ +export const modalRailClass = 'ios-theme-vertical-bars-modal'; +const states = new WeakMap(); + +export const topModal = (doc: Document): HTMLIonModalElement | undefined => states.get(doc)?.top; +export const modalUsesVerticalBars = (element: Element): boolean => { + const modal = element.closest('ion-modal'); + return !modal || modal.classList.contains(modalRailClass); +}; + +/** Only the foreground modal may contribute controls; never the covered page. */ +export const inVerticalBarsSurface = (element: Element): boolean => { + const modal = topModal(element.ownerDocument); + return modal ? element.closest('ion-modal') === modal && modalUsesVerticalBars(element) : !element.closest('ion-modal'); +}; + +export const observeVerticalBarsModals = (doc: Document): (() => void) => { + const win = doc.defaultView!; + const state: { top?: HTMLIonModalElement } = {}; + states.set(doc, state); + const presenting = new Set(); + const dismissed = new WeakSet(); + const observed = new Set(); + const listeners = new AbortController(); + let stopped = false; + let frame = 0; + let pointerActive = false; + let bounds = ''; + const toolbarTop = '--ios-theme-vertical-bars-modal-offset-top'; + const refresh = () => { + win.cancelAnimationFrame(frame); + frame = 0; + if (stopped) return; + const app = doc.querySelector('ion-app.ios-theme-vertical-bars'); + const modals = Array.from(doc.querySelectorAll('ion-modal')); + const active = modals.filter( + (modal) => !dismissed.has(modal) && ((modal as HTMLIonModalElement & { presented?: boolean }).presented || presenting.has(modal)), + ); + for (const modal of presenting) if (!modal.isConnected) presenting.delete(modal); + const top = active.sort((a, b) => a.overlayIndex - b.overlayIndex).slice(-1)[0]; + let changed = state.top !== top; + state.top = top; + const nextBounds = top ? JSON.stringify(modalVerticalBarFrame(top)) : ''; + changed ||= bounds !== nextBounds; + bounds = nextBounds; + const wanted = new Set(); + for (const modal of modals) { + const content = modal.shadowRoot?.querySelector('[part~="content"]'); + if (content) wanted.add(content); + // offsetWidth is unaffected by Ionic's presentation transforms. + const fullWidth = !!content && Math.abs(content.offsetWidth - win.innerWidth) <= 1 && Math.abs(content.offsetLeft) <= 1; + const enabled = !!app?.contains(modal) && active.includes(modal) && fullWidth; + if (enabled) { + const top = modalVerticalBarFrame(modal)?.y ?? 0; + const offset = `${top}px`; + if (modal.style.getPropertyValue(toolbarTop) !== offset) { + modal.style.setProperty(toolbarTop, offset); + changed = true; + } + } else modal.style.removeProperty(toolbarTop); + if (modal.classList.contains(modalRailClass) !== enabled) { + modal.classList.toggle(modalRailClass, enabled); + changed = true; + } + } + if (wanted.size !== observed.size || [...wanted].some((element) => !observed.has(element))) { + resize?.disconnect(); + observer.disconnect(); + observer.observe(doc.documentElement, observation); + observed.clear(); + for (const element of wanted) { + resize?.observe(element); + observer.observe(element, { attributes: true, attributeFilter: ['style', 'class'] }); + observed.add(element); + } + } + if (changed) win.dispatchEvent(new Event('nativeUIShellRefresh')); + if ( + top && + (pointerActive || + Array.from(wanted).some((element) => element.getAnimations().some((animation) => animation.playState === 'running'))) + ) + schedule(); + }; + const schedule = () => { + if (!stopped && !frame) frame = win.requestAnimationFrame(refresh); + }; + const resize = win.ResizeObserver ? new win.ResizeObserver(schedule) : undefined; + const observer = new MutationObserver(schedule); + const observation = { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style'] }; + observer.observe(doc.documentElement, observation); + for (const name of ['ionModalWillPresent', 'ionModalDidPresent', 'ionModalWillDismiss', 'ionModalDidDismiss', 'ionBreakpointDidChange']) { + doc.addEventListener( + name, + (event) => { + const modal = event.target as HTMLIonModalElement; + if (modal.localName !== 'ion-modal') return; + if (name === 'ionModalDidDismiss') { + presenting.delete(modal); + dismissed.add(modal); + } else { + presenting.add(modal); + dismissed.delete(modal); + } + refresh(); + }, + { capture: true, signal: listeners.signal }, + ); + } + doc.addEventListener( + 'pointerdown', + (event) => { + if ((event.target as Element).closest('ion-modal')) { + pointerActive = true; + schedule(); + } + }, + { capture: true, signal: listeners.signal }, + ); + for (const name of ['pointerup', 'pointercancel']) + doc.addEventListener( + name, + () => { + pointerActive = false; + schedule(); + }, + { capture: true, signal: listeners.signal }, + ); + win.addEventListener('resize', schedule, { signal: listeners.signal }); + win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal }); + refresh(); + return () => { + stopped = true; + win.cancelAnimationFrame(frame); + observer.disconnect(); + resize?.disconnect(); + listeners.abort(); + if (states.get(doc) !== state) return; + states.delete(doc); + doc.querySelectorAll(`.${modalRailClass}`).forEach((modal) => { + modal.classList.remove(modalRailClass); + modal.style.removeProperty(toolbarTop); + }); + }; +}; + +/** Visible modal bounds, clipped at the viewport for Ionic's translated sheets. */ +export const modalVerticalBarFrame = (modal: Element): Frame | undefined => { + const rect = modal.shadowRoot?.querySelector('[part~="content"]')?.getBoundingClientRect(); + const win = modal.ownerDocument.defaultView!; + if (!rect) return; + const y = Math.max(0, rect.top); + const height = Math.min(win.innerHeight, rect.bottom) - y; + return height > 0 ? { x: 0, y, width: win.innerWidth, height } : undefined; +}; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 62feed41..8d86327a 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -1,3 +1,4 @@ +import { inVerticalBarsSurface, topModal } from './shared/modal'; import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration'; import { @@ -74,11 +75,13 @@ export const createVerticalBarsWebProjection = ( }); const inEligibleToolbar = (element: HTMLElement) => !!verticalBarsRoot()?.contains(element) && + inVerticalBarsSurface(element) && verticalBarsActionCandidate(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !verticalBarsPages.isDeparted(element); const isEligibleBack = (element: HTMLIonBackButtonElement) => !!verticalBarsRoot()?.contains(element) && + inVerticalBarsSurface(element) && verticalBarsOwned(element) && verticalBarsBackCandidate(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && @@ -151,7 +154,10 @@ export const createVerticalBarsWebProjection = ( const syncAction = (target: HTMLElement, original: HTMLElement) => { copyAttributes(target, original); target.classList.add('ios-theme-vertical-bars-toolbar-action', 'ion-cloned-element'); - const label = original.getAttribute('aria-label') ?? original.textContent?.trim(); + const label = + original.getAttribute('aria-label') ?? + original.shadowRoot?.querySelector('[part~=native]')?.getAttribute('aria-label') ?? + original.textContent?.trim(); if (label) target.setAttribute('aria-label', label); if ('disabled' in original) (target as HTMLIonButtonElement).disabled = (original as HTMLIonButtonElement).disabled; if (original.matches('ion-button')) { @@ -185,7 +191,7 @@ export const createVerticalBarsWebProjection = ( } }; const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => { - root = verticalBarsRoot()!; + root = topModal(doc) ?? verticalBarsRoot()!; let topOffset = 0; if (nextBack) { backSource = nextBack; @@ -269,7 +275,7 @@ export const createVerticalBarsWebProjection = ( const nextBack = findBack(); const groups = findToolbarGroups(); if (!nextBack && !groups.length) return restore(); - if (sameSources(nextBack, groups)) return syncExisting(); + if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) return syncExisting(); restore(); project(nextBack, groups); updates++; @@ -336,6 +342,7 @@ export const createVerticalBarsWebProjection = ( 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 }); + win.addEventListener('resize', schedule, { signal: listeners.signal }); win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal }); schedule(); diff --git a/src/sheets-of-glass/animations.ts b/src/sheets-of-glass/animations.ts index 131cb1d4..2f8183bf 100644 --- a/src/sheets-of-glass/animations.ts +++ b/src/sheets-of-glass/animations.ts @@ -80,15 +80,16 @@ export const createTabBarPressAnimation = ( const start = (fromBox.left + fromBox.width / 2 - box.left) / parentScale; const end = (toBox.left + toBox.width / 2 - box.left) / parentScale; const selected = from === to; + const growth = vertical ? 20 / 16 : 1; const frames = tabBarPressFrames.map(([time, position, extraWidth, extraHeight, expansion]) => { - const scale = 1 + (14.14 / mainSize(bar, vertical)) * expansion; + const scale = vertical ? 1 : 1 + (14.14 / mainSize(bar, vertical)) * expansion; // A selected item expands in place, without the transfer's sideways deformation. const press = 1 - Math.exp(-18 * time) * (1 + 18 * time); return { offset: time / 0.9, x: center + (left + start + (end - start) * position - center) * scale - width / 2, - sx: ((width + (selected ? 16 * press : extraWidth)) * scale) / width, - sy: ((height + (selected ? 16 * press : extraHeight)) * scale) / height, + sx: ((width + (selected ? 16 * press : extraWidth) * growth) * scale) / width, + sy: ((height + (selected ? 16 * press : extraHeight) * growth) * scale) / height, }; }); const move = createAnimation() @@ -111,9 +112,9 @@ export const createTabBarDragAnimation = (effect: HTMLElement, bar: HTMLElement, const native = effect.shadowRoot!.querySelector('[part="native"]')!; const current = axisScale(native.style.transform || getComputedStyle(native).transform, vertical); const { width, height } = animationRect(effect, vertical); - const scale = 1 + 14.14 / mainSize(bar, vertical); - const restX = ((width + 16) * scale) / width; - const restY = ((height + 16) * scale) / height; + const scale = vertical ? 1 : 1 + 14.14 / mainSize(bar, vertical); + const restX = ((width + (vertical ? 20 : 16)) * scale) / width; + const restY = ((height + (vertical ? 20 : 16)) * scale) / height; // Approximate the 100/600pt/s UIKit samples; keep deformation bounded for fast swipes. const stretch = Math.min(16, 32 * velocity * velocity) * scale; const rebound = Math.max(stretch, (current.a - restX) * width) * 0.8; @@ -157,16 +158,19 @@ export const createTabBarReleaseAnimation = ( const center = box.left + box.width / 2; const x = center + (targetBox.left + targetBox.width / 2 - center) / (box.width / mainSize(bar, vertical)) - current.width / 2; const y = box.top + box.height / 2 - current.height / 2; + // The vertical platter contracts from 64pt items to 44 × 58pt on release. + const endScaleX = vertical ? target.offsetHeight / current.width : 1; + const endScaleY = vertical ? target.offsetWidth / current.height : 1; const elapsed = (tapElapsed ?? 0) / 1000; const duration = tapElapsed === undefined ? 450 : 800 - tapElapsed; let frames = samples.map(([time, remaining]) => ({ offset: time / 0.45, x: x + (current.x - x) * remaining, y: y + (current.y - y) * remaining, - sx: 1 + (scale.a - 1) * remaining, - sy: 1 + (scale.d - 1) * remaining, + sx: endScaleX + (scale.a - endScaleX) * remaining, + sy: endScaleY + (scale.d - endScaleY) * remaining, })); - if (tapElapsed !== undefined) { + if (tapElapsed !== undefined && !vertical) { const nextIndex = tabBarTapFrames.findIndex(([time]) => time > elapsed); const previous = tabBarTapFrames[Math.max(0, nextIndex - 1)]; const next = tabBarTapFrames[nextIndex]; @@ -221,7 +225,7 @@ export const createTabBarReleaseAnimation = ( { offset: 1, backgroundColor: selectedBackground }, ]); const release = createAnimation().duration(duration).addAnimation([stretch, move, glass, selected]); - if (tapElapsed !== undefined) { + if (tapElapsed !== undefined && !vertical) { // A short UIKit tap keeps expanding after lift-off, then settles independently of the lens. // Width deltas from the 50ms tap; preserve the rendered size for an uninterrupted handoff. const width = mainSize(bar, vertical); diff --git a/src/sheets-of-glass/index.ts b/src/sheets-of-glass/index.ts index 81157341..fe910367 100644 --- a/src/sheets-of-glass/index.ts +++ b/src/sheets-of-glass/index.ts @@ -124,6 +124,14 @@ export const registerEffect = ( document.addEventListener('pointercancel', onPointerCancel); }; targetElement.addEventListener('pointerdown', onPointerDown); + const onClick = (event: MouseEvent) => { + // A drag commits its destination itself; the browser may click the old pressed tab. + if (vertical && tabDragging && event.isTrusted && event.detail > 0) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + targetElement.addEventListener('click', onClick, true); const axisDetail = (detail: GestureDetail): GestureDetail => vertical @@ -193,6 +201,7 @@ export const registerEffect = ( String(1 + 14.14 / (vertical ? targetElement.offsetHeight : targetElement.offsetWidth)), ); } + if (vertical) targetElement.classList.add(ANIMATED_NAME); animationPosition = { minPositionX: animationRect(targetElement, vertical).left, maxPositionX: @@ -284,7 +293,20 @@ export const registerEffect = ( vertical ? previousY : currentX, vertical ? currentX : previousY, ); - const latestTouchedElement = (nextEl?.closest(effectTagName) as HTMLElement) || undefined; + let latestTouchedElement: HTMLElement | undefined = (nextEl?.closest(effectTagName) as HTMLElement) || undefined; + if (vertical && effectTagName === 'ion-tab-button') { + // Overlapping, scaled tabs must not steal a drag from their neighbor. + const bar = targetElement.getBoundingClientRect(); + const tabs = Array.from(targetElement.querySelectorAll(effectTagName)).filter((tab) => tab.offsetWidth > 0); + const distance = (tab: HTMLElement) => Math.abs(currentX - (bar.top + tab.offsetTop + tab.offsetHeight / 2)); + latestTouchedElement = + currentX >= bar.top && currentX <= bar.bottom + ? tabs.reduce( + (closest, tab) => (!closest || distance(tab) < distance(closest) ? tab : closest), + undefined, + ) + : undefined; + } if ( latestTouchedElement && @@ -346,6 +368,10 @@ export const registerEffect = ( moveAnimation.destroy(); moveAnimation = undefined; const tabs = Array.from(targetElement.querySelectorAll(effectTagName)).filter((tab) => tab.offsetWidth > 0); + if (vertical) { + currentTouchedElement.classList.remove('ion-activated'); + targetElement.classList.remove(ANIMATED_NAME); + } const releasing = createTabBarReleaseAnimation( effectElement, currentTouchedElement, @@ -386,6 +412,7 @@ export const registerEffect = ( cancelActiveGesture(); // Remove event listeners targetElement.removeEventListener('pointerdown', onPointerDown); + targetElement.removeEventListener('click', onClick, true); targetElement.removeEventListener('nativeUIShellChange', nativeChanged); // Destroy gesture diff --git a/src/styles/components/ion-tabs.scss b/src/styles/components/ion-tabs.scss index 61622ecf..f754c71a 100644 --- a/src/styles/components/ion-tabs.scss +++ b/src/styles/components/ion-tabs.scss @@ -70,11 +70,14 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { height: 54px; min-height: 54px; &:has(> ion-tab-button:nth-child(2):last-child) { - width: 180px; // 188px including the 4px inset on each side. + width: var(--ios-theme-side-tab-content-width, 180px); // 188px including the 4px inset on each side. } &:has(:nth-child(5)) { - width: calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 3px); - max-width: 543px; + width: var( + --ios-theme-side-tab-content-width, + calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 3px) + ); + max-width: var(--ios-theme-side-tab-content-width, 543px); } padding: 4px; @@ -96,7 +99,7 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { 1 of ion-tab-button:not(.ion-cloned-element) ) ) { - max-width: $width - 8px; + max-width: var(--ios-theme-side-tab-content-width, #{$width - 8px}); } } } diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 289999ef..cd55e381 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -31,8 +31,21 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { --ios-theme-vertical-bars-safe-area-right-resolved: var(--ios-theme-vertical-bars-safe-area-right, 0px); } +// Centered dialogs do not inherit the page's rail reservation. +ion-app.ios-theme-vertical-bars ion-modal:not(.ios-theme-vertical-bars-modal) { + --ios-theme-vertical-bars-safe-area-left-resolved: 0px; + --ios-theme-vertical-bars-safe-area-right-resolved: 0px; +} + +ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal { + --ios-theme-vertical-bars-modal-toolbar-top: max( + 24px, + calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) - var(--ios-theme-vertical-bars-modal-offset-top, 0px)) + ); +} + // Supply material defaults only to Web projections, never to ordinary controls. -ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready +:is(ion-app.ios-theme-vertical-bars, ion-modal.ios-theme-vertical-bars-modal).ios-theme-vertical-bars-toolbar-ready > :is(ion-menu-button, ion-button).ios-theme-vertical-bars-toolbar-projection { --ios26-glass-background-rgb: var(--ion-background-color-rgb, 255, 255, 255); --ios26-glass-border-color-rgb: var(--ion-background-color-rgb, 255, 255, 255); @@ -46,7 +59,9 @@ ion-app.ios-theme-vertical-bars .ios-transition-shade { // Keep page backgrounds and the router full width; move only their foregrounds. ion-app.ios-theme-vertical-bars - ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( + :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) + ) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -62,7 +77,9 @@ ion-app.ios-theme-vertical-bars } ion-app.ios-theme-vertical-bars - ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) { + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( + :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) + ) { --ion-safe-area-left: 0px; --ion-safe-area-right: 0px; @@ -131,26 +148,27 @@ ion-app.ios-theme-vertical-bars { ion-app.ios-theme-vertical-bars ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { - --ios-theme-side-tab-bar-width: 50px; - --ios-theme-side-tab-bar-gap: 8px; - --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-right-resolved); + // iOS 27.1 Duo: 48pt platter, 44 × 58pt items with 8pt overlap, 24pt edge gaps. + --ios-theme-side-tab-bar-width: 48px; + --ios-theme-side-tab-content-width: 44px; + --ios-theme-side-tab-bar-gap: 24px; contain: layout style; position: absolute; z-index: 2; display: flex; flex-direction: column; - width: 46px; - min-width: 46px; - max-width: 46px; + width: 44px; + min-width: 44px; + max-width: 44px; height: fit-content; min-height: 0; padding: 2px; - border-radius: 25px; + border-radius: 24px; left: auto; - right: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); + right: calc(24px + (48px - var(--ios-theme-side-tab-bar-width)) / 2); margin: 0; top: auto; - bottom: calc(var(--ion-safe-area-bottom, 0px) + var(--ios-theme-side-tab-bar-gap)); + bottom: var(--ios-theme-side-tab-bar-gap); &.ios27-enable-gesture { touch-action: pan-x pinch-zoom; @@ -159,14 +177,15 @@ ion-app.ios-theme-vertical-bars > ion-tab-button:not(.ion-cloned-element) { display: block; flex: none; - width: 46px; - min-width: 46px; - height: 52px; - min-height: 52px; - border-radius: 23px; + width: 44px; + min-width: 44px; + height: 58px; + min-height: 58px; + border-radius: 22px; &::part(native) { justify-content: center; - min-height: 52px; + min-height: 58px; + padding: 0; } ion-label { position: absolute; @@ -184,32 +203,54 @@ ion-app.ios-theme-vertical-bars font-size: 28px; } } + > ion-tab-button.ion-activated { + transform: scale(1.16); + } > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { - margin-block-start: 0; + margin-block-start: -8px; margin-inline-start: 0; } - &:has(> ion-tab-button:is(.ion-activated, :active)) > ion-tab-button:not(.ion-cloned-element) { + // Duo's expanded platter keeps its center and reveals labels without shrinking icons. + &:is(.ios27-animated, :has(> ion-tab-button:is(.ion-activated, :active))) { + --ios-theme-side-tab-bar-width: 72px; + --ios-theme-side-tab-content-width: 64px; + --ios-theme-side-tab-bar-gap: 20px; + width: 64px; + min-width: 64px; + max-width: 64px; + padding: 4px; + border-radius: 36px; + transform: none; + -webkit-transform: none; + } + &:is(.ios27-animated, :has(> ion-tab-button:is(.ion-activated, :active))) > ion-tab-button:not(.ion-cloned-element) { + width: 64px; + min-width: 64px; + height: 64px; + min-height: 64px; + border-radius: 32px; &::part(native) { flex-direction: column; - padding-block: 4px; + min-height: 64px; + padding-block: 11px; } ion-label { position: static; width: auto; height: auto; - max-width: 44px; + max-width: 64px; padding: 0; margin: 1px 0 0; overflow: hidden; clip: auto; - font-size: 0.5rem; - line-height: 1; + font-size: 10px; + line-height: 12px; text-overflow: ellipsis; white-space: nowrap; } ion-icon { margin: 0; - font-size: 22px; + font-size: 28px; } } } @@ -217,9 +258,8 @@ ion-app.ios-theme-vertical-bars ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left ion-tabs:not(:where(ion-menu *, ion-modal *, ion-popover *)) > ion-tab-bar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled) { - --ios-theme-side-safe-area: var(--ios-theme-vertical-bars-safe-area-left-resolved); right: auto; - left: max(4px, calc((var(--ios-theme-side-safe-area) - var(--ios-theme-side-tab-bar-width)) / 2)); + left: calc(24px + (48px - var(--ios-theme-side-tab-bar-width)) / 2); } .ios-theme-native-ui-shell-prehidden { @@ -256,8 +296,11 @@ html.ios-theme-native-ui-shell-prehide visibility: hidden !important; } -ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { - ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready, +ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-theme-vertical-bars-toolbar-ready { + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( + :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) + ) ion-back-button:is(.ios, .md)[data-native-ui-shell]:not( .ios-theme-disabled, .ios26-disabled, @@ -265,7 +308,9 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { ) { display: none; } - ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( + :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) + ) ion-buttons:is(.ios, .md) > :is(ion-button, ion-menu-button):is(.ios, .md)[data-native-ui-shell]:not( .ios-theme-disabled, @@ -274,7 +319,9 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { ) { display: none; } - ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( + :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) + ) ion-buttons:is(.ios, .md):has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) ) { @@ -283,7 +330,7 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { > ion-back-button.ios-theme-vertical-bars-back-button-projection { position: fixed; z-index: 1001; - top: var(--ios-theme-vertical-bars-toolbar-top, 220px); + top: var(--ios-theme-vertical-bars-modal-toolbar-top, var(--ios-theme-vertical-bars-toolbar-top, 220px)); right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); display: block; width: 46px; @@ -293,7 +340,10 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { position: fixed; z-index: 1001; - top: calc(var(--ios-theme-vertical-bars-toolbar-top, 220px) + var(--ios-theme-vertical-bars-toolbar-offset, 0px)); + top: calc( + var(--ios-theme-vertical-bars-modal-toolbar-top, var(--ios-theme-vertical-bars-toolbar-top, 220px)) + + var(--ios-theme-vertical-bars-toolbar-offset, 0px) + ); right: max(4px, calc((var(--ios-theme-vertical-bars-safe-area-right-resolved) - 46px) / 2)); width: 46px; min-width: 46px; @@ -312,10 +362,12 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { border-radius: 50%; } > ion-button:is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { - --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); + &:not(.button-solid):not(.button-outline) { + --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); + } --border-radius: 50%; @include glass.light-shadow('--box-shadow'); - &::part(native) { + &:not(.button-outline)::part(native) { @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); } } @@ -338,7 +390,8 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready { } } -ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left, +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left ion-modal.ios-theme-vertical-bars-modal { > ion-back-button.ios-theme-vertical-bars-back-button-projection, > :is(.ios, .md).ios-theme-vertical-bars-toolbar-projection { right: auto; @@ -348,7 +401,7 @@ ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-left { // The lens is attached to body, outside the rail selectors. ion-tab-button.ios.ion-cloned-element.ios27-vertical-tab-effect { - border-radius: 23px; + border-radius: 32px; &::part(native) { min-height: 0; height: 100%;