sphinx-filter-tabs 0.9.2__py3-none-any.whl → 1.2.2__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.
- filter_tabs/extension.py +308 -298
- filter_tabs/renderer.py +261 -0
- filter_tabs/static/filter_tabs.css +96 -79
- filter_tabs/static/filter_tabs.js +73 -43
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/METADATA +11 -6
- sphinx_filter_tabs-1.2.2.dist-info/RECORD +11 -0
- sphinx_filter_tabs-0.9.2.dist-info/RECORD +0 -10
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/top_level.txt +0 -0
filter_tabs/renderer.py
ADDED
|
@@ -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,112 +1,129 @@
|
|
|
1
|
-
/* Sphinx Filter Tabs
|
|
2
|
-
/* This file provides both structural and accessibility styles */
|
|
1
|
+
/* Sphinx Filter Tabs - Simplified Accessibility-First Stylesheet */
|
|
3
2
|
|
|
4
|
-
/* Main container
|
|
3
|
+
/* Main container */
|
|
5
4
|
.sft-container {
|
|
6
5
|
border: 1px solid #e0e0e0;
|
|
7
6
|
border-radius: var(--sft-border-radius, 8px);
|
|
8
7
|
overflow: hidden;
|
|
9
8
|
margin: 1.5em 0;
|
|
9
|
+
background: white;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/*
|
|
13
|
-
.sft-fieldset {
|
|
14
|
-
border: none;
|
|
15
|
-
padding: 0;
|
|
16
|
-
margin: 0;
|
|
12
|
+
/* Semantic structure */
|
|
13
|
+
.sft-fieldset {
|
|
14
|
+
border: none;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: 0;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/*
|
|
20
|
-
.sft-legend {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
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;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/*
|
|
33
|
-
.sft-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
overflow: hidden;
|
|
40
|
-
clip: rect(0, 0, 0, 0);
|
|
41
|
-
white-space: nowrap;
|
|
42
|
-
border: 0;
|
|
43
|
-
opacity: 0;
|
|
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;
|
|
44
38
|
}
|
|
45
39
|
|
|
46
|
-
/*
|
|
47
|
-
.sft-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
/* Hidden radio buttons (accessible to screen readers) */
|
|
41
|
+
.sft-radio-group input[type="radio"] {
|
|
42
|
+
width: 1px;
|
|
43
|
+
height: 1px;
|
|
44
|
+
opacity: 0.01;
|
|
45
|
+
position: absolute;
|
|
46
|
+
z-index: -1;
|
|
47
|
+
margin: 0;
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
64
62
|
}
|
|
65
63
|
|
|
66
|
-
/*
|
|
67
|
-
.sft-
|
|
68
|
-
|
|
69
|
-
color: #
|
|
64
|
+
/* Active tab styling */
|
|
65
|
+
.sft-radio-group input[type="radio"]:checked + label {
|
|
66
|
+
color: var(--sft-highlight-color, #007bff);
|
|
67
|
+
border-bottom-color: var(--sft-highlight-color, #007bff);
|
|
68
|
+
background: white;
|
|
70
69
|
font-weight: 600;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
/*
|
|
74
|
-
.sft-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
/* Focus styling for keyboard navigation */
|
|
73
|
+
.sft-radio-group input[type="radio"]:focus + label {
|
|
74
|
+
outline: 2px solid var(--sft-highlight-color, #007bff);
|
|
75
|
+
outline-offset: 2px;
|
|
76
|
+
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.25);
|
|
77
|
+
z-index: 1;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/*
|
|
80
|
-
.sft-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
outline: none;
|
|
84
|
-
z-index: 1;
|
|
85
|
-
position: relative;
|
|
80
|
+
/* Hover effects */
|
|
81
|
+
.sft-radio-group label:hover {
|
|
82
|
+
color: #495057;
|
|
83
|
+
background: rgba(0, 123, 255, 0.05);
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
/*
|
|
89
|
-
.sft-content {
|
|
90
|
-
padding: 20px;
|
|
86
|
+
/* Content area */
|
|
87
|
+
.sft-content {
|
|
88
|
+
padding: 20px;
|
|
89
|
+
flex-basis: 100%; /* Add this line */
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
/*
|
|
94
|
-
.sft-
|
|
92
|
+
/* Panels - hidden by default */
|
|
93
|
+
.sft-panel {
|
|
95
94
|
display: none;
|
|
95
|
+
outline: none;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/*
|
|
99
|
-
.sft-
|
|
98
|
+
/* Focus styling for panels */
|
|
99
|
+
.sft-panel:focus {
|
|
100
|
+
outline: 2px solid var(--sft-highlight-color, #007bff);
|
|
101
|
+
outline-offset: -2px;
|
|
102
|
+
border-radius: 4px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* General content panel - always visible */
|
|
106
|
+
.sft-panel[data-filter="General"] {
|
|
100
107
|
display: block;
|
|
108
|
+
margin-bottom: 20px;
|
|
109
|
+
padding-bottom: 20px;
|
|
110
|
+
border-bottom: 1px solid #eee;
|
|
101
111
|
}
|
|
102
112
|
|
|
103
|
-
/*
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
/*
|
|
126
|
+
/* Collapsible sections */
|
|
110
127
|
.collapsible-section {
|
|
111
128
|
border: 1px solid #e0e0e0;
|
|
112
129
|
border-left: 4px solid var(--sft-collapsible-accent-color, #17a2b8);
|
|
@@ -147,20 +164,20 @@
|
|
|
147
164
|
padding: 15px;
|
|
148
165
|
}
|
|
149
166
|
|
|
150
|
-
/*
|
|
167
|
+
/* High contrast mode support */
|
|
151
168
|
@media (prefers-contrast: high) {
|
|
152
|
-
.sft-
|
|
169
|
+
.sft-radio-group input[type="radio"]:checked + label {
|
|
153
170
|
border-bottom-width: 4px;
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
.sft-
|
|
173
|
+
.sft-radio-group input[type="radio"]:focus + label {
|
|
157
174
|
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.8);
|
|
158
175
|
}
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
/*
|
|
178
|
+
/* Reduced motion preferences */
|
|
162
179
|
@media (prefers-reduced-motion: reduce) {
|
|
163
|
-
.sft-
|
|
180
|
+
.sft-radio-group label,
|
|
164
181
|
.custom-arrow {
|
|
165
182
|
transition: none;
|
|
166
183
|
}
|
|
@@ -1,17 +1,70 @@
|
|
|
1
|
-
// Progressive enhancement for keyboard navigation
|
|
2
|
-
// This file ensures proper keyboard navigation
|
|
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.
|
|
3
4
|
|
|
4
5
|
(function() {
|
|
5
6
|
'use strict';
|
|
6
7
|
|
|
7
|
-
// Only enhance if the extension is present
|
|
8
|
+
// Only enhance if the extension's HTML is present on the page.
|
|
8
9
|
if (!document.querySelector('.sft-container')) return;
|
|
9
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
|
+
*/
|
|
10
63
|
function initTabKeyboardNavigation() {
|
|
11
64
|
const containers = document.querySelectorAll('.sft-container');
|
|
12
65
|
|
|
13
66
|
containers.forEach(container => {
|
|
14
|
-
const tabBar = container.querySelector('.sft-
|
|
67
|
+
const tabBar = container.querySelector('.sft-radio-group');
|
|
15
68
|
if (!tabBar) return;
|
|
16
69
|
|
|
17
70
|
const radios = tabBar.querySelectorAll('input[type="radio"]');
|
|
@@ -19,14 +72,14 @@
|
|
|
19
72
|
|
|
20
73
|
if (radios.length === 0 || labels.length === 0) return;
|
|
21
74
|
|
|
22
|
-
// Make labels focusable
|
|
75
|
+
// Make labels focusable to act as keyboard navigation targets.
|
|
23
76
|
labels.forEach(label => {
|
|
24
77
|
if (!label.hasAttribute('tabindex')) {
|
|
25
78
|
label.setAttribute('tabindex', '0');
|
|
26
79
|
}
|
|
27
80
|
});
|
|
28
|
-
|
|
29
|
-
// Handle keyboard navigation on labels
|
|
81
|
+
|
|
82
|
+
// Handle keyboard navigation on the tab labels.
|
|
30
83
|
labels.forEach((label, index) => {
|
|
31
84
|
label.addEventListener('keydown', (event) => {
|
|
32
85
|
let targetIndex = index;
|
|
@@ -59,7 +112,7 @@
|
|
|
59
112
|
|
|
60
113
|
case 'Enter':
|
|
61
114
|
case ' ':
|
|
62
|
-
// Activate the associated radio button
|
|
115
|
+
// Activate the associated radio button on Enter/Space.
|
|
63
116
|
event.preventDefault();
|
|
64
117
|
if (radios[index]) {
|
|
65
118
|
radios[index].checked = true;
|
|
@@ -72,7 +125,7 @@
|
|
|
72
125
|
}
|
|
73
126
|
|
|
74
127
|
if (handled) {
|
|
75
|
-
// Move focus to target label and activate its radio
|
|
128
|
+
// Move focus to the target label and activate its radio button.
|
|
76
129
|
labels[targetIndex].focus();
|
|
77
130
|
if (radios[targetIndex]) {
|
|
78
131
|
radios[targetIndex].checked = true;
|
|
@@ -82,46 +135,23 @@
|
|
|
82
135
|
});
|
|
83
136
|
});
|
|
84
137
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
radio.
|
|
89
|
-
|
|
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]) {
|
|
90
144
|
announceTabChange(labels[index].textContent.trim());
|
|
91
145
|
}
|
|
92
|
-
|
|
146
|
+
// Move focus to the newly visible panel.
|
|
147
|
+
focusOnPanel(radio);
|
|
148
|
+
}
|
|
93
149
|
});
|
|
94
|
-
}
|
|
150
|
+
});
|
|
95
151
|
});
|
|
96
152
|
}
|
|
97
153
|
|
|
98
|
-
|
|
99
|
-
// Create or update live region for screen reader announcements
|
|
100
|
-
let liveRegion = document.getElementById('tab-live-region');
|
|
101
|
-
if (!liveRegion) {
|
|
102
|
-
liveRegion = document.createElement('div');
|
|
103
|
-
liveRegion.id = 'tab-live-region';
|
|
104
|
-
liveRegion.setAttribute('role', 'status');
|
|
105
|
-
liveRegion.setAttribute('aria-live', 'polite');
|
|
106
|
-
liveRegion.setAttribute('aria-atomic', 'true');
|
|
107
|
-
liveRegion.style.position = 'absolute';
|
|
108
|
-
liveRegion.style.left = '-10000px';
|
|
109
|
-
liveRegion.style.width = '1px';
|
|
110
|
-
liveRegion.style.height = '1px';
|
|
111
|
-
liveRegion.style.overflow = 'hidden';
|
|
112
|
-
document.body.appendChild(liveRegion);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Update the announcement
|
|
116
|
-
liveRegion.textContent = `${tabName} tab selected`;
|
|
117
|
-
|
|
118
|
-
// Clear the announcement after a short delay
|
|
119
|
-
setTimeout(() => {
|
|
120
|
-
liveRegion.textContent = '';
|
|
121
|
-
}, 1000);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Initialize when DOM is ready
|
|
154
|
+
// Initialize the script once the DOM is ready.
|
|
125
155
|
if (document.readyState === 'loading') {
|
|
126
156
|
document.addEventListener('DOMContentLoaded', initTabKeyboardNavigation);
|
|
127
157
|
} else {
|