Skip to content

feat: Add TabsExtension for tabbed content interfaces#130

Merged
dereuromark merged 2 commits intomasterfrom
feature/tabs-extension
Mar 26, 2026
Merged

feat: Add TabsExtension for tabbed content interfaces#130
dereuromark merged 2 commits intomasterfrom
feature/tabs-extension

Conversation

@dereuromark
Copy link
Copy Markdown
Contributor

Summary

Adds a new TabsExtension that transforms nested divs into accessible tabbed interfaces, supporting both CSS-only and ARIA-based rendering modes.

Features

  • Two output modes: css (default) and aria
  • Tab labels from headings or {label="..."} attribute
  • Selected tab via {selected} attribute
  • Fully configurable CSS classes
  • Keyboard navigation (ARIA mode)
  • No external dependencies

Syntax

:::: tabs
::: tab
### First Tab

Content for first tab.
:::

{label="Custom Label" selected}
::: tab
Content for second tab (selected by default).
:::
::::

Note: Use 4 colons for outer wrapper, 3 for inner tabs (standard djot nesting).

Usage

// CSS-only mode (default)
$converter->addExtension(new TabsExtension());

// ARIA mode (requires JavaScript)
$converter->addExtension(new TabsExtension(mode: 'aria'));

// Custom classes
$converter->addExtension(new TabsExtension(
    wrapperClass: 'my-tabs',
    tabClass: 'my-panel',
    labelClass: 'my-label',
));

Output Modes

CSS-only Mode

Uses radio inputs and CSS sibling selectors. No JavaScript required.

<div class="tabs">
  <input type="radio" name="tabset-1" id="tabset-1-tab-1" class="tabs-radio" checked>
  <label for="tabset-1-tab-1" class="tabs-label">First Tab</label>
  <input type="radio" name="tabset-1" id="tabset-1-tab-2" class="tabs-radio">
  <label for="tabset-1-tab-2" class="tabs-label">Second Tab</label>
  <div class="tabs-panel">Content 1</div>
  <div class="tabs-panel">Content 2</div>
</div>

Required CSS:

.tabs { display: flex; flex-wrap: wrap; }
.tabs-radio { display: none; }
.tabs-label { padding: 0.5rem 1rem; cursor: pointer; border-bottom: 2px solid transparent; }
.tabs-radio:checked + .tabs-label { border-bottom-color: currentColor; font-weight: bold; }
.tabs-panel { display: none; width: 100%; order: 1; padding: 1rem 0; }
.tabs-radio:nth-of-type(1):checked ~ .tabs-panel:nth-of-type(1),
.tabs-radio:nth-of-type(2):checked ~ .tabs-panel:nth-of-type(2),
.tabs-radio:nth-of-type(3):checked ~ .tabs-panel:nth-of-type(3),
.tabs-radio:nth-of-type(4):checked ~ .tabs-panel:nth-of-type(4),
.tabs-radio:nth-of-type(5):checked ~ .tabs-panel:nth-of-type(5) { display: block; }

ARIA Mode

Uses semantic ARIA roles. Requires JavaScript for interactivity.

<div class="tabs" role="tablist">
  <button role="tab" aria-selected="true" aria-controls="tabset-1-panel-1">First</button>
  <button role="tab" aria-selected="false" aria-controls="tabset-1-panel-2" tabindex="-1">Second</button>
  <div role="tabpanel" id="tabset-1-panel-1">Content 1</div>
  <div role="tabpanel" id="tabset-1-panel-2" hidden>Content 2</div>
</div>

Required JavaScript:

document.addEventListener('click', function(e) {
  const tab = e.target.closest('[role="tab"]');
  if (!tab) return;

  const tablist = tab.closest('[role="tablist"]');
  const tabs = tablist.querySelectorAll('[role="tab"]');
  const panels = tablist.querySelectorAll('[role="tabpanel"]');

  tabs.forEach(t => { t.setAttribute('aria-selected', 'false'); t.setAttribute('tabindex', '-1'); });
  panels.forEach(p => p.hidden = true);

  tab.setAttribute('aria-selected', 'true');
  tab.removeAttribute('tabindex');
  tablist.querySelector('#' + tab.getAttribute('aria-controls')).hidden = false;
});

// Keyboard navigation
document.addEventListener('keydown', function(e) {
  const tab = e.target.closest('[role="tab"]');
  if (!tab) return;
  const tabs = Array.from(tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]'));
  const index = tabs.indexOf(tab);
  let newIndex;
  if (e.key === 'ArrowRight') newIndex = (index + 1) % tabs.length;
  else if (e.key === 'ArrowLeft') newIndex = (index - 1 + tabs.length) % tabs.length;
  else return;
  e.preventDefault();
  tabs[newIndex].click();
  tabs[newIndex].focus();
});

Transforms nested divs (:::: tabs / ::: tab) into accessible tabbed interfaces
with two output modes:

- CSS-only mode (default): Uses radio inputs + CSS sibling selectors, no JS needed
- ARIA mode: Uses semantic tablist/tab/tabpanel roles, requires minimal JS

Features:
- Tab labels extracted from first heading or label attribute
- Configurable classes for wrapper, panels, labels
- Collects selected state from {selected} attribute
- Full keyboard navigation support (ARIA mode)
- Documented CSS and JavaScript requirements in docblock
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 26, 2026

Codecov Report

❌ Patch coverage is 97.70992% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.03%. Comparing base (41bdb80) to head (e75bf6d).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
src/Extension/TabsExtension.php 97.70% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #130      +/-   ##
============================================
+ Coverage     93.94%   94.03%   +0.08%     
- Complexity     2385     2443      +58     
============================================
  Files            81       83       +2     
  Lines          6310     6469     +159     
============================================
+ Hits           5928     6083     +155     
- Misses          382      386       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dereuromark dereuromark marked this pull request as ready for review March 26, 2026 16:18
@dereuromark dereuromark merged commit 9b09975 into master Mar 26, 2026
6 checks passed
@dereuromark dereuromark deleted the feature/tabs-extension branch March 26, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant