ProfileForm
Avatar, name, username, bio and website in the usual layout: a plain form posting to your route, shown again with errors.
Demo
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.qwemain.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>}Related
AvatarUploadA round picture with Change and Remove, showing the picture picked before it is sent, on a real file input.6 views · 6 downloadsEmailInputAn email box that offers the address probably meant when the domain has a slip (gmial.com, outlook.con), one click to take it.5 views · 8 downloadsPhoneInputA country code select and a number box, sent together in international form (+351912345678) with the country's ISO code.6 views · 6 downloadsAddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAuthFormSign in, sign up, forgot and reset password in one form that switches in place, each posting to its own route.7 views · 4 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloads
Thread
Thread 0 posts
On ProfileForm: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.