mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 07:41:51 +08:00
Merge branch 'main' into chore/update-from-main
This commit is contained in:
@ -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
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
35
core/src/components/textarea/test/basic/textarea.e2e.ts
Normal file
35
core/src/components/textarea/test/basic/textarea.e2e.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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">
|
||||
{/**
|
||||
|
||||
38
core/src/components/toggle/test/basic/toggle.e2e.ts
Normal file
38
core/src/components/toggle/test/basic/toggle.e2e.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user