Feature 321 Content

Tab Panel แท็บ

ARIA tabs with arrow-key navigation; each tab's content is loaded from the server only when first opened.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Each tab asks GET /api/categories/{name}/summary the first time it opens. Requests so far: 0

Keyboard: focus a tab, then ← → Home End. The open tab is in the URL hash.

What to notice

  • role=tablist / tab / tabpanel with aria-selected and roving tabindex.
  • Left / Right / Home / End move between tabs.
  • The selected tab is kept in the URL hash, so reload and Back work.

Try it

Source code

6 parts, read from the files in this project
src/main/java/com/study/uiux/web/ProductApi.java Tab content is fetched the first time a tab opens
@GetMapping("/categories/{category}/summary")
public Map<String, Object> categorySummary(@PathVariable String category,
                                           @RequestParam(defaultValue = "0") int delay) throws InterruptedException {
    DemoApi.pause(delay);
    List<Product> items = products.findByCategoryOrderByName(category);
    var stats = products.categoryStats().stream().filter(s -> s.getCategory().equalsIgnoreCase(category)).findFirst();
    Map<String, Object> body = new LinkedHashMap<>();
    body.put("category", category);
    body.put("items", stats.map(ProductRepository.CategoryStats::getItems).orElse(0L));
    body.put("stock", stats.map(ProductRepository.CategoryStats::getStock).orElse(0L));
    body.put("avgPrice", stats.map(s -> String.format(Locale.ROOT, "%.2f", s.getAvgPrice())).orElse("0.00"));
    body.put("avgRating", stats.map(s -> String.format(Locale.ROOT, "%.1f", s.getAvgRating())).orElse("0.0"));
    body.put("top", items.stream().sorted((a, b) -> b.getRating().compareTo(a.getRating())).limit(5).map(ProductApi::view).toList());
    return body;
}
src/main/java/com/study/uiux/repo/ProductRepository.java GROUP BY query: one row of numbers per category tab
@Query("""
        select p.category as category, count(p) as items, sum(p.stock) as stock,
               avg(p.price) as avgPrice, avg(p.rating) as avgRating
        from Product p group by p.category order by p.category""")
List<CategoryStats> categoryStats();

interface CategoryStats {
    String getCategory();
    long getItems();
    long getStock();
    Double getAvgPrice();
    Double getAvgRating();
}
src/main/resources/templates/demos/l321.html ARIA tab markup: tablist, tab (aria-controls) and tabpanel (aria-labelledby)
<div class="tabs" data-tabs data-tabs-hash data-tabs-manual id="catTabs">
  <div class="tab-list" role="tablist" aria-label="Product categories">
    <button th:each="c, s : ${categories}" type="button" role="tab" class="tab"
            th:id="'tab-' + ${#strings.toLowerCase(c)}" th:attr="aria-controls='panel-' + ${#strings.toLowerCase(c)},data-category=${c}"
            th:text="${c}">Audio</button>
  </div>
  <div th:each="c : ${categories}" class="tab-panel" role="tabpanel" tabindex="0"
       th:id="'panel-' + ${#strings.toLowerCase(c)}" th:attr="aria-labelledby='tab-' + ${#strings.toLowerCase(c)}" hidden>
    <div class="tab-content" data-empty="1"></div>
  </div>
</div>
src/main/resources/static/css/app.css Tabs: an underline under the selected tab
.tabs { min-width: 0; }
.tab-list { display: flex; gap: .25rem; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: thin; }
.tab {
  display: inline-flex; align-items: center; gap: .45rem; padding: .6rem .9rem; border: 0; background: none; font: inherit; font-size: .86rem; font-weight: 600;
  color: var(--text-3); cursor: pointer; white-space: nowrap; position: relative; border-radius: .5rem .5rem 0 0;
}
.tab:hover { color: var(--text); background: var(--surface-2); }
.tab[aria-selected="true"] { color: var(--primary-text); }
.tab[aria-selected="true"]::after { content: ""; position: absolute; left: .5rem; right: .5rem; bottom: -1px; height: 2.5px; border-radius: 2px; background: var(--primary); }
.tab-panel { padding-top: 1rem; outline: none; }
.tab-panel:focus-visible { box-shadow: inset 0 0 0 2px var(--ring); border-radius: .5rem; }
.tab-badge { font-size: .68rem; background: var(--surface-2); color: var(--text-3); padding: 0 .4rem; border-radius: 999px; }
src/main/resources/static/js/core.js Roving tabindex + arrow keys + optional URL hash
function initTabs(container, { onShow } = {}) {
  const tabs = $$('[role="tab"]', container);
  const useHash = container.hasAttribute('data-tabs-hash');
  function select(tab, focus = false) {
    tabs.forEach((t) => {
      const on = t === tab;
      t.setAttribute('aria-selected', String(on));
      t.tabIndex = on ? 0 : -1;
      const panel = document.getElementById(t.getAttribute('aria-controls'));
      if (panel) panel.hidden = !on;
    });
    if (focus) tab.focus();
    if (useHash) history.replaceState(null, '', '#' + tab.id);
    onShow?.(tab, document.getElementById(tab.getAttribute('aria-controls')));
  }
  tabs.forEach((tab) => {
    tab.addEventListener('click', () => select(tab));
    tab.addEventListener('keydown', (e) => {
      const i = tabs.indexOf(tab);
      const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
      if (next === undefined) return;
      e.preventDefault();
      select(tabs[(next + tabs.length) % tabs.length], true);
    });
  });
  const fromHash = useHash && location.hash && tabs.find((t) => '#' + t.id === location.hash);
  select(fromHash || tabs.find((t) => t.getAttribute('aria-selected') === 'true') || tabs[0]);
  return { select };
}
src/main/resources/static/js/demos.js Load a tab's content only the first time it is shown
demos[321] = () => {
  let requests = 0;
  UI.initTabs($('#catTabs'), {
    onShow: async (tab, panel) => {
      const box = $('.tab-content', panel);
      if (!box.dataset.empty) return; // already loaded: no new request
      delete box.dataset.empty;
      box.innerHTML = `<div class="grid-3">${'<div class="stat"><div class="skeleton sk-line w-40" style="height:1.4rem"></div><div class="skeleton sk-line w-60"></div></div>'.repeat(4)}</div>`;
      const d = await api(`/api/categories/${encodeURIComponent(tab.dataset.category)}/summary?delay=500`);
      $('#tabRequests').textContent = ++requests;
      box.innerHTML = `
        <div class="grid-3">
          <div class="stat"><b>${d.items}</b><span>products</span></div>
          <div class="stat"><b>${d.stock}</b><span>items in stock</span></div>
          <div class="stat"><b>$${d.avgPrice}</b><span>average price</span></div>
          <div class="stat"><b>★ ${d.avgRating}</b><span>average rating</span></div>
        </div>
        <h4 style="margin:1.25rem 0 .6rem">Top rated in ${esc(d.category)}</h4>
        <div class="product-grid">${d.top.map(productCard).join('')}</div>
        <p class="muted small" style="margin-top:.75rem">Loaded at ${new Date().toLocaleTimeString()} - switch away and back: no new request.</p>`;
    }
  });
};

Are you sure?

Rename

↑↓ moveEnter openEsc close