Bladeren bron

Add .inert and .noscroll x-trap modifiers (#2309)

Caleb Porzio 3 jaren geleden
bovenliggende
commit
b682aa245a
3 gewijzigde bestanden met toevoegingen van 171 en 3 verwijderingen
  1. 73 0
      packages/docs/src/en/plugins/trap.md
  2. 56 2
      packages/trap/src/index.js
  3. 42 1
      tests/cypress/integration/plugins/trap.spec.js

+ 73 - 0
packages/docs/src/en/plugins/trap.md

@@ -178,3 +178,76 @@ Here is nesting in action:
     </div>
     </div>
 </div>
 </div>
 <!-- END_VERBATIM -->
 <!-- END_VERBATIM -->
+
+<a name="modifiers"></a>
+## Modifiers
+
+<a name="inert"></a>
+### .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
+<!-- When `open` is `false`: -->
+<body>
+    <div x-trap="open" ...>
+        ...
+    </div>
+
+    <div>
+        ...
+    </div>
+</body>
+
+<!-- When `open` is `true`: -->
+<body>
+    <div x-trap="open" ...>
+        ...
+    </div>
+
+    <div aria-hidden="true">
+        ...
+    </div>
+</body>
+```
+
+<a name="noscroll"></a>
+### .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
+<div x-data="{ open: false }">
+    <button>Open Dialog</button>
+
+    <div x-show="open" x-trap.noscroll="open">
+        Dialog Contents
+
+        <button @click="open = false">Close Dialog</button>
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <div x-data="{ open: false }">
+        <button @click="open = true">Open Dialog</button>
+
+        <div x-show="open" x-trap.noscroll="open" class="border mt-4 p-4">
+            <div class="mb-4 text-bold">Dialog Contents</div>
+
+            <p class="mb-4 text-gray-600 text-sm">Notice how you can no longer scroll on this page while this dialog is open.</p>
+
+            <button class="mt-4" @click="open = false">Close Dialog</button>
+        </div>
+    </div>
+</div>
+<!-- END_VERBATIM -->

+ 56 - 2
packages/trap/src/index.js

@@ -1,28 +1,41 @@
 import { createFocusTrap } from 'focus-trap';
 import { createFocusTrap } from 'focus-trap';
 
 
 export default function (Alpine) {
 export default function (Alpine) {
-    Alpine.directive('trap', (el, { expression }, { effect, evaluateLater }) => {
+    Alpine.directive('trap', (el, { expression, modifiers }, { effect, evaluateLater }) => {
         let evaluator = evaluateLater(expression)
         let evaluator = evaluateLater(expression)
 
 
         let oldValue = false
         let oldValue = false
 
 
         let trap = createFocusTrap(el, { 
         let trap = createFocusTrap(el, { 
             escapeDeactivates: false,
             escapeDeactivates: false,
-            allowOutsideClick: true
+            allowOutsideClick: true,
+            fallbackFocus: () => el,
         })
         })
 
 
+        let undoInert = () => {}
+        let undoDisableScrolling = () => {}
+
         effect(() => evaluator(value => {
         effect(() => evaluator(value => {
             if (oldValue === value) return
             if (oldValue === value) return
 
 
             // Start trapping.
             // Start trapping.
             if (value && ! oldValue) {
             if (value && ! oldValue) {
                 setTimeout(() => {
                 setTimeout(() => {
+                    if (modifiers.includes('inert')) undoInert = setInert(el)
+                    if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling()
+
                     trap.activate()
                     trap.activate()
                 });
                 });
             }
             }
 
 
             // Stop trapping.
             // Stop trapping.
             if (! value && oldValue) {
             if (! value && oldValue) {
+                undoInert()
+                undoInert = () => {}
+
+                undoDisableScrolling()
+                undoDisableScrolling = () => {}
+
                 trap.deactivate()
                 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
+    }
+}

+ 42 - 1
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',
 test('can trap focus',
     [html`
     [html`
@@ -56,3 +56,44 @@ test('works with clone',
         get('p').should(haveText('bar'))
         get('p').should(haveText('bar'))
     }
     }
 )
 )
+
+test('can trap focus with inert',
+    [html`
+        <div x-data="{ open: false }">
+            <h1>I should have aria-hidden when outside trap</h1>
+
+            <button id="open" @click="open = true">open</button>
+
+            <div x-trap.inert="open">
+                <button @click="open = false" id="close">close</button>
+            </div>
+        </div>
+    `],
+    ({ 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`
+        <div x-data="{ open: false }">
+            <button id="open" @click="open = true">open</button>
+
+            <div x-trap.noscroll="open">
+                <button @click="open = false" id="close">close</button>
+            </div>
+
+            <div style="height: 100vh">&nbsp;</div>
+        </div>
+    `],
+    ({ 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;'))
+    },
+)