ScrollTabs
A row of tabs that scrolls sideways when there are more than fit, with arrows at the ends and the keyboard.
Demo
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.qwemain.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>}Related
BottomNavA fixed bar of icon tabs along the bottom of a phone's screen, with counts and the current one marked.6 views · 7 downloadsFilterTabsTabs with counts that filter a list, one selected, moved between with the arrow keys; a hidden input holds the key.5 views · 6 downloadsIconTabsTabs with an icon above each label and a count on its corner, the arrow keys moving between them.5 views · 6 downloadsMobileNavA menu button that opens the site's navigation over the whole screen, as a dialog: groups, the current page, actions at the bottom.5 views · 6 downloadsSubnavA second row of links under the main navigation, for one section's pages, with counts and the current one marked.5 views · 5 downloadsTabsPanels behind a row of tabs, from radio buttons and CSS: no JavaScript, any HTML from the server.5 views · 7 downloads
Thread
Thread 0 posts
On ScrollTabs: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.