Improve multiselect’s usability with keyboard handling

Had to replace the tags <ul> with a div with an input, so that the
browser can focus the keywoard there.  For now i do not have a
focus-within CSS rule because we do no yet have a style for focus
highlight.

I have replaced the template for-loop to fill the options with the
JavaScript equivalent for two reasons.  The first is that GoLand is very
stupid and can not handle that templating code inside the JavaScript
function and complains of non-existing problemes all the time.

The second is that, taking advantage of the input, i now have filtering
of options and have to remove accents from the label and convert it to
lowercase into a separate property just for that.  I could do that with
a Go function, but it is something that i also have to do for the
input’s value when it changes, therefore i am forced to use JavaScript
and, if i am already using it for one string, it makes no sense to have
duplicate functionality in Go code.

The control still has missing aria attributes, and the list of options
is not yet navigable with the keyboard.
This commit is contained in:
jordi fita mas 2023-03-16 12:52:44 +01:00
parent f93d557aa9
commit 1c9fe14ab9
2 changed files with 99 additions and 41 deletions

View File

@ -613,29 +613,6 @@ main > nav {
min-width: 20rem;
}
.multiselect ul + .placeholder {
position: absolute;
font-style: italic;
pointer-events: none;
font-size: 1em;
background-color: initial;
top: 1rem;
left: 2rem;
transition: 0.2s;
}
.multiselect ul:not(.empty) + .placeholder {
background-color: var(--numerus--background-color);
top: -.9rem;
left: 2rem;
font-size: 0.8em;
padding: 0 .25rem;
}
.multiselect ul + .placeholder::after {
content: " (optional)";
}
.multiselect .tags:after {
content: '';
border-top: 6px solid black;
@ -648,24 +625,38 @@ main > nav {
cursor: pointer;
}
.multiselect .tags li {
.multiselect .tag {
background-color: var(--numerus--color--hay);
}
.multiselect .tags button {
.multiselect .tag button {
border: none;
cursor: pointer;
background: transparent;
min-width: initial;
}
.multiselect .tags button:hover {
.multiselect .tag button:hover {
background: rgba(255, 255, 255, .4);
}
.multiselect .tags input {
flex: 1;
width: 100%;
border: 0;
outline: 0;
background: none;
overflow: hidden;
text-overflow: ellipsis;
appearance: none;
}
.multiselect .options {
padding: 0;
border-top: 0;
position: absolute;
left: 0;
right: 0;
}
.multiselect .options li {

View File

@ -42,15 +42,75 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.SelectField*/ -}}
<div class="input{{ if .Errors }} has-errors{{ end }}"
{{- if .Multiple }}
@focusin.window="! $el.contains($event.target) && close() "
@click.away="close()"
x-data="{
options: [
{{- range $option := .Options }}
{ value: '{{ .Value }}', label: '{{ .Label }}', selected: {{ if $.IsSelected .Value }}true{{ else }}false{{ end }} },
{{- end }}
],
options: [],
name: '',
filter: '',
open: false,
init() {
$el.querySelector('select').remove();
const select = $el.querySelector('select');
this.name = select.name;
for (const option of select.options) {
this.options.push({
value: option.value,
label: option.innerText,
search: this.normalize(option.innerText),
selected: option.getAttribute('selected') !== null,
});
}
select.remove();
const template = $el.querySelector('template');
const input = template.content.querySelector('input');
const label = $el.querySelector('label');
input.after(label);
},
selected() {
return this.options.filter(option => option.selected);
},
normalize(s) {
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
},
unselected() {
const filter = this.normalize(this.filter);
return this.options.filter(option => !option.selected && (filter === '' || option.search.includes(filter)));
},
empty() {
return this.selected().length === 0;
},
selectOption(option) {
option.selected = true;
this.filter = '';
},
selectFirst() {
if (!this.open) {
return;
}
const all = this.unselected();
if (all.length === 0) {
return;
}
this.selectOption(all[0]);
},
unselectLast() {
if (this.filter !== '') {
return;
}
const all = this.selected();
if (all.length === 0) {
return;
}
all[all.length - 1].selected = false;
},
toggle() {
if (this.open) {
return this.close();
}
this.open = true;
},
close() {
this.open = false;
},
}"
{{- end -}}
@ -82,21 +142,28 @@
{{- if .Multiple }}
<template x-if="true">
<div class="multiselect">
<ul class="tags" :class="{'empty': options.filter(option => option.selected).length === 0}"
@click="open = !open" @click.away="open = false">
<template x-for="(option) in options.filter(option => option.selected)">
<li>
<input type="hidden" name="{{ .Name }}" :value="option.value">
<div class="tags" :class="{'empty': empty()}"
@click="toggle()"
>
<template x-for="option in selected()">
<div class="tag">
<input type="hidden" :name="name" :value="option.value">
<span x-text="option.label"></span>
<button type="button" @click.stop="option.selected = false">×</button>
</li>
</div>
</template>
</ul>
<input id="{{ .Name }}-field" :placeholder="empty() && '{{ .Label }}'" x-model="filter"
@input="open = true"
@keydown.escape="close()"
@keydown.prevent.enter="selectFirst()"
@keydown.backspace="unselectLast()"
>
</div>
<ul class="options" x-show.transition="open">
<template x-for="(option) in options.filter(option => !option.selected)">
<template x-for="option in unselected()">
<li
x-text="option.label"
@click.stop="option.selected = true"
@click.stop="selectOption(option)"
></li>
</template>
</ul>