Layout

ResizableBox

A box the user resizes by dragging its corner or an edge, within limits, with the keyboard too.

#layout#drag#keyboard#utility#interactive

Demo

Preview
Drag the corner, or focus it and use the arrow keys (Shift for bigger steps), Home and End.
320 by 200 pixels

320 × 200 pixels.

Only the width changes (axis="x"): a column of text to try line lengths with. The handle is along the right edge.

420 pixels wide

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

Example

The code of the preview above.

demos/resizable_box.qwe
component ResizableBoxDemoLive() {    state w = 320    state h = 200    <div.demo-stack>        <ResizableBox id="resizable-box-demo-live" width={320} height={200} min_width={160} max_width={600} min_height={100} max_height={400}                      label="Resize the preview" on_resize={(nw, nh) => { w = nw; h = nh }}>            <div style="display: grid; place-items: center; height: 100%; padding: 16px; text-align: center; color: var(--ui-muted); font-size: 13px">                Drag the corner, or focus it and use the arrow keys (Shift for bigger steps), Home and End.            </div>        </ResizableBox>        <p.demo-note>{w} × {h} pixels.</p>    </div>}view ResizableBoxDemo() =    <div.demo-stack>        <ResizableBoxDemoLive/>        <ResizableBox id="resizable-box-demo-wide" axis="x" width={420} min_width={200} max_width={800} label="Resize the column">            <p style="margin: 0; padding: 16px; font-size: 13px">Only the width changes (axis="x"): a column of text to try line lengths with. The handle is along the right edge.</p>        </ResizableBox>    </div>

Source

ui/resizable_box.qwe
// ResizableBox: a box whose size the user sets by dragging its corner (or an// edge), within limits: a preview pane, a textarea-like editor, a demo of how// something behaves at different widths. The handle takes focus: the arrow keys// change the size by `step` pixels (Shift: by ten times that), Home and End go to// the smallest and the largest, and screen readers hear the size.////   <ResizableBox width={360} height={220} min_width={200} max_width={720}> <Preview/> </ResizableBox>//   <ResizableBox axis="x" width={300} on_resize={(w, h) => { px = w }}> ... </ResizableBox>//// width, height: the starting size, in pixels (320 by 200). min_width, max_width,// min_height, max_height: the limits (120 to 960, 80 to 640; max_width is also// capped at the container's width while dragging).// axis: "both" (the corner handle, the default), "x" (a right-edge handle, the width// only) or "y" (a bottom-edge handle, the height only). step: pixels per arrow key (10).// label: the handle's name for screen readers ("Resize"). show_size: the size shows// in the corner while it is dragged (true).// on_resize(width, height): inside one of your components, called after each drag or key.import ui/theme.qwe use ui_clslet resizable_box_css = css`.ui-resizable {  position: relative; box-sizing: border-box; width: var(--ui-resizable-w, 320px); height: var(--ui-resizable-h, 200px); max-width: 100%;  min-width: 0; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface);  color: var(--ui-fg); font: 14px/1.5 var(--ui-font);}.ui-resizable-x { height: auto; }.ui-resizable-y { width: auto; }.ui-resizable-body { height: 100%; min-height: 0; overflow: auto; }.ui-resizable-handle {  position: absolute; z-index: 2; display: grid; place-items: center; margin: 0; padding: 0; border: 0; background: transparent;  color: var(--ui-muted); cursor: nwse-resize; touch-action: none; user-select: none;}.ui-resizable-handle svg { width: 14px; height: 14px; }.ui-resizable-corner { right: 0; bottom: 0; width: 22px; height: 22px; }.ui-resizable-right { top: 0; bottom: 0; right: 0; width: 12px; cursor: ew-resize; }.ui-resizable-bottom { left: 0; right: 0; bottom: 0; height: 12px; cursor: ns-resize; }.ui-resizable-right::before, .ui-resizable-bottom::before { content: ""; border-radius: 999px; background: var(--ui-strong); }.ui-resizable-right::before { width: 3px; height: 28px; }.ui-resizable-bottom::before { width: 28px; height: 3px; }.ui-resizable-handle:hover, .ui-resizable-dragging .ui-resizable-handle { color: var(--ui-accent); }.ui-resizable-handle:hover::before, .ui-resizable-dragging .ui-resizable-handle::before { background: var(--ui-accent); }.ui-resizable-handle:focus-visible { outline: none; color: var(--ui-accent); }.ui-resizable-handle:focus-visible::after, .ui-resizable-dragging::after {  content: ""; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; box-shadow: var(--ui-focus);}.ui-resizable:has(.ui-resizable-handle:focus-visible) { box-shadow: var(--ui-focus); }.ui-resizable-dragging { box-shadow: var(--ui-focus); }.ui-resizable-dragging .ui-resizable-body { pointer-events: none; }.ui-resizable-size {  position: absolute; right: 8px; bottom: 8px; z-index: 1; padding: 2px 7px; border-radius: var(--ui-radius-sm);  background: var(--ui-fg); color: var(--ui-bg); font: 500 11px/1.4 var(--ui-mono); pointer-events: none; opacity: 0; transition: opacity .15s;}.ui-resizable-dragging .ui-resizable-size { opacity: .9; }.ui-resizable-corner + .ui-resizable-size { right: 24px; }@media (prefers-reduced-motion: reduce) { .ui-resizable-size { transition: none; } }`fn resizable_box_clamp(v: num, lo: num, hi: num) -> num = v < lo ? lo : v > hi ? hi : vcomponent ResizableBox(width: int = 320, height: int = 200, min_width: int = 120, max_width: int = 960, min_height: int = 80,                       max_height: int = 640, axis: str = "both", step: int = 10, label: str = "Resize", show_size: bool = true,                       on_resize = null, id: str? = null, class: str? = null, children) {    style global resizable_box_css    state w = resizable_box_clamp(width, min_width, max_width)    state h = resizable_box_clamp(height, min_height, max_height)    state dragging = false    state from = null    let across = axis != "y"    let down = axis != "x"    let tell = () => { on_resize && on_resize(round(w), round(h)) }    let to = (nw: num, nh: num) => {        if across { w = resizable_box_clamp(nw, min_width, max_width) }        if down { h = resizable_box_clamp(nh, min_height, max_height) }    }    let said = across && down ? `${round(w)} by ${round(h)} pixels` : across ? `${round(w)} pixels wide` : `${round(h)} pixels tall`    <div class={ui_cls(["ui-resizable", axis == "x" && "ui-resizable-x", axis == "y" && "ui-resizable-y", dragging && "ui-resizable-dragging", class])}         id={id} style={`--ui-resizable-w: ${w}px; --ui-resizable-h: ${h}px`}>        <div.ui-resizable-body>{children}</div>        <button type="button" class={ui_cls(["ui-resizable-handle", axis == "both" ? "ui-resizable-corner" : axis == "x" ? "ui-resizable-right" : "ui-resizable-bottom"])}                aria-label={label} aria-roledescription="resize handle" aria-describedby={id != null ? `${id}-size` : null}                title={label}                @pointerdown={(e) => {                    if e.button == 0 {                        e.preventDefault()                        e.target.setPointerCapture(e.pointerId)                        let box = e.target.closest(".ui-resizable")                        // (The box's own parent may be a component's island, which has no box: take the first that does.)                        let holder = box.parentElement.closest(":not(quillo-island):not(quillo-raw)")                        let room = holder.getBoundingClientRect().right - box.getBoundingClientRect().left                        from = { x: e.clientX, y: e.clientY, w, h, room }                        dragging = true                    }                }}                @pointermove={(e) => {                    if dragging {                        let nw = from.w + e.clientX - from.x                        to(nw > from.room ? from.room : nw, from.h + e.clientY - from.y)                    }                }}                @pointerup={() => {                    if dragging {                        dragging = false                        tell()                    }                }}                @pointercancel={() => { dragging = false }}                @keydown={(e) => {                    let by = e.shiftKey ? step * 10 : step                    let moved = match e.key {                        "ArrowRight" => across ? { nw: w + by, nh: h } : null                        "ArrowLeft" => across ? { nw: w - by, nh: h } : null                        "ArrowDown" => down ? { nw: w, nh: h + by } : null                        "ArrowUp" => down ? { nw: w, nh: h - by } : null                        "Home" => { nw: min_width, nh: min_height }                        "End" => { nw: max_width, nh: max_height }                        _ => null                    }                    if moved != null {                        e.preventDefault()                        to(moved.nw, moved.nh)                        tell()                    }                }}>            {axis == "both" && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M14 2 2 14M14 8l-6 6M14 14h0"/></svg>}        </button>        {show_size && <span.ui-resizable-size aria-hidden="true">{round(w)} × {round(h)}</span>}        <span.ui-sr id={id != null ? `${id}-size` : null} aria-live="polite">{said}</span>    </div>}
Thread
55Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.