ProfileScreen
A cover collapsing as you scroll, with stats and sticky tabs over one shared scroller. Two columns on a tablet.
Demo
Ana Silva
@ana
Site engineer on the Mill Road bridge. Photographs mostly concrete.
- Posts
- 214
- Followers
- 15.3K
- Following
- 1.9K
- Joined
- 2019
Post 1: the north side, week 1
Post 2: the north side, week 2
Post 3: the north side, week 3
Post 4: the north side, week 4
Post 5: the north side, week 5
Post 6: the north side, week 6
Post 7: the north side, week 7
Post 8: the north side, week 8
Post 9: the north side, week 9
Post 10: the north side, week 10
Post 11: the north side, week 11
Post 12: the north side, week 12
Post 13: the north side, week 13
Post 14: the north side, week 14
Post 15: the north side, week 15
Post 16: the north side, week 16
Post 17: the north side, week 17
Post 18: the north side, week 18
Post 19: the north side, week 19
Post 20: the north side, week 20
Post 21: the north side, week 21
Post 22: the north side, week 22
Post 23: the north side, week 23
Post 24: the north side, week 24
Post 25: the north side, week 25
Post 26: the north side, week 26
Post 27: the north side, week 27
Post 28: the north side, week 28
Post 29: the north side, week 29
Post 30: the north side, week 30
Liked 1
Liked 2
Liked 3
Liked 4
Liked 5
Liked 6
Liked 7
Liked 8
Liked 9
Liked 10
Liked 11
Liked 12
Liked 13
Liked 14
Liked 15
Liked 16
Liked 17
Liked 18
Liked 19
Liked 20
Liked 21
Liked 22
Liked 23
Liked 24
Short, so that the tabs must hold their place by themselves.
Nothing yet.
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/profile_screen.qweimport ui/theme.qwe use theme_cssuse ui/profile_screen.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let profile_screen_demo_posts = for i in range(30) => `Post ${i + 1}: the north side, week ${i + 1}`let profile_screen_demo_likes = for i in range(24) => `Liked ${i + 1}`let profile_screen_demo_cover = "data:image/svg+xml," + url_encode(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 180"><rect width="390" height="180" fill="#2f6f9f"/><path d="M0 150 C100 80 290 80 390 150 V180 H0z" fill="#23506f"/></svg>`)component ProfileScreenDemoLive() { state note = "Nothing yet." state tall = false <div.demo-stack> <div.demo-phone id="profile-screen-demo" style="height: 620px"> <ProfileScreen name="Ana Silva" handle="@ana" cover={profile_screen_demo_cover} cover_height={tall ? 200 : 180} class={tall ? "demo-tall" : "demo-short"} bio="Site engineer on the Mill Road bridge. Photographs mostly concrete." action="Follow" stats={[{ label: "Posts", value: 214 }, { label: "Followers", value: 15300 }, { label: "Following", value: 1999 }, { label: "Joined", value: "2019" }]} tabs={[{ id: "posts", label: "Posts", count: 214 }, { id: "likes", label: "Likes", count: 1200 }, { id: "about", label: "About" }]} on_action={(on) => { note = `Following: ${on}` }} on_tab={(t) => { note = `Tab: ${t}` }} on_back={() => { note = "Back" }}> <div data-tab="posts">{for p in profile_screen_demo_posts => <p key={p}>{p}</p>}</div> <div data-tab="likes">{for p in profile_screen_demo_likes => <p key={p}>{p}</p>}</div> <div data-tab="about"><p>Short, so that the tabs must hold their place by themselves.</p></div> </ProfileScreen> </div> <Button size="lg" variant="outline" id="profile-screen-tall" on_click={() => { tall = !tall }}>Change the cover's height</Button> <p.demo-note id="profile-screen-note">{note}</p> </div>}view ProfileScreenDemo() = <ProfileScreenDemoLive/>Source
// ProfileScreen: a person's page in a phone app. One scroller holds it all: the cover// shrinks to a bar as you scroll and stays as that bar, the name appears in it, the// picture shrinks away, and the tabs stick under it. Each tab remembers how far down it// was, so changing tabs with the page scrolled does not lose your place, and the header// never jumps. On a tablet the person's side stays in a column of its own, beside// the tabs and what they show.//// <ProfileScreen name="Ana Silva" handle="@ana" bio="Site engineer." cover="/c/ana.jpg" avatar="/a/ana.jpg"// stats={[{ label: "Posts", value: 214 }, { label: "Followers", value: 15300 }]}// tabs={[{ id: "posts", label: "Posts" }, { id: "likes", label: "Likes", count: 41 }]}>// <div data-tab="posts"> ... </div>// <div data-tab="likes"> ... </div>// </ProfileScreen>//// name, handle, bio, avatar: who. cover: a picture's URL, or a colour from the theme.// cover_height: how tall it is, in pixels (180). stats: a list of { label, value }; a large// number is shortened, 15300 to 15.3K, never upwards; text is shown as it is.// tabs: a list of { id, label, count? }. start: the id of the first shown (the first// tab). children: a <div data-tab="id"> for each tab, shown when its tab is: without// JavaScript the first shows. action: the text of a button by the name, such as "Follow";// action_on: its text once pressed ("Following"); active: pressed to start with.// on_action(on), on_tab(id), on_back(), which adds a Back button: inside one of your components.// id, class: for the outer element. It fills its parent, which needs a height.import ui/theme.qwe use ui_clslet profile_screen_css = css`.ui-profile-screen { position: relative; container-type: size; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.45 var(--ui-font); --ui-profile-screen-bar: calc(52px + env(safe-area-inset-top)); }.ui-profile-screen-scroll { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; overflow-anchor: none; overscroll-behavior-y: contain; }.ui-profile-screen-scroll:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }/* On a phone this column is no box at all, so that the cover can stick for the whole page. */.ui-profile-screen-aside { display: contents; }/* What scrolling changes by hand is kept here, whose attributes are fixed: the outer element's are written again whenever a prop they read changes. */.ui-profile-screen-in { position: relative; width: 100%; height: 100%; }.ui-profile-screen-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 4; display: flex; align-items: center; gap: 4px; box-sizing: border-box; height: var(--ui-profile-screen-bar); padding: env(safe-area-inset-top) 12px 0 4px; color: #fff; pointer-events: none; }.ui-profile-screen-bar button { pointer-events: auto; }.ui-profile-screen-back { flex: none; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: rgba(15, 14, 24, .35); color: #fff; cursor: pointer; }.ui-profile-screen-back svg { width: 22px; height: 22px; }.ui-profile-screen-back:focus-visible { outline: 2px solid #fff; }.ui-profile-screen-title { min-width: 0; padding-left: 10px; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; text-shadow: 0 1px 4px rgba(0, 0, 0, .5); white-space: nowrap; opacity: 0; transform: translateY(6px); transition: opacity .16s, transform .16s; }.ui-profile-screen-titled .ui-profile-screen-title { opacity: 1; transform: none; }.ui-profile-screen-cover { position: sticky; top: calc(var(--ui-profile-screen-bar) - var(--ui-profile-screen-h)); z-index: 3; height: var(--ui-profile-screen-h); overflow: hidden; background: linear-gradient(135deg, var(--ui-accent), color-mix(in srgb, var(--ui-accent) 55%, #000));}.ui-profile-screen-cover img { display: block; width: 100%; height: 100%; object-fit: cover; transform-origin: 50% 100%; }.ui-profile-screen-cover::after { content: ""; position: absolute; inset: 0; background: rgba(15, 14, 24, calc(.5 * var(--ui-profile-screen-p, 0))); }.ui-profile-screen-who { position: relative; padding: 0 16px 12px; }.ui-profile-screen-face { position: relative; z-index: 3; display: grid; place-items: center; box-sizing: border-box; width: 84px; height: 84px; margin-top: -42px; overflow: hidden; border: 3px solid var(--ui-bg); border-radius: 50%; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 28px; font-weight: 650; transform: scale(calc(1 - .45 * var(--ui-profile-screen-p, 0))); transform-origin: 0 100%;}.ui-profile-screen-face img { width: 100%; height: 100%; object-fit: cover; }.ui-profile-screen-line { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-top: 6px; }.ui-profile-screen-names { min-width: 0; }.ui-profile-screen-who h2 { margin: 0; font-size: 22px; line-height: 1.25; overflow-wrap: anywhere; }.ui-profile-screen-handle { margin: 0; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-profile-screen-bio { margin: 8px 0 0; overflow-wrap: anywhere; }.ui-profile-screen-action { flex: none; min-height: 44px; padding: 0 18px; border: 1px solid transparent; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-profile-screen-action[aria-pressed="true"] { border-color: var(--ui-border); background: transparent; color: var(--ui-fg); }.ui-profile-screen-action:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-profile-screen-stats { display: flex; gap: 20px; margin: 10px 0 0; padding: 0; }.ui-profile-screen-stats div { display: flex; flex-direction: column-reverse; min-width: 0; }.ui-profile-screen-stats dt { color: var(--ui-muted); font-size: 13px; }.ui-profile-screen-stats dd { margin: 0; font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-profile-screen-tabs { position: sticky; top: var(--ui-profile-screen-bar); z-index: 2; display: flex; overflow-x: auto; border-bottom: 1px solid var(--ui-border); background: var(--ui-bg); overscroll-behavior-x: contain; scrollbar-width: none;}.ui-profile-screen-tabs::-webkit-scrollbar { display: none; }.ui-profile-screen-tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 88px; min-height: 48px; padding: 0 14px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--ui-muted); font: 600 14px var(--ui-font); cursor: pointer; }.ui-profile-screen-tab[aria-selected="true"] { border-bottom-color: var(--ui-accent); color: var(--ui-fg); }.ui-profile-screen-tab:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-profile-screen-tab small { color: var(--ui-muted); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }/* Tall enough that a short tab can keep the tabs at the top. */.ui-profile-screen-panels { min-height: calc(100cqh - 100px); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); }.ui-profile-screen-panels:not([data-ready]) > :not(:first-child) { display: none; }.ui-profile-screen-panels > [hidden] { display: none; }/* A tablet keeps the person in sight: their column stays, and only the tabs' side scrolls. */@container (min-width: 720px) { .ui-profile-screen-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); align-items: start; } .ui-profile-screen-aside { display: block; position: sticky; top: 0; max-height: 100cqh; overflow-y: auto; border-right: 1px solid var(--ui-border); } .ui-profile-screen-cover { position: relative; top: 0; } .ui-profile-screen-face { transform: none; } .ui-profile-screen-tabs { top: 0; } .ui-profile-screen-panels { min-height: calc(100cqh - 50px); } .ui-profile-screen-cover::after { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-profile-screen-title { transition: none; } }`// 999, 1.2K, 15.3K, 1.2M: cut down, never up, so a page never claims more than is true.// What is not a number stays as it is.fn profile_screen_count(value) -> str { if type_of(value) != "int" && type_of(value) != "float" { return str(value ?? "") } let n = floor(abs(value)) let sign = value < 0 ? "-" : "" if n < 1000 { return `${sign}${n}` } let short = (over: int, unit: str) => { let tenths = floor(n * 10 / over) let whole = floor(tenths / 10) let rest = tenths - whole * 10 rest > 0 && whole < 100 ? `${sign}${whole}.${rest}${unit}` : `${sign}${whole}${unit}` } if n < 1000000 { return short(1000, "K") } if n < 1000000000 { return short(1000000, "M") } short(1000000000, "B")}// How far the cover has collapsed: 0 to 1.fn profile_screen_progress(top: num, reach: num) -> num { if reach <= 0 { return 0 } round(max([0, min([top / reach, 1])]) * 1000) / 1000}// How much a cover grows when iOS pulls past the top, whose scroll goes negative.fn profile_screen_stretch(top: num, height: num) -> num { if top >= 0 || height <= 0 { return 1 } round((1 + (0 - top) / height) * 1000) / 1000}// Where to scroll on arriving at a tab. With the tabs not yet stuck, nothing moves. With them// stuck, it goes where that tab was left, but never so high as to bring the header back.fn profile_screen_restore(now_at: num, saved, pin: num) -> num { if now_at < pin { return now_at } max([saved ?? pin, pin])}// Up to two letters for someone with no picture.fn profile_screen_initials(name) -> str = upper(join(split(trim(str(name ?? "")), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))component ProfileScreen(name: str = "", handle: str? = null, bio: str? = null, avatar: str? = null, cover: str? = null, cover_height: int = 180, stats: list = [], tabs: list = [], start: str? = null, action: str? = null, action_on: str = "Following", active: bool = false, on_action = null, on_tab = null, on_back = null, id: str? = null, class: str? = null, children) { style global profile_screen_css state root = null state inner = null state box = null state strip = null state panels = null state pic = null state bar = null state tab = start ?? (len(tabs) > 0 ? tabs[0].id : "") state saved = {} state on = active let wide = () => root.clientWidth >= 720 // The bar's height has the safe area in it, so it is measured, not assumed. let stop = () => wide() ? 0 : bar.offsetHeight let reach = () => max([cover_height - bar.offsetHeight, 1]) let paint = () => { let top = box.scrollTop let p = wide() ? 0 : profile_screen_progress(top, reach()) inner.style.setProperty("--ui-profile-screen-p", str(p)) inner.classList.toggle("ui-profile-screen-titled", !wide() && top >= reach() + 40) if pic != null { pic.style.transform = top < 0 ? `scale(${profile_screen_stretch(top, cover_height)})` : "" } } let show = (want: str) => { for c in js("Array.from", panels.children) { c.hidden = c.dataset.tab != want c.setAttribute("role", "tabpanel") } } let pick = (want: str, focus: bool) => { if want != tab { let was = box.scrollTop // The scroll at which the tabs reach their sticky place, from what does not stick. let line = panels.getBoundingClientRect().top - box.getBoundingClientRect().top + was - strip.offsetHeight - stop() saved = set(saved, tab, was) tab = want show(want) box.scrollTop = profile_screen_restore(was, get(saved, want, null), line) paint() on_tab && on_tab(want) } let b = find(js("Array.from", strip.querySelectorAll("button")), (x) => x.dataset.tab == want) if b != null { strip.scrollLeft = b.offsetLeft - (strip.clientWidth - b.offsetWidth) / 2 if focus { b.focus() } } } <div class={ui_cls(["ui-profile-screen", class])} id={id} style={`--ui-profile-screen-h: ${cover_height}px`} @mount={(el) => { root = el () => { root = null } }}> <div.ui-profile-screen-in @mount={(el) => { let sized = js_new("ResizeObserver", (_r = null) => { if root != null && box != null { paint() } }) inner = el sized.observe(el) () => { sized.disconnect() } }}> <div.ui-profile-screen-bar @mount={(el) => { bar = el }}> {on_back != null && <button.ui-profile-screen-back type="button" aria-label="Back" @click={() => { on_back() }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg> </button>} <span.ui-profile-screen-title aria-hidden="true">{name}</span> </div> <div.ui-profile-screen-scroll tabindex="0" @mount={(el) => { box = el }} @scroll={paint}> <div.ui-profile-screen-layout> <div.ui-profile-screen-aside> <div.ui-profile-screen-cover> {cover != null && <img src={cover} alt="" @mount={(el) => { pic = el }}/>} </div> <div.ui-profile-screen-who> <div.ui-profile-screen-face aria-hidden="true">{avatar != null ? <img src={avatar} alt=""/> : profile_screen_initials(name)}</div> <div.ui-profile-screen-line> <div.ui-profile-screen-names> <h2>{name}</h2> {handle != null && <p.ui-profile-screen-handle>{handle}</p>} </div> {action != null && <button.ui-profile-screen-action type="button" aria-pressed={on ? "true" : "false"} @click={() => { on = !on on_action && on_action(on) }}>{on ? action_on : action}</button>} </div> {bio != null && <p.ui-profile-screen-bio>{bio}</p>} {len(stats) > 0 && <dl.ui-profile-screen-stats> {for s in stats => <div key={s.label}><dt>{s.label}</dt><dd>{profile_screen_count(s.value)}</dd></div>} </dl>} </div> </div> <div.ui-profile-screen-main> {len(tabs) > 0 && <div.ui-profile-screen-tabs role="tablist" aria-label={`${name}'s pages`} @mount={(el) => { strip = el }} @keydown={(e) => { let ids = tabs | map((t) => t.id) let at = index_of(ids, tab) ?? 0 let n = len(ids) let to = match e.key { "ArrowRight" => (at + 1) % n "ArrowLeft" => (at + n - 1) % n "Home" => 0 "End" => n - 1 _ => -1 } if to >= 0 { e.preventDefault() pick(ids[to], true) } }}> {for t in tabs => <button.ui-profile-screen-tab key={t.id} type="button" role="tab" data-tab={t.id} aria-selected={t.id == tab ? "true" : "false"} tabindex={t.id == tab ? "0" : "-1"} @click={() => { pick(t.id, false) }}> {t.label}{t.count != null && <small>{profile_screen_count(t.count)}</small>} </button>} </div>} <div.ui-profile-screen-panels @mount={(el) => { panels = el show(tab) el.setAttribute("data-ready", "true") }}>{children}</div> </div> </div> </div> </div> </div>}Related
Thread 0 posts
On ProfileScreen: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.