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",
"version": "1.5.0",
"version": "2.0.0",
"description": "",
"main": "tp-dropdown.js",
"scripts": {

View File

@@ -28,7 +28,7 @@ const BaseElement = mixins.reduce((baseClass, mixin) => {
return mixin(baseClass);
}, LitElement);
class TpDropdown extends BaseElement {
export class TpDropdown extends BaseElement {
static get styles() {
return [
css`
@@ -152,10 +152,9 @@ class TpDropdown extends BaseElement {
color: var(--tp-dropdown-hovered-item-color, inherit);
}
div[role="option"]:focus {
background: var(--tp-dropdown-focused-item-bg, #EEEEEE);
color: var(--tp-dropdown-focused-item-color, inherit);
outline: none;
div[role="option"][highlighted] {
background: var(--tp-dropdown-highlighted-item-bg, #EEEEEE);
color: var(--tp-dropdown-highlighted-item-color, inherit);
}
div[role="option"][selected] {
@@ -251,15 +250,15 @@ class TpDropdown extends BaseElement {
</div>
<div id="popup" ?open="${isOpen}" part="popup">
${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}>
<tp-icon id="filterIcon" .icon=${extensible ? TpDropdown.addIcon : TpDropdown.filterIcon} slot="suffix"></tp-icon>
</tp-input>
` : null}
<div id="itemList" part="list">
${isOpen ? html`
${items.map(item => this._filter(item) ? html`
<div part="item" role="option" .value=${item.value} tabindex=${isOpen ? '0' : '-1'}>${item.label}</div>
${items.map((item, index) => this._filter(item) ? html`
<div part="item" role="option" .value=${item.value} ?highlighted=${this._getVisibleIndex(index) === this._highlightedIndex} tabindex="-1">${item.label}</div>
` : null)}
` : null}
</div>
@@ -298,7 +297,8 @@ class TpDropdown extends BaseElement {
* back again.
*/
_memorizedValue: { type: String },
_filterTerm: { type: String }
_filterTerm: { type: String },
_highlightedIndex: { type: Number }
};
}
@@ -324,6 +324,7 @@ class TpDropdown extends BaseElement {
super();
this.items = [];
this._onDocClickHandler = () => this.close();
this._highlightedIndex = -1;
}
disconnectedCallback() {
@@ -440,22 +441,10 @@ class TpDropdown extends BaseElement {
}
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) {
if (newValue === null || newValue === undefined) {
this.$.itemList.selection = null;
this.label = '';
}
}
@@ -498,7 +487,16 @@ class TpDropdown extends BaseElement {
this._setListPosition();
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) {
@@ -535,6 +533,7 @@ class TpDropdown extends BaseElement {
}
this.isOpen = false;
this._highlightedIndex = -1;
window.removeEventListener('resize', this._boundSetListPosition);
document.removeEventListener('scroll', this._boundSetListPosition);
document.removeEventListener('click', this._onDocClickHandler, true);
@@ -659,11 +658,34 @@ class TpDropdown extends BaseElement {
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() {
if (this.extensible) {
this._addItem();
}
this._filterTerm = '';
this._highlightedIndex = 0;
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.
if (!this.isOpen || closest(rootTarget, '#toggleIcon', true) !== undefined || (!closest(rootTarget, '#filter', true))) {
this.toggle();
this._setListFocus();
this._setListHighlight();
}
}
@@ -712,7 +734,7 @@ class TpDropdown extends BaseElement {
if (this._isEnter(e)) {
if (this.isOpen) {
if (this.extensible && !this.$.itemList.querySelector('div[role="option"]:focus')) {
if (this.extensible && this._highlightedIndex < 0) {
this._addItem();
}
@@ -728,30 +750,12 @@ class TpDropdown extends BaseElement {
if (this.isOpen) {
if (this._isDownKey(e)) {
this._focusNextItem();
this._highlightNextItem();
e.preventDefault();
}
if (this._isUpKey(e)) {
this._focusPrevItem();
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();
}
this._highlightPrevItem();
e.preventDefault();
}
}
@@ -780,10 +784,16 @@ class TpDropdown extends BaseElement {
}
this._filterTerm = '';
this._highlightedIndex = 0;
}
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.stopPropagation();
}
@@ -814,54 +824,52 @@ class TpDropdown extends BaseElement {
}
_selectItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus');
const visibleItems = this._getVisibleItems();
const highlightedItem = visibleItems[this._highlightedIndex];
if (item) {
this.value = item.value;
if (highlightedItem) {
this.value = highlightedItem.value;
this.dispatchEvent(new CustomEvent('selection-changed', { detail: this.value, bubbles: true, composed: true }));
}
}
_setListFocus() {
var item = this.$.itemList.querySelector('div[role="option"].iron-selected') || this.$.itemList.querySelector('div[role="option"]');
if (item) {
item.focus();
}
}
_focusNextItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus');
if (!item) {
_setListHighlight() {
// Initialize highlighting to current value or first item
const visibleItems = this._getVisibleItems();
if (visibleItems.length === 0) {
this._highlightedIndex = -1;
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') {
next = this.$.itemList.querySelector('div[role="option"]');
}
_scrollHighlightedIntoView() {
if (this._highlightedIndex < 0) return;
if (next) {
next.focus();
const highlightedElement = this.shadowRoot.querySelector('div[role="option"][highlighted]');
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
_focusPrevItem() {
var item = this.$.itemList.querySelector('div[role="option"]:focus');
if (!item) {
return;
}
_highlightNextItem() {
const visibleItems = this._getVisibleItems();
if (visibleItems.length === 0) return;
var next = item.previousElementSibling;
this._highlightedIndex = (this._highlightedIndex + 1) % visibleItems.length;
this._scrollHighlightedIntoView();
}
if (!next || next.tagName !== 'DIV') {
next = this.$.itemList.querySelector('div[role="option"]:last-of-type');
}
_highlightPrevItem() {
const visibleItems = this._getVisibleItems();
if (visibleItems.length === 0) return;
if (next) {
next.focus();
}
this._highlightedIndex = this._highlightedIndex <= 0 ? visibleItems.length - 1 : this._highlightedIndex - 1;
this._scrollHighlightedIntoView();
}
}