Skip to content

Commit

Permalink
Add .inert and .noscroll x-trap modifiers (#2309)
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Nov 3, 2021
1 parent 98805c3 commit b682aa2
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 3 deletions.
73 changes: 73 additions & 0 deletions packages/docs/src/en/plugins/trap.md
Expand Up @@ -178,3 +178,76 @@ Here is nesting in action:
</div>
</div>
<!-- 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 -->
58 changes: 56 additions & 2 deletions packages/trap/src/index.js
@@ -1,32 +1,86 @@
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()
}

oldValue = !! value
}))
})
}

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
}
}
43 changes: 42 additions & 1 deletion 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`
Expand Down Expand Up @@ -56,3 +56,44 @@ test('works with clone',
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;'))
},
)

0 comments on commit b682aa2

Please sign in to comment.