Forms

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.

#forms#picker#icon#search#interactive

Demo

Preview
Category icon
Picked: star51 of 51

Search by name or by what it means: bin, gear, person.

App icon
Picked: heart8 of 8

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.qwe
main.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>}
Thread
55Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.