VerticalTabs
Tabs down the left with icons and a line of detail, and the selected one's panel at the right; a row above it when narrow.
Demo
Profile
Name, photo, bio: the panel for tab 1 of 5, from state. The up and down arrows move between tabs.
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/vertical_tabs.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/vertical_tabs.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/vertical_tabs.qwe
let vertical_tabs_demo_tabs = [ { label: "Profile", hint: "Name, photo, bio", icon: "M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" }, { label: "Notifications", hint: "Email and push", icon: "M6 16V11a6 6 0 0 1 12 0v5l2 2H4zM10 21h4", badge: 2 }, { label: "Security", hint: "Password, two-factor", icon: "M6 11h12v10H6zM8 11V7a4 4 0 0 1 8 0v4" }, { label: "Billing", hint: "Plan and invoices", icon: "M3 7h18v10H3zM3 11h18" }, { label: "Developer", hint: "API keys", icon: "M8 6 2 12l6 6M16 6l6 6-6 6", disabled: true }]component VerticalTabsDemoLive() { state tab = 0 <VerticalTabs id="vertical-tabs-demo" label="Settings" tabs={vertical_tabs_demo_tabs} selected={tab} on_change={(i) => { tab = i }}> <h4 style="margin: 0 0 6px">{vertical_tabs_demo_tabs[tab].label}</h4> <p.demo-note>{vertical_tabs_demo_tabs[tab].hint}: the panel for tab {tab + 1} of 5, from state. The up and down arrows move between tabs.</p> </VerticalTabs>}view VerticalTabsDemo() = <div.demo-stack> <VerticalTabsDemoLive/> </div>Source
ui/vertical_tabs.qwe
// VerticalTabs: tabs down the left side, each with an icon and a line of detail if// it has one, and the selected tab's panel at the right: settings pages, a long form// in sections, an account's areas. Real ARIA tabs driven by your component's state;// the up and down arrow keys move between them. Under 640px the tabs go above the// panel, in a row that scrolls.//// <VerticalTabs id="settings" selected={tab} on_change={(i) => { tab = i }} tabs={[// { label: "Profile", hint: "Name, photo, bio", icon: "👤" },// { label: "Security", hint: "Password, 2FA", icon: "🔒" }// ]}>// {tab == 0 ? <ProfileForm/> : <SecurityForm/>}// </VerticalTabs>//// id (required): a prefix for the tabs' ids, unique on the page. tabs: strings, or// { label, hint?, icon?, badge?, disabled? }. icon: a character or an emoji, or an SVG// path (24x24, stroked) when it starts with "M". selected: the index showing first.// children: the panel of the selected tab. width: the tabs column's width in pixels// (220). label: what screen readers call the tabs. on_change(index): called with the// tab picked.import ui/theme.qwe use ui_clslet vertical_tabs_css = css`.ui-vtabs { display: grid; grid-template-columns: var(--ui-vtabs-w, 220px) minmax(0, 1fr); gap: 28px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-vtabs-list { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; border-right: 1px solid var(--ui-border); }.ui-vtabs-tab { display: flex; align-items: center; gap: 10px; margin-right: -1px; padding: 9px 12px; border: 0; border-right: 2px solid transparent; border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm); background: transparent; color: var(--ui-muted); font: inherit; text-align: left; cursor: pointer; transition: color .12s, background .12s, border-color .12s;}.ui-vtabs-tab:hover { color: var(--ui-fg); background: var(--ui-subtle); }.ui-vtabs-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-vtabs-tab[aria-selected="true"] { color: var(--ui-accent); background: var(--ui-accent-soft); border-right-color: var(--ui-accent); }.ui-vtabs-tab:disabled { opacity: .5; cursor: not-allowed; }.ui-vtabs-icon { display: inline-grid; place-items: center; flex: none; width: 22px; height: 22px; font-size: 18px; line-height: 1; }.ui-vtabs-icon svg { width: 20px; height: 20px; }.ui-vtabs-text { flex: 1; min-width: 0; display: grid; gap: 1px; }.ui-vtabs-label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-vtabs-hint { color: var(--ui-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-vtabs-badge { flex: none; min-width: 20px; padding: 1px 6px; border-radius: 999px; background: var(--ui-subtle); border: 1px solid var(--ui-border); color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: center;}.ui-vtabs-tab[aria-selected="true"] .ui-vtabs-badge { border-color: transparent; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-vtabs-panel { min-width: 0; }.ui-vtabs-panel > :first-child { margin-top: 0; }.ui-vtabs-panel > :last-child { margin-bottom: 0; }@media (max-width: 640px) { .ui-vtabs { grid-template-columns: 1fr; gap: 16px; } .ui-vtabs-list { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--ui-border); scrollbar-width: none; } .ui-vtabs-list::-webkit-scrollbar { display: none; } .ui-vtabs-tab { flex: none; margin: 0 0 -1px; border-right: 0; border-bottom: 2px solid transparent; border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0; } .ui-vtabs-tab[aria-selected="true"] { border-bottom-color: var(--ui-accent); } .ui-vtabs-hint { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-vtabs-tab { transition: none; } }`// The tab a key goes to, of the live ones (indexes), from the one at `at`; null for// another key. A function of its own, not a `match` in the handler: in a handler that// can wait, Quillo may wait after a `match`, and preventDefault would come too late.fn vertical_tabs_to(key: str, live: list, at: int) { let n = len(live) let k = index_of(live, at) ?? 0 n == 0 ? null : match key { "ArrowDown" => live[(k + 1) % n] "ArrowUp" => live[(k + n - 1) % n] "Home" => live[0] "End" => live[n - 1] _ => null }}component VerticalTabs(id: str, tabs: list = [], selected: int = 0, width: int = 220, label: str? = null, on_change = null, class: str? = null, children) { style global vertical_tabs_css state at = selected let items = tabs | map((t) => type_of(t) == "str" ? { label: t } : t) let live = range(0, len(items)) | filter((i) => get(items[i], "disabled") != true) let pick = (i: int) => { at = i on_change && on_change(i) } <div class={ui_cls(["ui-vtabs", class])} style={`--ui-vtabs-w: ${width}px`}> <div.ui-vtabs-list role="tablist" aria-label={label} aria-orientation="vertical" @keydown={(e) => { let to = vertical_tabs_to(e.key, live, at) if to != null { e.preventDefault() // Kept before on_change runs: once the handler has waited on it, the // event is over and its currentTarget is null. let list = e.currentTarget pick(to) list.querySelector(`[data-i="${to}"]`).focus() } }}> {for [i, t] in enumerate(items) => <button.ui-vtabs-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"} disabled={get(t, "disabled") == true} @click={() => { pick(i) }}> {get(t, "icon") != null && <span.ui-vtabs-icon aria-hidden="true">{starts_with(t.icon, "M") && len(t.icon) > 2 ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={t.icon}/></svg> : t.icon}</span>} <span.ui-vtabs-text> <span.ui-vtabs-label>{t.label}</span> {get(t, "hint") != null && <span.ui-vtabs-hint>{t.hint}</span>} </span> {get(t, "badge") != null && <span.ui-vtabs-badge>{t.badge}</span>} </button>} </div> <div.ui-vtabs-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 downloadsScrollTabsA row of tabs that scrolls sideways when there are more than fit, with arrows at the ends and the keyboard.5 views · 8 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 VerticalTabs: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.