SwipeTabs
Pages swiped sideways in step with their tab strip, the line under the tabs tracking the finger, pages built when reached; tapping and keys do the same.
Demo
top story 1
top story 2
top story 3
top story 4
top story 5
top story 6
top story 7
top story 8
top story 9
top story 10
top story 11
top story 12
top story 13
top story 14
top story 15
top story 16
top story 17
top story 18
top story 19
top story 20
top story 21
top story 22
top story 23
top story 24
top story 25
top story 26
top story 27
top story 28
top story 29
Showing top, after 0 changes
Starting on the third tab, with a tab given twice and a bad row dropped:
business page
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/swipe_tabs.qweimport ui/theme.qwe use theme_cssuse ui/swipe_tabs.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let swipe_tabs_demo_tabs = [{ id: "top", label: "Top" }, { id: "local", label: "Local" }, { id: "business", label: "Business" }, { id: "sport", label: "Sport" }, { id: "weather", label: "Weather" }]component SwipeTabsDemoCounter(name: str) { state n = 0 <Button size="lg" variant="outline" on_click={() => { n += 1 }}>{name} liked {n}</Button>}view SwipeTabsDemoPage(name: str) = <div data-built={name} style="padding: 12px 16px"> <SwipeTabsDemoCounter name={name}/> {for i in range(1, 30) => <p key={i} style="margin: 0; padding: 14px 0; border-bottom: 1px solid var(--ui-border)">{name} story {i}</p>} </div>component SwipeTabsDemoLive() { state shown = "top" state changes = 0 state wanted = "top" <div.demo-stack> <div.demo-tablet> <SwipeTabs id="swipe-tabs-demo" current={wanted} tabs={swipe_tabs_demo_tabs} page={(t) => <SwipeTabsDemoPage name={t}/>} on_change={(t) => { shown = t; changes += 1 }}/> </div> <p class="demo-note">Showing {shown}, after {changes} {changes == 1 ? "change" : "changes"}</p> <Button size="lg" variant="outline" on_click={() => { wanted = "sport" }}>Go to sport</Button> <p class="demo-note">Starting on the third tab, with a tab given twice and a bad row dropped:</p> <div style="height: 260px; border: 1px solid var(--ui-border)"> <SwipeTabs id="swipe-tabs-demo-third" current="business" tabs={[null, swipe_tabs_demo_tabs[0], swipe_tabs_demo_tabs[0], swipe_tabs_demo_tabs[1], swipe_tabs_demo_tabs[2]]} page={(t) => <p data-third={t} style="margin: 12px">{t} page</p>}/> </div> </div>}view SwipeTabsDemo() = <SwipeTabsDemoLive/>Source
// SwipeTabs: pages swiped sideways, in step with a strip of tabs above them. The line// under the tabs moves with the finger, between one tab's place and width and the// next's. A page is built only when it comes into reach. Tapping a tab and the arrow// keys do all that swiping does. The pages are a scroller that snaps, so the swipe is// the system's own, at its speed and with its feel, on both engines.//// import ui/swipe_tabs.qwe use SwipeTabs//// component News() {// <SwipeTabs tabs={[{ id: "top", label: "Top" }, { id: "local", label: "Local" }, { id: "sport", label: "Sport" }]}// page={(id) => <Stories section={id}/>} on_change={(id) => { seen(id) }}/>// }//// tabs: { id, label }. page(id): gives a page, called when it first comes into reach// and once only, so a page keeps what it holds. It is a function, so SwipeTabs goes// inside one of your components. current: the tab showing (the first if it is no tab's// id); a later change to it moves there. on_change(id): called when another page has// come to the front, by whatever way. label: what screen readers call the strip// ("Sections"). wait_text: what a page says until it is built ("Loading"). id: a prefix// for the elements' ids, unique on the page. The box takes the size of its parent, which// needs a height. Each page scrolls up and down by itself and keeps its place.//// A strip of more tabs than fit scrolls sideways, keeping the current one in view; in a// pane 600 px wide or more the tabs stand in the middle, up to 160 px each, sharing what// there is. A page still fills the pane, one at a time.import ui/theme.qwe use ui_clslet swipe_tabs_css = css`.ui-swipetabs { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; container-type: inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-swipetabs-strip { position: relative; display: flex; flex: none; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); padding-inline: max(0px, env(safe-area-inset-left)) max(0px, env(safe-area-inset-right));}.ui-swipetabs-strip::-webkit-scrollbar { display: none; }.ui-swipetabs-tab { flex: none; min-width: 72px; min-height: 48px; padding: 0 16px; border: 0; background: transparent; color: var(--ui-muted); font: 500 14px/1.2 var(--ui-font); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation;}.ui-swipetabs-tab[aria-selected="true"] { color: var(--ui-accent); }.ui-swipetabs-tab:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }@media (hover: hover) { .ui-swipetabs-tab:hover { color: var(--ui-fg); } .ui-swipetabs-tab[aria-selected="true"]:hover { color: var(--ui-accent); } }/* Before the first measure the selected tab marks itself, so the page shows it from the server. */.ui-swipetabs-strip:not(.ui-swipetabs-measured) .ui-swipetabs-tab[aria-selected="true"] { box-shadow: inset 0 -2px 0 var(--ui-accent); }.ui-swipetabs-line { position: absolute; bottom: 0; left: 0; width: 1px; height: 2px; background: var(--ui-accent); transform-origin: 0 0; visibility: hidden; pointer-events: none; will-change: transform;}.ui-swipetabs-measured .ui-swipetabs-line { visibility: visible; }.ui-swipetabs-track { display: flex; flex: 1; min-height: 0; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; }.ui-swipetabs-track::-webkit-scrollbar { display: none; }.ui-swipetabs-page { box-sizing: border-box; flex: 0 0 100%; min-width: 0; height: 100%; overflow-y: auto; overscroll-behavior-y: contain; scroll-snap-align: start; scroll-snap-stop: always; outline: none; }.ui-swipetabs-page:focus-visible { box-shadow: inset var(--ui-focus); }.ui-swipetabs-built { display: contents; }.ui-swipetabs-wait { padding: 32px 16px; color: var(--ui-muted); text-align: center; }@container (min-width: 600px) { .ui-swipetabs-strip { justify-content: center; } .ui-swipetabs-tab { flex: 0 1 160px; min-width: 0; }}`// The tabs that can be used: bad rows and a second use of an id are dropped.fn swipe_tabs_rows(tabs: list) -> list { let rows = take(tabs, 40) | filter((t) => type_of(t) == "map" && get(t, "id") != null) let ids = rows | map((t) => t.id) enumerate(rows) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// Where a tab is to start: the one asked for, by its place, or the first.fn swipe_tabs_start(rows: list, wanted) -> int = wanted == null ? 0 : (index_of(rows | map((t) => t.id), wanted) ?? 0)// The line for a place between two pages: from the left tab's spot towards the// right one's, in both place and width. spots: [{ x, w }], one per tab.fn swipe_tabs_line(spots: list, place: num) { let n = len(spots) if n == 0 { return { x: 0, w: 0 } } if place <= 0 { return spots[0] } if place >= n - 1 { return spots[n - 1] } let i = floor(place) let t = place - i { x: spots[i].x + (spots[i + 1].x - spots[i].x) * t, w: spots[i].w + (spots[i + 1].w - spots[i].w) * t }}// The pages a place has in reach: the one or two showing. Within a hair of a page, as// at the end of a scroll, that is one.fn swipe_tabs_reach(place: num, n: int) -> list { let both = abs(place - round(place)) < 0.02 ? [round(place)] : [floor(place), ceil(place)] both | filter((i) => i >= 0 && i < n)}fn swipe_tabs_paint(line, spots: list, place: num) { let s = swipe_tabs_line(spots, place) line.style.transform = `translate3d(${s.x}px, 0, 0) scaleX(${s.w})`}component SwipeTabs(tabs: list = [], page = null, current: str? = null, on_change = null, label: str = "Sections", wait_text: str = "Loading", id: str = "ui-swipetabs", class: str? = null) { style global swipe_tabs_css let rows = swipe_tabs_rows(tabs) let ids = rows | map((t) => t.id) state at = swipe_tabs_start(swipe_tabs_rows(tabs), current) state built = [swipe_tabs_start(swipe_tabs_rows(tabs), current)] state asked = current state aim = null // a page being scrolled to: until there, what passes does not count state spots = [] state win = null state track = null state strip = null state line = null let build = (list_of: list) => { let more = list_of | filter((i) => !contains(built, i)) if len(more) > 0 { built = built + more } } // Keeps the strip's tab in view, gently, and in the middle where it can. let reveal = (i: int) => { if i < len(spots) && strip.scrollWidth > strip.clientWidth { let left = spots[i].x - (strip.clientWidth - spots[i].w) / 2 strip.scrollTo({ left: left, behavior: win.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }) } } let landed = (i: int) => { if i != at { at = i reveal(i) if i < len(ids) { on_change && on_change(ids[i]) } } } let go = (i: int, tell: bool) => { if i >= 0 && i < len(ids) && i != at { let width = track.clientWidth build([i]) aim = i at = i reveal(i) if tell { on_change && on_change(ids[i]) } let still = win.matchMedia("(prefers-reduced-motion: reduce)").matches track.scrollTo({ left: i * width, behavior: still ? "auto" : "smooth" }) } } if current != asked { asked = current go(swipe_tabs_start(rows, current), false) } let measure = () => { if strip != null { let els = strip.querySelectorAll("[role=tab]") spots = range(0, els.length) | map((i) => { let el = els.item(i) { x: el.offsetLeft, w: el.offsetWidth } }) if len(spots) > 0 { strip.classList.add("ui-swipetabs-measured") swipe_tabs_paint(line, spots, track.clientWidth > 0 ? abs(track.scrollLeft) / track.clientWidth : at) } } } <div class={ui_cls(["ui-swipetabs", class])} @mount={(el) => { win = js("window") track = el.querySelector(".ui-swipetabs-track") strip = el.querySelector(".ui-swipetabs-strip") line = el.querySelector(".ui-swipetabs-line") if at > 0 { track.scrollLeft = at * track.clientWidth } measure() // Rotation or a new pane: the pages change width, the page stays the page. let ro = js_new("ResizeObserver", () => { track.scrollLeft = at * track.clientWidth measure() }) ro.observe(track) ro.observe(strip) () => ro.disconnect() }}> <div.ui-swipetabs-strip role="tablist" aria-label={label} @keydown={(e) => { let n = len(ids) 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() go(to, true) strip.querySelectorAll("[role=tab]").item(to).focus() } }}> {for t in rows => <SwipeTabsTab key={t.id} prefix={id} tab={t} on={ids[at] == t.id} on_press={() => { go(index_of(ids, t.id) ?? 0, true) }}/>} <span.ui-swipetabs-line aria-hidden="true"></span> </div> <div.ui-swipetabs-track @scroll={(e) => { let width = e.clientWidth if width > 0 { let place = abs(e.scrollLeft) / width swipe_tabs_paint(line, spots, place) if aim == null { build(swipe_tabs_reach(place, len(ids))) // A finger: what it comes to rest on, or nearly so, is the page. if abs(place - round(place)) < 0.02 { landed(round(place)) } } else if abs(place - aim) < 0.02 { aim = null } } }} @pointerdown={() => { aim = null }}> {for t in ids => <div.ui-swipetabs-page key={t} id={`${id}-page-${t}`} role="tabpanel" aria-labelledby={`${id}-tab-${t}`} tabindex="0" inert={ids[at] == t ? null : ""}> {contains(built, index_of(ids, t) ?? 0) ? <div.ui-swipetabs-built>{page != null ? page(t) : null}</div> : <div.ui-swipetabs-wait role="status">{wait_text}</div>} </div>} </div> </div>}view SwipeTabsTab(tab, prefix: str, on: bool, on_press) = <button.ui-swipetabs-tab type="button" role="tab" id={`${prefix}-tab-${tab.id}`} aria-selected={on ? "true" : "false"} aria-controls={`${prefix}-page-${tab.id}`} tabindex={on ? "0" : "-1"} @click={() => { on_press() }}>{get(tab, "label", "")}</button>Related
Thread 0 posts
On SwipeTabs: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.