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 { ClickController, NotchController, StartContainerController } from '@utils/forms';
import {
createClickController,
createNotchController,
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 clickController?: ClickController;

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.clickController?.handleClickCapture(ev);
}

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

this.startContainerController.calculateStartContainerWidth();

this.clickController = createClickController(el, () => this.nativeInput);

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
Expand Down
87 changes: 87 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,90 @@ 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 slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button slot="end" aria-label="Show password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
<ion-checkbox slot="end" aria-label="Remember"></ion-checkbox>
<ion-radio slot="end" aria-label="Preferred"></ion-radio>
<ion-toggle slot="end" aria-label="Notify"></ion-toggle>
</ion-input>
`,
config
);
});

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

await page.locator('ion-icon[slot="start"]').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

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

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

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

await page.locator('ion-button[slot="end"]').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

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

/**
* Browsers skip the label forwarding when a click lands on interactive
* content, so activating a slotted control leaves the input alone. A radio
* outside a radio group cannot be checked, so only the input is asserted
* for it.
*/
[
{ tag: 'ion-checkbox', checkable: true },
{ tag: 'ion-radio', checkable: false },
{ tag: 'ion-toggle', checkable: true },
].forEach(({ tag, checkable }) => {
test(`should not focus the input when a slotted ${tag} is clicked`, async ({ page }) => {
const control = page.locator(tag);

await control.click();
await page.waitForChanges();

if (checkable) {
await expect(control).toHaveJSProperty('checked', true);
}

await expect(page.locator('ion-input input.native-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('ion-button[slot="end"]').click();

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

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

expect(clickEvent).toHaveReceivedEventTimes(1);
});
});
});
114 changes: 79 additions & 35 deletions core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
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 type { NotchController, StartContainerController } from '@utils/forms';
import type { ClickController, NotchController, StartContainerController } from '@utils/forms';
import {
compareOptions,
createClickController,
createNotchController,
createStartContainerController,
getSlottedClickContent,
isOptionSelected,
checkInvalidState,
} from '@utils/forms';
Expand Down Expand Up @@ -91,6 +106,8 @@ export class Select implements ComponentInterface {
private startContainerEl: HTMLElement | undefined;
private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT);

private clickController?: ClickController;

@Element() el!: HTMLIonSelectElement;

@State() isExpanded = false;
Expand Down Expand Up @@ -364,6 +381,8 @@ export class Select implements ComponentInterface {

this.startContainerController.calculateStartContainerWidth();

this.clickController = createClickController(el);

this.updateOverlayOptions();
this.emitStyle();

Expand Down Expand Up @@ -993,42 +1012,39 @@ export class Select implements ComponentInterface {
this.ionStyle.emit(style);
}

/**
* The label wrapping the slots has no `for` attribute, so the browser
* forwards a click on slotted content to the label's first labelable
* descendant, the internal button. That forwarded click bubbles back out of
* the shadow root targeting the host, where it would be emitted a second
* time and open the select. The controller swallows it during the capture
* phase, leaving the click on the slotted content itself alone so slotted
* links, checkboxes and buttons keep their default behavior.
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
this.clickController?.handleClickCapture(ev);
}

private onClick = (ev: UIEvent) => {
const target = ev.target as HTMLElement;
const closestSlot = target.closest('[slot="start"], [slot="end"]');
const slotted = getSlottedClickContent(ev, this.el);

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.
*/
ev.preventDefault();
/**
* Interactive slotted content, such as a button or a checkbox, 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.
*/
if (slotted !== null) {
const interactive = (ev.target as HTMLElement).closest(INTERACTIVE_SLOTTED_CONTENT);

if (interactive !== null && slotted.contains(interactive)) {
return;
}
}

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

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

const OPTION_CLASS = 'select-interface-option';

/**
* Slotted content that handles its own click, so clicking it should not also
* open the select.
*
* This deliberately does not reuse `focusableQueryString`. That selector
* answers whether an element can take focus right now, which is a different
* question: an ion-radio outside a radio group carries tabindex="-1" from the
* group's roving tabindex, and disabled controls are excluded, yet both still
* handle their own clicks.
*/
const INTERACTIVE_SLOTTED_CONTENT = [
'a[href]',
'button',
'input[type="checkbox"]',
'input[type="radio"]',
'ion-button',
'ion-checkbox',
'ion-radio',
'ion-toggle',
'[tabindex]:not([tabindex^="-"])',
/**
* Covers the remaining Ionic controls. The tags above are still listed
* because ion-checkbox and ion-radio only carry this class when they are
* outside an item, so a select inside an item would lose the match.
*/
'.ion-focusable',
].join(', ');
Loading
Loading