Mobile

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.

#mobile#interactive#tablet#navigation#toolbar#fab

Demo

Preview

Note 52

Note 51

Note 50

Note 49

Note 48

Note 47

Note 46

Note 45

Note 44

Note 43

Note 42

Note 41

Note 40

Note 39

Note 38

Note 37

Note 36

Note 35

Note 34

Note 33

Note 32

Note 31

Note 30

Note 29

Note 28

Note 27

Note 26

Note 25

Note 24

Note 23

Note 22

Note 21

Note 20

Note 19

Note 18

Note 17

Note 16

Note 15

Note 14

Note 13

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

Thread 0 posts

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

No posts yet. The first one opens the thread.