Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4925,6 +4925,197 @@ test('horizontal tabs retain measured native width on Web handoff without feedin
expect((await bar.boundingBox())!.width).toBeCloseTo(337, 0);
});

for (const native of [true, false]) {
for (const grouped of [true, false]) {
test(`covered rail buttons remain Web-projected behind modals: native=${native}, grouped=${grouped}`, async ({ page }) => {
await page.setViewportSize({ width: grouped ? 1100 : 700, height: 900 });
await page.route('**/styles.css', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
});
await mockNative(page, !native);
await page.goto('/main/index/modal');
await page
.locator('app-modal ion-toolbar')
.first()
.evaluate((toolbar, grouped) => {
const group = document.createElement('ion-buttons');
group.slot = 'end';
for (const label of grouped ? ['Covered action', 'Disabled action'] : ['Covered action']) {
const button = document.createElement('ion-button');
button.setAttribute('aria-label', label);
if (label === 'Covered action') button.id = 'covered-action';
button.disabled = label === 'Disabled action';
button.innerHTML = '<ion-icon name="add" slot="icon-only"></ion-icon>';
button.addEventListener('click', () => toolbar.setAttribute('data-activated', label));
group.append(button);
}
toolbar.append(group);
}, grouped);
await page.locator('ion-app').evaluate((app, grouped) => {
app.dir = grouped ? 'ltr' : 'rtl';
app.classList.add('ios-theme-vertical-bars');
app.classList.toggle('ios-theme-vertical-bars-left', !grouped);
}, grouped);
const source = page.locator('#covered-action');
const projection = page
.locator('ion-app > .ios-theme-vertical-bars-toolbar-projection')
.filter({ has: page.locator('[aria-label="Covered action"]') });
const action = page.locator(
'ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label="Covered action"], ion-app > ion-buttons.ios-theme-vertical-bars-toolbar-projection > ion-button[aria-label="Covered action"]',
);
let before;
if (native) {
await expect(grouped ? source.locator('..') : source).toHaveAttribute('data-native-ui-projected', '');
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<NativeUIMock>('NativeUI')
.updates.at(-1)
?.controls.some(
(control) =>
control.placement === 'vertical-bars' && control.items.some((item) => item.accessibilityLabel === 'Covered action'),
),
),
)
.toBe(true);
await page.evaluate(() => {
const plugin = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
plugin.geometry = {};
for (const control of plugin.updates.at(-1)!.controls) {
control.items.forEach((item, index) => {
if (item.accessibilityLabel?.includes('action'))
plugin.geometry![item.id] = {
x: document.querySelector('ion-app')!.dir === 'rtl' ? 19 : innerWidth - 65,
y: 300 + index * 46,
width: 46,
height: 46,
};
});
}
});
before = { x: grouped ? 1035 : 19, y: 300, width: 46, height: 46 };
} else {
await expect(action).toBeVisible();
before = await action.boundingBox();
}
await page.getByText('present:normal', { exact: true }).click();
const modal = page.locator('ion-modal.show-modal');
await expect(action).toBeVisible();
const surface = grouped ? projection : action;
await expect(surface).toHaveAttribute('inert', '');
await expect(surface).toHaveAttribute('aria-hidden', 'true');
await expect.poll(() => action.boundingBox()).toEqual(before);
if (grouped) await expect(projection.locator('ion-button[aria-label="Disabled action"]')).toHaveJSProperty('disabled', true);
await source.evaluate((button: HTMLIonButtonElement) => (button.disabled = true));
await expect(action).toHaveJSProperty('disabled', true);
await source.evaluate((button: HTMLIonButtonElement) => (button.disabled = false));
await expect(action).toHaveJSProperty('disabled', false);
// Even delayed or synthetic clicks cannot activate the covered page.
await action.dispatchEvent('click');
await expect(page.locator('app-modal ion-toolbar').first()).not.toHaveAttribute('data-activated');
await modal.getByText('present:normal', { exact: true }).click();
await expect(page.locator('ion-modal.show-modal')).toHaveCount(2);
await expect(action).toBeVisible();
await page
.locator('ion-modal.show-modal')
.last()
.evaluate((element: HTMLIonModalElement) => element.dismiss());
await expect(action).toBeVisible();
await page
.locator('ion-modal.show-modal')
.last()
.evaluate((element: HTMLIonModalElement) => element.dismiss());
if (native) {
await expect(action).toHaveCount(0);
await expect(grouped ? source.locator('..') : source).toHaveAttribute('data-native-ui-projected', '');
} else {
await expect(surface).not.toHaveAttribute('inert');
await action.click();
await expect(page.locator('app-modal ion-toolbar').first()).toHaveAttribute('data-activated', 'Covered action');
}
});
}
}

for (const native of [true, false]) {
test(`covered mixed automatic and explicit toolbar controls keep separate ownership: native=${native}`, async ({ page }) => {
await page.setViewportSize({ width: 1100, height: 900 });
await page.route('**/styles.css', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
});
await mockNative(page, !native);
await page.goto('/main/index/modal');
await page
.locator('app-modal ion-toolbar')
.first()
.evaluate((toolbar) => {
const icon = '<ion-icon name="add" slot="icon-only"></ion-icon>';
toolbar.insertAdjacentHTML(
'beforeend',
`
<ion-buttons slot="end" id="edge-group">
<ion-button id="edge-group-a" aria-label="Edge group A">${icon}</ion-button>
<ion-button id="edge-group-b" aria-label="Edge group B">${icon}</ion-button>
</ion-buttons>
<ion-button slot="end" id="edge-single" aria-label="Edge single">${icon}</ion-button>
<ion-buttons slot="start" id="edge-mixed">
<ion-button id="edge-mixed-auto" aria-label="Edge mixed auto">${icon}</ion-button>
<button id="edge-explicit" data-native-ui="button" aria-label="Edge explicit">Explicit</button>
</ion-buttons>`,
);
for (const button of toolbar.querySelectorAll<HTMLElement>('[id^="edge-"]:not(ion-buttons)')) {
button.setAttribute('data-clicks', '0');
button.addEventListener('click', () =>
button.setAttribute('data-clicks', String(Number(button.getAttribute('data-clicks')) + 1)),
);
}
});
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
const labels = ['Edge group A', 'Edge group B', 'Edge single', 'Edge mixed auto'];
const clone = (label: string) =>
page.locator(
`ion-app > .ios-theme-vertical-bars-toolbar-projection[aria-label="${label}"], ion-app > .ios-theme-vertical-bars-toolbar-projection > ion-button[aria-label="${label}"]`,
);
if (native) {
await expect(page.locator('#edge-group')).toHaveAttribute('data-native-ui-projected', '');
await expect(page.locator('#edge-single')).toHaveAttribute('data-native-ui-projected', '');
await expect(page.locator('#edge-mixed-auto')).toHaveAttribute('data-native-ui-projected', '');
await expect(page.locator('#edge-explicit')).toHaveAttribute('data-native-ui-projected', '');
} else for (const label of labels) await expect(clone(label)).toBeVisible();
await page.getByText('present:normal', { exact: true }).click();
await expect(page.locator('ion-modal.show-modal')).toHaveCount(1);
for (const label of labels) {
await expect(clone(label)).toHaveCount(1);
await expect(clone(label)).toBeVisible();
expect(await clone(label).evaluate((element) => !!element.closest('[inert]'))).toBe(true);
await clone(label).dispatchEvent('click');
}
await expect(clone('Edge explicit')).toHaveCount(0);
await expect(page.locator('#edge-explicit')).not.toHaveAttribute('data-native-ui-projected');
for (const id of ['edge-group-a', 'edge-group-b', 'edge-single', 'edge-mixed-auto', 'edge-explicit'])
await expect(page.locator(`#${id}`)).toHaveAttribute('data-clicks', '0');
await page.locator('#edge-group-b').evaluate((element: HTMLIonButtonElement) => (element.disabled = true));
await expect(clone('Edge group B')).toHaveJSProperty('disabled', true);
await page.locator('ion-modal.show-modal').evaluate((modal: HTMLIonModalElement) => modal.dismiss());
if (native) {
for (const label of labels) await expect(clone(label)).toHaveCount(0);
await expect(page.locator('#edge-explicit')).toHaveAttribute('data-native-ui-projected', '');
await activate(page, 'Edge group A', true);
await activate(page, 'Edge single', true);
await activate(page, 'Edge mixed auto', true);
await activate(page, 'Edge explicit', true);
} else {
for (const label of labels.filter((label) => label !== 'Edge group B')) await clone(label).click();
await page.locator('#edge-explicit').click();
}
for (const id of ['edge-group-a', 'edge-single', 'edge-mixed-auto', 'edge-explicit'])
await expect(page.locator(`#${id}`)).toHaveAttribute('data-clicks', '1');
await expect(page.locator('#edge-group-b')).toHaveAttribute('data-clicks', '0');
});
}

test('Duo tab handoff recovers from one update failure without replaying the tab click', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui');
Expand Down
3 changes: 2 additions & 1 deletion demo/e2e/vertical-bars-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ test('stacked modals keep rail controls inside the active dialog and restore the
await expect(modals.first().locator(projection)).toBeVisible();
await modals.first().getByText('present:normal', { exact: true }).click();
await expect(modals).toHaveCount(2);
await expect(modals.first().locator(projection)).toHaveCount(0);
await expect(modals.first().locator(projection)).toBeVisible();
await expect(modals.first().locator(projection)).toHaveAttribute('inert', '');
await expect(modals.last().locator(projection)).toBeVisible();
await modals.last().getByRole('button', { name: 'Close', exact: true }).focus();
await expect(modals.last().getByRole('button', { name: 'Close', exact: true })).toBeFocused();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, paren
const frame = verticalBarFrame(source);
if (!frame) return;
const origin = parent.getBoundingClientRect();
// Absolute children use the padding edge, not the outer edge of a glass group.
Object.assign(clone.style, {
position: 'absolute',
left: `${frame.x - origin.left}px`,
top: `${frame.y - origin.top}px`,
left: `${frame.x - origin.left - parent.clientLeft}px`,
top: `${frame.y - origin.top - parent.clientTop}px`,
right: 'auto',
bottom: 'auto',
width: `${frame.width}px`,
Expand Down
Loading
Loading