mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Merge branch 'main' into chore-update-next-from-main
This commit is contained in:
@@ -388,7 +388,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
|
||||
<div class="action-sheet-container">
|
||||
@@ -449,7 +449,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="alert-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
|
||||
<div class="alert-head">
|
||||
@@ -781,7 +781,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
{this.renderAlertButtons()}
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ export class Backdrop implements ComponentInterface {
|
||||
const theme = getIonTheme(this);
|
||||
return (
|
||||
<Host
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class={{
|
||||
[theme]: true,
|
||||
|
||||
@@ -38,6 +38,8 @@ import { getCounterText } from './input.utils';
|
||||
export class Input implements ComponentInterface {
|
||||
private nativeInput?: HTMLInputElement;
|
||||
private inputId = `ion-input-${inputIds++}`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private isComposing = false;
|
||||
private slotMutationController?: SlotMutationController;
|
||||
@@ -655,9 +657,30 @@ export class Input implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
if (helperText) {
|
||||
return helperTextId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private renderCounter() {
|
||||
@@ -905,6 +928,8 @@ export class Input implements ComponentInterface {
|
||||
onKeyDown={this.onKeydown}
|
||||
onCompositionstart={this.onCompositionStart}
|
||||
onCompositionend={this.onCompositionEnd}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
{...this.inheritedAttributes}
|
||||
/>
|
||||
{clearInput && !readonly && !disabled && (
|
||||
|
||||
@@ -93,6 +93,19 @@ configs({ modes: ['md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, sc
|
||||
await expect(helperText).toHaveText('my helper');
|
||||
await expect(errorText).toBeHidden();
|
||||
});
|
||||
test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
const helperText = page.locator('ion-input .helper-text');
|
||||
const helperTextId = await helperText.getAttribute('id');
|
||||
const ariaDescribedBy = await input.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(helperTextId);
|
||||
});
|
||||
test('error text should be visible when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
@@ -122,6 +135,48 @@ configs({ modes: ['md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, sc
|
||||
const bottomEl = page.locator('ion-input .input-bottom');
|
||||
await expect(bottomEl).toHaveScreenshot(screenshot(`input-error-custom-color`));
|
||||
});
|
||||
test('input should have an aria-describedby attribute when error text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
const errorText = page.locator('ion-input .error-text');
|
||||
const errorTextId = await errorText.getAttribute('id');
|
||||
const ariaDescribedBy = await input.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(errorTextId);
|
||||
});
|
||||
test('input should have aria-invalid attribute when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('input should not have aria-invalid attribute when input is valid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('input should not have aria-describedby attribute when no hint or error text is present', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(`<ion-input label="my input"></ion-input>`, config);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
test.describe('input: hint text rendering', () => {
|
||||
test.describe('regular inputs', () => {
|
||||
|
||||
@@ -358,7 +358,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="loading-wrapper ion-overlay-wrapper">
|
||||
{spinner && (
|
||||
@@ -370,7 +370,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
||||
{message !== undefined && this.renderLoadingMessage(msgId)}
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}></ion-backdrop>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="picker-wrapper ion-overlay-wrapper" role="dialog">
|
||||
<div class="picker-toolbar">
|
||||
@@ -396,7 +396,7 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
await expect(helperText).toHaveText('my helper');
|
||||
await expect(errorText).toBeHidden();
|
||||
});
|
||||
test('textarea should have an aria-describedby attribute when helper text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
const helperText = page.locator('ion-textarea .helper-text');
|
||||
const helperTextId = await helperText.getAttribute('id');
|
||||
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(helperTextId);
|
||||
});
|
||||
test('error text should be visible when textarea is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
@@ -55,6 +68,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
const errorText = page.locator('ion-textarea .error-text');
|
||||
await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`));
|
||||
});
|
||||
test('textarea should have an aria-describedby attribute when error text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
const errorText = page.locator('ion-textarea .error-text');
|
||||
const errorTextId = await errorText.getAttribute('id');
|
||||
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(errorTextId);
|
||||
});
|
||||
test('textarea should have aria-invalid attribute when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('textarea should not have aria-invalid attribute when input is valid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('textarea should not have aria-describedby attribute when no hint or error text is present', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(`<ion-textarea label="my textarea"></ion-textarea>`, config);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
test.describe('textarea: hint text rendering', () => {
|
||||
test.describe('regular textareas', () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
|
||||
export class Textarea implements ComponentInterface {
|
||||
private nativeInput?: HTMLTextAreaElement;
|
||||
private inputId = `ion-textarea-${textareaIds++}`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
/**
|
||||
* `true` if the textarea was cleared as a result of the user typing
|
||||
* with `clearOnEdit` enabled.
|
||||
@@ -601,9 +603,30 @@ export class Textarea implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
if (helperText) {
|
||||
return helperTextId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private renderCounter() {
|
||||
@@ -742,6 +765,8 @@ export class Textarea implements ComponentInterface {
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onKeyDown={this.onKeyDown}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
{...this.inheritedAttributes}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -510,11 +510,21 @@ export const present = async <OverlayPresentOptions>(
|
||||
return;
|
||||
}
|
||||
|
||||
setRootAriaHidden(true);
|
||||
/**
|
||||
* Due to accessibility guidelines, toasts do not have
|
||||
* focus traps.
|
||||
*
|
||||
* All other overlays should have focus traps to prevent
|
||||
* the keyboard focus from leaving the overlay.
|
||||
*/
|
||||
if (overlay.el.tagName !== 'ION-TOAST') {
|
||||
setRootAriaHidden(true);
|
||||
}
|
||||
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
|
||||
hideOverlaysFromScreenReaders(overlay.el);
|
||||
hideUnderlyingOverlaysFromScreenReaders(overlay.el);
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
|
||||
overlay.presented = true;
|
||||
overlay.willPresent.emit();
|
||||
@@ -560,6 +570,11 @@ export const present = async <OverlayPresentOptions>(
|
||||
* it would still have aria-hidden on being presented again.
|
||||
* Removing it here ensures the overlay is visible to screen
|
||||
* readers.
|
||||
*
|
||||
* If this overlay was being presented, then it was hidden
|
||||
* from screen readers during the animation. Now that the
|
||||
* animation is complete, we can reveal the overlay to
|
||||
* screen readers.
|
||||
*/
|
||||
overlay.el.removeAttribute('aria-hidden');
|
||||
};
|
||||
@@ -630,13 +645,26 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastOverlay = doc !== undefined && getPresentedOverlays(doc).length === 1;
|
||||
/**
|
||||
* For accessibility, toasts lack focus traps and don’t receive
|
||||
* `aria-hidden` on the root element when presented.
|
||||
*
|
||||
* All other overlays use focus traps to keep keyboard focus
|
||||
* within the overlay, setting `aria-hidden` on the root element
|
||||
* to enhance accessibility.
|
||||
*
|
||||
* Therefore, we must remove `aria-hidden` from the root element
|
||||
* when the last non-toast overlay is dismissed.
|
||||
*/
|
||||
const overlaysNotToast = doc !== undefined ? getPresentedOverlays(doc).filter((o) => o.tagName !== 'ION-TOAST') : [];
|
||||
|
||||
const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id;
|
||||
|
||||
/**
|
||||
* If this is the last visible overlay then
|
||||
* we want to re-add the root to the accessibility tree.
|
||||
* If this is the last visible overlay that is not a toast
|
||||
* then we want to re-add the root to the accessibility tree.
|
||||
*/
|
||||
if (lastOverlay) {
|
||||
if (lastOverlayNotToast) {
|
||||
setRootAriaHidden(false);
|
||||
document.body.classList.remove(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
@@ -644,6 +672,13 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
overlay.presented = false;
|
||||
|
||||
try {
|
||||
/**
|
||||
* There is no need to show the overlay to screen readers during
|
||||
* the dismiss animation. This is because the overlay will be removed
|
||||
* from the DOM after the animation is complete.
|
||||
*/
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
|
||||
// Overlay contents should not be clickable during dismiss
|
||||
overlay.el.style.setProperty('pointer-events', 'none');
|
||||
overlay.willDismiss.emit({ data, role });
|
||||
@@ -930,6 +965,29 @@ export const createTriggerController = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The overlay that is being animated also needs to hide from screen
|
||||
* readers during its animation. This ensures that assistive technologies
|
||||
* like TalkBack do not announce or interact with the content until the
|
||||
* animation is complete, avoiding confusion for users.
|
||||
*
|
||||
* If the overlay is being presented, it prevents focus rings from appearing
|
||||
* in incorrect positions due to the transition (specifically `transform`
|
||||
* styles), ensuring that when aria-hidden is removed, the focus rings are
|
||||
* correctly displayed in the final location of the elements.
|
||||
*
|
||||
* @param overlay - The overlay that is being animated.
|
||||
*/
|
||||
const hideAnimatingOverlayFromScreenReaders = (overlay: HTMLIonOverlayElement) => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
/**
|
||||
* Once the animation is complete, this attribute will be removed.
|
||||
* This is done at the end of the `present` method.
|
||||
*/
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that underlying overlays have aria-hidden if necessary so that screen readers
|
||||
* cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout
|
||||
@@ -940,7 +998,7 @@ export const createTriggerController = () => {
|
||||
* @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been
|
||||
* fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result.
|
||||
*/
|
||||
const hideOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => {
|
||||
const hideUnderlyingOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
const overlays = getPresentedOverlays(doc);
|
||||
|
||||
Reference in New Issue
Block a user