diff --git a/web/static/numerus.css b/web/static/numerus.css index 4a46637..29fdca8 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -589,11 +589,11 @@ main > nav { } /* Multiselect */ -.multiselect { +[is="numerus-multiselect"] { max-width: 35rem; } -.multiselect .tags, .multiselect .options { +[is="numerus-multiselect"] .tags, [is="numerus-multiselect"] .options { font-size: 1em; list-style: none; color: var(--numerus--text-color); @@ -602,7 +602,7 @@ main > nav { border-radius: 0; } -.multiselect .tags { +[is="numerus-multiselect"] .tags { display: flex; flex-wrap: wrap; gap: 1rem; @@ -613,7 +613,7 @@ main > nav { min-width: 20rem; } -.multiselect .tags:after { +[is="numerus-multiselect"] .tags:after { content: ''; border-top: 6px solid black; border-left: 6px solid transparent; @@ -625,22 +625,22 @@ main > nav { cursor: pointer; } -.multiselect .tag { +[is="numerus-multiselect"] .tag { background-color: var(--numerus--color--hay); } -.multiselect .tag button { +[is="numerus-multiselect"] .tag button { border: none; cursor: pointer; background: transparent; min-width: initial; } -.multiselect .tag button:hover { +[is="numerus-multiselect"] .tag button:hover { background: rgba(255, 255, 255, .4); } -.multiselect .tags input { +[is="numerus-multiselect"] .tags input { flex: 1; width: 100%; border: 0; @@ -651,22 +651,23 @@ main > nav { appearance: none; } -.multiselect .options { +[is="numerus-multiselect"] .options { padding: 0; border-top: 0; position: absolute; left: 0; right: 0; + z-index: 10; } -.multiselect .options li { +[is="numerus-multiselect"] .options li { display: block; width: 100%; cursor: pointer; padding: 1rem 2rem; } -.multiselect .options li:hover { +[is="numerus-multiselect"] .options li:hover { background-color: var(--numerus--color--light-gray); } diff --git a/web/static/numerus.js b/web/static/numerus.js new file mode 100644 index 0000000..68c804a --- /dev/null +++ b/web/static/numerus.js @@ -0,0 +1,203 @@ +class Multiselect extends HTMLDivElement { + constructor() { + super(); + this.initialized = false; + this.toSearch = ''; + } + + connectedCallback() { + if (!this.initialized) { + for (const child of this.children) { + switch (child.nodeName) { + case 'SELECT': + this.select = child; + break; + case 'LABEL': + this.label = child; + break; + } + } + if (this.label && this.select) { + this.init(); + } + } + } + + init() { + this.initialized = true; + + this.select.style.display = 'none'; + for (const option of this.select.options) { + option.dataset.numerusSearch = Multiselect.normalize(option.textContent); + } + + this.tags = document.createElement('div'); + this.append(this.tags); + this.tags.classList.add('tags'); + this.tags.addEventListener('click', () => this.toggle()); + + this.search = document.createElement('input'); + this.tags.append(this.search); + this.search.id = this.select.id; + this.select.removeAttribute('id'); + this.search.addEventListener('input', (e) => { + this.open(); + this.filter(e.target.value); + }); + this.search.addEventListener('keydown', (e) => { + switch (e.code) { + case "Enter": + e.preventDefault(); + this.selectFirst(); + break; + case "Backspace": + if (e.target.value === '') { + this.deselectLast(); + } + break; + case "Escape": + this.close(); + break; + } + }); + + // Must come after the search input + this.tags.append(this.label); + + this.options = document.createElement('ul'); + this.append(this.options); + this.options.classList.add('options'); + this.close(); + + window.addEventListener('focusin', (e) => { + if (this.contains(e.target)) return; + this.close(); + }); + + document.addEventListener('click', (e) => { + if (this.contains(e.target)) return; + if (!e.target.isConnected) return; + this.close(); + }); + + this.rebuild(); + } + + rebuildOptions() { + this.options.innerHTML = ''; + for (const option of this.select.options) { + if (!this.isOptionSelectable(option)) { + continue; + } + const li = document.createElement('li'); + this.options.append(li); + li.textContent = option.textContent; + li.addEventListener('click', () => this.selectOption(option, true)); + } + } + + isOptionSelectable(option) { + if (option.selected) { + return false; + } + return this.toSearch === '' || option.dataset.numerusSearch.includes(this.toSearch); + } + + selectOption(option, selected) { + option.selected = selected; + this.toSearch = this.search.value = ''; + this.rebuild(); + } + + selectFirst() { + if (!this.isOpen()) { + return; + } + for (const option of this.select.options) { + if (this.isOptionSelectable(option)) { + this.selectOption(option, true); + break; + } + } + } + + deselectLast() { + const options = this.select.options; + for (let i = options.length - 1; i >= 0; i--) { + const option = options[i]; + if (option.selected) { + this.selectOption(option, false); + break; + } + } + } + + rebuild() { + this.rebuildTags(); + this.rebuildOptions(); + } + + rebuildTags() { + this.tags.querySelectorAll('.tag').forEach((tag) => tag.remove()); + let empty = true; + for (const option of this.select.options) { + if (!option.selected) { + continue; + } + empty = false; + const tag = document.createElement('div'); + this.tags.insertBefore(tag, this.search); + tag.classList.add('tag'); + tag.dataset.numerusSearch = option.dataset.numerusSearch; + + const span = document.createElement('span'); + tag.append(span); + span.textContent = option.textContent; + + const button = document.createElement('button'); + tag.append(button); + button.type = 'button'; + button.textContent = '×'; + button.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectOption(option, false) + }); + } + if (empty) { + this.search.setAttribute('placeholder', this.label.textContent); + } else { + this.search.removeAttribute('placeholder'); + } + } + + close() { + this.options.style.display = 'none'; + } + + open() { + this.options.removeAttribute('style'); + } + + toggle() { + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + } + + isOpen() { + return !this.options.hasAttribute('style'); + } + + filter(search) { + this.toSearch = Multiselect.normalize(search); + this.rebuildOptions() + } + + static normalize(s) { + return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim(); + } +} + +customElements.define('numerus-multiselect', Multiselect, {extends: 'div'}); diff --git a/web/template/app.gohtml b/web/template/app.gohtml index 237a83c..160518b 100644 --- a/web/template/app.gohtml +++ b/web/template/app.gohtml @@ -5,7 +5,7 @@ {{ template "title" . }} — Numerus - +
diff --git a/web/template/form.gohtml b/web/template/form.gohtml index 6fb9908..32f88f6 100644 --- a/web/template/form.gohtml +++ b/web/template/form.gohtml @@ -40,81 +40,7 @@ {{ define "select-field" -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.SelectField*/ -}} -
+
- {{- if .Multiple }} - - {{ end -}} {{- if .Errors }}