Skip to content

Commit

Permalink
fix(radio,toggle,checkbox,select): padded space is clickable in items (
Browse files Browse the repository at this point in the history
…#28136)

Issue number: Resolves #27169

---------

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

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

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

Clicking the padded space within an `ion-item` will not pass the click
event to the slotted `ion-radio`, `ion-checkbox`, `ion-select` or
`ion-toggle`.

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

- The padded space at the start of `.item-native` and at the end of
`.item-inner` is clickable to activate a control.
- When the item is clicked, we check if the event is a result of
clicking the control or clicking the item's padded space. If the click
event is on the control, we don't need to do anything and let the
default behavior occur. If the click event is on the padded space, we
manually call the `.click()` method for the interactive element.
- The cursor pointer displays when hovering over the padded space when a
slotted interactive control is present.


## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
  • Loading branch information
sean-perkins authored Sep 20, 2023
1 parent 0104d89 commit 5b7e422
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 11 deletions.
11 changes: 8 additions & 3 deletions core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,8 @@ export class Checkbox implements ComponentInterface {
*/
@Event() ionStyle!: EventEmitter<StyleEventDetail>;

// TODO(FW-3100): remove this
connectedCallback() {
this.legacyFormController = createLegacyFormController(this.el);
this.legacyFormController = createLegacyFormController(this.el); // TODO(FW-3100): remove this
}

componentWillLoad() {
Expand Down Expand Up @@ -195,7 +194,7 @@ export class Checkbox implements ComponentInterface {
});
};

private toggleChecked = (ev: any) => {
private toggleChecked = (ev: Event) => {
ev.preventDefault();

this.setFocus();
Expand All @@ -211,6 +210,10 @@ export class Checkbox implements ComponentInterface {
this.ionBlur.emit();
};

private onClick = (ev: MouseEvent) => {
this.toggleChecked(ev);
};

// TODO(FW-3100): run contents of renderCheckbox directly instead
render() {
const { legacyFormController } = this;
Expand Down Expand Up @@ -252,6 +255,7 @@ export class Checkbox implements ComponentInterface {
[`checkbox-alignment-${alignment}`]: true,
[`checkbox-label-placement-${labelPlacement}`]: true,
})}
onClick={this.onClick}
>
<label class="checkbox-wrapper">
{/*
Expand Down Expand Up @@ -333,6 +337,7 @@ Developers can dismiss this warning by removing their usage of the "legacy" prop
'legacy-checkbox': true,
interactive: true,
})}
onClick={this.onClick}
>
<svg class="checkbox-icon" viewBox="0 0 24 24" part="container">
{path}
Expand Down
23 changes: 23 additions & 0 deletions core/src/components/checkbox/test/basic/checkbox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,28 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
expect(ionChange).not.toHaveReceivedEvent();
});

test('clicking padded space within item should click the checkbox', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox>Size</ion-checkbox>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});
});
});
23 changes: 23 additions & 0 deletions core/src/components/checkbox/test/legacy/basic/checkbox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,28 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
await expect(ionChange).not.toHaveReceivedEvent();
});

test('clicking padded space within item should click the checkbox', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox legacy="true" value="my-checkbox"></ion-checkbox>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});
});
});
8 changes: 8 additions & 0 deletions core/src/components/item/item.scss
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@
}
}

// Item: Interactive
// --------------------------------------------------

:host(.item-has-interactive-control) {
cursor: pointer;
}


// Item: Disabled
// --------------------------------------------------
Expand All @@ -189,6 +196,7 @@
}



// Native Item
// --------------------------------------------------

Expand Down
58 changes: 51 additions & 7 deletions core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import { Build, Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes, raf } from '@utils/helpers';
Expand Down Expand Up @@ -350,6 +350,22 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
}
}

private getFirstInteractive() {
if (Build.isTesting) {
/**
* Pseudo selectors can't be tested in unit tests.
* It will cause an error when running the tests.
*
* TODO: FW-5205 - Remove the build conditional when this is fixed in Stencil
*/
return undefined;
}
const controls = this.el.querySelectorAll<HTMLElement>(
'ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled]), ion-select:not([disabled])'
);
return controls[0];
}

render() {
const {
counterString,
Expand All @@ -367,6 +383,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
routerAnimation,
routerDirection,
inheritedAriaAttributes,
multipleInputs,
} = this;
const childStyles = {} as StyleEventDetail;
const mode = getIonMode(this);
Expand All @@ -383,15 +400,41 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
rel,
target,
};

let clickFn = {};

const firstInteractive = this.getFirstInteractive();

// Only set onClick if the item is clickable to prevent screen
// readers from reading all items as clickable
const clickFn = clickable
? {
onClick: (ev: Event) => {
if (clickable || (firstInteractive !== undefined && !multipleInputs)) {
clickFn = {
onClick: (ev: MouseEvent) => {
if (clickable) {
openURL(href, ev, routerDirection, routerAnimation);
},
}
: {};
}
if (firstInteractive !== undefined && !multipleInputs) {
const path = ev.composedPath();
const target = path[0] as HTMLElement;
if (ev.isTrusted) {
/**
* Dispatches a click event to the first interactive element,
* when it is the result of a user clicking on the item.
*
* We check if the click target is in the shadow root,
* which means the user clicked on the .item-native or
* .item-inner padding.
*/
const clickedWithinShadowRoot = this.el.shadowRoot!.contains(target);
if (clickedWithinShadowRoot) {
firstInteractive.click();
}
}
}
},
};
}

const showDetail = detail !== undefined ? detail : mode === 'ios' && clickable;
this.itemStyles.forEach((value) => {
Object.assign(childStyles, value);
Expand All @@ -413,6 +456,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
[`item-lines-${lines}`]: lines !== undefined,
[`item-fill-${fillValue}`]: true,
[`item-shape-${shape}`]: shape !== undefined,
'item-has-interactive-control': firstInteractive !== undefined,
'item-disabled': disabled,
'in-list': inList,
'item-multiple-inputs': this.multipleInputs,
Expand Down
29 changes: 29 additions & 0 deletions core/src/components/radio/test/item/radio.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,33 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
await expect(list).toHaveScreenshot(screenshot(`radio-stacked-label-in-item`));
});
});

test.describe(title('radio: ionChange'), () => {
test('clicking padded space within item should click the radio', async ({ page }) => {
await page.setContent(
`
<ion-radio-group>
<ion-item>
<ion-radio>
<ion-label>Enable Notifications</ion-label>
</ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
const itemNative = page.locator('.item-native');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});
});
});
28 changes: 28 additions & 0 deletions core/src/components/select/test/basic/select.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,5 +234,33 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await select.evaluate((el: HTMLIonSelectElement) => (el.value = 'banana'));
await expect(ionChange).not.toHaveReceivedEvent();
});

test('clicking padded space within item should click the select', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-select label="Fruit" interface="action-sheet">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
</ion-select>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

await ionActionSheetDidPresent.next();

expect(ionActionSheetDidPresent).toHaveReceivedEvent();
});
});
});
34 changes: 34 additions & 0 deletions core/src/components/toggle/test/item/toggle.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,38 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
await expect(list).toHaveScreenshot(screenshot(`toggle-stacked-label-in-item`));
});
});

test.describe(title('toggle: ionChange'), () => {
test('clicking padded space within item should click the toggle', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-toggle>Size</ion-toggle>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionChange = await page.spyOnEvent('ionChange');

/**
* Clicks the padded space within the item.
*
* We intentionally activate the toggle control when clicking either
* the label or padded space. This is different than native iOS,
* but we do it for three reasons:
* 1. Clicking a label connected to a control is standard behavior for web controls.
* 2. iOS is inconsistent in their implementation and other controls can be activated by clicking the label.
* 3. MD is consistent in their implementation and activates controls by clicking the label.
*/
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});
});
});
2 changes: 1 addition & 1 deletion core/src/components/toggle/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export class Toggle implements ComponentInterface {
}
}

private onClick = (ev: Event) => {
private onClick = (ev: MouseEvent) => {
ev.preventDefault();

if (this.lastDrag + 300 < Date.now()) {
Expand Down

0 comments on commit 5b7e422

Please sign in to comment.