Forms

CharacterCounter

A text box or textarea with a live 120 / 280 count that warns near the limit, as a hard stop or a soft one that blocks sending.

#forms#input#text#interactive

Demo

Preview
20 / 20 characters

Over the limit it will not send.

7 / 40 characters

7 characters typed.

11 / 12 characters

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

Example

The code of the preview above.

demos/character_counter.qwe
component CharacterCounterDemoLive() {    state text = "Ship it"    <div.demo-stack>        <CharacterCounter name="post" label="Post" limit={40} rows={3} hard={false} value={text} field="character-counter-demo-soft"                          hint="Over the limit it will not send." on_input={(t) => { text = t }}/>        <p.demo-note>{len(text)} characters typed.</p>    </div>}view CharacterCounterDemo() =    <div.demo-stack.demo-narrow>        <CharacterCounter name="headline" label="Headline" limit={20} value="Quillo UI 2.0 is out" field="character-counter-demo-hard"                          placeholder="Short and sharp" required={true}/>        <CharacterCounterDemoLive/>        <CharacterCounter name="tag" label="Small" size="sm" limit={12} value="eleven char" field="character-counter-demo-small"/>    </div>

Source

ui/character_counter.qwe
// CharacterCounter: a text box or a textarea with "120 / 280" under it that warns// as the limit comes near and turns red at it. It is a real <input> or <textarea>// with `name`, so a plain <form> sends it; the count is told to screen readers as// it changes.////   <CharacterCounter name="headline" label="Headline" limit={60}/>//   <CharacterCounter name="post" label="Post" limit={280} rows={4} hard={false} on_input={(t) => { text = t }}/>//// limit: the number of characters allowed. hard (true): the box takes no more// (maxlength); false lets more be typed, counts how many over in red and marks// the box invalid, so the form will not send it. rows: a textarea of that many// lines (none: a one-line box). warn_at (0.9): the share of the limit at which// the count turns amber. value, placeholder, hint, error, required, disabled,// field (the id, default "character-counter-" + name) and size ("sm" or "md") as// in TextField.// on_input(text): inside one of your components, called with the text as it is typed.import ui/theme.qwe use ui_clslet character_counter_css = css`.ui-character-counter { display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-character-counter-label { font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-character-counter-req { margin-left: 2px; color: var(--ui-danger); }.ui-character-counter-control {  box-sizing: border-box; display: block; width: 100%; margin: 0; padding: 0 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);  transition: border-color .15s, box-shadow .15s;}input.ui-character-counter-control { height: 38px; }textarea.ui-character-counter-control { padding: 8px 12px; resize: vertical; min-height: 38px; }.ui-character-counter-control::placeholder { color: var(--ui-muted); opacity: 1; }.ui-character-counter-control:hover { border-color: var(--ui-strong); }.ui-character-counter-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-character-counter-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-character-counter-control:disabled { opacity: .55; cursor: not-allowed; }.ui-character-counter-sm input.ui-character-counter-control { height: 30px; font-size: 13px; }.ui-character-counter-sm textarea.ui-character-counter-control { padding: 5px 10px; font-size: 13px; }.ui-character-counter-foot { display: flex; align-items: baseline; gap: 12px; }.ui-character-counter-foot > div { flex: 1; display: grid; gap: 4px; }.ui-character-counter-hint, .ui-character-counter-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-character-counter-error { color: var(--ui-danger); }.ui-character-counter-count { flex: none; margin-left: auto; font-size: 12px; color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-character-counter-near { color: var(--ui-warning); }.ui-character-counter-full, .ui-character-counter-over { color: var(--ui-danger); font-weight: 500; }.ui-character-counter-bar { height: 3px; border-radius: 2px; background: var(--ui-subtle); overflow: hidden; }.ui-character-counter-bar span { display: block; height: 100%; width: var(--p); background: var(--ui-accent); transition: width .15s, background .15s; }.ui-character-counter-near .ui-character-counter-bar span { background: var(--ui-warning); }.ui-character-counter-full .ui-character-counter-bar span, .ui-character-counter-over .ui-character-counter-bar span { background: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-character-counter-control, .ui-character-counter-bar span { transition: none; } }`component CharacterCounter(name: str? = null, label: str? = null, limit: int = 280, hard: bool = true, rows: int? = null,                           warn_at: num = 0.9, value: str = "", placeholder: str? = null, hint: str? = null,                           error: str? = null, required: bool = false, disabled: bool = false, field: str? = null,                           size: str = "md", on_input = null, class: str? = null) {    style global character_counter_css    state text = value    let id = field ?? `character-counter-${name ?? re.replace(lower(label ?? "field"), "[^a-z0-9]+", "-")}`    let used = len(text)    let over = used > limit    let phase = over ? "over" : used == limit ? "full" : used >= limit * warn_at ? "near" : ""    let shown_error = error ?? (over ? `${used - limit} characters over the limit.` : null)    let described = ui_cls([hint != null && `${id}-hint`, shown_error != null && `${id}-error`, `${id}-count`])    let common = {        id, name, placeholder, required, disabled, maxlength: hard ? limit : null,        "aria-invalid": shown_error != null ? "true" : null, "aria-describedby": described,    }    let typed = (e) => {        text = e.value        e.target.setCustomValidity(!hard && len(e.value) > limit ? "Too long" : "")        on_input && on_input(e.value)    }    <div class={ui_cls(["ui-character-counter", size == "sm" && "ui-character-counter-sm", phase != "" && `ui-character-counter-${phase}`, class])}>        {label != null && <label.ui-character-counter-label for={id}>            {label}{required && <span.ui-character-counter-req aria-hidden="true">*</span>}        </label>}        {rows != null            ? <textarea.ui-character-counter-control {...common} rows={rows} @input={typed}>{text}</textarea>            : <input.ui-character-counter-control type="text" {...common} value={text} @input={typed}/>}        <div.ui-character-counter-bar aria-hidden="true"><span style={`--p: ${min([100, round(used * 100 / limit)])}%`}></span></div>        <div.ui-character-counter-foot>            <div>                {hint != null && <p.ui-character-counter-hint id={`${id}-hint`}>{hint}</p>}                {shown_error != null && <p.ui-character-counter-error id={`${id}-error`} role="alert">{shown_error}</p>}            </div>            <span.ui-character-counter-count id={`${id}-count`} aria-live="polite" aria-atomic="true">                {used} / {limit}<span.ui-sr> characters</span>            </span>        </div>    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.