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 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 ---
@@ -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
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
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
- # --- ARIA-Compliant HTML Structure ---
141
- # The tab bar container now gets the role="tablist".
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 = nodes.container(classes=[SFT_CONTENT])
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 first and add them to the tab_bar.
144
+ # 1. Create all radio buttons and labels - NO ARIA on labels
153
145
  for i, tab_name in enumerate(self.tab_names):
154
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
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
- # The label is the visible tab. It gets role="tab" and aria-controls.
166
- label = LabelNode(for_id=radio_id, role='tab', **{'aria-controls': panel_id})
167
- if is_default:
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 and add them to the content_area.
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
- panel = PanelNode(classes=[SFT_PANEL], **{'data-filter': tab_name})
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
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
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
- # The panel gets role="tabpanel" and is linked back to the label.
182
- panel = PanelNode(classes=[SFT_PANEL], ids=[panel_id], role='tabpanel', **{'aria-labelledby': radio_id})
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, which orchestrates the entire component."""
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() # Unset the context flag.
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) # Remove the old title.
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
- 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
+
369
369
  def depart_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
370
- pass # No closing tag for <input>.
370
+ pass
371
371
 
372
372
  def visit_label_node(self: HTML5Translator, node: LabelNode) -> None:
373
373
  attrs = _get_html_attrs(node)
374
- 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
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
- 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
+
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
- 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
+
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', **_get_html_attrs(node)))
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 file to the build output directory.
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
- source_css = Path(__file__).parent / "static" / "filter_tabs.css"
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
- 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)
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, allowing users to theme from conf.py.
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 (hooks) to run custom functions at specific build stages.
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 -- Static Stylesheet */
2
- /* This file provides the structural and base theme styles for the component. */
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 { border: none; padding: 0; margin: 0; }
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 { 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
+ }
18
31
 
19
- /* The <input type="radio"> buttons are the functional core.
20
- They are hidden accessibly, not with display:none, so they remain
21
- focusable for keyboard users. */
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
- /* THEME: The font size is controlled by a CSS variable. */
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
- /* THEME: The active tab label is highlighted using a CSS variable for the color. */
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
- /* Add a clear, visible focus ring for keyboard navigation. */
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: 5px;
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 { padding: 20px; }
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
- .collapsible-section summary {
102
- 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
+ }
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.8.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,,