Merge branch 'main' into chore/update-next-from-main

This commit is contained in:
Brandy Smith
2025-08-04 18:56:38 -04:00
124 changed files with 8289 additions and 20586 deletions

View File

@ -125,7 +125,7 @@
<ion-toolbar color="dark">
<ion-buttons slot="start">
<ion-back-button class="ion-hide"></ion-back-button>
<ion-back-button class="ion-display-none"></ion-back-button>
</ion-buttons>
<ion-title>Hidden</ion-title>
</ion-toolbar>

View File

@ -22,15 +22,15 @@ export type DatetimePresentation = 'date-time' | 'time-date' | 'date' | 'time' |
export type TitleSelectedDatesFormatter = (selectedDates: string[]) => string;
export type DatetimeHighlightStyle =
| {
textColor: string;
backgroundColor?: string;
}
| {
textColor?: string;
backgroundColor: string;
};
/**
* DatetimeHighlightStyle must include textColor, backgroundColor, or border.
* It cannot be an empty object.
*/
export type DatetimeHighlightStyle = {
textColor?: string;
backgroundColor?: string;
border?: string;
} & ({ textColor: string } | { backgroundColor: string } | { border: string });
export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;

View File

@ -2343,6 +2343,7 @@ export class Datetime implements ComponentInterface {
`${dateStyle ? dateStyle.backgroundColor : ''}`,
'important'
);
el.style.setProperty('border', `${dateStyle ? dateStyle.border : ''}`, 'important');
}
}}
tabindex="-1"

View File

@ -5,6 +5,8 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('datetime: custom'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/datetime/test/custom`, config);
await page.locator('.datetime-ready').last().waitFor();
});
test('should allow styling wheel style datetimes', async ({ page }) => {
@ -30,6 +32,13 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test('should allow styling calendar days in grid style datetimes', async ({ page }) => {
const datetime = page.locator('#custom-calendar-days');
// Wait for calendar days to be rendered
await page.waitForFunction(() => {
const datetime = document.querySelector('#custom-calendar-days');
const calendarDays = datetime?.shadowRoot?.querySelectorAll('.calendar-day');
return calendarDays && calendarDays.length > 0;
});
await expect(datetime).toHaveScreenshot(screenshot(`datetime-custom-calendar-days`));
});
});

View File

@ -164,7 +164,7 @@
const customDatetime = document.querySelector('#custom-calendar-days');
// Mock the current day to always have the same screenshots
const mockToday = '2023-06-10T16:22';
const mockToday = '2023-06-10T16:22:00.000Z';
Date = class extends Date {
constructor(...args) {
if (args.length === 0) {

View File

@ -22,11 +22,23 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(monthYearToggle).toContainText('January 2022');
// Click to open the picker
await monthYearToggle.click();
await page.waitForChanges();
// February
await monthColumnItems.nth(1).click();
// Wait for the picker to be open
await page.locator('.month-year-picker-open').waitFor();
// Wait a bit for the picker to fully load
await page.waitForTimeout(200);
const ionChange = await page.spyOnEvent('ionChange');
// Click on February
await monthColumnItems.filter({ hasText: 'February' }).click();
// Wait for changes
await ionChange.next();
await page.waitForChanges();
await expect(monthYearToggle).toContainText('February 2022');
@ -38,13 +50,23 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
const datetime = page.locator('ion-datetime');
const ionChange = await page.spyOnEvent('ionChange');
// Click to open the picker
await monthYearToggle.click();
await page.waitForChanges();
// February
await monthColumnItems.nth(1).click();
// Wait for the picker to be open
await page.locator('.month-year-picker-open').waitFor();
// Wait a bit for the picker to fully load
await page.waitForTimeout(200);
// Click on February
await monthColumnItems.filter({ hasText: 'February' }).click();
// Wait for changes
await ionChange.next();
await page.waitForChanges();
await expect(ionChange).toHaveReceivedEventTimes(1);
await expect(datetime).toHaveJSProperty('value', '2022-02-28');
});

View File

@ -21,16 +21,19 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
date: '2023-01-01', // ensure selected date style overrides highlight
textColor: '#800080',
backgroundColor: '#ffc0cb',
border: '2px solid purple',
},
{
date: '2023-01-02',
textColor: '#b22222',
backgroundColor: '#fa8072',
border: '2px solid purple',
},
{
date: '2023-01-03',
textColor: '#0000ff',
backgroundColor: '#add8e6',
border: '2px solid purple',
},
];
});
@ -52,6 +55,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
return {
textColor: '#b22222',
backgroundColor: '#fa8072',
border: '2px solid purple',
};
}
@ -59,6 +63,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
return {
textColor: '#800080',
backgroundColor: '#ffc0cb',
border: '2px solid purple',
};
}
@ -66,6 +71,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
return {
textColor: '#0000ff',
backgroundColor: '#add8e6',
border: '2px solid purple',
};
}
@ -77,7 +83,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
await expect(datetime).toHaveScreenshot(screenshot(`datetime-highlightedDates-callback`));
});
test('should render highlights correctly when only using one color or the other', async ({ page }) => {
test('should render highlights correctly when only using only one color property', async ({ page }) => {
const datetime = page.locator('ion-datetime');
await datetime.evaluate((el: HTMLIonDatetimeElement) => {
@ -90,6 +96,10 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
date: '2023-01-03',
textColor: '#0000ff',
},
{
date: '2023-01-04',
border: '2px solid purple',
},
];
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -78,6 +78,10 @@
textColor: 'blue',
backgroundColor: 'lightblue',
},
{
date: '2023-01-07',
border: '2px dotted red',
},
];
document.querySelector('#withCallback').highlightedDates = (isoString) => {
@ -103,6 +107,7 @@
date: new Date().toISOString().split('T')[0],
textColor: 'purple',
backgroundColor: 'pink',
border: '2px solid purple',
},
];
</script>

View File

@ -207,6 +207,7 @@ export const getHighlightStyles = (
return {
textColor: matchingHighlight.textColor,
backgroundColor: matchingHighlight.backgroundColor,
border: matchingHighlight.border,
} as DatetimeHighlightStyle;
}
} else {

View File

@ -2,7 +2,7 @@
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Infinite Scroll - Basic</title>
<title>Infinite Scroll - Top</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
@ -18,7 +18,7 @@
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Infinite Scroll - Basic</ion-title>
<ion-title>Infinite Scroll - Top</ion-title>
</ion-toolbar>
</ion-header>
@ -28,9 +28,9 @@
</ion-infinite-scroll-content>
</ion-infinite-scroll>
<button onclick="toggleInfiniteScroll()" class="expand">Toggle InfiniteScroll</button>
<div id="list"></div>
<ion-list id="list"></ion-list>
<button onclick="toggleInfiniteScroll()" class="expand">Toggle InfiniteScroll</button>
</ion-content>
</ion-app>
@ -46,17 +46,26 @@
console.log('Loading data...');
await wait(500);
infiniteScroll.complete();
appendItems();
appendItems(true);
// Custom event consumed in the e2e tests
window.dispatchEvent(new CustomEvent('ionInfiniteComplete'));
console.log('Done');
});
function appendItems() {
function appendItems(newItems = false) {
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0');
for (var i = 0; i < 30; i++) {
const el = document.createElement('ion-item');
el.textContent = `${1 + i}`;
el.textContent = `Item ${1 + i}`;
if (newItems) {
el.style.borderLeft = `4px solid ${randomColor}`;
}
list.prepend(el);
}
}

View File

@ -95,6 +95,8 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
el.separators = [2, 3];
});
await page.waitForChanges();
await expect(await hasSeparatorAfter(page, 0)).toBe(false);
await expect(await hasSeparatorAfter(page, 1)).toBe(true);
await expect(await hasSeparatorAfter(page, 2)).toBe(true);

View File

@ -84,7 +84,7 @@
}
function initGroup(group) {
var groupEl = document.getElementById(group.id);
groupEl.addEventListener('ionItemReorder', function (ev) {
groupEl.addEventListener('ionReorderEnd', function (ev) {
ev.detail.complete();
});
var groupItems = [];

View File

@ -18,7 +18,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const heading = page.locator('ion-menu h1');
await expect(heading).toHaveText('Open Menu');
const results = await new AxeBuilder({ page }).analyze();
/**
* Disable the 'scrollable-region-focusable' rule because this test
* is missing the required `ion-app` wrapper component. The `ion-app`
* wrapper provides the necessary focus management that allows the
* menu content to be focusable.
*/
const results = await new AxeBuilder({ page }).disableRules('scrollable-region-focusable').analyze();
expect(results.violations).toEqual([]);
});
});

View File

@ -135,6 +135,7 @@ const renderProgress = (value: number, buffer: number) => {
* When finalBuffer === 1, we use display: none
* instead of removing the element to avoid flickering.
*/
// TODO(FW-6697): change `ion-hide` class to `ion-display-none` or another class
<div
class={{ 'buffer-circles-container': true, 'ion-hide': finalBuffer === 1 }}
style={{ transform: `translateX(${finalBuffer * 100}%)` }}

View File

@ -1,10 +1,33 @@
// TODO(FW-6590): Remove this once the deprecated event is removed
export interface ItemReorderEventDetail {
from: number;
to: number;
complete: (data?: boolean | any[]) => any;
}
// TODO(FW-6590): Remove this once the deprecated event is removed
export interface ItemReorderCustomEvent extends CustomEvent {
detail: ItemReorderEventDetail;
target: HTMLIonReorderGroupElement;
}
export interface ReorderMoveEventDetail {
from: number;
to: number;
}
export interface ReorderEndEventDetail {
from: number;
to: number;
complete: (data?: boolean | any[]) => any;
}
export interface ReorderMoveCustomEvent extends CustomEvent {
detail: ReorderMoveEventDetail;
target: HTMLIonReorderGroupElement;
}
export interface ReorderEndCustomEvent extends CustomEvent {
detail: ReorderEndEventDetail;
target: HTMLIonReorderGroupElement;
}

View File

@ -6,8 +6,9 @@ import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from
import { getIonTheme } from '../../global/ionic-global';
import type { Gesture, GestureDetail } from '../../interface';
import type { HTMLStencilElement } from '../../utils/element-interface';
import type { ItemReorderEventDetail } from './reorder-group-interface';
import type { ItemReorderEventDetail, ReorderMoveEventDetail, ReorderEndEventDetail } from './reorder-group-interface';
// TODO(FW-2832): types
@ -42,7 +43,7 @@ export class ReorderGroup implements ComponentInterface {
@State() state = ReorderGroupState.Idle;
@Element() el!: HTMLElement;
@Element() el!: HTMLStencilElement;
/**
* If `true`, the reorder will be hidden.
@ -55,12 +56,35 @@ export class ReorderGroup implements ComponentInterface {
}
}
// TODO(FW-6590): Remove this in a major release.
/**
* Event that needs to be listened to in order to complete the reorder action.
* @deprecated Use `ionReorderEnd` instead. If you are accessing
* `event.detail.from` or `event.detail.to` and relying on them
* being different you should now add checks as they are always emitted
* in `ionReorderEnd`, even when they are the same.
*/
@Event() ionItemReorder!: EventEmitter<ItemReorderEventDetail>;
/**
* Event that is emitted when the reorder gesture starts.
*/
@Event() ionReorderStart!: EventEmitter<void>;
/**
* Event that is emitted as the reorder gesture moves.
*/
@Event() ionReorderMove!: EventEmitter<ReorderMoveEventDetail>;
/**
* Event that is emitted when the reorder gesture ends.
* The from and to properties are always available, regardless of
* if the reorder gesture moved the item. If the item did not change
* from its start position, the from and to properties will be the same.
* Once the event has been emitted, the `complete()` method then needs
* to be called in order to finalize the reorder action.
*/
@Event() ionItemReorder!: EventEmitter<ItemReorderEventDetail>;
@Event() ionReorderEnd!: EventEmitter<ReorderEndEventDetail>;
async connectedCallback() {
const contentEl = findClosestIonContent(this.el);
@ -92,7 +116,8 @@ export class ReorderGroup implements ComponentInterface {
}
/**
* Completes the reorder operation. Must be called by the `ionItemReorder` event.
* Completes the reorder operation. Must be called by the `ionReorderEnd` event.
*
* If a list of items is passed, the list will be reordered and returned in the
* proper order.
*
@ -132,7 +157,7 @@ export class ReorderGroup implements ComponentInterface {
const heights = this.cachedHeights;
heights.length = 0;
const el = this.el;
const children: any = el.children;
const children: any = el.__children;
if (!children || children.length === 0) {
return;
}
@ -167,6 +192,8 @@ export class ReorderGroup implements ComponentInterface {
item.classList.add(ITEM_REORDER_SELECTED);
hapticSelectionStart();
this.ionReorderStart.emit();
}
private onMove(ev: GestureDetail) {
@ -183,6 +210,7 @@ export class ReorderGroup implements ComponentInterface {
const currentY = Math.max(top, Math.min(ev.currentY, bottom));
const deltaY = scroll + currentY - ev.startY;
const normalizedY = currentY - top;
const fromIndex = this.lastToIndex;
const toIndex = this.itemIndexForTop(normalizedY);
if (toIndex !== this.lastToIndex) {
const fromIndex = indexForItem(selectedItem);
@ -194,6 +222,11 @@ export class ReorderGroup implements ComponentInterface {
// Update selected item position
selectedItem.style.transform = `translateY(${deltaY}px)`;
this.ionReorderMove.emit({
from: fromIndex,
to: toIndex,
});
}
private onEnd() {
@ -210,6 +243,7 @@ export class ReorderGroup implements ComponentInterface {
if (toIndex === fromIndex) {
this.completeReorder();
} else {
// TODO(FW-6590): Remove this once the deprecated event is removed
this.ionItemReorder.emit({
from: fromIndex,
to: toIndex,
@ -218,12 +252,18 @@ export class ReorderGroup implements ComponentInterface {
}
hapticSelectionEnd();
this.ionReorderEnd.emit({
from: fromIndex,
to: toIndex,
complete: this.completeReorder.bind(this),
});
}
private completeReorder(listOrReorder?: boolean | any[]): any {
const selectedItemEl = this.selectedItemEl;
if (selectedItemEl && this.state === ReorderGroupState.Complete) {
const children = this.el.children as any;
const children: any = this.el.__children;
const len = children.length;
const toIndex = this.lastToIndex;
const fromIndex = indexForItem(selectedItemEl);
@ -273,7 +313,7 @@ export class ReorderGroup implements ComponentInterface {
/********* DOM WRITE ********* */
private reorderMove(fromIndex: number, toIndex: number) {
const itemHeight = this.selectedItemHeight;
const children = this.el.children;
const children: any = this.el.__children;
for (let i = 0; i < children.length; i++) {
const style = (children[i] as any).style;
let value = '';

View File

@ -122,8 +122,25 @@
const reorderGroup = document.getElementById('reorder');
reorderGroup.disabled = !reorderGroup.disabled;
// TODO(FW-6590): Remove this once the deprecated event is removed
reorderGroup.addEventListener('ionItemReorder', ({ detail }) => {
console.log('Dragged from index', detail.from, 'to', detail.to);
console.log('ionItemReorder: Dragged from index', detail.from, 'to', detail.to);
});
reorderGroup.addEventListener('ionReorderStart', () => {
console.log('ionReorderStart');
});
reorderGroup.addEventListener('ionReorderMove', ({ detail }) => {
console.log('ionReorderMove: Dragged from index', detail.from, 'to', detail.to);
});
reorderGroup.addEventListener('ionReorderEnd', ({ detail }) => {
if (detail.from !== detail.to) {
console.log('ionReorderEnd: Dragged from index', detail.from, 'to', detail.to);
} else {
console.log('ionReorderEnd: No position change occurred');
}
detail.complete();
});

View File

@ -14,7 +14,7 @@
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body onLoad="render()">
<body>
<ion-app>
<ion-header>
<ion-toolbar>
@ -24,7 +24,7 @@
<ion-content id="content">
<ion-list>
<ion-reorder-group id="reorderGroup" disabled="false">
<ion-reorder-group disabled="false">
<!-- items will be inserted here -->
</ion-reorder-group>
</ion-list>
@ -36,27 +36,44 @@
for (var i = 0; i < 30; i++) {
items.push(i + 1);
}
const reorderGroup = document.getElementById('reorderGroup');
function render() {
let html = '';
for (let item of items) {
html += `
<ion-item>
<ion-label>${item}</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>`;
}
reorderGroup.innerHTML = html;
}
reorderGroup.addEventListener('ionItemReorder', ({ detail }) => {
console.log('Dragged from index', detail.from, 'to', detail.to);
const reorderGroup = document.querySelector('ion-reorder-group');
reorderItems(items);
reorderGroup.addEventListener('ionReorderEnd', ({ detail }) => {
// Before complete is called with the items they will remain in the
// order before the drag
console.log('Before complete', items);
// Finish the reorder and position the item in the DOM based on
// where the gesture ended. Update the items variable to the
// new order of items
items = detail.complete(items);
// Reorder the items in the DOM
reorderItems(items);
// After complete is called the items will be in the new order
console.log('After complete', items);
});
function reorderItems(items) {
reorderGroup.replaceChildren();
let reordered = '';
for (let i = 0; i < items.length; i++) {
reordered += `
<ion-item>
<ion-label>
Item ${items[i]}
</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
`;
}
reorderGroup.innerHTML = reordered;
}
</script>
</body>
</html>

View File

@ -37,9 +37,9 @@
</ion-reorder-group>
<script>
const group = document.querySelector('ion-reorder-group');
group.addEventListener('ionItemReorder', (ev) => {
group.addEventListener('ionReorderEnd', (ev) => {
ev.detail.complete();
window.dispatchEvent(new CustomEvent('ionItemReorderComplete'));
window.dispatchEvent(new CustomEvent('ionReorderComplete'));
});
</script>
</body>

View File

@ -11,24 +11,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {
const items = page.locator('ion-item');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(items.nth(1), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 1', 'Item 3', 'Item 4', 'Item 2']);
});
test('should drag and drop when ion-item wraps ion-reorder', async ({ page }) => {
const reorderHandle = page.locator('ion-reorder');
const items = page.locator('ion-item');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(reorderHandle.nth(0), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 2', 'Item 3', 'Item 4', 'Item 1']);
});

View File

@ -68,9 +68,9 @@
customElements.define('app-reorder', AppReorder);
const group = document.querySelector('ion-reorder-group');
group.addEventListener('ionItemReorder', (ev) => {
group.addEventListener('ionReorderEnd', (ev) => {
ev.detail.complete();
window.dispatchEvent(new CustomEvent('ionItemReorderComplete'));
window.dispatchEvent(new CustomEvent('ionReorderComplete'));
});
</script>
</ion-app>

View File

@ -11,24 +11,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {
const items = page.locator('app-reorder');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(items.nth(1), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 1', 'Item 3', 'Item 4', 'Item 2']);
});
test('should drag and drop when ion-item wraps ion-reorder', async ({ page }) => {
const reorderHandle = page.locator('app-reorder ion-reorder');
const items = page.locator('app-reorder');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(reorderHandle.nth(0), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 2', 'Item 3', 'Item 4', 'Item 1']);
});

View File

@ -0,0 +1,289 @@
import { expect } from '@playwright/test';
import { configs, dragElementBy, test } from '@utils/test/playwright';
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('reorder-group: events:'), () => {
test.describe('ionReorderStart', () => {
test('should emit when the reorder operation starts', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionReorderStart = await page.spyOnEvent('ionReorderStart');
await expect(ionReorderStart).toHaveReceivedEventTimes(0);
// Start the drag to verify it emits the event without having to
// actually move the item. Do not release the drag here.
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 0, undefined, undefined, false);
await page.waitForChanges();
await expect(ionReorderStart).toHaveReceivedEventTimes(1);
// Drag the reorder item further to verify it does
// not emit the event again
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 300);
await page.waitForChanges();
await expect(ionReorderStart).toHaveReceivedEventTimes(1);
});
});
test.describe('ionReorderMove', () => {
test('should emit when the reorder operation does not move the item position', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionReorderMove = await page.spyOnEvent('ionReorderMove');
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 0);
await page.waitForChanges();
expect(ionReorderMove.events.length).toBeGreaterThan(0);
// Grab the last event to verify that it is emitting
// the correct from and to positions
const lastEvent = ionReorderMove.events[ionReorderMove.events.length - 1];
expect(lastEvent?.detail.from).toBe(0);
expect(lastEvent?.detail.to).toBe(0);
});
test('should emit when the reorder operation moves the item by multiple positions', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionReorderMove = await page.spyOnEvent('ionReorderMove');
// Drag the reorder item by a lot to verify it emits the event
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 300);
await page.waitForChanges();
expect(ionReorderMove.events.length).toBeGreaterThan(0);
// Grab the last event where the from and to are different to
// verify that it is not using the gesture start position as the from
const lastDifferentEvent = ionReorderMove.events
.reverse()
.find((event) => event.detail.from !== event.detail.to);
expect(lastDifferentEvent?.detail.from).toBe(1);
expect(lastDifferentEvent?.detail.to).toBe(2);
});
});
test.describe('ionReorderEnd', () => {
test('should emit without details when the reorder operation ends without moving the item position', async ({
page,
}) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionReorderEnd = await page.spyOnEvent('ionReorderEnd');
// Drag the reorder item a little bit but not enough to
// make it switch to a different position
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 20);
await page.waitForChanges();
await expect(ionReorderEnd).toHaveReceivedEventTimes(1);
await expect(ionReorderEnd).toHaveReceivedEventDetail({ from: 0, to: 0, complete: undefined });
});
test('should emit with details when the reorder operation ends and the item has moved', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionReorderEnd = await page.spyOnEvent('ionReorderEnd');
// Start the drag to verify it does not emit the event at the start
// of the drag or during the drag. Do not release the drag here.
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 100, undefined, undefined, false);
await page.waitForChanges();
await expect(ionReorderEnd).toHaveReceivedEventTimes(0);
// Drag the reorder item further and release the drag to verify it emits the event
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 300);
await page.waitForChanges();
await expect(ionReorderEnd).toHaveReceivedEventTimes(1);
await expect(ionReorderEnd).toHaveReceivedEventDetail({ from: 0, to: 2, complete: undefined });
});
});
// TODO(FW-6590): Remove this once the deprecated event is removed
test.describe('ionItemReorder', () => {
test('should not emit when the reorder operation ends without moving the item position', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionItemReorder = await page.spyOnEvent('ionItemReorder');
// Drag the reorder item a little bit but not enough to
// make it switch to a different position
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 20);
await page.waitForChanges();
await expect(ionItemReorder).toHaveReceivedEventTimes(0);
});
test('should emit when the reorder operation ends and the item has moved', async ({ page }) => {
await page.setContent(
`
<ion-reorder-group disabled="false">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-reorder slot="end"></ion-reorder>
</ion-item>
</ion-reorder-group>
`,
config
);
const reorderGroup = page.locator('ion-reorder-group');
const ionItemReorder = await page.spyOnEvent('ionItemReorder');
// Start the drag to verify it does not emit the event at the start
// of the drag or during the drag. Do not release the drag here.
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 100, undefined, undefined, false);
await page.waitForChanges();
await expect(ionItemReorder).toHaveReceivedEventTimes(0);
// Drag the reorder item further and release the drag to verify it emits the event
await dragElementBy(reorderGroup.locator('ion-reorder').first(), page, 0, 300);
await page.waitForChanges();
await expect(ionItemReorder).toHaveReceivedEventTimes(1);
await expect(ionItemReorder).toHaveReceivedEventDetail({ from: 0, to: 2, complete: undefined });
});
});
});
});

View File

@ -57,9 +57,9 @@
<script>
const group = document.querySelector('ion-reorder-group');
group.addEventListener('ionItemReorder', (ev) => {
group.addEventListener('ionReorderEnd', (ev) => {
ev.detail.complete();
window.dispatchEvent(new CustomEvent('ionItemReorderComplete'));
window.dispatchEvent(new CustomEvent('ionReorderComplete'));
});
</script>
</body>

View File

@ -11,24 +11,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {
const items = page.locator('ion-item');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(items.nth(1), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 1', 'Item 3', 'Item 4', 'Item 2']);
});
test('should drag and drop when ion-item wraps ion-reorder', async ({ page }) => {
const reorderHandle = page.locator('ion-reorder');
const items = page.locator('ion-item');
const ionItemReorderComplete = await page.spyOnEvent('ionItemReorderComplete');
const ionReorderComplete = await page.spyOnEvent('ionReorderComplete');
await expect(items).toContainText(['Item 1', 'Item 2', 'Item 3', 'Item 4']);
await dragElementBy(reorderHandle.nth(0), page, 0, 300);
await ionItemReorderComplete.next();
await ionReorderComplete.next();
await expect(items).toContainText(['Item 2', 'Item 3', 'Item 4', 'Item 1']);
});

View File

@ -1,10 +1,6 @@
import type { AnimationBuilder, ComponentProps } from '../../../interface';
import type { AnimationBuilder, ComponentProps, HTMLStencilElement } from '../../../interface';
import type { NavigationHookCallback } from '../../route/route-interface';
export interface HTMLStencilElement extends HTMLElement {
componentOnReady(): Promise<this>;
}
export interface NavOutlet {
setRouteId(
id: string,

View File

@ -71,7 +71,27 @@ export class Tabs implements NavOutlet {
componentWillRender() {
const tabBar = this.el.querySelector('ion-tab-bar');
if (tabBar) {
const tab = this.selectedTab ? this.selectedTab.tab : undefined;
let tab = this.selectedTab ? this.selectedTab.tab : undefined;
// Fallback: if no selectedTab is set but we're using router mode,
// determine the active tab from the current URL. This works around
// timing issues in React Router integration where setRouteId may not
// be called in time for the initial render.
// TODO(FW-6724): Remove this with React Router upgrade
if (!tab && this.useRouter && typeof window !== 'undefined') {
const currentPath = window.location.pathname;
const tabButtons = this.el.querySelectorAll('ion-tab-button');
// Look for a tab button that matches the current path pattern
for (const tabButton of tabButtons) {
const tabId = tabButton.getAttribute('tab');
if (tabId && currentPath.includes(tabId)) {
tab = tabId;
break;
}
}
}
tabBar.selectedTab = tab;
}
}