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.
Demo
Nothing posted yet.
Ctrl+Enter posts. Below, one that sends a form (text and files):
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.qwemain.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>}Related
ChatInputA chat message box that grows as you type, sends on Enter and breaks the line on Shift+Enter; a plain form in a page.6 views · 6 downloadsReplyBoxA reply box that is one line until focused, then grows and shows Post and Cancel; it posts a form or calls back.6 views · 5 downloadsMentionInputA textarea that offers people when @ is typed, puts @handle in, and sends the handles mentioned beside the text.5 views · 6 downloadsProfileFormAvatar, name, username, bio and website in the usual layout: a plain form posting to your route, shown again with errors.5 views · 8 downloadsReactionsEmoji reaction chips with counts, yours marked, and an add button that opens a row of emoji to pick from.6 views · 5 downloadsFieldsetA <fieldset> with a legend and a line about it, its fields stacked, in a row or in a grid; disabled as one.6 views · 7 downloads
Thread
Thread 0 posts
On PostComposer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.