@matdata/yasgui 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ .yasgui {
2
+ .autocomplete {
3
+ padding: 3px 6px;
4
+ margin: 4px 0px;
5
+ border: 2px solid #ccc;
6
+ width: 100%;
7
+ &:hover {
8
+ border-color: #bbb;
9
+ }
10
+ &:focus {
11
+ border-color: #337ab7;
12
+ background: none;
13
+ outline: none;
14
+ }
15
+ box-sizing: border-box;
16
+ transition: border-color ease-in 200ms;
17
+ }
18
+ .autocompleteWrapper {
19
+ width: 100%;
20
+ max-width: 700px;
21
+ margin-left: 10px;
22
+ position: relative;
23
+ }
24
+ .autocompleteList {
25
+ position: absolute;
26
+ max-height: 300px;
27
+ overflow-y: auto;
28
+ z-index: 6;
29
+ margin: 0;
30
+ margin-top: -4px;
31
+ padding: 0;
32
+ list-style: none;
33
+ background: white;
34
+ border: 1px solid #aaa;
35
+ box-sizing: border-box;
36
+ left: 0;
37
+ right: 0;
38
+ &:hover {
39
+ .autoComplete_result.autoComplete_selected:not(:hover) {
40
+ background: unset;
41
+ .removeItem {
42
+ visibility: hidden;
43
+ }
44
+ }
45
+ }
46
+ .autoComplete_result {
47
+ cursor: pointer;
48
+ padding: 5px 10px;
49
+ margin: 0;
50
+ overflow: hidden;
51
+ display: flex;
52
+ transition: background visibility ease-in 200ms;
53
+ b {
54
+ color: #1f49a3;
55
+ }
56
+ .autoComplete_highlighted {
57
+ font-weight: bold;
58
+ }
59
+ &.autoComplete_selected {
60
+ background: #ccc;
61
+ .removeItem {
62
+ visibility: visible;
63
+ }
64
+ }
65
+ &:hover {
66
+ background: #ccc;
67
+ .removeItem {
68
+ visibility: visible;
69
+ }
70
+ }
71
+ }
72
+ .noResults {
73
+ padding: 5px 10px;
74
+ margin: 0;
75
+ }
76
+ .removeItem {
77
+ color: #000;
78
+ font-size: 15px;
79
+ text-shadow: 0 1px 0 #fff;
80
+ opacity: 0.5;
81
+ font-weight: 700;
82
+ text-align: end;
83
+ margin-left: auto; // Make sure x always appears at the same place
84
+ visibility: hidden;
85
+ background: none;
86
+ border: none;
87
+ cursor: pointer;
88
+ margin-right: -10px;
89
+ padding-right: 20px;
90
+ &:hover {
91
+ opacity: 0.8;
92
+ color: #1f49a3;
93
+ }
94
+ }
95
+
96
+ &:empty {
97
+ display: none;
98
+ }
99
+ }
100
+
101
+ .clearEndpointBtn {
102
+ border: 1px solid #d1d1d1;
103
+ background-color: #d1d1d1;
104
+ color: #505050;
105
+ border-radius: 3px;
106
+ cursor: pointer;
107
+
108
+ padding: 4px 8px;
109
+ margin: 4px;
110
+
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+
115
+ opacity: 1;
116
+ transition: opacity ease-in 200ms;
117
+
118
+ &:hover {
119
+ opacity: 0.8;
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,339 @@
1
+ import Autocomplete from "@tarekraafat/autocomplete.js";
2
+ import { EventEmitter } from "events";
3
+ import { pick } from "lodash-es";
4
+ import { addClass } from "@matdata/yasgui-utils";
5
+ require("./endpointSelect.scss");
6
+ import parse from "autosuggest-highlight/parse";
7
+ import DOMPurify from "dompurify";
8
+
9
+ //Export this here instead of from our custom-types folder of autocomplete-js
10
+ //as this interface is exported via the yasgui config. The custom typings are
11
+ //not exported as part of the yasgui typings, so we'd get typing errors.
12
+ //instead, include this interface in the yasgui typings itself by defining it here
13
+ interface AutocompleteItem<T> {
14
+ index: number; //index of suggestion in array of suggestions
15
+ value: T; //suggestion object
16
+ key: keyof T; //key that matches search string. In our case, always 'all'
17
+ match: string; //suggestion value of the key above
18
+ }
19
+ export interface CatalogueItem {
20
+ endpoint: string;
21
+ type?: "history";
22
+ }
23
+
24
+ export interface RenderedCatalogueItem<T> {
25
+ matches: { [k in keyof T]?: ReturnType<typeof parse> } & { endpoint?: ReturnType<typeof parse> };
26
+ }
27
+
28
+ const { sanitize } = DOMPurify;
29
+
30
+ function listElementIsFullyVissible(el: HTMLLIElement) {
31
+ const { top, bottom } = el.getBoundingClientRect();
32
+ // Check if bottom of the element is off the page
33
+ if (bottom < 0) return false;
34
+ // Check its within the document viewport
35
+ if (top > document.documentElement.clientHeight) return false;
36
+
37
+ const ulRect = (el.parentNode as HTMLUListElement).getBoundingClientRect();
38
+ if (bottom <= ulRect.bottom === false) return false;
39
+ // Check if the element is out of view due to a container scrolling
40
+ if (top <= ulRect.top) return false;
41
+ return true;
42
+ }
43
+
44
+ function splitSearchString(searchString: string): string[] {
45
+ return searchString.match(/\S+/g) || [];
46
+ }
47
+
48
+ export interface EndpointSelectConfig<T = CatalogueItem> {
49
+ // Omit endpoint since it will be used as default, this makes sure elements don't appear twice in the list
50
+ keys: (keyof T)[];
51
+ getData: () => T[];
52
+ renderItem: (data: AutocompleteItem<T> & RenderedCatalogueItem<T>, source: HTMLElement) => void;
53
+ }
54
+ export interface EndpointSelect {
55
+ on(event: string | symbol, listener: (...args: any[]) => void): this;
56
+ on(event: "remove", listener: (endpoint: string, history: string[]) => void): this;
57
+ emit(event: "remove", endpoint: string, history: string[]): boolean;
58
+ on(event: "select", listener: (endpoint: string, history: string[]) => void): this;
59
+ emit(event: "select", endpoint: string, history: string[]): boolean;
60
+ }
61
+
62
+ export class EndpointSelect extends EventEmitter {
63
+ private container: HTMLDivElement;
64
+ private options: EndpointSelectConfig;
65
+ private value: string;
66
+ private history: CatalogueItem[];
67
+ private inputField!: HTMLInputElement;
68
+ constructor(initialValue: string, container: HTMLDivElement, options: EndpointSelectConfig, history: string[]) {
69
+ super();
70
+ this.container = container;
71
+ this.options = options;
72
+ this.value = initialValue;
73
+ this.history = history.map((endpoint) => {
74
+ return { endpoint: sanitize(endpoint), type: "history" };
75
+ });
76
+ // Add endpoint if not defined
77
+ if (this.options.keys.indexOf("endpoint") <= 0) this.options.keys.push("endpoint");
78
+ this.draw();
79
+ }
80
+ private draw() {
81
+ // Create container, we don't need to interact with it anymore
82
+ const autocompleteWrapper = document.createElement("div");
83
+ addClass(autocompleteWrapper, "autocompleteWrapper");
84
+ this.container.appendChild(autocompleteWrapper);
85
+
86
+ // Create field
87
+ this.inputField = document.createElement("input");
88
+ addClass(this.inputField, "autocomplete");
89
+ this.inputField.value = this.value;
90
+ autocompleteWrapper.appendChild(this.inputField);
91
+
92
+ // Create clear button
93
+ const clearBtn = document.createElement("button");
94
+ clearBtn.title = "Clear endpoint";
95
+ addClass(clearBtn, "clearEndpointBtn");
96
+ clearBtn.innerText = "✖";
97
+ clearBtn.addEventListener("click", () => {
98
+ this.inputField.value = "";
99
+ this.inputField.focus();
100
+ });
101
+ this.container.appendChild(clearBtn);
102
+
103
+ // Init autocomplete library
104
+ new Autocomplete<CatalogueItem>({
105
+ placeholder: "Search or add an endpoint",
106
+ highlight: false,
107
+ maxResults: 100,
108
+ trigger: {
109
+ event: ["input", "focusin"],
110
+ //we always want to show the autocomplete, even if no query is set
111
+ //in that case, we'd just show the full list
112
+ condition: () => true,
113
+ },
114
+ // threshold: -1,
115
+ searchEngine: (query, record) => {
116
+ if (!query || query.trim().length === 0) {
117
+ //show everything when we've got an empty search string
118
+ return true;
119
+ }
120
+ return splitSearchString(query).every((m) => record.indexOf(m) >= 0);
121
+ },
122
+ data: {
123
+ src: async () => {
124
+ return [...this.history, ...this.options.getData()].map((item) => ({
125
+ ...item,
126
+ all: Object.values(pick(item, ["endpoint", ...this.options.keys])).join(" "),
127
+ }));
128
+ },
129
+ key: ["all" as any], // All is something we add as a workaround for getting multiple results of the library
130
+ cache: false,
131
+ },
132
+ // Use a selector coming from the container, this is to make sure we grab our own autocomplete element
133
+ selector: () => this.inputField,
134
+ resultsList: {
135
+ render: true,
136
+ destination: this.inputField,
137
+ container: (element) => {
138
+ // Remove id, there can be multiple yasgui's active on one page, we can't delete since the library will then add the default
139
+ element.id = "";
140
+ addClass(element, "autocompleteList");
141
+ },
142
+ },
143
+ resultItem: {
144
+ content: (data, source) => {
145
+ const endpoint = sanitize(data.value.endpoint);
146
+
147
+ // Custom handling of items with history, these are able to be removed
148
+ if (data.value.type && data.value.type === "history") {
149
+ // Add a container to make folding work correctly
150
+ const resultsContainer = document.createElement("div");
151
+ // Match is highlighted text
152
+ resultsContainer.innerHTML = parse(endpoint, createHighlights(endpoint, this.inputField.value)).reduce(
153
+ (current, object) => (object.highlight ? current + object.text.bold() : current + object.text),
154
+ "",
155
+ );
156
+ source.append(resultsContainer);
157
+
158
+ // Remove button
159
+ const removeBtn = document.createElement("button");
160
+ removeBtn.textContent = "✖";
161
+ addClass(removeBtn, "removeItem");
162
+ removeBtn.addEventListener("mousedown", (event) => {
163
+ this.history = this.history.filter((item) => item.endpoint !== endpoint);
164
+ this.emit(
165
+ "remove",
166
+ this.value,
167
+ this.history.map((value) => value.endpoint),
168
+ );
169
+ source.remove();
170
+ event.stopPropagation();
171
+ });
172
+
173
+ source.appendChild(removeBtn);
174
+ } else {
175
+ // Add our own field highlighting
176
+ const matches: RenderedCatalogueItem<CatalogueItem> = { matches: {} };
177
+ for (const key of [...this.options.keys]) {
178
+ const val = data.value[key];
179
+ if (val) {
180
+ matches.matches[key] = parse(val, createHighlights(val, this.inputField.value));
181
+ }
182
+ }
183
+ this.options.renderItem({ ...data, ...matches }, source);
184
+ }
185
+ },
186
+ element: "li",
187
+ },
188
+ onSelection: (feedback) => {
189
+ const item = feedback.selection.value;
190
+ this.value = item.endpoint;
191
+ this.inputField.value = this.value;
192
+ this.emit(
193
+ "select",
194
+ this.value,
195
+ this.history.map((value) => value.endpoint),
196
+ );
197
+ },
198
+ noResults: () => {
199
+ const container = this.container.querySelector(".autocompleteList");
200
+ if (container) {
201
+ const noResults = document.createElement("div");
202
+ addClass(noResults, "noResults");
203
+ noResults.innerText = 'Press "enter" to add this endpoint';
204
+ container.appendChild(noResults);
205
+ }
206
+ },
207
+ });
208
+ // New data handler
209
+ this.inputField.addEventListener("keyup", (event) => {
210
+ const target = <HTMLInputElement>event.target;
211
+ // Enter
212
+ if (event.keyCode === 13) {
213
+ if (this.value === target.value) {
214
+ //we have typed exactly the same value the one from the suggestion list
215
+ //So, just close the suggestion list
216
+ this.clearListSuggestionList();
217
+ this.inputField.blur();
218
+ return;
219
+ }
220
+ if (!target.value || !target.value.trim()) {
221
+ this.clearListSuggestionList();
222
+ this.inputField.blur();
223
+ return;
224
+ }
225
+ if (
226
+ this.options.getData().find((i) => i.endpoint === this.inputField.value) ||
227
+ this.history.find((item) => item.endpoint === this.inputField.value)
228
+ ) {
229
+ //the value you typed is already in our catalogue or in our history
230
+ this.value = target.value;
231
+ this.clearListSuggestionList();
232
+ this.emit(
233
+ "select",
234
+ this.value,
235
+ this.history.map((h) => h.endpoint),
236
+ );
237
+ this.inputField.blur();
238
+ return;
239
+ }
240
+ this.value = target.value;
241
+ this.history.push({ endpoint: target.value, type: "history" });
242
+ this.emit(
243
+ "select",
244
+ this.value,
245
+ this.history.map((value) => value.endpoint),
246
+ );
247
+ this.clearListSuggestionList();
248
+ this.inputField.blur();
249
+ }
250
+ // Blur and set value on enter
251
+ if (event.keyCode === 27) {
252
+ this.inputField.blur();
253
+ this.inputField.value = this.value;
254
+ this.clearListSuggestionList();
255
+ }
256
+
257
+ // Stop moving caret around when hitting up and down keys
258
+ if (event.keyCode === 38 || event.keyCode === 40) {
259
+ event.stopPropagation();
260
+ const selected: HTMLLIElement | null = this.container.querySelector(
261
+ ".autocompleteList .autoComplete_result.autoComplete_selected",
262
+ );
263
+ if (selected && !listElementIsFullyVissible(selected)) {
264
+ selected.scrollIntoView(false);
265
+ }
266
+ }
267
+ });
268
+ this.inputField.addEventListener("blur", (event) => {
269
+ const target = <HTMLInputElement>event.target;
270
+ // Tabbing blur event
271
+ if (target.className === this.inputField.className && event.relatedTarget) {
272
+ this.clearListSuggestionList();
273
+ this.inputField.value = this.value;
274
+ }
275
+ });
276
+ // Improvised clickAway handler, Blur will fire before select or click events, causing interactive suggestion to no longer work
277
+ document.addEventListener("mousedown", (event) => {
278
+ if (event.button !== 2) {
279
+ const target = <HTMLElement>event.target;
280
+ if (
281
+ target.className === "removeItem" ||
282
+ target.className === "autoComplete_result" ||
283
+ target.className === "autocomplete"
284
+ )
285
+ return;
286
+ this.clearListSuggestionList();
287
+ this.inputField.value = this.value;
288
+ }
289
+ });
290
+ }
291
+ private clearListSuggestionList = () => {
292
+ const autocompleteList = this.container.querySelector(".autocompleteList");
293
+ if (autocompleteList) autocompleteList.innerHTML = "";
294
+ };
295
+
296
+ public setEndpoint(endpoint: string, endpointHistory?: string[]) {
297
+ this.value = endpoint;
298
+ if (endpointHistory) {
299
+ this.history = endpointHistory.map((endpoint) => {
300
+ return { endpoint, type: "history" };
301
+ });
302
+ }
303
+ // Force focus when the endpoint is open
304
+ if (this.inputField === document.activeElement) {
305
+ this.inputField.focus();
306
+ } else {
307
+ // Only set when the user is not using the widget at this time
308
+ this.inputField.value = endpoint;
309
+ }
310
+ }
311
+ public destroy() {
312
+ this.removeAllListeners();
313
+ this.inputField.remove();
314
+ }
315
+ }
316
+
317
+ function createHighlights(text: string, query: string) {
318
+ return splitSearchString(query)
319
+ .reduce((result: Array<[number, number]>, word: string) => {
320
+ if (!word.length) return result;
321
+ const wordLen = word.length;
322
+ // const regex = new RegExp(escapeRegexCharacters(word), 'i');
323
+ // const { index = -1 } = text.match(regex);
324
+ const index = text.indexOf(word);
325
+ if (index > -1) {
326
+ result.push([index, index + wordLen]);
327
+
328
+ // Replace what we just found with spaces so we don't find it again.
329
+ text = text.slice(0, index) + new Array(wordLen + 1).join(" ") + text.slice(index + wordLen);
330
+ }
331
+
332
+ return result;
333
+ }, [])
334
+ .sort((match1: [number, number], match2: [number, number]) => {
335
+ return match1[0] - match2[0];
336
+ });
337
+ }
338
+
339
+ export default EndpointSelect;
package/src/index.scss ADDED
@@ -0,0 +1,97 @@
1
+ .yasgui {
2
+ a {
3
+ color: #337ab7;
4
+ text-decoration: none;
5
+ }
6
+
7
+ // Keep tabs visible when yasqe is in fullscreen
8
+ &:has(.yasqe.fullscreen) .tabsList {
9
+ position: fixed;
10
+ top: 0;
11
+ left: 0;
12
+ right: 0;
13
+ z-index: 9999;
14
+ background: white;
15
+ border-bottom: 1px solid #ddd;
16
+ padding: 5px;
17
+ margin: 0;
18
+
19
+ .tab {
20
+ span {
21
+ display: inline !important;
22
+ }
23
+
24
+ a {
25
+ display: flex !important;
26
+ align-items: center !important;
27
+ }
28
+ }
29
+ }
30
+
31
+ //css taken from https://www.muicss.com/docs/v1/css-js/forms
32
+ $focusColor: #337ab7;
33
+ $font-size: 15px;
34
+ $font-color: rgba(0, 0, 0, 0.87);
35
+ $border-color: rgba(0, 0, 0, 0.26);
36
+ $label-font-size: 12px;
37
+ $label-font-color: rgba(0, 0, 0, 0.54);
38
+ $label-line-height: 15px;
39
+ .yasgui_textfield {
40
+ display: block;
41
+ padding-top: $font-size * 1.25;
42
+ // margin-bottom: $mui-form-group-margin-bottom;
43
+ position: relative;
44
+
45
+ > label {
46
+ // Positioning
47
+ position: absolute;
48
+ top: 0;
49
+
50
+ // Display
51
+ display: block;
52
+ width: 100%;
53
+
54
+ // Other
55
+ color: $label-font-color;
56
+ font-size: $label-font-size;
57
+ font-weight: 400;
58
+ line-height: $label-line-height;
59
+ overflow-x: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ }
63
+
64
+ & > input,
65
+ & > textarea {
66
+ box-sizing: border-box;
67
+ display: block;
68
+ color: $font-color;
69
+ border: none;
70
+ border-bottom: 1px solid $border-color;
71
+ outline: none;
72
+ width: 100%;
73
+ padding: 0;
74
+ box-shadow: none;
75
+ border-radius: 0px;
76
+
77
+ // Typography
78
+ font-size: $font-size;
79
+ font-family: inherit;
80
+ line-height: inherit;
81
+
82
+ // Bugfix for firefox-android
83
+ background-image: none;
84
+
85
+ &:focus {
86
+ border-color: $focusColor;
87
+ border-width: 2px;
88
+ }
89
+ }
90
+ > input,
91
+ > textarea {
92
+ &:focus ~ label {
93
+ color: $focusColor;
94
+ }
95
+ }
96
+ }
97
+ }