Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(item): do not automatically delegate focus #29091

Merged
merged 17 commits into from
Mar 6, 2024
Merged
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
5 changes: 5 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Content](#version-8x-content)
- [Datetime](#version-8x-datetime)
- [Input](#version-8x-input)
- [Item](#version-8x-item)
- [Modal](#version-8x-modal)
- [Nav](#version-8x-nav)
- [Picker](#version-8x-picker)
Expand Down Expand Up @@ -168,6 +169,10 @@ For more information on the dynamic font, refer to the [Dynamic Font Scaling doc
- `accept` has been removed from the `ion-input` component. This was previously used in conjunction with the `type="file"`. However, the `file` value for `type` is not a valid value in Ionic Framework.
- The `legacy` property and support for the legacy syntax, which involved placing an `ion-input` inside of an `ion-item` with an `ion-label`, have been removed. For more information on migrating from the legacy input syntax, refer to the [Input documentation](https://ionicframework.com/docs/api/input#migrating-from-legacy-input-syntax).

<h4 id="version-8x-item">Item</h4>

- Item no longer automatically delegates focus to the first focusable element. While most developers should not need to make any changes to account for this update, usages of `ion-item` with interactive elements such as form controls (inputs, textareas, etc) should be evaluated to verify that interactions still work as expected.

<h4 id="version-8x-modal">Modal</h4>

- Detection for Capacitor <= 2 with applying status bar styles has been removed. Developers should ensure they are using Capacitor 3 or later when using the card modal presentation.
Expand Down
27 changes: 27 additions & 0 deletions core/src/components/input/test/item/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,30 @@ configs().forEach(({ title, screenshot, config }) => {
});
});
});

configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: item functionality'), () => {
test('clicking padded space within item should focus the input', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-input label="Input"></ion-input>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const input = page.locator('ion-input input');

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

await expect(input).toBeFocused();
});
});
});
3 changes: 2 additions & 1 deletion core/src/components/item/item.scss
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@
// Item: Interactive
// --------------------------------------------------

:host(.item-has-interactive-control) {
// Inputs and textareas do not need the cursor, but other components like checkbox or toggle do.
:host(.item-control-needs-pointer-cursor) {
cursor: pointer;
}

Expand Down
26 changes: 20 additions & 6 deletions core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ import type { CounterFormatter } from './item-interface';
ios: 'item.ios.scss',
md: 'item.md.scss',
},
shadow: {
delegatesFocus: true,
},
shadow: true,
})
export class Item implements ComponentInterface, AnchorInterface, ButtonInterface {
private labelColorStyles = {};
Expand Down Expand Up @@ -359,7 +357,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac

private getFirstInteractive() {
const controls = this.el.querySelectorAll<HTMLElement>(
'ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled]), ion-select:not([disabled])'
'ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled]), ion-select:not([disabled]), ion-input:not([disabled]), ion-textarea:not([disabled])'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: The padding around the input now has cursor: pointer, even though the rest of the control (label, input) doesn't; the inconsistency looks strange. For the other controls, it's consistent since cursor: pointer is used over the entire control.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Any thoughts on how we could mitigate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That cursor: pointer is coming from the item-has-interactive-control class on ion-item, and then it gets overwritten by label's default styles. Either we could add cursor: pointer to the label --

/* possibly restricted to just labels in inputs/textareas */
.item-has-interactive-control label {
  cursor: pointer;
}

-- or make items with inputs/textareas not get cursor: pointer at all, likely by having it not add the class in that case. I'm not really sure which is best though 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 143fb7d I updated the code so that inputs and textures do not have the cursor effect. I updated the name of the class to be more descriptive of what it's doing, but I can change the name if you have other ideas.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could also do this with CSS and have it be .item-has-interactive-control:not(:has(ion-input, ion-textarea)) but the specificity gets a little complicated, so I opted to just apply the class using logic in JS (since the class is already applied using JS logic)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something like item-has-click-toggle-control or item-control-needs-pointer-cursor? "Needs cursor" by itself is confusing to me since in theory everything needs a cursor, like initially clicking into the input.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified in 4362c3b and 0c9311c

);
return controls[0];
}
Expand Down Expand Up @@ -425,7 +423,16 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
*/
const clickedWithinShadowRoot = this.el.shadowRoot!.contains(target);
if (clickedWithinShadowRoot) {
firstInteractive.click();
/**
* For input/textarea clicking the padding should focus the
* text field (thus making it editable). For everything else,
* we want to click the control so it activates.
*/
if (firstInteractive.tagName === 'ION-INPUT' || firstInteractive.tagName === 'ION-TEXTAREA') {
(firstInteractive as HTMLIonInputElement | HTMLIonTextareaElement).setFocus();
} else {
firstInteractive.click();
}
}
}
}
Expand All @@ -441,6 +448,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
const fillValue = fill || 'none';
const inList = hostContext('ion-list', this.el) && !hostContext('ion-radio-group', this.el);

/**
* Inputs and textareas do not need to show a cursor pointer.
* However, other form controls such as checkboxes and radios do.
*/
const firstInteractiveNeedsPointerCursor =
firstInteractive !== undefined && !['ION-INPUT', 'ION-TEXTAREA'].includes(firstInteractive.tagName);

return (
<Host
aria-disabled={ariaDisabled}
Expand All @@ -454,7 +468,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-control-needs-pointer-cursor': firstInteractiveNeedsPointerCursor,
'item-disabled': disabled,
'in-list': inList,
'item-multiple-inputs': this.multipleInputs,
Expand Down
33 changes: 6 additions & 27 deletions core/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap';
import { GESTURE_CONTROLLER } from '@utils/gesture';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers';
Expand All @@ -19,8 +20,6 @@ const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)';
const focusableQueryString =
'[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])';

/**
* @part container - The container for the menu content.
Expand Down Expand Up @@ -398,31 +397,9 @@ export class Menu implements ComponentInterface, MenuI {
return menuController._setOpen(this, shouldOpen, animated);
}

private focusFirstDescendant() {
const { el } = this;
const firstInput = el.querySelector(focusableQueryString) as HTMLElement | null;

if (firstInput) {
firstInput.focus();
} else {
el.focus();
}
}

private focusLastDescendant() {
const { el } = this;
const inputs = Array.from(el.querySelectorAll<HTMLElement>(focusableQueryString));
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;

if (lastInput) {
lastInput.focus();
} else {
el.focus();
}
}

private trapKeyboardFocus(ev: Event, doc: Document) {
const target = ev.target as HTMLElement | null;

if (!target) {
return;
}
Expand All @@ -439,13 +416,15 @@ export class Menu implements ComponentInterface, MenuI {
* Wrap the focus to either the first or last element.
*/

const { el } = this;

/**
* Once we call `focusFirstDescendant`, another focus event
* will fire, which will cause `lastFocus` to be updated
* before we can run the code after that. We cache the value
* here to avoid that.
*/
this.focusFirstDescendant();
focusFirstDescendant(el);

/**
* If the cached last focused element is the same as the now-
Expand All @@ -454,7 +433,7 @@ export class Menu implements ComponentInterface, MenuI {
* last descendant.
*/
if (this.lastFocus === doc.activeElement) {
this.focusLastDescendant();
focusLastDescendant(el);
}
}
}
Expand Down
4 changes: 1 addition & 3 deletions core/src/components/menu/test/basic/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@
</ion-header>
<ion-content>
<ion-list>
<ion-item>
<ion-button id="start-menu-button">Button</ion-button>
</ion-item>
<ion-button id="start-menu-button">Button</ion-button>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 3 additions & 10 deletions core/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { focusFirstDescendant } from '@utils/focus-trap';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
import { addEventListener, raf, hasLazyBuild } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging';
import {
BACKDROP,
dismiss,
eventMethod,
focusFirstDescendant,
prepareOverlay,
present,
setOverlayId,
} from '@utils/overlays';
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present, setOverlayId } from '@utils/overlays';
import { isPlatform } from '@utils/platform';
import { getClassMap } from '@utils/theme';
import { deepReady, waitForMount } from '@utils/transition';
Expand Down Expand Up @@ -512,7 +505,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
* descendant inside of the popover.
*/
if (this.focusDescendantOnPresent) {
focusFirstDescendant(this.el, this.el);
focusFirstDescendant(el);
}

unlock();
Expand Down
27 changes: 27 additions & 0 deletions core/src/components/textarea/test/item/textarea.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,30 @@ configs().forEach(({ title, screenshot, config }) => {
});
});
});

configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: item functionality'), () => {
test('clicking padded space within item should focus the textarea', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-textarea label="Textarea"></ion-textarea>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const textarea = page.locator('ion-textarea textarea');

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

await expect(textarea).toBeFocused();
});
});
});
86 changes: 86 additions & 0 deletions core/src/utils/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { focusVisibleElement } from '@utils/helpers';

/**
* This query string selects elements that
* are eligible to receive focus. We select
* interactive elements that meet the following
* criteria:
* 1. Element does not have a negative tabindex
* 2. Element does not have `hidden`
* 3. Element does not have `disabled` for non-Ionic components.
* 4. Element does not have `disabled` or `disabled="true"` for Ionic components.
* Note: We need this distinction because `disabled="false"` is
* valid usage for the disabled property on ion-button.
*/
export const focusableQueryString =
'[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])';

/**
* Focuses the first descendant in a context
* that can receive focus. If none exists,
* a fallback element will be focused.
* This fallback is typically an ancestor
* container such as a menu or overlay so focus does not
* leave the container we are trying to trap focus in.
*
* If no fallback is specified then we focus the container itself.
*/
export const focusFirstDescendant = <R extends HTMLElement, T extends HTMLElement>(ref: R, fallbackElement?: T) => {
const firstInput = ref.querySelector<HTMLElement>(focusableQueryString);

focusElementInContext(firstInput, fallbackElement ?? ref);
};

/**
* Focuses the last descendant in a context
* that can receive focus. If none exists,
* a fallback element will be focused.
* This fallback is typically an ancestor
* container such as a menu or overlay so focus does not
* leave the container we are trying to trap focus in.
*
* If no fallback is specified then we focus the container itself.
*/
export const focusLastDescendant = <R extends HTMLElement, T extends HTMLElement>(ref: R, fallbackElement?: T) => {
const inputs = Array.from(ref.querySelectorAll<HTMLElement>(focusableQueryString));
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;

focusElementInContext(lastInput, fallbackElement ?? ref);
};

/**
* Focuses a particular element in a context. If the element
* doesn't have anything focusable associated with it then
* a fallback element will be focused.
*
* This fallback is typically an ancestor
* container such as a menu or overlay so focus does not
* leave the container we are trying to trap focus in.
* This should be used instead of the focus() method
* on most elements because the focusable element
* may not be the host element.
*
* For example, if an ion-button should be focused
* then we should actually focus the native <button>
* element inside of ion-button's shadow root, not
* the host element itself.
*/
const focusElementInContext = <T extends HTMLElement>(
hostToFocus: HTMLElement | null | undefined,
fallbackElement: T
) => {
let elementToFocus = hostToFocus;

const shadowRoot = hostToFocus?.shadowRoot;
if (shadowRoot) {
// If there are no inner focusable elements, just focus the host element.
elementToFocus = shadowRoot.querySelector<HTMLElement>(focusableQueryString) || hostToFocus;
}

if (elementToFocus) {
focusVisibleElement(elementToFocus);
} else {
// Focus fallback element instead of letting focus escape
fallbackElement.focus();
}
};
Loading
Loading