Navigation

ScrollTabs

A row of tabs that scrolls sideways when there are more than fit, with arrows at the ends and the keyboard.

#navigation#tabs#mobile#interactive

Demo

Preview

Sales for January (tab 1 of 12). The row scrolls: the arrows, a swipe, or the arrow keys.

Topic 3 of 8.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/scroll_tabs.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/scroll_tabs.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/scroll_tabs.qwe
let scroll_tabs_demo_months = ["January", "February", "March", "April", "May", "June", "July", "August", "September",                               "October", "November", "December"]component ScrollTabsDemoLive() {    state m = 0    <div style="width: min(420px, 100%)">        <ScrollTabs id="scroll-tabs-demo" label="Months" labels={scroll_tabs_demo_months} selected={m} on_change={(i) => { m = i }}>            <p.demo-note>Sales for {scroll_tabs_demo_months[m]} (tab {m + 1} of 12). The row scrolls: the arrows, a swipe, or the arrow keys.</p>        </ScrollTabs>    </div>}component ScrollTabsDemoPill() {    state t = 2    <div style="width: min(360px, 100%)">        <ScrollTabs id="scroll-tabs-demo-pill" variant="pill" label="Topics" selected={t} on_change={(i) => { t = i }}                    labels={["All", "Design", "Engineering", "Marketing", "Sales", "Support", "Legal", "Finance"]}>            <p.demo-note>Topic {t + 1} of 8.</p>        </ScrollTabs>    </div>}view ScrollTabsDemo() =    <div.demo-stack style="gap: 28px">        <ScrollTabsDemoLive/>        <ScrollTabsDemoPill/>    </div>

Source

ui/scroll_tabs.qwe
// ScrollTabs: a row of tabs that scrolls sideways when there are more than fit, with// an arrow at each end that appears when there is more that way. The selected tab// is scrolled into view, and the arrow keys move between tabs. As TabBar, the tabs// are real ARIA tabs driven by your component's state, with one panel: the children.////   <ScrollTabs id="months" labels={months} selected={m} on_change={(i) => { m = i }}>//       <Report month={m}/>//   </ScrollTabs>//// id (required): a prefix for the tabs' ids, unique on the page. labels: the tabs'// text. selected: the index showing first. children: the panel of the selected tab.// variant: "line" (default) or "pill". label: what screen readers call the row.// on_change(index): called with the tab picked.import ui/theme.qwe use ui_clslet scroll_tabs_css = css`.ui-scrolltabs { font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-scrolltabs-row { position: relative; display: flex; align-items: stretch; }.ui-scrolltabs-list {  display: flex; flex: 1; min-width: 0; margin: 0; padding: 0; list-style: none; overflow-x: auto; scrollbar-width: none;  scroll-behavior: smooth; scroll-padding-inline: 36px;}.ui-scrolltabs-list::-webkit-scrollbar { display: none; }.ui-scrolltabs-tab {  flex: none; display: inline-flex; align-items: center; height: 38px; padding: 0 14px; border: 0; background: none;  color: var(--ui-muted); font: 500 14px/1 var(--ui-font); white-space: nowrap; cursor: pointer;  transition: color .15s, background .15s, box-shadow .15s;}.ui-scrolltabs-tab:hover { color: var(--ui-fg); }.ui-scrolltabs-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-scrolltabs-line .ui-scrolltabs-list { border-bottom: 1px solid var(--ui-border); }.ui-scrolltabs-line .ui-scrolltabs-tab { border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0; margin-bottom: -1px; border-bottom: 1px solid var(--ui-border); }.ui-scrolltabs-line .ui-scrolltabs-tab[aria-selected="true"] { color: var(--ui-fg); box-shadow: inset 0 -2px 0 var(--ui-accent); }.ui-scrolltabs-line .ui-scrolltabs-tab[aria-selected="true"]:focus-visible { box-shadow: inset 0 -2px 0 var(--ui-accent), var(--ui-focus); }.ui-scrolltabs-pill .ui-scrolltabs-list { gap: 4px; }.ui-scrolltabs-pill .ui-scrolltabs-tab { height: 32px; border-radius: 999px; }.ui-scrolltabs-pill .ui-scrolltabs-tab:hover { background: var(--ui-subtle); }.ui-scrolltabs-pill .ui-scrolltabs-tab[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-scrolltabs-arrow {  position: absolute; top: 0; bottom: 0; z-index: 1; display: none; align-items: center; justify-content: center; width: 36px; padding: 0;  border: 0; background: var(--ui-bg); color: var(--ui-muted); cursor: pointer;}.ui-scrolltabs-arrow svg { width: 16px; height: 16px; }.ui-scrolltabs-arrow:hover { color: var(--ui-fg); }.ui-scrolltabs-arrow:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-scrolltabs-left { left: 0; box-shadow: 8px 0 8px -6px rgba(0, 0, 0, .12); }.ui-scrolltabs-right { right: 0; box-shadow: -8px 0 8px -6px rgba(0, 0, 0, .12); }.ui-scrolltabs-line .ui-scrolltabs-arrow { bottom: 1px; }.ui-scrolltabs-more-left .ui-scrolltabs-left, .ui-scrolltabs-more-right .ui-scrolltabs-right { display: inline-flex; }.ui-scrolltabs-panel { padding-top: 16px; }.ui-scrolltabs-panel > :first-child { margin-top: 0; }.ui-scrolltabs-panel > :last-child { margin-bottom: 0; }@media (prefers-reduced-motion: reduce) { .ui-scrolltabs-tab { transition: none; } .ui-scrolltabs-list { scroll-behavior: auto; } }`component ScrollTabs(id: str, labels: list = [], selected: int = 0, variant: str = "line", label: str? = null,                     on_change = null, class: str? = null, children) {    style global scroll_tabs_css    state at = selected    state row = null    state more_left = false    state more_right = false    let check = (_e = null) => {        if row != null {            more_left = row.scrollLeft > 1            more_right = row.scrollLeft + row.clientWidth < row.scrollWidth - 1        }    }    let reveal = (i: int) => {        let tab = row.querySelector(`[data-i="${i}"]`)        tab && tab.scrollIntoView({ block: "nearest", inline: "nearest" })    }    let pick = (i: int) => {        at = i        reveal(i)        on_change && on_change(i)    }    let by = (dir: int) => { row.scrollBy({ left: dir * row.clientWidth * 0.6 }) }    <div class={ui_cls(["ui-scrolltabs", `ui-scrolltabs-${variant}`, more_left && "ui-scrolltabs-more-left", more_right && "ui-scrolltabs-more-right", class])}>        <div.ui-scrolltabs-row>            <button.ui-scrolltabs-arrow.ui-scrolltabs-left type="button" tabindex="-1" aria-hidden="true" @click={() => { by(-1) }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 6-6 6 6 6"/></svg>            </button>            <div.ui-scrolltabs-list role="tablist" aria-label={label}                 @mount={(el) => {                     row = el                     check()                     reveal(at)                     let ro = js_new("ResizeObserver", check)                     ro.observe(el)                     () => ro.disconnect()                 }}                 @scroll={check}                 @keydown={(e) => {                     let n = len(labels)                     let to = match e.key {                         "ArrowRight" => (at + 1) % n                         "ArrowLeft" => (at + n - 1) % n                         "Home" => 0                         "End" => n - 1                         _ => null                     }                     if to != null {                         e.preventDefault()                         pick(to)                         row.querySelector(`[data-i="${to}"]`).focus({ preventScroll: true })                     }                 }}>                {for [i, t] in enumerate(labels) => <button.ui-scrolltabs-tab type="button" role="tab" id={`${id}-tab-${i}`} data-i={i}                        aria-selected={i == at ? "true" : "false"} aria-controls={`${id}-panel`}                        tabindex={i == at ? "0" : "-1"} @click={() => { pick(i) }}>{t}</button>}            </div>            <button.ui-scrolltabs-arrow.ui-scrolltabs-right type="button" tabindex="-1" aria-hidden="true" @click={() => { by(1) }}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>            </button>        </div>        <div.ui-scrolltabs-panel id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${at}`} tabindex="0">{children}</div>    </div>}
Thread
58Useful? Like it, or add to its thread below.

Thread 0 posts

On ScrollTabs: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.