Compare commits

..

6 Commits

Author SHA1 Message Date
Maria Hutt
885b0edc97 refactor(alert): moved slot location 2025-04-01 08:12:10 -07:00
Maria Hutt
62cf80c433 refactor(alert): update comments 2025-03-28 14:50:11 -07:00
Maria Hutt
6a819d5eeb fix(alert): add missing type 2025-03-28 14:17:06 -07:00
Maria Hutt
ec40cd5b3e feat(alert): add slots to controller 2025-03-28 14:15:07 -07:00
Maria Hutt
4c598d5a34 feat(alert): add slot for customization 2025-03-25 14:20:48 -07:00
Maria Hutt
1cfa915e8f fix(segment-button): ensure consistent disabled state for segment-content error handling (#30288)
Issue number: N/A

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Segment buttons do not consistently set themselves to a disabled state.
When disabling them and rapidly refreshing the page, their state may
vary—sometimes they appear disabled, and other times they do not.

This was due to this
[PR](https://github.com/ionic-team/ionic-framework/pull/30138). The
reason that this PR was created was due to the console errors being
shown too early when segment buttons and contents were dynamically
rendered.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- I reverted the changes added through the other PR since the
`setTimeout` was causing the inconsistency.
- By moving the console errors to `componentWillLoad`, it provides
`ion-segment-content` time to render before the console errors are
thrown.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.5.2-dev.11742514102.1b51674d`

How to test:
1. Run the server locally
2. Navigate to the [segment view
disabled](http://localhost:3333/src/components/segment-view/test/disabled)
page
3. Verify that the "Paid", "Free", and "Top" buttons disabled
4. Rapid fire some hard refreshes
5.  Verify that the "Paid", "Free", and "Top" buttons disabled
6. Navigate to this StackBlitz
[repro](https://stackblitz.com/edit/yhktpj19-wxmxpmw7?file=src%2Fmain.tsx)
7. Install the dev build: `npm i
@ionic/react@8.5.2-dev.11742514102.1b51674d
@ionic/react-router@8.5.2-dev.11742514102.1b51674d`
8. Open the console log
9. Click on the "Add last child" button
10. Verify that there are no console errors like `Segment Button: Unable
to find Segment Content with id="content3".`
2025-03-25 18:21:31 +00:00
12 changed files with 345 additions and 314 deletions

View File

@@ -100,12 +100,15 @@ ion-alert,scoped
ion-alert,prop,animated,boolean,true,false,false
ion-alert,prop,backdropDismiss,boolean,true,false,false
ion-alert,prop,buttons,(string | AlertButton)[],[],false,false
ion-alert,prop,component,Function | HTMLElement | null | string | undefined,undefined,false,false
ion-alert,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false
ion-alert,prop,cssClass,string | string[] | undefined,undefined,false,false
ion-alert,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-alert,prop,header,string | undefined,undefined,false,false
ion-alert,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
ion-alert,prop,inputs,AlertInput[],[],false,false
ion-alert,prop,isOpen,boolean,false,false,false
ion-alert,prop,keepContentsMounted,boolean,false,false,false
ion-alert,prop,keyboardClose,boolean,true,false,false
ion-alert,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-alert,prop,message,IonicSafeString | string | undefined,undefined,false,false

View File

@@ -233,6 +233,14 @@ export namespace Components {
* Array of buttons to be added to the alert.
*/
"buttons": (AlertButton | string)[];
/**
* The component to display inside of the alert.
*/
"component"?: ComponentRef;
/**
* The data to pass to the alert component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component.
*/
"componentProps"?: ComponentProps;
/**
* Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces.
*/
@@ -265,6 +273,10 @@ export namespace Components {
* If `true`, the alert will open. If `false`, the alert will close. Use this if you need finer grained control over presentation, otherwise just use the alertController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the alert dismisses. You will need to do that in your code.
*/
"isOpen": boolean;
/**
* If `true`, the component passed into `ion-alert` will automatically be mounted when the alert is created. The component will remain mounted even when the alert is dismissed. However, the component will be destroyed when the alert is destroyed. This property is not reactive and should only be used when initially creating a alert. Note: This feature only applies to inline alerts in JavaScript frameworks such as Angular, React, and Vue.
*/
"keepContentsMounted": boolean;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
@@ -3594,6 +3606,7 @@ declare global {
"willPresent": void;
"willDismiss": OverlayEventDetail;
"didDismiss": OverlayEventDetail;
"ionMount": void;
}
interface HTMLIonAlertElement extends Components.IonAlert, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonAlertElementEventMap>(type: K, listener: (this: HTMLIonAlertElement, ev: IonAlertCustomEvent<HTMLIonAlertElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -5023,6 +5036,14 @@ declare namespace LocalJSX {
* Array of buttons to be added to the alert.
*/
"buttons"?: (AlertButton | string)[];
/**
* The component to display inside of the alert.
*/
"component"?: ComponentRef;
/**
* The data to pass to the alert component. You only need to use this if you are not using a JavaScript framework. Otherwise, you can just set the props directly on your component.
*/
"componentProps"?: ComponentProps;
/**
* Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces.
*/
@@ -5049,6 +5070,10 @@ declare namespace LocalJSX {
* If `true`, the alert will open. If `false`, the alert will close. Use this if you need finer grained control over presentation, otherwise just use the alertController or the `trigger` property. Note: `isOpen` will not automatically be set back to `false` when the alert dismisses. You will need to do that in your code.
*/
"isOpen"?: boolean;
/**
* If `true`, the component passed into `ion-alert` will automatically be mounted when the alert is created. The component will remain mounted even when the alert is dismissed. However, the component will be destroyed when the alert is destroyed. This property is not reactive and should only be used when initially creating a alert. Note: This feature only applies to inline alerts in JavaScript frameworks such as Angular, React, and Vue.
*/
"keepContentsMounted"?: boolean;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
@@ -5089,6 +5114,10 @@ declare namespace LocalJSX {
* Emitted before the alert has presented.
*/
"onIonAlertWillPresent"?: (event: IonAlertCustomEvent<void>) => void;
/**
* Emitted before the modal has presented, but after the component has been mounted in the DOM. This event exists so iOS can run the entering transition properly
*/
"onIonMount"?: (event: IonAlertCustomEvent<void>) => void;
/**
* Emitted before the alert has dismissed. Shorthand for ionAlertWillDismiss.
*/

View File

@@ -1,7 +1,16 @@
import type { AnimationBuilder, LiteralUnion, Mode, TextFieldTypes } from '../../interface';
import type {
AnimationBuilder,
LiteralUnion,
Mode,
TextFieldTypes,
ComponentRef,
ComponentProps,
} from '../../interface';
import type { IonicSafeString } from '../../utils/sanitization';
export interface AlertOptions {
export interface AlertOptions<T extends ComponentRef = ComponentRef> {
component?: T;
componentProps?: ComponentProps<T>;
header?: string;
subHeader?: string;
message?: string | IonicSafeString;

View File

@@ -1,12 +1,12 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, Watch, forceUpdate, h } from '@stencil/core';
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
import type { Gesture } from '@utils/gesture';
import { createButtonActiveGesture } from '@utils/gesture/button-active';
import { raf } from '@utils/helpers';
import { raf, hasLazyBuild } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import {
createDelegateController,
createTriggerController,
BACKDROP,
dismiss,
@@ -19,10 +19,18 @@ import {
} from '@utils/overlays';
import { sanitizeDOMString } from '@utils/sanitization';
import { getClassMap } from '@utils/theme';
import { deepReady, waitForMount } from '@utils/transition';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import type { AnimationBuilder, CssClassMap, OverlayInterface, FrameworkDelegate } from '../../interface';
import type {
AnimationBuilder,
CssClassMap,
OverlayInterface,
FrameworkDelegate,
ComponentRef,
ComponentProps,
} from '../../interface';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import type { IonicSafeString } from '../../utils/sanitization';
@@ -46,7 +54,6 @@ import { mdLeaveAnimation } from './animations/md.leave';
scoped: true,
})
export class Alert implements ComponentInterface, OverlayInterface {
private readonly delegateController = createDelegateController(this);
private readonly lockController = createLockController();
private readonly triggerController = createTriggerController();
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);
@@ -57,6 +64,11 @@ export class Alert implements ComponentInterface, OverlayInterface {
private wrapperEl?: HTMLElement;
private gesture?: Gesture;
private inline = false;
private workingDelegate?: FrameworkDelegate;
private coreDelegate: FrameworkDelegate = CoreDelegate();
private usersElement?: HTMLElement;
presented = false;
lastFocus?: HTMLElement;
@@ -150,6 +162,32 @@ export class Alert implements ComponentInterface, OverlayInterface {
*/
@Prop() htmlAttributes?: { [key: string]: any };
/**
* The component to display inside of the alert.
*/
@Prop() component?: ComponentRef;
/**
* The data to pass to the alert component.
* You only need to use this if you are not using
* a JavaScript framework. Otherwise, you can just
* set the props directly on your component.
*/
@Prop() componentProps?: ComponentProps;
/**
* If `true`, the component passed into `ion-alert` will
* automatically be mounted when the alert is created. The
* component will remain mounted even when the alert is dismissed.
* However, the component will be destroyed when the alert is
* destroyed. This property is not reactive and should only be
* used when initially creating a alert.
*
* Note: This feature only applies to inline alerts in JavaScript
* frameworks such as Angular, React, and Vue.
*/
@Prop() keepContentsMounted = false;
/**
* If `true`, the alert will open. If `false`, the alert will close.
* Use this if you need finer grained control over presentation, otherwise
@@ -347,6 +385,16 @@ export class Alert implements ComponentInterface, OverlayInterface {
);
}
/**
* Emitted before the modal has presented, but after the component
* has been mounted in the DOM.
* This event exists so iOS can run the entering
* transition properly
*
* @internal
*/
@Event() ionMount!: EventEmitter<void>;
connectedCallback() {
prepareOverlay(this.el);
this.triggerChanged();
@@ -403,6 +451,52 @@ export class Alert implements ComponentInterface, OverlayInterface {
this.triggerChanged();
}
private onLifecycle = (modalEvent: CustomEvent) => {
const el = this.usersElement;
const name = LIFECYCLE_MAP[modalEvent.type];
if (el && name) {
const event = new CustomEvent(name, {
bubbles: false,
cancelable: false,
detail: modalEvent.detail,
});
el.dispatchEvent(event);
}
};
/**
* Determines whether or not an overlay
* is being used inline or via a controller/JS
* and returns the correct delegate.
* By default, subsequent calls to getDelegate
* will use a cached version of the delegate.
* This is useful for calling dismiss after
* present so that the correct delegate is given.
*/
private getDelegate(force = false) {
if (this.workingDelegate && !force) {
return {
delegate: this.workingDelegate,
inline: this.inline,
};
}
/**
* If using overlay inline
* we potentially need to use the coreDelegate
* so that this works in vanilla JS apps.
* If a developer has presented this component
* via a controller, then we can assume
* the component is already in the
* correct place.
*/
const parentEl = this.el.parentNode as HTMLElement | null;
const inline = (this.inline = parentEl !== null && !this.hasController);
const delegate = (this.workingDelegate = inline ? this.delegate || this.coreDelegate : this.delegate);
return { inline, delegate };
}
/**
* Present the alert overlay after it has been created.
*/
@@ -410,7 +504,46 @@ export class Alert implements ComponentInterface, OverlayInterface {
async present(): Promise<void> {
const unlock = await this.lockController.lock();
await this.delegateController.attachViewToDom();
const { el } = this;
const { inline, delegate } = this.getDelegate(true);
/**
* Emit ionMount so JS Frameworks have an opportunity
* to add the child component to the DOM. The child
* component will be assigned to this.usersElement below.
*/
this.ionMount.emit();
this.usersElement = await attachComponent(
delegate,
el,
this.component,
['alert-viewport'],
this.componentProps,
inline
);
/**
* When using the lazy loaded build of Stencil, we need to wait
* for every Stencil component instance to be ready before presenting
* otherwise there can be a flash of unstyled content. With the
* custom elements bundle we need to wait for the JS framework
* mount the inner contents of the overlay otherwise WebKit may
* get the transition incorrect.
*/
if (hasLazyBuild(el)) {
await deepReady(this.usersElement);
/**
* If keepContentsMounted="true" then the
* JS Framework has already mounted the inner
* contents so there is no need to wait.
* Otherwise, we need to wait for the JS
* Framework to mount the inner contents
* of this component.
*/
} else if (!this.keepContentsMounted) {
await waitForMount();
}
await present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation).then(() => {
/**
@@ -449,7 +582,13 @@ export class Alert implements ComponentInterface, OverlayInterface {
const dismissed = await dismiss(this, data, role, 'alertLeave', iosLeaveAnimation, mdLeaveAnimation);
if (dismissed) {
this.delegateController.removeViewFromDom();
/**
* If using alert inline
* we potentially need to use the coreDelegate
* so that this works in vanilla JS apps
*/
const { delegate } = this.getDelegate();
await detachComponent(delegate, this.usersElement);
}
unlock();
@@ -704,6 +843,8 @@ export class Alert implements ComponentInterface, OverlayInterface {
const cancelButton = this.processedButtons.find((b) => b.role === 'cancel');
this.callButtonHandler(cancelButton);
}
this.onLifecycle(ev);
};
private renderAlertButtons() {
@@ -746,7 +887,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
}
render() {
const { overlayIndex, header, subHeader, message, htmlAttributes } = this;
const { overlayIndex, header, subHeader, message, htmlAttributes, onLifecycle } = this;
const mode = getIonMode(this);
const hdrId = `alert-${overlayIndex}-hdr`;
const msgId = `alert-${overlayIndex}-msg`;
@@ -773,7 +914,10 @@ export class Alert implements ComponentInterface, OverlayInterface {
'overlay-hidden': true,
'alert-translucent': this.translucent,
}}
onIonAlertDidPresent={onLifecycle}
onIonAlertWillPresent={onLifecycle}
onIonAlertWillDismiss={this.dispatchCancelHandler}
onIonAlertDidDismiss={onLifecycle}
onIonBackdropTap={this.onBackdropTap}
>
<ion-backdrop tappable={this.backdropDismiss} />
@@ -812,6 +956,8 @@ export class Alert implements ComponentInterface, OverlayInterface {
{this.renderAlertMessage(msgId)}
<slot></slot>
{this.renderAlertInputs()}
{this.renderAlertButtons()}
</div>
@@ -822,6 +968,13 @@ export class Alert implements ComponentInterface, OverlayInterface {
}
}
const LIFECYCLE_MAP: any = {
ionAlertDidPresent: 'ionViewDidEnter',
ionAlertWillPresent: 'ionViewWillEnter',
ionAlertWillDismiss: 'ionViewWillLeave',
ionAlertDidDismiss: 'ionViewDidLeave',
};
const inputClass = (input: AlertInput): CssClassMap => {
return {
'alert-input': true,

View File

@@ -40,6 +40,16 @@
<button class="expand" id="radio" onclick="presentAlertRadio()">Radio</button>
<button class="expand" id="checkbox" onclick="presentAlertCheckbox()">Checkbox</button>
<button class="expand" onclick="presentWithCssClass()">CssClass</button>
<ion-button id="present-alert">Click Me</ion-button>
<ion-alert
trigger="present-alert"
header="A Short Title Is Best"
sub-header="A Sub Header Is Optional"
message="A message should be a short, complete sentence."
>
<p>woah</p>
</ion-alert>
</ion-content>
<style>
@@ -62,11 +72,30 @@
},
};
const alert = document.querySelector('ion-alert');
alert.buttons = ['Action'];
async function openAlert(opts) {
const alert = await alertController.create(opts);
await alert.present();
}
class ProfilePage extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `
<div>
<p>Profile Page</p>
</div>
`;
}
}
customElements.define('profile-page', ProfilePage);
function presentAlert() {
openAlert({
header: 'Alert',
@@ -76,6 +105,7 @@
htmlAttributes: {
'data-testid': 'basic-alert',
},
component: 'profile-page',
});
}

View File

@@ -1984,7 +1984,7 @@ export class Datetime implements ComponentInterface {
});
this.setActiveParts({
...this.getActivePartsWithFallback(),
...activePart,
hour: ev.detail.value,
});
@@ -2024,7 +2024,7 @@ export class Datetime implements ComponentInterface {
});
this.setActiveParts({
...this.getActivePartsWithFallback(),
...activePart,
minute: ev.detail.value,
});
@@ -2070,7 +2070,7 @@ export class Datetime implements ComponentInterface {
});
this.setActiveParts({
...this.getActivePartsWithFallback(),
...activePart,
ampm: ev.detail.value,
hour,
});

View File

@@ -410,13 +410,8 @@ export class Picker implements ComponentInterface {
colEl: HTMLIonPickerColumnElement,
value: string,
zeroBehavior: 'start' | 'end' = 'start'
): boolean => {
if (!value) {
return false;
}
) => {
const behavior = zeroBehavior === 'start' ? /^0+/ : /0$/;
value = value.replace(behavior, '');
const option = Array.from(colEl.querySelectorAll('ion-picker-column-option')).find((el) => {
return el.disabled !== true && el.textContent!.replace(behavior, '') === value;
});
@@ -424,58 +419,6 @@ export class Picker implements ComponentInterface {
if (option) {
colEl.setValue(option.value);
}
return !!option;
};
/**
* Attempts to intelligently search the first and second
* column as if they're number columns for the provided numbers
* where the first two numbers are the first column
* and the last 2 are the last column. Tries to allow for the first
* number to be ignored for situations where typos occurred.
*/
private multiColumnSearch = (
firstColumn: HTMLIonPickerColumnElement,
secondColumn: HTMLIonPickerColumnElement,
input: string
) => {
if (input.length === 0) {
return;
}
const inputArray = input.split('');
const hourValue = inputArray.slice(0, 2).join('');
// Try to find a match for the first two digits in the first column
const foundHour = this.searchColumn(firstColumn, hourValue);
// If we have more than 2 digits and found a match for hours,
// use the remaining digits for the second column (minutes)
if (inputArray.length > 2 && foundHour) {
const minuteValue = inputArray.slice(2, 4).join('');
this.searchColumn(secondColumn, minuteValue);
}
// If we couldn't find a match for the two-digit hour, try single digit approaches
else if (!foundHour && inputArray.length >= 1) {
// First try the first digit as a single-digit hour
let singleDigitHour = inputArray[0];
let singleDigitFound = this.searchColumn(firstColumn, singleDigitHour);
// If that didn't work, try the second digit as a single-digit hour
// (handles case where user made a typo in the first digit, or they typed over themselves)
if (!singleDigitFound) {
inputArray.shift();
singleDigitHour = inputArray[0];
singleDigitFound = this.searchColumn(firstColumn, singleDigitHour);
}
// If we found a single-digit hour and have remaining digits,
// use up to 2 of the remaining digits for the second column
if (singleDigitFound && inputArray.length > 1) {
const remainingDigits = inputArray.slice(1, 3).join('');
this.searchColumn(secondColumn, remainingDigits);
}
}
};
private selectMultiColumn = () => {
@@ -490,15 +433,91 @@ export class Picker implements ComponentInterface {
const lastColumn = numericPickers[1];
let value = inputEl.value;
if (value.length > 4) {
const startIndex = inputEl.value.length - 4;
const newString = inputEl.value.substring(startIndex);
let minuteValue;
switch (value.length) {
case 1:
this.searchColumn(firstColumn, value);
break;
case 2:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacter = inputEl.value.substring(0, 1);
value = firstCharacter === '0' || firstCharacter === '1' ? inputEl.value : firstCharacter;
inputEl.value = newString;
value = newString;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
if (value.length === 1) {
minuteValue = inputEl.value.substring(inputEl.value.length - 1);
this.searchColumn(lastColumn, minuteValue, 'end');
}
break;
case 3:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacterAgain = inputEl.value.substring(0, 1);
value =
firstCharacterAgain === '0' || firstCharacterAgain === '1'
? inputEl.value.substring(0, 2)
: firstCharacterAgain;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
minuteValue = value.length === 1 ? inputEl.value.substring(1) : inputEl.value.substring(2);
this.searchColumn(lastColumn, minuteValue, 'end');
break;
case 4:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacterAgainAgain = inputEl.value.substring(0, 1);
value =
firstCharacterAgainAgain === '0' || firstCharacterAgainAgain === '1'
? inputEl.value.substring(0, 2)
: firstCharacterAgainAgain;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
const minuteValueAgain =
value.length === 1
? inputEl.value.substring(1, inputEl.value.length)
: inputEl.value.substring(2, inputEl.value.length);
this.searchColumn(lastColumn, minuteValueAgain, 'end');
break;
default:
const startIndex = inputEl.value.length - 4;
const newString = inputEl.value.substring(startIndex);
inputEl.value = newString;
this.selectMultiColumn();
break;
}
this.multiColumnSearch(firstColumn, lastColumn, value);
};
/**

View File

@@ -163,172 +163,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(ionChange).toHaveReceivedEventDetail({ value: 12 });
await expect(column).toHaveJSProperty('value', 12);
});
test('should allow typing 22 in a column where the max value is 23 and not just set it to 2', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/28877',
});
await page.setContent(
`
<ion-picker>
<ion-picker-column id="hours"></ion-picker-column>
<ion-picker-column id="minutes"></ion-picker-column>
</ion-picker>
<script>
const hoursColumn = document.querySelector('ion-picker-column#hours');
hoursColumn.numericInput = true;
const hourItems = [
{ text: '01', value: 1 },
{ text: '02', value: 2},
{ text: '20', value: 20 },
{ text: '21', value: 21 },
{ text: '22', value: 22 },
{ text: '23', value: 23 }
];
hourItems.forEach((item) => {
const option = document.createElement('ion-picker-column-option');
option.value = item.value;
option.textContent = item.text;
hoursColumn.appendChild(option);
});
const minutesColumn = document.querySelector('ion-picker-column#minutes');
minutesColumn.numericInput = true;
const minuteItems = [
{ text: '00', value: 0 },
{ text: '15', value: 15 },
{ text: '30', value: 30 },
{ text: '45', value: 45 }
];
minuteItems.forEach((item) => {
const option = document.createElement('ion-picker-column-option');
option.value = item.value;
option.textContent = item.text;
minutesColumn.appendChild(option);
});
</script>
`,
config
);
const hoursColumn = page.locator('ion-picker-column#hours');
const minutesColumn = page.locator('ion-picker-column#minutes');
const hoursIonChange = await (hoursColumn as E2ELocator).spyOnEvent('ionChange');
const minutesIonChange = await (minutesColumn as E2ELocator).spyOnEvent('ionChange');
const highlight = page.locator('ion-picker .picker-highlight');
const box = await highlight.boundingBox();
if (box !== null) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
// Simulate typing '2230' (22 hours, 30 minutes)
await page.keyboard.press('Digit2');
await page.keyboard.press('Digit2');
await page.keyboard.press('Digit3');
await page.keyboard.press('Digit0');
// Ensure the hours column is set to 22
await expect(hoursIonChange).toHaveReceivedEventDetail({ value: 22 });
await expect(hoursColumn).toHaveJSProperty('value', 22);
// Ensure the minutes column is set to 30
await expect(minutesIonChange).toHaveReceivedEventDetail({ value: 30 });
await expect(minutesColumn).toHaveJSProperty('value', 30);
});
test('should set value to 2 and not wait for another digit when max value is 12', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/28877',
});
await page.setContent(
`
<ion-picker>
<ion-picker-column id="hours"></ion-picker-column>
<ion-picker-column id="minutes"></ion-picker-column>
</ion-picker>
<script>
const hoursColumn = document.querySelector('ion-picker-column#hours');
hoursColumn.numericInput = true;
const hourItems = [
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 },
{ text: '06', value: 6 },
{ text: '07', value: 7 },
{ text: '08', value: 8 },
{ text: '09', value: 9 },
{ text: '10', value: 10 },
{ text: '11', value: 11 },
{ text: '12', value: 12 }
];
hourItems.forEach((item) => {
const option = document.createElement('ion-picker-column-option');
option.value = item.value;
option.textContent = item.text;
hoursColumn.appendChild(option);
});
const minutesColumn = document.querySelector('ion-picker-column#minutes');
minutesColumn.numericInput = true;
const minuteItems = [
{ text: '00', value: 0 },
{ text: '15', value: 15 },
{ text: '30', value: 30 },
{ text: '45', value: 45 }
];
minuteItems.forEach((item) => {
const option = document.createElement('ion-picker-column-option');
option.value = item.value;
option.textContent = item.text;
minutesColumn.appendChild(option);
});
</script>
`,
config
);
const hoursColumn = page.locator('ion-picker-column#hours');
const minutesColumn = page.locator('ion-picker-column#minutes');
const hoursIonChange = await (hoursColumn as E2ELocator).spyOnEvent('ionChange');
const minutesIonChange = await (minutesColumn as E2ELocator).spyOnEvent('ionChange');
const highlight = page.locator('ion-picker .picker-highlight');
const box = await highlight.boundingBox();
if (box !== null) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
// Simulate typing '245' (2 hours, 45 minutes)
await page.keyboard.press('Digit2');
await page.keyboard.press('Digit4');
await page.keyboard.press('Digit5');
// Ensure the hours column is set to 2
await expect(hoursIonChange).toHaveReceivedEventDetail({ value: 2 });
await expect(hoursColumn).toHaveJSProperty('value', 2);
// Ensure the minutes column is set to 45
await expect(minutesIonChange).toHaveReceivedEventDetail({ value: 45 });
await expect(minutesColumn).toHaveJSProperty('value', 45);
});
test('pressing Enter should dismiss the keyboard', async ({ page }) => {
test.info().annotations.push({
type: 'issue',

View File

@@ -2,7 +2,7 @@ import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, Method, State, Watch, forceUpdate, h } from '@stencil/core';
import type { ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { addEventListener, removeEventListener, inheritAttributes, getNextSiblingOfType } from '@utils/helpers';
import { addEventListener, removeEventListener, inheritAttributes } from '@utils/helpers';
import { hostContext } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
@@ -65,41 +65,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
this.updateState();
}
private waitForSegmentContent(ionSegment: HTMLIonSegmentElement | null, contentId: string): Promise<HTMLElement> {
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | undefined = undefined;
let animationFrameId: number;
const check = () => {
if (!ionSegment) {
reject(new Error(`Segment not found when looking for Segment Content`));
return;
}
const segmentView = getNextSiblingOfType<HTMLIonSegmentViewElement>(ionSegment); // Skip the text nodes
const segmentContent = segmentView?.querySelector(
`ion-segment-content[id="${contentId}"]`
) as HTMLIonSegmentContentElement | null;
if (segmentContent && timeoutId) {
clearTimeout(timeoutId); // Clear the timeout if the segmentContent is found
cancelAnimationFrame(animationFrameId);
resolve(segmentContent);
} else {
animationFrameId = requestAnimationFrame(check); // Keep checking on the next animation frame
}
};
check();
// Set a timeout to reject the promise
timeoutId = setTimeout(() => {
cancelAnimationFrame(animationFrameId);
reject(new Error(`Unable to find Segment Content with id="${contentId} within 1000 ms`));
}, 1000);
});
}
async connectedCallback() {
connectedCallback() {
const segmentEl = (this.segmentEl = this.el.closest('ion-segment'));
if (segmentEl) {
this.updateState();
@@ -107,27 +73,8 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
addEventListener(segmentEl, 'ionStyle', this.updateStyle);
}
// Return if there is no contentId defined
if (!this.contentId) return;
let segmentContent;
try {
// Attempt to find the Segment Content by its contentId
segmentContent = await this.waitForSegmentContent(segmentEl, this.contentId);
} catch (error) {
// If no associated Segment Content exists, log an error and return
console.error('Segment Button: ', (error as Error).message);
return;
}
// Ensure the found element is a valid ION-SEGMENT-CONTENT
if (segmentContent.tagName !== 'ION-SEGMENT-CONTENT') {
console.error(`Segment Button: Element with id="${this.contentId}" is not an <ion-segment-content> element.`);
return;
}
// Prevent buttons from being disabled when associated with segment content
if (this.disabled) {
if (this.contentId && this.disabled) {
console.warn(`Segment Button: Segment buttons cannot be disabled when associated with an <ion-segment-content>.`);
this.disabled = false;
}
@@ -146,6 +93,24 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
this.inheritedAttributes = {
...inheritAttributes(this.el, ['aria-label']),
};
// Return if there is no contentId defined
if (!this.contentId) return;
// Attempt to find the Segment Content by its contentId
const segmentContent = document.getElementById(this.contentId) as HTMLIonSegmentContentElement | null;
// If no associated Segment Content exists, log an error and return
if (!segmentContent) {
console.error(`Segment Button: Unable to find Segment Content with id="${this.contentId}".`);
return;
}
// Ensure the found element is a valid ION-SEGMENT-CONTENT
if (segmentContent.tagName !== 'ION-SEGMENT-CONTENT') {
console.error(`Segment Button: Element with id="${this.contentId}" is not an <ion-segment-content> element.`);
return;
}
}
private get hasLabel() {

View File

@@ -388,17 +388,6 @@ export const shallowEqualStringMap = (
return true;
};
export const getNextSiblingOfType = <T extends Element>(element: Element): T | null => {
let sibling = element.nextSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && (sibling as T) !== null) {
return sibling as T;
}
sibling = sibling.nextSibling;
}
return null;
};
/**
* Checks input for usable number. Not NaN and not Infinite.
*/

View File

@@ -125,7 +125,7 @@ Shorthand for ionActionSheetDidDismiss.
@ProxyCmp({
inputs: ['animated', 'backdropDismiss', 'buttons', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
inputs: ['animated', 'backdropDismiss', 'buttons', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
})
@Component({
@@ -133,7 +133,7 @@ Shorthand for ionActionSheetDidDismiss.
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'backdropDismiss', 'buttons', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
inputs: ['animated', 'backdropDismiss', 'buttons', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
})
export class IonAlert {
protected el: HTMLIonAlertElement;

View File

@@ -205,7 +205,7 @@ Shorthand for ionActionSheetDidDismiss.
@ProxyCmp({
defineCustomElementFn: defineIonAlert,
inputs: ['animated', 'backdropDismiss', 'buttons', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
inputs: ['animated', 'backdropDismiss', 'buttons', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss']
})
@Component({
@@ -213,7 +213,7 @@ Shorthand for ionActionSheetDidDismiss.
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'backdropDismiss', 'buttons', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
inputs: ['animated', 'backdropDismiss', 'buttons', 'component', 'componentProps', 'cssClass', 'enterAnimation', 'header', 'htmlAttributes', 'inputs', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'subHeader', 'translucent', 'trigger'],
standalone: true
})
export class IonAlert {