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

This commit is contained in:
Brandy Smith
2025-06-04 10:33:07 -04:00
54 changed files with 861 additions and 373 deletions

View File

@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, State, h } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, State, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
@ -158,6 +158,26 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
*/
@Event() ionBlur!: EventEmitter<void>;
/**
* This component is used within the `ion-input-password-toggle` component
* to toggle the visibility of the password input.
* These attributes need to update based on the state of the password input.
* Otherwise, the values will be stale.
*
* @param newValue
* @param _oldValue
* @param propName
*/
@Watch('aria-checked')
@Watch('aria-label')
onAriaChanged(newValue: string, _oldValue: string, propName: string) {
this.inheritedAttributes = {
...this.inheritedAttributes,
[propName]: newValue,
};
forceUpdate(this);
}
/**
* This is responsible for rendering a hidden native
* button element inside the associated form. This allows

View File

@ -199,6 +199,14 @@ export class Checkbox implements ComponentInterface {
this.toggleChecked(ev);
};
/**
* Stops propagation when the display label is clicked,
* otherwise, two clicks will be triggered.
*/
private onDivLabelClick = (ev: MouseEvent) => {
ev.stopPropagation();
};
private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
@ -314,6 +322,7 @@ export class Checkbox implements ComponentInterface {
}}
part="label"
id={this.inputLabelId}
onClick={this.onDivLabelClick}
>
<slot></slot>
{this.renderHintText()}

View File

@ -99,4 +99,38 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(ionChange).not.toHaveReceivedEvent();
});
});
test.describe(title('checkbox: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(`<ion-checkbox onclick="console.log('click called')">Test Checkbox</ion-checkbox>`, config);
// Track calls to the exposed function
let clickCount = 0;
page.on('console', (msg) => {
if (msg.text().includes('click called')) {
clickCount++;
}
});
const input = page.locator('div.label-text-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 5,
y: 5,
},
});
// Verify the click was triggered exactly once
expect(clickCount).toBe(1);
});
});
});

View File

@ -1276,21 +1276,20 @@ export class Datetime implements ComponentInterface {
}
/**
* If there are multiple values, pick an arbitrary one to clamp to. This way,
* if the values are across months, we always show at least one of them. Note
* that the values don't necessarily have to be in order.
* If there are multiple values, clamp to the last one.
* This is because the last value is the one that the user
* has most recently interacted with.
*/
const singleValue = Array.isArray(valueToProcess) ? valueToProcess[0] : valueToProcess;
const singleValue = Array.isArray(valueToProcess) ? valueToProcess[valueToProcess.length - 1] : valueToProcess;
const targetValue = clampDate(singleValue, minParts, maxParts);
const { month, day, year, hour, minute } = targetValue;
const ampm = parseAmPm(hour!);
/**
* Since `activeParts` indicates a value that
* been explicitly selected either by the
* user or the app, only update `activeParts`
* if the `value` property is set.
* Since `activeParts` indicates a value that been explicitly selected
* either by the user or the app, only update `activeParts` if the
* `value` property is set.
*/
if (hasValue) {
if (Array.isArray(valueToProcess)) {
@ -1314,53 +1313,29 @@ export class Datetime implements ComponentInterface {
this.activeParts = [];
}
/**
* Only animate if:
* 1. We're using grid style (wheel style pickers should just jump to new value)
* 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to)
* 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example)
* 4. The month/year picker is not open (since you wouldn't see the animation anyway)
*/
const didChangeMonth =
(month !== undefined && month !== workingParts.month) || (year !== undefined && year !== workingParts.year);
const bodyIsVisible = el.classList.contains('datetime-ready');
const { isGridStyle, showMonthAndYear } = this;
let areAllSelectedDatesInSameMonth = true;
if (Array.isArray(valueToProcess)) {
const firstMonth = valueToProcess[0].month;
for (const date of valueToProcess) {
if (date.month !== firstMonth) {
areAllSelectedDatesInSameMonth = false;
break;
}
}
}
/**
* If there is more than one date selected
* and the dates aren't all in the same month,
* then we should neither animate to the date
* nor update the working parts because we do
* not know which date the user wants to view.
*/
if (areAllSelectedDatesInSameMonth) {
if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) {
this.animateToDate(targetValue);
} else {
/**
* We only need to do this if we didn't just animate to a new month,
* since that calls prevMonth/nextMonth which calls setWorkingParts for us.
*/
this.setWorkingParts({
month,
day,
year,
hour,
minute,
ampm,
});
}
if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) {
/**
* Only animate if:
* 1. We're using grid style (wheel style pickers should just jump to new value)
* 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to)
* 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example)
* 4. The month/year picker is not open (since you wouldn't see the animation anyway)
*/
this.animateToDate(targetValue);
} else {
this.setWorkingParts({
month,
day,
year,
hour,
minute,
ampm,
});
}
};

View File

@ -174,18 +174,6 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(monthYear).toHaveText(/June 2022/);
});
test('should not scroll to new month when value is updated with dates in different months', async ({ page }) => {
const datetime = await datetimeFixture.goto(config, MULTIPLE_DATES);
await datetime.evaluate((el: HTMLIonDatetimeElement, dates: string[]) => {
el.value = dates;
}, MULTIPLE_DATES_SEPARATE_MONTHS);
await page.waitForChanges();
const monthYear = datetime.locator('.calendar-month-year');
await expect(monthYear).toHaveText(/June 2022/);
});
test('with buttons, should only update value when confirm is called', async ({ page }) => {
const datetime = await datetimeFixture.goto(config, SINGLE_DATE, { showDefaultButtons: true });
const june2Button = datetime.locator('[data-month="6"][data-day="2"]');
@ -311,4 +299,41 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(header).toHaveText('Mon, Oct 10');
});
});
test.describe('with selected days in different months', () => {
test(`set the active month view to the latest value's month`, async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29094',
});
const datetime = await new DatetimeMultipleFixture(page).goto(config, MULTIPLE_DATES_SEPARATE_MONTHS);
const calendarMonthYear = datetime.locator('.calendar-month-year');
await expect(calendarMonthYear).toHaveText(/May 2022/);
});
test('does not change the active month view when selecting a day in a different month', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29094',
});
const datetime = await new DatetimeMultipleFixture(page).goto(config, MULTIPLE_DATES_SEPARATE_MONTHS);
const nextButton = page.locator('.calendar-next-prev ion-button:nth-child(2)');
const calendarMonthYear = datetime.locator('.calendar-month-year');
await nextButton.click();
await expect(calendarMonthYear).toHaveText(/June 2022/);
const june8Button = datetime.locator('[data-month="6"][data-day="8"]');
await june8Button.click();
await expect(calendarMonthYear).toHaveText(/June 2022/);
});
});
});

View File

@ -127,7 +127,7 @@ export class InputPasswordToggle implements ComponentInterface {
fill="clear"
shape="round"
aria-checked={isPasswordVisible ? 'true' : 'false'}
aria-label="show password"
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
role="switch"
type="button"
onPointerDown={(ev) => {

View File

@ -20,4 +20,38 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
expect(results.violations).toEqual([]);
});
});
test.describe(title('input password toggle: aria attributes'), () => {
test('should inherit aria attributes to inner button on load', async ({ page }) => {
await page.setContent(
`
<ion-input label="input" type="password">
<ion-input-password-toggle slot="end"></ion-input-password-toggle>
</ion-input>
`,
config
);
const nativeButton = page.locator('ion-input-password-toggle button');
await expect(nativeButton).toHaveAttribute('aria-label', 'Show password');
await expect(nativeButton).toHaveAttribute('aria-checked', 'false');
});
test('should inherit aria attributes to inner button after toggle', async ({ page }) => {
await page.setContent(
`
<ion-input label="input" type="password">
<ion-input-password-toggle slot="end"></ion-input-password-toggle>
</ion-input>
`,
config
);
const nativeButton = page.locator('ion-input-password-toggle button');
await nativeButton.click();
await expect(nativeButton).toHaveAttribute('aria-label', 'Hide password');
await expect(nativeButton).toHaveAttribute('aria-checked', 'true');
});
});
});

View File

@ -720,6 +720,18 @@ export class Input implements ComponentInterface {
return this.label !== undefined || this.labelSlot !== null;
}
/**
* Stops propagation when the label is clicked,
* otherwise, two clicks will be triggered.
*/
private onLabelClick = (ev: MouseEvent) => {
// Only stop propagation if the click was directly on the label
// and not on the input or other child elements
if (ev.target === ev.currentTarget) {
ev.stopPropagation();
}
};
/**
* Renders the border container
* when fill="outline".
@ -815,9 +827,9 @@ export class Input implements ComponentInterface {
* interactable, clicking the label would focus that instead
* since it comes before the input in the DOM.
*/}
<label class="input-wrapper" htmlFor={inputId}>
<label class="input-wrapper" htmlFor={inputId} onClick={this.onLabelClick}>
{this.renderLabelContainer()}
<div class="native-wrapper">
<div class="native-wrapper" onClick={this.onLabelClick}>
<slot name="start"></slot>
<input
class="native-input"

View File

@ -130,4 +130,81 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
await expect(item).toHaveScreenshot(screenshot(`input-with-clear-button-item-color`));
});
});
test.describe(title('input: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(
`
<ion-input
label="Click Me"
value="Test Value"
></ion-input>
`,
config
);
// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const input = page.locator('label.input-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 5,
y: 5,
},
});
// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-input');
});
test('should trigger onclick only once when clicking the wrapper', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(
`
<ion-input
label="Click Me"
value="Test Value"
label-placement="floating"
></ion-input>
`,
config
);
// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const input = page.locator('div.native-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 1,
y: 1,
},
});
// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-input');
});
});
});

View File

@ -41,50 +41,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
.addElement(baseEl)
.easing('cubic-bezier(0.32,0.72,0,1)')
.duration(500)
.addAnimation([wrapperAnimation])
.beforeAddWrite(() => {
if (expandToScroll) {
// Scroll can only be done when the modal is fully expanded.
return;
}
/**
* There are some browsers that causes flickering when
* dragging the content when scroll is enabled at every
* breakpoint. This is due to the wrapper element being
* transformed off the screen and having a snap animation.
*
* A workaround is to clone the footer element and append
* it outside of the wrapper element. This way, the footer
* is still visible and the drag can be done without
* flickering. The original footer is hidden until the modal
* is dismissed. This maintains the animation of the footer
* when the modal is dismissed.
*
* The workaround needs to be done before the animation starts
* so there are no flickering issues.
*/
const ionFooter = baseEl.querySelector('ion-footer');
/**
* This check is needed to prevent more than one footer
* from being appended to the shadow root.
* Otherwise, iOS and MD enter animations would append
* the footer twice.
*/
const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer');
if (ionFooter && !ionFooterAlreadyAppended) {
const footerHeight = ionFooter.clientHeight;
const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement;
baseEl.shadowRoot!.appendChild(clonedFooter);
ionFooter.style.setProperty('display', 'none');
ionFooter.setAttribute('aria-hidden', 'true');
// Padding is added to prevent some content from being hidden.
const page = baseEl.querySelector('.ion-page') as HTMLElement;
page.style.setProperty('padding-bottom', `${footerHeight}px`);
}
});
.addAnimation([wrapperAnimation]);
if (contentAnimation) {
baseAnimation.addAnimation(contentAnimation);

View File

@ -19,7 +19,7 @@ const createLeaveAnimation = () => {
* iOS Modal Leave Animation
*/
export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 500): Animation => {
const { presentingEl, currentBreakpoint, expandToScroll } = opts;
const { presentingEl, currentBreakpoint } = opts;
const root = getElementRoot(baseEl);
const { wrapperAnimation, backdropAnimation } =
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation();
@ -32,33 +32,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
.addElement(baseEl)
.easing('cubic-bezier(0.32,0.72,0,1)')
.duration(duration)
.addAnimation(wrapperAnimation)
.beforeAddWrite(() => {
if (expandToScroll) {
// Scroll can only be done when the modal is fully expanded.
return;
}
/**
* If expandToScroll is disabled, we need to swap
* the visibility to the original, so the footer
* dismisses with the modal and doesn't stay
* until the modal is removed from the DOM.
*/
const ionFooter = baseEl.querySelector('ion-footer');
if (ionFooter) {
const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!;
ionFooter.style.removeProperty('display');
ionFooter.removeAttribute('aria-hidden');
clonedFooter.style.setProperty('display', 'none');
clonedFooter.setAttribute('aria-hidden', 'true');
const page = baseEl.querySelector('.ion-page') as HTMLElement;
page.style.removeProperty('padding-bottom');
}
});
.addAnimation(wrapperAnimation);
if (presentingEl) {
const isMobile = window.innerWidth < 768;

View File

@ -37,56 +37,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption
// The content animation is only added if scrolling is enabled for
// all the breakpoints.
expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!);
!expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!);
const baseAnimation = createAnimation()
.addElement(baseEl)
.easing('cubic-bezier(0.36,0.66,0.04,1)')
.duration(280)
.addAnimation([backdropAnimation, wrapperAnimation])
.beforeAddWrite(() => {
if (expandToScroll) {
// Scroll can only be done when the modal is fully expanded.
return;
}
/**
* There are some browsers that causes flickering when
* dragging the content when scroll is enabled at every
* breakpoint. This is due to the wrapper element being
* transformed off the screen and having a snap animation.
*
* A workaround is to clone the footer element and append
* it outside of the wrapper element. This way, the footer
* is still visible and the drag can be done without
* flickering. The original footer is hidden until the modal
* is dismissed. This maintains the animation of the footer
* when the modal is dismissed.
*
* The workaround needs to be done before the animation starts
* so there are no flickering issues.
*/
const ionFooter = baseEl.querySelector('ion-footer');
/**
* This check is needed to prevent more than one footer
* from being appended to the shadow root.
* Otherwise, iOS and MD enter animations would append
* the footer twice.
*/
const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer');
if (ionFooter && !ionFooterAlreadyAppended) {
const footerHeight = ionFooter.clientHeight;
const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement;
baseEl.shadowRoot!.appendChild(clonedFooter);
ionFooter.style.setProperty('display', 'none');
ionFooter.setAttribute('aria-hidden', 'true');
// Padding is added to prevent some content from being hidden.
const page = baseEl.querySelector('.ion-page') as HTMLElement;
page.style.setProperty('padding-bottom', `${footerHeight}px`);
}
});
.addAnimation([backdropAnimation, wrapperAnimation]);
if (contentAnimation) {
baseAnimation.addAnimation(contentAnimation);

View File

@ -21,7 +21,7 @@ const createLeaveAnimation = () => {
* Md Modal Leave Animation
*/
export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => {
const { currentBreakpoint, expandToScroll } = opts;
const { currentBreakpoint } = opts;
const root = getElementRoot(baseEl);
const { wrapperAnimation, backdropAnimation } =
currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation();
@ -32,33 +32,7 @@ export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption
const baseAnimation = createAnimation()
.easing('cubic-bezier(0.47,0,0.745,0.715)')
.duration(200)
.addAnimation([backdropAnimation, wrapperAnimation])
.beforeAddWrite(() => {
if (expandToScroll) {
// Scroll can only be done when the modal is fully expanded.
return;
}
/**
* If expandToScroll is disabled, we need to swap
* the visibility to the original, so the footer
* dismisses with the modal and doesn't stay
* until the modal is removed from the DOM.
*/
const ionFooter = baseEl.querySelector('ion-footer');
if (ionFooter) {
const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!;
ionFooter.style.removeProperty('display');
ionFooter.removeAttribute('aria-hidden');
clonedFooter.style.setProperty('display', 'none');
clonedFooter.setAttribute('aria-hidden', 'true');
const page = baseEl.querySelector('.ion-page') as HTMLElement;
page.style.removeProperty('padding-bottom');
}
});
.addAnimation([backdropAnimation, wrapperAnimation]);
return baseAnimation;
};

View File

@ -84,6 +84,9 @@ export const createSheetGesture = (
let offset = 0;
let canDismissBlocksGesture = false;
let cachedScrollEl: HTMLElement | null = null;
let cachedFooterEl: HTMLIonFooterElement | null = null;
let cachedFooterYPosition: number | null = null;
let currentFooterState: 'moving' | 'stationary' | null = null;
const canDismissMaxStep = 0.95;
const maxBreakpoint = breakpoints[breakpoints.length - 1];
const minBreakpoint = breakpoints[0];
@ -118,33 +121,74 @@ export const createSheetGesture = (
};
/**
* Toggles the visible modal footer when `expandToScroll` is disabled.
* @param footer The footer to show.
* Toggles the footer to an absolute position while moving to prevent
* it from shaking while the sheet is being dragged.
* @param newPosition Whether the footer is in a moving or stationary position.
*/
const swapFooterVisibility = (footer: 'original' | 'cloned') => {
const originalFooter = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null;
if (!originalFooter) {
return;
const swapFooterPosition = (newPosition: 'moving' | 'stationary') => {
if (!cachedFooterEl) {
cachedFooterEl = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null;
if (!cachedFooterEl) {
return;
}
}
const clonedFooter = wrapperEl.nextElementSibling as HTMLIonFooterElement;
const footerToHide = footer === 'original' ? clonedFooter : originalFooter;
const footerToShow = footer === 'original' ? originalFooter : clonedFooter;
const page = baseEl.querySelector('.ion-page') as HTMLElement | null;
footerToShow.style.removeProperty('display');
footerToShow.removeAttribute('aria-hidden');
currentFooterState = newPosition;
if (newPosition === 'stationary') {
// Reset positioning styles to allow normal document flow
cachedFooterEl.classList.remove('modal-footer-moving');
cachedFooterEl.style.removeProperty('position');
cachedFooterEl.style.removeProperty('width');
cachedFooterEl.style.removeProperty('height');
cachedFooterEl.style.removeProperty('top');
cachedFooterEl.style.removeProperty('left');
page?.style.removeProperty('padding-bottom');
const page = baseEl.querySelector('.ion-page') as HTMLElement;
if (footer === 'original') {
page.style.removeProperty('padding-bottom');
// Move to page
page?.appendChild(cachedFooterEl);
} else {
const pagePadding = footerToShow.clientHeight;
page.style.setProperty('padding-bottom', `${pagePadding}px`);
}
// Get both the footer and document body positions
const cachedFooterElRect = cachedFooterEl.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
footerToHide.style.setProperty('display', 'none');
footerToHide.setAttribute('aria-hidden', 'true');
// Add padding to the parent element to prevent content from being hidden
// when the footer is positioned absolutely. This has to be done before we
// make the footer absolutely positioned or we may accidentally cause the
// sheet to scroll.
const footerHeight = cachedFooterEl.clientHeight;
page?.style.setProperty('padding-bottom', `${footerHeight}px`);
// Apply positioning styles to keep footer at bottom
cachedFooterEl.classList.add('modal-footer-moving');
// Calculate absolute position relative to body
// We need to subtract the body's offsetTop to get true position within document.body
const absoluteTop = cachedFooterElRect.top - bodyRect.top;
const absoluteLeft = cachedFooterElRect.left - bodyRect.left;
// Capture the footer's current dimensions and hard code them during the drag
cachedFooterEl.style.setProperty('position', 'absolute');
cachedFooterEl.style.setProperty('width', `${cachedFooterEl.clientWidth}px`);
cachedFooterEl.style.setProperty('height', `${cachedFooterEl.clientHeight}px`);
cachedFooterEl.style.setProperty('top', `${absoluteTop}px`);
cachedFooterEl.style.setProperty('left', `${absoluteLeft}px`);
// Also cache the footer Y position, which we use to determine if the
// sheet has been moved below the footer. When that happens, we need to swap
// the position back so it will collapse correctly.
cachedFooterYPosition = absoluteTop;
// If there's a toolbar, we need to combine the toolbar height with the footer position
// because the toolbar moves with the drag handle, so when it starts overlapping the footer,
// we need to account for that.
const toolbar = baseEl.querySelector('ion-toolbar') as HTMLIonToolbarElement | null;
if (toolbar) {
cachedFooterYPosition -= toolbar.clientHeight;
}
document.body.appendChild(cachedFooterEl);
}
};
/**
@ -247,12 +291,11 @@ export const createSheetGesture = (
/**
* If expandToScroll is disabled, we need to swap
* the footer visibility to the original, so if the modal
* is dismissed, the footer dismisses with the modal
* and doesn't stay on the screen after the modal is gone.
* the footer position to moving so that it doesn't shake
* while the sheet is being dragged.
*/
if (!expandToScroll) {
swapFooterVisibility('original');
swapFooterPosition('moving');
}
/**
@ -275,6 +318,21 @@ export const createSheetGesture = (
};
const onMove = (detail: GestureDetail) => {
/**
* If `expandToScroll` is disabled, we need to see if we're currently below
* the footer element and the footer is in a stationary position. If so,
* we need to make the stationary the original position so that the footer
* collapses with the sheet.
*/
if (!expandToScroll && cachedFooterYPosition !== null && currentFooterState !== null) {
// Check if we need to swap the footer position
if (detail.currentY >= cachedFooterYPosition && currentFooterState === 'moving') {
swapFooterPosition('stationary');
} else if (detail.currentY < cachedFooterYPosition && currentFooterState === 'stationary') {
swapFooterPosition('moving');
}
}
/**
* If `expandToScroll` is disabled, and an upwards swipe gesture is done within
* the scrollable content, we should not allow the swipe gesture to continue.
@ -431,15 +489,6 @@ export const createSheetGesture = (
*/
gesture.enable(false);
/**
* If expandToScroll is disabled, we need to swap
* the footer visibility to the cloned one so the footer
* doesn't flicker when the sheet's height is animated.
*/
if (!expandToScroll && shouldRemainOpen) {
swapFooterVisibility('cloned');
}
if (shouldPreventDismiss) {
handleCanDismiss(baseEl, animation);
} else if (!shouldRemainOpen) {
@ -457,11 +506,31 @@ export const createSheetGesture = (
contentEl.scrollY = true;
}
/**
* If expandToScroll is disabled and we're animating
* to close the sheet, we need to swap
* the footer position to stationary so that it
* will collapse correctly. We cannot just always swap
* here or it'll be jittery while animating movement.
*/
if (!expandToScroll && snapToBreakpoint === 0) {
swapFooterPosition('stationary');
}
return new Promise<void>((resolve) => {
animation
.onFinish(
() => {
if (shouldRemainOpen) {
/**
* If expandToScroll is disabled, we need to swap
* the footer position to stationary so that it
* will act as it would by default.
*/
if (!expandToScroll) {
swapFooterPosition('stationary');
}
/**
* Once the snapping animation completes,
* we need to reset the animation to go

View File

@ -87,16 +87,3 @@
:host(.modal-sheet) .modal-wrapper {
@include border-radius(var(--border-radius), var(--border-radius), 0, 0);
}
// iOS Sheet Modal - Scroll at all breakpoints
// --------------------------------------------------
/**
* Sheet modals require an additional padding as mentioned in the
* `core.scss` file. However, there's a workaround that requires
* a cloned footer to be added to the modal. This is only necessary
* because the core styles are not being applied to the cloned footer.
*/
:host(.modal-sheet.modal-no-expand-scroll) ion-footer ion-toolbar:first-of-type {
padding-top: $modal-sheet-padding-top;
}

View File

@ -386,7 +386,7 @@ export class PickerColumnCmp implements ComponentInterface {
const colEl = this.optsEl;
if (colEl) {
// DOM READ
// We perfom a DOM read over a rendered item, this needs to happen after the first render or after the the column has changed
// We perfom a DOM read over a rendered item, this needs to happen after the first render or after the column has changed
this.optHeight = colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0;
}
this.refresh(forceRefresh, animated);

View File

@ -101,7 +101,7 @@ export class Searchbar implements ComponentInterface {
@Prop() cancelButtonIcon = config.get('backButtonIcon', arrowBackSharp) as string;
/**
* Set the the cancel button text. Only applies to `ios` mode.
* Set the cancel button text. Only applies to `ios` mode.
*/
@Prop() cancelButtonText = 'Cancel';

View File

@ -911,6 +911,18 @@ export class Select implements ComponentInterface {
return this.label !== undefined || this.labelSlot !== null;
}
/**
* Stops propagation when the label is clicked,
* otherwise, two clicks will be triggered.
*/
private onLabelClick = (ev: MouseEvent) => {
// Only stop propagation if the click was directly on the label
// and not on the input or other child elements
if (ev.target === ev.currentTarget) {
ev.stopPropagation();
}
};
/**
* Renders the border container
* when fill="outline".
@ -1173,7 +1185,7 @@ export class Select implements ComponentInterface {
[`select-label-placement-${labelPlacement}`]: true,
})}
>
<label class="select-wrapper" id="select-label">
<label class="select-wrapper" id="select-label" onClick={this.onLabelClick}>
{this.renderLabelContainer()}
<div class="select-wrapper-inner">
<slot name="start"></slot>

View File

@ -1,6 +1,6 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright';
import { configs, test } from '@utils/test/playwright';
/**
* This checks that certain overlays open correctly. While the
@ -150,6 +150,45 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(alerts.length).toBe(1);
});
});
test.describe(title('select: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(
`
<ion-select aria-label="Fruit" interface="alert">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
</ion-select>
`,
config
);
// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const input = page.locator('label.select-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 5,
y: 5,
},
});
// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-select');
});
});
});
/**

View File

@ -0,0 +1,35 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(`<ion-textarea label="Textarea"></ion-textarea>`, config);
// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const input = page.locator('label.textarea-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 5,
y: 5,
},
});
// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});
});
});

View File

@ -572,6 +572,18 @@ export class Textarea implements ComponentInterface {
return this.label !== undefined || this.labelSlot !== null;
}
/**
* Stops propagation when the label is clicked,
* otherwise, two clicks will be triggered.
*/
private onLabelClick = (ev: MouseEvent) => {
// Only stop propagation if the click was directly on the label
// and not on the input or other child elements
if (ev.target === ev.currentTarget) {
ev.stopPropagation();
}
};
/**
* Renders the border container when fill="outline".
*/
@ -726,7 +738,7 @@ export class Textarea implements ComponentInterface {
* interactable, clicking the label would focus that instead
* since it comes before the textarea in the DOM.
*/}
<label class="textarea-wrapper" htmlFor={inputId}>
<label class="textarea-wrapper" htmlFor={inputId} onClick={this.onLabelClick}>
{this.renderLabelContainer()}
<div class="textarea-wrapper-inner">
{/**

View File

@ -0,0 +1,38 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('toggle: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(`<ion-toggle onclick="console.log('click called')">my label</ion-toggle>`, config);
// Track calls to the exposed function
let clickCount = 0;
page.on('console', (msg) => {
if (msg.text().includes('click called')) {
clickCount++;
}
});
const input = page.locator('div.label-text-wrapper');
// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
position: {
x: 5,
y: 5,
},
});
// Verify the click was triggered exactly once
expect(clickCount).toBe(1);
});
});
});

View File

@ -277,6 +277,14 @@ export class Toggle implements ComponentInterface {
}
};
/**
* Stops propagation when the display label is clicked,
* otherwise, two clicks will be triggered.
*/
private onDivLabelClick = (ev: MouseEvent) => {
ev.stopPropagation();
};
private onFocus = () => {
this.ionFocus.emit();
};
@ -446,6 +454,7 @@ export class Toggle implements ComponentInterface {
}}
part="label"
id={inputLabelId}
onClick={this.onDivLabelClick}
>
<slot></slot>
{this.renderHintText()}