Skip to content

Commit 4df0e0f

Browse files
joselriothetaPC
andauthored
fix(alert): change focused element and improve keyboard navigation (#30220)
Issue number: internal ## What is the current behavior? > Once Alert gets open the focusable element was the ion-alert itself. > <img width="279" alt="Screenshot 2025-02-27 at 18 07 19" src="https://github.com/user-attachments/assets/50ad3b75-ba32-4dd1-b17e-c5a5bf16f93b" /> ## What is the new behavior? In order to mimick native alert a11y behaviour... Changed the focused element based on the amount of existing buttons. > If there is only 1 button, it should be that one focused > <img width="304" alt="Screenshot 2025-02-27 at 18 04 52" src="https://github.com/user-attachments/assets/e273f65a-5347-4a29-a156-f6e57852f0dc" /> > Otherwise it should focus the `.alert-wrapper` container > <img width="284" alt="Screenshot 2025-02-27 at 18 05 02" src="https://github.com/user-attachments/assets/4a8507f3-a31f-40b9-8cd7-478ec881e3ed" /> > > **NOTE**: The yellow outline it's just for demo purposes, it was not implemented 🤪 ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information - Also updated support to the shiftTab keyboard navigation. - Updated tests and screenshots with the latest changes. --------- Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
1 parent 2149ba2 commit 4df0e0f

File tree

54 files changed

+44
-14
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+44
-14
lines changed

core/src/components/alert/alert.tsx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,18 @@ export class Alert implements ComponentInterface, OverlayInterface {
237237
return;
238238
}
239239

240+
/**
241+
* Ensure when alert container is being focused, and the user presses the tab + shift keys, the focus will be set to the last alert button.
242+
*/
243+
if (ev.target.classList.contains('alert-wrapper')) {
244+
if (ev.key === 'Tab' && ev.shiftKey) {
245+
ev.preventDefault();
246+
const lastChildBtn = this.wrapperEl?.querySelector('.alert-button:last-child') as HTMLButtonElement;
247+
lastChildBtn.focus();
248+
return;
249+
}
250+
}
251+
240252
// The only inputs we want to navigate between using arrow keys are the radios
241253
// ignore the keydown event if it is not on a radio button
242254
if (
@@ -400,7 +412,19 @@ export class Alert implements ComponentInterface, OverlayInterface {
400412

401413
await this.delegateController.attachViewToDom();
402414

403-
await present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation);
415+
await present(this, 'alertEnter', iosEnterAnimation, mdEnterAnimation).then(() => {
416+
/**
417+
* Check if alert has only one button and no inputs.
418+
* If so, then focus on the button. Otherwise, focus the alert wrapper.
419+
* This will map to the default native alert behavior.
420+
*/
421+
if (this.buttons.length === 1 && this.inputs.length === 0) {
422+
const queryBtn = this.wrapperEl?.querySelector('.alert-button') as HTMLButtonElement;
423+
queryBtn.focus();
424+
} else {
425+
this.wrapperEl?.focus();
426+
}
427+
});
404428

405429
unlock();
406430
}
@@ -725,8 +749,8 @@ export class Alert implements ComponentInterface, OverlayInterface {
725749
const { overlayIndex, header, subHeader, message, htmlAttributes } = this;
726750
const mode = getIonMode(this);
727751
const hdrId = `alert-${overlayIndex}-hdr`;
728-
const subHdrId = `alert-${overlayIndex}-sub-hdr`;
729752
const msgId = `alert-${overlayIndex}-msg`;
753+
const subHdrId = `alert-${overlayIndex}-sub-hdr`;
730754
const role = this.inputs.length > 0 || this.buttons.length > 0 ? 'alertdialog' : 'alert';
731755

732756
/**
@@ -739,12 +763,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
739763

740764
return (
741765
<Host
742-
role={role}
743-
aria-modal="true"
744-
aria-labelledby={ariaLabelledBy}
745-
aria-describedby={message !== undefined ? msgId : null}
746766
tabindex="-1"
747-
{...(htmlAttributes as any)}
748767
style={{
749768
zIndex: `${20000 + overlayIndex}`,
750769
}}
@@ -761,7 +780,16 @@ export class Alert implements ComponentInterface, OverlayInterface {
761780

762781
<div tabindex="0" aria-hidden="true"></div>
763782

764-
<div class="alert-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
783+
<div
784+
class="alert-wrapper ion-overlay-wrapper"
785+
role={role}
786+
aria-modal="true"
787+
aria-labelledby={ariaLabelledBy}
788+
aria-describedby={message !== undefined ? msgId : null}
789+
tabindex="0"
790+
ref={(el) => (this.wrapperEl = el)}
791+
{...(htmlAttributes as any)}
792+
>
765793
<div class="alert-head">
766794
{header && (
767795
<h2 id={hdrId} class="alert-title">

core/src/components/alert/test/a11y/alert.e2e.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const testAria = async (
1616
await didPresent.next();
1717

1818
const alert = page.locator('ion-alert');
19+
const alertwrapper = alert.locator('.alert-wrapper');
1920

2021
const header = alert.locator('.alert-title');
2122
const subHeader = alert.locator('.alert-sub-title');
@@ -42,8 +43,8 @@ const testAria = async (
4243
* expect().toHaveAttribute() can't check for a null value, so grab and check
4344
* the values manually instead.
4445
*/
45-
const ariaLabelledBy = await alert.getAttribute('aria-labelledby');
46-
const ariaDescribedBy = await alert.getAttribute('aria-describedby');
46+
const ariaLabelledBy = await alertwrapper.getAttribute('aria-labelledby');
47+
const ariaDescribedBy = await alertwrapper.getAttribute('aria-describedby');
4748

4849
expect(ariaLabelledBy).toBe(expectedAriaLabelledBy);
4950
expect(ariaDescribedBy).toBe(expectedAriaDescribedBy);

0 commit comments

Comments
 (0)