RotationLayout
Two arrangements of one set of children, upright and turned: no flash, and focus and scroll kept across the turn.
Demo
Stop 1: the depot
Stop 2: the car park behind the school, on the left as you come in
Stop 3: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 4: the depot
Stop 5: the car park behind the school, on the left as you come in
Stop 6: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 7: the depot
Stop 8: the car park behind the school, on the left as you come in
Stop 9: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 10: the depot
Stop 11: the car park behind the school, on the left as you come in
Stop 12: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 13: the depot
Stop 14: the car park behind the school, on the left as you come in
Stop 15: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 16: the depot
Stop 17: the car park behind the school, on the left as you come in
Stop 18: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 19: the depot
Stop 20: the car park behind the school, on the left as you come in
Stop 21: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 22: the depot
Stop 23: the car park behind the school, on the left as you come in
Stop 24: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 25: the depot
Stop 26: the car park behind the school, on the left as you come in
Stop 27: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 28: the depot
Stop 29: the car park behind the school, on the left as you come in
Stop 30: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 31: the depot
Stop 32: the car park behind the school, on the left as you come in
Stop 33: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 34: the depot
Stop 35: the car park behind the school, on the left as you come in
Stop 36: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 37: the depot
Stop 38: the car park behind the school, on the left as you come in
Stop 39: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 40: the depot
Stop 41: the car park behind the school, on the left as you come in
Stop 42: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 43: the depot
Stop 44: the car park behind the school, on the left as you come in
Stop 45: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 46: the depot
Stop 47: the car park behind the school, on the left as you come in
Stop 48: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 49: the depot
Stop 50: the car park behind the school, on the left as you come in
Stop 51: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 52: the depot
Stop 53: the car park behind the school, on the left as you come in
Stop 54: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 55: the depot
Stop 56: the car park behind the school, on the left as you come in
Stop 57: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Stop 58: the depot
Stop 59: the car park behind the school, on the left as you come in
Stop 60: the long way round the reservoir, past the old pumping station and over the footbridge to the north gate
Turned: more room for the map.
This one reads the shape of its own box (unknown), not the screen's:
Given an arrangement that is not a rectangle, it stacks:
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/rotation_layout.qweimport ui/theme.qwe use theme_cssuse ui/rotation_layout.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let rotation_layout_demo_css = css`.demo-rotation { display: flex; flex-direction: column; height: 100%; font: 14px/1.45 var(--ui-font); }.demo-rotation-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.demo-rotation-body { flex: 1; min-height: 0; padding: 12px; }.demo-rotation-photo { height: 100%; min-height: 120px; border-radius: var(--ui-radius); background: linear-gradient(135deg, #2b6f8f, #7ab77a 60%, #e0c56e); }.demo-rotation-card { display: grid; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }.demo-rotation-card label { font-weight: 600; }.demo-rotation-card input { box-sizing: border-box; width: 100%; height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.2 var(--ui-font); }.demo-rotation-stop { margin: 0; padding: 10px 12px; border-bottom: 1px solid var(--ui-border); }.demo-rotation-stops { border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }.demo-rotation-tip { padding: 12px; border-radius: var(--ui-radius); background: var(--ui-accent-soft); }.demo-rotation-tile { display: grid; place-items: center; min-height: 40px; border-radius: var(--ui-radius-sm); background: var(--ui-accent-soft); }`let rotation_layout_demo_stops = range(1, 61) | map((n) => { n: n, text: `Stop ${n}: ${n % 3 == 0 ? "the long way round the reservoir, past the old pumping station and over the footbridge to the north gate" : n % 3 == 1 ? "the depot" : "the car park behind the school, on the left as you come in"}`})component RotationLayoutDemoLive() { style global rotation_layout_demo_css state facing = "unknown" state shape = "unknown" <div.demo-stack> <div.demo-tablet> <div.demo-rotation> <div.demo-rotation-bar> <strong>Site survey</strong> <output id="rotation-demo-facing">{facing}</output> </div> <div.demo-rotation-body> <RotationLayout portrait={["photo", "details", "stops"]} portrait_rows="180px auto minmax(0, 1fr)" landscape={["photo stops", "tip stops", "details stops"]} landscape_columns="3fr 2fr" landscape_rows="minmax(0, 1fr) auto auto" on_turn={(o) => { facing = o }}> <RotationLayoutArea name="photo"><div.demo-rotation-photo id="rotation-demo-photo" role="img" aria-label="The site from the north gate"></div></RotationLayoutArea> <RotationLayoutArea name="details"> <div.demo-rotation-card> <label for="rotation-demo-name">Name of the site</label> <input id="rotation-demo-name" type="text" autocomplete="off"/> </div> </RotationLayoutArea> <RotationLayoutArea name="stops" scroll={true} class="demo-rotation-stops"> <div id="rotation-demo-stops"> {for s in rotation_layout_demo_stops => <p.demo-rotation-stop key={s.n} id={`rotation-demo-stop-${s.n}`}>{s.text}</p>} </div> </RotationLayoutArea> <RotationLayoutArea name="tip" only="landscape"> <p.demo-rotation-tip id="rotation-demo-tip">Turned: more room for the map.</p> </RotationLayoutArea> </RotationLayout> </div> </div> </div> <p.demo-note>This one reads the shape of its own box ({shape}), not the screen's:</p> <div id="rotation-demo-box-wrap" style="width: 100%; max-width: 480px; height: 200px"> <RotationLayout fit="box" portrait={["a", "b"]} landscape={["a b"]} on_turn={(o) => { shape = o }}> <RotationLayoutArea name="a"><div.demo-rotation-tile id="rotation-demo-a">First</div></RotationLayoutArea> <RotationLayoutArea name="b"><div.demo-rotation-tile id="rotation-demo-b">Second</div></RotationLayoutArea> </RotationLayout> </div> <p.demo-note>Given an arrangement that is not a rectangle, it stacks:</p> <div id="rotation-demo-bad"> <RotationLayout portrait={["a b", "b a"]} landscape={["a a b", "c"]} height="auto"> <RotationLayoutArea name="a"><div.demo-rotation-tile>One</div></RotationLayoutArea> <RotationLayoutArea name="b"><div.demo-rotation-tile>Two</div></RotationLayoutArea> </RotationLayout> </div> </div>}view RotationLayoutDemo() = <RotationLayoutDemoLive/>Source
// RotationLayout: one set of children in two arrangements, one for upright and one for// turned. Nothing is moved or made again: the same elements are placed by a CSS grid,// so the right arrangement is on the first paint, a video keeps playing, and what is// focused stays focused, keyboard and all. A person's place is kept as well: a list// shows the same row at its top after a turn, though everything has been laid out anew.//// import ui/rotation_layout.qwe use RotationLayout, RotationLayoutArea//// <RotationLayout portrait={["photo", "details", "stops"]} portrait_rows="240px auto 1fr"// landscape={["photo stops", "details stops"]} landscape_columns="3fr 2fr"// on_turn={(o) => { facing = o }}>// <RotationLayoutArea name="photo"><img src="/site.jpg" alt="The site"/></RotationLayoutArea>// <RotationLayoutArea name="details"><Details/></RotationLayoutArea>// <RotationLayoutArea name="stops" scroll={true}><Stops/></RotationLayoutArea>// </RotationLayout>//// portrait, landscape: the arrangement, a list of rows, each row the areas' names with// spaces between, as CSS's grid-template-areas: an area may span rows or columns, as// long as it stays a rectangle. Left out, or not a rectangle, the areas stack in order.// Name every area in both, or mark it `only`: one left out shares a cell with another.// portrait_columns, portrait_rows, landscape_columns, landscape_rows: the sizes of the// grid's tracks, as CSS ("3fr 2fr", "240px auto 1fr"); left out, columns share the// width and rows take what they need.// fit: what "turned" means. "screen" (the default) looks at the window, which on an// iPad in Split View is the window's share. "box" looks at the shape of this component's// own box, for one sitting in a pane: it then needs a height.// height: its height, as CSS ("100%", the default, of the box around it).// gap: the space between areas in px (12).// on_turn(orientation): inside one of your components, called with "portrait" or// "landscape" once it has measured and at each turn.// class: added to its root. data-orientation on the root says which, for your CSS.//// RotationLayoutArea(name): one area, named as in the arrangements. scroll: it scrolls// by itself. only: "portrait" or "landscape" keeps it to that arrangement: it stays on// the page the rest of the time, out of sight, with its state and its scroll.import ui/theme.qwe use ui_clslet rotation_layout_css = css`.ui-rotation { position: relative; min-width: 0; height: var(--ui-rotation-h, 100%); }.ui-rotation-box { container: ui-rotation / size; }.ui-rotation-grid { display: grid; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; gap: var(--ui-rotation-gap, 12px); grid-template-areas: var(--ui-rotation-p-areas, none); grid-template-columns: var(--ui-rotation-p-cols, none); grid-template-rows: var(--ui-rotation-p-rows, none); grid-auto-columns: minmax(0, 1fr); --ui-rotation-place: var(--ui-rotation-p-place);}/* The arrangement is CSS alone, so it changes in the same frame as the turn: no script runs first, so nothing flashes. */@media (orientation: landscape) { .ui-rotation-screen > .ui-rotation-grid { grid-template-areas: var(--ui-rotation-l-areas, none); grid-template-columns: var(--ui-rotation-l-cols, none); grid-template-rows: var(--ui-rotation-l-rows, none); --ui-rotation-place: var(--ui-rotation-l-place); }}@container ui-rotation (orientation: landscape) { .ui-rotation-grid { grid-template-areas: var(--ui-rotation-l-areas, none); grid-template-columns: var(--ui-rotation-l-cols, none); grid-template-rows: var(--ui-rotation-l-rows, none); --ui-rotation-place: var(--ui-rotation-l-place); }}.ui-rotation-area { min-width: 0; min-height: 0; }/* Not Chrome's own scroll anchoring: Safari has none, so both go by the one below. */.ui-rotation-area-scroll { overflow: auto; overflow-anchor: none; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }/* Out of sight by visibility, not display: what is not laid out forgets its scroll. */.ui-rotation-area[data-only="landscape"] { position: absolute; width: 0; height: 0; overflow: hidden; visibility: hidden; }@media (orientation: portrait) { .ui-rotation-screen .ui-rotation-area[data-only="portrait"] { position: static; width: auto; height: auto; visibility: visible; }}@media (orientation: landscape) { .ui-rotation-screen .ui-rotation-area[data-only="landscape"] { position: static; width: auto; height: auto; visibility: visible; } .ui-rotation-screen .ui-rotation-area[data-only="portrait"] { position: absolute; width: 0; height: 0; overflow: hidden; visibility: hidden; }}.ui-rotation-box .ui-rotation-area[data-only="portrait"] { position: static; width: auto; height: auto; visibility: visible; }@container ui-rotation (orientation: landscape) { .ui-rotation-area[data-only="landscape"] { position: static; width: auto; height: auto; visibility: visible; } .ui-rotation-area[data-only="portrait"] { position: absolute; width: 0; height: 0; overflow: hidden; visibility: hidden; }}`// CSS's grid-template-areas from a list of rows. Every row needs as many names as the// first and each name must fill a rectangle, or the browser drops the whole property// and every area lands where it was going to anyway: `none` says so plainly.fn rotation_layout_areas(rows) -> str { if type_of(rows) != "list" || len(rows) == 0 { return "none" } let cells = rows | map((r) => type_of(r) == "str" ? split(trim(r), " ") | filter((c) => c != "") : []) let width = len(cells[0]) if width == 0 || any(cells, (r) => len(r) != width) { return "none" } let names = flatten(cells) | unique() | filter((n) => n != ".") if any(names, (n) => !re.test(n, "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$")) { return "none" } let square = all(names, (n) => rotation_layout_square(cells, n)) if !square { return "none" } rows | map((r) => `"${trim(r)}"`) | join(" ")}// Whether a name fills exactly the rectangle around it.fn rotation_layout_square(cells, name: str) -> bool { let at = flatten(range(len(cells)) | map((y) => range(len(cells[y])) | filter((x) => cells[y][x] == name) | map((x) => { x: x, y: y }))) let xs = at | map((c) => c.x) let ys = at | map((c) => c.y) (max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1) == len(at)}// Tracks as written, with the two things that would break out of a style attribute kept out.fn rotation_layout_track(text) -> str? { if type_of(text) != "str" || trim(text) == "" { return null } re.test(text, "[;{}<>\"]") ? null : text}fn rotation_layout_style(portrait, landscape, pc, pr, lc, lr, height, gap) -> str { // A name with no such area puts every child on one cell, so without an arrangement // the areas go back to the grid's own order: --ui-rotation-*-place, read by each area. let p = rotation_layout_areas(portrait) let l = rotation_layout_areas(landscape) let parts = [`--ui-rotation-p-areas: ${p}`, `--ui-rotation-l-areas: ${l}`] + (p == "none" ? ["--ui-rotation-p-place: auto"] : []) + (l == "none" ? ["--ui-rotation-l-place: auto"] : []) let tracks = [["p-cols", pc], ["p-rows", pr], ["l-cols", lc], ["l-rows", lr]] | filter((t) => rotation_layout_track(t[1]) != null) | map((t) => `--ui-rotation-${t[0]}: ${t[1]}`) let tall = rotation_layout_track(height) != null ? [`--ui-rotation-h: ${height}`] : [] join(parts + tracks + tall + [`--ui-rotation-gap: ${gap}px`], "; ")}view RotationLayoutArea(name: str = "", scroll: bool = false, only: str? = null, class: str? = null, children) { style global rotation_layout_css let safe = re.test(name, "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$") <div class={ui_cls(["ui-rotation-area", scroll && "ui-rotation-area-scroll", class])} data-area={name} data-only={only == "portrait" || only == "landscape" ? only : null} style={safe ? `grid-area: var(--ui-rotation-place, ${name})` : null}>{children}</div>}component RotationLayout(portrait = null, landscape = null, portrait_columns: str? = null, portrait_rows: str? = null, landscape_columns: str? = null, landscape_rows: str? = null, fit: str = "screen", height: str? = null, gap: num = 12, on_turn = null, class: str? = null, children) { style global rotation_layout_css state facing = "" // "" until measured: the CSS does not need to know state marks = [] // each scroller in play: what is at its top, and how far above the edge it starts let box = fit == "box" <div class={ui_cls(["ui-rotation", box ? "ui-rotation-box" : "ui-rotation-screen", class])} data-orientation={facing == "" ? null : facing} style={rotation_layout_style(portrait, landscape, portrait_columns, portrait_rows, landscape_columns, landscape_rows, height, gap)} @mount={(el) => { let win = js("window") let doc = win.document let wide = win.matchMedia("(orientation: landscape)") let page = doc.scrollingElement // Noted on every scroll, since by the time a turn is known the old layout has // gone. Elements are told apart by isSameNode: == calls any two of them equal. let note = (e) => { let t = doc.isSameNode(e.target) ? page : e.target if t.contains != null && (t.contains(el) || el.contains(t)) { let whole = t.isSameNode(page) let r = whole ? { left: 0, top: 0 } : t.getBoundingClientRect() let at = t.scrollTop > 0 ? doc.elementFromPoint(r.left + 12, r.top + 2) : null let rest = marks | filter((m) => !m.s.isSameNode(t)) marks = at != null && t.contains(at) && !at.isSameNode(t) ? rest + [{ s: t, a: at, d: at.getBoundingClientRect().top - r.top }] : rest } } let back = () => { for m in marks { let top = m.s.isSameNode(page) ? 0 : m.s.getBoundingClientRect().top m.s.scrollTop = m.s.scrollTop + m.a.getBoundingClientRect().top - top - m.d } let held = doc.activeElement if held != null && el.contains(held) { held.scrollIntoView({ block: "nearest" }) } } let look = (_e = null) => { let next = box ? (el.clientWidth >= el.clientHeight ? "landscape" : "portrait") : (wide.matches ? "landscape" : "portrait") if next != facing { let first = facing == "" facing = next if !first { back() } on_turn && on_turn(next) } } let seen = js_new("ResizeObserver", look) seen.observe(el) // The window's resize comes before the frame's scroll events, which would // otherwise tell of the clamped places a shorter page leaves behind. win.addEventListener("resize", look, { passive: true }) win.addEventListener("scroll", note, { capture: true, passive: true }) () => { seen.disconnect() win.removeEventListener("resize", look) win.removeEventListener("scroll", note, { capture: true }) } }}> <div.ui-rotation-grid>{children}</div> </div>}Related
Thread 0 posts
On RotationLayout: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.