DevicePreview
A phone or tablet frame sized by model for showing a component, rotation, safe areas drawn, dark or light.
Demo
Orders
Today, 4 to pack
Tomorrow, 9 booked
Friday, 2 waiting
0 changes.
A tablet on its side, dark, fitted to 300 px.
Get it
It needs theme.qwe, as every component does, and safe_area.qwe; 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/safe_area.qwe$ curl -O https://quillolang.org/components/ui/device_preview.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/device_preview.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/device_preview.qwe
let device_preview_demo_css = css`.device-preview-demo-app { display: grid; grid-template-rows: auto 1fr; height: 100cqh; }.device-preview-demo-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.device-preview-demo-cards { display: grid; grid-template-columns: 1fr; gap: 10px; align-content: start; padding: 14px; overflow: auto; }.device-preview-demo-cards > div { padding: 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }@container (min-width: 600px) { .device-preview-demo-cards { grid-template-columns: repeat(3, 1fr); } }.device-preview-demo-count { min-width: 64px; min-height: 44px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 16px var(--ui-font); }`component DevicePreviewDemoLive() { style global device_preview_demo_css state count = 0 state changes = 0 state said = "" <div.demo-stack> <DevicePreview id="device-preview-demo" model="iphone-15" max_height={620} on_change={(c) => { changes += 1; said = `${c.model} ${c.landscape} ${c.theme} ${c.insets}` }}> <SafeArea id="device-preview-demo-safe" class="device-preview-demo-app"> <div.device-preview-demo-head> <span>Orders</span> <button.device-preview-demo-count id="device-preview-demo-count" type="button" @click={() => { count += 1 }}>{count}</button> </div> <div.device-preview-demo-cards id="device-preview-demo-cards"> <div>Today, 4 to pack</div> <div>Tomorrow, 9 booked</div> <div>Friday, 2 waiting</div> </div> </SafeArea> </DevicePreview> <p.demo-note id="device-preview-demo-out">{changes} changes. {said}</p> </div>}view DevicePreviewDemo() = <div.demo-stack> <DevicePreviewDemoLive/> <DevicePreview id="device-preview-demo-tablet" model="ipad-11" landscape={true} theme="dark" controls={false} max_height={300} label="A tablet"> <p style="margin: 40px">A tablet on its side, dark, fitted to 300 px.</p> </DevicePreview> </div>Source
ui/device_preview.qwe
// DevicePreview: a phone or tablet drawn around whatever you put in it, at the size of a// real model, for showing a component or a screen. It turns on its side, draws the safe// areas, and shows light or dark, and what is inside keeps its state through all of that,// as it is never remade. The screen is a size container and gives its stand-in for// env(), so what is inside lays itself out as on that model: container queries see the// model's width, a SafeArea or PhoneShell inside takes the model's insets, and// position: fixed is held to the screen. Scaled down to fit, never up.//// <DevicePreview model="iphone-15">// <MyScreen/>// </DevicePreview>// <DevicePreview model="ipad-11" landscape={true} theme="dark" controls={false}> ... </DevicePreview>//// model: an id from device_preview_models: "iphone-se", "iphone-15" (default),// "iphone-15-pro-max", "pixel-8", "galaxy-s24", "ipad-mini", "ipad-11", "ipad-13",// "galaxy-tab-s9"; one it does not know gives the first. models: your own list in place// of those, of { id, name, kind, w, h, top, bottom, side, foot, turned_top, radius, cut }:// w and h in points, upright; top and bottom the upright insets; side, foot and// turned_top the insets on its side (left and right, bottom, top); cut "island", "notch",// "punch" or "none".// landscape: on its side. theme: "light" (default) or "dark". insets: draw the safe areas// (true). controls: the bar of model, turn, theme and insets (true). max_height: the most// it may take up, in pixels; by default what is left of the window. label: what screen// readers call the screen ("Device preview"). id, class: for the outer element.// on_change({ model, landscape, theme, insets }): inside one of your components, on a// change made with the controls.//// The iPhone and iPad insets are Apple's. The two Android phones and the Android tablet// are near enough for a preview, as they vary with the system's version and settings.// 100dvh and media queries inside still see the real window: use container units.import ui/theme.qwe use ui_clslet device_preview_css = css`.ui-device-preview { container-type: inline-size; box-sizing: border-box; width: 100%; min-width: 0; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-device-preview-layout { display: grid; gap: 12px; }.ui-device-preview-bar { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; min-width: 0; }.ui-device-preview-field { display: grid; gap: 2px; flex: 1 1 150px; min-width: 0; color: var(--ui-muted); font-size: 13px; }.ui-device-preview-field select { box-sizing: border-box; width: 100%; min-height: 44px; 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);}.ui-device-preview-field select:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-device-preview-key { flex: none; min-width: 44px; min-height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer;}.ui-device-preview-key[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-device-preview-key:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-device-preview-size { flex: 1 1 100%; margin: 0; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-device-preview-stage { min-width: 0; overflow: hidden; }.ui-device-preview-fit { margin: 0 auto; transition: width .24s ease, height .24s ease; }.ui-device-preview-body { box-sizing: border-box; padding: 10px; border-radius: var(--ui-device-preview-outer); background: #16151c; box-shadow: var(--ui-shadow-lg), inset 0 0 0 2px #3b3946; transform: scale(var(--ui-device-preview-scale)); transform-origin: 0 0; transition: transform .24s ease, width .24s ease, height .24s ease;}.ui-device-preview-screen { position: relative; container-type: size; overflow: hidden; border-radius: var(--ui-device-preview-radius); background: var(--ui-bg); color: var(--ui-fg); }.ui-device-preview-marks { position: absolute; inset: 0; z-index: 50; overflow: hidden; border-radius: inherit; pointer-events: none; }.ui-device-preview-band { position: absolute; display: grid; place-items: center; overflow: hidden; color: #fff; font: 600 11px/1 var(--ui-mono); background: repeating-linear-gradient(45deg, rgba(220, 40, 60, .38) 0 6px, rgba(220, 40, 60, .16) 6px 12px);}.ui-device-preview-cut { position: absolute; background: #000; }.ui-device-preview-cut-island { left: 50%; top: 11px; width: 126px; height: 37px; margin-left: -63px; border-radius: 19px; }.ui-device-preview-cut-notch { left: 50%; top: 0; width: 160px; height: 30px; margin-left: -80px; border-radius: 0 0 18px 18px; }.ui-device-preview-cut-punch { left: 50%; top: 12px; width: 20px; height: 20px; margin-left: -10px; border-radius: 50%; }.ui-device-preview-turned .ui-device-preview-cut-island { left: 11px; top: 50%; width: 37px; height: 126px; margin: -63px 0 0; }.ui-device-preview-turned .ui-device-preview-cut-notch { left: 0; top: 50%; width: 30px; height: 160px; margin: -80px 0 0; border-radius: 0 18px 18px 0; }.ui-device-preview-turned .ui-device-preview-cut-punch { left: 12px; top: 50%; margin: -10px 0 0; }.ui-device-preview-home { position: absolute; left: 50%; bottom: 7px; width: 134px; height: 5px; margin-left: -67px; border-radius: 3px; background: color-mix(in srgb, var(--ui-fg) 70%, transparent); }@container (min-width: 700px) { .ui-device-preview-layout { grid-template-columns: 200px minmax(0, 1fr); align-items: start; } .ui-device-preview-bar { flex-direction: column; align-items: stretch; } .ui-device-preview-field { flex: none; } .ui-device-preview-size { flex: none; }}@media (prefers-reduced-motion: reduce) { .ui-device-preview-fit, .ui-device-preview-body { transition: none; } }`let device_preview_models = [ { id: "iphone-se", name: "iPhone SE", kind: "phone", w: 375, h: 667, top: 20, bottom: 0, side: 0, foot: 0, turned_top: 0, radius: 0, cut: "none" }, { id: "iphone-15", name: "iPhone 15", kind: "phone", w: 393, h: 852, top: 59, bottom: 34, side: 59, foot: 21, turned_top: 0, radius: 48, cut: "island" }, { id: "iphone-15-pro-max", name: "iPhone 15 Pro Max", kind: "phone", w: 430, h: 932, top: 59, bottom: 34, side: 59, foot: 21, turned_top: 0, radius: 52, cut: "island" }, { id: "pixel-8", name: "Pixel 8", kind: "phone", w: 412, h: 915, top: 48, bottom: 24, side: 48, foot: 24, turned_top: 0, radius: 36, cut: "punch" }, { id: "galaxy-s24", name: "Galaxy S24", kind: "phone", w: 360, h: 780, top: 40, bottom: 24, side: 40, foot: 24, turned_top: 0, radius: 30, cut: "punch" }, { id: "ipad-mini", name: "iPad mini", kind: "tablet", w: 744, h: 1133, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 20, cut: "none" }, { id: "ipad-11", name: "iPad Pro 11", kind: "tablet", w: 834, h: 1194, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 18, cut: "none" }, { id: "ipad-13", name: "iPad Pro 13", kind: "tablet", w: 1024, h: 1366, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 18, cut: "none" }, { id: "galaxy-tab-s9", name: "Galaxy Tab S9", kind: "tablet", w: 753, h: 1205, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 14, cut: "none" },]fn device_preview_number(x, fallback) { let kind = type_of(x) (kind == "int" || kind == "float") && x >= 0 ? x : fallback}fn device_preview_find(id, models) { let known = type_of(models) == "list" && len(models) > 0 ? models : device_preview_models find(known, (m) => get(m, "id") == id) ?? known[0]}// The screen's size in points, as it stands.fn device_preview_size(m, turned) { let w = device_preview_number(get(m, "w"), 390) let h = device_preview_number(get(m, "h"), 844) turned == true ? { w: h, h: w } : { w, h }}// The insets as they stand: upright they are top and bottom, on its side the notch moves to a side.fn device_preview_insets(m, turned) { let n = (k: str) => device_preview_number(get(m, k), 0) turned == true ? { top: n("turned_top"), right: n("side"), bottom: n("foot"), left: n("side") } : { top: n("top"), right: 0, bottom: n("bottom"), left: 0 }}// How far to scale a screen of w by h, with its bezel, to fit: never above 1. With no room// measured yet, 1.fn device_preview_scale(room_w, room_h, w, h, bezel) { if room_w <= 0 || room_h <= 0 { return 1 } floor(min([1, room_w / (w + 2 * bezel), room_h / (h + 2 * bezel)]) * 1000) / 1000}let device_preview_bezel = 10fn device_preview_percent(scale) -> str = `${round(scale * 100)}%`component DevicePreview(model: str = "iphone-15", models = null, landscape: bool = false, theme: str = "light", insets: bool = true, controls: bool = true, max_height = null, label: str = "Device preview", id: str? = null, class: str? = null, on_change = null, children) { style global device_preview_css state pick = model state turned = landscape state dark = theme == "dark" state marks = insets state room_w = 0 state room_h = 0 let known = type_of(models) == "list" && len(models) > 0 ? models : device_preview_models let m = device_preview_find(pick, models) let size = device_preview_size(m, turned) let safe = device_preview_insets(m, turned) let scale = device_preview_scale(room_w, room_h, size.w, size.h, device_preview_bezel) let whole_w = size.w + 2 * device_preview_bezel let whole_h = size.h + 2 * device_preview_bezel let radius = device_preview_number(get(m, "radius"), 0) let cut = get(m, "cut") ?? "none" let tell = () => { on_change && on_change({ model: m.id, landscape: turned, theme: dark ? "dark" : "light", insets: marks }) } <div class={ui_cls(["ui-device-preview", turned && "ui-device-preview-turned", class])} id={id}> <div.ui-device-preview-layout> {controls && <div.ui-device-preview-bar role="group" aria-label="Preview settings"> <label.ui-device-preview-field>Model <select name="model" @change={(e) => { pick = e.value; tell() }}> {for x in known => <option key={x.id} value={x.id} selected={x.id == m.id}>{x.name}</option>} </select> </label> <button.ui-device-preview-key type="button" aria-pressed={turned ? "true" : "false"} @click={() => { turned = !turned; tell() }}>Turn</button> <button.ui-device-preview-key type="button" aria-pressed={dark ? "true" : "false"} @click={() => { dark = !dark; tell() }}>Dark</button> <button.ui-device-preview-key type="button" aria-pressed={marks ? "true" : "false"} @click={() => { marks = !marks; tell() }}>Safe areas</button> <p.ui-device-preview-size role="status">{m.name}, {size.w} × {size.h}, shown at {device_preview_percent(scale)}</p> </div>} <div.ui-device-preview-stage @mount={(el) => { let win = js("window") let fit = () => { room_w = el.clientWidth room_h = max_height != null ? max_height : win.innerHeight - el.getBoundingClientRect().top + win.document.documentElement.getBoundingClientRect().top - 12 } fit() let grown = js_new("ResizeObserver", fit) grown.observe(el) win.addEventListener("resize", fit) () => { grown.disconnect() win.removeEventListener("resize", fit) } }}> <div.ui-device-preview-fit style={`width: ${whole_w * scale}px; height: ${whole_h * scale}px`}> <div.ui-device-preview-body style={`width: ${whole_w}px; height: ${whole_h}px; --ui-device-preview-scale: ${scale}; --ui-device-preview-outer: ${radius + device_preview_bezel}px; --ui-device-preview-radius: ${radius}px`}> // data-small-target: what is inside is drawn at the model's size and scaled to fit, so a 44 px button there is a picture of one. <div class={ui_cls(["ui-device-preview-screen", dark ? "ui-dark" : "ui-light"])} role="group" aria-label={label} data-kind={get(m, "kind") ?? "phone"} data-small-target style={`width: ${size.w}px; height: ${size.h}px; --ui-safe-top: ${safe.top}px; --ui-safe-right: ${safe.right}px; --ui-safe-bottom: ${safe.bottom}px; --ui-safe-left: ${safe.left}px`}> {children} <div.ui-device-preview-marks aria-hidden="true"> {cut != "none" && <i class={`ui-device-preview-cut ui-device-preview-cut-${cut}`}></i>} {marks && <div> <span.ui-device-preview-band style={`left: 0; right: 0; top: 0; height: ${safe.top}px`}>{safe.top > 0 ? safe.top : ""}</span> <span.ui-device-preview-band style={`left: 0; right: 0; bottom: 0; height: ${safe.bottom}px`}>{safe.bottom > 0 ? safe.bottom : ""}</span> <span.ui-device-preview-band style={`left: 0; top: 0; bottom: 0; width: ${safe.left}px`}>{safe.left > 0 ? safe.left : ""}</span> <span.ui-device-preview-band style={`right: 0; top: 0; bottom: 0; width: ${safe.right}px`}>{safe.right > 0 ? safe.right : ""}</span> </div>} {safe.bottom > 16 && <i.ui-device-preview-home></i>} </div> </div> </div> </div> </div> </div> </div>}Related
ResponsiveSplitMaster and detail as one stack on a phone and side by side on a tablet, changing live on rotation and keeping the selection.2 views · 5 downloadsRotationLayoutTwo arrangements of one set of children, upright and turned: no flash, and focus and scroll kept across the turn.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 6 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On DevicePreview: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.