Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from Nov 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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" ...>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't that be x-trap.inert="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;'))
},
)