sphinx-filter-tabs 0.8.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,261 @@
1
+ # filter_tabs/renderer.py
2
+ """
3
+ Renders the HTML and fallback output for the filter-tabs directive.
4
+ Consolidated version including parsing utilities and content type inference.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ from docutils import nodes
11
+ from sphinx.util import logging
12
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
13
+
14
+ # Import models from extension.py (after consolidation)
15
+ from .extension import TabData, FilterTabsConfig, IDGenerator
16
+ from .extension import ContainerNode, FieldsetNode, LegendNode, RadioInputNode, LabelNode, PanelNode
17
+
18
+ if TYPE_CHECKING:
19
+ from sphinx.environment import BuildEnvironment
20
+ from docutils.parsers.rst import Directive
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Constants
25
+ SFT_CONTAINER = "sft-container"
26
+ SFT_FIELDSET = "sft-fieldset"
27
+ SFT_LEGEND = "sft-legend"
28
+ SFT_RADIO_GROUP = "sft-radio-group"
29
+ SFT_CONTENT = "sft-content"
30
+ SFT_PANEL = "sft-panel"
31
+
32
+
33
+ # =============================================================================
34
+ # Content Type Inference Utilities (moved from parsers.py)
35
+ # =============================================================================
36
+
37
+ class ContentTypeInferrer:
38
+ """
39
+ Infers the type of content based on tab names to generate meaningful legends.
40
+ """
41
+ PATTERNS = [
42
+ (['python', 'javascript', 'java', 'c++', 'rust', 'go', 'ruby', 'php'], 'programming language'),
43
+ (['windows', 'mac', 'macos', 'linux', 'ubuntu', 'debian', 'fedora'], 'operating system'),
44
+ (['pip', 'conda', 'npm', 'yarn', 'cargo', 'gem', 'composer'], 'package manager'),
45
+ (['cli', 'gui', 'terminal', 'command', 'console', 'graphical'], 'interface'),
46
+ (['development', 'staging', 'production', 'test', 'local'], 'environment'),
47
+ (['source', 'binary', 'docker', 'manual', 'automatic'], 'installation method'),
48
+ ]
49
+
50
+ @classmethod
51
+ def infer_type(cls, tab_names: List[str]) -> str:
52
+ """
53
+ Infer content type from a list of tab names.
54
+
55
+ Args:
56
+ tab_names: List of tab names to analyze
57
+
58
+ Returns:
59
+ Inferred content type string (e.g., 'programming language', 'operating system')
60
+ """
61
+ lower_names = [name.lower() for name in tab_names]
62
+
63
+ # First pass: exact matches
64
+ for keywords, content_type in cls.PATTERNS:
65
+ if any(name in keywords for name in lower_names):
66
+ return content_type
67
+
68
+ # Second pass: substring matches
69
+ for keywords, content_type in cls.PATTERNS:
70
+ for name in lower_names:
71
+ if any(keyword in name for keyword in keywords):
72
+ return content_type
73
+
74
+ # Default fallback
75
+ return 'option'
76
+
77
+
78
+ # =============================================================================
79
+ # Main Renderer Class
80
+ # =============================================================================
81
+
82
+ class FilterTabsRenderer:
83
+ """
84
+ Renders filter tabs with a focus on accessibility and browser compatibility.
85
+ Consolidated version with integrated content type inference.
86
+ """
87
+
88
+ def __init__(self, directive: Directive, tab_data: List[TabData], general_content: List[nodes.Node], custom_legend: Optional[str] = None):
89
+ self.directive = directive
90
+ self.env: BuildEnvironment = directive.state.document.settings.env
91
+ self.app = self.env.app
92
+ self.tab_data = tab_data
93
+ self.general_content = general_content
94
+ self.custom_legend = custom_legend
95
+
96
+ # 1. Load configuration first
97
+ self.config = FilterTabsConfig.from_sphinx_config(self.app.config)
98
+
99
+ # 2. Safely initialize the counter on the environment if it doesn't exist
100
+ if not hasattr(self.env, 'filter_tabs_counter'):
101
+ self.env.filter_tabs_counter = 0
102
+
103
+ # 3. Increment the counter for this new tab group
104
+ self.env.filter_tabs_counter += 1
105
+
106
+ # 4. Generate the unique group ID and the ID generator instance
107
+ self.group_id = f"filter-group-{self.env.filter_tabs_counter}"
108
+ self.id_gen = IDGenerator(self.group_id)
109
+
110
+ # 5. Perform debug logging now that config and group_id are set
111
+ if self.config.debug_mode:
112
+ logger.info(f"Initialized new tab group with id: '{self.group_id}'")
113
+
114
+ def render_html(self) -> List[nodes.Node]:
115
+ """Render HTML with a browser-compatible CSS approach."""
116
+ if self.config.debug_mode:
117
+ logger.info(f"Rendering filter-tabs group {self.group_id}")
118
+
119
+ css_node = self._generate_compatible_css()
120
+
121
+ container_attrs = self._get_container_attributes()
122
+ container = ContainerNode(**container_attrs)
123
+
124
+ fieldset = self._create_fieldset()
125
+ container.children = [fieldset]
126
+
127
+ return [css_node, container]
128
+
129
+ def render_fallback(self) -> List[nodes.Node]:
130
+ """Render for non-HTML builders (e.g., LaTeX)."""
131
+ output_nodes: List[nodes.Node] = []
132
+
133
+ if self.general_content:
134
+ output_nodes.extend(copy.deepcopy(self.general_content))
135
+
136
+ for tab in self.tab_data:
137
+ admonition = nodes.admonition()
138
+ admonition += nodes.title(text=tab.name)
139
+ admonition.extend(copy.deepcopy(tab.content))
140
+ output_nodes.append(admonition)
141
+
142
+ return output_nodes
143
+
144
+ def _get_container_attributes(self) -> Dict[str, Any]:
145
+ """Get container attributes, including the style for custom properties."""
146
+ return {
147
+ 'classes': [SFT_CONTAINER],
148
+ 'role': 'region',
149
+ 'aria-labelledby': self.id_gen.legend_id(),
150
+ 'style': self.config.to_css_properties()
151
+ }
152
+
153
+ def _generate_compatible_css(self) -> nodes.raw:
154
+ """Generate CSS using sibling selectors to show/hide panels."""
155
+ css_rules = []
156
+ for i, tab in enumerate(self.tab_data):
157
+ radio_id = self.id_gen.radio_id(i)
158
+ panel_id = self.id_gen.panel_id(i)
159
+ css_rules.append(
160
+ f"#{radio_id}:checked ~ .{SFT_CONTENT} #{panel_id} {{ display: block; }}"
161
+ )
162
+
163
+ css_content = "\n".join(css_rules)
164
+ return nodes.raw(text=f"<style>\n{css_content}\n</style>", format='html')
165
+
166
+ def _create_fieldset(self) -> FieldsetNode:
167
+ """Create the main fieldset containing the legend, radio buttons, and panels."""
168
+ fieldset = FieldsetNode(role="radiogroup")
169
+
170
+ fieldset += self._create_legend()
171
+
172
+ radio_group = ContainerNode(classes=[SFT_RADIO_GROUP])
173
+ self._populate_radio_group(radio_group)
174
+
175
+ content_area = ContainerNode(classes=[SFT_CONTENT])
176
+ self._populate_content_area(content_area)
177
+
178
+ # This is the fix: place the content_area inside the radio_group.
179
+ radio_group += content_area
180
+
181
+ fieldset += radio_group
182
+
183
+ return fieldset
184
+
185
+ def _create_legend(self) -> LegendNode:
186
+ """Create a meaningful, visible legend for the tab group."""
187
+ legend = LegendNode(classes=[SFT_LEGEND], ids=[self.id_gen.legend_id()])
188
+
189
+ # Use the custom legend if it exists
190
+ if self.custom_legend:
191
+ legend_text = self.custom_legend
192
+ else:
193
+ # Fallback to the auto-generated legend using content type inference
194
+ tab_names = [tab.name for tab in self.tab_data]
195
+ content_type = ContentTypeInferrer.infer_type(tab_names)
196
+ legend_text = f"Choose {content_type}: {', '.join(tab_names)}"
197
+
198
+ legend += nodes.Text(legend_text)
199
+ return legend
200
+
201
+ def _populate_radio_group(self, radio_group: ContainerNode) -> None:
202
+ """Create and add all radio buttons and labels to the radio group container."""
203
+ default_index = next((i for i, tab in enumerate(self.tab_data) if tab.is_default), 0)
204
+
205
+ for i, tab in enumerate(self.tab_data):
206
+ radio_group += self._create_radio_button(i, tab, is_checked=(i == default_index))
207
+ radio_group += self._create_label(i, tab)
208
+ radio_group += self._create_screen_reader_description(i, tab)
209
+
210
+ def _create_radio_button(self, index: int, tab: TabData, is_checked: bool) -> RadioInputNode:
211
+ """Create a single radio button input."""
212
+ radio = RadioInputNode(
213
+ classes=['sr-only'],
214
+ type='radio',
215
+ name=self.group_id,
216
+ ids=[self.id_gen.radio_id(index)],
217
+ **{'aria-describedby': self.id_gen.desc_id(index)}
218
+ )
219
+ if tab.aria_label:
220
+ radio['aria-label'] = tab.aria_label
221
+ if is_checked:
222
+ radio['checked'] = 'checked'
223
+ return radio
224
+
225
+ def _create_label(self, index: int, tab: TabData) -> LabelNode:
226
+ """Create a label for a radio button."""
227
+ label = LabelNode(for_id=self.id_gen.radio_id(index))
228
+ label += nodes.Text(tab.name)
229
+ return label
230
+
231
+ def _create_screen_reader_description(self, index: int, tab: TabData) -> ContainerNode:
232
+ """Create the hidden description for screen readers."""
233
+ desc_text = f"Show content for {tab.name}"
234
+ description_node = ContainerNode(classes=['sr-only'], ids=[self.id_gen.desc_id(index)])
235
+ description_node += nodes.Text(desc_text)
236
+ return description_node
237
+
238
+ def _populate_content_area(self, content_area: ContainerNode) -> None:
239
+ """Create and add all general and tab-specific content panels."""
240
+ if self.general_content:
241
+ general_panel = PanelNode(classes=[SFT_PANEL], **{'data-filter': 'General'})
242
+ general_panel.extend(copy.deepcopy(self.general_content))
243
+ content_area += general_panel
244
+
245
+ for i, tab in enumerate(self.tab_data):
246
+ content_area += self._create_tab_panel(i, tab)
247
+
248
+ def _create_tab_panel(self, index: int, tab: TabData) -> PanelNode:
249
+ """Create a single content panel for a tab."""
250
+ panel_attrs = {
251
+ 'classes': [SFT_PANEL],
252
+ 'ids': [self.id_gen.panel_id(index)],
253
+ 'role': 'region',
254
+ 'aria-labelledby': self.id_gen.radio_id(index),
255
+ 'tabindex': '0',
256
+ 'data-tab': tab.name.lower().replace(' ', '-')
257
+ }
258
+ panel = PanelNode(**panel_attrs)
259
+ panel.extend(copy.deepcopy(tab.content))
260
+ return panel
261
+
@@ -1,92 +1,136 @@
1
- /* Sphinx Filter Tabs -- Static Stylesheet */
2
- /* This file provides the structural and base theme styles for the component. */
1
+ /* Sphinx Filter Tabs - Simplified Accessibility-First Stylesheet */
3
2
 
4
- /* Main container for the tab component */
3
+ /* Main container */
5
4
  .sft-container {
6
5
  border: 1px solid #e0e0e0;
7
- /* THEME: The border-radius is controlled by a CSS variable from conf.py. */
8
6
  border-radius: var(--sft-border-radius, 8px);
9
7
  overflow: hidden;
10
8
  margin: 1.5em 0;
9
+ background: white;
11
10
  }
12
11
 
13
- /* The <fieldset> provides semantic grouping for accessibility. */
14
- .sft-fieldset { border: none; padding: 0; margin: 0; }
12
+ /* Semantic structure */
13
+ .sft-fieldset {
14
+ border: none;
15
+ padding: 0;
16
+ margin: 0;
17
+ }
15
18
 
16
- /* The <legend> is visually hidden but essential for screen reader users. */
17
- .sft-legend { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
19
+ /* Visible, meaningful legend */
20
+ .sft-legend {
21
+ background: #f8f9fa;
22
+ padding: 12px 20px;
23
+ margin: 0;
24
+ font-weight: 600;
25
+ color: #495057;
26
+ border-bottom: 1px solid #e0e0e0;
27
+ width: 100%;
28
+ font-size: 0.95em;
29
+ }
18
30
 
19
- /* The <input type="radio"> buttons are the functional core.
20
- They are hidden accessibly, not with display:none, so they remain
21
- focusable for keyboard users. */
22
- .sft-tab-bar > input[type="radio"] {
23
- position: absolute;
31
+ /* Radio group container */
32
+ .sft-radio-group {
33
+ display: flex;
34
+ flex-wrap: wrap;
35
+ background: #f8f9fa;
36
+ border-bottom: 1px solid #e0e0e0;
37
+ padding: 0 10px;
38
+ }
39
+
40
+ /* Hidden radio buttons (accessible to screen readers) */
41
+ .sft-radio-group input[type="radio"] {
24
42
  width: 1px;
25
43
  height: 1px;
26
- padding: 0;
27
- margin: -1px;
28
- overflow: hidden;
29
- clip: rect(0, 0, 0, 0);
30
- white-space: nowrap;
31
- border: 0;
44
+ opacity: 0.01;
45
+ position: absolute;
46
+ z-index: -1;
47
+ margin: 0;
32
48
  }
33
49
 
34
- /* The tab bar containing the clickable labels. */
35
- .sft-tab-bar {
36
- display: flex;
37
- flex-wrap: wrap;
38
- /* THEME: The background color is controlled by a CSS variable. */
39
- background-color: var(--sft-tab-background, #f0f0f0);
40
- border-bottom: 1px solid #e0e0e0;
41
- padding: 0 10px;
42
- }
43
- .sft-tab-bar > label {
44
- padding: 12px 18px;
45
- cursor: pointer;
46
- transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out;
47
- border-bottom: 3px solid transparent;
48
- color: #555;
49
- font-weight: 500;
50
- /* THEME: The font size is controlled by a CSS variable. */
51
- font-size: var(--sft-font-size, 1em);
52
- line-height: 1.5;
53
- }
54
-
55
- /* THEME: The active tab label is highlighted using a CSS variable for the color. */
56
- .sft-tab-bar > input[type="radio"]:checked + label {
50
+ /* Tab labels */
51
+ .sft-radio-group label {
52
+ display: block;
53
+ padding: 12px 18px;
54
+ cursor: pointer;
55
+ transition: all 0.2s ease;
56
+ border-bottom: 3px solid transparent;
57
+ color: #6c757d;
58
+ font-weight: 500;
59
+ font-size: var(--sft-tab-font-size, 1em);
60
+ position: relative;
61
+ background: transparent;
62
+ }
63
+
64
+ /* Active tab styling */
65
+ .sft-radio-group input[type="radio"]:checked + label {
66
+ color: var(--sft-tab-highlight-color, #007bff);
57
67
  border-bottom-color: var(--sft-tab-highlight-color, #007bff);
58
- color: #000;
68
+ background: white;
69
+ font-weight: 600;
59
70
  }
60
71
 
61
- /* Add a clear, visible focus ring for keyboard navigation. */
62
- .sft-tab-bar > input[type="radio"]:focus + label {
63
- box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6);
64
- border-radius: 5px;
65
- outline: none;
72
+ /* Focus styling for keyboard navigation */
73
+ .sft-radio-group input[type="radio"]:focus + label {
74
+ outline: 2px solid var(--sft-tab-highlight-color, #007bff);
75
+ outline-offset: 2px;
76
+ box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.25);
77
+ z-index: 1;
66
78
  }
67
79
 
68
- /* The container for all content panels. */
69
- .sft-content { padding: 20px; }
80
+ /* Hover effects */
81
+ .sft-radio-group label:hover {
82
+ color: #495057;
83
+ background: rgba(0, 123, 255, 0.05);
84
+ }
70
85
 
71
- /* By default, hide all tab-specific panels. The dynamic CSS will show the active one. */
72
- .sft-content > .sft-panel {
86
+ /* Content area */
87
+ .sft-content {
88
+ padding: 20px;
89
+ flex-basis: 100%; /* Add this line */
90
+ }
91
+
92
+ /* Panels - hidden by default */
93
+ .sft-panel {
73
94
  display: none;
95
+ outline: none;
96
+ }
97
+
98
+ /* Focus styling for panels */
99
+ .sft-panel:focus {
100
+ outline: 2px solid var(--sft-tab-highlight-color, #007bff);
101
+ outline-offset: -2px;
102
+ border-radius: 4px;
74
103
  }
75
104
 
76
- /* Always show the "General" panel, as it has no radio button to control it. */
77
- .sft-content > .sft-panel[data-filter="General"] {
105
+ /* General content panel - always visible */
106
+ .sft-panel[data-filter="General"] {
78
107
  display: block;
108
+ margin-bottom: 20px;
109
+ padding-bottom: 20px;
110
+ border-bottom: 1px solid #eee;
79
111
  }
80
112
 
113
+ /* Screen reader only content */
114
+ .sr-only {
115
+ position: absolute;
116
+ width: 1px;
117
+ height: 1px;
118
+ padding: 0;
119
+ margin: -1px;
120
+ overflow: hidden;
121
+ clip: rect(0, 0, 0, 0);
122
+ white-space: nowrap;
123
+ border: 0;
124
+ }
81
125
 
82
- /* Styles for collapsible sections */
126
+ /* Collapsible sections */
83
127
  .collapsible-section {
84
128
  border: 1px solid #e0e0e0;
85
- /* THEME: The accent color is controlled by a CSS variable. */
86
129
  border-left: 4px solid var(--sft-collapsible-accent-color, #17a2b8);
87
130
  border-radius: 4px;
88
131
  margin-top: 1em;
89
132
  }
133
+
90
134
  .collapsible-section summary {
91
135
  display: block;
92
136
  cursor: pointer;
@@ -94,14 +138,47 @@
94
138
  font-weight: bold;
95
139
  background-color: #f9f9f9;
96
140
  outline: none;
141
+ list-style: none;
97
142
  }
143
+
98
144
  .collapsible-section summary::-webkit-details-marker {
99
145
  display: none;
100
146
  }
101
- .collapsible-section summary {
102
- list-style: none;
147
+
148
+ .collapsible-section[open] > summary {
149
+ border-bottom: 1px solid #e0e0e0;
150
+ }
151
+
152
+ .custom-arrow {
153
+ display: inline-block;
154
+ width: 1em;
155
+ margin-right: 8px;
156
+ transition: transform 0.2s;
157
+ }
158
+
159
+ .collapsible-section[open] > summary .custom-arrow {
160
+ transform: rotate(90deg);
161
+ }
162
+
163
+ .collapsible-content {
164
+ padding: 15px;
165
+ }
166
+
167
+ /* High contrast mode support */
168
+ @media (prefers-contrast: high) {
169
+ .sft-radio-group input[type="radio"]:checked + label {
170
+ border-bottom-width: 4px;
171
+ }
172
+
173
+ .sft-radio-group input[type="radio"]:focus + label {
174
+ box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.8);
175
+ }
176
+ }
177
+
178
+ /* Reduced motion preferences */
179
+ @media (prefers-reduced-motion: reduce) {
180
+ .sft-radio-group label,
181
+ .custom-arrow {
182
+ transition: none;
183
+ }
103
184
  }
104
- .collapsible-section[open] > summary { border-bottom: 1px solid #e0e0e0; }
105
- .custom-arrow { display: inline-block; width: 1em; margin-right: 8px; transition: transform 0.2s; }
106
- .collapsible-section[open] > summary .custom-arrow { transform: rotate(90deg); }
107
- .collapsible-content { padding: 15px; }
@@ -0,0 +1,160 @@
1
+ // Progressive enhancement for keyboard navigation and accessibility.
2
+ // This file ensures proper keyboard navigation and focus management,
3
+ // while maintaining a CSS-only fallback.
4
+
5
+ (function() {
6
+ 'use strict';
7
+
8
+ // Only enhance if the extension's HTML is present on the page.
9
+ if (!document.querySelector('.sft-container')) return;
10
+
11
+ /**
12
+ * Moves focus to the content panel associated with a given radio button.
13
+ * This improves accessibility by directing screen reader users to the new content.
14
+ * @param {HTMLInputElement} radio The radio button that was selected.
15
+ */
16
+ function focusOnPanel(radio) {
17
+ if (!radio.checked) return;
18
+
19
+ // Derive the panel's ID from the radio button's ID.
20
+ // e.g., 'filter-group-1-radio-0' becomes 'filter-group-1-panel-0'
21
+ const panelId = radio.id.replace('-radio-', '-panel-');
22
+ const panel = document.getElementById(panelId);
23
+
24
+ if (panel) {
25
+ panel.focus();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Creates or updates a live region to announce tab changes to screen readers.
31
+ * @param {string} tabName The name of the selected tab.
32
+ */
33
+ function announceTabChange(tabName) {
34
+ // Create or find the live region for screen reader announcements.
35
+ let liveRegion = document.getElementById('tab-live-region');
36
+ if (!liveRegion) {
37
+ liveRegion = document.createElement('div');
38
+ liveRegion.id = 'tab-live-region';
39
+ liveRegion.setAttribute('role', 'status');
40
+ liveRegion.setAttribute('aria-live', 'polite');
41
+ liveRegion.setAttribute('aria-atomic', 'true');
42
+ // Hide the element visually but keep it accessible.
43
+ liveRegion.style.position = 'absolute';
44
+ liveRegion.style.left = '-10000px';
45
+ liveRegion.style.width = '1px';
46
+ liveRegion.style.height = '1px';
47
+ liveRegion.style.overflow = 'hidden';
48
+ document.body.appendChild(liveRegion);
49
+ }
50
+
51
+ // Update the announcement text.
52
+ liveRegion.textContent = `${tabName} tab selected`;
53
+
54
+ // Clear the announcement after a short delay to prevent clutter.
55
+ setTimeout(() => {
56
+ liveRegion.textContent = '';
57
+ }, 1000);
58
+ }
59
+
60
+ /**
61
+ * Initializes keyboard navigation for all filter-tab components on the page.
62
+ */
63
+ function initTabKeyboardNavigation() {
64
+ const containers = document.querySelectorAll('.sft-container');
65
+
66
+ containers.forEach(container => {
67
+ const tabBar = container.querySelector('.sft-radio-group');
68
+ if (!tabBar) return;
69
+
70
+ const radios = tabBar.querySelectorAll('input[type="radio"]');
71
+ const labels = tabBar.querySelectorAll('label');
72
+
73
+ if (radios.length === 0 || labels.length === 0) return;
74
+
75
+ // Make labels focusable to act as keyboard navigation targets.
76
+ labels.forEach(label => {
77
+ if (!label.hasAttribute('tabindex')) {
78
+ label.setAttribute('tabindex', '0');
79
+ }
80
+ });
81
+
82
+ // Handle keyboard navigation on the tab labels.
83
+ labels.forEach((label, index) => {
84
+ label.addEventListener('keydown', (event) => {
85
+ let targetIndex = index;
86
+ let handled = false;
87
+
88
+ switch (event.key) {
89
+ case 'ArrowRight':
90
+ event.preventDefault();
91
+ targetIndex = (index + 1) % labels.length;
92
+ handled = true;
93
+ break;
94
+
95
+ case 'ArrowLeft':
96
+ event.preventDefault();
97
+ targetIndex = (index - 1 + labels.length) % labels.length;
98
+ handled = true;
99
+ break;
100
+
101
+ case 'Home':
102
+ event.preventDefault();
103
+ targetIndex = 0;
104
+ handled = true;
105
+ break;
106
+
107
+ case 'End':
108
+ event.preventDefault();
109
+ targetIndex = labels.length - 1;
110
+ handled = true;
111
+ break;
112
+
113
+ case 'Enter':
114
+ case ' ':
115
+ // Activate the associated radio button on Enter/Space.
116
+ event.preventDefault();
117
+ if (radios[index]) {
118
+ radios[index].checked = true;
119
+ radios[index].dispatchEvent(new Event('change', { bubbles: true }));
120
+ }
121
+ return;
122
+
123
+ default:
124
+ return;
125
+ }
126
+
127
+ if (handled) {
128
+ // Move focus to the target label and activate its radio button.
129
+ labels[targetIndex].focus();
130
+ if (radios[targetIndex]) {
131
+ radios[targetIndex].checked = true;
132
+ radios[targetIndex].dispatchEvent(new Event('change', { bubbles: true }));
133
+ }
134
+ }
135
+ });
136
+ });
137
+
138
+ // Add listeners for announcements and focus management.
139
+ radios.forEach((radio, index) => {
140
+ radio.addEventListener('change', () => {
141
+ if (radio.checked) {
142
+ // Announce the change to screen readers.
143
+ if (labels[index]) {
144
+ announceTabChange(labels[index].textContent.trim());
145
+ }
146
+ // Move focus to the newly visible panel.
147
+ focusOnPanel(radio);
148
+ }
149
+ });
150
+ });
151
+ });
152
+ }
153
+
154
+ // Initialize the script once the DOM is ready.
155
+ if (document.readyState === 'loading') {
156
+ document.addEventListener('DOMContentLoaded', initTabKeyboardNavigation);
157
+ } else {
158
+ initTabKeyboardNavigation();
159
+ }
160
+ })();