SafeArea
Pads content for notches and home bars by env(), per side, with a view of the insets under a debug prop.
Demo
top 0 · right 0 · bottom 0 · left 0
Top bar, clear of the notch
Bottom bar, clear of the home bar
Only the top and the bottom, at least 12 px, plus 4.
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/safe_area.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/safe_area.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/safe_area.qwe
let safe_area_demo_css = css`.safe-area-demo-screen { height: 100%; background: var(--ui-accent-soft); }.safe-area-demo-fill { display: grid; align-content: space-between; height: 100%; min-height: 100%; color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.safe-area-demo-fill > * { margin: 0; padding: 10px 12px; background: var(--ui-surface); }.safe-area-demo-keys { display: flex; flex-wrap: wrap; gap: 8px; }`component SafeAreaDemoLive() { style global safe_area_demo_css state shape = "none" state debug = true let vars = match shape { "notch" => "--ui-safe-top: 59px; --ui-safe-right: 0px; --ui-safe-bottom: 34px; --ui-safe-left: 0px" "turned" => "--ui-safe-top: 0px; --ui-safe-right: 59px; --ui-safe-bottom: 21px; --ui-safe-left: 59px" _ => "" } <div.demo-phone id="safe-area-demo-frame" style={vars}> <SafeArea id="safe-area-demo-box" least={8} debug={debug} class="safe-area-demo-screen"> <div.safe-area-demo-fill> <p>Top bar, clear of the notch</p> <div.safe-area-demo-keys> <Button size="lg" variant={shape == "none" ? "solid" : "outline"} on_click={() => { shape = "none" }}>Flat</Button> <Button size="lg" variant={shape == "notch" ? "solid" : "outline"} on_click={() => { shape = "notch" }}>Notch</Button> <Button size="lg" variant={shape == "turned" ? "solid" : "outline"} on_click={() => { shape = "turned" }}>Turned</Button> <Button size="lg" variant="ghost" on_click={() => { debug = !debug }}>{debug ? "Hide insets" : "Show insets"}</Button> </div> <p>Bottom bar, clear of the home bar</p> </div> </SafeArea> </div>}view SafeAreaDemo() = <div.demo-stack> <SafeAreaDemoLive/> <SafeArea id="safe-area-demo-sides" sides="top bottom" least={12} extra={4}> <p.demo-note>Only the top and the bottom, at least 12 px, plus 4.</p> </SafeArea> </div>Source
ui/safe_area.qwe
// SafeArea: keeps what is inside it clear of the notch, the rounded corners and the// home bar, side by side, by the system's own `env(safe-area-inset-*)`. It turns with the// phone, as those values do, and needs no script. With debug it also shows the insets it// reads: the bands drawn on the content, the numbers, and a warning when the page has not// asked for them, since without `viewport-fit=cover` in its viewport meta the system// gives 0 on every side.//// <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>// <SafeArea> ... </SafeArea>// <SafeArea sides="top left right" least={12}> ... </SafeArea>// <SafeArea sides="bottom" extra={8} debug={true}> ... </SafeArea>//// sides: which sides to pad, "all" (default) or any of "top right bottom left", spaced.// least: at least this many pixels on those sides, where the system gives less: 16 keeps// a bar off the edge of a phone with no notch. extra: pixels added on top of that.// margin: true uses margin in place of padding, for an element with a background that// should stop at the safe area. debug: show the insets. id, class: for the outer element.//// A frame that stands in for a phone sets --ui-safe-top, --ui-safe-right, --ui-safe-bottom// and --ui-safe-left, as DevicePreview does: they win over the system's values. A// tablet has insets too, and Split View changes them: nothing here looks at the screen.//// In the apps, iOS's web view starts under the status bar and runs to the bottom, so top// is 0 and bottom is the home bar; Android's shell keeps the web view off all its bars,// so all four are 0.import ui/theme.qwe use ui_clslet safe_area_css = css`.ui-safe-area { box-sizing: border-box; }.ui-safe-area-debug { position: relative; }.ui-safe-area-guide { position: absolute; inset: 0; z-index: 5; overflow: hidden; pointer-events: none; color: var(--ui-fg); font: 600 12px/1.3 var(--ui-mono); }.ui-safe-area-probe { position: absolute; left: 0; top: 0; visibility: hidden; }.ui-safe-area-probe-a { width: var(--ui-safe-left, env(safe-area-inset-left, 0px)); height: var(--ui-safe-top, env(safe-area-inset-top, 0px)); }.ui-safe-area-probe-b { width: var(--ui-safe-right, env(safe-area-inset-right, 0px)); height: var(--ui-safe-bottom, env(safe-area-inset-bottom, 0px)); }.ui-safe-area-band { position: absolute; display: grid; place-items: center; overflow: hidden; background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--ui-danger) 34%, transparent) 0 6px, color-mix(in srgb, var(--ui-danger) 14%, transparent) 6px 12px);}.ui-safe-area-band-top { inset: 0 0 auto 0; }.ui-safe-area-band-bottom { inset: auto 0 0 0; }.ui-safe-area-band-left { inset: 0 auto 0 0; }.ui-safe-area-band-right { inset: 0 0 0 auto; }.ui-safe-area-band-off { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--ui-muted) 22%, transparent) 0 6px, transparent 6px 12px); }.ui-safe-area-legend { position: absolute; left: 50%; top: 24%; display: grid; gap: 4px; width: max-content; max-width: min(320px, 86%); padding: 10px 12px; transform: translateX(-50%); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: color-mix(in srgb, var(--ui-surface) 90%, transparent); box-shadow: var(--ui-shadow);}.ui-safe-area-numbers { margin: 0; font-variant-numeric: tabular-nums; }.ui-safe-area-warn { margin: 0; color: var(--ui-warning); font: 500 13px/1.4 var(--ui-font); }`fn safe_area_px(x) { let kind = type_of(x) (kind == "int" || kind == "float") && x > 0 ? x : 0}// The words of `sides`, in the order CSS writes them. Words it does not know are left out.fn safe_area_sides(sides) -> list { let order = ["top", "right", "bottom", "left"] if type_of(sides) != "str" || trim(sides) == "all" { return order } let words = split(lower(sides), " ") order | filter((s) => contains(words, s))}// The value of one side, with the frame's stand-in ahead of the system's.fn safe_area_value(side: str, least, extra) -> str { let inset = `var(--ui-safe-${side}, env(safe-area-inset-${side}, 0px))` let low = safe_area_px(least) let more = safe_area_px(extra) let floor_of = low > 0 ? `max(${low}px, ${inset})` : inset more > 0 ? `calc(${floor_of} + ${more}px)` : floor_of}fn safe_area_style(sides, least, extra, margin) -> str { let prop = margin == true ? "margin" : "padding" safe_area_sides(sides) | map((s) => `${prop}-${s}: ${safe_area_value(s, least, extra)}`) | join("; ")}// Whether a viewport meta's content asks for the insets. Spaces and case do not matter.fn safe_area_covers(content) -> bool { if type_of(content) != "str" { return false } let parts = split(replace(lower(content), " ", ""), ",") contains(parts, "viewport-fit=cover")}component SafeAreaGuide(sides = "all") { state top = 0 state right = 0 state bottom = 0 state left = 0 state covered = true let padded = safe_area_sides(sides) let band = (side: str) => contains(padded, side) ? `ui-safe-area-band ui-safe-area-band-${side}` : `ui-safe-area-band ui-safe-area-band-${side} ui-safe-area-band-off` <div.ui-safe-area-guide data-safe-top={top} data-safe-right={right} data-safe-bottom={bottom} data-safe-left={left} @mount={(el) => { let win = js("window") let doc = win.document let a = el.querySelector(".ui-safe-area-probe-a") let b = el.querySelector(".ui-safe-area-probe-b") let read = () => { let ra = a.getBoundingClientRect() let rb = b.getBoundingClientRect() top = round(ra.height) left = round(ra.width) bottom = round(rb.height) right = round(rb.width) let meta = doc.querySelector("meta[name=viewport]") covered = meta != null && safe_area_covers(meta.content) } read() // A probe sized by each inset, so a change is seen whatever caused it. let grown = js_new("ResizeObserver", read) grown.observe(a) grown.observe(b) win.addEventListener("resize", read) () => { grown.disconnect() win.removeEventListener("resize", read) } }}> <i.ui-safe-area-probe.ui-safe-area-probe-a></i> <i.ui-safe-area-probe.ui-safe-area-probe-b></i> <div class={band("top")} style={`height: ${top}px`}>{top > 16 ? top : ""}</div> <div class={band("bottom")} style={`height: ${bottom}px`}>{bottom > 16 ? bottom : ""}</div> <div class={band("left")} style={`width: ${left}px`}>{left > 16 ? left : ""}</div> <div class={band("right")} style={`width: ${right}px`}>{right > 16 ? right : ""}</div> <div.ui-safe-area-legend role="status"> <p.ui-safe-area-numbers>top {top} · right {right} · bottom {bottom} · left {left}</p> {!covered && <p.ui-safe-area-warn>This page has no viewport-fit=cover, so the system gives 0.</p>} </div> </div>}view SafeArea(sides: str = "all", least = 0, extra = 0, margin: bool = false, debug: bool = false, id: str? = null, class: str? = null, children) { style global safe_area_css <div class={ui_cls(["ui-safe-area", debug && "ui-safe-area-debug", class])} id={id} style={safe_area_style(sides, least, extra, margin)}> {debug && <SafeAreaGuide sides={sides}/>} {children} </div>}Related
PhoneShellThe whole app frame: header, scroller, tab bar, safe areas and keyboard handled once, for one fixed-height screen that does not bounce.2 views · 6 downloadsResponsiveSplitMaster and detail as one stack on a phone and side by side on a tablet, changing live on rotation and keeping the selection.2 views · 5 downloadsRotationLayoutTwo arrangements of one set of children, upright and turned: no flash, and focus and scroll kept across the turn.3 views · 6 downloadsSizeClassViewTells compact, medium and expanded widths by its own box, so Split View, Slide Over and a turn are one case, and swaps slots without losing what is in them.1 view · 5 downloadsSplitViewTwo or three panes with a divider to drag, which give way by width, with the selection kept across a turn and a keyboard route for the divider.3 views · 7 downloadsTabletSidebarA sidebar that folds to icons, then to an overlay, by width class, with sections, badges and a pin kept per device.2 views · 3 downloads
Thread
Thread 0 posts
On SafeArea: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.