IconPicker
Pick an icon from a grid of 51, narrowed by a search over names and meanings; the arrow keys move and a form gets the name.
Demo
Picked heart.
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/icon_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/icon_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/icon_picker.qwe
component IconPickerDemoLive() { state icon = "heart" <div.demo-stack> <IconPicker id="icon-picker-demo-live" label="App icon" value={icon} search={false} size="sm" only={["home", "star", "heart", "bell", "mail", "user", "settings", "globe"]} on_change={(n) => { icon = n }}/> <p.demo-note>Picked {icon}.</p> </div>}view IconPickerDemo() = <div.demo-stack.demo-narrow> <IconPicker id="icon-picker-demo-all" name="icon" label="Category icon" value="star" hint="Search by name or by what it means: bin, gear, person."/> <IconPickerDemoLive/> </div>Source
ui/icon_picker.qwe
// IconPicker: pick an icon from a grid, with a search box that narrows it as one// types (by name and by other words: "trash" also finds "delete"). The icons are// radio buttons, so the arrow keys move between them; the name of the one picked// goes in a hidden input named `name`, so a plain <form> sends it.//// <IconPicker id="cat-icon" name="icon" label="Category icon" value="star"/>// <IconPicker id="app-icon" label="App icon" only={["home", "star", "heart", "bell"]} search={false}// on_change={(n) => { icon = n }}/>//// id: a prefix for its ids, unique on the page. value: the icon picked to start// with. only: the names to offer, in place of the 51 built in// (`icon_picker_icons`: { name: [svg path, extra words] }; `icon_picker_svg(name)`// draws one anywhere). search (true): the search box. columns (8): icons per row// where there is room. empty: what the grid says when nothing matches// ("No icons match"). label, hint, error, required and size ("sm" or "md") as for// every field.// on_change(name): inside one of your components, given the icon's name.import ui/theme.qwe use ui_clslet icon_picker_css = css`.ui-icon-picker { display: grid; gap: 8px; min-width: 0; margin: 0; padding: 0; border: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-icon-picker-legend { padding: 0; font-size: 13px; font-weight: 500; }.ui-icon-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-icon-picker-panel { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-icon-picker-search { box-sizing: border-box; width: 100%; height: 34px; margin: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px var(--ui-font);}.ui-icon-picker-search::placeholder { color: var(--ui-muted); opacity: 1; }.ui-icon-picker-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-icon-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 4px; max-height: 220px; overflow: auto; max-width: calc(var(--cols) * 44px); padding: 2px;}.ui-icon-picker-opt { position: relative; display: grid; place-items: center; height: 40px; border-radius: var(--ui-radius-sm); color: var(--ui-fg); cursor: pointer; }.ui-icon-picker-opt input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }.ui-icon-picker-opt svg { width: 20px; height: 20px; }.ui-icon-picker-opt:hover { background: var(--ui-subtle); }.ui-icon-picker-opt:has(input:checked) { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-icon-picker-opt:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-icon-picker-empty { padding: 14px 4px; color: var(--ui-muted); font-size: 13px; text-align: center; }.ui-icon-picker-status { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--ui-muted); }.ui-icon-picker-status b { color: var(--ui-fg); font-weight: 500; }.ui-icon-picker-sm .ui-icon-picker-opt { height: 32px; }.ui-icon-picker-sm .ui-icon-picker-opt svg { width: 16px; height: 16px; }.ui-icon-picker-sm .ui-icon-picker-grid { grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); max-width: calc(var(--cols) * 36px); }.ui-icon-picker-invalid .ui-icon-picker-panel { border-color: var(--ui-danger); }.ui-icon-picker-hint, .ui-icon-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-icon-picker-error { color: var(--ui-danger); }`// Each icon: its path (24 by 24, drawn with a 2px stroke) and the other words it is found by.let icon_picker_icons = { "alarm": ["M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM12 9v4l2 2M5 3 2 6M22 6l-3-3", "clock time wake"], "archive": ["M3 4h18v4H3zM5 8v12h14V8M10 12h4", "box store"], "arrow-down": ["M12 5v14M5 12l7 7 7-7", "down"], "arrow-left": ["M19 12H5M12 19l-7-7 7-7", "back left"], "arrow-right": ["M5 12h14M12 5l7 7-7 7", "next forward right"], "arrow-up": ["M12 19V5M5 12l7-7 7 7", "up"], "bell": ["M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0", "notification alert"], "book": ["M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5zM4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5", "read docs"], "bookmark": ["M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z", "save"], "calendar": ["M3 6h18v15H3zM16 3v6M8 3v6M3 10h18", "date event"], "camera": ["M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "photo picture"], "cart": ["M9 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM20 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM1 1h4l2.7 13.4a2 2 0 0 0 2 1.6h9.7a2 2 0 0 0 2-1.6L23 6H6", "shop buy basket"], "chart": ["M3 3v18h18M7 16v-5M12 16V8M17 16v-8", "graph stats analytics"], "check": ["M20 6 9 17l-5-5", "done ok tick"], "chevron-down": ["M6 9l6 6 6-6", "expand"], "chevron-right": ["M9 6l6 6-6 6", "more"], "clock": ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2", "time hour"], "cloud": ["M18 10h-1.3A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z", "weather sync"], "code": ["M16 18l6-6-6-6M8 6l-6 6 6 6", "develop brackets"], "copy": ["M9 9h13v13H9zM5 15H2V2h13v3", "duplicate clone"], "download": ["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3", "save get"], "edit": ["M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z", "pencil write change"], "external": ["M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3", "open link new"], "eye": ["M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "view show see"], "file": ["M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6", "document page"], "filter": ["M22 3H2l8 9.5V19l4 2v-8.5z", "funnel narrow"], "flag": ["M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7", "report mark"], "folder": ["M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z", "directory"], "gift": ["M20 12v10H4V12M2 7h20v5H2zM12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z", "present reward"], "globe": ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10z", "world web language"], "heart": ["M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21.2l8.8-8.8a5.5 5.5 0 0 0 0-7.8z", "love like favourite"], "home": ["M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM9 22V12h6v10", "house start"], "image": ["M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21", "picture photo"], "info": ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h0", "about help"], "key": ["M21 2l-2 2M11.4 11.6a5.5 5.5 0 1 1-7.8 7.8 5.5 5.5 0 0 1 7.8-7.8zM11.4 11.6 15.5 7.5M15.5 7.5l3 3L22 7l-3-3", "password access"], "link": ["M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7", "url chain"], "lock": ["M5 11h14v11H5zM7 11V7a5 5 0 0 1 10 0v4", "secure private"], "mail": ["M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6", "email message letter"], "map-pin": ["M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "location place"], "menu": ["M3 12h18M3 6h18M3 18h18", "hamburger lines"], "minus": ["M5 12h14", "remove less"], "phone": ["M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z", "call"], "plus": ["M12 5v14M5 12h14", "add new more"], "search": ["M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.4-4.4", "find magnifier"], "settings": ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z", "gear options preferences cog"], "star": ["M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z", "favourite rating"], "tag": ["M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L2 12V2h10l8.6 8.6a2 2 0 0 1 0 2.8zM7 7h0", "label price"], "trash": ["M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2", "delete remove bin"], "user": ["M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "person account profile"], "users": ["M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8", "people team group"], "x": ["M18 6 6 18M6 6l12 12", "close cancel"],}// An icon drawn as inline SVG, 1em square unless styled.fn icon_picker_svg(name: str) { let it = icon_picker_icons[name] it == null ? null : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="1em" height="1em"><path d={it[0]}/></svg>}// "map-pin" as "map pin": an icon's name for people.fn icon_picker_said(name: str) -> str = replace(name, "-", " ")component IconPicker(id: str, name: str? = null, label: str? = null, value: str? = null, only: list? = null, search: bool = true, columns: int = 8, empty: str = "No icons match", hint: str? = null, error: str? = null, required: bool = false, size: str = "md", on_change = null, class: str? = null) { style global icon_picker_css state chosen = value state query = "" let names = only ?? (keys(icon_picker_icons) | sort_by((n) => n)) let needle = lower(trim(query)) let shown = needle == "" ? names : names | filter((n) => contains(n, needle) || contains(icon_picker_icons[n] != null ? icon_picker_icons[n][1] : "", needle)) let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`]) <fieldset class={ui_cls(["ui-icon-picker", size == "sm" && "ui-icon-picker-sm", error != null && "ui-icon-picker-invalid", class])} id={id} aria-describedby={described != "" ? described : null}> {label != null && <legend.ui-icon-picker-legend> {label}{required && <span.ui-icon-picker-req aria-hidden="true">*</span>} </legend>} <div.ui-icon-picker-panel> {search && <input.ui-icon-picker-search type="search" id={`${id}-search`} placeholder="Search icons" value={query} autocomplete="off" spellcheck="false" aria-label="Search icons" aria-controls={`${id}-grid`} form="ui-icon-picker-none" @input={(e) => { query = e.value }}/>} <div.ui-icon-picker-grid id={`${id}-grid`} role="radiogroup" aria-label={label ?? "Icons"} style={`--cols: ${columns}`}> {for n in shown => <label.ui-icon-picker-opt key={n} title={icon_picker_said(n)}> <input type="radio" name={`${id}-radio`} value={n} checked={n == chosen} form="ui-icon-picker-none" aria-label={icon_picker_said(n)} @change={() => { chosen = n on_change && on_change(n) }}/> {icon_picker_svg(n)} </label>} {len(shown) == 0 && <p.ui-icon-picker-empty role="status">{empty}</p>} </div> <div.ui-icon-picker-status aria-live="polite"> <span>{chosen != null ? ["Picked: ", <b>{icon_picker_said(chosen)}</b>] : "None picked"}</span> <span>{len(shown)} of {len(names)}</span> </div> </div> {name != null && <input type="hidden" name={name} value={chosen ?? ""} required={required}/>} {hint != null && <p.ui-icon-picker-hint id={`${id}-hint`}>{hint}</p>} {error != null && <p.ui-icon-picker-error id={`${id}-error`} role="alert">{error}</p>} </fieldset>}Related
EmojiPickerPick an emoji from tabs of categories or by searching their names; each is named for screen readers and a form gets the one picked.4 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 downloadsBirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsColorPickerA colour as hex, or picked from a square, a hue slider and swatches; it says when hex is wrong.4 views · 5 downloadsColorSwatchesPick one colour from a set of round swatches: real radio buttons, so they post with a form and work with the keyboard.5 views · 5 downloadsComboboxA text box that filters a list of options as you type, or asks for them (a server, a map service), with the arrow keys, Enter and Escape.5 views · 6 downloads
Thread
Thread 0 posts
On IconPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.