<h2 id="fish-tabs-heading">Fish</h2>
<div class="js-tabs" data-heading-id='fish-tabs-heading' data-tabs='[{"label":"Estuarine Fish","content":"<p>Estuarine fish are those that live in <a href=\"#\">estuaries</a>, the semi-enclosed areas where freshwater rivers meet the ocean and mix, and they are important for coastal fisheries and nurseries. These fish are adapted to a wide range of salinities and can be found in various parts of the estuary, including freshwater, saltwater, and brackish areas.</p>","image":{"src":"https://placehold.co/538x358","altText":"A placeholder image","class":["foo","bar"]}},{"label":"Freshwater Fish","content":"<p>Freshwater fish are fish species that spend some or all of their lives in bodies of fresh water such as rivers, lakes, ponds and inland wetlands, where the salinity is less than 1.05%. These environments differ from marine habitats in many ways, especially the difference in levels of osmolarity.</p>"},{"label":"Migratory Fish","content":"<p>Migratory fish, also called diadromous fish, are those that migrate between salt water, brackish water, and freshwater as part of their life cycle, including anadromous and catadromous species.</p>"}]' data-theme='buttons'>
<h3>Estuarine Fish</h3>
<div>
<p>Estuarine fish are those that live in <a href="#">estuaries</a>, the semi-enclosed areas where freshwater rivers meet the ocean and mix, and they are important for coastal fisheries and nurseries. These fish are adapted to a wide range of salinities and can be found in various parts of the estuary, including freshwater, saltwater, and brackish areas.</p>
</div>
<h3>Freshwater Fish</h3>
<div>
<p>Freshwater fish are fish species that spend some or all of their lives in bodies of fresh water such as rivers, lakes, ponds and inland wetlands, where the salinity is less than 1.05%. These environments differ from marine habitats in many ways, especially the difference in levels of osmolarity.</p>
</div>
<h3>Migratory Fish</h3>
<div>
<p>Migratory fish, also called diadromous fish, are those that migrate between salt water, brackish water, and freshwater as part of their life cycle, including anadromous and catadromous species.</p>
</div>
</div>
<link media="all" rel="stylesheet" href="/tabs/tabs.css" />
<script src="/tabs/tabs.js"></script>
<h2 id="{{ headingId }}">{{ heading }}</h2>
<div class="js-tabs"
data-heading-id='{{ headingId }}'
data-tabs='{{ tabs|json_encode() }}'
data-theme='{{ theme }}'
>
{# Placeholder/fallback content while the Vue app renders #}
{% for tab in tabs %}
<h3>{{ tab.label }}</h3>
<div>{{ tab.content|raw }}</div>
{% endfor %}
</div>
{% import "_macros.twig" as h %}
{{ h.componentAssets('tabs') }}
{
"heading": "Fish",
"headingId": "fish-tabs-heading",
"theme": "buttons",
"tabs": [
{
"label": "Estuarine Fish",
"content": "<p>Estuarine fish are those that live in <a href=\"#\">estuaries</a>, the semi-enclosed areas where freshwater rivers meet the ocean and mix, and they are important for coastal fisheries and nurseries. These fish are adapted to a wide range of salinities and can be found in various parts of the estuary, including freshwater, saltwater, and brackish areas.</p>",
"image": {
"src": "https://placehold.co/538x358",
"altText": "A placeholder image",
"class": [
"foo",
"bar"
]
}
},
{
"label": "Freshwater Fish",
"content": "<p>Freshwater fish are fish species that spend some or all of their lives in bodies of fresh water such as rivers, lakes, ponds and inland wetlands, where the salinity is less than 1.05%. These environments differ from marine habitats in many ways, especially the difference in levels of osmolarity.</p>"
},
{
"label": "Migratory Fish",
"content": "<p>Migratory fish, also called diadromous fish, are those that migrate between salt water, brackish water, and freshwater as part of their life cycle, including anadromous and catadromous species.</p>"
}
]
}
<script setup>
//modeled on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-automatic/
import { onMounted, ref, useId, useSlots } from "vue";
import XScroll from "../_x-scroll/x-scroll-util";
const props = defineProps({
headingId: {
type: String,
required: true,
},
tabs: {
type: Array,
required: true,
},
theme: {
type: String,
default: "default", //default | buttons
},
});
const activeTab = ref(0);
const idModifier = useId();
const slots = useSlots();
//Lifecycle
onMounted(() => {
XScroll();
});
//Methods
function idify(id) {
return `${idModifier}__${id}`;
}
function setActiveTab(index) {
if (index >= props.tabs.length) index = 0;
if (index < 0) index = props.tabs.length - 1;
activeTab.value = index;
//set button focus
const button = document.getElementById(idify("tab-" + index));
button.focus();
}
</script>
<template>
<div class="tabs">
<div :class="{ xScroll: props.theme === 'buttons' }">
<div
role="tablist"
class="tabs__tabList"
:class="[
props.theme === 'buttons'
? 'tabs__tabList--buttons xScroll__items'
: 'tabs__tabList--tabs',
]"
:aria-labelledby="headingId"
>
<button
v-for="(tab, index) in tabs"
:id="idify('tab-' + index)"
class="tabs__tab"
:class="[
{ 'tabs__tab--active': index === activeTab },
props.theme === 'buttons'
? 'tabs__tab--buttons'
: 'tabs__tab--tabs',
]"
v-on:click="activeTab = index"
v-on:keydown.right="setActiveTab(index + 1)"
v-on:keydown.left="setActiveTab(index - 1)"
v-on:keydown.home="setActiveTab(0)"
v-on:keydown.end="setActiveTab(tabs.length - 1)"
type="button"
role="tab"
:tabindex="index === activeTab ? null : -1"
:aria-selected="index === activeTab"
:aria-controls="idify('tabpanel-' + index)"
>
{{ tab.label }}
</button>
</div>
</div>
<div
v-for="(tab, index) in tabs"
:id="idify('tabpanel-' + index)"
class="tabs__tabPanel"
:class="{
'tabs__tabPanel--closed': index !== activeTab,
}"
role="tabpanel"
tabindex="0"
:aria-labelledby="idify('tab-' + index)"
>
<div class="tabs__tabPanelContent">
<slot
:name="'tabPanel-' + index"
v-if="'tabPanel-' + index in slots"
></slot>
<div v-else v-html="tab.content"></div>
</div>
<figure class="tabs__tabPanelFigure" v-if="tab.image">
<img
:src="tab.image.src"
:width="tab.image.width"
:height="tab.image.height"
:srcset="tab.image.srcset"
class="tabs__tabPanelImg"
:class="tab.image.class"
:alt="tab.image.altText"
/>
</figure>
</div>
</div>
</template>
<style lang="scss">
@use "../_layout/mixins";
.tabs__tabPanelContent {
a:not([class]) {
@include mixins.plainLinkInverse();
}
}
</style>
<style scoped lang="scss">
@use "../_layout/mixins";
@use "../_x-scroll/x-scroll.scss";
.tabs__tabList--buttons {
display: flex;
gap: 0.625em;
margin-block-end: 1em;
}
.tabs__tab {
@include mixins.blockFocus();
}
.tabs__tab--tabs {
padding: 0.8em 1.3em;
font-weight: var(--bold-weight);
color: var(--blue--ultraDark);
border: 2px solid var(--blue--ultraDark);
background-color: var(--white);
& + & {
border-inline-start: 0;
}
&:hover,
&:focus {
&:not(.tabs__tab--active) {
background-color: var(--accent);
cursor: pointer;
}
}
&.tabs__tab--active {
color: var(--accent);
background-color: var(--blue--ultraDark);
}
}
.tabs__tab--buttons {
@include mixins.buttonSmall();
@include mixins.button();
@include mixins.buttonSecondary();
&.tabs__tab--active {
background-color: var(--accent);
}
}
.tabs__tabPanel {
display: flex;
flex-wrap: wrap;
@include mixins.blockFocus();
}
.tabs__tabPanel--closed {
display: none;
}
.tabs__tabPanelContent {
padding: 1em;
background-color: var(--blue--ultraDark);
color: var(--white);
flex: 1 1 var(--tabPanelContentFlex, 50%);
}
.tabs__tabPanelFigure {
flex: var(--tabPanelFigureFlex, 25%);
order: -1;
}
.tabs__tabPanelImg {
order: -1;
flex: 1;
min-width: var(--tabPanelImgMinWidth, 300px);
width: 100%;
height: 100%;
align-self: start;
object-fit: cover;
}
</style>
import { createApp, h } from "vue";
import Tabs from "./Tabs.vue";
const els = document.querySelectorAll(".js-tabs");
els.forEach((el) => {
//grab slots
const children = {};
const slots = el.querySelectorAll("[data-slot]");
slots.forEach((slot) => {
children[slot.dataset.slot] = () =>
h(slot.tagName.toLowerCase(), { innerHTML: slot.innerHTML });
});
const a = h(
Tabs,
{
headingId: el.dataset.headingId,
tabs: JSON.parse(el.dataset.tabs),
theme: el.dataset.theme,
},
children
);
const app = createApp(a);
app.mount(el);
});
type: String
required: true
The id of the element that will serve as the label for the tab list.
<h2 id="meetings-heading">Meetings</h2>
<Tabs heading-id="meetings-heading" :tabs="[]"></Tabs>type: Object[]
required: true
Pass the tabs data as an array of objects:
[
{
"label": "",
"content": ""
},
{
"label": "",
"content": "",
"image": {}
}
]The content key is optional, see below for various ways to pass tab panel content html.
type: String
required: false
options: default|buttons
default: default
The floating theme styles the tab buttons as visual buttons, rather than a traditional folder tab.
Content can either be passed as a child element (if through html), a slot (if through Vue) or as a data prop.
<div class="js-tabs">
<div data-slot="tabPanel-0"><p>Some content for the first tab</p></div>
<div data-slot="tabPanel-1"><h3>Second tab</h3></div>
</div><Tabs heading-id="h" :tabs="[]">
<template v-slot:tabPanel-0></template>
<template v-slot:tabPanel-1></template>
</Tabs>Include a content key in the tabs. This data option also allows you to pass in an image which will be anchored to the left. Set the breakpoint through --tabPanelFigureFlex custom property.
[
{
"label": "Tab 1",
"content": "<p>Some content for the first tab</p>",
"image": {
"src": "",
"width": "",
"height": "",
"srcset": "",
"altText": "",
"class": []
}
},
{
"label": "Tab 2",
"content": "<p>Second tab</p>"
}
]Implements the Tabs pattern outlined by W3C’s ARIA Authoring Guide.