fix(sheet): disable focus trap with string-based logic as well

This commit is contained in:
ShaneK
2025-10-02 06:23:37 -07:00
parent 3b80473f2f
commit f332f62cbd
7 changed files with 92 additions and 17 deletions

View File

@ -98,7 +98,11 @@ export const createSheetGesture = (
// Respect explicit opt-out of focus trapping/backdrop interactions // Respect explicit opt-out of focus trapping/backdrop interactions
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap // If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean }; const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
if (el.focusTrap === false || el.showBackdrop === false) { const focusTrapAttr = el.getAttribute?.('focus-trap');
const showBackdropAttr = el.getAttribute?.('show-backdrop');
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
if (focusTrapDisabled || backdropDisabled) {
return; return;
} }
baseEl.style.setProperty('pointer-events', 'auto'); baseEl.style.setProperty('pointer-events', 'auto');
@ -241,10 +245,12 @@ export const createSheetGesture = (
* ion-backdrop and .modal-wrapper always have pointer-events: auto * ion-backdrop and .modal-wrapper always have pointer-events: auto
* applied, so the modal content can still be interacted with. * applied, so the modal content can still be interacted with.
*/ */
const shouldEnableBackdrop = const modalEl = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
currentBreakpoint > backdropBreakpoint && const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false && const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false; const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
if (shouldEnableBackdrop) { if (shouldEnableBackdrop) {
enableBackdrop(); enableBackdrop();
} else { } else {
@ -591,10 +597,16 @@ export const createSheetGesture = (
* Backdrop should become enabled * Backdrop should become enabled
* after the backdropBreakpoint value * after the backdropBreakpoint value
*/ */
const modalEl = baseEl as HTMLIonModalElement & {
focusTrap?: boolean;
showBackdrop?: boolean;
};
const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldEnableBackdrop = const shouldEnableBackdrop =
currentBreakpoint > backdropBreakpoint && currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
if (shouldEnableBackdrop) { if (shouldEnableBackdrop) {
enableBackdrop(); enableBackdrop();
} else { } else {

View File

@ -1237,6 +1237,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
const isHandleCycle = handleBehavior === 'cycle'; const isHandleCycle = handleBehavior === 'cycle';
const isSheetModalWithHandle = isSheetModal && showHandle; const isSheetModalWithHandle = isSheetModal && showHandle;
const focusTrapAttr = this.el.getAttribute('focus-trap');
return ( return (
<Host <Host
no-router no-router
@ -1253,7 +1254,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
[`modal-sheet`]: isSheetModal, [`modal-sheet`]: isSheetModal,
[`modal-no-expand-scroll`]: isSheetModal && !expandToScroll, [`modal-no-expand-scroll`]: isSheetModal && !expandToScroll,
'overlay-hidden': true, 'overlay-hidden': true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
...getClassMap(this.cssClass), ...getClassMap(this.cssClass),
}} }}
onIonBackdropTap={this.onBackdropTap} onIonBackdropTap={this.onBackdropTap}

View File

@ -28,6 +28,18 @@ describe('modal: focus trap', () => {
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true); expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
}); });
it('should set the focus trap class when disabled via attribute string', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal focus-trap="false"></ion-modal>
`,
});
const modal = page.body.querySelector('ion-modal')!;
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => { it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({ const page = await newSpecPage({
components: [Modal], components: [Modal],

View File

@ -687,6 +687,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this; const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this;
const desktop = isPlatform('desktop'); const desktop = isPlatform('desktop');
const enableArrow = arrow && !parentPopover; const enableArrow = arrow && !parentPopover;
const focusTrapAttr = this.el.getAttribute('focus-trap');
return ( return (
<Host <Host
@ -704,7 +705,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
'overlay-hidden': true, 'overlay-hidden': true,
'popover-desktop': desktop, 'popover-desktop': desktop,
[`popover-side-${side}`]: true, [`popover-side-${side}`]: true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false, [FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
'popover-nested': !!parentPopover, 'popover-nested': !!parentPopover,
}} }}
onIonPopoverDidPresent={onLifecycle} onIonPopoverDidPresent={onLifecycle}

View File

@ -29,6 +29,18 @@ describe('popover: focus trap', () => {
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true); expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
}); });
it('should set the focus trap class when disabled via attribute string', async () => {
const page = await newSpecPage({
components: [Popover],
html: `
<ion-popover focus-trap="false"></ion-popover>
`,
});
const popover = page.body.querySelector('ion-popover')!;
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => { it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({ const page = await newSpecPage({
components: [Popover], components: [Popover],

View File

@ -539,11 +539,18 @@ export const present = async <OverlayPresentOptions>(
* view container subtree, skip adding aria-hidden/inert there * view container subtree, skip adding aria-hidden/inert there
* to avoid disabling the overlay. * to avoid disabling the overlay.
*/ */
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; const overlayEl = overlay.el as HTMLIonOverlayElement & {
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; focusTrap?: boolean;
showBackdrop?: boolean;
};
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled;
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false // Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
// expect background interaction to remain enabled. // expect background interaction to remain enabled.
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false; const shouldLockRoot = shouldTrapFocus && !backdropDisabled;
overlay.presented = true; overlay.presented = true;
overlay.willPresent.emit(); overlay.willPresent.emit();
@ -681,11 +688,21 @@ export const dismiss = async <OverlayDismissOptions>(
*/ */
const overlaysLockingRoot = presentedOverlays.filter((o) => { const overlaysLockingRoot = presentedOverlays.filter((o) => {
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false; const focusTrapAttr = el.getAttribute?.('focus-trap');
const showBackdropAttr = el.getAttribute?.('show-backdrop');
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
return el.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
}); });
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; const overlayEl = overlay.el as HTMLIonOverlayElement & {
const locksRoot = focusTrap?: boolean;
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false; showBackdrop?: boolean;
};
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
const locksRoot = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
/** /**
* If this is the last visible overlay that is trapping focus * If this is the last visible overlay that is trapping focus

View File

@ -37,6 +37,26 @@ describe('overlays: scroll blocking', () => {
expect(body).not.toHaveClass('backdrop-no-scroll'); expect(body).not.toHaveClass('backdrop-no-scroll');
}); });
it('should not block scroll when focus-trap attribute is set to "false"', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal focus-trap="false"></ion-modal>
`,
});
const modal = page.body.querySelector('ion-modal')!;
const body = page.doc.querySelector('body')!;
await modal.present();
expect(body).not.toHaveClass('backdrop-no-scroll');
await modal.dismiss();
expect(body).not.toHaveClass('backdrop-no-scroll');
});
it('should not block scroll when the overlay is dismissed', async () => { it('should not block scroll when the overlay is dismissed', async () => {
const page = await newSpecPage({ const page = await newSpecPage({
components: [Modal], components: [Modal],