@natachah/vanilla-frontend 0.1.21 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,6 +104,25 @@
104
104
  </doc-code>
105
105
  </div>
106
106
 
107
+ <p>By default the external link with <code>target="_blank"</code> attribute, will have an icon for accessibility.</p>
108
+
109
+ <doc-demo>
110
+ <a href="#" target="_blank" title="This link opens in a new window" aria-label="This link opens in a new window">Anchor</a>
111
+ </doc-demo>
112
+
113
+ <div class="code-group">
114
+ <div role="tablist">
115
+ <button role="tab" aria-controls="html">HTML</button>
116
+ <button role="tab" aria-controls="css">CSS</button>
117
+ </div>
118
+ <doc-code id="html" data-type="html" role="tabpanel">
119
+ <a href="#" target="_blank" title="This link opens in a new window" aria-label="This link opens in a new window">Anchor</a>
120
+ </doc-code>
121
+ <doc-code id="css" data-type="css" role="tabpanel">
122
+ --icon-external
123
+ </doc-code>
124
+ </div>
125
+
107
126
  <h2>Inline</h2>
108
127
 
109
128
  <doc-demo>
@@ -31,7 +31,7 @@
31
31
  <rect width="100" height="10" x="0" y="80" rx="0"></rect>
32
32
  </svg>
33
33
  </button>
34
- <div id="drawer" class="drawer" tabindex="0" hidden>
34
+ <div id="drawer" class="drawer" hidden>
35
35
  My awsome drawer !
36
36
  </div>
37
37
  </doc-code>
@@ -63,6 +63,8 @@
63
63
 
64
64
  <blockquote class="note">The <code>#backdrop</code> must be a child of the <code>&lt;body&gt;</code> ! <br> And the default CSS breakpoint to view the backdrop is 960px</blockquote>
65
65
 
66
+ <blockquote class="warning"> You should add a toggle button inside the drawer to avoid some Focus Trap !</blockquote>
67
+
66
68
  <h2>Javascript</h2>
67
69
  <p>This component is mostly in javascript, to use it you must import the javascript file and create a new Drawer object.</p>
68
70
  <p>You can have a <b>Backdrop</b> if you want to make it more like a drawer opening on the front of the content, you juste need a <code>#backdrop</code> somewhere on your page.</p>
@@ -106,7 +108,7 @@
106
108
  <doc-code data-type="js">
107
109
  import Drawer from '@natachah/vanilla-frontend/js/utilities/_drawer.js'
108
110
  const drawer = document.getElementById('drawer')
109
- if (drawer) new Drawer(drawer, { breakpoint : 960, cookkie: '_drawer-cookie' })
111
+ if (drawer) new Drawer(drawer, { breakpoint : 960, cookie: '_drawer-cookie' })
110
112
  </doc-code>
111
113
 
112
114
  <blockquote>
@@ -0,0 +1,89 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Documentations: Javascript > Trap</title>
8
+ </head>
9
+
10
+ <body data-preload>
11
+ <doc-layout>
12
+
13
+ <h1>Trap</h1>
14
+ <p>The trap component make you able to create a focus trap on an element.</p>
15
+
16
+ <h2>Javascript</h2>
17
+ <p>This component is only in javascript, to use it you must import the javascript file and create a new Trap object.</p>
18
+ <p>You can pass the element and an array of exclusions when initiate the component.</p>
19
+
20
+ <doc-code data-type="js">
21
+ import Trap from "@natachah/vanilla-frontend/js/utilities/_trap"
22
+ new Trap(document.getElementById('drawer'), ['#backdrop'])
23
+ </doc-code>
24
+
25
+ <h3>Methods</h3>
26
+ <table>
27
+ <thead>
28
+ <tr>
29
+ <th>Method</th>
30
+ <th>Description</th>
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ <tr>
35
+ <td data-label="Method">
36
+ <p>focusOnFirst()</p>
37
+ </td>
38
+ <td data-label="Description">
39
+ <p>This will focus on the first focusable element</p>
40
+ </td>
41
+ </tr>
42
+ <tr>
43
+ <td data-label="Method">
44
+ <p>activate()</p>
45
+ </td>
46
+ <td data-label="Description">
47
+ <p>This method will activate the trap, it will also set <code>inert</code> and <code>aria-hidden</code> attributes on other HTMLElements</p>
48
+ </td>
49
+ </tr>
50
+ <tr>
51
+ <td data-label="Method">
52
+ <p>desactivate()</p>
53
+ </td>
54
+ <td data-label="Description">
55
+ <p>This method will desactivate the trap and remove related attributes</p>
56
+ </td>
57
+ </tr>
58
+ <tr>
59
+ <td data-label="Method">
60
+ <p>handleKeydown(e)</p>
61
+ </td>
62
+ <td data-label="Description">
63
+ <p>This method will keep the <kbd>TAB</kbd> focus inside the element</p>
64
+ </td>
65
+ </tr>
66
+ <tr>
67
+ <td data-label="Method">
68
+ <p>getFocusableElements()</p>
69
+ </td>
70
+ <td data-label="Description">
71
+ <p>This method will return an array of focusable items inside the element</p>
72
+ </td>
73
+ </tr>
74
+ <tr>
75
+ <td data-label="Method">
76
+ <p>getSiblingsOutside()</p>
77
+ </td>
78
+ <td data-label="Description">
79
+ <p>This method will return an array of HTMLElements outside of the element</p>
80
+ </td>
81
+ </tr>
82
+ </tbody>
83
+ </table>
84
+
85
+ </doc-layout>
86
+ <script type="module" src="/main.js"></script>
87
+ </body>
88
+
89
+ </html>
@@ -28,69 +28,70 @@
28
28
  <doc-code data-type="css">
29
29
  :root {
30
30
 
31
- // Typography
32
- --font-size: 16px;
33
- --line-height: 1.5;
34
- --font-family: Arial;
35
- --font-weight: normal;
36
-
37
- --font-size-h1: 2.25em; // 36px
38
- --font-size-h2: 2.00em; // 32px
39
- --font-size-h3: 1.75em; // 28px
40
- --font-size-h4: 1.50em; // 24px
41
- --font-size-h5: 1.25em; // 20px
42
- --font-size-h6: 1.125em; // 18px
43
- --font-size-large: 1.125em; // 18px
44
- --font-size-small: .875em; // 14px
45
-
46
- // Anchor
47
- --decoration: none;
48
-
49
- // Layouts
50
- --padding-inline: .75em;
51
- --padding-block: .5em;
52
-
53
- // Border
54
- --border-size: 1px;
55
- --border-style: solid;
56
- --border-radius: .25rem;
57
-
58
- // Outline (:focus)
59
- --outline-size: 3px;
60
- --outline-style: solid;
61
- --outline-offset: 0;
62
- --outline-opacity: 50%;
63
-
64
- // Hover (color-mix)
65
- --hover-color: black;
66
- --hover-percent: 5%;
67
-
68
- // Active (color-mix)
69
- --active-color: black;
70
- --active-percent: 10%;
71
-
72
- // Disabled
73
- --disabled-opacity: 50%;
74
-
75
- // Colors
76
- --color-body: white;
77
- --color-font: black;
78
- --color-primary: #B790E5;
79
- --color-error: #DC3030;
80
- --color-success: #008A00;
81
- --color-warning: #FFA500;
82
-
83
- // Contrasts
84
- --color-warning-contrast: black;
85
-
86
- // Icons
87
- --icon-date: url('data:image/svg+xml,...');
88
- --icon-time: url('data:image/svg+xml,...');
89
- --icon-file: url('data:image/svg+xml,...');
90
- --icon-select: url('data:image/svg+xml,...');
91
- --icon-radio: url('data:image/svg+xml,...');
92
- --icon-check: url('data:image/svg+xml,...');
93
- --icon-switch: var(--icon-radio);
31
+ // Typography
32
+ --font-size: 16px;
33
+ --line-height: 1.5;
34
+ --font-family: Arial;
35
+ --font-weight: normal;
36
+
37
+ --font-size-h1: 2.25em; // 36px
38
+ --font-size-h2: 2.00em; // 32px
39
+ --font-size-h3: 1.75em; // 28px
40
+ --font-size-h4: 1.50em; // 24px
41
+ --font-size-h5: 1.25em; // 20px
42
+ --font-size-h6: 1.125em; // 18px
43
+ --font-size-large: 1.125em; // 18px
44
+ --font-size-small: .875em; // 14px
45
+
46
+ // Anchor
47
+ --decoration: none;
48
+
49
+ // Layouts
50
+ --padding-inline: .75em;
51
+ --padding-block: .5em;
52
+
53
+ // Border
54
+ --border-size: 1px;
55
+ --border-style: solid;
56
+ --border-radius: .25rem;
57
+
58
+ // Outline (:focus)
59
+ --outline-size: 3px;
60
+ --outline-style: solid;
61
+ --outline-offset: 0;
62
+ --outline-opacity: 50%;
63
+
64
+ // Hover (color-mix)
65
+ --hover-color: black;
66
+ --hover-percent: 5%;
67
+
68
+ // Active (color-mix)
69
+ --active-color: black;
70
+ --active-percent: 10%;
71
+
72
+ // Disabled
73
+ --disabled-opacity: 50%;
74
+
75
+ // Colors
76
+ --color-body: white;
77
+ --color-font: black;
78
+ --color-primary: #B790E5;
79
+ --color-error: #DC3030;
80
+ --color-success: #008A00;
81
+ --color-warning: #FFA500;
82
+
83
+ // Contrasts
84
+ --color-warning-contrast: black;
85
+
86
+ // Icons
87
+ --icon-external: url('data:image/svg+xml,...');
88
+ --icon-date: url('data:image/svg+xml,...');
89
+ --icon-time: url('data:image/svg+xml,...');
90
+ --icon-file: url('data:image/svg+xml,...');
91
+ --icon-select: url('data:image/svg+xml,...');
92
+ --icon-radio: url('data:image/svg+xml,...');
93
+ --icon-check: url('data:image/svg+xml,...');
94
+ --icon-switch: var(--icon-radio);
94
95
 
95
96
  }
96
97
  </doc-code>
@@ -102,20 +103,20 @@
102
103
  // This is the light theme (or if there is none)
103
104
  html[data-theme=light],
104
105
  html:not([data-theme]) {
105
- --color-body: white;
106
- --color-font: black;
106
+ --color-body: white;
107
+ --color-font: black;
107
108
  }
108
109
 
109
110
  // This is for the dark theme
110
111
  html[data-theme=dark] {
111
- --color-body: black;
112
- --color-font: white;
112
+ --color-body: black;
113
+ --color-font: white;
113
114
  }
114
115
 
115
116
  // This is for the dark theme
116
117
  html[data-theme=my-custom-theme] {
117
- --color-body: white;
118
- --color-font: orange;
118
+ --color-body: white;
119
+ --color-font: orange;
119
120
  }
120
121
  </doc-code>
121
122
 
@@ -137,10 +138,10 @@
137
138
 
138
139
  <doc-code data-type="scss">
139
140
  @use '@natachah/vanilla-frontend/scss/abstracts/_options' with (
140
- $colors: (
141
- primary,
142
- danger
143
- )
141
+ $colors: (
142
+ primary,
143
+ danger
144
+ )
144
145
  );
145
146
  @use "@natachah/vanilla-frontend/scss/base";
146
147
  </doc-code>
@@ -18,7 +18,7 @@ class DocLayout extends HTMLElement {
18
18
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pin-angle" viewBox="0 0 16 16">
19
19
  <path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a6 6 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707s.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a6 6 0 0 1 1.013.16l3.134-3.133a3 3 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146m.122 2.112v-.002zm0-.002v.002a.5.5 0 0 1-.122.51L6.293 6.878a.5.5 0 0 1-.511.12H5.78l-.014-.004a5 5 0 0 0-.288-.076 5 5 0 0 0-.765-.116c-.422-.028-.836.008-1.175.15l5.51 5.509c.141-.34.177-.753.149-1.175a5 5 0 0 0-.192-1.054l-.004-.013v-.001a.5.5 0 0 1 .12-.512l3.536-3.535a.5.5 0 0 1 .532-.115l.096.022c.087.017.208.034.344.034q.172.002.343-.04L9.927 2.028q-.042.172-.04.343a1.8 1.8 0 0 0 .062.46z"/>
20
20
  </svg>
21
- 0.1.21
21
+ 0.2.0
22
22
  </span>
23
23
  </li>
24
24
  <li>
@@ -36,7 +36,7 @@ class DocLayout extends HTMLElement {
36
36
  </ul>
37
37
  </nav>
38
38
  </header>
39
- <aside id="sidebar" class="drawer" hidden>
39
+ <div id="sidebar" class="drawer" hidden>
40
40
  <header>
41
41
  <a href="/index.html">
42
42
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rocket" viewBox="0 0 16 16">
@@ -91,14 +91,18 @@ class DocLayout extends HTMLElement {
91
91
  <li><a href="/pages/javascript/sortable.html">Sortable</a></li>
92
92
  <li><a href="/pages/javascript/tabpanel.html">Tabpanel</a></li>
93
93
  <li><a href="/pages/javascript/toggle.html">Toggle</a></li>
94
+ <li><a href="/pages/javascript/trap.html">Trap</a></li>
94
95
  <li><a href="/pages/javascript/tree.html">Tree</a></li>
95
96
  </ul>
96
97
  <div id="copyright">
97
98
  Released under the MIT License.<br>
98
99
  Copyright © 2024-present <a href="https://natachaherth.ch">Natacha Herth</a>
99
100
  </div>
101
+ <button aria-expanded="false" aria-pressed="false" aria-controls="sidebar" aria-label="Close the sidebar navigation">
102
+ Close
103
+ </button>
100
104
  </nav>
101
- </aside>
105
+ </div>
102
106
  <main>
103
107
  ${this.innerHTML}
104
108
  </main>
@@ -40,7 +40,7 @@ doc-layout {
40
40
  overflow-y: auto;
41
41
  }
42
42
 
43
- aside {
43
+ #sidebar {
44
44
 
45
45
  --drawer-display: grid;
46
46
  --drawer-transform: translatex(-100%);
@@ -75,7 +75,7 @@ doc-layout {
75
75
  }
76
76
 
77
77
  @media (min-width: 960px) {
78
- &:has(aside:not([hidden])) {
78
+ &:has(#sidebar:not([hidden])) {
79
79
  grid-template-columns: 320px 1fr;
80
80
  }
81
81
  }
@@ -47,7 +47,7 @@ doc-layout {
47
47
  border-bottom: 1px solid var(--doc-border-color);
48
48
  }
49
49
 
50
- > aside {
50
+ > #sidebar {
51
51
 
52
52
  background-color: var(--color-body, white);
53
53
  border-right: 1px solid var(--doc-border-color);
package/js/_drawer.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import BaseComponent from './utilities/_base-component'
14
14
  import ErrorMessage from "./utilities/_error"
15
15
  import Cookie from "./utilities/_cookie"
16
+ import Trap from "./utilities/_trap"
16
17
 
17
18
  export default class Drawer extends BaseComponent {
18
19
 
@@ -45,13 +46,12 @@ export default class Drawer extends BaseComponent {
45
46
 
46
47
  this._isOpen = !this._element.hidden
47
48
 
48
- this._focus = this._element.getAttribute('tabindex') === '0' ? this._element : this._element.querySelector('[tabindex="0"]') ?? this._element.querySelector('button, a, input')
49
-
50
49
  this._cookie = this._options.cookie ? new Cookie(this._options.cookie) : null
51
50
 
51
+ this._trap = new Trap(this._element, ['#backdrop'])
52
+
52
53
  // Init the event listener
53
54
  this.#init()
54
-
55
55
  }
56
56
 
57
57
  /**
@@ -64,29 +64,15 @@ export default class Drawer extends BaseComponent {
64
64
  // Set the cookie by default
65
65
  if (this._cookie && !this._cookie.has('drawer-is-open')) this._cookie.set({ ...this._cookie.value, 'drawer-is-open': this._isOpen })
66
66
 
67
- // Define the default state on bigger screen
68
- const shouldBeOpen = this._cookie ? this._cookie.get('drawer-is-open') : true
69
-
70
- // Window is bigger than breakpoint
71
- // -> Show/Hide the drawer by the cookie
72
- if (window.innerWidth > this._options.breakpoint) this.toggle(shouldBeOpen)
73
-
74
- // Window is smaller than breakpoint
75
- // -> Hide the drawer
76
- if (window.innerWidth <= this._options.breakpoint) this.toggle(false)
67
+ // Define the default status of the drawer by breakpoint
68
+ this.defineDrawerByBreakpoint()
77
69
 
78
70
  // On window resize
79
71
  // -> Toggle the drawer
80
72
  window.onresize = () => {
81
73
  clearTimeout(this._timeout)
82
74
  this._timeout = setTimeout(() => {
83
-
84
- // Bigger than breakpoint
85
- if (window.innerWidth > this._options.breakpoint && (!this._isOpen && shouldBeOpen)) this.toggle(true)
86
-
87
- // Smaller than breakpoint -> Invisible
88
- if (window.innerWidth <= this._options.breakpoint && this._isOpen) this.toggle(false)
89
-
75
+ this.defineDrawerByBreakpoint()
90
76
  }, 250)
91
77
  }
92
78
 
@@ -98,6 +84,34 @@ export default class Drawer extends BaseComponent {
98
84
 
99
85
  }
100
86
 
87
+ defineDrawerByBreakpoint() {
88
+
89
+ let drawerStatus
90
+
91
+ // On mobil, the drawer become a Dialog and it is hidden per default
92
+ if (window.innerWidth <= this._options.breakpoint) {
93
+ // Add Dialog role
94
+ this._element.setAttribute('role', 'dialog')
95
+ this._element.setAttribute('aria-modal', true)
96
+
97
+ // The drawer should be closed
98
+ drawerStatus = false
99
+ }
100
+
101
+ // On breakpoint, the drawer become by default "this._options.default"
102
+ if (window.innerWidth > this._options.breakpoint) {
103
+ // Remove Dialog role
104
+ this._element.removeAttribute('role')
105
+ this._element.removeAttribute('aria-modal')
106
+
107
+ // The drawer should be cookie or open
108
+ drawerStatus = this._cookie ? this._cookie.get('drawer-is-open') : true
109
+ }
110
+
111
+ if (drawerStatus != this._isOpen) this.toggle(drawerStatus)
112
+
113
+ }
114
+
101
115
  /**
102
116
  * Toggle the drawer
103
117
  *
@@ -122,15 +136,15 @@ export default class Drawer extends BaseComponent {
122
136
  this._cookie.set({ ...this._cookie.value, 'drawer-is-open': this._isOpen })
123
137
  }
124
138
 
125
- // Add the focus if open
139
+ // Add the Trap focus
126
140
  // * Need to wait the transition before make it focused
127
- if (this._focus && this._isOpen) {
128
- this._element.addEventListener("transitionend", () => {
129
- this._focus.focus()
130
- }, { once: true })
131
- }
141
+ this._element.addEventListener('transitionend', () => {
142
+ if (this._element.getAttribute('role') === 'dialog') {
143
+ this._isOpen ? this._trap.activate() : this._trap.desactivate()
144
+ } else if (this._isOpen) {
145
+ this._trap.focusOnFirst()
146
+ }
147
+ }, { once: true })
132
148
 
133
149
  }
134
-
135
-
136
150
  }
@@ -44,7 +44,6 @@ describe('Structure of the class', () => {
44
44
  expect(fakeDrawer._buttons).toStrictEqual(document.querySelectorAll('button[aria-controls="drawer"]'))
45
45
  expect(fakeDrawer._backdrop).toStrictEqual(document.getElementById('backdrop'))
46
46
  expect(fakeDrawer._isOpen).toBeTruthy()
47
- expect(fakeDrawer._focus).toStrictEqual(document.getElementById('focus'))
48
47
  })
49
48
 
50
49
  })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ------------------------------------------------------------------
3
+ * TEST for the /utilities/_trap.js
4
+ * ------------------------------------------------------------------
5
+ * The test will take care of:
6
+ * - Has all the public method
7
+ * - Constructor: Passing the correct parameters
8
+ * - FocusOnFirst(): Passing the correct parameters
9
+ * - Activate(): Save the new cookie value
10
+ * - Desactivate(): Passing the correct parameters
11
+ * - HandleKeydown(): Verify the existance of a key in the cookie value
12
+ * - GetFocusableElements(): Passing the correct parameters
13
+ */
14
+
15
+ import { describe, test, expect, beforeAll, vi } from "vitest"
16
+ import { fireEvent } from "@testing-library/dom"
17
+ import Trap from "../utilities/_trap"
18
+ import ErrorMessage from "../utilities/_error"
19
+
20
+ let fakeTrap, fakeBtn, fakeLink, fakeDisabledLink, fakeInput
21
+
22
+ /**
23
+ * Before all tests
24
+ *
25
+ */
26
+
27
+ beforeAll(() => {
28
+
29
+ document.body.innerHTML =
30
+ '<div id="trap">' +
31
+ '<button id="button"></button>' +
32
+ '<a id="link" href="#"></a>' +
33
+ '<a id="linkDisabled"></a>' +
34
+ '<input id="input">' +
35
+ '</div>' +
36
+ '<div id="backdrop"></div>' +
37
+ '<div id="content"></div>'
38
+
39
+ fakeTrap = new Trap(document.getElementById('trap'), ['#backdrop'])
40
+ fakeBtn = document.getElementById('button')
41
+ fakeLink = document.getElementById('link')
42
+ fakeDisabledLink = document.getElementById('linkDisabled')
43
+ fakeInput = document.getElementById('input')
44
+
45
+ for (const el of [fakeBtn, fakeLink, fakeDisabledLink, fakeInput]) {
46
+ Object.defineProperty(el, 'offsetWidth', { configurable: true, value: 100 })
47
+ Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 20 })
48
+ }
49
+
50
+ })
51
+
52
+ describe('Structure of the class', () => {
53
+
54
+ test('Has all the public method', () => {
55
+ expect(fakeTrap.focusOnFirst).toBeTypeOf('function')
56
+ expect(fakeTrap.activate).toBeTypeOf('function')
57
+ expect(fakeTrap.desactivate).toBeTypeOf('function')
58
+ expect(fakeTrap.handleKeydown).toBeTypeOf('function')
59
+ expect(fakeTrap.getFocusableElements).toBeTypeOf('function')
60
+ expect(fakeTrap.getSiblingsOutside).toBeTypeOf('function')
61
+ })
62
+
63
+ test('Constructor: Passing the correct parameters', () => {
64
+ expect(() => new Trap()).toThrowError(ErrorMessage.instanceOf('el', 'HTMLElement'))
65
+ expect(() => new Trap(document.getElementById('trap'), 'error')).toThrowError(ErrorMessage.typeOf('exclusions', 'object'))
66
+ })
67
+
68
+ })
69
+
70
+ describe('FocusOnFirst()', () => {
71
+
72
+ test('Focus on first element', () => {
73
+
74
+ expect(fakeTrap._items).toStrictEqual([])
75
+ expect(fakeTrap._first).toStrictEqual(null)
76
+
77
+ fakeTrap.focusOnFirst()
78
+
79
+ expect(fakeTrap._items).toStrictEqual(fakeTrap.getFocusableElements())
80
+ expect(fakeTrap._first).toStrictEqual(fakeBtn)
81
+ expect(document.activeElement).toBe(fakeTrap._first)
82
+
83
+ })
84
+
85
+ })
86
+
87
+ describe('Toggle the trap', () => {
88
+
89
+ test('Activate the trap', () => {
90
+ const eventSpy = vi.spyOn(fakeTrap._element, 'addEventListener')
91
+
92
+ fakeTrap.activate()
93
+
94
+ expect(fakeTrap._items).toStrictEqual(fakeTrap.getFocusableElements())
95
+ expect(fakeTrap._items).not.toContain(fakeDisabledLink)
96
+ expect(fakeTrap._first).toStrictEqual(document.getElementById('button'))
97
+ expect(fakeTrap._last).toStrictEqual(document.getElementById('input'))
98
+ expect(fakeTrap._inerts).toStrictEqual(fakeTrap.getSiblingsOutside())
99
+ expect(fakeTrap._inerts).not.toContain(document.getElementById('backdrop'))
100
+ expect(document.activeElement).toBe(fakeTrap._first)
101
+ expect(eventSpy).toHaveBeenCalledWith('keydown', fakeTrap.handleKeydown)
102
+ expect(document.getElementById('backdrop').hasAttribute('inert')).toBeFalsy()
103
+ expect(document.getElementById('backdrop').hasAttribute('aria-hidden')).toBeFalsy()
104
+ expect(document.getElementById('content').hasAttribute('inert')).toBeTruthy()
105
+ expect(document.getElementById('content').hasAttribute('aria-hidden')).toBeTruthy()
106
+
107
+ })
108
+
109
+ test('Desactivate the trap', () => {
110
+ const eventSpy = vi.spyOn(fakeTrap._element, 'removeEventListener')
111
+ fakeTrap.desactivate()
112
+ expect(eventSpy).toHaveBeenCalledWith('keydown', fakeTrap.handleKeydown)
113
+ expect(document.getElementById('backdrop').hasAttribute('inert')).toBeFalsy()
114
+ expect(document.getElementById('backdrop').hasAttribute('aria-hidden')).toBeFalsy()
115
+ expect(document.getElementById('content').hasAttribute('inert')).toBeFalsy()
116
+ expect(document.getElementById('content').hasAttribute('aria-hidden')).toBeFalsy()
117
+ })
118
+
119
+ })
@@ -0,0 +1,155 @@
1
+ /**
2
+ * ------------------------------------------------------------------
3
+ * Focus Trap
4
+ * ------------------------------------------------------------------
5
+ * This class create a focus trap on an element as a drawer or a dialog.
6
+ *
7
+ * @author Natacha Herth
8
+ * @copyright Natacha Herth, design & web development
9
+ */
10
+
11
+ import ErrorMessage from "./_error"
12
+
13
+ export default class Trap {
14
+
15
+ /**
16
+ * Creates an instance
17
+ *
18
+ * @constructor
19
+ */
20
+ constructor(el, exclusions = null) {
21
+
22
+ // Check for errors
23
+ if (!(el instanceof HTMLElement)) throw new Error(ErrorMessage.instanceOf('el', 'HTMLElement'))
24
+ if (exclusions && typeof exclusions !== 'object') throw new Error(ErrorMessage.typeOf('exclusions', 'object'))
25
+
26
+ // Define elements
27
+ this._element = el
28
+ this._items = []
29
+ this._first = null
30
+ this._last = null
31
+ this._inerts = []
32
+ this._exclusions = exclusions ?? []
33
+ this.handleKeydown = this.handleKeydown.bind(this)
34
+
35
+ }
36
+
37
+ /**
38
+ * Focus on first element
39
+ *
40
+ */
41
+ focusOnFirst() {
42
+ // Define the focusable items
43
+ this._items = this.getFocusableElements()
44
+ if (this._items.length === 0) return
45
+
46
+ // Define the first and last item
47
+ this._first = this._element.querySelector('[tabindex="0"]') ?? this._items[0]
48
+
49
+ // Focus the first element by default
50
+ this._first.focus()
51
+ }
52
+
53
+ /**
54
+ * Activate the trap
55
+ *
56
+ */
57
+ activate() {
58
+ // Define the focusable items
59
+ this._items = this.getFocusableElements()
60
+ if (this._items.length === 0) return
61
+
62
+ // Define the first and last item
63
+ this._first = this._element.querySelector('[tabindex="0"]') ?? this._items[0]
64
+ this._last = this._items[this._items.length - 1]
65
+
66
+ // Define event listener
67
+ this._element.addEventListener('keydown', this.handleKeydown)
68
+
69
+ // Focus the first element by default
70
+ this._first.focus()
71
+
72
+ // Make the rest of the content inert for screen reader
73
+ this._inerts = this.getSiblingsOutside()
74
+ this._inerts.forEach(el => {
75
+ el.setAttribute('aria-hidden', 'true')
76
+ el.setAttribute('inert', true)
77
+ })
78
+
79
+ }
80
+
81
+ /**
82
+ * Desactivate the trap
83
+ *
84
+ */
85
+ desactivate() {
86
+ this._element.removeEventListener('keydown', this.handleKeydown)
87
+
88
+ this._inerts.forEach(el => {
89
+ el.removeAttribute('aria-hidden')
90
+ el.removeAttribute('inert')
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Handle event on Tab
96
+ *
97
+ * @param {event} e - Event
98
+ */
99
+ handleKeydown(e) {
100
+
101
+ // Check for the Tab event only
102
+ if (e.key !== 'Tab') return
103
+
104
+ const isShift = e.shiftKey
105
+
106
+ if (isShift && document.activeElement === this._first) {
107
+ e.preventDefault()
108
+ this._last.focus()
109
+ } else if (!isShift && document.activeElement === this._last) {
110
+ e.preventDefault()
111
+ this._first.focus()
112
+ }
113
+
114
+ }
115
+
116
+ /**
117
+ * Get all focusable elements
118
+ *
119
+ */
120
+ getFocusableElements() {
121
+ return Array.from(
122
+ this._element.querySelectorAll('a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]),textarea:not([disabled]), button:not([disabled]), iframe, object, embed,[contenteditable], [tabindex]:not([tabindex="-1"])')
123
+ ).filter(el => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement)
124
+ }
125
+
126
+ /**
127
+ * Find elements outside of the element
128
+ * Generated by ChatGPT
129
+ *
130
+ * @returns {HTMLElement[]}
131
+ */
132
+ getSiblingsOutside() {
133
+ const hiddenEls = new Set()
134
+ let current = this._element
135
+
136
+ while (current && current !== document.body) {
137
+ const parent = current.parentElement
138
+ if (!parent) break
139
+
140
+ const siblings = Array.from(parent.children).filter(el =>
141
+ el !== current &&
142
+ !current.contains(el) &&
143
+ !this._exclusions.some(sel => el.matches(sel))
144
+ )
145
+
146
+ siblings.forEach(el => hiddenEls.add(el))
147
+
148
+ current = parent
149
+ }
150
+
151
+ return Array.from(hiddenEls)
152
+
153
+ }
154
+
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natachah/vanilla-frontend",
3
- "version": "0.1.21",
3
+ "version": "0.2.0",
4
4
  "description": "A vanilla frontend framework",
5
5
  "keywords": [
6
6
  "html5",
@@ -120,16 +120,16 @@ body {
120
120
  overflow-x: hidden;
121
121
  margin-right: calc(100% - 100vw);
122
122
 
123
- // Avoid scroll when a dialog is opened
124
- &[inert] {
125
- overflow: hidden;
126
- pointer-events: none;
127
- touch-action: none;
128
- }
129
-
130
123
  // Data attribute to prevent animation on first load DOM
131
124
  &[data-preload] * {
132
125
  transition-duration: 0s !important;
133
126
  }
134
127
 
128
+ }
129
+
130
+ // Avoid scroll when a dialog is opened
131
+ [inert] {
132
+ overflow: hidden;
133
+ pointer-events: none;
134
+ touch-action: none;
135
135
  }
@@ -49,6 +49,18 @@ button[role=link] {
49
49
  opacity: var(--anchor-disabled-opacity, var(--disabled-opacity));
50
50
  }
51
51
 
52
+ &[target=_blank]::after {
53
+ content: "";
54
+ display: inline-block;
55
+ width: .825em;
56
+ height: .825em;
57
+ vertical-align: baseline;
58
+ margin-inline-start: 0.25em;
59
+ background-color: currentColor;
60
+ mask-image: var(--icon-external);
61
+ mask-size: cover;
62
+ }
63
+
52
64
  }
53
65
 
54
66
  small {
@@ -67,6 +67,7 @@
67
67
  --color-warning-contrast: black;
68
68
 
69
69
  // Icons
70
+ --icon-external: url('data: image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" ><path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" /><path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" /></svg>');
70
71
  --icon-date: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="black" viewBox="0 0 16 16"><path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/></svg>');
71
72
  --icon-time: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/></svg>');
72
73
  --icon-file: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/></svg>');