mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 23:58:13 +08:00
Merge branch 'main' into chore/merge-main
This commit is contained in:
@ -49,6 +49,7 @@ export class InputOTP implements ComponentInterface {
|
||||
|
||||
@State() private inputValues: string[] = [];
|
||||
@State() hasFocus = false;
|
||||
@State() private previousInputValues: string[] = [];
|
||||
|
||||
/**
|
||||
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
|
||||
@ -337,6 +338,7 @@ export class InputOTP implements ComponentInterface {
|
||||
});
|
||||
// Update the value without emitting events
|
||||
this.value = this.inputValues.join('');
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -526,19 +528,12 @@ export class InputOTP implements ComponentInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation and input for the OTP component.
|
||||
* Handles keyboard navigation for the OTP component.
|
||||
*
|
||||
* Navigation:
|
||||
* - Backspace: Clears current input and moves to previous box if empty
|
||||
* - Arrow Left/Right: Moves focus between input boxes
|
||||
* - Tab: Allows normal tab navigation between components
|
||||
*
|
||||
* Input Behavior:
|
||||
* - Validates input against the allowed pattern
|
||||
* - When entering a key in a filled box:
|
||||
* - Shifts existing values right if there is room
|
||||
* - Updates the value of the input group
|
||||
* - Prevents default behavior to avoid automatic focus shift
|
||||
*/
|
||||
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
|
||||
const { length } = this;
|
||||
@ -596,34 +591,32 @@ export class InputOTP implements ComponentInterface {
|
||||
// Let all tab events proceed normally
|
||||
return;
|
||||
}
|
||||
|
||||
// If the input box contains a value and the key being
|
||||
// entered is a valid key for the input box update the value
|
||||
// and shift the values to the right if there is room.
|
||||
if (this.inputValues[index] && this.validKeyPattern.test(event.key)) {
|
||||
if (!this.inputValues[length - 1]) {
|
||||
for (let i = length - 1; i > index; i--) {
|
||||
this.inputValues[i] = this.inputValues[i - 1];
|
||||
this.inputRefs[i].value = this.inputValues[i] || '';
|
||||
}
|
||||
}
|
||||
this.inputValues[index] = event.key;
|
||||
this.inputRefs[index].value = event.key;
|
||||
this.updateValue(event);
|
||||
|
||||
// Prevent default to avoid the browser from
|
||||
// automatically moving the focus to the next input
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes all input scenarios for each input box.
|
||||
*
|
||||
* This function manages:
|
||||
* 1. Autofill handling
|
||||
* 2. Input validation
|
||||
* 3. Full selection replacement or typing in an empty box
|
||||
* 4. Inserting in the middle with available space (shifting)
|
||||
* 5. Single character replacement
|
||||
*/
|
||||
private onInput = (index: number) => (event: InputEvent) => {
|
||||
const { length, validKeyPattern } = this;
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
const previousValue = this.previousInputValues[index] || '';
|
||||
|
||||
// If the value is longer than 1 character (autofill), split it into
|
||||
// characters and filter out invalid ones
|
||||
if (value.length > 1) {
|
||||
// 1. Autofill handling
|
||||
// If the length of the value increases by more than 1 from the previous
|
||||
// value, treat this as autofill. This is to prevent the case where the
|
||||
// user is typing a single character into an input box containing a value
|
||||
// as that will trigger this function with a value length of 2 characters.
|
||||
const isAutofill = value.length - previousValue.length > 1;
|
||||
if (isAutofill) {
|
||||
// Distribute valid characters across input boxes
|
||||
const validChars = value
|
||||
.split('')
|
||||
.filter((char) => validKeyPattern.test(char))
|
||||
@ -640,8 +633,10 @@ export class InputOTP implements ComponentInterface {
|
||||
});
|
||||
}
|
||||
|
||||
// Update the value of the input group and emit the input change event
|
||||
this.value = validChars.join('');
|
||||
for (let i = 0; i < length; i++) {
|
||||
this.inputValues[i] = validChars[i] || '';
|
||||
this.inputRefs[i].value = validChars[i] || '';
|
||||
}
|
||||
this.updateValue(event);
|
||||
|
||||
// Focus the first empty input box or the last input box if all boxes
|
||||
@ -652,23 +647,85 @@ export class InputOTP implements ComponentInterface {
|
||||
this.inputRefs[nextIndex]?.focus();
|
||||
}, 20);
|
||||
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow input if it matches the pattern
|
||||
if (value.length > 0 && !validKeyPattern.test(value)) {
|
||||
this.inputRefs[index].value = '';
|
||||
this.inputValues[index] = '';
|
||||
// 2. Input validation
|
||||
// If the character entered is invalid (does not match the pattern),
|
||||
// restore the previous value and exit
|
||||
if (value.length > 0 && !validKeyPattern.test(value[value.length - 1])) {
|
||||
input.value = this.inputValues[index] || '';
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
|
||||
// For single character input, fill the current box
|
||||
this.inputValues[index] = value;
|
||||
this.updateValue(event);
|
||||
|
||||
if (value.length > 0) {
|
||||
// 3. Full selection replacement or typing in an empty box
|
||||
// If the user selects all text in the input box and types, or if the
|
||||
// input box is empty, replace only this input box. If the box is empty,
|
||||
// move to the next box, otherwise stay focused on this box.
|
||||
const isAllSelected = input.selectionStart === 0 && input.selectionEnd === value.length;
|
||||
const isEmpty = !this.inputValues[index];
|
||||
if (isAllSelected || isEmpty) {
|
||||
this.inputValues[index] = value;
|
||||
input.value = value;
|
||||
this.updateValue(event);
|
||||
this.focusNext(index);
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Inserting in the middle with available space (shifting)
|
||||
// If typing in a filled input box and there are empty boxes at the end,
|
||||
// shift all values starting at the current box to the right, and insert
|
||||
// the new character at the current box.
|
||||
const hasAvailableBoxAtEnd = this.inputValues[this.inputValues.length - 1] === '';
|
||||
if (this.inputValues[index] && hasAvailableBoxAtEnd && value.length === 2) {
|
||||
// Get the inserted character (from event or by diffing value/previousValue)
|
||||
let newChar = (event as InputEvent).data;
|
||||
if (!newChar) {
|
||||
newChar = value.split('').find((c, i) => c !== previousValue[i]) || value[value.length - 1];
|
||||
}
|
||||
// Validate the new character before shifting
|
||||
if (!validKeyPattern.test(newChar)) {
|
||||
input.value = this.inputValues[index] || '';
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
// Shift values right from the end to the insertion point
|
||||
for (let i = this.inputValues.length - 1; i > index; i--) {
|
||||
this.inputValues[i] = this.inputValues[i - 1];
|
||||
this.inputRefs[i].value = this.inputValues[i] || '';
|
||||
}
|
||||
this.inputValues[index] = newChar;
|
||||
this.inputRefs[index].value = newChar;
|
||||
this.updateValue(event);
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Single character replacement
|
||||
// Handles replacing a single character in a box containing a value based
|
||||
// on the cursor position. We need the cursor position to determine which
|
||||
// character was the last character typed. For example, if the user types "2"
|
||||
// in an input box with the cursor at the beginning of the value of "6",
|
||||
// the value will be "26", but we want to grab the "2" as the last character
|
||||
// typed.
|
||||
const cursorPos = input.selectionStart ?? value.length;
|
||||
const newCharIndex = cursorPos - 1;
|
||||
const newChar = value[newCharIndex] ?? value[0];
|
||||
|
||||
// Check if the new character is valid before updating the value
|
||||
if (!validKeyPattern.test(newChar)) {
|
||||
input.value = this.inputValues[index] || '';
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputValues[index] = newChar;
|
||||
input.value = newChar;
|
||||
this.updateValue(event);
|
||||
this.previousInputValues = [...this.inputValues];
|
||||
};
|
||||
|
||||
/**
|
||||
@ -712,12 +769,8 @@ export class InputOTP implements ComponentInterface {
|
||||
|
||||
// Focus the next empty input after pasting
|
||||
// If all boxes are filled, focus the last input
|
||||
const nextEmptyIndex = validChars.length;
|
||||
if (nextEmptyIndex < length) {
|
||||
inputRefs[nextEmptyIndex]?.focus();
|
||||
} else {
|
||||
inputRefs[length - 1]?.focus();
|
||||
}
|
||||
const nextEmptyIndex = validChars.length < length ? validChars.length : length - 1;
|
||||
inputRefs[nextEmptyIndex]?.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -442,6 +442,67 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
|
||||
|
||||
await verifyInputValues(inputOtp, ['1', '9', '3', '']);
|
||||
});
|
||||
|
||||
test('should replace the last value when typing one more than the length', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
const firstInput = inputOtp.locator('input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
await page.keyboard.type('12345');
|
||||
|
||||
await verifyInputValues(inputOtp, ['1', '2', '3', '5']);
|
||||
});
|
||||
|
||||
test('should replace the last value when typing one more than the length and the type is text', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30459',
|
||||
});
|
||||
|
||||
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
const firstInput = inputOtp.locator('input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
await page.keyboard.type('abcde');
|
||||
|
||||
await verifyInputValues(inputOtp, ['a', 'b', 'c', 'e']);
|
||||
});
|
||||
|
||||
test('should not insert or shift when typing an invalid character before a number', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
const firstInput = inputOtp.locator('input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
// Move cursor to the start of the first input
|
||||
await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(0, 0));
|
||||
|
||||
await page.keyboard.type('w');
|
||||
|
||||
await verifyInputValues(inputOtp, ['1', '2', '', '']);
|
||||
});
|
||||
|
||||
test('should not insert or shift when typing an invalid character after a number', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
const firstInput = inputOtp.locator('input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
// Move cursor to the end of the first input
|
||||
await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1));
|
||||
|
||||
await page.keyboard.type('w');
|
||||
|
||||
await verifyInputValues(inputOtp, ['1', '2', '', '']);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('input-otp: autofill functionality'), () => {
|
||||
@ -460,6 +521,53 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
|
||||
await expect(lastInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('should handle autofill correctly when all characters are the same', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
||||
|
||||
const firstInput = page.locator('ion-input-otp input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
await simulateAutofill(firstInput, '1111');
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
await verifyInputValues(inputOtp, ['1', '1', '1', '1']);
|
||||
|
||||
const lastInput = page.locator('ion-input-otp input').last();
|
||||
await expect(lastInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('should handle autofill correctly when length is 2', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp length="2">Description</ion-input-otp>`, config);
|
||||
|
||||
const firstInput = page.locator('ion-input-otp input').first();
|
||||
await firstInput.focus();
|
||||
|
||||
await simulateAutofill(firstInput, '12');
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
await verifyInputValues(inputOtp, ['1', '2']);
|
||||
|
||||
const lastInput = page.locator('ion-input-otp input').last();
|
||||
await expect(lastInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('should handle autofill correctly when length is 2 after typing 1 character', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp length="2">Description</ion-input-otp>`, config);
|
||||
|
||||
await page.keyboard.type('1');
|
||||
|
||||
const secondInput = page.locator('ion-input-otp input').nth(1);
|
||||
await secondInput.focus();
|
||||
|
||||
await simulateAutofill(secondInput, '22');
|
||||
|
||||
const inputOtp = page.locator('ion-input-otp');
|
||||
await verifyInputValues(inputOtp, ['2', '2']);
|
||||
|
||||
const lastInput = page.locator('ion-input-otp input').last();
|
||||
await expect(lastInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('should handle autofill correctly when it exceeds the length', async ({ page }) => {
|
||||
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
||||
|
||||
|
||||
@ -508,5 +508,5 @@
|
||||
*/
|
||||
:host([disabled]) ::slotted(ion-input-password-toggle),
|
||||
:host([readonly]) ::slotted(ion-input-password-toggle) {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@ -26,6 +26,70 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
const input = page.locator('ion-input');
|
||||
await expect(input).toHaveScreenshot(screenshot(`input-disabled`));
|
||||
});
|
||||
|
||||
test('should maintain consistent height when password toggle is hidden on disabled input', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29562',
|
||||
});
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="Password" type="password" value="password123">
|
||||
<ion-input-password-toggle slot="end"></ion-input-password-toggle>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
|
||||
// Get the height when input is enabled
|
||||
const enabledHeight = await input.boundingBox().then((box) => box?.height);
|
||||
|
||||
// Disable the input
|
||||
await input.evaluate((el) => el.setAttribute('disabled', 'true'));
|
||||
await page.waitForChanges();
|
||||
|
||||
// Get the height when input is disabled
|
||||
const disabledHeight = await input.boundingBox().then((box) => box?.height);
|
||||
|
||||
// Verify heights are the same
|
||||
expect(enabledHeight).toBe(disabledHeight);
|
||||
});
|
||||
|
||||
test('should maintain consistent height when password toggle is hidden on readonly input', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29562',
|
||||
});
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="Password" type="password" value="password123">
|
||||
<ion-input-password-toggle slot="end"></ion-input-password-toggle>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
|
||||
// Get the height when input is enabled
|
||||
const enabledHeight = await input.boundingBox().then((box) => box?.height);
|
||||
|
||||
// Make the input readonly
|
||||
await input.evaluate((el) => el.setAttribute('readonly', 'true'));
|
||||
await page.waitForChanges();
|
||||
|
||||
// Get the height when input is readonly
|
||||
const readonlyHeight = await input.boundingBox().then((box) => box?.height);
|
||||
|
||||
// Verify heights are the same
|
||||
expect(enabledHeight).toBe(readonlyHeight);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
|
||||
@State() multipleInputs = false;
|
||||
@State() focusable = true;
|
||||
@State() isInteractive = false;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
@ -176,14 +177,12 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
componentDidLoad() {
|
||||
raf(() => {
|
||||
this.setMultipleInputs();
|
||||
this.setIsInteractive();
|
||||
this.focusable = this.isFocusable();
|
||||
});
|
||||
}
|
||||
|
||||
// If the item contains multiple clickable elements and/or inputs, then the item
|
||||
// should not have a clickable input cover over the entire item to prevent
|
||||
// interfering with their individual click events
|
||||
private setMultipleInputs() {
|
||||
private totalNestedInputs() {
|
||||
// The following elements have a clickable cover that is relative to the entire item
|
||||
const covers = this.el.querySelectorAll('ion-checkbox, ion-datetime, ion-select, ion-radio');
|
||||
|
||||
@ -197,6 +196,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
// The following elements should also stay clickable when an input with cover is present
|
||||
const clickables = this.el.querySelectorAll('ion-router-link, ion-button, a, button');
|
||||
|
||||
return {
|
||||
covers,
|
||||
inputs,
|
||||
clickables,
|
||||
};
|
||||
}
|
||||
|
||||
// If the item contains multiple clickable elements and/or inputs, then the item
|
||||
// should not have a clickable input cover over the entire item to prevent
|
||||
// interfering with their individual click events
|
||||
private setMultipleInputs() {
|
||||
const { covers, inputs, clickables } = this.totalNestedInputs();
|
||||
|
||||
// Check for multiple inputs to change the position of the input cover to relative
|
||||
// for all of the covered inputs above
|
||||
this.multipleInputs =
|
||||
@ -205,6 +217,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
(covers.length > 0 && this.isClickable());
|
||||
}
|
||||
|
||||
private setIsInteractive() {
|
||||
// If item contains any interactive children, set isInteractive to `true`
|
||||
const { covers, inputs, clickables } = this.totalNestedInputs();
|
||||
|
||||
this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0;
|
||||
}
|
||||
|
||||
// slot change listener updates state to reflect how/if item should be interactive
|
||||
private updateInteractivityOnSlotChange = () => {
|
||||
this.setIsInteractive();
|
||||
this.setMultipleInputs();
|
||||
};
|
||||
|
||||
// If the item contains an input including a checkbox, datetime, select, or radio
|
||||
// then the item will have a clickable input cover that covers the item
|
||||
// that should get the hover, focused and activated states UNLESS it has multiple
|
||||
@ -398,12 +423,12 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
disabled={disabled}
|
||||
{...clickFn}
|
||||
>
|
||||
<slot name="start"></slot>
|
||||
<slot name="start" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
<div class="item-inner">
|
||||
<div class="input-wrapper">
|
||||
<slot></slot>
|
||||
<slot onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
<slot name="end" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
{showDetail && (
|
||||
<ion-icon
|
||||
icon={itemDetailIcon}
|
||||
|
||||
@ -252,5 +252,46 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
|
||||
await expect(list).toHaveScreenshot(screenshot(`item-inputs-div-with-inputs`));
|
||||
});
|
||||
|
||||
test('should update interactivity state when elements are conditionally rendered', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29763',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-list>
|
||||
<ion-item>
|
||||
<ion-label>Conditional Checkbox</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const item = document.querySelector('ion-item');
|
||||
const checkbox = document.createElement('ion-checkbox');
|
||||
item?.appendChild(checkbox);
|
||||
});
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const checkbox = page.locator('ion-checkbox');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Test that clicking on the left edge of the item toggles the checkbox
|
||||
await item.click({
|
||||
position: {
|
||||
x: 5,
|
||||
y: 5,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -72,22 +72,22 @@ export const portraitToLandscapeTransition = (
|
||||
// need to care about layering and modal-specific styles.
|
||||
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
|
||||
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
|
||||
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
|
||||
const toTransform = `translateY(0px) scale(1)`;
|
||||
|
||||
presentingAnimation
|
||||
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
|
||||
.addElement(presentingEl)
|
||||
.afterStyles({
|
||||
transform: toTransform,
|
||||
})
|
||||
.fromTo('transform', fromTransform, toTransform)
|
||||
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
|
||||
.fromTo('filter', 'contrast(0.85)', 'contrast(1)');
|
||||
|
||||
const shadowAnimation = createAnimation()
|
||||
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
|
||||
.afterStyles({
|
||||
transform: toTransform,
|
||||
opacity: '0',
|
||||
})
|
||||
.fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals
|
||||
.fromTo('transform', fromTransform, toTransform);
|
||||
|
||||
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
|
||||
@ -148,17 +148,8 @@ export const landscapeToPortraitTransition = (
|
||||
|
||||
presentingAnimation
|
||||
.addElement(presentingEl)
|
||||
.beforeStyles({
|
||||
transform: 'translateY(0px) scale(1)',
|
||||
'transform-origin': 'top center',
|
||||
overflow: 'hidden',
|
||||
})
|
||||
.afterStyles({
|
||||
transform: toTransform,
|
||||
'border-radius': '10px 10px 0 0',
|
||||
filter: 'contrast(0.85)',
|
||||
overflow: 'hidden',
|
||||
'transform-origin': 'top center',
|
||||
})
|
||||
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
|
||||
.keyframes([
|
||||
@ -173,22 +164,21 @@ export const landscapeToPortraitTransition = (
|
||||
// to handle layering and modal-specific styles.
|
||||
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
|
||||
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
|
||||
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
|
||||
const toTransform = `translateY(0) scale(1)`;
|
||||
|
||||
presentingAnimation
|
||||
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
|
||||
.addElement(presentingEl)
|
||||
.afterStyles({
|
||||
transform: toTransform,
|
||||
})
|
||||
.fromTo('transform', fromTransform, toTransform)
|
||||
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
|
||||
.fromTo('transform', fromTransform, toTransform);
|
||||
|
||||
const shadowAnimation = createAnimation()
|
||||
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
|
||||
.afterStyles({
|
||||
transform: toTransform,
|
||||
opacity: '0',
|
||||
})
|
||||
.fromTo('opacity', '0', '0') // Shadow stays hidden
|
||||
.fromTo('transform', fromTransform, toTransform);
|
||||
|
||||
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
|
||||
|
||||
@ -98,6 +98,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private viewTransitionAnimation?: Animation;
|
||||
private resizeTimeout?: any;
|
||||
|
||||
// Mutation observer to watch for parent removal
|
||||
private parentRemovalObserver?: MutationObserver;
|
||||
// Cached original parent from before modal is moved to body during presentation
|
||||
private cachedOriginalParent?: HTMLElement;
|
||||
|
||||
lastFocus?: HTMLElement;
|
||||
animation?: Animation;
|
||||
|
||||
@ -409,6 +414,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
disconnectedCallback() {
|
||||
this.triggerController.removeClickListener();
|
||||
this.cleanupViewTransitionListener();
|
||||
this.cleanupParentRemovalObserver();
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@ -418,6 +424,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
const attributesToInherit = ['aria-label', 'role'];
|
||||
this.inheritedAttributes = inheritAttributes(el, attributesToInherit);
|
||||
|
||||
// Cache original parent before modal gets moved to body during presentation
|
||||
if (el.parentNode) {
|
||||
this.cachedOriginalParent = el.parentNode as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* When using a controller modal you can set attributes
|
||||
* using the htmlAttributes property. Since the above attributes
|
||||
@ -654,6 +665,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
// Initialize view transition listener for iOS card modals
|
||||
this.initViewTransitionListener();
|
||||
|
||||
// Initialize parent removal observer
|
||||
this.initParentRemovalObserver();
|
||||
|
||||
unlock();
|
||||
}
|
||||
|
||||
@ -798,6 +812,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
const unlock = await this.lockController.lock();
|
||||
|
||||
/**
|
||||
* Dismiss all child modals. This is especially important in
|
||||
* Angular and React because it's possible to lose control of a child
|
||||
* modal when the parent modal is dismissed.
|
||||
*/
|
||||
await this.dismissNestedModals();
|
||||
|
||||
/**
|
||||
* If a canDismiss handler is responsible
|
||||
* for calling the dismiss method, we should
|
||||
@ -854,6 +875,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.gesture.destroy();
|
||||
}
|
||||
this.cleanupViewTransitionListener();
|
||||
this.cleanupParentRemovalObserver();
|
||||
}
|
||||
this.currentBreakpoint = undefined;
|
||||
this.animation = undefined;
|
||||
@ -1130,7 +1152,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
wrapperEl.style.opacity = '1';
|
||||
}
|
||||
|
||||
if (presentingElement) {
|
||||
if (presentingElement?.tagName === 'ION-MODAL') {
|
||||
const isPortrait = window.innerWidth < 768;
|
||||
|
||||
if (isPortrait) {
|
||||
@ -1145,6 +1167,89 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the slot changes, we need to find all the modals in the slot
|
||||
* and set the data-parent-ion-modal attribute on them so we can find them
|
||||
* and dismiss them when we get dismissed.
|
||||
* We need to do it this way because when a modal is opened, it's moved to
|
||||
* the end of the body and is no longer an actual child of the modal.
|
||||
*/
|
||||
private onSlotChange = ({ target }: Event) => {
|
||||
const slot = target as HTMLSlotElement;
|
||||
slot.assignedElements().forEach((el) => {
|
||||
el.querySelectorAll('ion-modal').forEach((childModal) => {
|
||||
// We don't need to write to the DOM if the modal is already tagged
|
||||
// If this is a deeply nested modal, this effect should cascade so we don't
|
||||
// need to worry about another modal claiming the same child.
|
||||
if (childModal.getAttribute('data-parent-ion-modal') === null) {
|
||||
childModal.setAttribute('data-parent-ion-modal', this.el.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private async dismissNestedModals(): Promise<void> {
|
||||
const nestedModals = document.querySelectorAll(`ion-modal[data-parent-ion-modal="${this.el.id}"]`);
|
||||
nestedModals?.forEach(async (modal) => {
|
||||
await (modal as HTMLIonModalElement).dismiss(undefined, 'parent-dismissed');
|
||||
});
|
||||
}
|
||||
|
||||
private initParentRemovalObserver() {
|
||||
if (typeof MutationObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only observe if we have a cached parent and are in browser environment
|
||||
if (typeof window === 'undefined' || !this.cachedOriginalParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't observe document or fragment nodes as they can't be "removed"
|
||||
if (
|
||||
this.cachedOriginalParent.nodeType === Node.DOCUMENT_NODE ||
|
||||
this.cachedOriginalParent.nodeType === Node.DOCUMENT_FRAGMENT_NODE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.parentRemovalObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
|
||||
// Check if our cached original parent was removed
|
||||
const cachedParentWasRemoved = Array.from(mutation.removedNodes).some((node) => {
|
||||
const isDirectMatch = node === this.cachedOriginalParent;
|
||||
const isContainedMatch = this.cachedOriginalParent
|
||||
? (node as HTMLElement).contains?.(this.cachedOriginalParent)
|
||||
: false;
|
||||
return isDirectMatch || isContainedMatch;
|
||||
});
|
||||
|
||||
// Also check if parent is no longer connected to DOM
|
||||
const cachedParentDisconnected = this.cachedOriginalParent && !this.cachedOriginalParent.isConnected;
|
||||
|
||||
if (cachedParentWasRemoved || cachedParentDisconnected) {
|
||||
this.dismiss(undefined, 'parent-removed');
|
||||
// Release the reference to the cached original parent
|
||||
// so we don't have a memory leak
|
||||
this.cachedOriginalParent = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Observe document body with subtree to catch removals at any level
|
||||
this.parentRemovalObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupParentRemovalObserver() {
|
||||
this.parentRemovalObserver?.disconnect();
|
||||
this.parentRemovalObserver = undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
handle,
|
||||
@ -1224,7 +1329,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
ref={(el) => (this.dragHandleEl = el)}
|
||||
></button>
|
||||
)}
|
||||
<slot></slot>
|
||||
<slot onSlotchange={this.onSlotChange}></slot>
|
||||
</div>
|
||||
</Host>
|
||||
);
|
||||
|
||||
@ -22,31 +22,91 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
|
||||
<div id="modal-container">
|
||||
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
|
||||
<ion-modal swipe-to-close="true">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title> Modal </ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>This is my inline modal content!</p>
|
||||
<button id="open-child-modal" onclick="openChildModal(event)">Open Child Modal</button>
|
||||
<button id="remove-modal-container" onclick="removeModalContainer(event)">
|
||||
Remove Modal Container
|
||||
</button>
|
||||
|
||||
<ion-modal swipe-to-close="true">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title> Modal </ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding"> This is my inline modal content! </ion-content>
|
||||
</ion-modal>
|
||||
<ion-modal id="child-modal" swipe-to-close="true">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Child Modal</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>This is the child modal content!</p>
|
||||
<p>When the parent modal is dismissed, this child modal should also be dismissed automatically.</p>
|
||||
<button id="dismiss-parent" onclick="dismissParent(event)">Dismiss Parent Modal</button>
|
||||
<button id="dismiss-child" onclick="dismissChild(event)">Dismiss Child Modal</button>
|
||||
<button id="child-remove-modal-container" onclick="removeModalContainer(event)">
|
||||
Remove Modal Container
|
||||
</button>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</div>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const modal = document.querySelector('ion-modal');
|
||||
const childModal = document.querySelector('#child-modal');
|
||||
|
||||
modal.presentingElement = document.querySelector('.ion-page');
|
||||
childModal.presentingElement = modal;
|
||||
|
||||
const openModal = () => {
|
||||
modal.isOpen = true;
|
||||
};
|
||||
|
||||
const openChildModal = () => {
|
||||
childModal.isOpen = true;
|
||||
};
|
||||
|
||||
const dismissParent = () => {
|
||||
modal.isOpen = false;
|
||||
};
|
||||
|
||||
const dismissChild = () => {
|
||||
childModal.isOpen = false;
|
||||
};
|
||||
|
||||
const removeModalContainer = () => {
|
||||
const container = document.querySelector('#modal-container');
|
||||
if (container) {
|
||||
container.remove();
|
||||
console.log('Modal container removed from DOM');
|
||||
}
|
||||
};
|
||||
|
||||
modal.addEventListener('didDismiss', () => {
|
||||
modal.isOpen = false;
|
||||
});
|
||||
|
||||
childModal.addEventListener('didDismiss', () => {
|
||||
childModal.isOpen = false;
|
||||
});
|
||||
|
||||
// Add event listeners to demonstrate the new functionality
|
||||
modal.addEventListener('ionModalDidDismiss', (event) => {
|
||||
console.log('Parent modal dismissed with role:', event.detail.role);
|
||||
});
|
||||
|
||||
childModal.addEventListener('ionModalDidDismiss', (event) => {
|
||||
console.log('Child modal dismissed with role:', event.detail.role);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -7,7 +7,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await page.goto('/src/components/modal/test/inline', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
const modal = page.locator('ion-modal');
|
||||
const modal = page.locator('ion-modal').first();
|
||||
|
||||
await page.click('#open-inline-modal');
|
||||
|
||||
@ -22,6 +22,67 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await expect(modal).toBeHidden();
|
||||
});
|
||||
|
||||
test('it should dismiss child modals when parent modal is dismissed', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/inline', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
const parentModal = page.locator('ion-modal').first();
|
||||
const childModal = page.locator('#child-modal');
|
||||
|
||||
// Open the parent modal
|
||||
await page.click('#open-inline-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(parentModal).toBeVisible();
|
||||
|
||||
// Open the child modal
|
||||
await page.click('#open-child-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(childModal).toBeVisible();
|
||||
|
||||
// Both modals should be visible
|
||||
await expect(parentModal).toBeVisible();
|
||||
await expect(childModal).toBeVisible();
|
||||
|
||||
// Dismiss the parent modal
|
||||
await page.click('#dismiss-parent');
|
||||
|
||||
// Wait for both modals to be dismissed
|
||||
await ionModalDidDismiss.next(); // child modal dismissed
|
||||
await ionModalDidDismiss.next(); // parent modal dismissed
|
||||
|
||||
// Both modals should be hidden
|
||||
await expect(parentModal).toBeHidden();
|
||||
await expect(childModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('it should only dismiss child modal when child dismiss button is clicked', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/inline', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
const parentModal = page.locator('ion-modal').first();
|
||||
const childModal = page.locator('#child-modal');
|
||||
|
||||
// Open the parent modal
|
||||
await page.click('#open-inline-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(parentModal).toBeVisible();
|
||||
|
||||
// Open the child modal
|
||||
await page.click('#open-child-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(childModal).toBeVisible();
|
||||
|
||||
// Dismiss only the child modal
|
||||
await page.click('#dismiss-child');
|
||||
await ionModalDidDismiss.next();
|
||||
|
||||
// Parent modal should still be visible, child modal should be hidden
|
||||
await expect(parentModal).toBeVisible();
|
||||
await expect(childModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('presenting should create a single root element with the ion-page class', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
@ -61,5 +122,152 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await modal.evaluate((el: HTMLIonModalElement) => el.firstElementChild!.firstElementChild!.className)
|
||||
).not.toContain('ion-page');
|
||||
});
|
||||
|
||||
test('it should dismiss modal when parent container is removed from DOM', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/inline', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
const modal = page.locator('ion-modal').first();
|
||||
const modalContainer = page.locator('#modal-container');
|
||||
|
||||
// Open the modal
|
||||
await page.click('#open-inline-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Remove the modal container from DOM
|
||||
await page.click('#remove-modal-container');
|
||||
|
||||
// Wait for modal to be dismissed
|
||||
const dismissEvent = await ionModalDidDismiss.next();
|
||||
|
||||
// Verify the modal was dismissed with the correct role
|
||||
expect(dismissEvent.detail.role).toBe('parent-removed');
|
||||
|
||||
// Verify the modal is no longer visible
|
||||
await expect(modal).toBeHidden();
|
||||
|
||||
// Verify the container was actually removed
|
||||
await expect(modalContainer).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('it should dismiss both parent and child modals when parent container is removed from DOM', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/src/components/modal/test/inline', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
const parentModal = page.locator('ion-modal').first();
|
||||
const childModal = page.locator('#child-modal');
|
||||
const modalContainer = page.locator('#modal-container');
|
||||
|
||||
// Open the parent modal
|
||||
await page.click('#open-inline-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(parentModal).toBeVisible();
|
||||
|
||||
// Open the child modal
|
||||
await page.click('#open-child-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(childModal).toBeVisible();
|
||||
|
||||
// Remove the modal container from DOM
|
||||
await page.click('#child-remove-modal-container');
|
||||
|
||||
// Wait for both modals to be dismissed
|
||||
const firstDismissEvent = await ionModalDidDismiss.next();
|
||||
const secondDismissEvent = await ionModalDidDismiss.next();
|
||||
|
||||
// Verify at least one modal was dismissed with 'parent-removed' role
|
||||
const dismissRoles = [firstDismissEvent.detail.role, secondDismissEvent.detail.role];
|
||||
expect(dismissRoles).toContain('parent-removed');
|
||||
|
||||
// Verify both modals are no longer visible
|
||||
await expect(parentModal).toBeHidden();
|
||||
await expect(childModal).toBeHidden();
|
||||
|
||||
// Verify the container was actually removed
|
||||
await expect(modalContainer).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('it should dismiss modals when top-level ancestor is removed', async ({ page }) => {
|
||||
// We need to make sure we can close a modal when a much higher
|
||||
// element is removed from the DOM. This will be a common
|
||||
// use case in frameworks like Angular and React, where an entire
|
||||
// page container for much more than the modal might be swapped out.
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Top Level Removal Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<div id="top-level-container">
|
||||
<div id="nested-container">
|
||||
<button id="open-nested-modal">Open Nested Modal</button>
|
||||
<ion-modal id="nested-modal">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Nested Modal</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>This modal's original parent is deeply nested</p>
|
||||
<button id="remove-top-level">Remove Top Level Container</button>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const nestedModal = document.querySelector('#nested-modal');
|
||||
nestedModal.presentingElement = document.querySelector('.ion-page');
|
||||
|
||||
document.getElementById('open-nested-modal').addEventListener('click', () => {
|
||||
nestedModal.isOpen = true;
|
||||
});
|
||||
|
||||
document.getElementById('remove-top-level').addEventListener('click', () => {
|
||||
document.querySelector('#top-level-container').remove();
|
||||
});
|
||||
</script>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
const nestedModal = page.locator('#nested-modal');
|
||||
const topLevelContainer = page.locator('#top-level-container');
|
||||
|
||||
// Open the nested modal
|
||||
await page.click('#open-nested-modal');
|
||||
await ionModalDidPresent.next();
|
||||
await expect(nestedModal).toBeVisible();
|
||||
|
||||
// Remove the top-level container
|
||||
await page.click('#remove-top-level');
|
||||
|
||||
// Wait for modal to be dismissed
|
||||
const dismissEvent = await ionModalDidDismiss.next();
|
||||
|
||||
// Verify the modal was dismissed with the correct role
|
||||
expect(dismissEvent.detail.role).toBe('parent-removed');
|
||||
|
||||
// Verify the modal is no longer visible
|
||||
await expect(nestedModal).toBeHidden();
|
||||
|
||||
// Verify the container was actually removed
|
||||
await expect(topLevelContainer).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,12 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
// Workaround for a Safari/WebKit bug where flexbox children with dynamic
|
||||
// height (e.g., height: fit-content) are not included in the scrollable area
|
||||
// when using horizontal scrolling. This is needed to make the segment view
|
||||
// scroll to the correct content.
|
||||
min-height: 1px;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
/* Hide scrollbar in Firefox */
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Segment View - Dynamic Height</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
|
||||
<style>
|
||||
ion-segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(1) {
|
||||
background: lightpink;
|
||||
}
|
||||
ion-segment-content:nth-of-type(2) {
|
||||
background: lightblue;
|
||||
}
|
||||
ion-segment-content:nth-of-type(3) {
|
||||
background: lightgreen;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Segment View - Dynamic Height</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-segment>
|
||||
<ion-segment-button value="first" content-id="first">
|
||||
<ion-label>First</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="second" content-id="second">
|
||||
<ion-label>Second</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="third" content-id="third">
|
||||
<ion-label>Third</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="first">
|
||||
Zombie ipsum reversus ab viral inferno, nam rick grimes malum cerebro. De carne lumbering animata corpora
|
||||
quaeritis. Summus brains sit, morbo vel maleficia? De apocalypsi gorger omero undead survivor dictum mauris.
|
||||
Hi mindless mortuis soulless creaturas, imo evil stalking monstra adventus resi dentevil vultus comedat
|
||||
cerebella viventium. Qui animated corpse, cricket bat max brucks terribilem incessu zomby. The voodoo
|
||||
sacerdos flesh eater, suscitat mortuos comedere carnem virus. Zonbi tattered for solum oculi eorum defunctis
|
||||
go lum cerebro. Nescio brains an Undead zombies. Sicut malus putrid voodoo horror. Nigh tofth eliv
|
||||
</ion-segment-content>
|
||||
<ion-segment-content id="second">
|
||||
<ion-input value="" label="Email"></ion-input>
|
||||
</ion-segment-content>
|
||||
<ion-segment-content id="third">
|
||||
<ion-img class="img-part" src="https://picsum.photos/200/300"></ion-img>
|
||||
</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,85 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('segment-view: dynamic height'), () => {
|
||||
test('should show the third content when clicking the third button', async ({ page, skip }) => {
|
||||
// Skip this test on Chrome and Firefox
|
||||
skip.browser('firefox', 'Original issue only happens on Safari.');
|
||||
skip.browser('chromium', 'Original issue only happens on Safari.');
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: fit-content;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-segment>
|
||||
<ion-segment-button value="first" content-id="first">
|
||||
<ion-label>First</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="second" content-id="second">
|
||||
<ion-label>Second</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="third" content-id="third">
|
||||
<ion-label>Third</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="first">
|
||||
Zombie ipsum reversus ab viral inferno, nam rick grimes malum cerebro. De carne lumbering animata corpora
|
||||
quaeritis. Summus brains sit, morbo vel maleficia? De apocalypsi gorger omero undead survivor dictum
|
||||
mauris. Hi mindless mortuis soulless creaturas, imo evil stalking monstra adventus resi dentevil vultus
|
||||
comedat cerebella viventium. Qui animated corpse, cricket bat max brucks terribilem incessu zomby. The
|
||||
voodoo sacerdos flesh eater, suscitat mortuos comedere carnem virus. Zonbi tattered for solum oculi eorum
|
||||
defunctis go lum cerebro. Nescio brains an Undead zombies. Sicut malus putrid voodoo horror. Nigh tofth eliv
|
||||
</ion-segment-content>
|
||||
<ion-segment-content id="second">
|
||||
<ion-input value="" label="Email"></ion-input>
|
||||
</ion-segment-content>
|
||||
<ion-segment-content id="third">
|
||||
<ion-img class="img-part" src="https://picsum.photos/200/300"></ion-img>
|
||||
</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
// Click the third button
|
||||
await page.locator('ion-segment-button[value="third"]').click();
|
||||
|
||||
// Wait for the content to be scrolled
|
||||
await page.waitForChanges();
|
||||
|
||||
// Wait for the image to load and be visible
|
||||
const imgLocator = page.locator('ion-segment-content#third ion-img');
|
||||
await imgLocator.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Wait for any layout adjustments
|
||||
await page.waitForChanges();
|
||||
|
||||
// Check that the third content is visible
|
||||
const segmentView = page.locator('ion-segment-view');
|
||||
const thirdContent = page.locator('ion-segment-content#third');
|
||||
|
||||
const viewBox = await segmentView.boundingBox();
|
||||
const contentBox = await thirdContent.boundingBox();
|
||||
|
||||
if (!viewBox || !contentBox) throw new Error('Bounding box not found');
|
||||
|
||||
// Allow a small tolerance to account for subpixel rendering,
|
||||
// scrollbars, or layout rounding differences
|
||||
const tolerance = 10;
|
||||
expect(contentBox.x).toBeGreaterThanOrEqual(viewBox.x);
|
||||
expect(contentBox.x + contentBox.width).toBeLessThanOrEqual(viewBox.x + viewBox.width + tolerance);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user