Highlight options using a attribute instead of focus. This brings a few advantages.

This commit is contained in:
2025-08-20 14:42:58 +02:00
parent 693314fc1e
commit 1319b57592
2 changed files with 87 additions and 79 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@tp/tp-dropdown", "name": "@tp/tp-dropdown",
"version": "1.5.0", "version": "2.0.0",
"description": "", "description": "",
"main": "tp-dropdown.js", "main": "tp-dropdown.js",
"scripts": { "scripts": {

View File

@@ -28,7 +28,7 @@ const BaseElement = mixins.reduce((baseClass, mixin) => {
return mixin(baseClass); return mixin(baseClass);
}, LitElement); }, LitElement);
class TpDropdown extends BaseElement { export class TpDropdown extends BaseElement {
static get styles() { static get styles() {
return [ return [
css` css`
@@ -152,10 +152,9 @@ class TpDropdown extends BaseElement {
color: var(--tp-dropdown-hovered-item-color, inherit); color: var(--tp-dropdown-hovered-item-color, inherit);
} }
div[role="option"]:focus { div[role="option"][highlighted] {
background: var(--tp-dropdown-focused-item-bg, #EEEEEE); background: var(--tp-dropdown-highlighted-item-bg, #EEEEEE);
color: var(--tp-dropdown-focused-item-color, inherit); color: var(--tp-dropdown-highlighted-item-color, inherit);
outline: none;
} }
div[role="option"][selected] { div[role="option"][selected] {
@@ -251,15 +250,15 @@ class TpDropdown extends BaseElement {
</div> </div>
<div id="popup" ?open="${isOpen}" part="popup"> <div id="popup" ?open="${isOpen}" part="popup">
${filterable ? html` ${filterable ? html`
<tp-input part="filter" exportparts="wrap:filterWrap" id="filter" .value=${this._filterTerm || ''} @input=${e => this._filterTerm = e.target.value} .inert=${!isOpen} ?hidden=${!(filterable || extensible)}> <tp-input part="filter" exportparts="wrap:filterWrap" id="filter" .value=${this._filterTerm || ''} @input=${e => this._onFilterInput(e)} .inert=${!isOpen} ?hidden=${!(filterable || extensible)}>
<input type="text" placeholder=${filterPlaceholder}> <input type="text" placeholder=${filterPlaceholder}>
<tp-icon id="filterIcon" .icon=${extensible ? TpDropdown.addIcon : TpDropdown.filterIcon} slot="suffix"></tp-icon> <tp-icon id="filterIcon" .icon=${extensible ? TpDropdown.addIcon : TpDropdown.filterIcon} slot="suffix"></tp-icon>
</tp-input> </tp-input>
` : null} ` : null}
<div id="itemList" part="list"> <div id="itemList" part="list">
${isOpen ? html` ${isOpen ? html`
${items.map(item => this._filter(item) ? html` ${items.map((item, index) => this._filter(item) ? html`
<div part="item" role="option" .value=${item.value} tabindex=${isOpen ? '0' : '-1'}>${item.label}</div> <div part="item" role="option" .value=${item.value} ?highlighted=${this._getVisibleIndex(index) === this._highlightedIndex} tabindex="-1">${item.label}</div>
` : null)} ` : null)}
` : null} ` : null}
</div> </div>
@@ -298,7 +297,8 @@ class TpDropdown extends BaseElement {
* back again. * back again.
*/ */
_memorizedValue: { type: String }, _memorizedValue: { type: String },
_filterTerm: { type: String } _filterTerm: { type: String },
_highlightedIndex: { type: Number }
}; };
} }
@@ -324,6 +324,7 @@ class TpDropdown extends BaseElement {
super(); super();
this.items = []; this.items = [];
this._onDocClickHandler = () => this.close(); this._onDocClickHandler = () => this.close();
this._highlightedIndex = -1;
} }
disconnectedCallback() { disconnectedCallback() {
@@ -440,22 +441,10 @@ class TpDropdown extends BaseElement {
} }
this.invalid = false; this.invalid = false;
setTimeout(() => {
var ariaSel = this.shadowRoot.querySelector('div[aria-selected]');
var item = this.shadowRoot.querySelector('div[selected]');
if (ariaSel) {
ariaSel.removeAttribute('aria-selected');
}
if (item) {
item.setAttribute('aria-selected', true);
}
}, 0);
} }
_checkValue(newValue, oldValue) { _checkValue(newValue, oldValue) {
if (newValue === null || newValue === undefined) { if (newValue === null || newValue === undefined) {
this.$.itemList.selection = null;
this.label = ''; this.label = '';
} }
} }
@@ -498,7 +487,16 @@ class TpDropdown extends BaseElement {
this._setListPosition(); this._setListPosition();
this.isOpen = true; this.isOpen = true;
this.$.selItem.focus();
// Initialize highlighting and focus
setTimeout(() => {
this._setListHighlight();
if (this.filterable) {
this.$.filter.focus();
} else {
this.$.selItem.focus();
}
}, 0);
} }
_filterClicked(e) { _filterClicked(e) {
@@ -535,6 +533,7 @@ class TpDropdown extends BaseElement {
} }
this.isOpen = false; this.isOpen = false;
this._highlightedIndex = -1;
window.removeEventListener('resize', this._boundSetListPosition); window.removeEventListener('resize', this._boundSetListPosition);
document.removeEventListener('scroll', this._boundSetListPosition); document.removeEventListener('scroll', this._boundSetListPosition);
document.removeEventListener('click', this._onDocClickHandler, true); document.removeEventListener('click', this._onDocClickHandler, true);
@@ -659,11 +658,34 @@ class TpDropdown extends BaseElement {
return new RegExp('.*' + this._filterTerm.toLowerCase() + '.*').test(item.label.toLowerCase()); return new RegExp('.*' + this._filterTerm.toLowerCase() + '.*').test(item.label.toLowerCase());
} }
_getVisibleIndex(itemIndex) {
// Calculate the visible index for filtered items
let visibleIndex = 0;
for (let i = 0; i < itemIndex; i++) {
if (this._filter(this.items[i])) {
visibleIndex++;
}
}
return this._filter(this.items[itemIndex]) ? visibleIndex : -1;
}
_getVisibleItems() {
return this.items.filter(item => this._filter(item));
}
_onFilterInput(e) {
this._filterTerm = e.target.value;
// Reset highlighting when filter changes
this._highlightedIndex = 0;
this._scrollHighlightedIntoView();
}
_filterIconClicked() { _filterIconClicked() {
if (this.extensible) { if (this.extensible) {
this._addItem(); this._addItem();
} }
this._filterTerm = ''; this._filterTerm = '';
this._highlightedIndex = 0;
this.$.filter.focus(); this.$.filter.focus();
} }
@@ -693,7 +715,7 @@ class TpDropdown extends BaseElement {
// Toggle list only if it's not open or when the toggle icon is clicked or the click wasn't on the selector input. // Toggle list only if it's not open or when the toggle icon is clicked or the click wasn't on the selector input.
if (!this.isOpen || closest(rootTarget, '#toggleIcon', true) !== undefined || (!closest(rootTarget, '#filter', true))) { if (!this.isOpen || closest(rootTarget, '#toggleIcon', true) !== undefined || (!closest(rootTarget, '#filter', true))) {
this.toggle(); this.toggle();
this._setListFocus(); this._setListHighlight();
} }
} }
@@ -712,7 +734,7 @@ class TpDropdown extends BaseElement {
if (this._isEnter(e)) { if (this._isEnter(e)) {
if (this.isOpen) { if (this.isOpen) {
if (this.extensible && !this.$.itemList.querySelector('div[role="option"]:focus')) { if (this.extensible && this._highlightedIndex < 0) {
this._addItem(); this._addItem();
} }
@@ -728,30 +750,12 @@ class TpDropdown extends BaseElement {
if (this.isOpen) { if (this.isOpen) {
if (this._isDownKey(e)) { if (this._isDownKey(e)) {
this._focusNextItem(); this._highlightNextItem();
e.preventDefault(); e.preventDefault();
} }
if (this._isUpKey(e)) { if (this._isUpKey(e)) {
this._focusPrevItem(); this._highlightPrevItem();
e.preventDefault();
}
} else {
if (this._isDownKey(e)) {
if (this.value === undefined && this.items && this.items.length > 0) {
this.$.itemList.select(0);
} else {
this.$.itemList.selectNext();
}
e.preventDefault();
}
if (this._isUpKey(e)) {
if (this.value === undefined && this.items && this.items.length > 0) {
this.$.itemList.select(this.items.length - 1);
} else {
this.$.itemList.selectPrevious();
}
e.preventDefault(); e.preventDefault();
} }
} }
@@ -780,10 +784,16 @@ class TpDropdown extends BaseElement {
} }
this._filterTerm = ''; this._filterTerm = '';
this._highlightedIndex = 0;
} }
if (this._isUpKey(e) || this._isDownKey(e)) { if (this._isUpKey(e) || this._isDownKey(e)) {
this._setListFocus(); // Navigate in the list while keeping focus in filter
if (this._isDownKey(e)) {
this._highlightNextItem();
} else {
this._highlightPrevItem();
}
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
@@ -814,54 +824,52 @@ class TpDropdown extends BaseElement {
} }
_selectItem() { _selectItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus'); const visibleItems = this._getVisibleItems();
const highlightedItem = visibleItems[this._highlightedIndex];
if (item) { if (highlightedItem) {
this.value = item.value; this.value = highlightedItem.value;
this.dispatchEvent(new CustomEvent('selection-changed', { detail: this.value, bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent('selection-changed', { detail: this.value, bubbles: true, composed: true }));
} }
} }
_setListFocus() { _setListHighlight() {
var item = this.$.itemList.querySelector('div[role="option"].iron-selected') || this.$.itemList.querySelector('div[role="option"]'); // Initialize highlighting to current value or first item
const visibleItems = this._getVisibleItems();
if (item) { if (visibleItems.length === 0) {
item.focus(); this._highlightedIndex = -1;
}
}
_focusNextItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus');
if (!item) {
return; return;
} }
var next = item.nextElementSibling; // Try to highlight the currently selected value
const currentIndex = visibleItems.findIndex(item => item.value === this.value);
this._highlightedIndex = currentIndex >= 0 ? currentIndex : 0;
this._scrollHighlightedIntoView();
}
if (!next || next.tagName !== 'DIV') { _scrollHighlightedIntoView() {
next = this.$.itemList.querySelector('div[role="option"]'); if (this._highlightedIndex < 0) return;
}
const highlightedElement = this.shadowRoot.querySelector('div[role="option"][highlighted]');
if (next) { if (highlightedElement) {
next.focus(); highlightedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} }
} }
_focusPrevItem() { _highlightNextItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus'); const visibleItems = this._getVisibleItems();
if (!item) { if (visibleItems.length === 0) return;
return;
}
var next = item.previousElementSibling; this._highlightedIndex = (this._highlightedIndex + 1) % visibleItems.length;
this._scrollHighlightedIntoView();
}
if (!next || next.tagName !== 'DIV') { _highlightPrevItem() {
next = this.$.itemList.querySelector('div[role="option"]:last-of-type'); const visibleItems = this._getVisibleItems();
} if (visibleItems.length === 0) return;
if (next) { this._highlightedIndex = this._highlightedIndex <= 0 ? visibleItems.length - 1 : this._highlightedIndex - 1;
next.focus(); this._scrollHighlightedIntoView();
}
} }
} }