sphinx-filter-tabs 0.8.0__py3-none-any.whl → 0.9.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 +105 -81
- filter_tabs/static/filter_tabs.css +82 -22
- filter_tabs/static/filter_tabs.js +130 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/METADATA +4 -2
- sphinx_filter_tabs-0.9.2.dist-info/RECORD +10 -0
- sphinx_filter_tabs-0.8.0.dist-info/RECORD +0 -9
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/top_level.txt +0 -0
filter_tabs/extension.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
#
|
|
4
4
|
|
|
5
5
|
# --- Imports ---
|
|
6
|
-
# Ensures that all type hints are treated as forward references, which is standard practice.
|
|
7
6
|
from __future__ import annotations
|
|
8
7
|
|
|
9
8
|
import re
|
|
@@ -17,24 +16,16 @@ from sphinx.application import Sphinx
|
|
|
17
16
|
from sphinx.util import logging
|
|
18
17
|
from sphinx.writers.html import HTML5Translator
|
|
19
18
|
|
|
20
|
-
# Used for type hinting to avoid circular imports and improve code clarity.
|
|
21
19
|
from typing import TYPE_CHECKING, Any, Dict, List
|
|
22
|
-
|
|
23
|
-
# Imports the package version, dynamically read from the installed package's metadata.
|
|
24
20
|
from . import __version__
|
|
25
21
|
|
|
26
|
-
# A block that only runs when a type checker is running, not at runtime.
|
|
27
|
-
# This avoids potential runtime errors from importing types that may not be available.
|
|
28
22
|
if TYPE_CHECKING:
|
|
29
23
|
from sphinx.config import Config
|
|
30
24
|
from sphinx.environment import BuildEnvironment
|
|
31
25
|
|
|
32
26
|
# --- Constants ---
|
|
33
|
-
# A dedicated UUID namespace ensures that the same tab name will always produce
|
|
34
|
-
# the same unique identifier. This is a crucial security measure to prevent CSS injection.
|
|
35
27
|
_CSS_NAMESPACE = uuid.UUID('d1b1b3e8-5e7c-48d6-a235-9a4c14c9b139')
|
|
36
28
|
|
|
37
|
-
# Centralizing CSS class names makes them easy to manage and prevents typos.
|
|
38
29
|
SFT_CONTAINER = "sft-container"
|
|
39
30
|
SFT_FIELDSET = "sft-fieldset"
|
|
40
31
|
SFT_LEGEND = "sft-legend"
|
|
@@ -46,28 +37,32 @@ COLLAPSIBLE_SECTION = "collapsible-section"
|
|
|
46
37
|
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
47
38
|
CUSTOM_ARROW = "custom-arrow"
|
|
48
39
|
|
|
49
|
-
# --- Logger ---
|
|
50
|
-
# A dedicated logger for this extension, following Sphinx's best practices.
|
|
51
|
-
# This allows for clean, configurable logging output.
|
|
52
40
|
logger = logging.getLogger(__name__)
|
|
53
41
|
|
|
54
42
|
# --- Custom Nodes ---
|
|
55
|
-
# Each custom node corresponds to a specific part of the component's HTML structure.
|
|
56
|
-
# This allows for fine-grained control over the final HTML output via visitor functions.
|
|
57
|
-
|
|
58
|
-
# The ContainerNode is essential for applying CSS Custom Properties via the 'style' attribute.
|
|
59
|
-
# The default docutils container doesn't reliably render the style attribute, so this
|
|
60
|
-
# custom node and its visitor function ensure the theming mechanism works correctly.
|
|
61
43
|
class ContainerNode(nodes.General, nodes.Element):
|
|
62
44
|
pass
|
|
63
45
|
|
|
64
|
-
class FieldsetNode(nodes.General, nodes.Element):
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class
|
|
46
|
+
class FieldsetNode(nodes.General, nodes.Element):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
class LegendNode(nodes.General, nodes.Element):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
class RadioInputNode(nodes.General, nodes.Element):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class LabelNode(nodes.General, nodes.Element):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
class PanelNode(nodes.General, nodes.Element):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
class DetailsNode(nodes.General, nodes.Element):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
class SummaryNode(nodes.General, nodes.Element):
|
|
65
|
+
pass
|
|
71
66
|
|
|
72
67
|
|
|
73
68
|
# --- Renderer Class ---
|
|
@@ -118,13 +113,11 @@ class FilterTabsRenderer:
|
|
|
118
113
|
fieldset += legend
|
|
119
114
|
|
|
120
115
|
# --- CSS Generation ---
|
|
121
|
-
# This generates the dynamic CSS that handles the core filtering logic.
|
|
122
116
|
css_rules = []
|
|
123
|
-
for tab_name in self.tab_names:
|
|
124
|
-
|
|
117
|
+
for i, tab_name in enumerate(self.tab_names):
|
|
118
|
+
# FIX 1: Use position-based IDs instead of hash-based
|
|
119
|
+
radio_id = f"{group_id}-tab-{i}"
|
|
125
120
|
panel_id = f"{radio_id}-panel"
|
|
126
|
-
# This rule finds the tab bar that contains the checked radio button,
|
|
127
|
-
# then finds its sibling content area and shows the correct panel inside.
|
|
128
121
|
css_rules.append(
|
|
129
122
|
f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ .sft-content > #{panel_id} {{ display: block; }}"
|
|
130
123
|
)
|
|
@@ -137,23 +130,23 @@ class FilterTabsRenderer:
|
|
|
137
130
|
(static_dir / css_filename).write_text(css_content, encoding='utf-8')
|
|
138
131
|
self.env.app.add_css_file(css_filename)
|
|
139
132
|
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
tab_bar = nodes.container(classes=[SFT_TAB_BAR], role='tablist')
|
|
133
|
+
# The tab bar container - NO ARIA attributes here
|
|
134
|
+
tab_bar = ContainerNode(classes=[SFT_TAB_BAR])
|
|
143
135
|
fieldset += tab_bar
|
|
144
136
|
|
|
145
|
-
# The content area holds all the panels
|
|
146
|
-
content_area =
|
|
137
|
+
# The content area holds all the panels
|
|
138
|
+
content_area = ContainerNode(classes=[SFT_CONTENT])
|
|
147
139
|
fieldset += content_area
|
|
148
140
|
|
|
149
141
|
# Map tab names to their content blocks for easy lookup.
|
|
150
142
|
content_map = {block['filter-name']: block.children for block in self.temp_blocks}
|
|
151
143
|
|
|
152
|
-
# 1. Create all radio buttons and labels
|
|
144
|
+
# 1. Create all radio buttons and labels - NO ARIA on labels
|
|
153
145
|
for i, tab_name in enumerate(self.tab_names):
|
|
154
|
-
|
|
146
|
+
# FIX 1: Use position-based IDs
|
|
147
|
+
radio_id = f"{group_id}-tab-{i}"
|
|
155
148
|
panel_id = f"{radio_id}-panel"
|
|
156
|
-
|
|
149
|
+
|
|
157
150
|
# The radio button is for state management.
|
|
158
151
|
radio = RadioInputNode(type='radio', name=group_id, ids=[radio_id])
|
|
159
152
|
|
|
@@ -162,24 +155,36 @@ class FilterTabsRenderer:
|
|
|
162
155
|
radio['checked'] = 'checked'
|
|
163
156
|
tab_bar += radio
|
|
164
157
|
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
label['aria-selected'] = 'true'
|
|
158
|
+
# FIX 2: Remove ALL ARIA attributes from labels - just use for_id
|
|
159
|
+
# FIX 3: Don't add IDs to labels - they don't need them
|
|
160
|
+
label = LabelNode(for_id=radio_id)
|
|
169
161
|
label += nodes.Text(tab_name)
|
|
170
162
|
tab_bar += label
|
|
171
163
|
|
|
172
|
-
# 2. Create all tab panels
|
|
164
|
+
# 2. Create all tab panels - panels can have ARIA attributes
|
|
173
165
|
all_tab_names = ["General"] + self.tab_names
|
|
174
|
-
for tab_name in all_tab_names:
|
|
175
|
-
# The "General" panel does not correspond to a specific tab control.
|
|
166
|
+
for i, tab_name in enumerate(all_tab_names):
|
|
176
167
|
if tab_name == "General":
|
|
177
|
-
|
|
168
|
+
# General panel doesn't correspond to a specific tab control
|
|
169
|
+
panel = PanelNode(
|
|
170
|
+
classes=[SFT_PANEL],
|
|
171
|
+
**{'data-filter': tab_name}
|
|
172
|
+
)
|
|
178
173
|
else:
|
|
179
|
-
|
|
174
|
+
# FIX 1: Use position-based IDs for panels too
|
|
175
|
+
tab_index = self.tab_names.index(tab_name)
|
|
176
|
+
radio_id = f"{group_id}-tab-{tab_index}"
|
|
180
177
|
panel_id = f"{radio_id}-panel"
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
|
|
179
|
+
# Panels can have ARIA attributes for screen readers
|
|
180
|
+
panel_attrs = {
|
|
181
|
+
'classes': [SFT_PANEL],
|
|
182
|
+
'ids': [panel_id],
|
|
183
|
+
'role': 'tabpanel',
|
|
184
|
+
'aria-labelledby': radio_id,
|
|
185
|
+
'tabindex': '0'
|
|
186
|
+
}
|
|
187
|
+
panel = PanelNode(**panel_attrs)
|
|
183
188
|
|
|
184
189
|
if tab_name in content_map:
|
|
185
190
|
panel.extend(copy.deepcopy(content_map[tab_name]))
|
|
@@ -208,8 +213,6 @@ class FilterTabsRenderer:
|
|
|
208
213
|
def _css_escape(name: str) -> str:
|
|
209
214
|
"""
|
|
210
215
|
Generates a deterministic, CSS-safe identifier from any given tab name string.
|
|
211
|
-
This uses uuid.uuid5 to create a hashed value, which robustly prevents
|
|
212
|
-
CSS injection vulnerabilities that could arise from special characters in tab names.
|
|
213
216
|
"""
|
|
214
217
|
return str(uuid.uuid5(_CSS_NAMESPACE, name.strip().lower()))
|
|
215
218
|
|
|
@@ -223,7 +226,6 @@ class TabDirective(Directive):
|
|
|
223
226
|
def run(self) -> list[nodes.Node]:
|
|
224
227
|
"""
|
|
225
228
|
Parses the content of a tab and stores it in a temporary container.
|
|
226
|
-
This method validates that the directive is used within a `filter-tabs` block.
|
|
227
229
|
"""
|
|
228
230
|
env = self.state.document.settings.env
|
|
229
231
|
# Ensure `tab` is only used inside `filter-tabs`.
|
|
@@ -237,7 +239,7 @@ class TabDirective(Directive):
|
|
|
237
239
|
|
|
238
240
|
|
|
239
241
|
class FilterTabsDirective(Directive):
|
|
240
|
-
"""Handles the main `.. filter-tabs::` directive
|
|
242
|
+
"""Handles the main `.. filter-tabs::` directive."""
|
|
241
243
|
has_content = True
|
|
242
244
|
required_arguments = 1
|
|
243
245
|
final_argument_whitespace = True
|
|
@@ -249,10 +251,6 @@ class FilterTabsDirective(Directive):
|
|
|
249
251
|
"""
|
|
250
252
|
env = self.state.document.settings.env
|
|
251
253
|
|
|
252
|
-
# Remove comments to prevent nesting of filter-tabs directives.
|
|
253
|
-
#if hasattr(env, 'sft_context') and env.sft_context:
|
|
254
|
-
# raise self.error("Nesting `filter-tabs` is not supported.")
|
|
255
|
-
|
|
256
254
|
# Set a context flag to indicate that we are inside a filter-tabs block.
|
|
257
255
|
if not hasattr(env, 'sft_context'):
|
|
258
256
|
env.sft_context = []
|
|
@@ -261,12 +259,11 @@ class FilterTabsDirective(Directive):
|
|
|
261
259
|
# Parse the content of the directive to find all `.. tab::` blocks.
|
|
262
260
|
temp_container = nodes.container()
|
|
263
261
|
self.state.nested_parse(self.content, self.content_offset, temp_container)
|
|
264
|
-
env.sft_context.pop()
|
|
262
|
+
env.sft_context.pop()
|
|
265
263
|
|
|
266
264
|
# Find all the temporary panel nodes created by the TabDirective.
|
|
267
265
|
temp_blocks = temp_container.findall(lambda n: isinstance(n, nodes.Element) and SFT_TEMP_PANEL in n.get('classes', []))
|
|
268
266
|
if not temp_blocks:
|
|
269
|
-
# Raise a clear error if the directive is empty, instead of failing silently.
|
|
270
267
|
self.error("No `.. tab::` directives found inside `filter-tabs`. Content will not be rendered.")
|
|
271
268
|
return []
|
|
272
269
|
|
|
@@ -302,12 +299,10 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
302
299
|
"""
|
|
303
300
|
Finds any admonition with the `:class: collapsible` option and transforms it
|
|
304
301
|
into an HTML `<details>`/`<summary>` element for a native collapsible effect.
|
|
305
|
-
This hook runs after the document tree is resolved.
|
|
306
302
|
"""
|
|
307
303
|
if not app.config.filter_tabs_collapsible_enabled or app.builder.name != 'html':
|
|
308
304
|
return
|
|
309
305
|
|
|
310
|
-
# Iterate over a copy of the list of nodes to allow for safe modification.
|
|
311
306
|
for node in list(doctree.findall(nodes.admonition)):
|
|
312
307
|
if 'collapsible' not in node.get('classes', []):
|
|
313
308
|
continue
|
|
@@ -316,7 +311,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
316
311
|
title_node = next(iter(node.findall(nodes.title)), None)
|
|
317
312
|
summary_text = title_node.astext() if title_node else "Details"
|
|
318
313
|
if title_node:
|
|
319
|
-
title_node.parent.remove(title_node)
|
|
314
|
+
title_node.parent.remove(title_node)
|
|
320
315
|
|
|
321
316
|
# Create the new <details> node.
|
|
322
317
|
details_node = DetailsNode(classes=[COLLAPSIBLE_SECTION])
|
|
@@ -326,7 +321,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
326
321
|
# Create the new <summary> node with a custom arrow.
|
|
327
322
|
summary_node = SummaryNode()
|
|
328
323
|
arrow_span = nodes.inline(classes=[CUSTOM_ARROW])
|
|
329
|
-
arrow_span += nodes.Text("
|
|
324
|
+
arrow_span += nodes.Text("▶")
|
|
330
325
|
summary_node += arrow_span
|
|
331
326
|
summary_node += nodes.Text(summary_text)
|
|
332
327
|
details_node += summary_node
|
|
@@ -347,8 +342,6 @@ def _get_html_attrs(node: nodes.Element) -> Dict[str, Any]:
|
|
|
347
342
|
return attrs
|
|
348
343
|
|
|
349
344
|
# --- HTML Visitor Functions ---
|
|
350
|
-
# These functions translate the custom docutils nodes into HTML tags.
|
|
351
|
-
|
|
352
345
|
def visit_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
353
346
|
self.body.append(self.starttag(node, 'div', **_get_html_attrs(node)))
|
|
354
347
|
def depart_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
@@ -365,53 +358,80 @@ def depart_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
|
365
358
|
self.body.append('</legend>')
|
|
366
359
|
|
|
367
360
|
def visit_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
368
|
-
|
|
361
|
+
attrs = _get_html_attrs(node)
|
|
362
|
+
# Don't manually add 'id' - starttag handles 'ids' automatically
|
|
363
|
+
# Include other important attributes
|
|
364
|
+
for key in ['type', 'name', 'checked']:
|
|
365
|
+
if key in node.attributes:
|
|
366
|
+
attrs[key] = node[key]
|
|
367
|
+
self.body.append(self.starttag(node, 'input', **attrs))
|
|
368
|
+
|
|
369
369
|
def depart_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
370
|
-
pass
|
|
370
|
+
pass
|
|
371
371
|
|
|
372
372
|
def visit_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
373
373
|
attrs = _get_html_attrs(node)
|
|
374
|
-
|
|
374
|
+
# Ensure the 'for' attribute is set correctly
|
|
375
|
+
if 'for_id' in node.attributes:
|
|
376
|
+
attrs['for'] = node['for_id']
|
|
377
|
+
# FIX: Don't add any ARIA attributes or IDs to labels
|
|
375
378
|
self.body.append(self.starttag(node, 'label', **attrs))
|
|
379
|
+
|
|
376
380
|
def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
377
381
|
self.body.append('</label>')
|
|
378
382
|
|
|
379
383
|
def visit_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
380
|
-
|
|
384
|
+
attrs = _get_html_attrs(node)
|
|
385
|
+
# Panels can have ARIA attributes
|
|
386
|
+
for key in ['role', 'aria-labelledby', 'tabindex']:
|
|
387
|
+
if key in node.attributes:
|
|
388
|
+
attrs[key] = node[key]
|
|
389
|
+
# Handle data attributes
|
|
390
|
+
if 'data-filter' in node.attributes:
|
|
391
|
+
attrs['data-filter'] = node['data-filter']
|
|
392
|
+
self.body.append(self.starttag(node, 'div', CLASS=SFT_PANEL, **attrs))
|
|
393
|
+
|
|
381
394
|
def depart_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
382
395
|
self.body.append('</div>')
|
|
383
396
|
|
|
384
397
|
def visit_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
385
|
-
|
|
398
|
+
attrs = {}
|
|
399
|
+
if 'open' in node.attributes:
|
|
400
|
+
attrs['open'] = node['open']
|
|
401
|
+
self.body.append(self.starttag(node, 'details', **attrs))
|
|
402
|
+
|
|
386
403
|
def depart_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
387
404
|
self.body.append('</details>')
|
|
388
405
|
|
|
389
406
|
def visit_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
390
|
-
self.body.append(self.starttag(node, 'summary'
|
|
407
|
+
self.body.append(self.starttag(node, 'summary'))
|
|
408
|
+
|
|
391
409
|
def depart_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
392
410
|
self.body.append('</summary>')
|
|
393
411
|
|
|
394
412
|
|
|
395
413
|
def copy_static_files(app: Sphinx):
|
|
396
414
|
"""
|
|
397
|
-
Copies the extension's static CSS
|
|
398
|
-
This hook runs when the builder is initialized, ensuring the CSS file is
|
|
399
|
-
always available for HTML builds without complex packaging maneuvers.
|
|
415
|
+
Copies the extension's static CSS and JS files to the build output directory.
|
|
400
416
|
"""
|
|
401
417
|
if app.builder.name != 'html':
|
|
402
418
|
return
|
|
403
|
-
|
|
419
|
+
|
|
420
|
+
static_source_dir = Path(__file__).parent / "static"
|
|
404
421
|
dest_dir = Path(app.outdir) / "_static"
|
|
405
422
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
406
|
-
|
|
423
|
+
|
|
424
|
+
# Copy both CSS and JS files
|
|
425
|
+
for file_pattern in ["*.css", "*.js"]:
|
|
426
|
+
for file_path in static_source_dir.glob(file_pattern):
|
|
427
|
+
shutil.copy(file_path, dest_dir)
|
|
407
428
|
|
|
408
429
|
|
|
409
430
|
def setup(app: Sphinx) -> Dict[str, Any]:
|
|
410
431
|
"""
|
|
411
432
|
The main entry point for the Sphinx extension.
|
|
412
|
-
This function registers all components with Sphinx.
|
|
413
433
|
"""
|
|
414
|
-
# Register custom configuration values
|
|
434
|
+
# Register custom configuration values
|
|
415
435
|
app.add_config_value('filter_tabs_tab_highlight_color', '#007bff', 'html', [str])
|
|
416
436
|
app.add_config_value('filter_tabs_tab_background_color', '#f0f0f0', 'html', [str])
|
|
417
437
|
app.add_config_value('filter_tabs_tab_font_size', '1em', 'html', [str])
|
|
@@ -419,9 +439,14 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
419
439
|
app.add_config_value('filter_tabs_debug_mode', False, 'html', [bool])
|
|
420
440
|
app.add_config_value('filter_tabs_collapsible_enabled', True, 'html', [bool])
|
|
421
441
|
app.add_config_value('filter_tabs_collapsible_accent_color', '#17a2b8', 'html', [str])
|
|
442
|
+
|
|
443
|
+
# NEW: Add accessibility configuration options
|
|
444
|
+
app.add_config_value('filter_tabs_keyboard_navigation', True, 'html', [bool])
|
|
445
|
+
app.add_config_value('filter_tabs_announce_changes', True, 'html', [bool])
|
|
422
446
|
|
|
423
|
-
# Add the main stylesheet to the HTML output.
|
|
447
|
+
# Add the main stylesheet and JavaScript to the HTML output.
|
|
424
448
|
app.add_css_file('filter_tabs.css')
|
|
449
|
+
app.add_js_file('filter_tabs.js')
|
|
425
450
|
|
|
426
451
|
# Register all custom nodes and their HTML visitor/depart functions.
|
|
427
452
|
app.add_node(ContainerNode, html=(visit_container_node, depart_container_node))
|
|
@@ -437,11 +462,10 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
437
462
|
app.add_directive('filter-tabs', FilterTabsDirective)
|
|
438
463
|
app.add_directive('tab', TabDirective)
|
|
439
464
|
|
|
440
|
-
# Connect to Sphinx events
|
|
465
|
+
# Connect to Sphinx events
|
|
441
466
|
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
442
467
|
app.connect('builder-inited', copy_static_files)
|
|
443
468
|
|
|
444
|
-
# Return metadata about the extension.
|
|
445
469
|
return {
|
|
446
470
|
'version': __version__,
|
|
447
471
|
'parallel_read_safe': True,
|
|
@@ -1,24 +1,35 @@
|
|
|
1
|
-
/* Sphinx Filter Tabs --
|
|
2
|
-
/* This file provides
|
|
1
|
+
/* Sphinx Filter Tabs -- Complete Stylesheet */
|
|
2
|
+
/* This file provides both structural and accessibility styles */
|
|
3
3
|
|
|
4
4
|
/* Main container for the tab component */
|
|
5
5
|
.sft-container {
|
|
6
6
|
border: 1px solid #e0e0e0;
|
|
7
|
-
/* THEME: The border-radius is controlled by a CSS variable from conf.py. */
|
|
8
7
|
border-radius: var(--sft-border-radius, 8px);
|
|
9
8
|
overflow: hidden;
|
|
10
9
|
margin: 1.5em 0;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
/* The <fieldset> provides semantic grouping for accessibility. */
|
|
14
|
-
.sft-fieldset {
|
|
13
|
+
.sft-fieldset {
|
|
14
|
+
border: none;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
15
18
|
|
|
16
19
|
/* The <legend> is visually hidden but essential for screen reader users. */
|
|
17
|
-
.sft-legend {
|
|
20
|
+
.sft-legend {
|
|
21
|
+
position: absolute;
|
|
22
|
+
width: 1px;
|
|
23
|
+
height: 1px;
|
|
24
|
+
padding: 0;
|
|
25
|
+
margin: -1px;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
clip: rect(0, 0, 0, 0);
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
border: 0;
|
|
30
|
+
}
|
|
18
31
|
|
|
19
|
-
/*
|
|
20
|
-
They are hidden accessibly, not with display:none, so they remain
|
|
21
|
-
focusable for keyboard users. */
|
|
32
|
+
/* Hide radio buttons completely but keep them accessible */
|
|
22
33
|
.sft-tab-bar > input[type="radio"] {
|
|
23
34
|
position: absolute;
|
|
24
35
|
width: 1px;
|
|
@@ -29,17 +40,18 @@
|
|
|
29
40
|
clip: rect(0, 0, 0, 0);
|
|
30
41
|
white-space: nowrap;
|
|
31
42
|
border: 0;
|
|
43
|
+
opacity: 0;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
/* The tab bar containing the clickable labels. */
|
|
35
47
|
.sft-tab-bar {
|
|
36
48
|
display: flex;
|
|
37
49
|
flex-wrap: wrap;
|
|
38
|
-
/* THEME: The background color is controlled by a CSS variable. */
|
|
39
50
|
background-color: var(--sft-tab-background, #f0f0f0);
|
|
40
51
|
border-bottom: 1px solid #e0e0e0;
|
|
41
52
|
padding: 0 10px;
|
|
42
53
|
}
|
|
54
|
+
|
|
43
55
|
.sft-tab-bar > label {
|
|
44
56
|
padding: 12px 18px;
|
|
45
57
|
cursor: pointer;
|
|
@@ -47,26 +59,36 @@
|
|
|
47
59
|
border-bottom: 3px solid transparent;
|
|
48
60
|
color: #555;
|
|
49
61
|
font-weight: 500;
|
|
50
|
-
|
|
51
|
-
font-size: var(--sft-font-size, 1em);
|
|
62
|
+
font-size: var(--sft-tab-font-size, 1em);
|
|
52
63
|
line-height: 1.5;
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
/*
|
|
66
|
+
/* Style for checked state - using adjacent sibling selector */
|
|
56
67
|
.sft-tab-bar > input[type="radio"]:checked + label {
|
|
57
68
|
border-bottom-color: var(--sft-tab-highlight-color, #007bff);
|
|
58
69
|
color: #000;
|
|
70
|
+
font-weight: 600;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
/*
|
|
73
|
+
/* Hover state for labels */
|
|
74
|
+
.sft-tab-bar > label:hover {
|
|
75
|
+
color: #333;
|
|
76
|
+
border-bottom-color: rgba(0, 123, 255, 0.3);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Focus state for keyboard navigation */
|
|
62
80
|
.sft-tab-bar > input[type="radio"]:focus + label {
|
|
63
81
|
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6);
|
|
64
|
-
border-radius:
|
|
82
|
+
border-radius: 3px;
|
|
65
83
|
outline: none;
|
|
84
|
+
z-index: 1;
|
|
85
|
+
position: relative;
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/* The container for all content panels. */
|
|
69
|
-
.sft-content {
|
|
89
|
+
.sft-content {
|
|
90
|
+
padding: 20px;
|
|
91
|
+
}
|
|
70
92
|
|
|
71
93
|
/* By default, hide all tab-specific panels. The dynamic CSS will show the active one. */
|
|
72
94
|
.sft-content > .sft-panel {
|
|
@@ -78,15 +100,20 @@
|
|
|
78
100
|
display: block;
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
/* Ensure panels have proper focus styles for screen readers */
|
|
104
|
+
.sft-content > .sft-panel[role="tabpanel"]:focus {
|
|
105
|
+
outline: 2px solid var(--sft-tab-highlight-color, #007bff);
|
|
106
|
+
outline-offset: 2px;
|
|
107
|
+
}
|
|
81
108
|
|
|
82
109
|
/* Styles for collapsible sections */
|
|
83
110
|
.collapsible-section {
|
|
84
111
|
border: 1px solid #e0e0e0;
|
|
85
|
-
/* THEME: The accent color is controlled by a CSS variable. */
|
|
86
112
|
border-left: 4px solid var(--sft-collapsible-accent-color, #17a2b8);
|
|
87
113
|
border-radius: 4px;
|
|
88
114
|
margin-top: 1em;
|
|
89
115
|
}
|
|
116
|
+
|
|
90
117
|
.collapsible-section summary {
|
|
91
118
|
display: block;
|
|
92
119
|
cursor: pointer;
|
|
@@ -94,14 +121,47 @@
|
|
|
94
121
|
font-weight: bold;
|
|
95
122
|
background-color: #f9f9f9;
|
|
96
123
|
outline: none;
|
|
124
|
+
list-style: none;
|
|
97
125
|
}
|
|
126
|
+
|
|
98
127
|
.collapsible-section summary::-webkit-details-marker {
|
|
99
128
|
display: none;
|
|
100
129
|
}
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
|
|
131
|
+
.collapsible-section[open] > summary {
|
|
132
|
+
border-bottom: 1px solid #e0e0e0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.custom-arrow {
|
|
136
|
+
display: inline-block;
|
|
137
|
+
width: 1em;
|
|
138
|
+
margin-right: 8px;
|
|
139
|
+
transition: transform 0.2s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.collapsible-section[open] > summary .custom-arrow {
|
|
143
|
+
transform: rotate(90deg);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.collapsible-content {
|
|
147
|
+
padding: 15px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Support for high contrast mode */
|
|
151
|
+
@media (prefers-contrast: high) {
|
|
152
|
+
.sft-tab-bar > input[type="radio"]:checked + label {
|
|
153
|
+
border-bottom-width: 4px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.sft-tab-bar > input[type="radio"]:focus + label {
|
|
157
|
+
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.8);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Support for reduced motion preferences */
|
|
162
|
+
@media (prefers-reduced-motion: reduce) {
|
|
163
|
+
.sft-tab-bar > label,
|
|
164
|
+
.custom-arrow {
|
|
165
|
+
transition: none;
|
|
166
|
+
}
|
|
103
167
|
}
|
|
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,130 @@
|
|
|
1
|
+
// Progressive enhancement for keyboard navigation
|
|
2
|
+
// This file ensures proper keyboard navigation while maintaining CSS-only fallback
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// Only enhance if the extension is present
|
|
8
|
+
if (!document.querySelector('.sft-container')) return;
|
|
9
|
+
|
|
10
|
+
function initTabKeyboardNavigation() {
|
|
11
|
+
const containers = document.querySelectorAll('.sft-container');
|
|
12
|
+
|
|
13
|
+
containers.forEach(container => {
|
|
14
|
+
const tabBar = container.querySelector('.sft-tab-bar');
|
|
15
|
+
if (!tabBar) return;
|
|
16
|
+
|
|
17
|
+
const radios = tabBar.querySelectorAll('input[type="radio"]');
|
|
18
|
+
const labels = tabBar.querySelectorAll('label');
|
|
19
|
+
|
|
20
|
+
if (radios.length === 0 || labels.length === 0) return;
|
|
21
|
+
|
|
22
|
+
// Make labels focusable for keyboard navigation
|
|
23
|
+
labels.forEach(label => {
|
|
24
|
+
if (!label.hasAttribute('tabindex')) {
|
|
25
|
+
label.setAttribute('tabindex', '0');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Handle keyboard navigation on labels
|
|
30
|
+
labels.forEach((label, index) => {
|
|
31
|
+
label.addEventListener('keydown', (event) => {
|
|
32
|
+
let targetIndex = index;
|
|
33
|
+
let handled = false;
|
|
34
|
+
|
|
35
|
+
switch (event.key) {
|
|
36
|
+
case 'ArrowRight':
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
targetIndex = (index + 1) % labels.length;
|
|
39
|
+
handled = true;
|
|
40
|
+
break;
|
|
41
|
+
|
|
42
|
+
case 'ArrowLeft':
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
targetIndex = (index - 1 + labels.length) % labels.length;
|
|
45
|
+
handled = true;
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case 'Home':
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
targetIndex = 0;
|
|
51
|
+
handled = true;
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
case 'End':
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
targetIndex = labels.length - 1;
|
|
57
|
+
handled = true;
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'Enter':
|
|
61
|
+
case ' ':
|
|
62
|
+
// Activate the associated radio button
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
if (radios[index]) {
|
|
65
|
+
radios[index].checked = true;
|
|
66
|
+
radios[index].dispatchEvent(new Event('change', { bubbles: true }));
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
|
|
70
|
+
default:
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (handled) {
|
|
75
|
+
// Move focus to target label and activate its radio
|
|
76
|
+
labels[targetIndex].focus();
|
|
77
|
+
if (radios[targetIndex]) {
|
|
78
|
+
radios[targetIndex].checked = true;
|
|
79
|
+
radios[targetIndex].dispatchEvent(new Event('change', { bubbles: true }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Optional: Announce tab changes for screen readers
|
|
86
|
+
if (window.config && window.config.filter_tabs_announce_changes !== false) {
|
|
87
|
+
radios.forEach((radio, index) => {
|
|
88
|
+
radio.addEventListener('change', () => {
|
|
89
|
+
if (radio.checked && labels[index]) {
|
|
90
|
+
announceTabChange(labels[index].textContent.trim());
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function announceTabChange(tabName) {
|
|
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
|
|
125
|
+
if (document.readyState === 'loading') {
|
|
126
|
+
document.addEventListener('DOMContentLoaded', initTabKeyboardNavigation);
|
|
127
|
+
} else {
|
|
128
|
+
initTabKeyboardNavigation();
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sphinx-filter-tabs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: A Sphinx extension for accessible, JS-free filterable content tabs.
|
|
5
5
|
Author-email: Aputsiak Niels Janussen <aputsiak+sphinx@gmail.com>
|
|
6
6
|
License: GNU General Public License v3.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/aputtu/sphinx-filter-tabs
|
|
8
8
|
Project-URL: Repository, https://github.com/aputtu/sphinx-filter-tabs.git
|
|
9
9
|
Project-URL: Issues, https://github.com/aputtu/sphinx-filter-tabs/issues
|
|
10
|
-
Keywords: sphinx,extension,tabs,filter,documentation,no-js
|
|
10
|
+
Keywords: sphinx,extension,tabs,filter,documentation,no-js,aria,accessibility
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Framework :: Sphinx :: Extension
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -60,4 +60,6 @@ Command to enter venv is provided.
|
|
|
60
60
|
```bash
|
|
61
61
|
pytest # Runs test suite on configured version of Sphinx.
|
|
62
62
|
tox # Check across multiple Sphinx versions. Manual install of tox required.
|
|
63
|
+
./scripts/export-project.sh # Outputs directory structure and code to txt
|
|
64
|
+
./dev.sh [options] # Allows for faster generation for html, pdf, clean up
|
|
63
65
|
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
filter_tabs/__init__.py,sha256=VPpIhj4HaLeMX7ai7dZFkUm81ii2ePPGjCd9hsMjsN4,397
|
|
2
|
+
filter_tabs/extension.py,sha256=VWZj6fcgkH2WSBX8r7JPyarpqCSLAq1dtqzZdBDGejo,19457
|
|
3
|
+
filter_tabs/static/filter_tabs.css,sha256=M-6VbOsWU7xiklpHeRtD-iVDK-Bmk_NZle6e7AgiYLE,3994
|
|
4
|
+
filter_tabs/static/filter_tabs.js,sha256=r_zWkPYDgdKEQP4vkXRbe3YtcChEvzJ5WPVKzpS8-Q0,5309
|
|
5
|
+
sphinx_filter_tabs-0.9.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
6
|
+
sphinx_filter_tabs-0.9.2.dist-info/METADATA,sha256=JIzur1-LMOiUgZz3Oazsp-FyJLnvl6UsQEq1dQ4grF0,2872
|
|
7
|
+
sphinx_filter_tabs-0.9.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
sphinx_filter_tabs-0.9.2.dist-info/entry_points.txt,sha256=za_bQcueY8AHyq7XnnjkW9X3C-LsZjeERVQ_ds7jV1A,62
|
|
9
|
+
sphinx_filter_tabs-0.9.2.dist-info/top_level.txt,sha256=K0Iy-6EsYYdvlyXdsJT0SQg-BLDBgT5-Y8ZKy5SNAfc,12
|
|
10
|
+
sphinx_filter_tabs-0.9.2.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
filter_tabs/__init__.py,sha256=VPpIhj4HaLeMX7ai7dZFkUm81ii2ePPGjCd9hsMjsN4,397
|
|
2
|
-
filter_tabs/extension.py,sha256=K7w7iRN72PGVvswDuULBxxxam4zvPF1ZulsAxeHlvz8,20937
|
|
3
|
-
filter_tabs/static/filter_tabs.css,sha256=gXQk2ceyjAj9p3G_k-mrcPdxq0ggIXSMuXHyUE5mPP0,3486
|
|
4
|
-
sphinx_filter_tabs-0.8.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
5
|
-
sphinx_filter_tabs-0.8.0.dist-info/METADATA,sha256=SjLifkJnouYp8U4_te9JgpPtELGgQcaaeXEXoGRSCkU,2705
|
|
6
|
-
sphinx_filter_tabs-0.8.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
-
sphinx_filter_tabs-0.8.0.dist-info/entry_points.txt,sha256=za_bQcueY8AHyq7XnnjkW9X3C-LsZjeERVQ_ds7jV1A,62
|
|
8
|
-
sphinx_filter_tabs-0.8.0.dist-info/top_level.txt,sha256=K0Iy-6EsYYdvlyXdsJT0SQg-BLDBgT5-Y8ZKy5SNAfc,12
|
|
9
|
-
sphinx_filter_tabs-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|