diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/shared/autocomplete')
6 files changed, 721 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/autocomplete.js b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/autocomplete.js new file mode 100644 index 0000000..21eeb53 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/autocomplete.js @@ -0,0 +1,321 @@ +/** + * @copyright Lea Verou and the Converse.js contributors + * @description + * Started as a fork of Lea Verou's "Awesomplete" + * https://leaverou.github.io/awesomplete/ + * @license Mozilla Public License (MPLv2) + */ + +import { Events } from '@converse/skeletor/src/events.js'; +import { helpers, FILTER_CONTAINS, ITEM, SORT_BY_QUERY_POSITION } from './utils.js'; +import Suggestion from './suggestion.js'; +import { converse } from "@converse/headless/core"; + + +const u = converse.env.utils; + + +export class AutoComplete { + + constructor (el, config={}) { + this.suggestions = []; + this.is_opened = false; + + if (u.hasClass('suggestion-box', el)) { + this.container = el; + } else { + this.container = el.querySelector('.suggestion-box'); + } + this.input = this.container.querySelector('.suggestion-box__input'); + this.input.setAttribute("aria-autocomplete", "list"); + + this.ul = this.container.querySelector('.suggestion-box__results'); + this.status = this.container.querySelector('.suggestion-box__additions'); + + Object.assign(this, { + 'match_current_word': false, // Match only the current word, otherwise all input is matched + 'ac_triggers': [], // Array of keys (`ev.key`) values that will trigger auto-complete + 'include_triggers': [], // Array of trigger keys which should be included in the returned value + 'min_chars': 2, + 'max_items': 10, + 'auto_evaluate': true, // Should evaluation happen automatically without any particular key as trigger? + 'auto_first': false, // Should the first element be automatically selected? + 'data': a => a, + 'filter': FILTER_CONTAINS, + 'sort': config.sort === false ? false : SORT_BY_QUERY_POSITION, + 'item': ITEM + }, config); + + this.index = -1; + + this.bindEvents() + + if (this.input.hasAttribute("list")) { + this.list = "#" + this.input.getAttribute("list"); + this.input.removeAttribute("list"); + } else { + this.list = this.input.getAttribute("data-list") || config.list || []; + } + } + + bindEvents () { + const input = { + "blur": () => this.close({'reason': 'blur'}) + } + if (this.auto_evaluate) { + input["input"] = (e) => this.evaluate(e); + } + + this._events = { + 'input': input, + 'form': { + "submit": () => this.close({'reason': 'submit'}) + }, + 'ul': { + "mousedown": (ev) => this.onMouseDown(ev), + "mouseover": (ev) => this.onMouseOver(ev) + } + }; + helpers.bind(this.input, this._events.input); + helpers.bind(this.input.form, this._events.form); + helpers.bind(this.ul, this._events.ul); + } + + set list (list) { + if (Array.isArray(list) || typeof list === "function") { + this._list = list; + } else if (typeof list === "string" && list.includes(",")) { + this._list = list.split(/\s*,\s*/); + } else { // Element or CSS selector + const children = helpers.getElement(list)?.children || []; + this._list = Array.from(children) + .filter(el => !el.disabled) + .map(el => { + const text = el.textContent.trim(); + const value = el.value || text; + const label = el.label || text; + return (value !== "") ? { label, value } : null; + }) + .filter(i => i); + } + + if (document.activeElement === this.input) { + this.evaluate(); + } + } + + get list () { + return this._list; + } + + get selected () { + return this.index > -1; + } + + get opened () { + return this.is_opened; + } + + close (o) { + if (!this.opened) { + return; + } + this.ul.setAttribute("hidden", ""); + this.is_opened = false; + this.index = -1; + this.trigger("suggestion-box-close", o || {}); + } + + insertValue (suggestion) { + if (this.match_current_word) { + u.replaceCurrentWord(this.input, suggestion.value); + } else { + this.input.value = suggestion.value; + } + } + + open () { + this.ul.removeAttribute("hidden"); + this.is_opened = true; + + if (this.auto_first && this.index === -1) { + this.goto(0); + } + this.trigger("suggestion-box-open"); + } + + destroy () { + //remove events from the input and its form + helpers.unbind(this.input, this._events.input); + helpers.unbind(this.input.form, this._events.form); + this.input.removeAttribute("aria-autocomplete"); + } + + next () { + const count = this.ul.children.length; + this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) ); + } + + previous () { + const count = this.ul.children.length, + pos = this.index - 1; + this.goto(this.selected && pos !== -1 ? pos : count - 1); + } + + goto (i, scroll=true) { + // Should not be used directly, highlights specific item without any checks! + const list = this.ul.children; + if (this.selected) { + list[this.index].setAttribute("aria-selected", "false"); + } + this.index = i; + + if (i > -1 && list.length > 0) { + list[i].setAttribute("aria-selected", "true"); + list[i].focus(); + this.status.textContent = list[i].textContent; + + if (scroll) { + // scroll to highlighted element in case parent's height is fixed + this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight; + } + this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]}); + } + } + + select (selected) { + if (selected) { + this.index = u.siblingIndex(selected); + } else { + selected = this.ul.children[this.index]; + } + if (selected) { + const suggestion = this.suggestions[this.index]; + this.insertValue(suggestion); + this.close({'reason': 'select'}); + this.auto_completing = false; + this.trigger("suggestion-box-selectcomplete", {'text': suggestion}); + } + } + + onMouseOver (ev) { + const li = u.ancestor(ev.target, 'li'); + if (li) { + const index = Array.prototype.slice.call(this.ul.children).indexOf(li); + this.goto(index, false); + } + } + + onMouseDown (ev) { + if (ev.button !== 0) { + return; // Only select on left click + } + const li = u.ancestor(ev.target, 'li'); + if (li) { + ev.preventDefault(); + this.select(li, ev.target); + } + } + + onKeyDown (ev) { + if (this.opened) { + if ([converse.keycodes.ENTER, converse.keycodes.TAB].includes(ev.keyCode) && this.selected) { + ev.preventDefault(); + ev.stopPropagation(); + this.select(); + return true; + } else if (ev.keyCode === converse.keycodes.ESCAPE) { + this.close({'reason': 'esc'}); + return true; + } else if ([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW].includes(ev.keyCode)) { + ev.preventDefault(); + ev.stopPropagation(); + this[ev.keyCode === converse.keycodes.UP_ARROW ? "previous" : "next"](); + return true; + } + } + + if ([converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT + ].includes(ev.keyCode)) { + + return; + } + + if (this.ac_triggers.includes(ev.key)) { + if (ev.key === "Tab") { + ev.preventDefault(); + } + this.auto_completing = true; + } else if (ev.key === "Backspace") { + const word = u.getCurrentWord(ev.target, ev.target.selectionEnd-1); + if (helpers.isMention(word, this.ac_triggers)) { + this.auto_completing = true; + } + } + } + + async evaluate (ev) { + const selecting = this.selected && ev && ( + ev.keyCode === converse.keycodes.UP_ARROW || + ev.keyCode === converse.keycodes.DOWN_ARROW + ); + + if (!this.auto_evaluate && !this.auto_completing || selecting) { + return; + } + + let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value; + + const contains_trigger = helpers.isMention(value, this.ac_triggers); + if (contains_trigger && !this.include_triggers.includes(ev.key)) { + value = u.isMentionBoundary(value[0]) + ? value.slice('2') + : value.slice('1'); + } + + const is_long_enough = value.length && value.length >= this.min_chars; + + if (contains_trigger || is_long_enough) { + this.auto_completing = true; + + const list = typeof this._list === "function" ? await this._list(value) : this._list; + if (list.length === 0 || !this.auto_completing) { + this.close({'reason': 'nomatches'}); + return; + } + + this.index = -1; + this.ul.innerHTML = ""; + + this.suggestions = list + .map(item => new Suggestion(this.data(item, value), value)) + .filter(item => this.filter(item, value)); + + if (this.sort !== false) { + this.suggestions = this.suggestions.sort(this.sort); + } + this.suggestions = this.suggestions.slice(0, this.max_items); + this.suggestions.forEach(text => this.ul.appendChild(this.item(text, value))); + + if (this.ul.children.length === 0) { + this.close({'reason': 'nomatches'}); + } else { + this.open(); + } + } else { + this.close({'reason': 'nomatches'}); + if (!contains_trigger) { + this.auto_completing = false; + } + } + } +} + +// Make it an event emitter +Object.assign(AutoComplete.prototype, Events); + +export default AutoComplete; diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/component.js b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/component.js new file mode 100644 index 0000000..ce9a4f4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/component.js @@ -0,0 +1,125 @@ +import AutoComplete from './autocomplete.js'; +import { CustomElement } from 'shared/components/element.js'; +import { FILTER_CONTAINS, FILTER_STARTSWITH } from './utils.js'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; + +/** + * A custom element that can be used to add auto-completion suggestions to a form input. + * @class AutoCompleteComponent + * + * @property { "above" | "below" } [position="above"] + * Should the autocomplete list show above or below the input element? + * @property { Boolean } [autofocus=false] + * Should the `focus` attribute be set on the input element? + * @property { Function } getAutoCompleteList + * A function that returns the list of autocomplete suggestions + * @property { Array } list + * An array of suggestions, to be used instead of the `getAutoCompleteList` * function + * @property { Boolean } [auto_evaluate=true] + * Should evaluation happen automatically without any particular key as trigger? + * @property { Boolean } [auto_first=false] + * Should the first element automatically be selected? + * @property { "contains" | "startswith" } [filter="contains"] + * Provide matches which contain the entered text, or which starts with the entered text + * @property { String } [include_triggers=""] + * Space separated characters which should be included in the returned value + * @property { Number } [min_chars=1] + * The minimum number of characters to be entered into the input before autocomplete starts. + * @property { String } [name] + * The `name` attribute of the `input` element + * @property { String } [placeholder] + * The `placeholder` attribute of the `input` element + * @property { String } [triggers] + * String of space separated characters which trigger autocomplete + * + * @example + * <converse-autocomplete + * .getAutoCompleteList="${getAutoCompleteList}" + * placeholder="${placeholder_text}" + * name="foo"> + * </converse-autocomplete> + */ +export default class AutoCompleteComponent extends CustomElement { + static get properties () { + return { + 'position': { type: String }, + 'autofocus': { type: Boolean }, + 'getAutoCompleteList': { type: Function }, + 'list': { type: Array }, + 'auto_evaluate': { type: Boolean }, + 'auto_first': { type: Boolean }, + 'filter': { type: String }, + 'include_triggers': { type: String }, + 'min_chars': { type: Number }, + 'name': { type: String }, + 'placeholder': { type: String }, + 'triggers': { type: String }, + 'required': { type: Boolean }, + }; + } + + constructor () { + super(); + this.position = 'above'; + this.auto_evaluate = true; + this.auto_first = false; + this.filter = 'contains'; + this.include_triggers = ''; + this.match_current_word = false; // Match only the current word, otherwise all input is matched + this.max_items = 10; + this.min_chars = 1; + this.triggers = ''; + } + + render () { + const position_class = `suggestion-box__results--${this.position}`; + return html` + <div class="suggestion-box suggestion-box__name"> + <ul class="suggestion-box__results ${position_class}" hidden=""></ul> + <input + ?autofocus=${this.autofocus} + ?required=${this.required} + type="text" + name="${this.name}" + autocomplete="off" + @keydown=${this.onKeyDown} + @keyup=${this.onKeyUp} + class="form-control suggestion-box__input" + placeholder="${this.placeholder}" + /> + <span + class="suggestion-box__additions visually-hidden" + role="status" + aria-live="assertive" + aria-relevant="additions" + ></span> + </div> + `; + } + + firstUpdated () { + this.auto_complete = new AutoComplete(this.firstElementChild, { + 'ac_triggers': this.triggers.split(' '), + 'auto_evaluate': this.auto_evaluate, + 'auto_first': this.auto_first, + 'filter': this.filter == 'contains' ? FILTER_CONTAINS : FILTER_STARTSWITH, + 'include_triggers': [], + 'list': this.list ?? ((q) => this.getAutoCompleteList(q)), + 'match_current_word': true, + 'max_items': this.max_items, + 'min_chars': this.min_chars, + }); + this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); + } + + onKeyDown (ev) { + this.auto_complete.onKeyDown(ev); + } + + onKeyUp (ev) { + this.auto_complete.evaluate(ev); + } +} + +api.elements.define('converse-autocomplete', AutoCompleteComponent); diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/index.js b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/index.js new file mode 100644 index 0000000..4a80fc0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/index.js @@ -0,0 +1,10 @@ +import './component.js'; +import AutoComplete from './autocomplete.js'; +import { FILTER_CONTAINS, FILTER_STARTSWITH } from './utils.js'; +import { _converse } from '@converse/headless/core'; + +import './styles/_autocomplete.scss'; + +_converse.FILTER_CONTAINS = FILTER_CONTAINS; +_converse.FILTER_STARTSWITH = FILTER_STARTSWITH; +_converse.AutoComplete = AutoComplete; diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/styles/_autocomplete.scss b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/styles/_autocomplete.scss new file mode 100644 index 0000000..78b330d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/styles/_autocomplete.scss @@ -0,0 +1,140 @@ +.conversejs { + [hidden] { display: none; } + + .visually-hidden { + position: absolute; + clip: rect(0, 0, 0, 0); + } + + .form-group { + .suggestion-box { + width: 100%; + } + } + + .suggestion-box { + position: relative; + mark { + background: var(--completion-light-color); + } + + > input { + display: block; + } + + .suggestion-box__results, + > ul { + &:before { + content: ""; + position: absolute; + top: -.43em; + left: 1em; + width: 0; height: 0; + padding: .4em; + background: var(--background); + border: inherit; + border-right: 0; + border-bottom: 0; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + z-index: -1; + } + border-radius: .3em; + border: 1px solid var(--focus-color); + box-shadow: .05em .2em .6em rgba(0,0,0,.1); + box-sizing: border-box; + left: 0; + list-style: none; + margin: .2em 0 0; + min-width: 100%; + padding: 0; + position: absolute; + right: 0; + text-shadow: none; + z-index: 2; + + > li { + background: var(--background); + color: var(--text-color); + cursor: pointer; + display: flex; + overflow-x: hidden; + padding: 1em; + position: relative; + text-overflow: ellipsis; + } + } + .suggestion-box__results--below { + top: 3em; + } + .suggestion-box__results--above { + bottom: 4.5em; + &:before { + display: none; + } + &:after { + z-index: -1; + content: ""; + position: absolute; + bottom: -0.43em; + left: 1em; + width: 0; + height: 0; + padding: 0.4em; + background: var(--background); + border: inherit; + border-left: 0; + border-top: 0; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } + } + } + + .suggestion-box > ul[hidden], + .suggestion-box > ul:empty { + display: none; + } + + @supports (transform: scale(0)) { + .suggestion-box > ul { + transition: .3s cubic-bezier(.4,.2,.5,1.4); + transform-origin: 1.43em -.43em; + } + + .suggestion-box > ul[hidden], + .suggestion-box > ul:empty { + opacity: 0; + transform: scale(0); + display: block; + transition-timing-function: ease; + } + } + + .suggestion-box > ul > li[aria-selected="true"] { + background: var(--completion-dark-color); + color: var(--inverse-link-color); + } + + .suggestion-box li:hover mark { + background: var(--completion-light-color); + color: var(--inverse-link-color); + } + + .suggestion-box li[aria-selected="true"] mark { + background: var(--completion-normal-color); + color: inherit; + } +} + +.conversejs.converse-fullscreen { + .suggestion-box__results--above { + bottom: 4.5em; + } +} + +.conversejs.converse-overlayed { + .suggestion-box__results--above { + bottom: 3.5em; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/suggestion.js b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/suggestion.js new file mode 100644 index 0000000..40bd132 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/suggestion.js @@ -0,0 +1,36 @@ +/** + * An autocomplete suggestion + */ +class Suggestion extends String { + /** + * @param { Any } data - The auto-complete data. Ideally an object e.g. { label, value }, + * which specifies the value and human-presentable label of the suggestion. + * @param { string } query - The query string being auto-completed + */ + constructor (data, query) { + super(); + const o = Array.isArray(data) + ? { label: data[0], value: data[1] } + : typeof data === 'object' && 'label' in data && 'value' in data + ? data + : { label: data, value: data }; + + this.label = o.label || o.value; + this.value = o.value; + this.query = query; + } + + get lenth () { + return this.label.length; + } + + toString () { + return '' + this.label; + } + + valueOf () { + return this.toString(); + } +} + +export default Suggestion; diff --git a/roles/reverseproxy/files/conversejs/src/shared/autocomplete/utils.js b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/utils.js new file mode 100644 index 0000000..af89268 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/shared/autocomplete/utils.js @@ -0,0 +1,89 @@ +import { converse } from '@converse/headless/core'; + +const u = converse.env.utils; + +export const helpers = { + getElement (expr, el) { + return typeof expr === 'string' ? (el || document).querySelector(expr) : expr || null; + }, + + bind (element, o) { + if (element) { + for (var event in o) { + if (!Object.prototype.hasOwnProperty.call(o, event)) { + continue; + } + const callback = o[event]; + event.split(/\s+/).forEach(event => element.addEventListener(event, callback)); + } + } + }, + + unbind (element, o) { + if (element) { + for (var event in o) { + if (!Object.prototype.hasOwnProperty.call(o, event)) { + continue; + } + const callback = o[event]; + event.split(/\s+/).forEach(event => element.removeEventListener(event, callback)); + } + } + }, + + regExpEscape (s) { + return s.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'); + }, + + isMention (word, ac_triggers) { + return ( + ac_triggers.includes(word[0]) || + (u.isMentionBoundary(word[0]) && ac_triggers.includes(word[1])) + ); + } +}; + +export const FILTER_CONTAINS = function (text, input) { + return RegExp(helpers.regExpEscape(input.trim()), 'i').test(text); +}; + +export const FILTER_STARTSWITH = function (text, input) { + return RegExp('^' + helpers.regExpEscape(input.trim()), 'i').test(text); +}; + +const SORT_BY_LENGTH = function (a, b) { + if (a.length !== b.length) { + return a.length - b.length; + } + return a < b ? -1 : 1; +}; + +export const SORT_BY_QUERY_POSITION = function (a, b) { + const query = a.query.toLowerCase(); + const x = a.label.toLowerCase().indexOf(query); + const y = b.label.toLowerCase().indexOf(query); + + if (x === y) { + return SORT_BY_LENGTH(a, b); + } + return (x === -1 ? Infinity : x) < (y === -1 ? Infinity : y) ? -1 : 1; +}; + +export const ITEM = (text, input) => { + input = input.trim(); + const element = document.createElement('li'); + element.setAttribute('aria-selected', 'false'); + + const regex = new RegExp('(' + input + ')', 'ig'); + const parts = input ? text.split(regex) : [text]; + parts.forEach(txt => { + if (input && txt.match(regex)) { + const match = document.createElement('mark'); + match.textContent = txt; + element.appendChild(match); + } else { + element.appendChild(document.createTextNode(txt)); + } + }); + return element; +}; |