Forms

ProfileForm

Avatar, name, username, bio and website in the usual layout: a plain form posting to your route, shown again with errors.

#forms#input#profile#user#upload#no-js

Demo

Preview

A square image looks best. JPG, PNG or GIF.

Cancel

Not saved 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_form.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/profile_form.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/profile_form.qwe
component ProfileFormDemoLive() {    state got = null    <div.demo-stack>        <ProfileForm action="#" id="profile-form-demo-live" avatar={false} values={{ name: "Grace Hopper", username: "grace" }}                     on_submit={(f) => { got = f }}/>        <p.demo-note>{got == null ? "Not saved yet." : `on_submit got @${got.username} (${got.name}).`}</p>    </div>}view ProfileFormDemo() =    <div.demo-stack>        <ProfileForm action="/settings/profile" id="profile-form-demo-plain" cancel_href="https://quillolang.org/"                     values={{ name: "Ada Lovelace", username: "ada", bio: "Analytical engines and the first program.", website: "ada.example" }}                     errors={{ username: "That username is taken." }}/>        <ProfileFormDemoLive/>    </div>

Source

ui/profile_form.qwe
// ProfileForm: the usual account profile: an avatar with a Change button, name,// username, bio and website, as a plain <form method="post" enctype="multipart/form-data">// to your route, so it works with no JavaScript. The server shows it again with// `errors` for what was wrong and `values` for what was typed.////   <ProfileForm action="/settings/profile" values={{ name: "Ada Lovelace", username: "ada", avatar: "/u/ada.jpg" }}/>//   <ProfileForm action="/settings/profile" values={values} errors={{ username: "That name is taken." }}/>//   <ProfileForm action="/settings/profile" values={values} notice="Saved." cancel_href="/ada"/>////   route POST /settings/profile { form: { name: str, username: str, bio: str = "", website: str = "" } } { ... }//// action: where it posts. values: what to fill in, { avatar (an image URL), name,// username, bio, website }. errors: a message per field, { avatar, name, username,// bio, website }, each shown under its field and read to screen readers.// notice: a line above the fields ("Saved."). handle ("@"): what comes before the// username; site ("https://") before the website. bio_limit: the bio's maxlength// (160). avatar (true): the avatar and its file input, named "avatar", that takes// an image; the initials of the name stand in for a missing picture. button// ("Save changes"), cancel_href (a Cancel link beside it; none by default).// id: the fields' ids start with it, unique on the page ("profile-form").// on_submit(fields): inside one of your components, called with the fields as a// map when the form is sent, in place of posting it (save them yourself, with a// server fn). Without on_submit the form posts to `action`.import ui/theme.qwe use ui_clslet profile_form_css = css`.ui-profile-form { display: grid; gap: 16px; min-width: 0; max-width: 560px; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-profile-form-fields { display: contents; }.ui-profile-form-form { display: grid; gap: 16px; margin: 0; }.ui-profile-form-avatar { display: flex; align-items: center; gap: 16px; }.ui-profile-form-pic {  flex: none; display: grid; place-items: center; width: 72px; height: 72px; border-radius: 50%; overflow: hidden;  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 24px; font-weight: 600; letter-spacing: .02em;}.ui-profile-form-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }.ui-profile-form-pick { display: grid; gap: 4px; }.ui-profile-form-pick input { font: 13px var(--ui-font); color: var(--ui-fg); max-width: 100%; }.ui-profile-form-pick input::file-selector-button {  margin-right: 10px; padding: 6px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer;}.ui-profile-form-pick input::file-selector-button:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-profile-form-pick input:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: var(--ui-radius-sm); }.ui-profile-form-hint { margin: 0; font-size: 12px; color: var(--ui-muted); }.ui-profile-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }.ui-profile-form-field { display: grid; gap: 6px; min-width: 0; }.ui-profile-form-label { font-size: 13px; font-weight: 500; }.ui-profile-form-opt { margin-left: 4px; color: var(--ui-muted); font-weight: 400; }.ui-profile-form-box {  display: flex; align-items: center; height: 38px; padding: 0 12px; gap: 6px; background: var(--ui-surface);  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); transition: border-color .15s, box-shadow .15s;}.ui-profile-form-box:hover { border-color: var(--ui-strong); }.ui-profile-form-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-profile-form-box input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 14px var(--ui-font); }.ui-profile-form-box input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-profile-form-affix { flex: none; color: var(--ui-muted); user-select: none; }.ui-profile-form-area {  box-sizing: border-box; width: 100%; margin: 0; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); resize: vertical; transition: border-color .15s, box-shadow .15s;}.ui-profile-form-area:hover { border-color: var(--ui-strong); }.ui-profile-form-area:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-profile-form-invalid .ui-profile-form-box, .ui-profile-form-invalid .ui-profile-form-area { border-color: var(--ui-danger); }.ui-profile-form-error { margin: 0; font-size: 13px; color: var(--ui-danger); }.ui-profile-form-notice { margin: 0; padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-success-soft); color: var(--ui-success); font-size: 13px; }.ui-profile-form-actions { display: flex; align-items: center; gap: 14px; padding-top: 4px; }.ui-profile-form-button {  display: inline-flex; align-items: center; justify-content: center; height: 38px; padding: 0 18px; border: 0;  border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 500 14px var(--ui-font);  cursor: pointer; transition: background .15s, box-shadow .15s;}.ui-profile-form-button:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-profile-form-button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-profile-form-cancel { color: var(--ui-muted); text-decoration: none; font-weight: 500; border-radius: 3px; }.ui-profile-form-cancel:hover { color: var(--ui-fg); text-decoration: underline; }.ui-profile-form-cancel:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (prefers-reduced-motion: reduce) { .ui-profile-form-box, .ui-profile-form-area, .ui-profile-form-button { transition: none; } }`// "AL" from "Ada Lovelace": the first letters of the first two words.fn profile_form_initials(name: str?) -> str {    let words = split(trim(name ?? ""), " ") | filter((w) => w != "") | take(2)    len(words) == 0 ? "?" : upper(join(words | map((w) => w[0]), ""))}view ProfileFormField(field: str, label: str, error: str? = null, optional: bool = false, children) {    style global profile_form_css    <div class={ui_cls(["ui-profile-form-field", error != null && "ui-profile-form-invalid"])}>        <label.ui-profile-form-label for={field}>{label}{optional && <span.ui-profile-form-opt>(optional)</span>}</label>        {children}        {error != null && <p.ui-profile-form-error id={`${field}-error`} role="alert">{error}</p>}    </div>}view ProfileForm(action: str = "/settings/profile", values: map = {}, errors: map = {}, notice: str? = null,                 handle: str = "@", site: str = "https://", bio_limit: int = 160, avatar: bool = true,                 button: str = "Save changes", cancel_href: str? = null, id: str = "profile-form", on_submit = null,                 class: str? = null) {    style global profile_form_css    let fid = (f: str) => `${id}-${f}`    let bad = (f: str) => errors[f] != null ? "true" : null    let described = (f: str, more: str? = null) => {        let d = ui_cls([more, errors[f] != null && `${fid(f)}-error`])        d != "" ? d : null    }    let fields = <div.ui-profile-form-fields>        {notice != null && <p.ui-profile-form-notice role="status">{notice}</p>}        {avatar && <div class={ui_cls(["ui-profile-form-avatar", errors.avatar != null && "ui-profile-form-invalid"])}>            <span.ui-profile-form-pic aria-hidden={values.avatar == null ? "true" : null}>                {values.avatar != null ? <img src={values.avatar} alt={`${values.name ?? "Your"} avatar`}/> : profile_form_initials(values.name)}            </span>            <div.ui-profile-form-pick>                <label.ui-profile-form-label for={fid("avatar")}>Profile picture</label>                <input type="file" id={fid("avatar")} name="avatar" accept="image/*"                       aria-invalid={bad("avatar")} aria-describedby={described("avatar", `${fid("avatar")}-hint`)}/>                <p.ui-profile-form-hint id={`${fid("avatar")}-hint`}>A square image looks best. JPG, PNG or GIF.</p>                {errors.avatar != null && <p.ui-profile-form-error id={`${fid("avatar")}-error`} role="alert">{errors.avatar}</p>}            </div>        </div>}        <div.ui-profile-form-row>            <ProfileFormField field={fid("name")} label="Name" error={errors.name}>                <div.ui-profile-form-box>                    <input type="text" id={fid("name")} name="name" value={values.name} required={true} autocomplete="name"                           aria-invalid={bad("name")} aria-describedby={described("name")}/>                </div>            </ProfileFormField>            <ProfileFormField field={fid("username")} label="Username" error={errors.username}>                <div.ui-profile-form-box>                    <span.ui-profile-form-affix id={`${fid("username")}-prefix`} aria-hidden="true">{handle}</span>                    <input type="text" id={fid("username")} name="username" value={values.username} required={true}                           autocomplete="username" autocapitalize="off" spellcheck="false" pattern="[A-Za-z0-9_.-]{1,39}"                           aria-invalid={bad("username")} aria-describedby={described("username", `${fid("username")}-prefix`)}/>                </div>            </ProfileFormField>        </div>        <ProfileFormField field={fid("bio")} label="Bio" optional={true} error={errors.bio}>            <textarea.ui-profile-form-area id={fid("bio")} name="bio" rows={3} maxlength={bio_limit}                      placeholder={`A line or two about you, up to ${bio_limit} characters.`}                      aria-invalid={bad("bio")} aria-describedby={described("bio")}>{values.bio ?? ""}</textarea>        </ProfileFormField>        <ProfileFormField field={fid("website")} label="Website" optional={true} error={errors.website}>            <div.ui-profile-form-box>                <span.ui-profile-form-affix id={`${fid("website")}-prefix`} aria-hidden="true">{site}</span>                <input type="text" id={fid("website")} name="website" value={values.website} placeholder="example.com"                       autocomplete="url" autocapitalize="off" spellcheck="false" inputmode="url"                       aria-invalid={bad("website")} aria-describedby={described("website", `${fid("website")}-prefix`)}/>            </div>        </ProfileFormField>        <div.ui-profile-form-actions>            <button.ui-profile-form-button type="submit">{button}</button>            {cancel_href != null && <a.ui-profile-form-cancel href={cancel_href}>Cancel</a>}        </div>    </div>    <div class={ui_cls(["ui-profile-form", class])}>        {on_submit != null            ? <form.ui-profile-form-form method="post" action={action} enctype={avatar ? "multipart/form-data" : null} @submit={(e) => { on_submit && on_submit(e.form) }}>{fields}</form>            : <form.ui-profile-form-form method="post" action={action} enctype={avatar ? "multipart/form-data" : null}>{fields}</form>}    </div>}
Thread
58Useful? Like it, or add to its thread below.

Thread 0 posts

On ProfileForm: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.