sphinx-filter-tabs 0.7.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 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): pass # For semantic grouping.
65
- class LegendNode(nodes.General, nodes.Element): pass # For accessibility.
66
- class RadioInputNode(nodes.General, nodes.Element): pass # The functional core for tab switching.
67
- class LabelNode(nodes.General, nodes.Element): pass # The visible, clickable tab titles.
68
- class PanelNode(nodes.General, nodes.Element): pass # The containers for tab content.
69
- class DetailsNode(nodes.General, nodes.Element): pass # For collapsible sections.
70
- class SummaryNode(nodes.General, nodes.Element): pass # The clickable title of a collapsible section.
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 ---
@@ -85,7 +80,7 @@ class FilterTabsRenderer:
85
80
  self.temp_blocks: list[nodes.Node] = temp_blocks
86
81
 
87
82
  def render_html(self) -> list[nodes.Node]:
88
- """Constructs the complete docutils node tree for the HTML output."""
83
+ """Constructs the complete, W3C valid, and ARIA-compliant docutils node tree."""
89
84
  # Ensure a unique ID for each filter-tabs instance on a page.
90
85
  if not hasattr(self.env, 'filter_tabs_counter'):
91
86
  self.env.filter_tabs_counter = 0
@@ -104,79 +99,98 @@ class FilterTabsRenderer:
104
99
  }
105
100
  style_string = "; ".join([f"{key}: {value}" for key, value in style_vars.items()])
106
101
 
107
- # If debug mode is on, log the generated ID and styles for easier troubleshooting.
102
+ # If debug mode is on, log the generated ID and styles.
108
103
  if config.filter_tabs_debug_mode:
109
104
  logger.info(f"[sphinx-filter-tabs] ID: {group_id}, Styles: '{style_string}'")
110
105
 
111
106
  # Create the main container node with the inline style for theming.
112
107
  container = ContainerNode(classes=[SFT_CONTAINER], style=style_string)
113
108
 
114
- # Build the semantic structure using fieldset and a hidden legend for accessibility.
109
+ # Build the semantic structure using fieldset and a hidden legend.
115
110
  fieldset = FieldsetNode()
116
111
  legend = LegendNode()
117
112
  legend += nodes.Text(f"Filter by: {', '.join(self.tab_names)}")
118
113
  fieldset += legend
119
-
120
- # --- Backward-compatible CSS Handling ---
121
- # Generate the dynamic CSS that handles the core filtering logic.
114
+
115
+ # --- CSS Generation ---
122
116
  css_rules = []
123
- for tab_name in self.tab_names:
124
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
125
- # This CSS rule shows a panel only when its corresponding radio button is checked.
126
- # The modern :has() selector makes this possible without any JavaScript.
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}"
120
+ panel_id = f"{radio_id}-panel"
127
121
  css_rules.append(
128
- f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ "
129
- f".{SFT_CONTENT} > .{SFT_PANEL}[data-filter='{tab_name}'] {{ display: block; }}"
122
+ f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ .sft-content > #{panel_id} {{ display: block; }}"
130
123
  )
131
-
132
- # 1. Write the dynamic CSS to a temporary file in the build's static directory.
124
+
125
+ # Write the dynamic CSS to a temporary file and add it to the build.
133
126
  css_content = ''.join(css_rules)
134
127
  static_dir = Path(self.env.app.outdir) / '_static'
135
128
  static_dir.mkdir(parents=True, exist_ok=True)
136
129
  css_filename = f"dynamic-filter-tabs-{group_id}.css"
137
130
  (static_dir / css_filename).write_text(css_content, encoding='utf-8')
138
-
139
- # 2. Add the temporary CSS file using the backward-compatible app.add_css_file() method.
140
- # This correctly places a <link> tag in the HTML <head>.
141
131
  self.env.app.add_css_file(css_filename)
142
132
 
143
- # Create the tab bar, but without the role="tablist"
144
- # Screen Reader Ambiguity: Elements announced as group of radio buttons
145
- tab_bar = nodes.container(classes=[SFT_TAB_BAR])
146
- for tab_name in self.tab_names:
147
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
133
+ # The tab bar container - NO ARIA attributes here
134
+ tab_bar = ContainerNode(classes=[SFT_TAB_BAR])
135
+ fieldset += tab_bar
136
+
137
+ # The content area holds all the panels
138
+ content_area = ContainerNode(classes=[SFT_CONTENT])
139
+ fieldset += content_area
140
+
141
+ # Map tab names to their content blocks for easy lookup.
142
+ content_map = {block['filter-name']: block.children for block in self.temp_blocks}
143
+
144
+ # 1. Create all radio buttons and labels - NO ARIA on labels
145
+ for i, tab_name in enumerate(self.tab_names):
146
+ # FIX 1: Use position-based IDs
147
+ radio_id = f"{group_id}-tab-{i}"
148
+ panel_id = f"{radio_id}-panel"
148
149
 
149
- # Create the radio input, but without the role="tab"
150
+ # The radio button is for state management.
150
151
  radio = RadioInputNode(type='radio', name=group_id, ids=[radio_id])
151
152
 
152
- if tab_name == self.default_tab:
153
+ is_default = (self.default_tab == tab_name) or (i == 0 and not self.default_tab)
154
+ if is_default:
153
155
  radio['checked'] = 'checked'
154
156
  tab_bar += radio
155
157
 
156
- # The label correctly points to the radio input
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
157
160
  label = LabelNode(for_id=radio_id)
158
161
  label += nodes.Text(tab_name)
159
162
  tab_bar += label
160
- fieldset += tab_bar
161
163
 
162
- # Create the content area where all panels will reside.
163
- content_area = nodes.container(classes=[SFT_CONTENT])
164
- # Map tab names to their content blocks for easy lookup.
165
- content_map = {block['filter-name']: block.children for block in self.temp_blocks}
166
- # Ensure we create panels for all declared tabs plus the "General" tab.
167
- all_tab_names = self.tab_names + ["General"]
168
- for tab_name in all_tab_names:
169
- panel = PanelNode(classes=[SFT_PANEL], **{'data-filter': tab_name, 'role': 'tabpanel'})
164
+ # 2. Create all tab panels - panels can have ARIA attributes
165
+ all_tab_names = ["General"] + self.tab_names
166
+ for i, tab_name in enumerate(all_tab_names):
167
+ if tab_name == "General":
168
+ # General panel doesn't correspond to a specific tab control
169
+ panel = PanelNode(
170
+ classes=[SFT_PANEL],
171
+ **{'data-filter': tab_name}
172
+ )
173
+ else:
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}"
177
+ panel_id = f"{radio_id}-panel"
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)
188
+
170
189
  if tab_name in content_map:
171
- # Use deepcopy to prevent docutils node mutation bugs. Since the same content
172
- # might be referenced or processed multiple times, a deep copy ensures that
173
- # each panel gets a completely independent set of nodes.
174
190
  panel.extend(copy.deepcopy(content_map[tab_name]))
175
191
  content_area += panel
176
- fieldset += content_area
177
- container.children = [fieldset]
178
192
 
179
- # Return just the main container node. Sphinx handles adding the CSS file to the <head>.
193
+ container.children = [fieldset]
180
194
  return [container]
181
195
 
182
196
  def render_fallback(self) -> list[nodes.Node]:
@@ -199,8 +213,6 @@ class FilterTabsRenderer:
199
213
  def _css_escape(name: str) -> str:
200
214
  """
201
215
  Generates a deterministic, CSS-safe identifier from any given tab name string.
202
- This uses uuid.uuid5 to create a hashed value, which robustly prevents
203
- CSS injection vulnerabilities that could arise from special characters in tab names.
204
216
  """
205
217
  return str(uuid.uuid5(_CSS_NAMESPACE, name.strip().lower()))
206
218
 
@@ -214,7 +226,6 @@ class TabDirective(Directive):
214
226
  def run(self) -> list[nodes.Node]:
215
227
  """
216
228
  Parses the content of a tab and stores it in a temporary container.
217
- This method validates that the directive is used within a `filter-tabs` block.
218
229
  """
219
230
  env = self.state.document.settings.env
220
231
  # Ensure `tab` is only used inside `filter-tabs`.
@@ -228,7 +239,7 @@ class TabDirective(Directive):
228
239
 
229
240
 
230
241
  class FilterTabsDirective(Directive):
231
- """Handles the main `.. filter-tabs::` directive, which orchestrates the entire component."""
242
+ """Handles the main `.. filter-tabs::` directive."""
232
243
  has_content = True
233
244
  required_arguments = 1
234
245
  final_argument_whitespace = True
@@ -239,10 +250,7 @@ class FilterTabsDirective(Directive):
239
250
  and delegates the final rendering to the FilterTabsRenderer.
240
251
  """
241
252
  env = self.state.document.settings.env
242
- # Prevent nesting of filter-tabs directives.
243
- if hasattr(env, 'sft_context') and env.sft_context:
244
- raise self.error("Nesting `filter-tabs` is not supported.")
245
-
253
+
246
254
  # Set a context flag to indicate that we are inside a filter-tabs block.
247
255
  if not hasattr(env, 'sft_context'):
248
256
  env.sft_context = []
@@ -251,12 +259,11 @@ class FilterTabsDirective(Directive):
251
259
  # Parse the content of the directive to find all `.. tab::` blocks.
252
260
  temp_container = nodes.container()
253
261
  self.state.nested_parse(self.content, self.content_offset, temp_container)
254
- env.sft_context.pop() # Unset the context flag.
262
+ env.sft_context.pop()
255
263
 
256
264
  # Find all the temporary panel nodes created by the TabDirective.
257
265
  temp_blocks = temp_container.findall(lambda n: isinstance(n, nodes.Element) and SFT_TEMP_PANEL in n.get('classes', []))
258
266
  if not temp_blocks:
259
- # Raise a clear error if the directive is empty, instead of failing silently.
260
267
  self.error("No `.. tab::` directives found inside `filter-tabs`. Content will not be rendered.")
261
268
  return []
262
269
 
@@ -292,12 +299,10 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
292
299
  """
293
300
  Finds any admonition with the `:class: collapsible` option and transforms it
294
301
  into an HTML `<details>`/`<summary>` element for a native collapsible effect.
295
- This hook runs after the document tree is resolved.
296
302
  """
297
303
  if not app.config.filter_tabs_collapsible_enabled or app.builder.name != 'html':
298
304
  return
299
305
 
300
- # Iterate over a copy of the list of nodes to allow for safe modification.
301
306
  for node in list(doctree.findall(nodes.admonition)):
302
307
  if 'collapsible' not in node.get('classes', []):
303
308
  continue
@@ -306,7 +311,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
306
311
  title_node = next(iter(node.findall(nodes.title)), None)
307
312
  summary_text = title_node.astext() if title_node else "Details"
308
313
  if title_node:
309
- title_node.parent.remove(title_node) # Remove the old title.
314
+ title_node.parent.remove(title_node)
310
315
 
311
316
  # Create the new <details> node.
312
317
  details_node = DetailsNode(classes=[COLLAPSIBLE_SECTION])
@@ -316,7 +321,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
316
321
  # Create the new <summary> node with a custom arrow.
317
322
  summary_node = SummaryNode()
318
323
  arrow_span = nodes.inline(classes=[CUSTOM_ARROW])
319
- arrow_span += nodes.Text("")
324
+ arrow_span += nodes.Text("")
320
325
  summary_node += arrow_span
321
326
  summary_node += nodes.Text(summary_text)
322
327
  details_node += summary_node
@@ -337,8 +342,6 @@ def _get_html_attrs(node: nodes.Element) -> Dict[str, Any]:
337
342
  return attrs
338
343
 
339
344
  # --- HTML Visitor Functions ---
340
- # These functions translate the custom docutils nodes into HTML tags.
341
-
342
345
  def visit_container_node(self: HTML5Translator, node: ContainerNode) -> None:
343
346
  self.body.append(self.starttag(node, 'div', **_get_html_attrs(node)))
344
347
  def depart_container_node(self: HTML5Translator, node: ContainerNode) -> None:
@@ -355,53 +358,80 @@ def depart_legend_node(self: HTML5Translator, node: LegendNode) -> None:
355
358
  self.body.append('</legend>')
356
359
 
357
360
  def visit_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
358
- self.body.append(self.starttag(node, 'input', **_get_html_attrs(node)))
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
+
359
369
  def depart_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
360
- pass # No closing tag for <input>.
370
+ pass
361
371
 
362
372
  def visit_label_node(self: HTML5Translator, node: LabelNode) -> None:
363
373
  attrs = _get_html_attrs(node)
364
- attrs['for'] = node['for_id'] # Connect the label to its radio button.
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
365
378
  self.body.append(self.starttag(node, 'label', **attrs))
379
+
366
380
  def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
367
381
  self.body.append('</label>')
368
382
 
369
383
  def visit_panel_node(self: HTML5Translator, node: PanelNode) -> None:
370
- self.body.append(self.starttag(node, 'div', CLASS=SFT_PANEL, **_get_html_attrs(node)))
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
+
371
394
  def depart_panel_node(self: HTML5Translator, node: PanelNode) -> None:
372
395
  self.body.append('</div>')
373
396
 
374
397
  def visit_details_node(self: HTML5Translator, node: DetailsNode) -> None:
375
- self.body.append(self.starttag(node, 'details', **_get_html_attrs(node)))
398
+ attrs = {}
399
+ if 'open' in node.attributes:
400
+ attrs['open'] = node['open']
401
+ self.body.append(self.starttag(node, 'details', **attrs))
402
+
376
403
  def depart_details_node(self: HTML5Translator, node: DetailsNode) -> None:
377
404
  self.body.append('</details>')
378
405
 
379
406
  def visit_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
380
- self.body.append(self.starttag(node, 'summary', **_get_html_attrs(node)))
407
+ self.body.append(self.starttag(node, 'summary'))
408
+
381
409
  def depart_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
382
410
  self.body.append('</summary>')
383
411
 
384
412
 
385
413
  def copy_static_files(app: Sphinx):
386
414
  """
387
- Copies the extension's static CSS file to the build output directory.
388
- This hook runs when the builder is initialized, ensuring the CSS file is
389
- always available for HTML builds without complex packaging maneuvers.
415
+ Copies the extension's static CSS and JS files to the build output directory.
390
416
  """
391
417
  if app.builder.name != 'html':
392
418
  return
393
- source_css = Path(__file__).parent / "static" / "filter_tabs.css"
419
+
420
+ static_source_dir = Path(__file__).parent / "static"
394
421
  dest_dir = Path(app.outdir) / "_static"
395
422
  dest_dir.mkdir(parents=True, exist_ok=True)
396
- shutil.copy(source_css, dest_dir)
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)
397
428
 
398
429
 
399
430
  def setup(app: Sphinx) -> Dict[str, Any]:
400
431
  """
401
432
  The main entry point for the Sphinx extension.
402
- This function registers all components with Sphinx.
403
433
  """
404
- # Register custom configuration values, allowing users to theme from conf.py.
434
+ # Register custom configuration values
405
435
  app.add_config_value('filter_tabs_tab_highlight_color', '#007bff', 'html', [str])
406
436
  app.add_config_value('filter_tabs_tab_background_color', '#f0f0f0', 'html', [str])
407
437
  app.add_config_value('filter_tabs_tab_font_size', '1em', 'html', [str])
@@ -409,9 +439,14 @@ def setup(app: Sphinx) -> Dict[str, Any]:
409
439
  app.add_config_value('filter_tabs_debug_mode', False, 'html', [bool])
410
440
  app.add_config_value('filter_tabs_collapsible_enabled', True, 'html', [bool])
411
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])
412
446
 
413
- # Add the main stylesheet to the HTML output.
447
+ # Add the main stylesheet and JavaScript to the HTML output.
414
448
  app.add_css_file('filter_tabs.css')
449
+ app.add_js_file('filter_tabs.js')
415
450
 
416
451
  # Register all custom nodes and their HTML visitor/depart functions.
417
452
  app.add_node(ContainerNode, html=(visit_container_node, depart_container_node))
@@ -427,11 +462,10 @@ def setup(app: Sphinx) -> Dict[str, Any]:
427
462
  app.add_directive('filter-tabs', FilterTabsDirective)
428
463
  app.add_directive('tab', TabDirective)
429
464
 
430
- # Connect to Sphinx events (hooks) to run custom functions at specific build stages.
465
+ # Connect to Sphinx events
431
466
  app.connect('doctree-resolved', setup_collapsible_admonitions)
432
467
  app.connect('builder-inited', copy_static_files)
433
468
 
434
- # Return metadata about the extension.
435
469
  return {
436
470
  'version': __version__,
437
471
  'parallel_read_safe': True,
@@ -1,37 +1,57 @@
1
- /* Sphinx Filter Tabs -- Static Stylesheet */
2
- /* This file provides the structural and base theme styles for the component.
3
- The core filtering logic (showing the active panel) is handled by a small,
4
- dynamically generated <style> block created by extension.py. */
1
+ /* Sphinx Filter Tabs -- Complete Stylesheet */
2
+ /* This file provides both structural and accessibility styles */
5
3
 
6
4
  /* Main container for the tab component */
7
5
  .sft-container {
8
6
  border: 1px solid #e0e0e0;
9
- /* THEME: The border-radius is controlled by a CSS variable from conf.py. */
10
7
  border-radius: var(--sft-border-radius, 8px);
11
8
  overflow: hidden;
12
9
  margin: 1.5em 0;
13
10
  }
14
11
 
15
12
  /* The <fieldset> provides semantic grouping for accessibility. */
16
- .sft-fieldset { border: none; padding: 0; margin: 0; }
13
+ .sft-fieldset {
14
+ border: none;
15
+ padding: 0;
16
+ margin: 0;
17
+ }
17
18
 
18
19
  /* The <legend> is visually hidden but essential for screen reader users. */
19
- .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; }
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
+ }
20
31
 
21
- /* The <input type="radio"> buttons are the functional core, but are not visible. */
32
+ /* Hide radio buttons completely but keep them accessible */
22
33
  .sft-tab-bar > input[type="radio"] {
23
- display: none;
34
+ position: absolute;
35
+ width: 1px;
36
+ height: 1px;
37
+ padding: 0;
38
+ margin: -1px;
39
+ overflow: hidden;
40
+ clip: rect(0, 0, 0, 0);
41
+ white-space: nowrap;
42
+ border: 0;
43
+ opacity: 0;
24
44
  }
25
45
 
26
46
  /* The tab bar containing the clickable labels. */
27
47
  .sft-tab-bar {
28
48
  display: flex;
29
49
  flex-wrap: wrap;
30
- /* THEME: The background color is controlled by a CSS variable. */
31
50
  background-color: var(--sft-tab-background, #f0f0f0);
32
51
  border-bottom: 1px solid #e0e0e0;
33
52
  padding: 0 10px;
34
53
  }
54
+
35
55
  .sft-tab-bar > label {
36
56
  padding: 12px 18px;
37
57
  cursor: pointer;
@@ -39,40 +59,61 @@
39
59
  border-bottom: 3px solid transparent;
40
60
  color: #555;
41
61
  font-weight: 500;
42
- /* THEME: The font size is controlled by a CSS variable. */
43
62
  font-size: var(--sft-tab-font-size, 1em);
44
63
  line-height: 1.5;
45
64
  }
46
65
 
47
- /* THEME: The active tab label is highlighted using a CSS variable for the color.
48
- The adjacent sibling selector (+) is robust and efficient. */
66
+ /* Style for checked state - using adjacent sibling selector */
49
67
  .sft-tab-bar > input[type="radio"]:checked + label {
50
68
  border-bottom-color: var(--sft-tab-highlight-color, #007bff);
51
69
  color: #000;
70
+ font-weight: 600;
71
+ }
72
+
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 */
80
+ .sft-tab-bar > input[type="radio"]:focus + label {
81
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6);
82
+ border-radius: 3px;
83
+ outline: none;
84
+ z-index: 1;
85
+ position: relative;
52
86
  }
53
87
 
54
88
  /* The container for all content panels. */
55
- .sft-content { padding: 20px; }
89
+ .sft-content {
90
+ padding: 20px;
91
+ }
56
92
 
57
- /* By default, hide all tab-specific panels. */
58
- .sft-container .sft-panel {
93
+ /* By default, hide all tab-specific panels. The dynamic CSS will show the active one. */
94
+ .sft-content > .sft-panel {
59
95
  display: none;
60
96
  }
61
- /* The "General" panel, however, is always visible. */
62
- .sft-container .sft-panel[data-filter="General"] {
97
+
98
+ /* Always show the "General" panel, as it has no radio button to control it. */
99
+ .sft-content > .sft-panel[data-filter="General"] {
63
100
  display: block;
64
101
  }
65
- /* The dynamically generated <style> block handles showing the one active panel. */
66
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
+ }
67
108
 
68
- /* Styles for collapsible sections, matching the golden record. */
109
+ /* Styles for collapsible sections */
69
110
  .collapsible-section {
70
111
  border: 1px solid #e0e0e0;
71
- /* THEME: The accent color is controlled by a CSS variable. */
72
112
  border-left: 4px solid var(--sft-collapsible-accent-color, #17a2b8);
73
113
  border-radius: 4px;
74
114
  margin-top: 1em;
75
115
  }
116
+
76
117
  .collapsible-section summary {
77
118
  display: block;
78
119
  cursor: pointer;
@@ -80,16 +121,47 @@
80
121
  font-weight: bold;
81
122
  background-color: #f9f9f9;
82
123
  outline: none;
124
+ list-style: none;
83
125
  }
84
- /* Hide the default disclosure triangle in browsers like Chrome/Safari. */
126
+
85
127
  .collapsible-section summary::-webkit-details-marker {
86
128
  display: none;
87
129
  }
88
- /* Hide the default disclosure triangle in browsers like Firefox. */
89
- .collapsible-section summary {
90
- list-style: none;
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
+ }
91
167
  }
92
- .collapsible-section[open] > summary { border-bottom: 1px solid #e0e0e0; }
93
- .custom-arrow { display: inline-block; width: 1em; margin-right: 8px; transition: transform 0.2s; }
94
- .collapsible-section[open] > summary .custom-arrow { transform: rotate(90deg); }
95
- .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.7.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
@@ -45,3 +45,21 @@ This extension provides `filter-tabs` and `tab` directives to create user-friend
45
45
  You can install this extension using `pip`:
46
46
  ```bash
47
47
  pip install sphinx-filter-tabs
48
+ ```
49
+
50
+ ## Development
51
+
52
+ 1. You can install a local version of the Sphinx with extension using:
53
+ ```bash
54
+ ./scripts/setup_dev.sh # Initially cleans previous folders in _docs/build and venv.
55
+ ```
56
+
57
+ Command to enter venv is provided.
58
+
59
+ 2. Once inside virtual environment, you can use following commands:
60
+ ```bash
61
+ pytest # Runs test suite on configured version of Sphinx.
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
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=tU2ZBYWRmYHyvQh8NgPWWqFidunsL16KHhoyqMfboCw,20739
3
- filter_tabs/static/filter_tabs.css,sha256=btJoI-Q87e0XUQARwJUhM6RCfg5m2dvxKtfABZ2zRsc,3429
4
- sphinx_filter_tabs-0.7.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
5
- sphinx_filter_tabs-0.7.0.dist-info/METADATA,sha256=WJjRU9rRRzgFFKlheXL4QTa_c1vF0FWhd3bqMyiG_zA,2264
6
- sphinx_filter_tabs-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- sphinx_filter_tabs-0.7.0.dist-info/entry_points.txt,sha256=za_bQcueY8AHyq7XnnjkW9X3C-LsZjeERVQ_ds7jV1A,62
8
- sphinx_filter_tabs-0.7.0.dist-info/top_level.txt,sha256=K0Iy-6EsYYdvlyXdsJT0SQg-BLDBgT5-Y8ZKy5SNAfc,12
9
- sphinx_filter_tabs-0.7.0.dist-info/RECORD,,