Mobile

KeyboardAvoider

Keeps a focused field in view above the on-screen keyboard by visualViewport, on both systems, handing back on close.

#mobile#interactive#keyboard#forms#layout

Demo

Preview

On a phone, tap a field near the bottom.

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

Example

The code of the preview above.

demos/keyboard_avoider.qwe
let keyboard_avoider_demo_css = css`.keyboard-avoider-demo-scroll { height: 100%; overflow: auto; overscroll-behavior: contain; }.keyboard-avoider-demo-form { display: grid; gap: 14px; padding: 16px; }.keyboard-avoider-demo-form label { display: grid; gap: 4px; color: var(--ui-muted); font: 500 13px/1.3 var(--ui-font); }.keyboard-avoider-demo-form input, .keyboard-avoider-demo-form textarea {  box-sizing: border-box; width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.4 var(--ui-font);}.keyboard-avoider-demo-status { position: sticky; top: 0; z-index: 1; margin: 0; padding: 8px 16px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); color: var(--ui-muted); font: 13px/1.4 var(--ui-font); }`component KeyboardAvoiderDemoLive() {    style global keyboard_avoider_demo_css    state height = 0    state changes = 0    let fields = ["Full name", "Street", "Town", "Postcode", "Region", "Country", "Phone", "Email", "Company", "Reference"]    <div.demo-phone>        <div.keyboard-avoider-demo-scroll id="keyboard-avoider-demo-scroll">            <p.keyboard-avoider-demo-status id="keyboard-avoider-demo-status" role="status" data-changes={changes}>                {height > 0 ? `Keyboard up: ${height} px` : "On a phone, tap a field near the bottom."}            </p>            <KeyboardAvoider id="keyboard-avoider-demo" on_change={(k) => {                height = k.height                changes += 1            }}>                <form.keyboard-avoider-demo-form>                    {for [i, f] in enumerate(fields) => <label key={f}>{f}                        <input id={`keyboard-avoider-demo-f${i}`} name={`f${i}`} autocomplete="off"/></label>}                    <label>A note                        <textarea id="keyboard-avoider-demo-note" name="note" rows="12"></textarea></label>                    <label>Agreed <input id="keyboard-avoider-demo-box" name="agreed" type="checkbox"/></label>                </form>            </KeyboardAvoider>        </div>    </div>}view KeyboardAvoiderDemo() = <div><KeyboardAvoiderDemoLive/></div>

Source

ui/keyboard_avoider.qwe
// KeyboardAvoider: keeps the field in use in view above the on-screen keyboard, on both// systems, and hands everything back when the keyboard goes. Wrap a form, or anything// with fields in it, inside what scrolls.//// The two systems do this differently. On iOS, and in Android's Chrome, the page stays// its full height and the keyboard only shrinks the visual viewport, so the keyboard// covers the end of the page and there is nothing below to scroll to: it adds that room// and scrolls. In the Android app the web view itself is made shorter, and there it only// scrolls. Either way the field stops `gap` pixels clear of the keyboard and of the top// of what scrolls, moving between fields moves with them, and turning the phone with// the keyboard open keeps the field in view. Zooming with two fingers also shrinks the// visual viewport, and is not taken for a keyboard.//// On closing, the room is taken away and the scroller goes back to where it stood,// unless the person has scrolled it themselves meanwhile.////   <div class="form-screen">   <!-- overflow: auto, with a height -->//       <KeyboardAvoider on_change={(k) => { lifted = k.open }}>//           <Field label="Name"> ... </Field>//           <Field label="Note"> ... </Field>//       </KeyboardAvoider>//   </div>//// gap: pixels to keep around the field (16). scroll_back: go back to the old position on// closing (true). id, class: for the outer element.// on_change({ open, height }): inside one of your components, on opening, closing, and// each new height. height is the keyboard's in pixels, 0 when closed, on both systems.// While open, --ui-keyboard-height is set on the outer element, for a bar of your own.//// Nothing happens where there is no on-screen keyboard or no visualViewport.import ui/theme.qwe use ui_clslet keyboard_avoider_css = css`.ui-keyboard-avoider { box-sizing: border-box; min-width: 0; }`// How much of the layout's height the keyboard covers. A pinch makes the visual viewport// smaller by the zoom and moves it about, so what it says is put back to screen size.fn keyboard_avoider_lift(inner, height, top, scale) {    if scale > 1.01 { return max([0, round(inner - height * scale)]) }    max([0, round(inner - height - top)])}// The keyboard's height. tall is the tallest the window has been at this width, as the// Android app's window shrinks with the keyboard and so says nothing by itself.fn keyboard_avoider_height(tall, height, scale) {    let seen = scale > 1.01 ? height * scale : height    let gone = round(tall - seen)    gone > 120 ? gone : 0}// How far to scroll so that a box from top to bottom lies between least and most: its// start, when it is taller than the room.fn keyboard_avoider_shift(top, bottom, least, most) {    if most <= least { return 0 }    if bottom - top > most - least { return top - least }    if bottom > most { return bottom - most }    if top < least { return top - least }    0}// Whether an element brings up the keyboard.fn keyboard_avoider_field(el) -> bool {    if el == null { return false }    let tag = el.tagName    if el.readOnly == true || el.disabled == true { return false }    if tag == "TEXTAREA" || tag == "SELECT" { return true }    if tag == "INPUT" {        return !contains(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"], lower(str(el.type)))    }    el.isContentEditable == true}// What scrolls this element: the nearest box that may, else the page.fn keyboard_avoider_scroller(win, el) {    let at = el.parentElement    let page = win.document.scrollingElement    while at != null {        if at.isSameNode(page) || at.tagName == "BODY" { return page }        let o = win.getComputedStyle(at).overflowY        if o == "auto" || o == "scroll" { return at }        at = at.parentElement    }    page}component KeyboardAvoider(gap = 16, scroll_back: bool = true, id: str? = null, class: str? = null, on_change = null, children) {    style global keyboard_avoider_css    state win = null    state box = null    state field = null    state open = false    state tall = 0    state wide = 0    state seen = 0    state told = 0    state was = null    state unfollow = null    let say = (h) => {        if h != told {            told = h            on_change && on_change({ open, height: h })        }    }    let reveal = () => {        if field != null {            let vp = win.visualViewport            let s = keyboard_avoider_scroller(win, field)            let page = s.isSameNode(win.document.scrollingElement)            let r = field.getBoundingClientRect()            let edge = page ? { top: 0, bottom: win.innerHeight } : s.getBoundingClientRect()            let by = keyboard_avoider_shift(r.top, r.bottom, max([edge.top, vp.offsetTop]) + gap,                                            min([edge.bottom, vp.offsetTop + vp.height]) - gap)            if by != 0 { s.scrollTop = s.scrollTop + by }            if was != null { was = set(was, "ours", s.scrollTop) }        }    }    let stop = () => {        if unfollow != null {            unfollow()            unfollow = null        }    }    let hand_back = () => {        open = false        // Read before the room goes, which can pull the scroller up by itself.        let untouched = was != null && abs(was.el.scrollTop - was.ours) <= 4        box.style.paddingBottom = ""        box.style.removeProperty("--ui-keyboard-height")        if scroll_back && untouched { was.el.scrollTop = was.top }        was = null        say(0)        if field == null { stop() }    }    let read = () => {        let vp = win.visualViewport        // Turned: the old tallest means nothing at the new width.        if win.innerWidth != wide {            wide = win.innerWidth            tall = 0        }        tall = max([tall, win.innerHeight])        let height = keyboard_avoider_height(tall, vp.height, vp.scale)        if height > 0 {            open = true            let lift = keyboard_avoider_lift(win.innerHeight, vp.height, vp.offsetTop, vp.scale)            box.style.paddingBottom = lift > 0 ? `${lift}px` : ""            box.style.setProperty("--ui-keyboard-height", `${height}px`)            seen = vp.height            reveal()            say(height)        } else {            // Not closed by a keyboard that only looks gone after a turn: closing makes more room.            if open && vp.height > seen + 80 {                hand_back()            } else {                if open {                    seen = vp.height                    reveal()                }            }        }    }    let follow = () => {        if unfollow == null {            let vp = win.visualViewport            if vp != null {                vp.addEventListener("resize", read)                vp.addEventListener("scroll", read)                unfollow = () => {                    vp.removeEventListener("resize", read)                    vp.removeEventListener("scroll", read)                }            }        }    }    let use_field = (el) => {        field = el        // Noted here, ahead of the browser's own scroll to a field it was given.        if !open {            let s = keyboard_avoider_scroller(win, el)            was = { el: s, top: s.scrollTop, ours: s.scrollTop }        }        follow()        if unfollow != null { read() }    }    <div class={ui_cls(["ui-keyboard-avoider", class])} id={id}         @mount={(el) => {             let w = js("window")             win = w             box = el             wide = w.innerWidth             tall = w.innerHeight             // A field already in use when this started.             let now = w.document.activeElement             if now != null && el.contains(now) && keyboard_avoider_field(now) { use_field(now) }             () => { stop() }         }}         @focusin={(e) => {             if win != null && keyboard_avoider_field(e.target) { use_field(e.target) }         }}         @focusout={(e) => {             let next = e.relatedTarget             if next == null || !box.contains(next) || !keyboard_avoider_field(next) {                 field = null                 // With no keyboard up there is nothing to wait for; with one, its closing hands back.                 if !open { stop() }             }         }}>        {children}    </div>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.