Highlight options using a attribute instead of focus. This brings a few advantages.
This commit is contained in:
@@ -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": {
|
||||||
|
160
tp-dropdown.js
160
tp-dropdown.js
@@ -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,8 +487,17 @@ class TpDropdown extends BaseElement {
|
|||||||
this._setListPosition();
|
this._setListPosition();
|
||||||
|
|
||||||
this.isOpen = true;
|
this.isOpen = true;
|
||||||
|
|
||||||
|
// Initialize highlighting and focus
|
||||||
|
setTimeout(() => {
|
||||||
|
this._setListHighlight();
|
||||||
|
if (this.filterable) {
|
||||||
|
this.$.filter.focus();
|
||||||
|
} else {
|
||||||
this.$.selItem.focus();
|
this.$.selItem.focus();
|
||||||
}
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
_filterClicked(e) {
|
_filterClicked(e) {
|
||||||
this._dontClose = true;
|
this._dontClose = true;
|
||||||
@@ -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);
|
||||||
if (!next || next.tagName !== 'DIV') {
|
this._highlightedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
next = this.$.itemList.querySelector('div[role="option"]');
|
this._scrollHighlightedIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next) {
|
_scrollHighlightedIntoView() {
|
||||||
next.focus();
|
if (this._highlightedIndex < 0) return;
|
||||||
|
|
||||||
|
const highlightedElement = this.shadowRoot.querySelector('div[role="option"][highlighted]');
|
||||||
|
if (highlightedElement) {
|
||||||
|
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;
|
|
||||||
|
this._highlightedIndex = (this._highlightedIndex + 1) % visibleItems.length;
|
||||||
|
this._scrollHighlightedIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
var next = item.previousElementSibling;
|
_highlightPrevItem() {
|
||||||
|
const visibleItems = this._getVisibleItems();
|
||||||
|
if (visibleItems.length === 0) return;
|
||||||
|
|
||||||
if (!next || next.tagName !== 'DIV') {
|
this._highlightedIndex = this._highlightedIndex <= 0 ? visibleItems.length - 1 : this._highlightedIndex - 1;
|
||||||
next = this.$.itemList.querySelector('div[role="option"]:last-of-type');
|
this._scrollHighlightedIntoView();
|
||||||
}
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
next.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user