Navigation

IconTabs

Tabs with an icon above each label and a count on its corner, the arrow keys moving between them.

#navigation#tabs#icon#interactive

Demo

Preview

Photos: the panel for tab 1 of 4, from state.

Tab 2 of 3, the tabs sharing the width.

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/icon_tabs.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/icon_tabs.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/icon_tabs.qwe
let icon_tabs_demo_tabs = [    { label: "Photos", icon: "M4 5h16v14H4zM4 15l5-5 4 4 3-3 4 4" },    { label: "Albums", icon: "M4 6h16v14H4zM8 3h8M8 10h8", badge: 3 },    { label: "Shared", icon: "M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6M20 19v-1a3 3 0 0 0-2-2.8" },    { label: "Trash", icon: "M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13", disabled: true }]component IconTabsDemoLive() {    state tab = 0    <div.demo-stack>        <IconTabs id="icon-tabs-demo" label="Library" tabs={icon_tabs_demo_tabs} selected={tab} on_change={(i) => { tab = i }}>            <p.demo-note>{icon_tabs_demo_tabs[tab].label}: the panel for tab {tab + 1} of 4, from state.</p>        </IconTabs>    </div>}component IconTabsDemoFull() {    state tab = 1    <div style="width: min(360px, 100%)">        <IconTabs id="icon-tabs-demo-full" full={true} size="sm" label="Emoji" selected={tab} on_change={(i) => { tab = i }}                  tabs={[{ label: "Feed", icon: "📰" }, { label: "Explore", icon: "🧭" }, { label: "Saved", icon: "🔖", badge: 12 }]}>            <p.demo-note>Tab {tab + 1} of 3, the tabs sharing the width.</p>        </IconTabs>    </div>}view IconTabsDemo() =    <div.demo-stack style="gap: 28px">        <IconTabsDemoLive/>        <IconTabsDemoFull/>    </div>

Source

ui/icon_tabs.qwe
// IconTabs: a row of tabs with an icon above each label, for the main areas of an// app or a phone-like switcher; the selected one is marked, and the arrow keys move// between them. Real ARIA tabs driven by your component's state, with one panel:// the children.////   <IconTabs id="areas" selected={tab} on_change={(i) => { tab = i }} tabs={[//       { label: "Photos", icon: "M4 5h16v14H4zM4 15l5-5 4 4 3-3 4 4" },//       { label: "Albums", icon: "📚", badge: 3 },//       { label: "Shared", icon: "👥" }//   ]}>//       {tab == 0 ? <Photos/> : tab == 1 ? <Albums/> : <Shared/>}//   </IconTabs>//// id (required): a prefix for the tabs' ids, unique on the page. tabs: { label, icon,// badge?, disabled? }. icon: a character or an emoji, or an SVG path (24x24, stroked)// when it starts with "M". badge: a count on the icon's corner. selected: the index// showing first. children: the panel of the selected tab. full: the tabs share the// width. size: "sm" or "md" (default). label: what screen readers call the row.// on_change(index): called with the tab picked.import ui/theme.qwe use ui_clslet icon_tabs_css = css`.ui-icontabs { font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-icontabs-list { display: flex; gap: 4px; margin: 0; padding: 0; border-bottom: 1px solid var(--ui-border); }.ui-icontabs-full .ui-icontabs-list .ui-icontabs-tab { flex: 1 1 0; }.ui-icontabs-tab {  display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; min-width: 72px; padding: 10px 12px 9px;  margin-bottom: -1px; border: 0; border-bottom: 2px solid transparent; border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0;  background: transparent; color: var(--ui-muted); font: 500 13px/1 var(--ui-font); cursor: pointer; transition: color .12s, background .12s, border-color .12s;}.ui-icontabs-tab:hover { color: var(--ui-fg); background: var(--ui-subtle); }.ui-icontabs-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-icontabs-tab[aria-selected="true"] { color: var(--ui-accent); border-bottom-color: var(--ui-accent); }.ui-icontabs-tab:disabled { opacity: .5; cursor: not-allowed; }.ui-icontabs-icon { position: relative; display: inline-grid; place-items: center; width: 28px; height: 28px; font-size: 22px; line-height: 1; }.ui-icontabs-icon svg { width: 24px; height: 24px; }.ui-icontabs-badge {  position: absolute; top: -5px; left: 100%; margin-left: -10px; min-width: 16px; padding: 0 4px; border-radius: 999px;  background: var(--ui-danger); color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center;}.ui-icontabs-sm .ui-icontabs-tab { min-width: 56px; padding: 7px 8px 6px; font-size: 12px; }.ui-icontabs-sm .ui-icontabs-icon { width: 22px; height: 22px; font-size: 18px; }.ui-icontabs-sm .ui-icontabs-icon svg { width: 20px; height: 20px; }.ui-icontabs-panel { padding-top: 16px; }.ui-icontabs-panel > :first-child { margin-top: 0; }.ui-icontabs-panel > :last-child { margin-bottom: 0; }@media (prefers-reduced-motion: reduce) { .ui-icontabs-tab { transition: none; } }`component IconTabs(id: str, tabs: list = [], selected: int = 0, full: bool = false, size: str = "md", label: str? = null,                   on_change = null, class: str? = null, children) {    style global icon_tabs_css    state at = selected    let live = range(0, len(tabs)) | filter((i) => get(tabs[i], "disabled") != true)    let pick = (i: int) => {        at = i        on_change && on_change(i)    }    <div class={ui_cls(["ui-icontabs", full && "ui-icontabs-full", size == "sm" && "ui-icontabs-sm", class])}>        <div.ui-icontabs-list role="tablist" aria-label={label} @keydown={(e) => {            // Taken first: pick may wait, as it calls on_change, and after a wait the event has no currentTarget.            let bar = e.currentTarget            let n = len(live)            let k = index_of(live, at) ?? 0            let to = match e.key {                "ArrowRight" => live[(k + 1) % n]                "ArrowLeft" => live[(k + n - 1) % n]                "Home" => live[0]                "End" => live[n - 1]                _ => null            }            if to != null && n > 0 {                e.preventDefault()                pick(to)                bar.querySelector(`[data-i="${to}"]`).focus()            }        }}>            {for [i, t] in enumerate(tabs) => <button.ui-icontabs-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) }}>                <span.ui-icontabs-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}                    {get(t, "badge") != null && <span.ui-icontabs-badge>{t.badge}</span>}                </span>                <span>{t.label}{get(t, "badge") != null && <span.ui-sr>, {t.badge} new</span>}</span>            </button>}        </div>        <div.ui-icontabs-panel id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${at}`} tabindex="0">{children}</div>    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.