diff --git a/packages/docs/src/en/plugins/trap.md b/packages/docs/src/en/plugins/trap.md index 94700fa52..2464c9a35 100644 --- a/packages/docs/src/en/plugins/trap.md +++ b/packages/docs/src/en/plugins/trap.md @@ -178,3 +178,76 @@ Here is nesting in action: + + +## Modifiers + + +### .inert + +When building things like dialogs/modals, it's recommended to hide all the other elements on the page from screenreaders when trapping focus. + +By adding `.inert` to `x-trap`, when focus is trapped, all other elements on the page will receive `aria-hidden="true"` attributes, and when focus trapping is disabled, those attributes will also be removed. + +```alpine + + +
+ ... +
+ +
+ ... +
+ + + + +
+ ... +
+ + + +``` + + +### .noscroll + +When building dialogs/modals with Alpine, it's recommended that you disable scrollling for the surrounding content when the dialog is open. + +`x-trap` allows you to do this automatically with the `.noscroll` modifiers. + +By adding `.noscroll`, Alpine will remove the scrollbar from the page and block users from scrolling down the page while a dialog is open. + +For example: + +```alpine +
+ + +
+ Dialog Contents + + +
+
+``` + + +
+
+ + +
+
Dialog Contents
+ +

Notice how you can no longer scroll on this page while this dialog is open.

+ + +
+
+
+ diff --git a/packages/trap/src/index.js b/packages/trap/src/index.js index 2586f888e..e02718340 100644 --- a/packages/trap/src/index.js +++ b/packages/trap/src/index.js @@ -1,28 +1,41 @@ import { createFocusTrap } from 'focus-trap'; export default function (Alpine) { - Alpine.directive('trap', (el, { expression }, { effect, evaluateLater }) => { + Alpine.directive('trap', (el, { expression, modifiers }, { effect, evaluateLater }) => { let evaluator = evaluateLater(expression) let oldValue = false let trap = createFocusTrap(el, { escapeDeactivates: false, - allowOutsideClick: true + allowOutsideClick: true, + fallbackFocus: () => el, }) + let undoInert = () => {} + let undoDisableScrolling = () => {} + effect(() => evaluator(value => { if (oldValue === value) return // Start trapping. if (value && ! oldValue) { setTimeout(() => { + if (modifiers.includes('inert')) undoInert = setInert(el) + if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling() + trap.activate() }); } // Stop trapping. if (! value && oldValue) { + undoInert() + undoInert = () => {} + + undoDisableScrolling() + undoDisableScrolling = () => {} + trap.deactivate() } @@ -30,3 +43,44 @@ export default function (Alpine) { })) }) } + +function setInert(el) { + let undos = [] + + crawlSiblingsUp(el, (sibling) => { + let cache = sibling.hasAttribute('aria-hidden') + + sibling.setAttribute('aria-hidden', 'true') + + undos.push(() => cache || sibling.removeAttribute('aria-hidden')) + }) + + return () => { + while(undos.length) undos.pop()() + } +} + +function crawlSiblingsUp(el, callback) { + if (el.isSameNode(document.body) || ! el.parentNode) return + + Array.from(el.parentNode.children).forEach(sibling => { + if (! sibling.isSameNode(el)) callback(sibling) + + crawlSiblingsUp(el.parentNode, callback) + }) +} + +function disableScrolling() { + let overflow = document.documentElement.style.overflow + let paddingRight = document.documentElement.style.paddingRight + + let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + + document.documentElement.style.overflow = 'hidden' + document.documentElement.style.paddingRight = `${scrollbarWidth}px` + + return () => { + document.documentElement.style.overflow = overflow + document.documentElement.style.paddingRight = paddingRight + } +} diff --git a/tests/cypress/integration/plugins/trap.spec.js b/tests/cypress/integration/plugins/trap.spec.js index e9a886735..178c62611 100644 --- a/tests/cypress/integration/plugins/trap.spec.js +++ b/tests/cypress/integration/plugins/trap.spec.js @@ -1,4 +1,4 @@ -import { haveText, test, html, haveFocus } from '../../utils' +import { haveText, test, html, haveFocus, notHaveAttribute, haveAttribute } from '../../utils' test('can trap focus', [html` @@ -56,3 +56,44 @@ test('works with clone', get('p').should(haveText('bar')) } ) + +test('can trap focus with inert', + [html` +
+

I should have aria-hidden when outside trap

+ + + +
+ +
+
+ `], + ({ get }, reload) => { + get('#open').should(notHaveAttribute('aria-hidden', 'true')) + get('#open').click() + get('#open').should(haveAttribute('aria-hidden', 'true')) + get('#close').click() + get('#open').should(notHaveAttribute('aria-hidden', 'true')) + }, +) + +test('can trap focus with noscroll', + [html` +
+ + +
+ +
+ +
 
+
+ `], + ({ get }, reload) => { + get('#open').click() + get('html').should(haveAttribute('style', 'overflow: hidden; padding-right: 0px;')) + get('#close').click() + get('html').should(notHaveAttribute('style', 'overflow: hidden; padding-right: 0px;')) + }, +)