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.
- filter_tabs/extension.py +334 -300
- filter_tabs/renderer.py +261 -0
- filter_tabs/static/filter_tabs.css +139 -62
- filter_tabs/static/filter_tabs.js +160 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/METADATA +14 -5
- sphinx_filter_tabs-1.2.0.dist-info/RECORD +11 -0
- sphinx_filter_tabs-0.8.0.dist-info/RECORD +0 -9
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.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,92 +1,136 @@
|
|
|
1
|
-
/* Sphinx Filter Tabs
|
|
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
|
|
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
|
-
/*
|
|
14
|
-
.sft-fieldset {
|
|
12
|
+
/* Semantic structure */
|
|
13
|
+
.sft-fieldset {
|
|
14
|
+
border: none;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
15
18
|
|
|
16
|
-
/*
|
|
17
|
-
.sft-legend {
|
|
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
|
-
/*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/*
|
|
35
|
-
.sft-
|
|
36
|
-
display:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
border-bottom:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
68
|
+
background: white;
|
|
69
|
+
font-weight: 600;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
/*
|
|
62
|
-
.sft-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
/*
|
|
69
|
-
.sft-
|
|
80
|
+
/* Hover effects */
|
|
81
|
+
.sft-radio-group label:hover {
|
|
82
|
+
color: #495057;
|
|
83
|
+
background: rgba(0, 123, 255, 0.05);
|
|
84
|
+
}
|
|
70
85
|
|
|
71
|
-
/*
|
|
72
|
-
.sft-content
|
|
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
|
-
/*
|
|
77
|
-
.sft-
|
|
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
|
-
/*
|
|
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
|
-
|
|
102
|
-
|
|
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
|
+
})();
|