Skip to content

Commit

Permalink
[!!!][TASK] Remove jQuery from FormEngine auto-suggest
Browse files Browse the repository at this point in the history
This patch migrates the auto-suggest feature used in FormEngine to a
custom implementation incorporating web components.
A user may navigate through the result list with the keyboard's arrow
keys and select an item with the "Enter" key.

In the same run, the JavaScript library `devbridge-autocomplete` is
removed as it is not used anymore within TYPO3, along with its
associated CSS definitions.

As the web component approach allows us more flexibility and to maintain
a consistent look & feel of the backend, some properties were removed
from the suggest wizard's items.

Resolves: #98455
Releases: main
Change-Id: I7abc40bfe9161a7a246ac451bd046034dcc8d9bd
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/75881
Tested-by: core-ci <typo3@b13.com>
Tested-by: Frank Nägler <frank.naegler@typo3.com>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Frank Nägler <frank.naegler@typo3.com>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
  • Loading branch information
andreaskienast committed Sep 29, 2022
1 parent 8eca62e commit fa94fea
Show file tree
Hide file tree
Showing 20 changed files with 513 additions and 220 deletions.
5 changes: 0 additions & 5 deletions Build/Gruntfile.js
Expand Up @@ -562,10 +562,6 @@ module.exports = function (grunt) {
process: (source, srcpath) => {
let imports = [], prefix = '';

if (srcpath === 'node_modules/devbridge-autocomplete/dist/jquery.autocomplete.min.js') {
imports.push('jquery');
}

if (srcpath === 'node_modules/@claviska/jquery-minicolors/jquery.minicolors.min.js') {
imports.push('jquery');
}
Expand All @@ -591,7 +587,6 @@ module.exports = function (grunt) {
'imagesloaded.js': 'imagesloaded/imagesloaded.js',
'interact.js': 'interactjs/dist/interact.min.js',
'jquery.js': 'jquery/dist/jquery.js',
'jquery.autocomplete.js': 'devbridge-autocomplete/dist/jquery.autocomplete.min.js',
'jquery/minicolors.js': '../node_modules/@claviska/jquery-minicolors/jquery.minicolors.min.js',
'moment.js': 'moment/min/moment-with-locales.min.js',
'moment-timezone.js': 'moment-timezone/builds/moment-timezone-with-data.min.js',
Expand Down
92 changes: 46 additions & 46 deletions Build/Sources/Sass/component/_autocomplete.scss
Expand Up @@ -2,64 +2,64 @@
// Autocomplete
// ============
//

//
// Variables
//
$autocomplete-border: #ddd;
$autocomplete-border-radius: 2px;
$autocomplete-results-bg: #fff;
$autocomplete-zindex: $zindex-dropdown;
$autocomplete-suggestion-link-hover-bg: #fafafa;

//
// Component
//
.autocomplete {
position: relative;
}

.autocomplete-results {
z-index: $autocomplete-zindex;
typo3-backend-formengine-suggest-result-container {
position: absolute;
margin: 5px 0;
top: 100%;
margin-left: 3.5em;
left: 0;
border: 1px solid $autocomplete-border;
border-radius: $autocomplete-border-radius;
background-color: $autocomplete-results-bg;
overflow: hidden;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, .25);
z-index: $autocomplete-zindex;
max-width: calc(100vw - 20px);
}

.autocomplete-suggestion {
border-top: 1px solid $autocomplete-border;

&:first-child {
border-top: none;
}
typo3-backend-formengine-suggest-result-list {
display: flex;
flex-direction: column;
gap: 1px;
background-color: var(--typo3-component-bg);
border: var(--typo3-component-border-width) solid var(--typo3-component-border-color);
border-radius: var(--typo3-component-border-radius);
box-shadow: var(--typo3-component-box-shadow);
font-size: var(--typo3-component-font-size);
line-height: var(--typo3-component-line-height);
}

.autocomplete-suggestion-link {
padding: 5px 13px 5px 28px;
display: block;
text-decoration: none;
}
typo3-backend-formengine-suggest-result-item {
display: flex;
gap: .5em;
font-size: var(--typo3-component-font-size);
line-height: var(--typo3-component-line-height);
padding: var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);
border-radius: calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));
color: #{$body-color};
background-color: #{$white};
cursor: pointer;

.autocomplete-info {
padding: 5px 15px;
}
&:hover,
&:focus {
z-index: 1;
outline-offset: -1px;
}

.autocomplete-suggestion {
&:hover {
background-color: $autocomplete-suggestion-link-hover-bg;
text-decoration: none;
background-color: tint-color($primary, 95%);
outline: 1px solid tint-color($primary, 85%);
}

&:not(:disabled):focus,
&:not(:disabled):hover,
&.autocomplete-selected {
background-color: rgba(255, 255, 255, .2);
&:focus {
background-color: tint-color($primary, 95%);
outline: 1px solid tint-color($primary, 20%);
}

.formengine-suggest-result-item-icon {
flex-grow: 0;
flex-shrink: 0;
}

.formengine-suggest-result-item-label {
flex-grow: 1;

small {
opacity: .5;
}
}
}
204 changes: 98 additions & 106 deletions Build/Sources/TypeScript/backend/form-engine-suggest.ts
Expand Up @@ -11,127 +11,119 @@
* The TYPO3 project - inspiring people to share!
*/

import $ from 'jquery';
import 'jquery/autocomplete';
import './form-engine/element/suggest/result-container';
import DocumentService from '@typo3/core/document-service';
import FormEngine from '@typo3/backend/form-engine';
import RegularEvent from '@typo3/core/event/regular-event';
import DebounceEvent from '@typo3/core/event/debounce-event';
import AjaxRequest from '@typo3/core/ajax/ajax-request';
import {AjaxResponse} from '@typo3/core/ajax/ajax-response';

// data structure returned by SuggestWizardDefaultReceiver::queryTable()
interface SuggestEntry {
table: string;
uid: number;

label: string;
// The HTML content for the suggest option
text: string;
// The record path
path: string;

style: string;
class: string;
// the icon
sprite: string;
}
class FormEngineSuggest {
private readonly element: HTMLInputElement;
private resultContainer: HTMLElement;
private currentRequest: AjaxRequest|null = null;

interface TransformedSuggestEntry {
value: string;
data: SuggestEntry;
}
constructor(element: HTMLInputElement) {
this.element = element;

class FormEngineSuggest {
constructor(element: HTMLElement) {
$((): void => {
DocumentService.ready().then((): void => {
this.initialize(element);
this.registerEvents();
});
}

private initialize(searchField: HTMLElement): void {
const containerElement: Element = searchField.closest('.t3-form-suggest-container');
const tableName: string = searchField.dataset.tablename;
const fieldName: string = searchField.dataset.fieldname;
const formEl: string = searchField.dataset.field;
const uid: number = parseInt(searchField.dataset.uid, 10);
const pid: number = parseInt(searchField.dataset.pid, 10);
const dataStructureIdentifier: string = searchField.dataset.datastructureidentifier;
const flexFormSheetName: string = searchField.dataset.flexformsheetname;
const flexFormFieldName: string = searchField.dataset.flexformfieldname;
const flexFormContainerName: string = searchField.dataset.flexformcontainername;
const flexFormContainerFieldName: string = searchField.dataset.flexformcontainerfieldname;
const minimumCharacters: number = parseInt(searchField.dataset.minchars, 10);
const url: string = TYPO3.settings.ajaxUrls.record_suggest;
const params = {
tableName,
fieldName,
uid,
pid,
dataStructureIdentifier,
flexFormSheetName,
flexFormFieldName,
flexFormContainerName,
flexFormContainerFieldName,
};

function insertValue(element: HTMLElement): void {

this.resultContainer = document.createElement('typo3-backend-formengine-suggest-result-container');
this.resultContainer.hidden = true;
containerElement.append(this.resultContainer);
}

private registerEvents(): void {
new RegularEvent('typo3:formengine:suggest-item-chosen', (e: CustomEvent): void => {
let insertData: string = '';
if (searchField.dataset.fieldtype === 'select') {
insertData = element.dataset.uid;
if (this.element.dataset.fieldtype === 'select') {
insertData = e.detail.element.uid;
} else {
insertData = element.dataset.table + '_' + element.dataset.uid;
insertData = e.detail.element.table + '_' + e.detail.element.uid;
}
FormEngine.setSelectOptionFromExternalSource(this.element.dataset.field, insertData, e.detail.element.label, e.detail.element.label);
FormEngine.Validation.markFieldAsChanged(document.querySelector('input[name="' + this.element.dataset.field + '"]') as HTMLInputElement);
this.resultContainer.hidden = true;
}).bindTo(this.resultContainer);

new RegularEvent('focus', (): void => {
const results = JSON.parse(this.resultContainer.getAttribute('results'));
if (results?.length > 0) {
this.resultContainer.hidden = false;
}
}).bindTo(this.element);

new RegularEvent('blur', (e: FocusEvent): void => {
if ((e.relatedTarget as HTMLElement)?.tagName.toLowerCase() === 'typo3-backend-formengine-suggest-result-item') {
// don't to anything if focus switches to a result item
return;
}

this.resultContainer.hidden = true;
}).bindTo(this.element);

new DebounceEvent('input', (e: InputEvent): void => {
if (this.currentRequest instanceof AjaxRequest) {
this.currentRequest.abort();
}
FormEngine.setSelectOptionFromExternalSource(formEl, insertData, element.dataset.label, element.dataset.label);
FormEngine.Validation.markFieldAsChanged($(document.querySelector('input[name="' + formEl + '"]')));

const target = e.target as HTMLInputElement;

if (target.value.length < parseInt(target.dataset.minchars, 10)) {
return;
}

this.currentRequest = new AjaxRequest(TYPO3.settings.ajaxUrls.record_suggest);
this.currentRequest.post({
value: target.value,
tableName: target.dataset.tablename,
fieldName: target.dataset.fieldname,
uid: parseInt(target.dataset.uid, 10),
pid: parseInt(target.dataset.pid, 10),
dataStructureIdentifier: target.dataset.datastructureidentifier,
flexFormSheetName: target.dataset.flexformsheetname,
flexFormFieldName: target.dataset.flexformfieldname,
flexFormContainerName: target.dataset.flexformcontainername,
flexFormContainerFieldName: target.dataset.flexformcontainerfieldname,
}).then(async (response: AjaxResponse): Promise<void> => {
const resultSet = await response.raw().text();
this.resultContainer.setAttribute('results', resultSet);
this.resultContainer.hidden = false;
});
}).bindTo(this.element);

new RegularEvent('keydown', this.handleKeyDown).bindTo(this.element);
}

private handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'ArrowDown') {
e.preventDefault();

const results = JSON.parse(this.resultContainer.getAttribute('results'));
if (results?.length > 0) {
this.resultContainer.hidden = false;
}

// Select first available result item
const firstSearchResultItem = this.resultContainer.querySelector('typo3-backend-formengine-suggest-result-item') as HTMLElement|null;
firstSearchResultItem?.focus();

return;
}

$(searchField).autocomplete({
// ajax options
serviceUrl: url,
params: params,
type: 'POST',
paramName: 'value',
dataType: 'json',
minChars: minimumCharacters,
groupBy: 'typeLabel',
containerClass: 'autocomplete-results',
appendTo: containerElement,
forceFixPosition: false,
preserveInput: true,
showNoSuggestionNotice: true,
noSuggestionNotice: '<div class="autocomplete-info">No results</div>',
minLength: minimumCharacters,
preventBadQueries: false,
// put the AJAX results in the right format
transformResult: (response: Array<SuggestEntry>): {suggestions: Array<TransformedSuggestEntry>} => {
return {
suggestions: response.map((dataItem: SuggestEntry): {value: string, data: SuggestEntry} => {
return {value: dataItem.text, data: dataItem};
}),
};
},
// Rendering of each item
formatResult: (suggestion: {data: SuggestEntry}): string => {
return $('<div>').append(
$('<a class="autocomplete-suggestion-link" href="#">' +
suggestion.data.sprite + suggestion.data.text +
'</a></div>').attr({
'data-label': suggestion.data.label,
'data-table': suggestion.data.table,
'data-uid': suggestion.data.uid,
})).html();
},
onSearchComplete: function(): void {
containerElement.classList.add('open');
},
beforeRender: function(container: JQuery): void {
// Unset height, width and z-index again, should be fixed by the plugin at a later point
container.attr('style', '');
containerElement.classList.add('open');
},
onHide: function(): void {
containerElement.classList.remove('open');
},
onSelect: function(): void {
insertValue(<HTMLElement>(containerElement.querySelector('.autocomplete-selected a')));
},
});
if (e.key === 'Escape') {
e.preventDefault();

this.resultContainer.hidden = true;
}
}
}

Expand Down
Expand Up @@ -33,10 +33,10 @@ class GroupElement extends AbstractSortableSelectItems {
}

private registerSuggest(): void {
let suggestContainer;
if ((suggestContainer = <HTMLElement>this.element.closest('.t3js-formengine-field-item').querySelector('.t3-form-suggest')) !== null) {
let suggestField;
if ((suggestField = <HTMLInputElement>this.element.closest('.t3js-formengine-field-item').querySelector('.t3-form-suggest')) !== null) {
// tslint:disable-next-line:no-unused-expression
new FormEngineSuggest(suggestContainer);
new FormEngineSuggest(suggestField);
}
}
}
Expand Down

0 comments on commit fa94fea

Please sign in to comment.