Merge branch 'main' into chore/merge-main

This commit is contained in:
Brandy Smith
2025-07-17 13:39:50 -04:00
41 changed files with 1100 additions and 190 deletions

View File

@ -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();
};
/**

View File

@ -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);

View File

@ -508,5 +508,5 @@
*/
:host([disabled]) ::slotted(ion-input-password-toggle),
:host([readonly]) ::slotted(ion-input-password-toggle) {
display: none;
visibility: hidden;
}

View File

@ -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);
});
});
});

View File

@ -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}

View File

@ -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();
});
});
});

View File

@ -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]);

View File

@ -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>
);

View File

@ -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>

View File

@ -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();
});
});
});

View File

@ -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 */

View File

@ -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>

View File

@ -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);
});
});
});