select

Better select widgets in vanilla javascript.  https://p.ce9e.org/select/demo/
git clone https://git.ce9e.org/select.git

commit
e9d498df267cf94a69b418a27cb643cab5c15b63
parent
39ea81e063ddcc4b1f74ab588d4359c669582fcd
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2025-09-02 05:43
allow to navigate values with keyboard

fixes #3

does not convey the focused value to AT though

Diffstat

M select.css 4 ++++
M select.js 29 ++++++++++++++++++-----------
M tags.js 17 ++++++++++-------
M values.js 43 +++++++++++++++++++++++++++++++++++++++++--

4 files changed, 73 insertions, 20 deletions


diff --git a/select.css b/select.css

@@ -103,6 +103,10 @@
  103   103 	border: 1px solid var(--border);
  104   104 	border-radius: 0.3em;
  105   105 }
   -1   106 .select__value--focus {
   -1   107 	background: var(--focus-bg);
   -1   108 	color: var(--focus-text);
   -1   109 }
  106   110 
  107   111 .select__measure {
  108   112 	inline-size: 0;

diff --git a/select.js b/select.js

@@ -8,6 +8,7 @@ export class Select {
    8     8 		this.id = options.id || randomString(8);
    9     9 		this.inputClass = options.inputClass || original.dataset.selectInputClass;
   10    10 		this.valueClass = options.valueClass || original.dataset.selectValueClass;
   -1    11 		this.valueFocusClass = options.valueFocusClass || original.dataset.selectValueFocusClass;
   11    12 
   12    13 		this.focus = -1;
   13    14 		this.indexMap = [];
@@ -28,7 +29,7 @@ export class Select {
   28    29 		if (this.original.multiple) {
   29    30 			var inputWrapper = create('<div class="select__input">');
   30    31 			inputWrapper.append(this.input);
   31    -1 			this.values = new Values(this.input, `${this.id}-values`, this.valueClass);
   -1    32 			this.values = new Values(this.input, `${this.id}-values`, this.valueClass, this.valueFocusClass);
   32    33 			this.wrapper.append(inputWrapper);
   33    34 		} else {
   34    35 			this.wrapper.append(this.input);
@@ -91,7 +92,9 @@ export class Select {
   91    92 			options[this.focus].scrollIntoView({block: 'nearest'});
   92    93 		} else {
   93    94 			this.input.setAttribute('aria-expanded', 'false');
   94    -1 			this.input.setAttribute('aria-activedescendant', '');
   -1    95 			if (!this.original.multiple || !this.values.focus) {
   -1    96 				this.input.removeAttribute('aria-activedescendant');
   -1    97 			}
   95    98 		}
   96    99 	}
   97   100 
@@ -224,15 +227,19 @@ export class Select {
  224   227 				this.open(true);
  225   228 			}
  226   229 		}
  227    -1 		if (this.original.multiple && !this.input.value && event.key === 'Backspace') {
  228    -1 			event.preventDefault();
  229    -1 			var n = this.original.selectedOptions.length;
  230    -1 			if (n) {
  231    -1 				var op = this.original.selectedOptions[n - 1];
  232    -1 				op.selected = false;
  233    -1 				this.updateInput();
  234    -1 				this.input.value = op.label;
  235    -1 				this.input.dispatchEvent(new Event('input'));
   -1   230 		if (this.original.multiple && !this.input.value) {
   -1   231 			if (this.values.onkeydown(event)) {
   -1   232 				this.close();
   -1   233 			} else if (event.key === 'Backspace') {
   -1   234 				event.preventDefault();
   -1   235 				var n = this.original.selectedOptions.length;
   -1   236 				if (n) {
   -1   237 					var op = this.original.selectedOptions[n - 1];
   -1   238 					op.selected = false;
   -1   239 					this.updateInput();
   -1   240 					this.input.value = op.label;
   -1   241 					this.input.dispatchEvent(new Event('input'));
   -1   242 				}
  236   243 			}
  237   244 		}
  238   245 	}

diff --git a/tags.js b/tags.js

@@ -8,6 +8,7 @@ export class TagInput {
    8     8 		this.id = options.id || randomString(8);
    9     9 		this.inputClass = options.inputClass || original.dataset.tagsInputClass;
   10    10 		this.valueClass = options.valueClass || original.dataset.tagsValueClass;
   -1    11 		this.valueFocusClass = options.valueFocusClass || original.dataset.tagsValueFocusClass;
   11    12 		this.separators = options.separators || (original.dataset.tagsSeparators || 'Enter').split(/\s+/);
   12    13 
   13    14 		this.createElements();
@@ -32,7 +33,7 @@ export class TagInput {
   32    33 		this.input.setAttribute('aria-labelledby', labels);
   33    34 		this.inputWrapper.append(this.input);
   34    35 
   35    -1 		this.values = new Values(this.input, `${this.id}-values`, this.valueClass);
   -1    36 		this.values = new Values(this.input, `${this.id}-values`, this.valueClass, this.valueFocusClass);
   36    37 
   37    38 		this.datalist = document.createElement('datalist');
   38    39 		this.datalist.innerHTML = this.original.innerHTML;
@@ -77,8 +78,14 @@ export class TagInput {
   77    78 	}
   78    79 
   79    80 	onkeydown(event) {
   80    -1 		if (event.key === 'Backspace') {
   81    -1 			if (!this.input.value) {
   -1    81 		if (this.input.value) {
   -1    82 			if (this.separators.includes(event.key)) {
   -1    83 				this.onchange(event);
   -1    84 			}
   -1    85 		} else {
   -1    86 			if (this.values.onkeydown(event)) {
   -1    87 				// nothing to do
   -1    88 			} else if (event.key === 'Backspace') {
   82    89 				event.preventDefault();
   83    90 				var n = this.original.selectedOptions.length;
   84    91 				if (n) {
@@ -89,10 +96,6 @@ export class TagInput {
   89    96 					this.input.dispatchEvent(new Event('input'));
   90    97 				}
   91    98 			}
   92    -1 		} else if (this.separators.includes(event.key)) {
   93    -1 			if (this.input.value) {
   94    -1 				this.onchange(event);
   95    -1 			}
   96    99 		}
   97   100 	}
   98   101 

diff --git a/values.js b/values.js

@@ -1,10 +1,12 @@
    1     1 import { create } from './utils.js';
    2     2 
    3     3 export class Values {
    4    -1 	constructor(input, id, valueClass) {
   -1     4 	constructor(input, id, valueClass, valueFocusClass) {
    5     5 		this.gap = 4;
   -1     6 		this.focus = 0;
    6     7 		this.input = input;
    7     8 		this.valueClass = valueClass || 'select__value';
   -1     9 		this.valueFocusClass = valueFocusClass || 'select__value--focus';
    8    10 
    9    11 		this.el = create('<ul class="select__values">');
   10    12 		this.el.id = id;
@@ -21,6 +23,26 @@ export class Values {
   21    23 		window.addEventListener('resize', this.updateSize.bind(this));
   22    24 	}
   23    25 
   -1    26 	setFocus(k) {
   -1    27 		var n = this.el.children.length;
   -1    28 		k = Math.min(0, Math.max(-n, k));
   -1    29 		if (k !== this.focus) {
   -1    30 			if (this.focus !== 0) {
   -1    31 				const el = this.el.children[n + this.focus];
   -1    32 				el.classList.remove(this.valueFocusClass);
   -1    33 				if (this.input.getAttribute('aria-activedescendant') === el.id) {
   -1    34 					this.input.removeAttribute('aria-activedescendant');
   -1    35 				}
   -1    36 			}
   -1    37 			this.focus = k;
   -1    38 			if (this.focus !== 0) {
   -1    39 				const el = this.el.children[n + this.focus];
   -1    40 				el.classList.add(this.valueFocusClass);
   -1    41 				this.input.setAttribute('aria-activedescendant', el.id);
   -1    42 			}
   -1    43 		}
   -1    44 	}
   -1    45 
   24    46 	updateSize() {
   25    47 		this.input.style.paddingTop = null;
   26    48 		this.input.style.lineHeight = null;
@@ -64,10 +86,12 @@ export class Values {
   64    86 	}
   65    87 
   66    88 	update(original, onChange) {
   -1    89 		this.setFocus(0);
   67    90 		this.el.innerHTML = '';
   68    -1 		Array.from(original.options).forEach(op => {
   -1    91 		Array.from(original.options).forEach((op, i) => {
   69    92 			if (op.selected && op.label) {
   70    93 				var li = document.createElement('li');
   -1    94 				li.id = `${this.el.id}-${i}`;
   71    95 				li.textContent = op.label;
   72    96 				li.className = this.valueClass;
   73    97 				li.onclick = () => {
@@ -81,4 +105,19 @@ export class Values {
   81   105 		});
   82   106 		this.updateSize();
   83   107 	}
   -1   108 
   -1   109 	onkeydown(event) {
   -1   110 		if (this.focus && (event.key === 'Backspace' || event.key === 'Delete')) {
   -1   111 			var n = this.el.children.length;
   -1   112 			this.el.children[n + this.focus].onclick();
   -1   113 		} else if (event.key === 'ArrowLeft') {
   -1   114 			this.setFocus(this.focus - 1);
   -1   115 		} else if (event.key === 'ArrowRight') {
   -1   116 			this.setFocus(this.focus + 1);
   -1   117 		} else {
   -1   118 			this.setFocus(0);
   -1   119 			return false;
   -1   120 		}
   -1   121 		return true;
   -1   122 	}
   84   123 }