ProfileSetup
A few steps after sign-up with a skip: keeps progress, a photo, a name, and ends with what was left undone.
Demo
Step 1 of 3
Add a photo
People you share a page with will see it.
Not finished.
Get it
It needs theme.qwe, as every component does, and welcome_carousel.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/welcome_carousel.qwe$ curl -O https://quillolang.org/components/ui/profile_setup.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/profile_setup.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/profile_setup.qwe
let profile_setup_demo_steps = [ { id: "photo", kind: "photo", title: "Add a photo", text: "People you share a page with will see it." }, { id: "name", kind: "text", title: "What should we call you?", label: "Your name", placeholder: "Ada Lovelace", autocomplete: "name" }, { id: "use", kind: "choice", title: "What brings you here?", text: "We use this to pick your first template.", options: ["Work", "School", "Just for me"] },]component ProfileSetupDemoLive() { state run = 0 state said = "Not finished." <div.demo-stack> <div.demo-phone id="profile-setup-demo-frame"> <ProfileSetup key={run} remember="ui-demo-profile" steps={profile_setup_demo_steps} on_finish={(r) => { said = `Finished. Done: ${r.done | join(", ")}. Left: ${r.left | join(", ")}.` }}/> </div> <div.demo-row> <Button size="lg" variant="outline" id="profile-setup-demo-open" on_click={() => { run = run + 1 }}>Leave and come back</Button> <Button size="lg" variant="ghost" id="profile-setup-demo-forget" on_click={() => { welcome_carousel_forget("ui-demo-profile") js("localStorage.removeItem", "ui-demo-profile-photos") said = "Not finished." run = run + 1 }}>Start over</Button> </div> <p.demo-note id="profile-setup-demo-said">{said}</p> </div>}view ProfileSetupDemo() = <ProfileSetupDemoLive/>Source
ui/profile_setup.qwe
// ProfileSetup: the few steps after signing up: a photo, a name and whatever else an// app asks. Each step can be skipped, what was done is kept, so leaving part way and coming// back lands on the same step, and the end says what was left undone, with a way to do it.//// <ProfileSetup remember="profile-1" on_finish={(r) => { save_profile(r.values); navigate("/") }} steps={[// { id: "photo", kind: "photo", title: "Add a photo", text: "People you share with will see it." },// { id: "name", kind: "text", title: "What should we call you?", label: "Your name", autocomplete: "name" },// { id: "use", kind: "choice", title: "What brings you here?", options: ["Work", "School", "Myself"] }// ]}/>//// steps: a list of { id, kind, title, text }. kind "photo" takes a picture, cropped square// and made small on the phone; "text" takes a line, with label, placeholder, autocomplete// and inputmode; "choice" takes one of its options. remember: the name progress is kept under// (null keeps none). saved: progress to start from, as on_progress gave it, for an app that keeps// it itself. fixed: it fills the screen (true); false sits where written, in a box with a height.// id: the start of its ids ("profile-setup").// title_text: what screen readers call it ("Set up your profile"). finish_label: the last// button ("Finish"). photo_size: the kept picture's side in pixels (320).// on_progress(progress): inside one of your components, given { at, values, skipped } after each// change: `at` the step, from 0, `values` a map from id to what was given (a photo is a data: address).// on_finish(result): given { values, done, left }, the ids filled in and those left undone.//// A phone app's localStorage is new at each launch (see welcome_carousel.qwe), and a// picture is too large for a cookie. So the words and the place survive a relaunch by// themselves, and a photo survives only in a browser: an app that must keep it across launches// keeps what on_progress gives and passes it back in `saved`. Then, or if the photo is gone,// its step simply counts as not done.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/welcome_carousel.qwe use welcome_carousel_keep, welcome_carousel_recall, welcome_carousel_forgetlet profile_setup_css = css`.ui-profile-setup { position: relative; z-index: 50; display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) calc(env(safe-area-inset-bottom) + var(--ui-profile-setup-keyboard, 0px)) env(safe-area-inset-left); background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.45 var(--ui-font); container: ui-profile-setup / size;}.ui-profile-setup-fixed { position: fixed; inset: 0; width: auto; height: auto; }.ui-profile-setup-top { display: flex; align-items: center; justify-content: space-between; flex: none; min-height: 46px; padding: 8px 8px 0; }.ui-profile-setup-count { color: var(--ui-muted); font-size: 14px; }.ui-profile-setup-bar { flex: none; height: 4px; margin: 4px 20px 0; border-radius: 999px; background: var(--ui-subtle); overflow: hidden; }.ui-profile-setup-bar span { display: block; height: 100%; border-radius: inherit; background: var(--ui-accent); transition: width .25s; }.ui-profile-setup-panes { display: flex; flex: 1; min-height: 0; }.ui-profile-setup-list { display: none; }.ui-profile-setup-main { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }.ui-profile-setup-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 24px 20px 12px; }.ui-profile-setup-body h2 { margin: 0 0 6px; font-size: 24px; line-height: 1.25; outline: none; }.ui-profile-setup-text { margin: 0 0 18px; color: var(--ui-muted); }.ui-profile-setup-foot { display: grid; flex: none; gap: 8px; padding: 8px 20px 16px; }.ui-profile-setup-problem { margin: 10px 0 0; color: var(--ui-danger); font-size: 14px; }.ui-profile-setup-face { display: grid; place-items: center; width: 132px; height: 132px; margin: 8px auto 16px; overflow: hidden; border-radius: 50%; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 44px; font-weight: 600; }.ui-profile-setup-face img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-touch-callout: none; }.ui-profile-setup-picks { display: grid; gap: 8px; }.ui-profile-setup-field { display: grid; gap: 4px; }.ui-profile-setup-field label { font-size: 14px; font-weight: 500; }.ui-profile-setup-field input { box-sizing: border-box; width: 100%; height: 50px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 17px var(--ui-font);}.ui-profile-setup-field input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-profile-setup-choice { display: flex; align-items: center; gap: 12px; min-height: 50px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); cursor: pointer; }.ui-profile-setup-choice + .ui-profile-setup-choice { margin-top: 8px; }.ui-profile-setup-choice:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-profile-setup-choice:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-profile-setup-choice input { width: 20px; height: 20px; margin: 0; accent-color: var(--ui-accent); }.ui-profile-setup-sum { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; }.ui-profile-setup-sum li { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; padding: 2px 0; border-bottom: 1px solid var(--ui-border); }.ui-profile-setup-sum span { min-width: 0; overflow-wrap: anywhere; }.ui-profile-setup-sum small { display: block; color: var(--ui-muted); font-size: 13px; }.ui-profile-setup-sum [data-done] small { color: var(--ui-success); }/* A tablet has room for the steps beside the one in hand, and they are places to go. */@container ui-profile-setup (min-width: 600px) { .ui-profile-setup-bar { display: none; } .ui-profile-setup-list { display: grid; align-content: start; flex: 0 0 240px; gap: 4px; margin: 0; padding: 20px 12px; list-style: none; border-inline-end: 1px solid var(--ui-border); } .ui-profile-setup-list button { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 15px var(--ui-font); text-align: start; cursor: pointer; } .ui-profile-setup-list button[aria-current="step"] { background: var(--ui-accent-soft); color: var(--ui-accent); } .ui-profile-setup-list button:focus-visible { outline: none; box-shadow: var(--ui-focus); } .ui-profile-setup-list small { margin-inline-start: auto; color: var(--ui-muted); font-size: 12px; } .ui-profile-setup-body, .ui-profile-setup-foot { width: 100%; max-width: 520px; margin-inline: auto; box-sizing: border-box; }}@media (prefers-reduced-motion: reduce) { .ui-profile-setup-bar span { transition: none; } }`// Whether a step's value counts as given.fn profile_setup_filled(step, value) -> bool { if value == null || type_of(value) != "str" { return false } let kind = get(step, "kind") if kind == "photo" { return starts_with(value, "data:image/") } if kind == "choice" { return (get(step, "options") ?? []) | any((o) => o == value) } trim(value) != ""}// The ids of the steps done, and of those not.fn profile_setup_split(steps: list, values) { let done = steps | filter((s) => profile_setup_filled(s, get(values, s.id))) let left = steps | filter((s) => !profile_setup_filled(s, get(values, s.id))) { done: done | map((s) => s.id), left: left | map((s) => s.id) }}// Progress as it was kept, with whatever does not fit today's steps dropped. Anything// unreadable is a fresh start, since this comes from storage a person can clear or a// new version of the app can outgrow.fn profile_setup_restore(text, steps: list) { let blank = { at: 0, values: {}, skipped: [] } if type_of(text) != "str" { return blank } let got = json.parse(text) if is_err(got) || type_of(got) != "map" { return blank } let ids = steps | map((s) => s.id) let old = get(got, "values") let values = type_of(old) == "map" ? (steps | reduce({}, (m, s) => profile_setup_filled(s, get(old, s.id)) ? set(m, s.id, get(old, s.id)) : m)) : {} let gone = get(got, "skipped") let skipped = type_of(gone) == "list" ? (gone | filter((x) => ids | any((i) => i == x))) : [] let at = get(got, "at") { at: type_of(at) == "int" && at >= 0 && at <= len(steps) ? at : 0, values: values, skipped: skipped }}// The square to cut from a picture w by h, and the side to draw it at, no larger than `limit`.fn profile_setup_crop(w: num, h: num, limit: int) { let side = min([w, h]) { x: floor((w - side) / 2), y: floor((h - side) / 2), side: side, out: min([side, limit]) }}// One or two letters for a name, in place of a photo.fn profile_setup_initials(name) -> str { let words = split(trim(name ?? ""), " ") | filter((w) => w != "") if len(words) == 0 { return "?" } let letter = (w) => upper(split(w, "")[0]) len(words) == 1 ? letter(words[0]) : `${letter(words[0])}${letter(words[len(words) - 1])}`}// Why a picked file cannot be used, or "".fn profile_setup_refuse(kind: str?, size: num) -> str { if !starts_with(kind ?? "", "image/") { return "That is not a picture. Pick a photo." } if size > 40000000 { return "That picture is too large. Pick a smaller one." } ""}component ProfileSetup(steps: list = [], remember: str? = null, saved = null, fixed: bool = true, title_text: str = "Set up your profile", finish_label: str = "Finish", photo_size: int = 320, id: str = "profile-setup", on_progress = null, on_finish = null, class: str? = null) { style global profile_setup_css let begun = profile_setup_restore(saved != null ? json.stringify(saved) : null, steps) state at = begun.at state values = begun.values state skipped = begun.skipped state problem = "" state busy = false state finished = false state returning = false // came from the end to fill one step: Next goes back there state turn = 0 // counts moves, so the heading takes the focus on each, not on the first state root = null let n = len(steps) let step = at < n ? steps[at] : null let parts = profile_setup_split(steps, values) let shown = step != null ? (get(values, step.id) ?? "") : "" let name_step = find(steps, (s) => get(s, "autocomplete") == "name") let word = (s) => { if profile_setup_filled(s, get(values, s.id)) { return "Done" } skipped | any((x) => x == s.id) ? "Skipped" : "" } let keep = () => { let photos = steps | filter((s) => get(s, "kind") == "photo") let small = photos | reduce(values, (m, s) => remove(m, s.id)) if remember != null { welcome_carousel_keep(remember, json.stringify({ at: at, values: small, skipped: skipped })) js_new("Promise", (done) => { done(js("localStorage.setItem", `${remember}-photos`, json.stringify({ values: values })) ?? true) }).catch((_e = null) => null) } on_progress && on_progress({ at: at, values: values, skipped: skipped }) } let go = (i: int) => { at = max([0, min([i, n])]) problem = "" turn = turn + 1 keep() } let onward = () => { if returning { returning = false go(n) } else { go(at + 1) } } let next = () => { if step != null && !profile_setup_filled(step, get(values, step.id)) { problem = get(step, "kind") == "text" ? "Fill this in, or skip it for now." : "Pick one, or skip it for now." } else { skipped = skipped | filter((x) => x != step.id) onward() } } let skip = () => { values = remove(values, step.id) if !(skipped | any((x) => x == step.id)) { skipped = skipped + [step.id] } onward() } let finish = () => { finished = true if remember != null { welcome_carousel_forget(remember) js_new("Promise", (done) => { done(js("localStorage.removeItem", `${remember}-photos`) ?? true) }).catch((_e = null) => null) } on_finish && on_finish({ values: values, done: parts.done, left: parts.left }) } let picked = (e) => { let input = e.target let file = input.files[0] let sid = step.id problem = "" if file != null { let refused = profile_setup_refuse(file.type, file.size) if refused != "" { problem = refused } else { busy = true // A camera's sideways photo comes upright from this, and a bad file as an error. let bitmap = js("createImageBitmap", file) if is_err(bitmap) { problem = "That picture could not be read. Pick another one." } else { let cut = profile_setup_crop(bitmap.width, bitmap.height, photo_size) let canvas = js("document.createElement", "canvas") canvas.width = cut.out canvas.height = cut.out canvas.getContext("2d").drawImage(bitmap, cut.x, cut.y, cut.side, cut.side, 0, 0, cut.out, cut.out) bitmap.close() values = set(values, sid, canvas.toDataURL("image/jpeg", 0.82)) skipped = skipped | filter((x) => x != sid) keep() } busy = false } } input.value = "" } <div class={ui_cls(["ui-profile-setup", fixed && "ui-profile-setup-fixed", class])} role="group" aria-label={title_text} @mount={(el) => { root = el if saved == null && remember != null { let text = js("Promise.resolve", welcome_carousel_recall(remember)) if !is_err(text) && text != null { let back = profile_setup_restore(text, steps) let photos = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.getItem", `${remember}-photos`)) })) let more = profile_setup_restore(is_err(photos) ? null : photos, steps) at = back.at values = { ...back.values, ...more.values } skipped = back.skipped } } // Over the form on iOS, and only visualViewport says how much. let vv = js("window").visualViewport let shift = (_e) => { let hidden = max([0, round(js("innerHeight") - vv.height - vv.offsetTop)]) el.style.setProperty("--ui-profile-setup-keyboard", `${hidden}px`) } if vv != null { vv.addEventListener("resize", shift) } () => { if vv != null { vv.removeEventListener("resize", shift) } } }}> {!finished && <div.ui-profile-setup-top> {at > 0 ? <Button variant="ghost" tone="neutral" size="lg" on_click={() => { returning = false; go(at - 1) }}>Back</Button> : <span></span>} <span.ui-profile-setup-count role="status">{at < n ? `Step ${at + 1} of ${n}` : "Last check"}</span> {step != null ? <Button variant="ghost" tone="neutral" size="lg" on_click={skip}>Skip</Button> : <span></span>} </div>} {!finished && <div.ui-profile-setup-bar aria-hidden="true"><span style={`width: ${n > 0 ? round(at * 100 / n) : 0}%`}></span></div>} {!finished && <div.ui-profile-setup-panes> <ul.ui-profile-setup-list aria-label="Steps"> {for [i, s] in enumerate(steps) => <li key={s.id}><button type="button" aria-current={i == at ? "step" : null} @click={() => { returning = false; go(i) }}> {s.title}<small>{word(s)}</small> </button></li>} </ul> <div.ui-profile-setup-main> <div.ui-profile-setup-body> {step != null && <h2 key={turn} tabindex="-1" @mount={(el) => { if turn > 0 { el.focus() } }}>{step.title}</h2>} {step != null && get(step, "text") != null && <p.ui-profile-setup-text>{step.text}</p>} {step != null && get(step, "kind") == "photo" && <div.ui-profile-setup-picks> <div.ui-profile-setup-face> {shown != "" ? <img src={shown} alt="Your photo"/> : <span aria-hidden="true">{profile_setup_initials(name_step != null ? get(values, name_step.id) : null)}</span>} </div> <input id={`${id}-camera`} type="file" accept="image/*" capture="user" hidden="hidden" @change={picked}/> <input id={`${id}-album`} type="file" accept="image/*" hidden="hidden" @change={picked}/> <Button size="lg" variant="outline" full={true} loading={busy} on_click={() => { root.querySelector(`#${id}-camera`).click() }}>Take a photo</Button> <Button size="lg" variant="outline" full={true} loading={busy} on_click={() => { root.querySelector(`#${id}-album`).click() }}>Choose a photo</Button> {shown != "" && <Button size="lg" variant="ghost" tone="danger" full={true} on_click={() => { values = remove(values, step.id); keep() }}>Remove it</Button>} </div>} {step != null && get(step, "kind") == "text" && <div.ui-profile-setup-field> <label for={`${id}-${step.id}`}>{get(step, "label") ?? step.title}</label> <input id={`${id}-${step.id}`} type="text" value={shown} maxlength="80" enterkeyhint="next" placeholder={get(step, "placeholder")} autocomplete={get(step, "autocomplete") ?? "off"} inputmode={get(step, "inputmode") ?? "text"} autocapitalize="words" aria-invalid={problem != "" ? "true" : null} @input={(e) => { let v = e.value problem = "" values = v == "" ? remove(values, step.id) : set(values, step.id, v) keep() }} @keydown={(e) => { if e.key == "Enter" { e.preventDefault(); next() } }}/> </div>} {step != null && get(step, "kind") == "choice" && <div role="radiogroup" aria-label={step.title}> {for o in get(step, "options") ?? [] => <label.ui-profile-setup-choice key={o}> <input type="radio" name={`${id}-${step.id}`} value={o} checked={shown == o} @change={() => { problem = ""; values = set(values, step.id, o); keep() }}/> {o} </label>} </div>} {problem != "" && <p.ui-profile-setup-problem role="alert">{problem}</p>} {step == null && <div> <h2 key={turn} tabindex="-1" @mount={(el) => { if turn > 0 { el.focus() } }}>{len(parts.left) == 0 ? "All done" : "Nearly there"}</h2> <p.ui-profile-setup-text>{len(parts.left) == 0 ? "That is everything." : `You left ${len(parts.left)} for later. You can add ${len(parts.left) == 1 ? "it" : "them"} now, or finish.`}</p> <ul.ui-profile-setup-sum> {for s in steps { let filled = profile_setup_filled(s, get(values, s.id)) <li key={s.id} data-done={filled ? "1" : null} data-left={filled ? null : "1"}> <span>{s.title}<small>{filled ? "Done" : "Left undone"}</small></span> {!filled && <Button size="lg" variant="soft" label={`Add now: ${s.title}`} on_click={() => { returning = true; go(index_of(steps, s)) }}>Add</Button>} </li> }} </ul> </div>} </div> <div.ui-profile-setup-foot> <Button size="lg" full={true} on_click={() => { if step != null { next() } else { finish() } }}>{step == null ? finish_label : returning ? "Save" : "Next"}</Button> </div> </div> </div>} </div>}Related
CoachOverlayA one-time spotlight of a real element, tracking it through scroll and rotation, advancing by tap, always skippable.2 views · 6 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 7 downloadsEmptyScreenEmpty states for first use, none-found and all-done, each with what to do next.3 views · 8 downloadsPermissionChecklistCamera, location, notifications and the like, each with its reason, a live state, and a route to Settings.3 views · 5 downloadsPhoneSignInSign in with a phone number: country, formatting, a code with autofill, resend, and every failure a person can reach.3 views · 6 downloadsProfileScreenA cover collapsing as you scroll, with stats and sticky tabs over one shared scroller. Two columns on a tablet.2 views · 6 downloads
Thread
Thread 0 posts
On ProfileSetup: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.