Skip to content

DEV: add generic-ish tab control #1651

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions assets/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,58 @@ input[type="radio"] {
appearance: none;
}

/* Generic GitHub-style tabs */
.generic-tabs {
@apply w-full mb-6;
}

.generic-tabs .tab-nav {
@apply flex border-b border-redis-pen-300 bg-redis-neutral-200 rounded-t-md;
}

.generic-tabs .tab-radio {
@apply sr-only;
}

.generic-tabs .tab-label {
@apply px-4 py-3 cursor-pointer text-sm font-medium text-redis-pen-600
bg-redis-neutral-200 border-r border-redis-pen-300
hover:bg-white hover:text-redis-ink-900
transition-colors duration-150 ease-in-out
focus:outline-none focus:ring-2 focus:ring-redis-red-500 focus:ring-inset
first:rounded-tl-md select-none;
}

.generic-tabs .tab-label:last-child {
@apply border-r-0 rounded-tr-md;
}

.generic-tabs .tab-radio:checked + .tab-label {
@apply bg-white text-redis-ink-900 border-b-2 border-b-redis-red-500 -mb-px relative z-10;
}

.generic-tabs .tab-radio:focus + .tab-label {
@apply border-b-2 border-b-redis-red-500 -mb-px;
}

.generic-tabs .tab-content {
@apply hidden p-6 bg-white border border-t-0 border-redis-pen-300 rounded-b-md shadow-sm;
}

.generic-tabs .tab-content.active {
@apply block;
}

/* Ensure proper stacking and borders */
.generic-tabs .tab-content-container {
@apply relative -mt-px;
}

/* Single content box styling (when no explicit tabs are provided) */
.generic-tabs-single .tab-content-single {
@apply prose prose-lg max-w-none;
}

.stack-logo-inline {
display: inline;
max-height: 1em;
Expand Down
63 changes: 63 additions & 0 deletions content/develop/multitabs-demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: "Multi-Tabs Test"
description: "Testing the simpler multi-tab syntax"
weight: 995
---

# Multi-Tabs Shortcode Test

This page tests a simpler approach to multi-tab syntax that works reliably with Hugo.

## Multi-Tab Example

{{< multitabs id="example-tabs" tab1="Getting Started" tab2="Features" tab3="Usage Guide" >}}
Welcome to the **Getting Started** tab! This demonstrates the simpler multi-tab syntax.

### Quick Setup
1. Include the tab component files
2. Use the `multitabs` shortcode with tab parameters
3. Separate content with a divider: `- tab - sep -

This approach avoids Hugo's nested shortcode parsing issues while still providing clean multi-tab functionality.

-tab-sep-

## Key Features

The tab control includes:

- **GitHub-style design**: Clean, professional appearance
- **Accessibility**: Full keyboard navigation and ARIA support
- **Responsive**: Works on all screen sizes
- **Markdown support**: Full markdown rendering within tabs
- **Simple syntax**: Uses parameter-based tab titles and content separators

### Code Example
```javascript
// Example of tab initialization
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.generic-tabs');
tabs.forEach(tab => new GenericTabs(tab));
});
```

-tab-sep-

## How to Use
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing here doesn't look great. Does removing the blank line between the heading and the -tab-sep- line fix it, or is this just a hazard of heading padding?
Screenshot 2025-06-03 at 11 33 35

Copy link
Collaborator Author

@dwdougherty dwdougherty Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @cmilesb! I appreciate the review.

I'm pretty sure the spacing issue is caused by heading padding. I removed the blank lines before and after -tab-sep- and the spacing stayed the same. Maybe don't use any headings in the tabs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was some extra padding being added in the CSS. I fixed that, but headings still cause a lot of extra top whitespace. I can't really do anything about that without really messing up our site.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, thanks for taking a look anyway.


The multi-tab syntax uses parameters for tab titles and separates content with triple dashes.

**Syntax structure:**
1. Define tab titles as parameters: `tab1="Title 1" tab2="Title 2"`
2. Separate content sections with `---` on its own line
3. Each section becomes the content for the corresponding tab

### Benefits
- **Reliable parsing**: No nested shortcode issues
- **Clean syntax**: Easy to read and write
- **Flexible content**: Any markdown content works
- **Maintainable**: Clear separation between tabs
- **Accessible**: Proper semantic structure

Perfect for organizing documentation, tutorials, and reference materials!
{{< /multitabs >}}
64 changes: 64 additions & 0 deletions layouts/partials/components/generic-tabs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{{/*
Generic GitHub-style tabs component

Usage:
{{ partial "components/generic-tabs.html" (dict "id" "my-tabs" "tabs" $tabs) }}

Where $tabs is an array of dictionaries with "title" and "content" keys:
$tabs := slice
(dict "title" "Tab 1" "content" "Content for tab 1")
(dict "title" "Tab 2" "content" "Content for tab 2")
*/}}

{{ $id := .id | default (printf "tabs-%s" (substr (.tabs | jsonify | md5) 0 8)) }}
{{ $tabs := .tabs | default (slice (dict "title" "Error" "content" "No tabs provided")) }}

<div class="generic-tabs" id="{{ $id }}">
<!-- Tab Navigation -->
<div class="tab-nav" role="tablist" aria-label="Tab navigation">
{{ range $index, $tab := $tabs }}
{{ $tabId := printf "%s-tab-%d" $id $index }}
{{ $panelId := printf "%s-panel-%d" $id $index }}

<input
type="radio"
name="{{ $id }}"
id="{{ $tabId }}"
class="tab-radio"
{{ if eq $index 0 }}checked{{ end }}
aria-controls="{{ $panelId }}"
data-tab-index="{{ $index }}"
/>
<label
for="{{ $tabId }}"
class="tab-label"
role="tab"
aria-selected="{{ if eq $index 0 }}true{{ else }}false{{ end }}"
aria-controls="{{ $panelId }}"
tabindex="{{ if eq $index 0 }}0{{ else }}-1{{ end }}"
>
{{ $tab.title }}
</label>
{{ end }}
</div>

<!-- Tab Content -->
<div class="tab-content-container">
{{ range $index, $tab := $tabs }}
{{ $tabId := printf "%s-tab-%d" $id $index }}
{{ $panelId := printf "%s-panel-%d" $id $index }}

<div
id="{{ $panelId }}"
class="tab-content {{ if eq $index 0 }}active{{ end }}"
role="tabpanel"
aria-labelledby="{{ $tabId }}"
tabindex="0"
data-tab-index="{{ $index }}"
{{ if ne $index 0 }}aria-hidden="true"{{ end }}
>
{{ $tab.content | safeHTML }}
</div>
{{ end }}
</div>
</div>
5 changes: 4 additions & 1 deletion layouts/partials/scripts.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,7 @@
}
}
}
</script>
</script>

<!-- Generic tabs functionality -->
<script src="{{ "js/generic-tabs.js" | relURL }}"></script>
63 changes: 63 additions & 0 deletions layouts/shortcodes/multitabs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{{/*
Multi-tabs shortcode with simpler syntax

Usage:
{{< multitabs id="my-tabs"
tab1="Tab Title 1"
tab2="Tab Title 2"
tab3="Tab Title 3" >}}

Content for tab 1

-tab-sep-

Content for tab 2

-tab-sep-

Content for tab 3
{{< /multitabs >}}
*/}}

{{ $id := .Get "id" | default (printf "tabs-%s" (substr (.Inner | md5) 0 8)) }}
{{ $tabs := slice }}

{{/* Split content by -tab-sep- separator */}}
{{ $sections := split .Inner "-tab-sep-" }}

{{/* Get tab titles from parameters */}}
{{ $tabTitles := slice }}
{{ range $i := seq 1 10 }}
{{ $tabParam := printf "tab%d" $i }}
{{ $title := $.Get $tabParam }}
{{ if $title }}
{{ $tabTitles = $tabTitles | append $title }}
{{ end }}
{{ end }}

{{/* Create tabs from sections and titles */}}
{{ range $index, $section := $sections }}
{{ $title := "Tab" }}
{{ if lt $index (len $tabTitles) }}
{{ $title = index $tabTitles $index }}
{{ else }}
{{ $title = printf "Tab %d" (add $index 1) }}
{{ end }}

{{ $content := $section | strings.TrimSpace | markdownify }}
{{ if ne $content "" }}
{{ $tabs = $tabs | append (dict "title" $title "content" $content) }}
{{ end }}
{{ end }}

{{/* Render tabs if we have any */}}
{{ if gt (len $tabs) 0 }}
{{ partial "components/generic-tabs.html" (dict "id" $id "tabs" $tabs) }}
{{ else }}
{{/* Fallback to single content box */}}
<div class="generic-tabs-single mb-6">
<div class="tab-content-single p-6 bg-white border border-redis-pen-300 rounded-md shadow-sm">
{{ .Inner | markdownify }}
</div>
</div>
{{ end }}
108 changes: 108 additions & 0 deletions static/js/generic-tabs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Generic GitHub-style tabs functionality
* Handles tab switching, keyboard navigation, and accessibility
*/

class GenericTabs {
constructor(container) {
this.container = container;
this.tabRadios = container.querySelectorAll('.tab-radio');
this.tabLabels = container.querySelectorAll('.tab-label');
this.tabPanels = container.querySelectorAll('.tab-content');

this.init();
}

init() {
// Add event listeners for radio button changes
this.tabRadios.forEach((radio, index) => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
this.switchToTab(index);
}
});
});

// Add keyboard navigation for tab labels
this.tabLabels.forEach((label, index) => {
label.addEventListener('keydown', (e) => {
this.handleKeydown(e, index);
});
});

// Set initial state
const checkedRadio = this.container.querySelector('.tab-radio:checked');
if (checkedRadio) {
const index = parseInt(checkedRadio.dataset.tabIndex);
this.switchToTab(index);
}
}

switchToTab(index) {
// Update radio buttons
this.tabRadios.forEach((radio, i) => {
radio.checked = i === index;
});

// Update tab labels
this.tabLabels.forEach((label, i) => {
const isSelected = i === index;
label.setAttribute('aria-selected', isSelected);
label.setAttribute('tabindex', isSelected ? '0' : '-1');
});

// Update tab panels
this.tabPanels.forEach((panel, i) => {
const isActive = i === index;
panel.classList.toggle('active', isActive);
panel.setAttribute('aria-hidden', !isActive);
});
}

handleKeydown(event, currentIndex) {
let newIndex = currentIndex;

switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
newIndex = currentIndex > 0 ? currentIndex - 1 : this.tabLabels.length - 1;
break;
case 'ArrowRight':
event.preventDefault();
newIndex = currentIndex < this.tabLabels.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
event.preventDefault();
newIndex = 0;
break;
case 'End':
event.preventDefault();
newIndex = this.tabLabels.length - 1;
break;
case 'Enter':
case ' ':
event.preventDefault();
this.tabRadios[currentIndex].checked = true;
this.switchToTab(currentIndex);
return;
default:
return;
}

// Focus and activate the new tab
this.tabLabels[newIndex].focus();
this.tabRadios[newIndex].checked = true;
this.switchToTab(newIndex);
}
}

// Initialize all generic tabs on page load
document.addEventListener('DOMContentLoaded', () => {
const tabContainers = document.querySelectorAll('.generic-tabs');
tabContainers.forEach(container => {
new GenericTabs(container);
});
});

// Export for potential external use
window.GenericTabs = GenericTabs;