Navigation

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.

#navigation#tabs#settings#interactive

Demo

Preview

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.qwe
main.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>}
Thread
46Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.