SizeClassView
Tells 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.
Demo
Today
Delivery 1
Delivery 2
Delivery 3
Delivery 4
Delivery 5
Delivery 6
Delivery 7
Delivery 8
Delivery 9
Delivery 10
Delivery 11
Delivery 12
Delivery 13
Delivery 14
Delivery 15
Delivery 16
Delivery 17
Delivery 18
Delivery 19
Delivery 20
Delivery 21
Delivery 22
Delivery 23
Delivery 24
Delivery 25
Delivery 26
Delivery 27
Delivery 28
Delivery 29
Delivery 30
Delivery 31
Delivery 32
Delivery 33
Delivery 34
Delivery 35
Delivery 36
Delivery 37
Delivery 38
Delivery 39
Delivery 40
Today
Delivery 1
Delivery 2
Delivery 3
Delivery 4
Delivery 5
Delivery 6
Delivery 7
Delivery 8
Delivery 9
Delivery 10
Delivery 11
Delivery 12
Delivery 13
Delivery 14
Delivery 15
Delivery 16
Delivery 17
Delivery 18
Delivery 19
Delivery 20
Delivery 21
Delivery 22
Delivery 23
Delivery 24
Delivery 25
Delivery 26
Delivery 27
Delivery 28
Delivery 29
Delivery 30
Delivery 31
Delivery 32
Delivery 33
Delivery 34
Delivery 35
Delivery 36
Delivery 37
Delivery 38
Delivery 39
Delivery 40
Van
Today
Delivery 1
Delivery 2
Delivery 3
Delivery 4
Delivery 5
Delivery 6
Delivery 7
Delivery 8
Delivery 9
Delivery 10
Delivery 11
Delivery 12
Delivery 13
Delivery 14
Delivery 15
Delivery 16
Delivery 17
Delivery 18
Delivery 19
Delivery 20
Delivery 21
Delivery 22
Delivery 23
Delivery 24
Delivery 25
Delivery 26
Delivery 27
Delivery 28
Delivery 29
Delivery 30
Delivery 31
Delivery 32
Delivery 33
Delivery 34
Delivery 35
Delivery 36
Delivery 37
Delivery 38
Delivery 39
Delivery 40
Van
Driver
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/size_class_view.qweimport ui/theme.qwe use theme_cssuse ui/size_class_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let size_class_view_demo_css = css`.demo-size-class { display: flex; flex-direction: column; height: 100%; }.demo-size-class-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); font: 14px/1.4 var(--ui-font); }.demo-size-class-bar output { margin-left: auto; font-weight: 600; }.demo-size-class-room { flex: 1; min-height: 0; background: var(--ui-subtle); }.demo-size-class-box { height: 100%; margin: 0 auto; background: var(--ui-bg); border-inline: 1px solid var(--ui-border); }.demo-size-class-cols { display: grid; gap: 16px; height: 100%; padding: 16px; }.demo-size-class-col { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; }.demo-size-class-col h4 { margin: 0; font: 600 15px/1.3 var(--ui-font); }.demo-size-class-scroll { flex: 1 1 200px; min-height: 120px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }.demo-size-class-scroll p { margin: 0; padding: 10px 12px; border-bottom: 1px solid var(--ui-border); font: 14px/1.4 var(--ui-font); }.demo-size-class-col textarea { box-sizing: border-box; width: 100%; min-height: 72px; padding: 8px 10px; 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); }`let size_class_view_demo_rooms = [ { id: "full", label: "Whole screen", css: "width: 100%" }, { id: "two", label: "Two thirds", css: "width: 66.6%" }, { id: "half", label: "Half", css: "width: 50%" }, { id: "over", label: "Slide Over", css: "width: 320px" },]let size_class_view_demo_rows = range(1, 41) | map((n) => `Delivery ${n}`)component SizeClassViewDemoCount(name: str) { state n = 0 <Button id={`size-demo-count-${name}`} size="lg" variant="soft" on_click={() => { n += 1 }}>Packed {n}</Button>}view SizeClassViewDemoList(name: str) = <div.demo-size-class-scroll id={`size-demo-list-${name}`} data-keep="deliveries" tabindex="0" aria-label="Deliveries"> {for r in size_class_view_demo_rows => <p>{r}</p>} </div>view SizeClassViewDemoNote(name: str) = <textarea id={`size-demo-note-${name}`} data-keep="note" aria-label="Note for the driver" placeholder="Note for the driver"></textarea>component SizeClassViewDemoLive() { style global size_class_view_demo_css state room = "full" state size = "unknown" let shown = size_class_view_demo_rooms | filter((r) => r.id == room) <div.demo-tablet> <div.demo-size-class> <div.demo-size-class-bar> {for r in size_class_view_demo_rooms => <Button key={r.id} id={`size-demo-room-${r.id}`} size="lg" variant={r.id == room ? "solid" : "outline"} on_click={() => { room = r.id }}>{r.label}</Button>} <output id="size-demo-now">{size}</output> </div> <div.demo-size-class-room> <div.demo-size-class-box id="size-demo-box" style={shown[0].css}> <SizeClassView fill={true} on_change={(c) => { size = c }}> <SizeClassSlot when="compact"> <div.demo-size-class-cols> <div.demo-size-class-col> <h4>Today</h4> <SizeClassViewDemoCount name="compact"/> <SizeClassViewDemoNote name="compact"/> <SizeClassViewDemoList name="compact"/> </div> </div> </SizeClassSlot> <SizeClassSlot when="medium"> <div.demo-size-class-cols style="grid-template-columns: 1fr 1fr"> <div.demo-size-class-col><h4>Today</h4><SizeClassViewDemoList name="medium"/></div> <div.demo-size-class-col> <h4>Van</h4> <SizeClassViewDemoCount name="medium"/> <SizeClassViewDemoNote name="medium"/> </div> </div> </SizeClassSlot> <SizeClassSlot when="expanded"> <div.demo-size-class-cols style="grid-template-columns: 1fr 1fr 1fr"> <div.demo-size-class-col><h4>Today</h4><SizeClassViewDemoList name="expanded"/></div> <div.demo-size-class-col><h4>Van</h4><SizeClassViewDemoCount name="expanded"/></div> <div.demo-size-class-col><h4>Driver</h4><SizeClassViewDemoNote name="expanded"/></div> </div> </SizeClassSlot> </SizeClassView> </div> </div> </div> </div>}view SizeClassViewDemo() = <SizeClassViewDemoLive/>Source
// SizeClassView: says whether the room it has is compact, medium or expanded, and// shows the slots meant for that. It measures its own box, not the screen, so an iPad// in Split View, a Slide Over, a pane or a turn are all the same thing to it: a new// width. Every slot stays on the page and only the shown one is drawn, so what is in// the others keeps its state, its text and its scroll while it is away.//// import ui/size_class_view.qwe use SizeClassView, SizeClassSlot//// <SizeClassView on_change={(c) => { size = c }}>// <SizeClassSlot when="compact"><Stacked/></SizeClassSlot>// <SizeClassSlot when="medium expanded"><Columns/></SizeClassSlot>// </SizeClassView>//// compact is under 600 px wide, medium under 840, expanded the rest: a phone, an iPad// upright, an iPad turned. They are fixed, as CSS has to know them before any script// runs and a custom property cannot reach a container query; that is why the right// slot shows on the first paint, with no flash.//// children: SizeClassSlots, or anything else, which shows at every size.// on_change(size): inside one of your components, called with "compact", "medium" or// "expanded" when the browser first measures and each time that changes.// fill: take the height of the box around it (false: as tall as what is in it).// class: added to its root. data-size-class on the root says the size, for your CSS.//// SizeClassSlot(when): shown at the sizes in `when`, separated by spaces.//// data-keep="name" on an element in two slots makes them one thing to a person: when// the size changes, the one coming into view takes the other's text, scroll and// caret, and the focus if it had it, so someone typing goes on typing. Nothing else// moves between slots: for a value both must show, hold it in your own state.import ui/theme.qwe use ui_clslet size_class_view_css = css`.ui-size-class { position: relative; container: ui-size-class / inline-size; min-width: 0; width: 100%; }.ui-size-class-fill { height: 100%; }/* Hidden with visibility, not display: an element that is not laid out forgets its scroll, and these must come back as they were. Neither focus nor a screen reader reaches it. */.ui-size-class-slot { position: absolute; inset: 0 0 auto 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; }/* Chrome's scroll anchoring moves a list as its slot leaves, by the height it lost. */.ui-size-class-slot * { overflow-anchor: none; }@container ui-size-class (max-width: 599.98px) { .ui-size-class-slot[data-when~="compact"] { position: relative; inset: auto; height: auto; overflow: visible; visibility: visible; pointer-events: auto; }}@container ui-size-class (min-width: 600px) and (max-width: 839.98px) { .ui-size-class-slot[data-when~="medium"] { position: relative; inset: auto; height: auto; overflow: visible; visibility: visible; pointer-events: auto; }}@container ui-size-class (min-width: 840px) { .ui-size-class-slot[data-when~="expanded"] { position: relative; inset: auto; height: auto; overflow: visible; visibility: visible; pointer-events: auto; }}.ui-size-class-fill > .ui-size-class-slot { min-height: 0; }@container ui-size-class (max-width: 599.98px) { .ui-size-class-fill > .ui-size-class-slot[data-when~="compact"] { height: 100%; } }@container ui-size-class (min-width: 600px) and (max-width: 839.98px) { .ui-size-class-fill > .ui-size-class-slot[data-when~="medium"] { height: 100%; } }@container ui-size-class (min-width: 840px) { .ui-size-class-fill > .ui-size-class-slot[data-when~="expanded"] { height: 100%; } }`// The one place that names the sizes, as the CSS above does. A width that is not a// number is the widest, where nothing is hidden for want of room.fn size_class_view_of(width) -> str { let w = type_of(width) == "int" || type_of(width) == "float" ? width : 10000 w < 600 ? "compact" : w < 840 ? "medium" : "expanded"}// Whether a slot's `when` names a size: whole words, so "compact" never matches "not-compact".fn size_class_view_in(when, size: str) -> bool = contains(split(str(when ?? ""), " "), size)// Whether element e is in a slot shown at size s. A top-level function, as a component's// own may not be given to `filter` on every Quillo version.fn size_class_view_inside(e, s: str) -> bool { let slot = e.closest(".ui-size-class-slot") slot != null && size_class_view_in(slot.dataset.when, s)}// Every element with a data-keep of that name, in whichever slots.fn size_class_view_twins(root, name: str) { let found = root.querySelectorAll("[data-keep]") range(found.length) | map((i) => found[i]) | filter((e) => e.dataset.keep == name)}view SizeClassSlot(when: str = "compact medium expanded", class: str? = null, children) { style global size_class_view_css <div class={ui_cls(["ui-size-class-slot", class])} data-when={when}>{children}</div>}component SizeClassView(on_change = null, fill: bool = false, class: str? = null, children) { style global size_class_view_css state size = "" // "" until measured: the server cannot know, and the CSS does not need to state kept = "" // the data-keep of what holds the focus state tops = {} // each data-keep's scroll, noted as it scrolls: a slot leaving is laid out anew before anyone can ask it // What came into view takes over from what left. Each pair is looked at once, by // name, so three slots sharing a name still give the text of the one that left. let carry = (root, left: str, came: str) => { let all = root.querySelectorAll("[data-keep]") let names = range(all.length) | map((i) => all[i].dataset.keep) | unique() for name in names { let twins = size_class_view_twins(root, name) let from = twins | filter((e) => size_class_view_inside(e, left) && !size_class_view_inside(e, came)) let to = twins | filter((e) => size_class_view_inside(e, came) && !size_class_view_inside(e, left)) if len(from) > 0 && len(to) > 0 { let a = from[0] let b = to[0] if a.value != null && a.value != b.value { b.value = a.value b.dispatchEvent(js_new("Event", "input", { bubbles: true })) } let top = get(tops, name, 0) a.scrollTop = top b.scrollTop = top if kept == name { b.focus({ preventScroll: true }) if a.selectionStart != null && b.setSelectionRange != null { b.setSelectionRange(a.selectionStart, a.selectionEnd) } } } } } <div class={ui_cls(["ui-size-class", fill && "ui-size-class-fill", class])} data-size-class={size == "" ? null : size} @focusin={(e) => { let holder = e.target.closest("[data-keep]") kept = holder != null ? holder.dataset.keep : "" }} @mount={(el) => { let look = (_e = null) => { let next = size_class_view_of(el.clientWidth) if next != size { let was = size size = next if was != "" { carry(el, was, next) } on_change && on_change(next) } } let scrolled = (e) => { let t = e.target if t.closest != null && t.dataset.keep != null && size_class_view_in(t.closest(".ui-size-class-slot").dataset.when, size) { tops = set(tops, t.dataset.keep, t.scrollTop) } } let seen = js_new("ResizeObserver", look) seen.observe(el) el.addEventListener("scroll", scrolled, { capture: true, passive: true }) () => { seen.disconnect() el.removeEventListener("scroll", scrolled, { capture: true }) } }}> {children} </div>}Related
Thread 0 posts
On SizeClassView: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.