TagInput
Collects tags: Enter or a comma adds one, Backspace removes; a form gets them as a list.
Demo
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.qwemain.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>}Related
AddressFormStreet, city, region, postcode and country in the usual layout, with the autocomplete names browsers fill from.5 views · 7 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsCardInputA payment card as it is typed: the number grouped, the brand, MM/YY and the code, with typos said.6 views · 6 downloadsCharacterCounterA 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.5 views · 6 downloadsCheckboxA checkbox drawn by CSS on a real input, with an indeterminate state, and a group of them in a fieldset.5 views · 6 downloadsCheckboxCardsAny number of choices as cards with a title, a line of detail and a check, each a real checkbox a form sends.5 views · 6 downloads
Thread
Thread 0 posts
On TagInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.