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:
[dependencies]
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.
Implementation
The State
definition:
struct State {
: Vec<String>,
tags: bool, // Track key release (see below)
is_key_released: Vec<String>,
valid_tags_set: String,
current_input: HashMap<String, (u8, u8, u8)>,
valid_tags_map: js_sys::Function,
remove_tag_cb: String,
field_name: String,
select_element_id: String,
tag_list_id: String,
input_id: String,
datalist_id: String,
msg_id: String, /* The singular name of what the user calls the options,
singular_name so that we can display it in error messages */
}
with the following methods:
&mut self, tag: String) -> std::result::Result<(), JsValue>
add_tag(&mut self) -> Option<String> : method
pop(&mut self, event: web_sys::Event) -> std::result::Result<(), JsValue>
remove_tag(&mut self) -> std::result::Result<(), JsValue>
update_datalist(&mut self) -> std::result::Result<(), JsValue>
update_dom(&mut self) -> std::result::Result<(), JsValue> update_from_select(
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:
onclick
event for the outer container<div>
, that will focus theinput
element inside.oninput
event for the<select>
, so that we can synchroniseState
when it changesonkeydown
event for<input>
so that we can detect when a tag name is terminated (I chose the comma character or Enter/Return) or when Backspace is pressed on an empty input which will “pop” the previous tagonkeyup
event for<input>
so we can track when a key is released. If Backspace is pressed and released, the user has pressed it repeatedly.
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]
:
#[wasm_bindgen]
pub fn setup(
: String,
singular_name: String,
field_name: String,
select_element_id: String,
tags_json_id-> 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
.get_element_by_id(&select_element_id)
.expect("could not find tag element");
let tag_container = document.create_element("div")?;
.set_id(&format!("{}-tag-wasm", &select_element_id));
tag_container.set_attribute("class", "tag-wasm")?;
tag_container.set_attribute("aria-hidden", "true")?; tag_container
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| {
JsCast::unchecked_into::<web_sys::HtmlElement>(
.get_element_by_id(&input_id).expect(""),
document
).focus()
.unwrap();
}) as Box<dyn FnMut(_)>);
.set_onclick(Some(onclick_db.as_ref().unchecked_ref()));
tag_container.forget();
onclick_db}
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.
<script>
var error = null;
import("tag_input_wasm.js")
.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});
.setup("tag", "tags", "id_tags", "tags_json");
module
}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.");
= err;
error ;
})</script>
<script id="tags_json" type="application/json">{"programming languages": "#ffffff", "python": "#3776ab", }</script>