<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>"
    }
  ]
}
  • Content:
    <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>
    
  • URL: /components/raw/tabs/Tabs.vue
  • Filesystem Path: src/components/tabs/Tabs.vue
  • Size: 4.5 KB
  • Content:
    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);
    });
    
  • URL: /components/raw/tabs/tabs.js
  • Filesystem Path: src/components/tabs/tabs.js
  • Size: 591 Bytes

Tabs

Props

heading-id

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>

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.

theme

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.

Tab Panel Content

Content can either be passed as a child element (if through html), a slot (if through Vue) or as a data prop.

Child Element

<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>

Slot

<Tabs heading-id="h" :tabs="[]">
  <template v-slot:tabPanel-0></template>
  <template v-slot:tabPanel-1></template>
</Tabs>

Data Prop

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>"
  }
]

Accessibility

Implements the Tabs pattern outlined by W3C’s ARIA Authoring Guide.