Refactor design of autocomplete dropdown (#2119)

Refactor design of Autocomplete Menu dropdown
This commit is contained in:
Felix Stieglitz 2024-07-01 09:04:16 +02:00 committed by GitHub
parent 50edb83c8e
commit 43c3e22cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 296 additions and 89 deletions

View file

@ -786,6 +786,10 @@
"message": "Use monochrome toolbar icon",
"description": "Use monochrome toolbar icon checkbox text."
},
"optionsCheckboxUseCompactMode": {
"message": "Use compact mode",
"description": "Whether the compact mode (legacy dropdown) should be enabled."
},
"optionsSaveDomainOnly": {
"message": "Save domain only",
"description": "Save domain only checkbox text."
@ -915,7 +919,7 @@
"description": "Autocomplete Usernames option help text."
},
"optionsShowGroupNameInAutocompleteHelpText": {
"message": "When credentials are returned from different groups, the group name will be shown.",
"message": "When credentials are returned from different groups, the group name will be shown. Only affects the compact mode.",
"description": "Show group name in autocomplete help text."
},
"optionsShowNotificationsHelpText": {
@ -926,6 +930,10 @@
"message": "Use a monochrome icon with colored badges in toolbar that adapts to light/dark browser theme.",
"description": "Use monochrome toolbar icon option help text."
},
"optionsUseCompactModeHelpText": {
"message": "Use a compact version of the dropdown list to select credentials.",
"description": "Use compact (legacy) mode help text."
},
"optionsSaveDomainOnlyHelpText": {
"message": "When saving new credentials, save only the domain instead of full URL.",
"description": "Save domain only option help text."

View file

@ -4,7 +4,6 @@ const defaultSettings = {
afterFillSorting: SORT_BY_MATCHING_CREDENTIALS_SETTING,
afterFillSortingTotp: SORT_BY_RELEVANT_ENTRY,
autoCompleteUsernames: true,
showGroupNameInAutocomplete: true,
autoFillAndSend: false,
autoFillSingleEntry: false,
autoFillSingleTotp: false,
@ -24,12 +23,14 @@ const defaultSettings = {
redirectAllowance: 1,
saveDomainOnly: true,
showGettingStartedGuideAlert: true,
showTroubleshootingGuideAlert: true,
showGroupNameInAutocomplete: true,
showLoginFormIcon: true,
showLoginNotifications: true,
showNotifications: true,
useMonochromeToolbarIcon: false,
showOTPIcon: true,
showTroubleshootingGuideAlert: true,
useCompactMode: false,
useMonochromeToolbarIcon: false,
useObserver: true,
usePredefinedSites: true,
usePasswordGeneratorIcons: false,

View file

@ -16,6 +16,8 @@ class Autocomplete {
this.elements = [];
this.index = -1;
this.input = undefined;
this.showGroup = true;
this.useCompactMode = false;
this.wrapper = undefined;
this.shadowRoot = undefined;
this.container = undefined;
@ -25,25 +27,28 @@ class Autocomplete {
this.elements = [];
}
async click(e) {
async click(e) {}
}
async itemClick(e, input, uuid) {}
async itemClick(e, input, uuid) {
async itemEnter(index, item) {}
}
async itemEnter(index, item) {
}
async create(input, showListInstantly = false, autoSubmit = false, afterFillSort = SORT_BY_MATCHING_CREDENTIALS_SETTING) {
async create(
input,
showListInstantly = false,
autoSubmit = false,
useCompactMode = false,
showGroup = true,
afterFillSort = SORT_BY_MATCHING_CREDENTIALS_SETTING,
) {
if (input.readOnly) {
return;
}
this.autoSubmit = autoSubmit;
this.afterFillSort = afterFillSort;
this.showGroup = showGroup;
this.useCompactMode = useCompactMode;
if (!this.autocompleteList.includes(input)) {
input.addEventListener('click', e => this.click(e));
@ -63,9 +68,12 @@ class Autocomplete {
return;
}
this.deselectItem();
e.target.classList.add('kpxcAutocomplete-active');
const itemContainer = e.target.classList.contains('kpxcAutocomplete-item') ? e.target : e.target.parentElement;
itemContainer.classList.add('kpxcAutocomplete-item--active');
const items = this.getAllItems();
this.index = Array.from(items).indexOf(e.target);
this.index = Array.from(items).indexOf(itemContainer);
}
async showList(inputField) {
@ -89,14 +97,21 @@ class Autocomplete {
this.wrapper = kpxcUI.createElement('div');
this.wrapper.style.all = 'unset';
this.wrapper.style.display = 'none';
styleSheet.addEventListener('load', () => this.wrapper.style.display = 'block');
this.container = kpxcUI.createElement('div', 'kpxcAutocomplete-container', { 'id': 'kpxcAutocomplete-container' });
styleSheet.addEventListener('load', () => (this.wrapper.style.display = 'block'));
this.container = kpxcUI.createElement('div', 'kpxcAutocomplete-container', {
id: 'kpxcAutocomplete-container',
});
// Apply compact mode class
if (kpxc.settings.useCompactMode) {
this.container.classList.add('kpxcAutocomplete-container--compact');
}
this.shadowRoot = this.wrapper.attachShadow({ mode: 'closed' });
this.shadowRoot.append(colorStyleSheet);
this.shadowRoot.append(styleSheet);
this.list = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { 'id': 'kpxcAutocomplete-list' });
this.list = kpxcUI.createElement('div', 'kpxcAutocomplete-items', { id: 'kpxcAutocomplete-list' });
initColorTheme(this.container);
this.container.append(this.list);
@ -111,7 +126,7 @@ class Autocomplete {
}
this.updateList();
this.container.style.display = 'block';
this.container.classList.add('kpxcAutocomplete-container--visible');
this.updatePosition();
}
@ -131,45 +146,85 @@ class Autocomplete {
// Update credentials to menu div
for (const c of this.elements) {
const item = document.createElement('div');
item.textContent = c.label;
item.setAttribute('uuid', c.uuid);
const item = this.generateItem(c);
const itemInput = kpxcUI.createElement('input', '', { 'type': 'hidden', 'value': c.value });
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('mousemove', e => this.mouseMove(e));
item.addEventListener('mousedown', e => e.stopPropagation());
item.addEventListener('mouseup', e => e.stopPropagation());
// If this page has an associated uuid and it matches this credential, then put it on top of the list
if (username === c.value
|| (this.afterFillSort === SORT_BY_RELEVANT_ENTRY && c.uuid === pageUuid)) {
if (username === c.value || (this.afterFillSort === SORT_BY_RELEVANT_ENTRY && c.uuid === pageUuid)) {
this.list.prepend(item);
} else {
this.list.appendChild(item);
}
}
// Use only two columns if groups are not shown
if (this.useCompactMode && !this.showGroup) {
this.list.style.gridTemplateColumns = 'repeat(2, auto)';
}
this.selectItem();
}
// Add parentElement parameter
generateItem(credential) {
// Create the DOM element for the list item wrapper.
const item = kpxcUI.createElement('div', 'kpxcAutocomplete-item', {
title: `${credential.title} | ${credential.value} | ${credential.group}`
});
item.setAttribute('uuid', credential.uuid);
// Create the DOM element for the list item header.
const itemHeader = document.createElement('div');
itemHeader.classList.add('kpxcAutocomplete-item__header');
// Create the DOM element for showing the group only when the group name is available.
if (!this.useCompactMode) {
const itemGroup = kpxcUI.createElement('div', 'kpxcAutocomplete-item__group', {}, credential.group);
itemHeader.append(itemGroup);
}
// Create the DOM element for showing the credentials name.
const itemLabel = kpxcUI.createElement('div', 'kpxcAutocomplete-item__label', {}, credential.title);
itemHeader.append(itemLabel);
// Append the header to the list item.
item.append(itemHeader);
// Create the DOM element for showing the credentials username field.
const itemClassName = this.showGroup ? 'kpxcAutocomplete-item__value' : 'kpxcAutocomplete-item__value--last';
const itemValue = kpxcUI.createElement('div', itemClassName, {}, credential.value);
item.append(itemValue);
// If compact mode is enabled, append group to the end
if (this.useCompactMode && this.showGroup) {
const itemGroup = kpxcUI.createElement('div', 'kpxcAutocomplete-item__group', {}, credential.group);
item.append(itemGroup);
}
return item;
}
selectItem() {
this.deselectItem();
const items = this.getAllItems();
const item = items[this.index];
if (item !== undefined) {
item.classList.add('kpxcAutocomplete-active');
item.classList.add('kpxcAutocomplete-item--active');
item.scrollIntoView({ block: 'nearest' });
}
}
deselectItem() {
const items = this.list.querySelectorAll('div.kpxcAutocomplete-active');
items.forEach(item => item.classList.remove('kpxcAutocomplete-active'));
const items = this.list.querySelectorAll('div.kpxcAutocomplete-item--active');
items.forEach(item => item.classList.remove('kpxcAutocomplete-item--active'));
}
closeList() {
@ -178,11 +233,11 @@ class Autocomplete {
return;
}
this.container.style.display = 'none';
this.container.classList.remove('kpxcAutocomplete-container--visible');
}
getAllItems() {
return this.list.getElementsByTagName('div');
return this.list.getElementsByClassName('kpxcAutocomplete-item');
}
/**
@ -191,7 +246,7 @@ class Autocomplete {
* - ArrowUp selects item above, or the last item (first is active)
* - Enter or Tab selects the item
* - Backspace and Delete shows the list if input field is empty. First item is activated
*/
*/
async keyDown(e) {
if (!e.isTrusted) {
return;
@ -211,7 +266,7 @@ class Autocomplete {
} else {
// Activate next item
const items = this.getAllItems();
this.index = (this.index+1) % items.length;
this.index = (this.index + 1) % items.length;
this.selectItem();
}
} else if (e.key === 'ArrowUp' && this.list) {
@ -274,23 +329,18 @@ class Autocomplete {
return;
}
const rect = this.input.getBoundingClientRect();
this.container.style.minWidth = Pixels(this.input.offsetWidth);
// Calculate Y offset if menu does not fit to the bottom of the screen -> show it at the top of the input field
const rect = this.input.getBoundingClientRect();
const menuRect = this.container.getBoundingClientRect();
const totalHeight = menuRect.height + rect.height;
const menuOffset = (totalHeight + rect.y) > window.self.visualViewport.height ? totalHeight : 0;
if (menuOffset > 0) {
this.container.classList.add('kpxcAutocomplete-container-on-top');
} else {
this.container.classList.remove('kpxcAutocomplete-container-on-top');
}
const menuOffset = totalHeight + rect.y > window.self.visualViewport.height ? totalHeight : 0;
const scrollTop = kpxcUI.getScrollTop();
const scrollLeft = kpxcUI.getScrollLeft();
if (kpxcUI.bodyStyle.position.toLowerCase() === 'relative') {
this.container.style.top = Pixels(rect.top - kpxcUI.bodyRect.top + scrollTop + this.input.offsetHeight - menuOffset);
this.container.style.top = Pixels(
rect.top - kpxcUI.bodyRect.top + scrollTop + this.input.offsetHeight - menuOffset,
);
this.container.style.left = Pixels(rect.left - kpxcUI.bodyRect.left + scrollLeft);
} else {
this.container.style.top = Pixels(rect.top + scrollTop + this.input.offsetHeight - menuOffset);

View file

@ -23,7 +23,7 @@ CredentialAutocomplete.prototype.itemClick = async function(e, input, uuid) {
e.stopPropagation();
const index = Array.prototype.indexOf.call(e.currentTarget.parentElement.childNodes, e.currentTarget);
const usernameValue = e.target.getElementsByTagName('input')[0].value;
const usernameValue = e.currentTarget.getElementsByTagName('input')[0]?.value;
await this.fillPassword(usernameValue, index, uuid);
this.closeList();

View file

@ -269,14 +269,35 @@ kpxc.initAutocomplete = function() {
for (const c of kpxc.combinations) {
if (c.username) {
kpxcUserAutocomplete.create(c.username, false, kpxc.settings.autoSubmit, kpxc.settings.afterFillSorting);
kpxcUserAutocomplete.create(
c.username,
false,
kpxc.settings.autoSubmit,
kpxc.settings.useCompactMode,
kpxc.settings.showGroupNameInAutocomplete,
kpxc.settings.afterFillSorting,
);
} else if (!c.username && c.password) {
// Single password field
kpxcUserAutocomplete.create(c.password, false, kpxc.settings.autoSubmit, kpxc.settings.afterFillSorting);
kpxcUserAutocomplete.create(
c.password,
false,
kpxc.settings.autoSubmit,
kpxc.settings.useCompactMode,
kpxc.settings.showGroupNameInAutocomplete,
kpxc.settings.afterFillSorting,
);
}
if (c.totp) {
kpxcTOTPAutocomplete.create(c.totp, false, kpxc.settings.autoSubmit, kpxc.settings.afterFillSortingTotp);
kpxcTOTPAutocomplete.create(
c.totp,
false,
kpxc.settings.autoSubmit,
kpxc.settings.useCompactMode,
kpxc.settings.showGroupNameInAutocomplete,
kpxc.settings.afterFillSortingTotp,
);
}
}
};
@ -382,7 +403,7 @@ kpxc.initLoginPopup = function() {
return {
title: title,
group: group,
group: credential.group,
visibleLogin: visibleLogin,
login: credential.login,
loginId: loginId,
@ -440,6 +461,8 @@ kpxc.initLoginPopup = function() {
popupLoginItems.push({ text: l.text, uuid: l.uuid });
kpxcUserAutocomplete.elements.push({
group: l.group,
title: l.title,
label: l.text,
value: l.login,
uuid: l.uuid,

View file

@ -1,18 +1,22 @@
.kpxcAutocomplete-container {
display: none;
z-index: 2147483646;
position: absolute !important;
background-color: var(--kpxc-background-color);
border: var(--kpxc-autocomplete-menu-border);
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
overflow: hidden; /* this fixes an issue with the border radius not showing up clearly */
border-radius: 8px;
box-shadow: var(--kpxc-box-shadow);
box-sizing: border-box;
display: none;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1em;
min-width: 16rem;
overflow: hidden; /* Prevent the content from bleeding over the border radius. */
padding: 6px;
position: absolute !important;
z-index: 2147483646;
}
.kpxcAutocomplete-container-on-top {
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
.kpxcAutocomplete-container--visible {
display: block;
}
.kpxcAutocomplete-items {
@ -20,37 +24,138 @@
overflow-y: auto;
}
.kpxcAutocomplete-items div {
padding: 5px;
cursor: pointer;
.kpxcAutocomplete-item {
background-color: var(--kpxc-background-color);
border-bottom: 1px solid rgba(0,0,0,.125);
width: auto;
border-radius: 4px;
color: var(--kpxc-text-color);
font-size: max(10px, .9em) !important;
cursor: pointer;
letter-spacing: normal !important;
word-spacing: normal !important;
text-rendering: auto !important;
padding: 12px;
text-align: start !important;
text-rendering: auto !important;
width: auto;
word-spacing: normal !important;
}
.kpxcAutocomplete-items div:last-child {
.kpxcAutocomplete-container--compact .kpxcAutocomplete-items {
cursor: pointer;
display: grid;
grid-template-columns: repeat(3, auto);
max-height: 250px;
max-width: 600px;
overflow-y: auto;
overflow-x: hidden;
}
.kpxcAutocomplete-container--compact .kpxcAutocomplete-item {
display: contents;
}
.kpxcAutocomplete-container--compact .kpxcAutocomplete-item__header {
align-self: flex-start;
border-radius: 4px 0 0 4px;
display: grid;
height: 14px;
padding: 6px 24px 6px 6px;
pointer-events: auto;
}
.kpxcAutocomplete-container--compact .kpxcAutocomplete-item__group {
align-self: center;
border-radius: 0 4px 4px 0;
display: flex;
height: 14px;
justify-content: flex-end;
padding: 6px 6px 6px 24px;
pointer-events: auto;
}
.kpxcAutocomplete-container--compact .kpxcAutocomplete-item__value {
align-self: flex-end;
height: 14px;
justify-self: start;
margin: 0;
padding: 6px 0 6px 0;
pointer-events: auto;
width: 100%;
}
/* Separate class for value if group is not shown */
.kpxcAutocomplete-container--compact .kpxcAutocomplete-item__value--last {
align-self: center;
border-radius: 0 4px 4px 0;
color: var(--kpxc-lighter-text-color);
display: flex;
font-size: .85em;
height: 14px;
justify-content: flex-end;
overflow: hidden;
padding: 6px 6px 6px 24px;
pointer-events: auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.kpxcAutocomplete-item:last-child {
border-bottom: none;
}
.kpxcAutocomplete-active {
.kpxcAutocomplete-item__header {
align-items: center;
display: flex;
flex-direction: row-reverse;
gap: 6px;
justify-content: space-between;
}
.kpxcAutocomplete-item__group {
color: var(--kpxc-light-text-color);
font-size: .7em;
font-weight: bold;
letter-spacing: 0.05em;
overflow: hidden;
pointer-events: none;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.kpxcAutocomplete-item__label {
font-size: .9em;
font-weight: bold;
overflow: hidden;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap
}
.kpxcAutocomplete-item__value {
color: var(--kpxc-lighter-text-color);
font-size: .85em;
margin-top: 4px;
overflow: hidden;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.kpxcAutocomplete-item--active {
background-color: #28a745 !important;
color: #ffffff !important;
}
.kpxcAutocomplete-item--active * {
background-color: #28a745 !important;
color: #ffffff !important;
}
.kpxcAutocomplete-container footer {
padding: 5px;
background-color: #ddd;
border-top: 1px solid rgba(0,0,0,.125) !important;
width: auto;
color: #000;
background-color: var(--kpxc-autocomplete-footer-color);
border-radius: 0 0 4px 4px;
color: var(--kpxc-lighter-text-color);
font-size: max(9px, .8em) !important;
font-style: italic;
padding: 5px;
width: auto;
}
@media (prefers-color-scheme: dark) {
@ -62,11 +167,4 @@
background: var(--kpxc-background-color);
color: var(--kpxc-text-color);
}
.kpxc-pwgen-input {
background-color: var(--kpxc-input-background-color) !important;
border: var(--kpxc-input-border);
border-color: var(--kpxc-input-border-color);
color: var(--kpxc-text-color) !important;
}
}

View file

@ -1,8 +1,11 @@
:root, :host {
:root,
:host {
--kpxc-autocomplete-footer-color: #f2f2f2;
--kpxc-autocomplete-menu-border: 1px solid #ddd;
--kpxc-background-color: #fff;
--kpxc-box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--kpxc-card-background-color: #fff;
--kpxc-card-border-color: rgba(0,0,0,.125);
--kpxc-card-border-color: rgba(0, 0, 0, .125);
--kpxc-card-header-color: #fafafa;
--kpxc-checkbox-color: #5b5b5d;
--kpxc-checkbox-background-color: #2b2a2a;
@ -11,7 +14,7 @@
--kpxc-input-background-color: #fff;
--kpxc-input-border: 1px solid #2b4d1a;
--kpxc-input-border-color: #292a2a;
--kpxc-input-main-border-color: rgba(0,0,0,.125);
--kpxc-input-main-border-color: rgba(0, 0, 0, .125);
--kpxc-link-color: #3a8233;
--kpxc-link-hover-color: #429f14;
--kpxc-sidebar-background-color: #27272a;
@ -19,10 +22,15 @@
--kpxc-table-hover-color: #f2f2f2;
--kpxc-table-odd-color: #f2f2f2;
--kpxc-text-color: #000;
--kpxc-lighter-text-color: rgba(0, 0, 0, 0.8);
--kpxc-light-text-color: rgba(0, 0, 0, 0.5);
}
@media (prefers-color-scheme: dark) {
:root, :host {
:root,
:host {
--kpxc-autocomplete-footer-color: #2b2a2a;
--kpxc-autocomplete-menu-border: 1px solid #3b3b3d;
--kpxc-background-color: #3b3b3d;
--kpxc-card-background-color: #2b2a2a;
@ -42,11 +50,14 @@
--kpxc-table-hover-color: #474948;
--kpxc-table-odd-color: #383a39;
--kpxc-text-color: #cbcfcb;
--kpxc-lighter-text-color: rgba(203, 207, 203, 0.8);
--kpxc-light-text-color: rgba(203, 207, 203, 0.5);
}
}
/* Same colors for manual switching */
[data-bs-theme='dark'] {
--kpxc-autocomplete-footer-color: #2b2a2a;
--kpxc-autocomplete-menu-border: 1px solid #3b3b3d;
--kpxc-background-color: #3b3b3d;
--kpxc-card-background-color: #2b2a2a;
@ -66,13 +77,17 @@
--kpxc-table-hover-color: #474948;
--kpxc-table-odd-color: #383a39;
--kpxc-text-color: #cbcfcb;
--kpxc-lighter-text-color: rgba(203, 207, 203, 0.8);
--kpxc-light-text-color: rgba(203, 207, 203, 0.5);
}
[data-bs-theme='light'] {
--kpxc-autocomplete-footer-color: #f2f2f2;
--kpxc-autocomplete-menu-border: 1px solid #ddd;
--kpxc-background-color: #fff;
--kpxc-box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--kpxc-card-background-color: #fff;
--kpxc-card-border-color: rgba(0,0,0,.125);
--kpxc-card-border-color: rgba(0, 0, 0, .125);
--kpxc-card-header-color: #fafafa;
--kpxc-checkbox-color: #5b5b5d;
--kpxc-checkbox-background-color: #5b5b5d;
@ -81,11 +96,14 @@
--kpxc-input-background-color: #fff;
--kpxc-input-border: 1px solid #2b4d1a;
--kpxc-input-border-color: #292a2a;
--kpxc-input-main-border-color: rgba(0,0,0,.125);
--kpxc-input-main-border-color: rgba(0, 0, 0, .125);
--kpxc-link-color: #3a8233;
--kpxc-link-hover-color: #429f14;
--kpxc-table-border-color: rgb(222, 226, 230);
--kpxc-table-hover-color: #f2f2f2;
--kpxc-table-odd-color: #f2f2f2;
--kpxc-text-color: #000;
--kpxc-lighter-text-color: rgba(0, 0, 0, 0.8);
--kpxc-light-text-color: rgba(0, 0, 0, 0.5);
}
}

View file

@ -136,6 +136,15 @@
<div class="form-text" data-i18n="optionsUseMonochromeToolbarIconHelpText"></div>
</div>
</div>
<!-- Use compact mode -->
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="useCompactMode" id="useCompactModeCheckBox" value="true">
<label class="form-check-label" for="useCompactModeCheckBox" data-i18n="optionsCheckboxUseCompactMode"></label>
<div class="form-text" data-i18n="optionsUseCompactModeHelpText"></div>
</div>
</div>
</div>
</div>