BottomAppBar
A bottom bar with actions and a notched floating button, hiding on scroll and standing clear of the safe area; a floating toolbar on a tablet.
Demo
Last: nothing yet
Get it
It needs theme.qwe, as every component does, and top_app_bar.qwe; 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/top_app_bar.qwe$ curl -O https://quillolang.org/components/ui/bottom_app_bar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/bottom_app_bar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/bottom_app_bar.qwe
let bottom_app_bar_demo_actions = [ { id: "menu", label: "Menu", icon: "M4 6h16M4 12h16M4 18h16" }, { id: "search", label: "Search", icon: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3" }, { id: "inbox", label: "Inbox", icon: "M4 4h16v16H4zM4 13h5l1.5 2h3L15 13h5", badge: 4 },]component BottomAppBarDemoLive() { state did = "nothing yet" state notes = 12 state middle = false <div.demo-stack> <div.demo-tablet> <BottomAppBar actions={bottom_app_bar_demo_actions} fab={{ label: "New note", icon: "M12 5v14M5 12h14" }} fab_at={middle ? "center" : "end"} on_action={(id) => { did = id }} on_fab={() => { notes += 1; did = "new note" }}> <div style="padding: 8px 16px"> {for i in range(0, 40) => <p key={i} style="margin: 0; padding: 14px 0; border-bottom: 1px solid var(--ui-border)">Note {notes + 40 - i}</p>} </div> </BottomAppBar> </div> <p class="demo-note">Last: {did}</p> <Button size="lg" variant="outline" on_click={() => { middle = !middle }}>Move the button</Button> </div>}view BottomAppBarDemo() = <BottomAppBarDemoLive/>Source
ui/bottom_app_bar.qwe
// BottomAppBar: a bar along the bottom of a screen with up to four actions and a// floating button set into a notch in its top edge. It goes down out of the way as the// content scrolls down and comes back on the way up, or at the end of the content; the// floating button stays, sinking into the corner. It stands above the safe area, and// the content has room to scroll clear of it. In a pane 600 px wide or more it is a// floating toolbar in the middle instead, as a bar across a tablet leaves its buttons// far from each other and from both hands.//// import ui/bottom_app_bar.qwe use BottomAppBar//// component Notes() {// <BottomAppBar fab={{ label: "New note", icon: "M12 5v14M5 12h14" }} on_fab={add}// actions={[// { id: "search", label: "Search", icon: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3" },// { id: "archive", label: "Archive", icon: "M3 7h18v13H3zM8 12h8" },// ]}// on_action={(id) => { pick(id) }}>// <NoteList/>// </BottomAppBar>// }//// children: the screen, which scrolls inside the box. actions: { id, label, icon, badge? }.// icon: an SVG path (24x24, stroked) when it starts with "M", else a character. label// is its name for screen readers. fab: { label, icon }, or null for no floating button.// fab_at: "end" (default) or "center". on_action(id), on_fab(): inside one of your// components, what they do. hide: go on scrolling down (true). on_hide(hidden): called// as it goes and comes back. label: what screen readers call the bar ("Actions").// More actions than fit scroll sideways. The box takes the size of its parent, which// needs a height.import ui/theme.qwe use ui_clsimport ui/top_app_bar.qwe use top_app_bar_clamp, top_app_bar_steplet bottom_app_bar_css = css`.ui-bottombar { position: relative; display: block; 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-bottombar-scroll { box-sizing: border-box; height: 100%; padding-bottom: calc(112px + env(safe-area-inset-bottom)); overflow: auto; overscroll-behavior: contain; outline: none;}.ui-bottombar-scroll:focus-visible { box-shadow: inset var(--ui-focus); }.ui-bottombar-bar { position: absolute; inset: auto 0 0; z-index: 2; box-sizing: border-box; height: calc(64px + env(safe-area-inset-bottom)); padding: 8px max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom) max(8px, env(safe-area-inset-left)); color: var(--ui-fg); transition: transform .2s cubic-bezier(.2, .8, .2, 1);}.ui-bottombar-hidden .ui-bottombar-bar { transform: translate3d(0, 100%, 0); }/* The notch is cut from a layer of its own, so the mask leaves the buttons alone; the outer layer's filter draws the edge around the cut. */.ui-bottombar-edge { position: absolute; inset: 0; filter: drop-shadow(0 -1px 0 var(--ui-border)); }.ui-bottombar-shape { position: absolute; inset: 0; background: var(--ui-surface); }.ui-bottombar-notch-end .ui-bottombar-shape { -webkit-mask: radial-gradient(circle 37px at calc(100% - 52px) 0, transparent 36px, #000 37px); mask: radial-gradient(circle 37px at calc(100% - 52px) 0, transparent 36px, #000 37px);}.ui-bottombar-notch-center .ui-bottombar-shape { -webkit-mask: radial-gradient(circle 37px at 50% 0, transparent 36px, #000 37px); mask: radial-gradient(circle 37px at 50% 0, transparent 36px, #000 37px);}.ui-bottombar-actions { position: relative; display: flex; align-items: center; gap: 4px; height: 56px; overflow-x: auto; scrollbar-width: none; }.ui-bottombar-actions::-webkit-scrollbar { display: none; }.ui-bottombar-notch-end .ui-bottombar-actions { margin-inline-end: 88px; }.ui-bottombar-gap { flex: 0 0 88px; }.ui-bottombar-notch-center .ui-bottombar-actions { justify-content: space-between; }.ui-bottombar-action { position: relative; display: inline-grid; flex: none; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; font: 20px var(--ui-font); cursor: pointer;}.ui-bottombar-action svg { width: 24px; height: 24px; }.ui-bottombar-action:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bottombar-action:active { background: var(--ui-subtle); }@media (hover: hover) { .ui-bottombar-action:hover { background: var(--ui-subtle); } }.ui-bottombar-badge { position: absolute; top: 4px; inset-inline-end: 2px; min-width: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--ui-danger); color: #fff; font: 700 10px/16px var(--ui-font); text-align: center;}.ui-bottombar-fab { position: absolute; z-index: 3; bottom: calc(36px + env(safe-area-inset-bottom)); inset-inline-end: 24px; display: grid; place-items: center; width: 56px; height: 56px; padding: 0; border: 0; border-radius: 50%; background: var(--ui-accent); color: #fff; box-shadow: var(--ui-shadow-lg); font: 22px var(--ui-font); cursor: pointer; transition: transform .2s cubic-bezier(.2, .8, .2, 1);}.ui-bottombar-fab svg { width: 24px; height: 24px; }.ui-bottombar-fab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bottombar-fab-center { inset-inline-end: calc(50% - 28px); }.ui-bottombar-hidden .ui-bottombar-fab { transform: translate3d(0, 20px, 0); }@container (min-width: 600px) { .ui-bottombar-bar { inset: auto auto calc(16px + env(safe-area-inset-bottom)) 50%; display: flex; width: auto; max-width: 80%; height: 64px; padding: 8px 12px; transform: translate3d(-50%, 0, 0); } .ui-bottombar-hidden .ui-bottombar-bar { transform: translate3d(-50%, calc(100% + 32px), 0); } .ui-bottombar-edge { filter: none; } .ui-bottombar-notch-end .ui-bottombar-shape, .ui-bottombar-notch-center .ui-bottombar-shape { -webkit-mask: none; mask: none; border: 1px solid var(--ui-border); border-radius: 999px; box-shadow: var(--ui-shadow-lg); } .ui-bottombar-notch-end .ui-bottombar-actions { margin-inline-end: 0; } .ui-bottombar-notch-center .ui-bottombar-actions { justify-content: flex-start; } .ui-bottombar-gap { display: none; } .ui-bottombar-fab, .ui-bottombar-fab-center { inset-inline-end: 24px; bottom: calc(20px + env(safe-area-inset-bottom)); } .ui-bottombar-hidden .ui-bottombar-fab { transform: none; }}@media (prefers-reduced-motion: reduce) { .ui-bottombar-bar, .ui-bottombar-fab { transition: none; } }`// As a top bar, with one thing more: at the end of the content it comes back, as the// last rows would otherwise never have their actions beside them.fn bottom_app_bar_step(hidden: bool, anchor: num, y: num, limit: num, height: num, slop: num) { if limit - y <= 2 { return { hidden: false, anchor: y } } top_app_bar_step(hidden, anchor, y, height, slop)}// The actions that can be used: bad rows and a second use of an id are dropped.fn bottom_app_bar_rows(actions: list) -> list { let rows = take(actions, 40) | filter((a) => type_of(a) == "map" && get(a, "id") != null) let ids = rows | map((a) => a.id) enumerate(rows) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}fn bottom_app_bar_icon(icon) -> bool = type_of(icon) == "str" && starts_with(icon, "M") && len(icon) > 2view BottomAppBarGlyph(icon = "") = bottom_app_bar_icon(icon) ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={icon}/></svg> : <span aria-hidden="true">{icon}</span>component BottomAppBar(actions: list = [], fab = null, fab_at: str = "end", on_action = null, on_fab = null, hide: bool = true, on_hide = null, label: str = "Actions", scroll_label: str = "Content", class: str? = null, children) { style global bottom_app_bar_css state hidden = false state anchor = 0 state bar = null let center = fab != null && fab_at == "center" let rows = bottom_app_bar_rows(actions) let half = floor((len(rows) + 1) / 2) let show = (on: bool) => { if on == hidden { hidden = !on on_hide && on_hide(!on) } } <div class={ui_cls(["ui-bottombar", hidden && "ui-bottombar-hidden", fab != null && (center ? "ui-bottombar-notch-center" : "ui-bottombar-notch-end"), class])}> <div.ui-bottombar-scroll tabindex="0" role="region" aria-label={scroll_label} @scroll={(e) => { if hide { let limit = e.scrollHeight - e.clientHeight let y = top_app_bar_clamp(e.scrollTop, limit) let next = bottom_app_bar_step(hidden, anchor, y, limit, bar.offsetHeight, 12) anchor = next.anchor show(!next.hidden) } }}> {children} </div> <div.ui-bottombar-bar role="toolbar" aria-label={label} @mount={(el) => { bar = el }} @focusin={() => { show(true) }}> <div.ui-bottombar-edge aria-hidden="true"><div.ui-bottombar-shape></div></div> <div.ui-bottombar-actions> {for a in center ? take(rows, half) : rows => <BottomAppBarAction key={a.id} item={a} on_action={on_action}/>} {center && <span.ui-bottombar-gap aria-hidden="true"></span>} {for a in center ? drop(rows, half) : [] => <BottomAppBarAction key={a.id} item={a} on_action={on_action}/>} </div> </div> {fab != null && <button class={ui_cls(["ui-bottombar-fab", center && "ui-bottombar-fab-center"])} type="button" aria-label={get(fab, "label", "Add")} @click={() => { on_fab && on_fab() }}><BottomAppBarGlyph icon={get(fab, "icon", "+")}/></button>} </div>}view BottomAppBarAction(item, on_action = null) { let badge = get(item, "badge") <button.ui-bottombar-action type="button" aria-label={badge != null ? `${get(item, "label", "")}, ${badge}` : get(item, "label", "")} @click={() => { on_action && on_action(item.id) }}> <BottomAppBarGlyph icon={get(item, "icon", "")}/> {badge != null && <span.ui-bottombar-badge aria-hidden="true">{badge}</span>} </button>}Related
AppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsEdgeDrawerA navigation drawer pulled from the screen's edge: follows the finger, snaps by speed, holds the focus, closes on Back; stands beside the content on a tablet.4 views · 7 downloadsFolderBrowserFolders with breadcrumbs, a Back, and move and rename in sheets.3 views · 7 downloadsLargeTitleBarAn iOS large title that shrinks into the bar as content scrolls, with back and action slots, tied to the scroller without jank.3 views · 6 downloadsNavRailA Material navigation rail for medium widths, with a menu, a floating action and badges, turning into a bottom bar on a phone.2 views · 7 downloadsNavStackA stack of screens pushed and popped with a slide, each keeping its state and scroll, with a real Back through the browser's history; a column beside the rest on a tablet.2 views · 6 downloads
Thread
Thread 0 posts
On BottomAppBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.