Tag input assistant for <select> elements in Rust/wasm

Choosing multiple options with the plain HTML <select> element limits you to what the browser chooses to display it as. This is necessary because it has to be accessible to assistive technologies that would choose to render it differently. Here’s how <select> looks in my browser (Firefox):

In the post submission form of the https://sic.pm link aggregator community you can select tags to categorise your post. I wrote a small “input assistant” module in Rust and Webassembly that enhances but not replaces the <select> element. The dynamic usage is not required since the <select> element works always even with javascript disabled. Here’s the finished result:

The code is AGPL-3.0 licensed and is located here.

Project setup

I followed the hello world example from the official rustwasm guide. It uses the wasm-pack tool to compile your Rust project to a wasm module. I chose not to use npm and any javascript other than what’s strictly necessary.

The browser and javascript APIs are exposed to Rust via the js-sys and web-sys crates, so we can do what we would normally do in Javascript: set up event callbacks and manipulate the DOM. For inspiration, I followed the general idea outlined in this logrocket.com guide: Building a tag input field component for React.

The web-sys crate exposes each API as different crate features. By default, it has none. We explicitly enable the features we end up needing:

wasm-bindgen = "0.2.63"
js-sys = "0.3.52"
web-sys = { version = "0.3.52", features = ["Document", "Text", "Window", "HtmlElement", "Element", "console", "HtmlInputElement", "KeyboardEvent", "Node", "NodeList", "HtmlOptionElement", "EventTarget", "HtmlSpanElement", "HtmlSelectElement"] }

Design considerations

We will need a way to track the state of the <select> field so we create a State struct singleton that we put behind a Mutex and then an Arc. This way when registering the event callbacks we can pass around a cloned state reference and access it from there. Every callback will have its own Arc<Mutex<State>>.

(Note: this is the general design pattern I followed when porting my terminal e-mail client meli to wasm for an interactive web demo. The terminal is simulated by rendering an <svg> element with each terminal cell.)

We need a way to know what options are valid. One could just read the options from <select> but I chose to read them from a json script element in order to include associated colors for each tag. The json <script> element should contain a dictionary of valid options as keys and hex colors as values and render as:

<script id="tags_json" type="application/json">{"programming languages": "#ffffff", "python": "#3776ab", }</script>

Finally, a <datalist> element will be used to enable autocomplete for the input.


The State definition:

struct State {
    tags: Vec<String>,
    is_key_released: bool, // Track key release (see below)
    valid_tags_set: Vec<String>,
    current_input: String,
    valid_tags_map: HashMap<String, (u8, u8, u8)>,
    remove_tag_cb: js_sys::Function,
    field_name: String,
    select_element_id: String,
    tag_list_id: String,
    input_id: String,
    datalist_id: String,
    msg_id: String,
    singular_name: String, /* The singular name of what the user calls the options,
                              so that we can display it in error messages */

with the following methods:

    add_tag(&mut self, tag: String) -> std::result::Result<(), JsValue>
    pop(&mut self) -> Option<String> : method
    remove_tag(&mut self, event: web_sys::Event) -> std::result::Result<(), JsValue>
    update_datalist(&mut self) -> std::result::Result<(), JsValue>
    update_dom(&mut self) -> std::result::Result<(), JsValue>
    update_from_select(&mut self) -> std::result::Result<(), JsValue>

The following elements are rendered in the DOM just before the <select> element:

<div id="id_tags-tag-wasm" class="tag-wasm" aria-hidden="true"><div id="id_tags-tag-wasm-tag-list" class="tag-wasm-tag-list"></div> <input id="id_tags-tag-wasm-input" class="tag-wasm-input" list="id_tags-tag-wasm-datalist" type="text" placeholder="tag name…"></div>
<div id="id_tags-tag-wasm-msg" class="tag-wasm-msg"></div>
<p class="after help-text" aria-hidden="true">Or, </p>

The following events will be monitored:

Finally, we’ll add a little ‘x’ button to each tag to enable quick removal and register onclick and onkeydown for it. This is where State.remove_tag_cb is needed: we keep one copy of the callback and register it for every rendered tag.

Setting up the module from Javascript

We register a setup function in the module by annotating it with #[wasm_bindgen]:

pub fn setup(
    singular_name: String,
    field_name: String,
    select_element_id: String,
    tags_json_id: String,
) -> std::result::Result<(), JsValue> {

Interacting with the DOM

The browser API symbols in web_sys are generally the equivalent Javascript symbols but not in snake-case. This part is mostly the tedious process of setting up elements, attributes and callbacks:

    let window = web_sys::window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    let tag_list_id = format!("{}-{}", &select_element_id, TAG_LIST_ID);
    let input_id = format!("{}-{}", &select_element_id, INPUT_ID);
    let datalist_id = format!("{}-{}", &select_element_id, DATALIST_ID);
    let msg_id = format!("{}-{}", &select_element_id, MSG_ID);
    let root_el = document
        .expect("could not find tag element");
    let tag_container = document.create_element("div")?;
    tag_container.set_id(&format!("{}-tag-wasm", &select_element_id));
    tag_container.set_attribute("class", "tag-wasm")?;
    tag_container.set_attribute("aria-hidden", "true")?;

To create a callback from Rust, we wrap a closure in Callback and we forget about it, meaning that we tell Rust not to call Drop on it because otherwise when it’s called from the browser it won’t exist.

        let input_id = input_id.clone();
        let onclick_db = Closure::wrap(Box::new(move |_event: web_sys::Event| {
        }) as Box<dyn FnMut(_)>);

Casting javascript objects and elements with JsCast is necessary to call the appropriate functions from each interface. The casting can be unchecked or checked on runtime.

Putting it all together

We build the module by running wasm-pack build --target web --release

This creates a pkg directory with a .wasm module and a .js file which does the loading and symbol export for us.

In the HTML page we dynamically import the module to avoid any errors showing up if it’s missing or something doesn’t work. We can just print a warning instead, since the <select> element still works. This is the graceful degradation part of this design: the user experience is not limited by the enhanced workflow.

      var error = null;
          .then((module) => {
          async function run() {
              let _ = await module.default("tag_input_wasm_bg.wasm");
              //module.setup({singular_name}, {field_name_attribute}, {field_id_attribute}, {json_id_attribute});
              module.setup("tag", "tags", "id_tags", "tags_json");
          return run();
      }).catch(err => {
          console.warn("Could not load tag input .wasm file.\n\nThe error is saved in the global variable `error` for more details. The page and submission form will still work.\n\nYou can use `console.dir(error)` to see what happened.");
          error = err;
<script id="tags_json" type="application/json">{"programming languages": "#ffffff", "python": "#3776ab", }</script>

return to index