@miso.ai/client-sdk-ui 1.9.10 → 1.10.0-beta.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.
- package/dist/cjs/actor/autocomplete.js +157 -0
- package/dist/cjs/actor/data.js +3 -11
- package/dist/cjs/actor/fields.js +8 -0
- package/dist/cjs/actor/index.js +7 -0
- package/dist/cjs/actor/utils.js +10 -0
- package/dist/cjs/defaults/ask/templates.js +21 -0
- package/dist/cjs/layout/input/search-box.js +213 -127
- package/dist/cjs/layout/list/collection.js +6 -1
- package/dist/cjs/layout/templates.js +50 -35
- package/dist/cjs/layout/text/typewriter/index.js +8 -53
- package/dist/cjs/source/index.js +20 -8
- package/dist/cjs/styles.js +4 -83
- package/dist/cjs/version.js +1 -1
- package/dist/cjs/workflow/ask.js +48 -16
- package/dist/cjs/workflow/base.js +1 -2
- package/dist/cjs/workflow/explore.js +4 -1
- package/dist/cjs/workflow/options.js +54 -13
- package/dist/cjs/workflow/search.js +35 -7
- package/package.json +2 -2
- package/src/actor/autocomplete.js +131 -0
- package/src/actor/data.js +3 -10
- package/src/actor/fields.js +8 -0
- package/src/actor/index.js +1 -0
- package/src/actor/utils.js +8 -0
- package/src/defaults/ask/templates.js +21 -0
- package/src/layout/input/search-box.js +186 -94
- package/src/layout/list/collection.js +6 -1
- package/src/layout/templates.js +31 -25
- package/src/layout/text/typewriter/index.js +6 -46
- package/src/source/index.js +14 -1
- package/src/styles.js +4 -86
- package/src/version.js +1 -1
- package/src/workflow/ask.js +31 -1
- package/src/workflow/base.js +1 -2
- package/src/workflow/explore.js +1 -1
- package/src/workflow/options.js +49 -12
- package/src/workflow/search.js +33 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { STATUS } from '../constants.js';
|
|
2
|
+
import * as fields from './fields.js';
|
|
3
|
+
|
|
4
|
+
export default class AutocompleteActor {
|
|
5
|
+
|
|
6
|
+
constructor(hub, { source, options }) {
|
|
7
|
+
this._hub = hub;
|
|
8
|
+
this._source = source;
|
|
9
|
+
this._options = options;
|
|
10
|
+
this._index = -1;
|
|
11
|
+
|
|
12
|
+
this._unsubscribes = [
|
|
13
|
+
hub.on(fields.input(), () => this._sync()),
|
|
14
|
+
options.on('autocomplete', () => this._syncOptions()),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// sync right away
|
|
18
|
+
this._sync(true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get active() {
|
|
22
|
+
return this._options.resolved.autocomplete.actor !== false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get source() {
|
|
26
|
+
return this._source;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_syncOptions() {
|
|
30
|
+
// TODO: explore scenarios where options.actor true -> false
|
|
31
|
+
this._sync(true);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async _sync(force = false) {
|
|
35
|
+
// protocol: inactive -> no reaction
|
|
36
|
+
if (!this.active) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { input } = this._hub.states;
|
|
41
|
+
const value = input && input.value && input.value.trim() || undefined;
|
|
42
|
+
|
|
43
|
+
if (sameInput(this._input, input) && !force) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this._input = input;
|
|
47
|
+
const index = ++this._index;
|
|
48
|
+
|
|
49
|
+
// new input, abort previous data fetch if any
|
|
50
|
+
this._clearTimeout();
|
|
51
|
+
this._abortPendingRequest({
|
|
52
|
+
type: 'new-input',
|
|
53
|
+
message: 'A new input renders previous autocomplete request obsolete.',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// emit loading/initial state
|
|
57
|
+
this._emitCompletions({ index, input, status: value ? STATUS.LOADING : STATUS.INITIAL });
|
|
58
|
+
|
|
59
|
+
if (!value) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await this._fetchCompletions(input);
|
|
65
|
+
if (this._index !== index) {
|
|
66
|
+
return; // new results loading, discard this one
|
|
67
|
+
}
|
|
68
|
+
this._emitCompletions({ index, input, status: STATUS.READY, value: response.completions });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this._error(error);
|
|
71
|
+
if (this._index !== index) {
|
|
72
|
+
return; // ignore
|
|
73
|
+
}
|
|
74
|
+
this._emitCompletions({ index, input, status: STATUS.ERRONEOUS, error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async _getCompletions(input) {
|
|
79
|
+
// TODO: cache
|
|
80
|
+
return await this._fetchCompletions(input);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async _fetchCompletions({ value }) {
|
|
84
|
+
// TODO: other options?
|
|
85
|
+
const { api } = this._options.resolved.autocomplete;
|
|
86
|
+
const { group, name } = api;
|
|
87
|
+
const { signal } = this._ac || {};
|
|
88
|
+
const options = { ...api.options, signal };
|
|
89
|
+
const payload = { ...api.payload, q: value };
|
|
90
|
+
return await this._source({ group, name, payload, options });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_clearTimeout() {
|
|
94
|
+
if (this._timeoutId) {
|
|
95
|
+
clearTimeout(this._timeoutId);
|
|
96
|
+
this._timeoutId = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_abortPendingRequest(info) {
|
|
101
|
+
this._ac && this._ac.abort(info);
|
|
102
|
+
this._ac = new AbortController();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_emitCompletions(event) {
|
|
106
|
+
event.source = 'actor';
|
|
107
|
+
this._hub.update(fields.completions(), event);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_error(error) {
|
|
111
|
+
// TODO
|
|
112
|
+
console.error(error);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_destroy() {
|
|
116
|
+
// abort ongoing data fetch if any
|
|
117
|
+
this._ac && this._ac.abort({
|
|
118
|
+
type: 'autocomplete-actor-destroy',
|
|
119
|
+
message: 'Autocomplete actor is destroyed.',
|
|
120
|
+
});
|
|
121
|
+
for (const unsubscribe of this._unsubscribes) {
|
|
122
|
+
unsubscribe();
|
|
123
|
+
}
|
|
124
|
+
this._unsubscribes = [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sameInput(a, b) {
|
|
130
|
+
return (!a && !b) || (a && b && a.value === b.value);
|
|
131
|
+
}
|
package/src/actor/data.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fields from './fields.js';
|
|
2
|
+
import { isCurrentSession } from './utils.js';
|
|
2
3
|
|
|
3
4
|
export default class DataActor {
|
|
4
5
|
|
|
@@ -61,7 +62,7 @@ export default class DataActor {
|
|
|
61
62
|
let value;
|
|
62
63
|
for await (value of response) {
|
|
63
64
|
// A new session invalidates ongoing data fetch for the old session, terminating the loop
|
|
64
|
-
if (!this.
|
|
65
|
+
if (!isCurrentSession(this._hub, session)) {
|
|
65
66
|
break;
|
|
66
67
|
}
|
|
67
68
|
this._emitResponse({ session, request, value });
|
|
@@ -77,15 +78,7 @@ export default class DataActor {
|
|
|
77
78
|
|
|
78
79
|
_emitResponseWithSessionCheck(response) {
|
|
79
80
|
// A new session invalidates ongoing data fetch
|
|
80
|
-
this.
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
_isCurrentSession(session) {
|
|
84
|
-
if (!session) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
const { session: currentSession } = this._hub.states;
|
|
88
|
-
return currentSession && currentSession.index === session.index;
|
|
81
|
+
isCurrentSession(this._hub, response.session) && this._emitResponse(response);
|
|
89
82
|
}
|
|
90
83
|
|
|
91
84
|
_emitResponse(response) {
|
package/src/actor/fields.js
CHANGED
package/src/actor/index.js
CHANGED
|
@@ -7,4 +7,5 @@ export { default as DataActor } from './data.js';
|
|
|
7
7
|
export { default as ViewsActor } from './views.js';
|
|
8
8
|
export { default as Trackers } from './trackers.js';
|
|
9
9
|
export { default as FeedbackActor } from './feedback.js';
|
|
10
|
+
export { default as AutocompleteActor } from './autocomplete.js';
|
|
10
11
|
export { default as InteractionsActor } from './interactions.js';
|
package/src/actor/utils.js
CHANGED
|
@@ -40,3 +40,11 @@ export function toInteraction({ property, misoId, request }, { event, values, ma
|
|
|
40
40
|
});
|
|
41
41
|
return payload;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
export function isCurrentSession(hub, session) {
|
|
45
|
+
if (!session) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const { session: currentSession } = hub.states;
|
|
49
|
+
return currentSession && currentSession.index === session.index;
|
|
50
|
+
}
|
|
@@ -26,6 +26,27 @@ export function followUp(options = {}) {
|
|
|
26
26
|
]);
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
export const sections = Object.freeze({
|
|
30
|
+
followUps,
|
|
31
|
+
relatedResources,
|
|
32
|
+
querySuggestions,
|
|
33
|
+
answerGroup,
|
|
34
|
+
answer,
|
|
35
|
+
sources,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const helpers = Object.freeze({
|
|
39
|
+
normalizeOptions,
|
|
40
|
+
normalizePhraseOptions,
|
|
41
|
+
normalizePhraseOptionKey,
|
|
42
|
+
phrase,
|
|
43
|
+
phraseText,
|
|
44
|
+
section,
|
|
45
|
+
container,
|
|
46
|
+
element,
|
|
47
|
+
attrs,
|
|
48
|
+
});
|
|
49
|
+
|
|
29
50
|
|
|
30
51
|
|
|
31
52
|
// options //
|
|
@@ -1,53 +1,104 @@
|
|
|
1
|
-
import { escapeHtml } from '@miso.ai/commons';
|
|
2
|
-
import { LAYOUT_CATEGORY } from '../../constants.js';
|
|
1
|
+
import { defineValues, escapeHtml, findInAncestors, debounce } from '@miso.ai/commons';
|
|
2
|
+
import { LAYOUT_CATEGORY, STATUS } from '../../constants.js';
|
|
3
3
|
import { fields } from '../../actor/index.js';
|
|
4
4
|
import TemplateBasedLayout from '../template.js';
|
|
5
|
+
import { helpers, imageBlock, productInfoBlock } from '../templates.js';
|
|
5
6
|
import { SEND, SEARCH } from '../../asset/svgs.js';
|
|
6
7
|
|
|
7
8
|
const TYPE = 'search-box';
|
|
8
9
|
const DEFAULT_CLASSNAME = 'miso-search-box';
|
|
10
|
+
const DEFAULT_AUTOCOMPLETE_CLASSNAME = 'miso-autocomplete';
|
|
9
11
|
|
|
10
12
|
function root(layout) {
|
|
11
|
-
const { className, role,
|
|
12
|
-
const {
|
|
13
|
+
const { className, role, options, workflow, autocomplete } = layout;
|
|
14
|
+
const { placeholder, buttonText } = options;
|
|
13
15
|
const roleAttr = role ? `data-role="${role}"` : '';
|
|
14
16
|
const buttonContent = buttonText || workflow === 'search' ? SEARCH : SEND;
|
|
15
17
|
return `
|
|
16
18
|
<div class="${className}" ${roleAttr}>
|
|
17
19
|
<div class="${className}__input-group">
|
|
18
20
|
<input class="${className}__input" type="text" data-role="input" ${placeholder ? `placeholder="${placeholder}"` : ''}>
|
|
19
|
-
<button class="${className}__button" type="submit" data-role="
|
|
21
|
+
<button class="${className}__button" type="submit" data-role="submit">${buttonContent}</button>
|
|
20
22
|
</div>
|
|
21
|
-
${autocomplete
|
|
22
|
-
</div
|
|
23
|
-
`;
|
|
23
|
+
<div class="${autocomplete.className}" data-role="autocomplete"></div>
|
|
24
|
+
</div>`.trim();
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function
|
|
27
|
-
const {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
function completions(layout, items) {
|
|
28
|
+
const { templates, workflow, autocomplete } = layout;
|
|
29
|
+
// use options under layout.autocomplete from now on
|
|
30
|
+
layout = {
|
|
31
|
+
templates,
|
|
32
|
+
workflow,
|
|
33
|
+
...autocomplete,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const products = [], queries = [];
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
(item.product ? products : queries).push(item);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return templates.queries(layout, queries) + (queries.length > 0 && products.length > 0 ? `<hr>` : '') + templates.products(layout, products);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function queries(layout, items) {
|
|
45
|
+
const { templates } = layout;
|
|
46
|
+
return templates.completionList(layout, 'query', items);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function products(layout, items) {
|
|
50
|
+
const { templates } = layout;
|
|
51
|
+
return templates.completionList(layout, 'product', items);
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
return `<ol class="${className}__suggestion-list" data-role="suggestion-list"></ol>`;
|
|
54
|
+
function query(layout, { text, text_with_inverted_markups } = {}) {
|
|
55
|
+
return text_with_inverted_markups || escapeHtml(text);
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
function
|
|
41
|
-
const {
|
|
42
|
-
const
|
|
43
|
-
return
|
|
58
|
+
function product(layout, { product }) {
|
|
59
|
+
const { templates } = layout;
|
|
60
|
+
const [openTag, closeTag] = helpers.tagPair(layout, product, { role: false });
|
|
61
|
+
return [
|
|
62
|
+
openTag,
|
|
63
|
+
templates.imageBlock(layout, product),
|
|
64
|
+
templates.productInfoBlock(layout, product),
|
|
65
|
+
closeTag,
|
|
66
|
+
].join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function completionList(layout, type, items) {
|
|
70
|
+
const { className, templates } = layout;
|
|
71
|
+
return `
|
|
72
|
+
<ul class="${className}__${type}-list" data-role="${type}-list">
|
|
73
|
+
${items.map(item => templates.completionItem(layout, type, item)).join('')}
|
|
74
|
+
</ul>`.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function completionItem(layout, type, item) {
|
|
78
|
+
const { className, templates } = layout;
|
|
79
|
+
return `
|
|
80
|
+
<li class="${className}__${type}-item" data-role="${type}-item" data-index="${item._index}" tabindex="0">
|
|
81
|
+
${templates[type](layout, item)}
|
|
82
|
+
</li>`.trim();
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
const DEFAULT_TEMPLATES = Object.freeze({
|
|
47
86
|
root,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
87
|
+
completions,
|
|
88
|
+
completionList,
|
|
89
|
+
completionItem,
|
|
90
|
+
queries,
|
|
91
|
+
products,
|
|
92
|
+
query,
|
|
93
|
+
product,
|
|
94
|
+
imageBlock,
|
|
95
|
+
productInfoBlock,
|
|
96
|
+
helpers,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const DEFAULT_AUTOCOMPLETE_OPTIONS = Object.freeze({
|
|
100
|
+
className: DEFAULT_AUTOCOMPLETE_CLASSNAME,
|
|
101
|
+
debounce: 300,
|
|
51
102
|
});
|
|
52
103
|
|
|
53
104
|
export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
@@ -68,31 +119,36 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
68
119
|
return DEFAULT_CLASSNAME;
|
|
69
120
|
}
|
|
70
121
|
|
|
71
|
-
constructor({ className = DEFAULT_CLASSNAME, templates, ...options } = {}) {
|
|
122
|
+
constructor({ className = DEFAULT_CLASSNAME, templates, autocomplete, ...options } = {}) {
|
|
72
123
|
super({
|
|
73
124
|
className,
|
|
74
125
|
templates: { ...DEFAULT_TEMPLATES, ...templates },
|
|
75
126
|
...options,
|
|
76
127
|
});
|
|
128
|
+
defineValues(this, {
|
|
129
|
+
autocomplete: { ...DEFAULT_AUTOCOMPLETE_OPTIONS, ...autocomplete },
|
|
130
|
+
});
|
|
77
131
|
this._contexts = new WeakMap();
|
|
78
|
-
this.
|
|
132
|
+
this._prevInput = undefined;
|
|
133
|
+
this._debounce = debounce(this.autocomplete.debounce);
|
|
79
134
|
}
|
|
80
135
|
|
|
81
136
|
initialize(view) {
|
|
82
|
-
const { autocomplete } = this.options;
|
|
83
137
|
this._view = view;
|
|
84
138
|
const { proxyElement, hub } = view;
|
|
85
139
|
this._unsubscribes = [
|
|
86
140
|
...this._unsubscribes,
|
|
141
|
+
proxyElement.on('input', (e) => this._syncInput(e)),
|
|
87
142
|
proxyElement.on('keydown', (e) => this._handleKeyDown(e)),
|
|
88
143
|
proxyElement.on('click', (e) => this._handleClick(e)),
|
|
144
|
+
proxyElement.on('focusin', (e) => this._handleFocusIn(e)),
|
|
145
|
+
proxyElement.on('focusout', (e) => this._handleFocusOut(e)),
|
|
89
146
|
];
|
|
90
|
-
|
|
91
|
-
this._unsubscribes.push(hub.on(fields.suggestions(), () => this._handleInput()));
|
|
92
|
-
}
|
|
147
|
+
this._unsubscribes.push(hub.on(fields.completions(), () => view.refresh({ force: true })));
|
|
93
148
|
}
|
|
94
149
|
|
|
95
150
|
focus() {
|
|
151
|
+
// TODO: support blur as well
|
|
96
152
|
if (this._element) {
|
|
97
153
|
this._context().focus();
|
|
98
154
|
} else {
|
|
@@ -101,6 +157,14 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
101
157
|
}
|
|
102
158
|
}
|
|
103
159
|
|
|
160
|
+
open() {
|
|
161
|
+
this._element.classList.add('open');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
close() {
|
|
165
|
+
this._element.classList.remove('open');
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
set value(value) {
|
|
105
169
|
if (this._element) {
|
|
106
170
|
this._context().value = value;
|
|
@@ -111,12 +175,10 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
111
175
|
|
|
112
176
|
_preprocess({ state }) {
|
|
113
177
|
state = super._preprocess({ state });
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
const suggestions = this._view.hub.states[fields.suggestions()];
|
|
119
|
-
return { ...state, suggestions };
|
|
178
|
+
return {
|
|
179
|
+
...state,
|
|
180
|
+
completions: this._view.hub.states[fields.completions()],
|
|
181
|
+
};
|
|
120
182
|
}
|
|
121
183
|
|
|
122
184
|
_render(element, data, controls) {
|
|
@@ -124,42 +186,32 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
124
186
|
// only render the template once, and also skip if there are children already to allow customized DOM
|
|
125
187
|
super._render(element, data, controls);
|
|
126
188
|
}
|
|
127
|
-
this.
|
|
128
|
-
this._renderSuggestions(element, data, controls);
|
|
189
|
+
this._updateCompletions(element, data, controls);
|
|
129
190
|
this._fullfillFocusRequest(element);
|
|
130
191
|
this._fullfillValueRequest(element);
|
|
131
192
|
}
|
|
132
193
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!autocomplete) {
|
|
194
|
+
_updateCompletions(element, { state, rendered }, { writeToState }) {
|
|
195
|
+
if (!state.completions) {
|
|
136
196
|
return;
|
|
137
197
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
_renderSuggestions(element, { state }, { writeToState } = {}) {
|
|
142
|
-
const { autocomplete } = this.options;
|
|
143
|
-
if (!autocomplete) {
|
|
198
|
+
const { status = STATUS.INITIAL, index, value: completions } = state.completions;
|
|
199
|
+
if (rendered && rendered.completions.index === index && rendered.completions.status === status) {
|
|
144
200
|
return;
|
|
145
201
|
}
|
|
146
|
-
|
|
147
|
-
const {
|
|
148
|
-
if (!
|
|
149
|
-
// not actually rendered
|
|
150
|
-
writeToState && writeToState({ suggestions: { value: [] } });
|
|
202
|
+
const context = this._context(element);
|
|
203
|
+
const { autocompleteElement } = context;
|
|
204
|
+
if (!autocompleteElement) {
|
|
151
205
|
return;
|
|
152
206
|
}
|
|
153
207
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
autocompleteElement.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
this._suggestionItems.set(itemElement, suggestionItems[i++]);
|
|
162
|
-
}
|
|
208
|
+
const items = context.trackItems(completions);
|
|
209
|
+
|
|
210
|
+
element.classList[items.length > 0 ? 'add' : 'remove']('nonempty');
|
|
211
|
+
autocompleteElement.setAttribute('data-status', status);
|
|
212
|
+
autocompleteElement.innerHTML = this.templates.completions(this, items);
|
|
213
|
+
|
|
214
|
+
writeToState({ completions: { index, status } });
|
|
163
215
|
}
|
|
164
216
|
|
|
165
217
|
_fullfillFocusRequest(element) {
|
|
@@ -190,39 +242,75 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
190
242
|
return context;
|
|
191
243
|
}
|
|
192
244
|
|
|
245
|
+
_syncInput() {
|
|
246
|
+
const { inputElement } = this._context();
|
|
247
|
+
if (!inputElement) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const value = inputElement.value.trim();
|
|
251
|
+
|
|
252
|
+
if (this._prevInput === value) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this._prevInput = value;
|
|
256
|
+
|
|
257
|
+
const self = this;
|
|
258
|
+
this._debounce(() => self._view.hub.update(fields.input(), { value }));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_handleFocusIn({ target }) {
|
|
262
|
+
if (!this._element) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (target.matches('[data-role="input"]')) {
|
|
266
|
+
this._syncInput();
|
|
267
|
+
this.open();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_handleFocusOut({ relatedTarget }) {
|
|
272
|
+
if (!this._element) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!relatedTarget || !findInAncestors(relatedTarget, element => element === this._element || undefined)) {
|
|
276
|
+
this.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
193
280
|
_handleKeyDown({ key, target, isComposing }) {
|
|
194
281
|
if (!isComposing && key === 'Enter' && target.matches('input')) {
|
|
195
282
|
this._submit(target.value);
|
|
196
283
|
target.blur();
|
|
197
284
|
}
|
|
285
|
+
// TODO: select completion items with arrow keys
|
|
198
286
|
}
|
|
199
287
|
|
|
200
288
|
_handleClick({ target }) {
|
|
201
289
|
const { inputElement } = this._context();
|
|
202
|
-
for (let element = target; element && element !== this.
|
|
203
|
-
if (element.matches('[
|
|
290
|
+
for (let element = target; element && element !== this._element; element = element.parentElement) {
|
|
291
|
+
if (element.matches('[data-role="submit"]') || element.matches('[data-role="button"]') || element.matches('[type="submit"]')) {
|
|
204
292
|
if (inputElement) {
|
|
205
293
|
inputElement.blur();
|
|
206
294
|
this._submit(inputElement.value);
|
|
207
295
|
}
|
|
208
296
|
return;
|
|
209
297
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
298
|
+
if (element.matches('[data-role="query-item"]')) {
|
|
299
|
+
const index = element.getAttribute('data-index');
|
|
300
|
+
const item = this._context().data.get(parseInt(index));
|
|
301
|
+
const value = item && item.text;
|
|
302
|
+
if (value) {
|
|
303
|
+
if (inputElement) {
|
|
304
|
+
inputElement.value = value;
|
|
305
|
+
}
|
|
306
|
+
this._submit(value);
|
|
307
|
+
}
|
|
308
|
+
this.close();
|
|
309
|
+
return;
|
|
219
310
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
inputElement.value = value;
|
|
311
|
+
if (element.matches('[data-role="product-item"]')) {
|
|
312
|
+
return;
|
|
223
313
|
}
|
|
224
|
-
this._submit(value);
|
|
225
|
-
return;
|
|
226
314
|
}
|
|
227
315
|
}
|
|
228
316
|
|
|
@@ -230,6 +318,7 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
|
|
|
230
318
|
if (!value) {
|
|
231
319
|
return;
|
|
232
320
|
}
|
|
321
|
+
this.close();
|
|
233
322
|
// TODO: q -> value
|
|
234
323
|
this._view.hub.trigger(fields.query(), { q: value });
|
|
235
324
|
}
|
|
@@ -245,6 +334,7 @@ class Context {
|
|
|
245
334
|
constructor(element) {
|
|
246
335
|
this._element = element;
|
|
247
336
|
this._cache = {};
|
|
337
|
+
this._data = new Map();
|
|
248
338
|
}
|
|
249
339
|
|
|
250
340
|
get inputElement() {
|
|
@@ -255,25 +345,27 @@ class Context {
|
|
|
255
345
|
return this._get('autocomplete');
|
|
256
346
|
}
|
|
257
347
|
|
|
258
|
-
get
|
|
259
|
-
return this.
|
|
348
|
+
get data() {
|
|
349
|
+
return this._data;
|
|
260
350
|
}
|
|
261
351
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
352
|
+
trackItems(completions) {
|
|
353
|
+
let index = 0;
|
|
354
|
+
const data = this._data = new Map();
|
|
355
|
+
const items = [];
|
|
356
|
+
for (const field in completions) {
|
|
357
|
+
for (const item of completions[field]) {
|
|
358
|
+
if (item.product && !item.product.url) {
|
|
359
|
+
continue; // product without URL is not clickable
|
|
360
|
+
}
|
|
361
|
+
item._index = index;
|
|
362
|
+
item._field = field;
|
|
363
|
+
data.set(index, item);
|
|
364
|
+
items.push(item);
|
|
365
|
+
index++;
|
|
366
|
+
}
|
|
276
367
|
}
|
|
368
|
+
return items;
|
|
277
369
|
}
|
|
278
370
|
|
|
279
371
|
focus() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineValues } from '@miso.ai/commons';
|
|
2
2
|
import { STATUS, LAYOUT_CATEGORY } from '../../constants.js';
|
|
3
3
|
import TemplateBasedLayout from '../template.js';
|
|
4
|
-
import { product, article, question } from '../templates.js';
|
|
4
|
+
import { product, article, question, productInfoBlock, articleInfoBlock, imageBlock, indexBlock, helpers } from '../templates.js';
|
|
5
5
|
|
|
6
6
|
const VALUE = Symbol.for('miso.value');
|
|
7
7
|
|
|
@@ -57,6 +57,11 @@ const DEFAULT_TEMPLATES = Object.freeze({
|
|
|
57
57
|
ordered: false,
|
|
58
58
|
items,
|
|
59
59
|
item,
|
|
60
|
+
productInfoBlock,
|
|
61
|
+
articleInfoBlock,
|
|
62
|
+
imageBlock,
|
|
63
|
+
indexBlock,
|
|
64
|
+
helpers,
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
export default class CollectionLayout extends TemplateBasedLayout {
|