refactor(item): do not automatically delegate focus (#29091)
resolves #21982 BREAKING CHANGE: - 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.
@ -22,6 +22,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
|
|||||||
- [Content](#version-8x-content)
|
- [Content](#version-8x-content)
|
||||||
- [Datetime](#version-8x-datetime)
|
- [Datetime](#version-8x-datetime)
|
||||||
- [Input](#version-8x-input)
|
- [Input](#version-8x-input)
|
||||||
|
- [Item](#version-8x-item)
|
||||||
- [Modal](#version-8x-modal)
|
- [Modal](#version-8x-modal)
|
||||||
- [Nav](#version-8x-nav)
|
- [Nav](#version-8x-nav)
|
||||||
- [Picker](#version-8x-picker)
|
- [Picker](#version-8x-picker)
|
||||||
@ -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.
|
- `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).
|
- 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>
|
<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.
|
- 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.
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -176,7 +176,8 @@
|
|||||||
// Item: Interactive
|
// 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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,9 +32,7 @@ import type { CounterFormatter } from './item-interface';
|
|||||||
ios: 'item.ios.scss',
|
ios: 'item.ios.scss',
|
||||||
md: 'item.md.scss',
|
md: 'item.md.scss',
|
||||||
},
|
},
|
||||||
shadow: {
|
shadow: true,
|
||||||
delegatesFocus: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export class Item implements ComponentInterface, AnchorInterface, ButtonInterface {
|
export class Item implements ComponentInterface, AnchorInterface, ButtonInterface {
|
||||||
private labelColorStyles = {};
|
private labelColorStyles = {};
|
||||||
@ -359,7 +357,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
|||||||
|
|
||||||
private getFirstInteractive() {
|
private getFirstInteractive() {
|
||||||
const controls = this.el.querySelectorAll<HTMLElement>(
|
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])'
|
||||||
);
|
);
|
||||||
return controls[0];
|
return controls[0];
|
||||||
}
|
}
|
||||||
@ -425,10 +423,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
|||||||
*/
|
*/
|
||||||
const clickedWithinShadowRoot = this.el.shadowRoot!.contains(target);
|
const clickedWithinShadowRoot = this.el.shadowRoot!.contains(target);
|
||||||
if (clickedWithinShadowRoot) {
|
if (clickedWithinShadowRoot) {
|
||||||
|
/**
|
||||||
|
* 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();
|
firstInteractive.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -441,6 +448,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
|||||||
const fillValue = fill || 'none';
|
const fillValue = fill || 'none';
|
||||||
const inList = hostContext('ion-list', this.el) && !hostContext('ion-radio-group', this.el);
|
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 (
|
return (
|
||||||
<Host
|
<Host
|
||||||
aria-disabled={ariaDisabled}
|
aria-disabled={ariaDisabled}
|
||||||
@ -454,7 +468,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
|||||||
[`item-lines-${lines}`]: lines !== undefined,
|
[`item-lines-${lines}`]: lines !== undefined,
|
||||||
[`item-fill-${fillValue}`]: true,
|
[`item-fill-${fillValue}`]: true,
|
||||||
[`item-shape-${shape}`]: shape !== undefined,
|
[`item-shape-${shape}`]: shape !== undefined,
|
||||||
'item-has-interactive-control': firstInteractive !== undefined,
|
'item-control-needs-pointer-cursor': firstInteractiveNeedsPointerCursor,
|
||||||
'item-disabled': disabled,
|
'item-disabled': disabled,
|
||||||
'in-list': inList,
|
'in-list': inList,
|
||||||
'item-multiple-inputs': this.multipleInputs,
|
'item-multiple-inputs': this.multipleInputs,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||||
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } 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 { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
|
||||||
|
import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap';
|
||||||
import { GESTURE_CONTROLLER } from '@utils/gesture';
|
import { GESTURE_CONTROLLER } from '@utils/gesture';
|
||||||
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
|
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
|
||||||
import type { Attributes } from '@utils/helpers';
|
import type { Attributes } from '@utils/helpers';
|
||||||
@ -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 mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
|
||||||
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
|
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
|
||||||
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)';
|
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.
|
* @part container - The container for the menu content.
|
||||||
@ -398,31 +397,9 @@ export class Menu implements ComponentInterface, MenuI {
|
|||||||
return menuController._setOpen(this, shouldOpen, animated);
|
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) {
|
private trapKeyboardFocus(ev: Event, doc: Document) {
|
||||||
const target = ev.target as HTMLElement | null;
|
const target = ev.target as HTMLElement | null;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -439,13 +416,15 @@ export class Menu implements ComponentInterface, MenuI {
|
|||||||
* Wrap the focus to either the first or last element.
|
* Wrap the focus to either the first or last element.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { el } = this;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Once we call `focusFirstDescendant`, another focus event
|
* Once we call `focusFirstDescendant`, another focus event
|
||||||
* will fire, which will cause `lastFocus` to be updated
|
* will fire, which will cause `lastFocus` to be updated
|
||||||
* before we can run the code after that. We cache the value
|
* before we can run the code after that. We cache the value
|
||||||
* here to avoid that.
|
* here to avoid that.
|
||||||
*/
|
*/
|
||||||
this.focusFirstDescendant();
|
focusFirstDescendant(el);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the cached last focused element is the same as the now-
|
* If the cached last focused element is the same as the now-
|
||||||
@ -454,7 +433,7 @@ export class Menu implements ComponentInterface, MenuI {
|
|||||||
* last descendant.
|
* last descendant.
|
||||||
*/
|
*/
|
||||||
if (this.lastFocus === doc.activeElement) {
|
if (this.lastFocus === doc.activeElement) {
|
||||||
this.focusLastDescendant();
|
focusLastDescendant(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,7 @@
|
|||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-item>
|
|
||||||
<ion-button id="start-menu-button">Button</ion-button>
|
<ion-button id="start-menu-button">Button</ion-button>
|
||||||
</ion-item>
|
|
||||||
<ion-item>Menu Item</ion-item>
|
<ion-item>Menu Item</ion-item>
|
||||||
<ion-item>Menu Item</ion-item>
|
<ion-item>Menu Item</ion-item>
|
||||||
<ion-item>Menu Item</ion-item>
|
<ion-item>Menu Item</ion-item>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
@ -1,18 +1,11 @@
|
|||||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||||
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } 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 { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
|
||||||
import { addEventListener, raf, hasLazyBuild } from '@utils/helpers';
|
import { addEventListener, raf, hasLazyBuild } from '@utils/helpers';
|
||||||
import { createLockController } from '@utils/lock-controller';
|
import { createLockController } from '@utils/lock-controller';
|
||||||
import { printIonWarning } from '@utils/logging';
|
import { printIonWarning } from '@utils/logging';
|
||||||
import {
|
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present, setOverlayId } from '@utils/overlays';
|
||||||
BACKDROP,
|
|
||||||
dismiss,
|
|
||||||
eventMethod,
|
|
||||||
focusFirstDescendant,
|
|
||||||
prepareOverlay,
|
|
||||||
present,
|
|
||||||
setOverlayId,
|
|
||||||
} from '@utils/overlays';
|
|
||||||
import { isPlatform } from '@utils/platform';
|
import { isPlatform } from '@utils/platform';
|
||||||
import { getClassMap } from '@utils/theme';
|
import { getClassMap } from '@utils/theme';
|
||||||
import { deepReady, waitForMount } from '@utils/transition';
|
import { deepReady, waitForMount } from '@utils/transition';
|
||||||
@ -512,7 +505,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
|||||||
* descendant inside of the popover.
|
* descendant inside of the popover.
|
||||||
*/
|
*/
|
||||||
if (this.focusDescendantOnPresent) {
|
if (this.focusDescendantOnPresent) {
|
||||||
focusFirstDescendant(this.el, this.el);
|
focusFirstDescendant(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
unlock();
|
unlock();
|
||||||
|
|||||||
@ -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
core/src/utils/focus-trap.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { doc } from '@utils/browser';
|
import { doc } from '@utils/browser';
|
||||||
|
import { focusFirstDescendant, focusLastDescendant, focusableQueryString } from '@utils/focus-trap';
|
||||||
import type { BackButtonEvent } from '@utils/hardware-back-button';
|
import type { BackButtonEvent } from '@utils/hardware-back-button';
|
||||||
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
|
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
|
||||||
|
|
||||||
@ -129,45 +130,8 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(
|
|||||||
return Promise.resolve() as any;
|
return Promise.resolve() as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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])';
|
|
||||||
const isOverlayHidden = (overlay: Element) => overlay.classList.contains('overlay-hidden');
|
const isOverlayHidden = (overlay: Element) => overlay.classList.contains('overlay-hidden');
|
||||||
|
|
||||||
/**
|
|
||||||
* Focuses the first descendant in an overlay
|
|
||||||
* that can receive focus. If none exists,
|
|
||||||
* the entire overlay will be focused.
|
|
||||||
*/
|
|
||||||
export const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
|
||||||
const firstInput = ref.querySelector(focusableQueryString) as HTMLElement | null;
|
|
||||||
|
|
||||||
focusElementInOverlay(firstInput, overlay);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Focuses the last descendant in an overlay
|
|
||||||
* that can receive focus. If none exists,
|
|
||||||
* the entire overlay will be focused.
|
|
||||||
*/
|
|
||||||
const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
|
||||||
const inputs = Array.from(ref.querySelectorAll(focusableQueryString)) as HTMLElement[];
|
|
||||||
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
|
|
||||||
|
|
||||||
focusElementInOverlay(lastInput, overlay);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Focuses a particular element in an overlay. If the element
|
* Focuses a particular element in an overlay. If the element
|
||||||
* doesn't have anything focusable associated with it then
|
* doesn't have anything focusable associated with it then
|
||||||
@ -282,7 +246,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
|
const overlayWrapper = overlayRoot.querySelector<HTMLElement>('.ion-overlay-wrapper');
|
||||||
if (!overlayWrapper) {
|
if (!overlayWrapper) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -370,7 +334,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
|||||||
const lastFocus = lastOverlay.lastFocus;
|
const lastFocus = lastOverlay.lastFocus;
|
||||||
|
|
||||||
// Focus the first element in the overlay wrapper
|
// Focus the first element in the overlay wrapper
|
||||||
focusFirstDescendant(lastOverlay, lastOverlay);
|
focusFirstDescendant(lastOverlay);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the cached last focused element is the
|
* If the cached last focused element is the
|
||||||
@ -382,7 +346,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
|||||||
* last focus to equal the active element.
|
* last focus to equal the active element.
|
||||||
*/
|
*/
|
||||||
if (lastFocus === doc.activeElement) {
|
if (lastFocus === doc.activeElement) {
|
||||||
focusLastDescendant(lastOverlay, lastOverlay);
|
focusLastDescendant(lastOverlay);
|
||||||
}
|
}
|
||||||
lastOverlay.lastFocus = doc.activeElement as HTMLElement;
|
lastOverlay.lastFocus = doc.activeElement as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|||||||