Forms

TagInput

Collects tags: Enter or a comma adds one, Backspace removes; a form gets them as a list.

#forms#input#interactive

Demo

Preview
rustweb

Enter or a comma adds a topic.

design

Up to 4.

1 label: design

locked

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

Example

The code of the preview above.

demos/tag_input.qwe
component TagInputDemoLive() {    state tags = ["design"]    <div.demo-stack>        <TagInput name="ti-labels" label="Labels" value={tags} max={4} on_change={(ts) => { tags = ts }}                  hint="Up to 4."/>        <p.demo-note>{len(tags)} label{len(tags) == 1 ? "" : "s"}: {join(tags, ", ")}</p>    </div>}view TagInputDemo() =    <div.demo-stack.demo-narrow>        <TagInput name="topics" label="Topics" value={["rust", "web"]} hint="Enter or a comma adds a topic."                  suggestions={["css", "databases", "html", "javascript", "performance", "rust", "security", "web"]}/>        <TagInputDemoLive/>        <TagInput name="ti-off" label="Disabled" value={["locked"]} disabled={true} size="sm"/>    </div>

Source

ui/tag_input.qwe
// TagInput: a field that collects short words as tags. Type one and press Enter or// a comma to add it; Backspace in the empty field takes the last one off, and each// tag's × removes it. A <form> gets the tags as a list: one `name` per tag.////   <TagInput name="topics" label="Topics" value={["rust", "web"]} max={5}//             suggestions={["css", "databases", "html", "javascript", "rust", "web"]}/>//// value: the tags to start with. max: at most this many. suggestions: words// offered as it is typed (the browser's own list, <datalist>); picking one adds it.// A tag already there (in any case) is not added again; a pasted "a, b, c" adds three.// name ("tags"), label, placeholder ("Add a tag"), hint, error, disabled, field (the// text box's id, "tag-input-" + name by default), size ("sm" or "md") as for every field.// on_change(tags): inside one of your components, called with the list of tags.import ui/theme.qwe use ui_clslet tag_input_css = css`.ui-tag-input { display: grid; gap: 6px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-tag-input-label { font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-tag-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-tag-input-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-tag-input-error { margin: 0; font-size: 13px; color: var(--ui-danger); }.ui-tag-input-box {  box-sizing: border-box; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 38px; padding: 4px 6px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); cursor: text;  transition: border-color .15s, box-shadow .15s;}.ui-tag-input-box:hover { border-color: var(--ui-strong); }.ui-tag-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-tag-input-invalid { border-color: var(--ui-danger); }.ui-tag-input-tag {  display: inline-flex; align-items: center; gap: 2px; height: 26px; padding: 0 2px 0 9px; border-radius: 4px;  background: var(--ui-accent-soft); color: var(--ui-accent); font: 500 13px/1 var(--ui-font); white-space: nowrap;}.ui-tag-input-x {  display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: 0;  border-radius: 3px; background: transparent; color: inherit; cursor: pointer; opacity: .75;}.ui-tag-input-x svg { width: 10px; height: 10px; }.ui-tag-input-x:hover { opacity: 1; background: color-mix(in srgb, var(--ui-accent) 18%, transparent); }.ui-tag-input-x:focus-visible { outline: none; opacity: 1; box-shadow: var(--ui-focus); }.ui-tag-input-text {  flex: 1 0 80px; min-width: 0; height: 28px; margin: 0; padding: 0 6px; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px/1 var(--ui-font);}.ui-tag-input-text::placeholder { color: var(--ui-muted); opacity: 1; }.ui-tag-input-text::-webkit-calendar-picker-indicator { display: none !important; }.ui-tag-input-sm .ui-tag-input-box { min-height: 30px; padding: 2px 4px; gap: 4px; }.ui-tag-input-sm .ui-tag-input-tag { height: 22px; font-size: 12px; }.ui-tag-input-sm .ui-tag-input-text { height: 24px; font-size: 13px; }.ui-tag-input:has(.ui-tag-input-text:disabled) .ui-tag-input-box { opacity: .55; cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-tag-input-box { transition: none; } }`// `tags` with the words in `text` (split at commas) added: trimmed, not empty,// not there already in any case, and no more than `max`.fn tag_input_add(tags: list, text: str, max: int?) -> list =    split(text, ",") | map((w) => trim(w)) | reduce(tags, (acc, t) =>        t == "" || any(acc, (x) => lower(x) == lower(t)) || (max != null && len(acc) >= max) ? acc : acc + [t])component TagInput(name: str = "tags", label: str? = null, value: list = [], placeholder: str = "Add a tag",                   hint: str? = null, error: str? = null, disabled: bool = false, field: str? = null,                   size: str = "md", max: int? = null, suggestions: list = [], on_change = null, class: str? = null) {    style global tag_input_css    state tags = value    state draft = ""    state said = ""    let fid = field ?? `tag-input-${name}`    let full = max != null && len(tags) >= max    let offered = suggestions | filter((s) => !any(tags, (t) => lower(t) == lower(s)))    let described = ui_cls([hint != null && `${fid}-hint`, error != null && `${fid}-error`])    let update = (next: list, note: str) => {        if next != tags {            tags = next            said = note            on_change && on_change(next)        }    }    let add = (text: str) => {        let next = tag_input_add(tags, text, max)        update(next, `Added ${join(next | drop(len(tags)), ", ")}`)        draft = ""    }    let remove = (t: str) => { update(tags | filter((x) => x != t), `Removed ${t}`) }    <div class={ui_cls(["ui-tag-input", size == "sm" && "ui-tag-input-sm", class])}>        {label != null && <label.ui-tag-input-label for={fid}>{label}</label>}        <div class={ui_cls(["ui-tag-input-box", error != null && "ui-tag-input-invalid"])}             @click={(e) => { if e.target.classList.contains("ui-tag-input-box") { e.target.querySelector(".ui-tag-input-text").focus() } }}>            {for t in tags => <span.ui-tag-input-tag key={t}>                {t}                <button.ui-tag-input-x type="button" aria-label={`Remove ${t}`} disabled={disabled} @click={(e) => {                    e.target.closest(".ui-tag-input-box").querySelector(".ui-tag-input-text").focus()                    remove(t)                }}><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                        aria-hidden="true"><path d="M2 2l6 6M8 2L2 8"/></svg></button>            </span>}            <input.ui-tag-input-text type="text" id={fid} value={draft} disabled={disabled} autocomplete="off"                   placeholder={full ? `Up to ${max}` : placeholder} list={len(suggestions) > 0 ? `${fid}-list` : null}                   enterkeyhint="done" aria-invalid={error != null ? "true" : null}                   aria-describedby={described != "" ? described : null}                   @input={(e) => {                       if contains(e.value, ",") || (e.inputType == "insertReplacementText" && e.value != "") {                           add(e.value)                       } else {                           draft = e.value                       }                   }}                   @keydown={(e) => {                       if e.key == "Enter" {                           e.preventDefault()                           if trim(draft) != "" { add(draft) }                       }                       if e.key == "Backspace" && draft == "" && len(tags) > 0 { remove(last(tags)) }                   }}                   @blur={() => { if trim(draft) != "" { add(draft) } }}/>        </div>        {len(suggestions) > 0 && <datalist id={`${fid}-list`}>{for s in offered => <option value={s}></option>}</datalist>}        {for t in tags => <input type="hidden" name={name} value={t}/>}        <span.ui-sr aria-live="polite">{said}</span>        {hint != null && <p.ui-tag-input-hint id={`${fid}-hint`}>{hint}</p>}        {error != null && <p.ui-tag-input-error id={`${fid}-error`} role="alert">{error}</p>}    </div>}
Thread
65Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.