// EdgeDrawer: a navigation drawer pulled in from the screen's edge. It follows the
// finger, and on release snaps open or shut by speed, or by how far it came. Open, it
// holds the focus, and closes with a swipe back, a tap beside it, Escape, its Close
// button or the Back button of a browser or an Android phone. In a pane 700 px wide or
// more it stops being an overlay and stands beside the content. In a right-to-left
// page it comes from the right.
//
//   import ui/edge_drawer.qwe use EdgeDrawer
//
//   component App() {
//       state menu = false
//       <EdgeDrawer title="Fieldbook" open={menu} on_change={(on) => { menu = on }}
//                   drawer={<SideNav/>}>
//           <button @click={() => { menu = true }}>Menu</button>
//           <Notes/>
//       </EdgeDrawer>
//   }
//
// drawer: what is in it, as HTML, so it goes inside one of your components; a link in
// it closes it. children: the screen. title: the drawer's heading and its name for
// screen readers. open: follows your state; the drawer also opens and closes by itself,
// so take on_change(open), called each time it does. width: how wide it is, in
// pixels (300, and never more than 85% of the pane). edge: how near the edge a swipe
// starts (24). dock: stand beside the content on a wide pane (true). back: use a step
// of the history while it is open, so Back closes it (true; false for a drawer inside
// a screen that has its own). The box takes the size of its parent, which needs a
// height.
//
// A menu button of your own is the way in for keys and screen readers; there is none
// here, as it belongs in your bar (TopAppBar's on_nav suits it). Give it the class
// ui-edgedrawer-menu and it goes when the drawer is docked. A link followed from
// the drawer leaves its step of the history behind, as taking it back would undo the
// link: one press of Back then shows the same page. Android's gesture navigation and
// Safari on an iPhone keep the edge for their own Back: there the menu button is the
// way in.

import ui/theme.qwe use ui_cls

let edge_drawer_css = css`
.ui-edgedrawer { display: block; width: 100%; height: 100%; min-height: 0; container-type: inline-size; color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }
.ui-edgedrawer-in { position: relative; display: flex; width: 100%; height: 100%; overflow: hidden; overscroll-behavior-x: contain; touch-action: pan-y pinch-zoom; background: var(--ui-bg); }
.ui-edgedrawer-main { flex: 1; min-width: 0; height: 100%; overflow: auto; overscroll-behavior: contain; touch-action: pan-y pinch-zoom; }
.ui-edgedrawer-panel {
  position: absolute; inset-block: 0; inset-inline-start: 0; z-index: 3; display: flex; flex-direction: column; box-sizing: border-box;
  width: min(var(--ui-edgedrawer-w, 300px), 85%); padding-top: env(safe-area-inset-top); padding-inline-start: env(safe-area-inset-left);
  background: var(--ui-surface); box-shadow: var(--ui-shadow-lg); outline: none; visibility: hidden; transform: translate3d(-100%, 0, 0);
  transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s; touch-action: pan-y pinch-zoom; overscroll-behavior: contain;
}
.ui-edgedrawer-panel:dir(rtl) { transform: translate3d(100%, 0, 0); }
.ui-edgedrawer-open .ui-edgedrawer-panel { visibility: visible; transform: none; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
.ui-edgedrawer-moving .ui-edgedrawer-panel { visibility: visible; transition: none; will-change: transform; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.ui-edgedrawer-scrim {
  position: absolute; inset: 0; z-index: 2; background: var(--ui-overlay); opacity: 0; visibility: hidden; touch-action: none;
  transition: opacity .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;
}
.ui-edgedrawer-open .ui-edgedrawer-scrim { opacity: 1; visibility: visible; transition: opacity .26s cubic-bezier(.2, .8, .2, 1); }
.ui-edgedrawer-moving .ui-edgedrawer-scrim { visibility: visible; transition: none; }
.ui-edgedrawer-head { display: flex; flex: none; align-items: center; gap: 8px; min-height: 52px; padding-inline: 16px 4px; border-bottom: 1px solid var(--ui-border); }
.ui-edgedrawer-head h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.ui-edgedrawer-close {
  display: grid; flex: none; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm);
  background: transparent; color: var(--ui-muted); cursor: pointer;
}
.ui-edgedrawer-close svg { width: 22px; height: 22px; }
.ui-edgedrawer-close:focus-visible { outline: none; box-shadow: var(--ui-focus); }
@media (hover: hover) { .ui-edgedrawer-close:hover { background: var(--ui-subtle); color: var(--ui-fg); } }
.ui-edgedrawer-menu { flex: none; }
/* touch-action stops counting at a scroller, so the one on the panel does not reach what is in this. */
.ui-edgedrawer-body { flex: 1; min-height: 0; padding-bottom: env(safe-area-inset-bottom); overflow: auto; overscroll-behavior: contain; touch-action: pan-y pinch-zoom; }
@container (min-width: 700px) {
  .ui-edgedrawer-dock .ui-edgedrawer-panel {
    position: static; flex: none; order: -1; width: min(var(--ui-edgedrawer-w, 300px), 40%); border-inline-end: 1px solid var(--ui-border);
    box-shadow: none; visibility: visible; transform: none; transition: none;
  }
  .ui-edgedrawer-dock .ui-edgedrawer-scrim, .ui-edgedrawer-dock .ui-edgedrawer-close, .ui-edgedrawer-dock .ui-edgedrawer-menu { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .ui-edgedrawer-panel, .ui-edgedrawer-scrim, .ui-edgedrawer-open .ui-edgedrawer-panel, .ui-edgedrawer-open .ui-edgedrawer-scrim { transition: none; }
}
`

// Where a drawer ends up. A flick decides by its direction; otherwise, the nearer side.
fn edge_drawer_settle(pos: num, width: num, speed: num) -> bool {
    if speed >= 0.35 { return true }
    if speed <= -0.35 { return false }
    pos >= width / 2
}

// A place for the panel, inside its range for any input.
fn edge_drawer_clamp(pos: num, width: num) -> num = pos < 0 ? 0 : pos > width ? width : pos

// Speed at release in px/ms: none if the finger rested before it lifted.
fn edge_drawer_speed(from_x: num, from_t: num, x: num, t: num, now: num) -> num {
    if now - t > 90 || t <= from_t { return 0 }
    (x - from_x) / (t - from_t)
}

// Where Tab goes next inside the panel: null leaves it to the browser. at: the place
// of what has the focus, -1 for the panel itself. Wraps at the ends.
fn edge_drawer_wrap(count: int, at: int, back: bool) -> int? {
    if count == 0 { return null }
    if back { return at <= 0 ? count - 1 : null }
    at < 0 || at == count - 1 ? 0 : null
}

let edge_drawer_focusable = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex='-1'])"

fn edge_drawer_paint(panel, scrim, pos: num, width: num, sign: num) {
    panel.style.transform = `translate3d(${(pos - width) * sign}px, 0, 0)`
    scrim.style.opacity = `${width > 0 ? pos / width : 0}`
}

fn edge_drawer_clear(panel, scrim) {
    panel.style.transform = ""
    scrim.style.opacity = ""
}

component EdgeDrawer(title: str = "Menu", open: bool = false, drawer = null, width: num = 300, edge: num = 24,
                     dock: bool = true, back: bool = true, on_change = null, class: str? = null, children) {
    style global edge_drawer_css
    state shown = open
    state asked = open          // the last `open` given: a change of it is a request
    state roomy = false         // the pane is wide enough to dock in
    state moving = false
    state stepped = false       // this open drawer has a history step of its own
    state win = null            // the window, as js() in a handler would wait, and a key's default not be stopped
    state root = null
    state grip = null           // what the handlers are on, so what a finger is held to
    state panel = null
    state scrim = null
    state before = null         // what had the focus, to give it back
    state g = null              // the finger: { sx, sy, fx, ft, x, t, on, base, sign, id, w }
    let wide = dock && roomy    // docked: nothing to pull, hold or close
    let modal = shown && !wide
    // An overlay coming open: it takes the focus and a step of the history.
    let enter = () => {
        before = win.document.activeElement
        if back {
            stepped = true
            win.history.pushState({ ...(win.history.state ?? {}), ui_edge_drawer: true }, "")
        }
        // Once it can take the focus: visibility comes with the state.
        win.requestAnimationFrame(() => { panel.focus({ preventScroll: true }) })
    }
    let place = (on: bool, step_back: bool) => {
        if on != shown {
            shown = on
            if on {
                if !wide { enter() }
            } else {
                if stepped {
                    stepped = false
                    if step_back { win.history.back() }
                }
                // After the update, as the content cannot take it while it is inert.
                let was = before
                before = null
                win.requestAnimationFrame(() => {
                    if was != null && was.isConnected { was.focus({ preventScroll: true }) }
                })
            }
            on_change && on_change(on)
        }
    }
    if win != null && open != asked {
        asked = open
        place(open, true)
    }
    // Back to how it was, for when the system takes the gesture, or a second finger comes.
    let abandon = () => {
        if g.on {
            moving = false
            edge_drawer_clear(panel, scrim)
        }
        g = null
    }
    let start = (e) => {
        if g != null && !e.isPrimary { abandon() }
        let sign = win.getComputedStyle(grip).direction == "rtl" ? -1 : 1
        let r = root.getBoundingClientRect()
        let from_edge = sign > 0 ? e.clientX - r.left : r.right - e.clientX
        if !wide && g == null && e.isPrimary && e.button == 0 && (shown || (from_edge >= 0 && from_edge <= edge)) {
            g = { sx: e.clientX, sy: e.clientY, fx: e.clientX, ft: e.timeStamp, x: e.clientX, t: e.timeStamp, on: false,
                  base: shown ? panel.offsetWidth : 0, sign: sign, id: e.pointerId, w: panel.offsetWidth }
        }
    }
    let move = (e) => {
        if g != null && e.pointerId == g.id {
            let dx = (e.clientX - g.sx) * g.sign
            let dy = e.clientY - g.sy
            if !g.on && abs(dx) + abs(dy) >= 10 {
                // Locked to one axis; opening starts only towards the middle.
                if abs(dy) >= abs(dx) || (!shown && dx < 0) {
                    g = null
                } else {
                    g = { ...g, on: true }
                    moving = true
                    grip.setPointerCapture(e.pointerId)
                }
            }
            if g != null && g.on {
                let keep = e.timeStamp - g.t > 40
                g = { ...g, fx: keep ? g.x : g.fx, ft: keep ? g.t : g.ft, x: e.clientX, t: e.timeStamp }
                edge_drawer_paint(panel, scrim, edge_drawer_clamp(g.base + dx, g.w), g.w, g.sign)
            }
        }
    }
    let release = (e) => {
        if g != null && e.pointerId == g.id {
            let was = g
            g = null
            if was.on {
                moving = false
                edge_drawer_clear(panel, scrim)
                let dx = (e.clientX - was.sx) * was.sign
                let speed = edge_drawer_speed(was.fx, was.ft, was.x, was.t, e.timeStamp) * was.sign
                place(edge_drawer_settle(edge_drawer_clamp(was.base + dx, was.w), was.w, speed), true)
            }
        }
    }
    let cancel = (e) => {
        if g != null && e.pointerId == g.id { abandon() }
    }
    <div.ui-edgedrawer @mount={(el) => {
             win = js("window")
             root = el
             roomy = el.clientWidth >= 700
             // Open from the start: it is an overlay like any other.
             if shown && !(dock && roomy) { enter() }
             let ro = js_new("ResizeObserver", () => { roomy = el.clientWidth >= 700 })
             ro.observe(el)
             // Back in a browser or on an Android phone. Forward does not open it again.
             let popped = () => {
                 let now = win.history.state
                 if shown && stepped && !(now != null && get(now, "ui_edge_drawer", false)) { place(false, false) }
             }
             win.addEventListener("popstate", popped)
             () => {
                 ro.disconnect()
                 win.removeEventListener("popstate", popped)
             }
         }}>
        <div class={ui_cls(["ui-edgedrawer-in", shown && "ui-edgedrawer-open", moving && "ui-edgedrawer-moving", dock && "ui-edgedrawer-dock", class])}
             style={`--ui-edgedrawer-w: ${width}px`} @mount={(el) => { grip = el }}
             @pointerdown={start} @pointermove={move} @pointerup={release} @pointercancel={cancel}
             @keydown={(e) => {
                 if modal && e.key == "Escape" {
                     e.preventDefault()
                     place(false, true)
                 }
                 if modal && e.key == "Tab" {
                     let items = panel.querySelectorAll(edge_drawer_focusable)
                     let count = items.length
                     let on = win.document.activeElement
                     let at = on.isSameNode(items.item(0)) ? 0 : on.isSameNode(items.item(count - 1)) ? count - 1 : panel.contains(on) && !on.isSameNode(panel) ? 1 : -1
                     let to = edge_drawer_wrap(count, at, e.shiftKey)
                     if to != null {
                         e.preventDefault()
                         items.item(to).focus()
                     }
                 }
             }}>
            <div.ui-edgedrawer-main inert={modal ? "" : null}>{children}</div>
            <div.ui-edgedrawer-scrim aria-hidden="true" @mount={(el) => { scrim = el }}
                 @click={() => { if !wide { place(false, true) } }}></div>
            <div.ui-edgedrawer-panel role={wide ? "navigation" : "dialog"} aria-modal={modal ? "true" : null} aria-label={title} tabindex="-1"
                 @mount={(el) => { panel = el }}
                 @click={(e) => {
                     if modal && e.target.closest("a[href]") != null { place(false, false) }
                 }}>
                <div.ui-edgedrawer-head>
                    <h2>{title}</h2>
                    <button.ui-edgedrawer-close type="button" aria-label="Close" @click={() => { place(false, true) }}>
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                             aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
                    </button>
                </div>
                <div.ui-edgedrawer-body>{drawer}</div>
            </div>
        </div>
    </div>
}
