Social

PostComposer

Write a post with a character ring and count, attach files, and post with a button or Ctrl+Enter; a plain form in a page.

#social#forms#input#upload#interactive

Demo

Preview
120 characters left

Nothing posted yet.

Ctrl+Enter posts. Below, one that sends a form (text and files):

280 characters left

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

Example

The code of the preview above.

demos/post_composer.qwe
component PostComposerDemoLive() {    state posts = []    <div.demo-stack>        <PostComposer id="post-composer-demo-live" you="Ana Silva" limit={120}                      on_post={(text, files) => { posts = posts | push({ text, files }) }}/>        <p.demo-note>{len(posts) == 0 ? "Nothing posted yet."            : join(posts | map((p) => `"${p.text}" with ${len(p.files)} ${len(p.files) == 1 ? "file" : "files"}`), " · ")}</p>    </div>}view PostComposerDemo() =    <div.demo-stack>        <PostComposerDemoLive/>        <p.demo-note>Ctrl+Enter posts. Below, one that sends a form (text and files):</p>        <PostComposer id="post-composer-demo-form" you="Ben Okafor" avatar="https://i.pravatar.cc/80?img=15" method="get"                      action="#post_composer" placeholder="Share an update with the team…" post_label="Share" attach={false}>            <input type="hidden" name="channel" value="general"/>        </PostComposer>    </div>

Source

ui/post_composer.qwe
// PostComposer: a box to write a post in, with how many characters are left, a// button to attach files (listed under the text, each removable), and Post, which is// off while the text is empty or over the limit. Ctrl+Enter (⌘+Enter) posts. It is a// real <textarea> and <input type="file">, so a plain <form method="post"// enctype="multipart/form-data"> sends them.////   <PostComposer action="/posts" you="Ana Silva" limit={280}/>//   <PostComposer you="Ana Silva" on_post={(text, files) => { publish(text, files) }}/>//// you: who is writing, for the avatar (initials, or `avatar`, an image).// limit: the most characters (280); the count turns amber near it and red past it.// placeholder ("What's happening?"), post_label ("Post"), rows (3).// attach: false drops the attach button. accept: the kinds of file taken ("image/*").// name: the textarea's name ("text"); files_name: the file input's ("files").// In a page, it is a <form method={method} action={action}> (post, and the page's own// address by default) with children inside it (hidden inputs). Inside one of your// components, on_post(text, files) is called instead, with the text and the files// attached as maps of { name, size, type }, and the box is emptied.// id: the text box's id ("ui-post-composer"); disabled.import ui/theme.qwe use ui_clslet post_composer_css = css`.ui-post-composer {  display: flex; align-items: flex-start; gap: 12px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.45 var(--ui-font); color: var(--ui-fg);}.ui-post-composer-avatar {  display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; overflow: hidden;  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 14px; font-weight: 650; user-select: none;}.ui-post-composer-avatar img { width: 100%; height: 100%; object-fit: cover; }.ui-post-composer-main { display: grid; flex: 1; gap: 10px; min-width: 0; }.ui-post-composer-input {  display: block; width: 100%; margin: 0; padding: 6px 0; border: 0; resize: none; field-sizing: content;  min-height: calc(var(--ui-post-composer-rows, 3) * 1.45em + 12px); background: transparent; color: var(--ui-fg); font: 17px/1.45 var(--ui-font);}.ui-post-composer-input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-post-composer-input:focus { outline: none; }.ui-post-composer:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-post-composer-files { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-post-composer-file {  display: inline-flex; align-items: center; gap: 6px; max-width: 100%; height: 28px; padding: 0 4px 0 10px; border: 1px solid var(--ui-border);  border-radius: 999px; background: var(--ui-subtle); font-size: 13px;}.ui-post-composer-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-post-composer-file small { color: var(--ui-muted); }.ui-post-composer-x {  display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: transparent;  color: var(--ui-muted); font-size: 15px; line-height: 1; cursor: pointer;}.ui-post-composer-x:hover { background: var(--ui-border); color: var(--ui-fg); }.ui-post-composer-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-post-composer-foot { display: flex; align-items: center; gap: 10px; padding-top: 10px; border-top: 1px solid var(--ui-border); }.ui-post-composer-attach {  position: relative; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: var(--ui-accent); cursor: pointer;}.ui-post-composer-attach svg { width: 20px; height: 20px; }.ui-post-composer-attach input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }.ui-post-composer-attach:hover { background: var(--ui-accent-soft); }.ui-post-composer-attach:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-post-composer-count { margin-left: auto; font-size: 13px; color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-post-composer-near { color: var(--ui-warning); }.ui-post-composer-over { color: var(--ui-danger); font-weight: 600; }.ui-post-composer-ring { width: 22px; height: 22px; margin-right: 6px; vertical-align: middle; transform: rotate(-90deg); }.ui-post-composer-ring circle { fill: none; stroke-width: 2.5; }.ui-post-composer-ring .ui-post-composer-track { stroke: var(--ui-border); }.ui-post-composer-ring .ui-post-composer-fill { stroke: currentColor; stroke-linecap: round; transition: stroke-dashoffset .15s; }.ui-post-composer-btn {  display: inline-flex; align-items: center; height: 34px; padding: 0 18px; border: 0; border-radius: 999px;  background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px/1 var(--ui-font); cursor: pointer;}.ui-post-composer-btn:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-post-composer-btn:disabled { opacity: .5; cursor: not-allowed; }.ui-post-composer-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (prefers-reduced-motion: reduce) { .ui-post-composer-ring .ui-post-composer-fill { transition: none; } }`// Up to two initials of a name: "Ana Silva" is "AS".fn post_composer_initials(name: str) -> str =    upper(join(split(trim(name), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))// A size in bytes as "12 KB".fn post_composer_size(n: num) -> str {    if n < 1024 { return `${n} B` }    if n < 1024 * 1024 { return `${round(n / 1024)} KB` }    `${round(n / 1024 / 1024, 1)} MB`}component PostComposer(name: str = "text", files_name: str = "files", you: str? = null, avatar: str? = null,                       limit: int = 280, placeholder: str = "What's happening?", post_label: str = "Post", rows: int = 3,                       attach: bool = true, accept: str = "image/*", action: str? = null, method: str = "post",                       disabled: bool = false, id: str = "ui-post-composer", on_post = null, class: str? = null, children) {    style global post_composer_css    state text = ""    state files = []    state picker = null    state box = null    let used = len(text)    let left = limit - used    let ready = trim(text) != "" && left >= 0    let post = () => {        if ready && on_post != null {            on_post(trim(text), files | map((f) => { name: f.name, size: f.size, type: f.type }))            text = ""            box.value = ""            files = []            if picker != null { picker.value = "" }        }    }    <form class={ui_cls(["ui-post-composer", class])} method={method} action={action} enctype="multipart/form-data"          style={`--ui-post-composer-rows: ${rows}`} @submit={on_post != null ? post : null}>        {children}        {you != null && <span.ui-post-composer-avatar aria-hidden="true">            {avatar != null ? <img src={avatar} alt="" loading="lazy"/> : post_composer_initials(you)}        </span>}        <div.ui-post-composer-main>            <label.ui-sr for={id}>{placeholder}</label>            <textarea.ui-post-composer-input id={id} name={name} rows={rows} placeholder={placeholder} required={true}                      disabled={disabled} aria-describedby={`${id}-count`}                      @mount={(el) => { box = el }}                      @input={(e) => { text = e.value }}                      @keydown={(e) => {                          if e.key == "Enter" && (e.ctrlKey || e.metaKey) {                              e.preventDefault()                              if trim(e.target.value) != "" && len(e.target.value) <= limit {                                  if on_post != null { post() } else { e.target.form.requestSubmit() }                              }                          }                      }}></textarea>            {len(files) > 0 && <ul.ui-post-composer-files aria-label="Attached files">                {for f in files => <li.ui-post-composer-file key={f.key}>                    <span>{f.name}</span><small>{post_composer_size(f.size)}</small>                    <button.ui-post-composer-x type="button" aria-label={`Remove ${f.name}`}                            @click={() => {                                files = files | filter((x) => x.key != f.key)                                if len(files) == 0 && picker != null { picker.value = "" }                            }}>×</button>                </li>}            </ul>}            <div.ui-post-composer-foot>                {attach && <label.ui-post-composer-attach title="Attach files">                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>                    <input type="file" name={files_name} accept={accept} multiple={true} disabled={disabled} aria-label="Attach files"                           @mount={(el) => { picker = el }}                           @change={(e) => {                               let fl = e.target.files                               let picked = for i in range(0, fl.length) => { key: `${now()}-${i}`, name: fl[i].name, size: fl[i].size, type: fl[i].type }                               files = files + picked                           }}/>                </label>}                <span id={`${id}-count`} aria-live="polite"                      class={ui_cls(["ui-post-composer-count", left < 0 && "ui-post-composer-over", left >= 0 && left <= limit * 0.1 && "ui-post-composer-near"])}>                    <svg.ui-post-composer-ring viewBox="0 0 24 24" aria-hidden="true">                        <circle.ui-post-composer-track cx="12" cy="12" r="10"/>                        <circle.ui-post-composer-fill cx="12" cy="12" r="10" stroke-dasharray="62.83"                                stroke-dashoffset={round(62.83 * (1 - min([used, limit]) / limit), 2)}/>                    </svg>                    <span.ui-sr>{left < 0 ? `${0 - left} characters over the limit` : `${left} characters left`}</span>                    <span aria-hidden="true">{used} / {limit}</span>                </span>                <button.ui-post-composer-btn type="submit" disabled={disabled || !ready}>{post_label}</button>            </div>        </div>    </form>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.