Skip to content

Commit 448734f

Browse files
committed
Initial commit
0 parents  commit 448734f

File tree

11 files changed

+7359
-0
lines changed

11 files changed

+7359
-0
lines changed

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 2
6+
end_of_line = lf
7+
charset = utf-8
8+
trim_trailing_whitespace = true
9+
insert_final_newline = true

.github/workflows/npm-publish.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: NPM Publish
2+
on:
3+
push:
4+
tags:
5+
- '*.*.*'
6+
jobs:
7+
publish:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v1
11+
- uses: actions/setup-node@v3
12+
with:
13+
node-version: 18
14+
- run: npm install
15+
- run: npm test
16+
- uses: JS-DevTools/npm-publish@v3
17+
with:
18+
token: ${{ secrets.NPM_TOKEN }}

.gitignore

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?
25+
26+
._*

.prettierignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules/**
2+
LICENSE
3+
*.min.js
4+
pnpm-lock.yaml
5+
tsconfig.json
6+
tsconfig.tsbuildinfo
7+
._*

.prettierrc.cjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @type {import('prettier').Options}
3+
*/
4+
module.exports = {
5+
printWidth: 80,
6+
tabWidth: 2,
7+
useTabs: false,
8+
semi: false,
9+
singleQuote: true,
10+
trailingComma: 'es5',
11+
bracketSpacing: true,
12+
bracketSameLine: true,
13+
}

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Pipecraft
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# focus-trap-lite
2+
3+
[![npm version](https://img.shields.io/npm/v/focus-trap-lite.svg)](https://www.npmjs.com/package/focus-trap-lite)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5+
[![WAI-ARIA Compliant](https://img.shields.io/badge/WAI--ARIA-Compliant-blue)](https://www.w3.org/WAI/standards-guidelines/aria/)
6+
7+
Lightweight (≤2kB) focus trapping utility for implementing accessible keyboard navigation constraints in modal dialogs, sidebars, and other contained UI components.
8+
9+
## Features
10+
11+
- ✅ Full WAI-ARIA compliance for accessibility
12+
- ✅ Tiny footprint (ES6 module)
13+
- ✅ Zero dependencies
14+
- ✅ Flexible focus control
15+
- ✅ Automatic cleanup
16+
- ✅ TypeScript support
17+
18+
## Installation
19+
20+
```bash
21+
npm install focus-trap-lite
22+
```
23+
24+
## Usage
25+
26+
### Basic Implementation
27+
28+
```javascript
29+
import { initFocusTrap } from 'focus-trap-lite'
30+
31+
// Initialize trap on modal open
32+
function openModal() {
33+
initFocusTrap(modalElement, '.focusable')
34+
// Add your modal opening logic
35+
}
36+
37+
// Trap automatically cleans up when:
38+
// - User closes modal
39+
// - Focus escapes trap boundaries
40+
// - Component unmounts
41+
```
42+
43+
### Advanced Configuration
44+
45+
```javascript
46+
// Container element with custom selector
47+
initFocusTrap(document.querySelector('#modal-container'), '.custom-focusable')
48+
49+
// Custom selector for focusable elements
50+
initFocusTrap(null, '#modal .focusable')
51+
52+
// Container element with default selector
53+
initFocusTrap(document.querySelector('.sidebar'))
54+
55+
// Default selector includes standard focusable elements:
56+
// 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
57+
```
58+
59+
## API
60+
61+
### initFocusTrap(container?, selector?)
62+
63+
| Parameter | Type | Description |
64+
| --------- | --------- | ------------------------------------------------------------------------------------------------------- |
65+
| container | `Element` | _(Optional)_ DOM element to scope the focus trap. When omitted, uses document.body |
66+
| selector | `string` | _(Optional)_ CSS selector for focusable elements within container. Default: standard focusable elements |
67+
68+
**Behavior:**
69+
70+
- Creates keyboard navigation constraints
71+
- Handles boundary focus wrapping
72+
- Automatic cleanup triggers:
73+
- When trapped container is removed from DOM
74+
- When calling function returns
75+
- On Escape key press
76+
- Dynamic element support
77+
- Focus restoration
78+
- ARIA role management
79+
80+
## Browser Support
81+
82+
Modern browsers with ES6 support:
83+
84+
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/main/src/chrome/chrome_48x48.png" alt="Chrome" width="24" />](http://godban.github.io/browsers-support-badges/)<br/>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/main/src/firefox/firefox_48x48.png" alt="Firefox" width="24" />](http://godban.github.io/browsers-support-badges/)<br/>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/main/src/safari/safari_48x48.png" alt="Safari" width="24" />](http://godban.github.io/browsers-support-badges/)<br/>Safari |
85+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86+
| 88+ | 78+ | 14.1+ |
87+
88+
For legacy browser support, add Array.prototype.at() polyfill.
89+
90+
## Contributing
91+
92+
1. Fork the repository
93+
2. Clone your fork
94+
95+
```bash
96+
git clone https://github.com/your-username/focus-trap-lite.git
97+
```
98+
99+
3. Install dependencies
100+
101+
```bash
102+
npm install
103+
```
104+
105+
4. Create feature branch
106+
107+
```bash
108+
git checkout -b feature/your-feature
109+
```
110+
111+
5. Commit changes
112+
6. Push to branch
113+
7. Create Pull Request
114+
115+
## License
116+
117+
MIT © [Pipecraft](https://www.pipecraft.net)
118+
119+
---
120+
121+
📝 Report issues on [GitHub](https://github.com/utags/focus-trap-lite/issues)
122+
123+
## >\_
124+
125+
[![Pipecraft](https://img.shields.io/badge/site-pipecraft-brightgreen)](https://www.pipecraft.net)
126+
[![UTags](https://img.shields.io/badge/site-UTags-brightgreen)](https://github.com/utags)

index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Initializes focus trap with custom container and selector
3+
* @param {HTMLElement} [container] - Container element to scope the focus trap
4+
* @param {string} [selector] - CSS selector for focusable elements (optional)
5+
* @returns {void}
6+
*
7+
* @description When no selector provided, uses default:
8+
* 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1")]'
9+
*/
10+
export function initFocusTrap(container?: HTMLElement, selector?: string): void

index.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* List of focusable elements within the trap
3+
*/
4+
let focusableElements
5+
/**
6+
* First element in the focus sequence
7+
*/
8+
let firstFocusableElement
9+
/**
10+
* Last element in the focus sequence
11+
*/
12+
let lastFocusableElement
13+
/**
14+
* CSS selector for focusable elements
15+
*/
16+
let focusableElementsSelector
17+
18+
let focusableElementsContainer
19+
20+
/**
21+
* Resets list of focusable elements and manages trap destruction
22+
* @returns {void}
23+
*/
24+
const resetFocusableElements = () => {
25+
focusableElements = Array.from(
26+
(focusableElementsContainer || document).querySelectorAll(
27+
focusableElementsSelector
28+
)
29+
).filter((element) => {
30+
return (
31+
(element.offsetWidth > 0 || element.offsetHeight > 0) &&
32+
element.disabled !== true
33+
)
34+
})
35+
firstFocusableElement = focusableElements[0]
36+
lastFocusableElement = focusableElements.at(-1)
37+
38+
if (!firstFocusableElement || !lastFocusableElement) {
39+
destroyFocusTrap()
40+
}
41+
}
42+
43+
/**
44+
* Initializes focusable elements and creates temporary anchor element
45+
* Creates invisible button to capture initial focus and handle boundary cases
46+
* @returns {void}
47+
*/
48+
const initFocusableElements = () => {
49+
resetFocusableElements()
50+
51+
if (firstFocusableElement) {
52+
const temporaryFirstFocusableElement = document.createElement('button')
53+
temporaryFirstFocusableElement.setAttribute(
54+
'style',
55+
'position: absolute; top: -100px;'
56+
)
57+
58+
firstFocusableElement.before(temporaryFirstFocusableElement)
59+
60+
temporaryFirstFocusableElement.addEventListener('blur', () => {
61+
temporaryFirstFocusableElement.remove()
62+
resetFocusableElements()
63+
})
64+
65+
firstFocusableElement = temporaryFirstFocusableElement
66+
67+
temporaryFirstFocusableElement.focus()
68+
}
69+
}
70+
71+
/**
72+
* Handles keyboard navigation constraints
73+
* @param {KeyboardEvent} event - Keyboard event
74+
* @returns {void}
75+
*/
76+
const handleKeyDown = (event) => {
77+
resetFocusableElements()
78+
if (!document.body.contains(firstFocusableElement)) {
79+
destroyFocusTrap()
80+
return
81+
}
82+
83+
if (event.key === 'Tab') {
84+
if (!firstFocusableElement || !lastFocusableElement) {
85+
destroyFocusTrap()
86+
return
87+
}
88+
89+
// After click adress bar or developer tool, the document.activeElement will be empty, so we need to init it again
90+
if (!focusableElements.includes(document.activeElement)) {
91+
initFocusableElements()
92+
}
93+
94+
if (event.shiftKey) {
95+
if (document.activeElement === firstFocusableElement) {
96+
lastFocusableElement.focus()
97+
event.preventDefault()
98+
}
99+
} else if (document.activeElement === lastFocusableElement) {
100+
firstFocusableElement.focus()
101+
event.preventDefault()
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Destroys focus trap by removing event listeners
108+
* @returns {void}
109+
*/
110+
function destroyFocusTrap() {
111+
document.removeEventListener('keydown', handleKeyDown)
112+
}
113+
114+
/**
115+
* Initializes focus trap with custom container and selector
116+
* @param {Object} [container] - Container element to scope the focus trap
117+
* @param {string} [selector] - CSS selector for focusable elements (optional)
118+
* @returns {void}
119+
*
120+
* @description When called with single parameter:
121+
* - Default selector: 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
122+
*/
123+
export function initFocusTrap(container, selector) {
124+
focusableElementsContainer = container
125+
focusableElementsSelector =
126+
selector ||
127+
'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
128+
document.addEventListener('keydown', handleKeyDown)
129+
initFocusableElements()
130+
}

0 commit comments

Comments
 (0)