Autocomplete search feature (#1511)

Add Autocomplete search feature
This commit is contained in:
Stefan Sundin 2022-01-06 12:17:50 -08:00 committed by GitHub
parent 2a78057eb5
commit a2576e19eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 144 additions and 152 deletions

View file

@ -9,19 +9,20 @@ class Autocomplete {
this.elements = [];
this.index = -1;
this.input = undefined;
this.shadowRoot = undefined;
this.wrapper = undefined;
this.shadowRoot = undefined;
this.container = undefined;
}
clear() {
this.elements = [];
}
async click(e, input) {
async click(e) {
}
async itemClick(e, item, input, uuid) {
async itemClick(e, input, uuid) {
}
@ -37,8 +38,9 @@ class Autocomplete {
this.autoSubmit = autoSubmit;
if (!this.autocompleteList.includes(input)) {
input.addEventListener('click', ev => this.click(ev, input));
input.addEventListener('keydown', ev => this.keyPress(ev));
input.addEventListener('click', e => this.click(e));
input.addEventListener('keydown', e => this.keyDown(e));
input.addEventListener('keyup', e => this.keyUp(e));
input.setAttribute('autocomplete', 'off');
this.autocompleteList.push(input);
}
@ -48,17 +50,21 @@ class Autocomplete {
}
}
mouseOver(e, div, item) {
this.removeItem(this.getAllItems());
item.classList.add('kpxcAutocomplete-active');
this.index = Array.from(div.childNodes).indexOf(item);
}
mouseOut(e, item) {
item.classList.remove('kpxcAutocomplete-active');
mouseMove(e) {
if (e.movementX === 0 && e.movementY === 0) {
return;
}
this.deselectItem();
e.target.classList.add('kpxcAutocomplete-active');
const items = this.getAllItems();
this.index = Array.from(items).indexOf(e.target);
}
async showList(inputField) {
if (this.input === inputField) {
return;
}
this.closeList();
// Return if there are no credentials
@ -67,104 +73,80 @@ class Autocomplete {
}
this.input = inputField;
this.input.select();
const div = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' });
initColorTheme(div);
if (!this.wrapper) {
const styleSheet = createStylesheet('css/autocomplete.css');
const colorStyleSheet = createStylesheet('css/colors.css');
this.wrapper = kpxcUI.createElement('div');
this.container = kpxcUI.createElement('div', 'kpxcAutocomplete-container', { 'id': 'kpxcAutocomplete-container' });
this.updatePosition(inputField, div);
div.style.zIndex = '2147483646';
this.shadowRoot = this.wrapper.attachShadow({ mode: 'closed' });
this.shadowRoot.append(colorStyleSheet);
this.shadowRoot.append(styleSheet);
const styleSheet = createStylesheet('css/autocomplete.css');
const colorStyleSheet = createStylesheet('css/colors.css');
const wrapper = kpxcUI.createElement('div');
this.list = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' });
initColorTheme(this.list);
this.shadowRoot = wrapper.attachShadow({ mode: 'closed' });
this.shadowRoot.append(colorStyleSheet);
this.shadowRoot.append(styleSheet);
this.shadowRoot.append(div);
this.wrapper = wrapper;
document.body.append(wrapper);
this.container.append(this.list);
this.shadowRoot.append(this.container);
document.body.append(this.wrapper);
await kpxc.updateTOTPList();
for (const c of this.elements) {
const item = document.createElement('div');
item.textContent += c.label;
const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value });
item.append(itemInput);
item.addEventListener('click', ev => this.itemClick(ev, item, inputField, c.uuid));
await kpxc.updateTOTPList();
for (const c of this.elements) {
const item = document.createElement('div');
item.textContent = c.label;
const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value });
item.append(itemInput);
item.addEventListener('click', e => this.itemClick(e, this.input, c.uuid));
// These events prevent the double hover effect if both keyboard and mouse are used
item.addEventListener('mouseover', ev => this.mouseOver(ev, div, item));
item.addEventListener('mouseout', ev => this.mouseOut(ev, item));
// These events prevent the double hover effect if both keyboard and mouse are used
item.addEventListener('mousemove', e => this.mouseMove(e));
item.addEventListener('mousedown', ev => ev.stopPropagation());
item.addEventListener('mouseup', ev => ev.stopPropagation());
item.addEventListener('mousedown', e => e.stopPropagation());
item.addEventListener('mouseup', e => e.stopPropagation());
div.appendChild(item);
this.list.appendChild(item);
}
// Add a footer message for auto-submit
if (this.autoSubmit) {
const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage'));
this.container.appendChild(footer);
}
}
// Add a footer message for auto-submit
if (this.autoSubmit) {
const footer = kpxcUI.createElement('footer', '', {}, tr('autocompleteSubmitMessage'));
div.appendChild(footer);
}
this.updatePosition();
this.container.style.display = 'block';
}
// Activate the first item automatically
selectItem() {
this.deselectItem();
const items = this.getAllItems();
this.index = 0;
this.activateItem(items);
}
activateItem(item) {
if (!item || item.length === 0) {
return;
}
this.removeItem(item);
if (this.index >= item.length) {
this.index = 0;
}
if (this.index < 0) {
this.index = item.length - 1;
}
if (item[this.index] !== undefined) {
item[this.index].classList.add('kpxcAutocomplete-active');
const item = items[this.index];
if (item !== undefined) {
item.classList.add('kpxcAutocomplete-active');
item.scrollIntoView({ block: 'nearest' });
}
}
removeItem(items) {
deselectItem() {
const items = this.list.querySelectorAll('div.kpxcAutocomplete-active');
for (const item of items) {
item.classList.remove('kpxcAutocomplete-active');
}
}
closeList(elem) {
closeList() {
this.input = undefined;
if (!this.shadowRoot) {
return;
}
const items = this.shadowSelectorAll('.kpxcAutocomplete-items');
if (!items) {
return;
}
for (const item of items) {
if (elem !== item && this.input) {
item.parentNode.removeChild(item);
}
}
this.container.style.display = 'none';
}
getAllItems() {
const list = this.shadowSelector('#kpxcAutocomplete-list');
if (!list) {
return [];
}
return list.getElementsByTagName('div');
return this.list.getElementsByTagName('div');
}
/**
@ -174,43 +156,46 @@ class Autocomplete {
* - Enter or Tab selects the item
* - Backspace and Delete shows the list if input field is empty. First item is activated
*/
keyPress(e) {
async keyDown(e) {
if (!e.isTrusted) {
return;
}
const items = this.getAllItems();
const inputField = e.target;
if (e.key === 'ArrowDown') {
e.preventDefault();
// If the list is not visible, show it
if (items.length === 0) {
this.index = -1;
this.showList(inputField);
if (!this.input) {
await this.showList(inputField);
this.index = 0;
if (inputField.value !== '') {
this.updateSearch();
}
requestAnimationFrame(() => this.selectItem());
} else {
// Activate next item
++this.index;
this.activateItem(items);
const items = this.getAllItems();
this.index = (this.index+1) % items.length;
this.selectItem();
}
} else if (e.key === 'ArrowUp') {
--this.index;
this.activateItem(items);
} else if (e.key === 'Enter') {
} else if (e.key === 'ArrowUp' && this.list) {
e.preventDefault();
const items = this.getAllItems();
this.index = (this.index > 0 ? this.index : items.length) - 1;
this.selectItem();
} else if (e.key === 'Enter' && this.input) {
if (inputField.value === '') {
e.preventDefault();
}
if (this.index >= 0 && items && items[this.index] !== undefined) {
const items = this.getAllItems();
if (this.index >= 0 && items[this.index] !== undefined) {
e.preventDefault();
this.itemEnter(this.index, this.elements);
await this.itemEnter(this.index, this.elements);
this.closeList();
}
} else if (e.key === 'Tab') {
// Return if the list is not open
if (items.length === 0) {
return;
}
} else if (e.key === 'Tab' && this.input) {
// Return if value is not in the list
if (inputField.value !== '' && !this.elements.some(c => c.value === inputField.value)) {
this.closeList();
@ -227,26 +212,45 @@ class Autocomplete {
this.closeList();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && inputField.value === '') {
// Show menu when input field has no value and backspace is pressed
this.index = -1;
this.showList(inputField);
this.index = 0;
this.selectItem();
}
}
updatePosition(inputField, elem) {
const div = elem || this.shadowSelector('.kpxcAutocomplete-items');
if (!div) {
keyUp(e) {
if (!this.input || !e.isTrusted || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
return;
}
const rect = inputField.getBoundingClientRect();
div.style.minWidth = Pixels(inputField.offsetWidth);
this.updateSearch();
this.selectItem();
}
updateSearch() {
if (this.index !== -1 && this.elements[this.index]?.value?.includes(this.input.value)) {
return;
}
this.index = this.elements.findIndex(c => c.value.startsWith(this.input.value));
if (this.index === -1) {
this.index = this.elements.findIndex(c => c.value.includes(this.input.value));
}
}
updatePosition() {
if (!this.container || !this.input) {
return;
}
const rect = this.input.getBoundingClientRect();
this.container.style.minWidth = Pixels(this.input.offsetWidth);
if (kpxcUI.bodyStyle.position.toLowerCase() === 'relative') {
div.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + inputField.offsetHeight);
div.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft);
this.container.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + document.scrollingElement.scrollTop + this.input.offsetHeight);
this.container.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + document.scrollingElement.scrollLeft);
} else {
div.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + inputField.offsetHeight);
div.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft);
this.container.style.top = Pixels(rect.top + document.scrollingElement.scrollTop + this.input.offsetHeight);
this.container.style.left = Pixels(rect.left + document.scrollingElement.scrollLeft);
}
}
}
@ -254,25 +258,10 @@ class Autocomplete {
// Global handlers
const handleOutsideClick = function(e, autocompleteMenu) {
const list = autocompleteMenu.shadowRoot ? autocompleteMenu.shadowSelector('#kpxcAutocomplete-list') : undefined;
if (!list) {
return;
}
if (e.target !== autocompleteMenu.input
&& !e.target.classList.contains('kpxc-username-icon')
&& e.target.nodeName !== autocompleteMenu.input.nodeName) {
autocompleteMenu.closeList(e.target);
if (autocompleteMenu.wrapper) {
document.body.removeChild(autocompleteMenu.wrapper);
}
}
};
const updatePosition = function(autocompleteMenu) {
if (autocompleteMenu.input) {
autocompleteMenu.updatePosition(autocompleteMenu.input);
&& e.target !== autocompleteMenu.input) {
autocompleteMenu.closeList();
}
};
@ -292,8 +281,8 @@ window.addEventListener('resize', function() {
return;
}
updatePosition(kpxcUserAutocomplete);
updatePosition(kpxcTOTPAutocomplete);
kpxcUserAutocomplete.updatePosition();
kpxcTOTPAutocomplete.updatePosition();
});
// Handle autocomplete position on scroll
@ -302,6 +291,6 @@ window.addEventListener('scroll', function() {
return;
}
updatePosition(kpxcUserAutocomplete);
updatePosition(kpxcTOTPAutocomplete);
kpxcUserAutocomplete.updatePosition();
kpxcTOTPAutocomplete.updatePosition();
});

View file

@ -1,24 +1,21 @@
'use strict';
class CredentialAutocomplete extends Autocomplete {}
CredentialAutocomplete.prototype.click = async function(e, input) {
CredentialAutocomplete.prototype.click = async function(e) {
if (!e.isTrusted) {
return;
}
e.stopPropagation();
if (input.value !== '') {
input.select();
}
const field = this.autocompleteList.find(a => a === input);
const field = this.autocompleteList.find(a => a === e.target);
if (field) {
this.showList(field);
await this.showList(field);
this.updateSearch();
}
};
CredentialAutocomplete.prototype.itemClick = async function(e, item, input, uuid) {
CredentialAutocomplete.prototype.itemClick = async function(e, input, uuid) {
if (!e.isTrusted) {
return;
}
@ -26,7 +23,7 @@ CredentialAutocomplete.prototype.itemClick = async function(e, item, input, uuid
e.stopPropagation();
const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget);
const usernameValue = item.getElementsByTagName('input')[0].value;
const usernameValue = e.target.getElementsByTagName('input')[0].value;
await this.fillPassword(usernameValue, index, uuid);
this.closeList();
@ -46,7 +43,6 @@ CredentialAutocomplete.prototype.fillPassword = async function(value, index, uui
const manualFill = await sendMessage('page_get_manual_fill');
await kpxc.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD);
this.input.setAttribute('fetched', true);
};
const kpxcUserAutocomplete = new CredentialAutocomplete();

View file

@ -1110,6 +1110,8 @@ kpxc.fillInCredentials = async function(combination, predefinedUsername, uuid, p
} else if (combination.form) {
combination.form.submit();
}
} else {
(combination.username || combination.password).focus();
}
};

View file

@ -10,7 +10,7 @@ TOTPAutocomplete.prototype.click = async function(e, input) {
this.showList(input, true);
};
TOTPAutocomplete.prototype.itemClick = async function(e, item, input, uuid) {
TOTPAutocomplete.prototype.itemClick = async function(e, input, uuid) {
if (!e.isTrusted) {
return;
}

View file

@ -1,5 +1,10 @@
.kpxcAutocomplete-items {
#kpxcAutocomplete-container {
display: none;
z-index: 2147483646;
position: absolute !important;
}
.kpxcAutocomplete-items {
max-height: 250px;
overflow-y: auto;
}
@ -23,7 +28,12 @@
border-bottom-left-radius: 4px !important;
}
.kpxcAutocomplete-items footer {
.kpxcAutocomplete-active {
background-color: #28a745 !important;
color: #ffffff !important;
}
#kpxcAutocomplete-container footer {
padding: 5px;
background-color: #ddd;
border: 1px solid rgba(0,0,0,.125) !important;
@ -33,11 +43,6 @@
font-style: italic;
}
.kpxcAutocomplete-active {
background-color: #28a745 !important;
color: #ffffff !important;
}
@media (prefers-color-scheme: dark) {
.kpxcAutocomplete-items {
background: var(--kpxc-background-color);