Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import {
forceUpdate,
h,
} from '@stencil/core';
import type { NotchController, StartContainerController } from '@utils/forms';
import { createNotchController, createStartContainerController, checkInvalidState } from '@utils/forms';
import type { NotchController, SlottedClickController, StartContainerController } from '@utils/forms';
import {
createNotchController,
createSlottedClickController,
createStartContainerController,
checkInvalidState,
} from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers';
import { createSlotMutationController } from '@utils/slot-mutation-controller';
Expand Down Expand Up @@ -56,6 +61,7 @@ export class Input implements ComponentInterface {
private notchSpacerEl: HTMLElement | undefined;
private startContainerController?: StartContainerController;
private startContainerEl: HTMLElement | undefined;
private slottedClickController?: SlottedClickController;

private originalIonInput?: EventEmitter<InputInputEventDetail>;

Expand Down Expand Up @@ -392,11 +398,7 @@ export class Input implements ComponentInterface {
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
const nativeInput = this.nativeInput;
if (nativeInput && ev.target === nativeInput) {
ev.stopPropagation();
this.el.click();
}
this.slottedClickController?.handleClickCapture(ev);
}

componentWillLoad() {
Expand Down Expand Up @@ -433,6 +435,8 @@ export class Input implements ComponentInterface {

this.startContainerController.calculateStartContainerWidth();

this.slottedClickController = createSlottedClickController(el, () => this.nativeInput);

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
Expand Down
67 changes: 67 additions & 0 deletions core/src/components/input/test/basic/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,70 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
});
});

/**
* This behavior does not vary across directions/modes
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: slotted click'), () => {
test.beforeEach(async ({ page }) => {
await page.setContent(
`
<ion-input label="Email">
<ion-icon id="start-icon" slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button id="end-button" slot="end" aria-label="Show password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
`,
config
);
});

test('should emit one click when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#start-icon').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');
});

test('should focus the input when a slotted icon is clicked', async ({ page }) => {
await page.locator('#start-icon').click();

await expect(page.locator('ion-input input')).toBeFocused();
});

test('should emit one click when a slotted button is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#end-button').click();

expect(clickEvent).toHaveReceivedEventTimes(1);
});

test('should not focus the input when a slotted button is clicked', async ({ page }) => {
await page.locator('#end-button').click();

await expect(page.locator('ion-input input')).not.toBeFocused();
});

test('should emit one click when the input is clicked after slotted content', async ({ page }) => {
/**
* Clicking a slotted button does not produce a forwarded click for the
* input to ignore, so the following click on the input itself must
* still be emitted.
*/
await page.locator('#end-button').click();

const clickEvent = await page.spyOnEvent('click');

await page.locator('ion-input input').click();

expect(clickEvent).toHaveReceivedEventTimes(1);
});
});
});
112 changes: 78 additions & 34 deletions core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
import {
Build,
Component,
Element,
Event,
Host,
Listen,
Method,
Prop,
State,
Watch,
h,
forceUpdate,
} from '@stencil/core';
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
import { focusableQueryString } from '@utils/focus-trap';
import type { NotchController, StartContainerController } from '@utils/forms';
import {
compareOptions,
createNotchController,
createStartContainerController,
isOptionSelected,
checkInvalidState,
isSlottedClick,
} from '@utils/forms';
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import { focusVisibleElement, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
Expand Down Expand Up @@ -91,6 +106,14 @@ export class Select implements ComponentInterface {
private startContainerEl: HTMLElement | undefined;
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);

/**
* `true` if the click currently being dispatched started on content slotted
* into the select. Used to ignore the click the wrapping label forwards to
* the internal button so that a single click is not emitted twice and does
* not open the select.
*/
private hasSlottedClick = false;

@Element() el!: HTMLIonSelectElement;

@State() isExpanded = false;
Expand Down Expand Up @@ -993,42 +1016,54 @@ export class Select implements ComponentInterface {
this.ionStyle.emit(style);
}

private onClick = (ev: UIEvent) => {
const target = ev.target as HTMLElement;
const closestSlot = target.closest('[slot="start"], [slot="end"]');
/**
* Clicking slotted content also clicks the <label> that wraps the slots.
* The label has no `for` attribute, so the browser forwards the click to its
* first labelable descendant, the internal button. That forwarded click
* bubbles back out of the shadow root targeting the host, which would emit a
* second click event and open the select.
*
* The forwarded click is swallowed here, during the capture phase, so it
* never reaches listeners on the host. The click on the slotted content
* itself is left alone so that slotted links, checkboxes and buttons keep
* their default behavior, and so click handlers on slotted content still
* fire in React. React attaches a native "click" listener on the root
* element and dispatches its synthetic event from there, so calling
* stopPropagation() on the slotted click would stop those handlers running.
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
if (isSlottedClick(ev, this.el)) {
this.hasSlottedClick = true;

if (target === this.el || closestSlot === null) {
this.setFocus();
this.open(ev);
} else {
/**
* Prevent clicks to the start/end slots from opening the select.
* We ensure the target isn't this element in case the select is slotted
* in, for example, an item. This would prevent the select from ever
* being opened since the element itself has slot="start"/"end".
*
* Clicking a slotted element also causes a click
* on the <label> element (since it wraps the slots).
* Clicking <label> dispatches another click event on
* the native form control that then bubbles up to this
* listener. This additional event targets the host
* element, so the select overlay is opened.
*
* When the slotted elements are clicked (and therefore
* the ancestor <label> element) we want to prevent the label
* from dispatching another click event.
*
* Do not call stopPropagation() because this will cause
* click handlers on the slotted elements to never fire in React.
* When developers do onClick in React a native "click" listener
* is added on the root element, not the slotted element. When that
* native click listener fires, React then dispatches the synthetic
* click event on the slotted element. However, if stopPropagation
* is called then the native click event will never bubble up
* to the root element.
* Browsers skip the label forwarding when the click lands on interactive
* content, such as a slotted button, so the flag is cleared on the next
* frame rather than waiting for a forwarded click that never arrives.
*/
ev.preventDefault();
raf(() => (this.hasSlottedClick = false));
return;
}

if (this.hasSlottedClick) {
ev.stopPropagation();
this.hasSlottedClick = false;
}
}

private onClick = (ev: UIEvent) => {
/**
* Interactive slotted content, such as a button or a link, handles its own
* click, so it should not open the select as well. Any other slotted
* content is decorative and behaves the same as clicking the select itself.
*/
const deepTarget = ev.composedPath()[0] as HTMLElement;
if (isSlottedClick(ev, this.el) && deepTarget.closest(INTERACTIVE_SLOTTED_CONTENT) !== null) {
return;
}

this.setFocus();
this.open(ev);
};

private onFocus = () => {
Expand Down Expand Up @@ -1692,3 +1727,12 @@ const extractOptionContent = (option: HTMLIonSelectOptionElement, customHTMLEnab
let selectIds = 0;

const OPTION_CLASS = 'select-interface-option';

/**
* Slotted content that the browser focuses or activates on its own when it is
* clicked. A <label> skips forwarding a click to its control when the click
* lands on content like this, so the select leaves it alone as well.
* Anchors are included because they are interactive without being focusable
* by the definition `focusableQueryString` uses.
*/
const INTERACTIVE_SLOTTED_CONTENT = `${focusableQueryString}, a[href]`;
91 changes: 91 additions & 0 deletions core/src/components/select/test/basic/select.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1339,3 +1339,94 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
});
});

/**
* This behavior does not vary across directions/modes
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select: slotted click'), () => {
test.beforeEach(async ({ page }) => {
await page.setContent(
`
<ion-select label="Fruit" interface="alert">
<ion-icon id="start-icon" slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-button id="end-button" slot="end" aria-label="Clear selection">
<ion-icon slot="icon-only" name="trash" aria-hidden="true"></ion-icon>
</ion-button>
<input id="end-checkbox" slot="end" type="checkbox" aria-label="Favorite" />
<ion-select-option value="apple">Apple</ion-select-option>
</ion-select>
`,
config
);
});

test('should emit one click when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#start-icon').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');
});

/**
* Decorative slotted content behaves the same as clicking the select
* itself, so it opens the overlay.
*/
test('should open when a slotted icon is clicked', async ({ page }) => {
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');

await page.locator('#start-icon').click();
await ionAlertDidPresent.next();

await expect(page.locator('ion-alert')).toBeVisible();
});

test('should emit one click when a slotted button is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#end-button').click();

expect(clickEvent).toHaveReceivedEventTimes(1);
});

test('should not open when a slotted button is clicked', async ({ page }) => {
await page.locator('#end-button').click();

await expect(page.locator('ion-alert')).toHaveCount(0);
});

test('should not focus the select when a slotted button is clicked', async ({ page }) => {
await page.locator('#end-button').click();

await expect(page.locator('ion-select')).not.toHaveClass(/has-focus/);
});

test('should activate slotted form controls', async ({ page }) => {
const checkbox = page.locator('#end-checkbox');

await checkbox.click();

await expect(checkbox).toBeChecked();
});

test('should open when the select is clicked after slotted content', async ({ page }) => {
/**
* Clicking a slotted button does not produce a forwarded click for the
* select to ignore, so the following click on the select itself must
* still open it.
*/
await page.locator('#end-button').click();

const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');

await page.locator('ion-select').click({ position: { x: 5, y: 5 } });
await ionAlertDidPresent.next();

await expect(page.locator('ion-alert')).toBeVisible();
});
});
});
Loading
Loading