diff --git a/assets/css/index.css b/assets/css/index.css index 28ae78149a..01872d7ae1 100644 --- a/assets/css/index.css +++ b/assets/css/index.css @@ -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 px-6 pb-6 pt-3 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; diff --git a/layouts/partials/components/generic-tabs.html b/layouts/partials/components/generic-tabs.html new file mode 100644 index 0000000000..ae1dcd84a1 --- /dev/null +++ b/layouts/partials/components/generic-tabs.html @@ -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")) }} + +
+ +
+ {{ range $index, $tab := $tabs }} + {{ $tabId := printf "%s-tab-%d" $id $index }} + {{ $panelId := printf "%s-panel-%d" $id $index }} + + + + {{ end }} +
+ + +
+ {{ range $index, $tab := $tabs }} + {{ $tabId := printf "%s-tab-%d" $id $index }} + {{ $panelId := printf "%s-panel-%d" $id $index }} + +
+ {{ $tab.content | safeHTML }} +
+ {{ end }} +
+
diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html index caaee46967..243916f8e9 100644 --- a/layouts/partials/scripts.html +++ b/layouts/partials/scripts.html @@ -141,4 +141,7 @@ } } } - \ No newline at end of file + + + + \ No newline at end of file diff --git a/layouts/shortcodes/multitabs.html b/layouts/shortcodes/multitabs.html new file mode 100644 index 0000000000..8a9a1904bf --- /dev/null +++ b/layouts/shortcodes/multitabs.html @@ -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 */}} +
+
+ {{ .Inner | markdownify }} +
+
+{{ end }} diff --git a/static/js/generic-tabs.js b/static/js/generic-tabs.js new file mode 100644 index 0000000000..3d37d9b07c --- /dev/null +++ b/static/js/generic-tabs.js @@ -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;