jekyll-theme-zer0 0.15.2 → 0.17.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -10
- data/README.md +7 -7
- data/_data/authors.yml +2 -2
- data/_data/features.yml +676 -0
- data/_data/navigation/README.md +54 -0
- data/_data/navigation/about.yml +7 -5
- data/_data/navigation/docs.yml +77 -31
- data/_data/navigation/home.yml +4 -3
- data/_data/navigation/main.yml +16 -7
- data/_data/navigation/quickstart.yml +4 -2
- data/_includes/components/js-cdn.html +2 -2
- data/_includes/components/preview-image.html +20 -3
- data/_includes/content/intro.html +11 -2
- data/_includes/content/seo.html +12 -1
- data/_includes/landing/landing-install-cards.html +8 -8
- data/_includes/landing/landing-quick-links.html +4 -4
- data/_includes/navigation/breadcrumbs.html +29 -6
- data/_includes/navigation/nav-tree.html +181 -0
- data/_includes/navigation/navbar.html +262 -9
- data/_includes/navigation/sidebar-left.html +22 -23
- data/_layouts/default.html +1 -1
- data/_layouts/landing.html +1 -1
- data/_layouts/notebook.html +4 -4
- data/_layouts/root.html +2 -2
- data/_plugins/preview_image_generator.rb +29 -8
- data/_sass/core/_nav-tree.scss +145 -0
- data/_sass/custom.scss +3 -0
- data/assets/images/previews/site-personalization-configuration.png +0 -0
- data/assets/js/modules/navigation/config.js +149 -0
- data/assets/js/modules/navigation/focus.js +189 -0
- data/assets/js/modules/navigation/gestures.js +179 -0
- data/assets/js/modules/navigation/index.js +227 -0
- data/assets/js/modules/navigation/keyboard.js +237 -0
- data/assets/js/modules/navigation/scroll-spy.js +219 -0
- data/assets/js/modules/navigation/sidebar-state.js +267 -0
- data/assets/js/modules/navigation/smooth-scroll.js +153 -0
- data/scripts/README.md +8 -1
- data/scripts/lib/preview_generator.py +164 -8
- data/scripts/migrate-nav-modes.sh +146 -0
- data/scripts/update-preview-paths.sh +145 -0
- metadata +17 -3
- data/assets/js/sidebar.js +0 -511
|
@@ -33,6 +33,8 @@ module Jekyll
|
|
|
33
33
|
'style' => 'retro pixel art, 8-bit video game aesthetic, vibrant colors, nostalgic, clean pixel graphics',
|
|
34
34
|
'style_modifiers' => 'pixelated, retro gaming style, CRT screen glow effect, limited color palette',
|
|
35
35
|
'output_dir' => 'assets/images/previews',
|
|
36
|
+
'assets_prefix' => '/assets',
|
|
37
|
+
'auto_prefix' => true,
|
|
36
38
|
'auto_generate' => false,
|
|
37
39
|
'collections' => ['posts', 'docs', 'quickstart']
|
|
38
40
|
}.freeze
|
|
@@ -52,18 +54,37 @@ module Jekyll
|
|
|
52
54
|
site = doc.site
|
|
53
55
|
config = self.config(site)
|
|
54
56
|
|
|
57
|
+
# Normalize the preview path using assets_prefix
|
|
58
|
+
normalized_preview = normalize_preview_path(preview, config)
|
|
59
|
+
|
|
55
60
|
# Build the full path
|
|
56
|
-
preview_path = if
|
|
57
|
-
File.join(site.source,
|
|
58
|
-
elsif
|
|
61
|
+
preview_path = if normalized_preview.start_with?('/')
|
|
62
|
+
File.join(site.source, normalized_preview.sub(/^\//, ''))
|
|
63
|
+
elsif normalized_preview.start_with?('http')
|
|
59
64
|
return true # External URL, assume it exists
|
|
60
65
|
else
|
|
61
|
-
File.join(site.source, config['output_dir'],
|
|
66
|
+
File.join(site.source, config['output_dir'], normalized_preview)
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
File.exist?(preview_path)
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
# Normalize a preview path by adding assets_prefix if needed
|
|
73
|
+
def self.normalize_preview_path(preview, config)
|
|
74
|
+
return preview if preview.nil? || preview.to_s.strip.empty?
|
|
75
|
+
return preview if preview.start_with?('http')
|
|
76
|
+
|
|
77
|
+
assets_prefix = config['assets_prefix'] || '/assets'
|
|
78
|
+
auto_prefix = config['auto_prefix'] != false # Default to true
|
|
79
|
+
|
|
80
|
+
# If auto_prefix is enabled and path doesn't already contain assets_prefix
|
|
81
|
+
if auto_prefix && !preview.include?(assets_prefix)
|
|
82
|
+
"#{assets_prefix}#{preview}"
|
|
83
|
+
else
|
|
84
|
+
preview
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
67
88
|
# Get the preview image path for a document
|
|
68
89
|
def self.preview_path(doc)
|
|
69
90
|
preview = doc.data['preview']
|
|
@@ -72,11 +93,11 @@ module Jekyll
|
|
|
72
93
|
site = doc.site
|
|
73
94
|
config = self.config(site)
|
|
74
95
|
|
|
75
|
-
# If it's
|
|
76
|
-
return preview if preview.start_with?('
|
|
96
|
+
# If it's an external URL, return as-is
|
|
97
|
+
return preview if preview.start_with?('http')
|
|
77
98
|
|
|
78
|
-
#
|
|
79
|
-
|
|
99
|
+
# Normalize path with assets_prefix
|
|
100
|
+
normalize_preview_path(preview, config)
|
|
80
101
|
end
|
|
81
102
|
|
|
82
103
|
# Get list of documents missing preview images
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ===================================================================
|
|
2
|
+
// NAV TREE - Sidebar Navigation Tree Styles
|
|
3
|
+
// ===================================================================
|
|
4
|
+
//
|
|
5
|
+
// File: _nav-tree.scss
|
|
6
|
+
// Path: _sass/core/_nav-tree.scss
|
|
7
|
+
// Purpose: Styles for the hierarchical navigation tree component
|
|
8
|
+
//
|
|
9
|
+
// Features:
|
|
10
|
+
// - Collapsible tree nodes with smooth transitions
|
|
11
|
+
// - Active state highlighting
|
|
12
|
+
// - Hover effects
|
|
13
|
+
// - Chevron rotation on expand/collapse
|
|
14
|
+
// - Nested indentation
|
|
15
|
+
//
|
|
16
|
+
// ===================================================================
|
|
17
|
+
|
|
18
|
+
// Navigation Tree Container
|
|
19
|
+
.nav-tree {
|
|
20
|
+
font-size: 0.875rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.nav-tree-root {
|
|
24
|
+
margin: 0;
|
|
25
|
+
padding: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Navigation Tree Items
|
|
29
|
+
.nav-tree-item {
|
|
30
|
+
margin-bottom: 0.125rem;
|
|
31
|
+
|
|
32
|
+
// Nested items get additional indentation via ps-3 class
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Navigation Tree Links
|
|
36
|
+
.nav-tree-link {
|
|
37
|
+
color: var(--bs-body-color);
|
|
38
|
+
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out;
|
|
39
|
+
|
|
40
|
+
&:hover {
|
|
41
|
+
color: var(--bs-primary);
|
|
42
|
+
background-color: var(--bs-tertiary-bg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&.active {
|
|
46
|
+
color: var(--bs-primary);
|
|
47
|
+
background-color: var(--bs-primary-bg-subtle);
|
|
48
|
+
font-weight: 500;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Navigation Tree Text (non-link items)
|
|
53
|
+
.nav-tree-text {
|
|
54
|
+
color: var(--bs-secondary-color);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Navigation Tree Toggle Buttons
|
|
58
|
+
.nav-tree-toggle {
|
|
59
|
+
color: var(--bs-body-color);
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
border: none;
|
|
62
|
+
background: transparent;
|
|
63
|
+
transition: color 0.15s ease-in-out;
|
|
64
|
+
|
|
65
|
+
&:hover {
|
|
66
|
+
color: var(--bs-primary);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&:focus {
|
|
70
|
+
outline: none;
|
|
71
|
+
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Chevron rotation
|
|
75
|
+
.bi-chevron-down,
|
|
76
|
+
.nav-tree-chevron {
|
|
77
|
+
transition: transform 0.2s ease-in-out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
&.collapsed {
|
|
81
|
+
.bi-chevron-down,
|
|
82
|
+
.nav-tree-chevron {
|
|
83
|
+
transform: rotate(-90deg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Children container
|
|
89
|
+
.nav-tree-children {
|
|
90
|
+
margin-top: 0.25rem;
|
|
91
|
+
border-left: 1px solid var(--bs-border-color);
|
|
92
|
+
margin-left: 0.5rem;
|
|
93
|
+
padding-left: 0.75rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Collapse animation
|
|
97
|
+
.nav-tree .collapse {
|
|
98
|
+
transition: height 0.2s ease-in-out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Depth-based styling
|
|
102
|
+
.nav-tree-item[data-depth="0"] > .nav-tree-link,
|
|
103
|
+
.nav-tree-item[data-depth="0"] > div > .nav-tree-link,
|
|
104
|
+
.nav-tree-item[data-depth="0"] > .nav-tree-toggle {
|
|
105
|
+
font-weight: 500;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.nav-tree-item[data-depth="1"] > .nav-tree-link,
|
|
109
|
+
.nav-tree-item[data-depth="1"] > div > .nav-tree-link {
|
|
110
|
+
font-weight: normal;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.nav-tree-item[data-depth="2"] > .nav-tree-link,
|
|
114
|
+
.nav-tree-item[data-depth="2"] > div > .nav-tree-link {
|
|
115
|
+
font-size: 0.8125rem;
|
|
116
|
+
color: var(--bs-secondary-color);
|
|
117
|
+
|
|
118
|
+
&.active {
|
|
119
|
+
color: var(--bs-primary);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Keyboard navigation focus state
|
|
124
|
+
.keyboard-nav .nav-tree-link:focus,
|
|
125
|
+
.keyboard-nav .nav-tree-toggle:focus {
|
|
126
|
+
outline: 2px solid var(--bs-primary);
|
|
127
|
+
outline-offset: 2px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Dark mode adjustments
|
|
131
|
+
[data-bs-theme="dark"] {
|
|
132
|
+
.nav-tree-link {
|
|
133
|
+
&:hover {
|
|
134
|
+
background-color: rgba(255, 255, 255, 0.05);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
&.active {
|
|
138
|
+
background-color: rgba(var(--bs-primary-rgb), 0.15);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.nav-tree-children {
|
|
143
|
+
border-left-color: var(--bs-border-color);
|
|
144
|
+
}
|
|
145
|
+
}
|
data/_sass/custom.scss
CHANGED
|
Binary file
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* NAVIGATION CONFIG - Shared Configuration for Navigation Modules
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: config.js
|
|
7
|
+
* Path: assets/js/modules/navigation/config.js
|
|
8
|
+
* Purpose: Centralized configuration for all navigation modules
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { config } from './config.js';
|
|
12
|
+
* const toc = document.querySelector(config.selectors.toc);
|
|
13
|
+
*
|
|
14
|
+
* ===================================================================
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const config = {
|
|
18
|
+
// ===================================================================
|
|
19
|
+
// DOM SELECTORS
|
|
20
|
+
// ===================================================================
|
|
21
|
+
selectors: {
|
|
22
|
+
// Table of Contents
|
|
23
|
+
toc: '#TableOfContents',
|
|
24
|
+
tocLinks: '#TableOfContents a',
|
|
25
|
+
tocContainer: '.bd-toc .offcanvas-body',
|
|
26
|
+
|
|
27
|
+
// Sidebars
|
|
28
|
+
leftSidebar: '#bdSidebar',
|
|
29
|
+
rightSidebar: '#tocContents',
|
|
30
|
+
|
|
31
|
+
// Content areas
|
|
32
|
+
mainContent: '.bd-content',
|
|
33
|
+
|
|
34
|
+
// Navigation elements
|
|
35
|
+
navTree: '.nav-tree',
|
|
36
|
+
navTreeToggle: '[data-nav-toggle]',
|
|
37
|
+
|
|
38
|
+
// Offcanvas
|
|
39
|
+
offcanvas: '.offcanvas'
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// ===================================================================
|
|
43
|
+
// SCROLL SPY SETTINGS
|
|
44
|
+
// ===================================================================
|
|
45
|
+
scrollSpy: {
|
|
46
|
+
// Root margin for Intersection Observer
|
|
47
|
+
// Negative values account for fixed headers
|
|
48
|
+
rootMargin: '-80px 0px -80px 0px',
|
|
49
|
+
|
|
50
|
+
// Intersection threshold levels
|
|
51
|
+
threshold: [0, 0.25, 0.5, 0.75, 1]
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// ===================================================================
|
|
55
|
+
// SMOOTH SCROLL SETTINGS
|
|
56
|
+
// ===================================================================
|
|
57
|
+
smoothScroll: {
|
|
58
|
+
// Offset for fixed headers when scrolling to anchor
|
|
59
|
+
offset: 80,
|
|
60
|
+
|
|
61
|
+
// Scroll behavior
|
|
62
|
+
behavior: 'smooth'
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// ===================================================================
|
|
66
|
+
// KEYBOARD SHORTCUTS
|
|
67
|
+
// ===================================================================
|
|
68
|
+
keyboard: {
|
|
69
|
+
// Enable/disable keyboard shortcuts
|
|
70
|
+
enabled: true,
|
|
71
|
+
|
|
72
|
+
// Key mappings
|
|
73
|
+
keys: {
|
|
74
|
+
previousSection: '[',
|
|
75
|
+
nextSection: ']',
|
|
76
|
+
search: '/',
|
|
77
|
+
toggleSidebar: 'b',
|
|
78
|
+
toggleToc: 't'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// ===================================================================
|
|
83
|
+
// SWIPE GESTURES
|
|
84
|
+
// ===================================================================
|
|
85
|
+
gestures: {
|
|
86
|
+
// Enable/disable swipe gestures
|
|
87
|
+
enabled: true,
|
|
88
|
+
|
|
89
|
+
// Minimum distance (px) for swipe to register
|
|
90
|
+
threshold: 50,
|
|
91
|
+
|
|
92
|
+
// Edge detection zone (px) for sidebar swipes
|
|
93
|
+
edgeZone: 50
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ===================================================================
|
|
97
|
+
// SIDEBAR STATE PERSISTENCE
|
|
98
|
+
// ===================================================================
|
|
99
|
+
state: {
|
|
100
|
+
// localStorage key prefix
|
|
101
|
+
storagePrefix: 'zer0-nav-',
|
|
102
|
+
|
|
103
|
+
// Keys for different state values
|
|
104
|
+
keys: {
|
|
105
|
+
expandedNodes: 'expanded-nodes',
|
|
106
|
+
sidebarOpen: 'sidebar-open',
|
|
107
|
+
tocOpen: 'toc-open'
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// ===================================================================
|
|
112
|
+
// DEBOUNCE/THROTTLE TIMINGS
|
|
113
|
+
// ===================================================================
|
|
114
|
+
timing: {
|
|
115
|
+
debounceDelay: 100,
|
|
116
|
+
scrollDebounce: 50
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// ===================================================================
|
|
120
|
+
// BREAKPOINTS (match Bootstrap 5)
|
|
121
|
+
// ===================================================================
|
|
122
|
+
breakpoints: {
|
|
123
|
+
sm: 576,
|
|
124
|
+
md: 768,
|
|
125
|
+
lg: 992,
|
|
126
|
+
xl: 1200,
|
|
127
|
+
xxl: 1400
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if viewport is below a breakpoint
|
|
133
|
+
* @param {string} breakpoint - Breakpoint name (sm, md, lg, xl, xxl)
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
export function isBelowBreakpoint(breakpoint) {
|
|
137
|
+
return window.innerWidth < config.breakpoints[breakpoint];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if viewport is at or above a breakpoint
|
|
142
|
+
* @param {string} breakpoint - Breakpoint name (sm, md, lg, xl, xxl)
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isAtOrAboveBreakpoint(breakpoint) {
|
|
146
|
+
return window.innerWidth >= config.breakpoints[breakpoint];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default config;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* FOCUS MANAGER - Accessibility Enhancements
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: focus.js
|
|
7
|
+
* Path: assets/js/modules/navigation/focus.js
|
|
8
|
+
* Purpose: Focus management for accessibility in navigation components
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Returns focus to trigger element when offcanvas closes
|
|
12
|
+
* - Manages focus trap in modal/offcanvas contexts
|
|
13
|
+
* - Provides focus ring styling hooks
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { FocusManager } from './focus.js';
|
|
17
|
+
* const focus = new FocusManager();
|
|
18
|
+
*
|
|
19
|
+
* ===================================================================
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { config } from './config.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get all elements safely
|
|
26
|
+
* @param {string} selector - CSS selector
|
|
27
|
+
* @returns {NodeList}
|
|
28
|
+
*/
|
|
29
|
+
function getElements(selector) {
|
|
30
|
+
try {
|
|
31
|
+
return document.querySelectorAll(selector);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class FocusManager {
|
|
38
|
+
constructor() {
|
|
39
|
+
this._boundHandleHidden = this._handleOffcanvasHidden.bind(this);
|
|
40
|
+
this._init();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize focus management
|
|
45
|
+
* @private
|
|
46
|
+
*/
|
|
47
|
+
_init() {
|
|
48
|
+
// Handle focus return when offcanvas closes
|
|
49
|
+
const offcanvasElements = getElements(config.selectors.offcanvas);
|
|
50
|
+
|
|
51
|
+
offcanvasElements.forEach(offcanvas => {
|
|
52
|
+
offcanvas.addEventListener('hidden.bs.offcanvas', this._boundHandleHidden);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Add focus-visible polyfill behavior
|
|
56
|
+
this._setupFocusVisible();
|
|
57
|
+
|
|
58
|
+
console.log(`FocusManager: Initialized with ${offcanvasElements.length} offcanvas elements`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Handle offcanvas hidden event - return focus to trigger
|
|
63
|
+
* @private
|
|
64
|
+
* @param {Event} event
|
|
65
|
+
*/
|
|
66
|
+
_handleOffcanvasHidden(event) {
|
|
67
|
+
const offcanvas = event.target;
|
|
68
|
+
this.returnFocus(offcanvas);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return focus to the trigger element that opened an offcanvas
|
|
73
|
+
* @param {Element} offcanvas - The offcanvas element
|
|
74
|
+
*/
|
|
75
|
+
returnFocus(offcanvas) {
|
|
76
|
+
const triggerId = offcanvas.id;
|
|
77
|
+
|
|
78
|
+
// Find the trigger button that opened this offcanvas
|
|
79
|
+
const trigger = document.querySelector(
|
|
80
|
+
`[data-bs-target="#${triggerId}"], [href="#${triggerId}"]`
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (trigger) {
|
|
84
|
+
// Small delay to ensure offcanvas animation completes
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
trigger.focus();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Setup focus-visible behavior for keyboard users
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
_setupFocusVisible() {
|
|
96
|
+
// Add class to body when user is navigating with keyboard
|
|
97
|
+
let hadKeyboardEvent = false;
|
|
98
|
+
|
|
99
|
+
document.addEventListener('keydown', (e) => {
|
|
100
|
+
if (e.key === 'Tab') {
|
|
101
|
+
hadKeyboardEvent = true;
|
|
102
|
+
document.body.classList.add('keyboard-nav');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
document.addEventListener('mousedown', () => {
|
|
107
|
+
hadKeyboardEvent = false;
|
|
108
|
+
document.body.classList.remove('keyboard-nav');
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Focus the first focusable element within a container
|
|
114
|
+
* @param {Element} container
|
|
115
|
+
*/
|
|
116
|
+
focusFirst(container) {
|
|
117
|
+
const focusable = container.querySelector(
|
|
118
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
119
|
+
);
|
|
120
|
+
if (focusable) {
|
|
121
|
+
focusable.focus();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Focus the last focusable element within a container
|
|
127
|
+
* @param {Element} container
|
|
128
|
+
*/
|
|
129
|
+
focusLast(container) {
|
|
130
|
+
const focusables = container.querySelectorAll(
|
|
131
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
132
|
+
);
|
|
133
|
+
if (focusables.length > 0) {
|
|
134
|
+
focusables[focusables.length - 1].focus();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Trap focus within a container (for modals/offcanvas)
|
|
140
|
+
* @param {Element} container
|
|
141
|
+
* @returns {Function} Cleanup function to remove trap
|
|
142
|
+
*/
|
|
143
|
+
trapFocus(container) {
|
|
144
|
+
const focusableElements = container.querySelectorAll(
|
|
145
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const firstFocusable = focusableElements[0];
|
|
149
|
+
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
150
|
+
|
|
151
|
+
const handleKeydown = (e) => {
|
|
152
|
+
if (e.key !== 'Tab') return;
|
|
153
|
+
|
|
154
|
+
if (e.shiftKey) {
|
|
155
|
+
// Shift + Tab
|
|
156
|
+
if (document.activeElement === firstFocusable) {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
lastFocusable.focus();
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// Tab
|
|
162
|
+
if (document.activeElement === lastFocusable) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
firstFocusable.focus();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
container.addEventListener('keydown', handleKeydown);
|
|
170
|
+
|
|
171
|
+
// Return cleanup function
|
|
172
|
+
return () => {
|
|
173
|
+
container.removeEventListener('keydown', handleKeydown);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Cleanup event listeners
|
|
179
|
+
*/
|
|
180
|
+
destroy() {
|
|
181
|
+
const offcanvasElements = getElements(config.selectors.offcanvas);
|
|
182
|
+
offcanvasElements.forEach(offcanvas => {
|
|
183
|
+
offcanvas.removeEventListener('hidden.bs.offcanvas', this._boundHandleHidden);
|
|
184
|
+
});
|
|
185
|
+
console.log('FocusManager: Destroyed');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default FocusManager;
|