@descope-ui/descope-autocomplete-field 0.0.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/CHANGELOG.md +14 -0
- package/e2e/descope-autocomplete.spec.ts +508 -0
- package/package.json +34 -0
- package/project.json +17 -0
- package/src/component/AutocompleteFieldClass.js +175 -0
- package/src/component/descope-autocomplete-field-internal/AutocompleteFieldInternal.js +229 -0
- package/src/component/descope-autocomplete-field-internal/index.js +5 -0
- package/src/component/index.js +12 -0
- package/src/theme.js +34 -0
- package/stories/descope-autocomplete-field.stories.js +151 -0
- package/testDriver/autocompleteTestDriver.ts +49 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
|
|
4
|
+
|
|
5
|
+
## 0.0.1 (2025-02-04)
|
|
6
|
+
|
|
7
|
+
### Dependency Updates
|
|
8
|
+
|
|
9
|
+
* `e2e-utils` updated to version `0.0.1`
|
|
10
|
+
* `test-drivers` updated to version `0.0.1`
|
|
11
|
+
* `@descope-ui/theme-globals` updated to version `0.0.3`
|
|
12
|
+
* `@descope-ui/theme-input-wrapper` updated to version `0.0.3`
|
|
13
|
+
* `@descope-ui/descope-combo-box` updated to version `0.0.3`
|
|
14
|
+
* `@descope-ui/common` updated to version `0.0.3`
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test';
|
|
2
|
+
import { getStoryUrl, loopConfig, loopPresets } from 'e2e-utils';
|
|
3
|
+
import createAutocompleteTestDriver from '../testDriver/autocompleteTestDriver';
|
|
4
|
+
|
|
5
|
+
const storyName = 'descope-autocomplete-field';
|
|
6
|
+
const componentName = 'descope-autocomplete-field';
|
|
7
|
+
|
|
8
|
+
const BooleanValues = ['true', 'false'];
|
|
9
|
+
|
|
10
|
+
const componentAttributes = {
|
|
11
|
+
label: 'autocomplete label',
|
|
12
|
+
placeholder: 'Test placeholder',
|
|
13
|
+
size: ['xs', 'sm', 'md', 'lg'],
|
|
14
|
+
bordered: BooleanValues,
|
|
15
|
+
readonly: BooleanValues,
|
|
16
|
+
required: BooleanValues,
|
|
17
|
+
'full-width': BooleanValues,
|
|
18
|
+
disabled: 'true',
|
|
19
|
+
'default-value': 'test value',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const presets = {
|
|
23
|
+
'with label and required': {
|
|
24
|
+
label: '-Label',
|
|
25
|
+
placeholder: '-Placeholder',
|
|
26
|
+
required: 'true',
|
|
27
|
+
},
|
|
28
|
+
'with floating label and required': {
|
|
29
|
+
label: '-Label',
|
|
30
|
+
placeholder: '-Placeholder',
|
|
31
|
+
required: 'true',
|
|
32
|
+
'label-type': 'floating',
|
|
33
|
+
},
|
|
34
|
+
'with label and not required': {
|
|
35
|
+
label: '-Label',
|
|
36
|
+
placeholder: '-Placeholder',
|
|
37
|
+
required: 'false',
|
|
38
|
+
},
|
|
39
|
+
'with floating label and not required': {
|
|
40
|
+
label: '-Label',
|
|
41
|
+
placeholder: '-Placeholder',
|
|
42
|
+
required: 'false',
|
|
43
|
+
'label-type': 'floating',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const runVisualTests = async (
|
|
48
|
+
name: string,
|
|
49
|
+
preset: Record<string, string>,
|
|
50
|
+
componentSetup?: (page: Page) => Promise<void>,
|
|
51
|
+
) => {
|
|
52
|
+
test.describe(name, () => {
|
|
53
|
+
test.beforeEach(async ({ page }) => {
|
|
54
|
+
await page.goto(getStoryUrl(storyName, preset));
|
|
55
|
+
if (componentSetup) await componentSetup(page);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('hover', async ({ page }) => {
|
|
59
|
+
const component = createAutocompleteTestDriver(
|
|
60
|
+
page.locator(componentName),
|
|
61
|
+
);
|
|
62
|
+
await component.hover();
|
|
63
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('focus', async ({ page }) => {
|
|
67
|
+
const component = createAutocompleteTestDriver(
|
|
68
|
+
page.locator(componentName),
|
|
69
|
+
);
|
|
70
|
+
await component.focus();
|
|
71
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
test.describe('theme', () => {
|
|
77
|
+
const { placeholder, ...restConfig } = componentAttributes;
|
|
78
|
+
|
|
79
|
+
runVisualTests('with placeholder', { placeholder });
|
|
80
|
+
|
|
81
|
+
runVisualTests('with default-value and allow-custom-value', {
|
|
82
|
+
'default-value': 'default value',
|
|
83
|
+
'allow-custom-value': 'true',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
runVisualTests('with default-value and not allow-custom-value', {
|
|
87
|
+
'default-value': 'default value',
|
|
88
|
+
'allow-custom-value': 'false',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
runVisualTests('rtl with default-value', {
|
|
92
|
+
'default-value': '-default value',
|
|
93
|
+
direction: 'rtl',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
loopPresets(presets, (preset, name) => {
|
|
97
|
+
test(name, async ({ page }) => {
|
|
98
|
+
await page.goto(getStoryUrl(storyName, preset));
|
|
99
|
+
const component = createAutocompleteTestDriver(
|
|
100
|
+
page.locator(componentName),
|
|
101
|
+
);
|
|
102
|
+
expect(
|
|
103
|
+
await component.screenshot({ animations: 'disabled' }),
|
|
104
|
+
).toMatchSnapshot();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test(`rtl ${name}`, async ({ page }) => {
|
|
108
|
+
await page.goto(getStoryUrl(storyName, { ...preset, direction: 'rtl' }));
|
|
109
|
+
const component = createAutocompleteTestDriver(
|
|
110
|
+
page.locator(componentName),
|
|
111
|
+
);
|
|
112
|
+
expect(
|
|
113
|
+
await component.screenshot({ animations: 'disabled' }),
|
|
114
|
+
).toMatchSnapshot();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
['', 'rtl'].forEach((direction) => {
|
|
119
|
+
test(`${!!direction ? 'rtl ' : ''}default error message`, async ({
|
|
120
|
+
page,
|
|
121
|
+
}) => {
|
|
122
|
+
await page.goto(
|
|
123
|
+
getStoryUrl(storyName, {
|
|
124
|
+
label: '-Label',
|
|
125
|
+
placeholder: '-Placeholder',
|
|
126
|
+
required: 'true',
|
|
127
|
+
direction,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
const component = createAutocompleteTestDriver(
|
|
131
|
+
page.locator(componentName),
|
|
132
|
+
);
|
|
133
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
134
|
+
expect(
|
|
135
|
+
await component.screenshot({ timeout: 1000, animations: 'disabled' }),
|
|
136
|
+
).toMatchSnapshot();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
['', 'rtl'].forEach((direction) => {
|
|
141
|
+
test(`${!!direction ? 'rtl ' : ''}custom error message`, async ({
|
|
142
|
+
page,
|
|
143
|
+
}) => {
|
|
144
|
+
await page.goto(
|
|
145
|
+
getStoryUrl(storyName, {
|
|
146
|
+
label: '-Label',
|
|
147
|
+
placeholder: '-Placeholder',
|
|
148
|
+
required: 'true',
|
|
149
|
+
'data-errormessage-value-missing': '-please fill it',
|
|
150
|
+
direction,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
const component = createAutocompleteTestDriver(
|
|
154
|
+
page.locator(componentName),
|
|
155
|
+
);
|
|
156
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
157
|
+
expect(
|
|
158
|
+
await component.screenshot({ timeout: 1000, animations: 'disabled' }),
|
|
159
|
+
).toMatchSnapshot();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test.describe('with value', () => {
|
|
164
|
+
loopConfig(restConfig, (attr, value) => {
|
|
165
|
+
runVisualTests(`${attr}: ${value}`, { [attr]: value }, async (page) => {
|
|
166
|
+
const component = createAutocompleteTestDriver(
|
|
167
|
+
page.locator(componentName),
|
|
168
|
+
);
|
|
169
|
+
await component.setValue('autovalue');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test.describe('dropdown', () => {
|
|
175
|
+
loopPresets(
|
|
176
|
+
{
|
|
177
|
+
'size: xs': { size: 'xs' },
|
|
178
|
+
'size: sm': { size: 'sm' },
|
|
179
|
+
'size: md': { size: 'md' },
|
|
180
|
+
'size: lg': { size: 'lg' },
|
|
181
|
+
'full-width: true': { 'full-width': 'true' },
|
|
182
|
+
'full-width: false': { 'full-width': 'false' },
|
|
183
|
+
rtl: { direction: 'rtl', 'full-width': 'true' },
|
|
184
|
+
},
|
|
185
|
+
(preset, name) => {
|
|
186
|
+
test(name, async ({ page }) => {
|
|
187
|
+
await page.goto(getStoryUrl(storyName, preset));
|
|
188
|
+
const component = createAutocompleteTestDriver(
|
|
189
|
+
page.locator(componentName),
|
|
190
|
+
);
|
|
191
|
+
await component.insertValue('fastmock');
|
|
192
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
193
|
+
hasText: 'The Adventures of fastmock in Wonderland',
|
|
194
|
+
});
|
|
195
|
+
await resultsLoaded.waitFor();
|
|
196
|
+
expect(await component.dropDown.screenshot()).toMatchSnapshot();
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('loading', async ({ page }) => {
|
|
203
|
+
await page.goto(getStoryUrl(storyName, {}));
|
|
204
|
+
const component = createAutocompleteTestDriver(page.locator(componentName));
|
|
205
|
+
await component.insertValue('loadin');
|
|
206
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
207
|
+
hasText: 'The Adventures of ',
|
|
208
|
+
});
|
|
209
|
+
await resultsLoaded.waitFor();
|
|
210
|
+
await component.insertValue('g');
|
|
211
|
+
await page.waitForTimeout(300);
|
|
212
|
+
expect(
|
|
213
|
+
await component.dropDown.screenshot({ animations: 'disabled' }),
|
|
214
|
+
).toMatchSnapshot();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('error message with icon', async ({ page }) => {
|
|
218
|
+
await page.goto(
|
|
219
|
+
getStoryUrl(storyName, { required: true, errorMsgIcon: true }),
|
|
220
|
+
);
|
|
221
|
+
const component = createAutocompleteTestDriver(page.locator(componentName));
|
|
222
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
223
|
+
await component.blur();
|
|
224
|
+
expect(
|
|
225
|
+
await component.screenshot({
|
|
226
|
+
animations: 'disabled',
|
|
227
|
+
caret: 'hide',
|
|
228
|
+
timeout: 3000,
|
|
229
|
+
}),
|
|
230
|
+
).toMatchSnapshot();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test.describe('logic', () => {
|
|
235
|
+
test.describe('min search length', () => {
|
|
236
|
+
test('default', async ({ page }) => {
|
|
237
|
+
await page.goto(getStoryUrl(storyName));
|
|
238
|
+
const component = createAutocompleteTestDriver(
|
|
239
|
+
page.locator(componentName),
|
|
240
|
+
);
|
|
241
|
+
await component.insertValue('moc');
|
|
242
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
243
|
+
hasText: 'The Adventures of ',
|
|
244
|
+
});
|
|
245
|
+
await resultsLoaded.waitFor();
|
|
246
|
+
const items = await component.getItems();
|
|
247
|
+
expect(items.length).toBeGreaterThan(0);
|
|
248
|
+
await expect(component.dropDown).toBeVisible();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('custom', async ({ page }) => {
|
|
252
|
+
await page.goto(getStoryUrl(storyName, { 'min-search-length': '4' }));
|
|
253
|
+
const component = createAutocompleteTestDriver(
|
|
254
|
+
page.locator(componentName),
|
|
255
|
+
);
|
|
256
|
+
await component.insertValue('fas');
|
|
257
|
+
// Need to have the timeout to wait for the results to load (or not)
|
|
258
|
+
await page.waitForTimeout(1000);
|
|
259
|
+
let items = await component.getItems();
|
|
260
|
+
expect(items.length).toBe(0);
|
|
261
|
+
await expect(component.dropDown).not.toBeVisible();
|
|
262
|
+
await component.insertValue('t');
|
|
263
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
264
|
+
hasText: 'The Adventures of ',
|
|
265
|
+
});
|
|
266
|
+
await resultsLoaded.waitFor();
|
|
267
|
+
items = await component.getItems();
|
|
268
|
+
expect(items.length).toBeGreaterThan(0);
|
|
269
|
+
await expect(component.dropDown).toBeVisible();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
['', 'rtl'].forEach((direction) => {
|
|
274
|
+
test(`${!!direction ? 'rtl ' : ''}select item and clear`, async ({
|
|
275
|
+
page,
|
|
276
|
+
}) => {
|
|
277
|
+
await page.goto(getStoryUrl(storyName, { direction }));
|
|
278
|
+
const component = createAutocompleteTestDriver(
|
|
279
|
+
page.locator(componentName),
|
|
280
|
+
);
|
|
281
|
+
await component.insertValue('fastmock');
|
|
282
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
283
|
+
hasText: 'The Adventures of ',
|
|
284
|
+
});
|
|
285
|
+
await resultsLoaded.waitFor();
|
|
286
|
+
await component.selectItem('mock');
|
|
287
|
+
expect(await component.getValue()).toBe(
|
|
288
|
+
'The Adventures of fastmock in Wonderland',
|
|
289
|
+
);
|
|
290
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
291
|
+
await component.openDropdown();
|
|
292
|
+
expect(await component.dropDown.screenshot()).toMatchSnapshot();
|
|
293
|
+
await component.blur();
|
|
294
|
+
await component.clearSelection();
|
|
295
|
+
expect(await component.getValue()).toBe('');
|
|
296
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test.describe('set value', () => {
|
|
301
|
+
test('allow custom value', async ({ page }) => {
|
|
302
|
+
await page.goto(getStoryUrl(storyName, { 'allow-custom-value': 'true' }));
|
|
303
|
+
const component = createAutocompleteTestDriver(
|
|
304
|
+
page.locator(componentName),
|
|
305
|
+
);
|
|
306
|
+
await component.setValue('set value');
|
|
307
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
308
|
+
expect(await component.getValue()).toBe('set value');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('not allow custom value', async ({ page }) => {
|
|
312
|
+
await page.goto(
|
|
313
|
+
getStoryUrl(storyName, { 'allow-custom-value': 'false' }),
|
|
314
|
+
);
|
|
315
|
+
const component = createAutocompleteTestDriver(
|
|
316
|
+
page.locator(componentName),
|
|
317
|
+
);
|
|
318
|
+
await component.setValue('set value');
|
|
319
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
320
|
+
expect(await component.getValue()).toBe('set value');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test.describe('search and blur', () => {
|
|
325
|
+
test('allow custom value', async ({ page }) => {
|
|
326
|
+
await page.goto(getStoryUrl(storyName, { 'allow-custom-value': 'true' }));
|
|
327
|
+
const component = createAutocompleteTestDriver(
|
|
328
|
+
page.locator(componentName),
|
|
329
|
+
);
|
|
330
|
+
await component.insertValue('fastmock1');
|
|
331
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
332
|
+
hasText: 'The Adventures of ',
|
|
333
|
+
});
|
|
334
|
+
await resultsLoaded.waitFor();
|
|
335
|
+
const items = await component.getItems();
|
|
336
|
+
expect(items.length).toBeGreaterThan(0);
|
|
337
|
+
await expect(component.dropDown).toBeVisible();
|
|
338
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
339
|
+
await expect(component.dropDown).not.toBeVisible({ timeout: 1000 });
|
|
340
|
+
expect(await component.getValue()).toBe('fastmock1');
|
|
341
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('not allow custom value', async ({ page }) => {
|
|
345
|
+
await page.goto(
|
|
346
|
+
getStoryUrl(storyName, { 'allow-custom-value': 'false' }),
|
|
347
|
+
);
|
|
348
|
+
const component = createAutocompleteTestDriver(
|
|
349
|
+
page.locator(componentName),
|
|
350
|
+
);
|
|
351
|
+
await component.insertValue('fastmock1');
|
|
352
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
353
|
+
hasText: 'The Adventures of ',
|
|
354
|
+
});
|
|
355
|
+
await resultsLoaded.waitFor();
|
|
356
|
+
const items = await component.getItems();
|
|
357
|
+
expect(items.length).toBeGreaterThan(0);
|
|
358
|
+
await expect(component.dropDown).toBeVisible();
|
|
359
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
360
|
+
await expect(component.dropDown).not.toBeVisible({ timeout: 1000 });
|
|
361
|
+
expect(await component.getValue()).toBe('');
|
|
362
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('select - allow custom value', async ({ page }) => {
|
|
366
|
+
await page.goto(getStoryUrl(storyName, { 'allow-custom-value': 'true' }));
|
|
367
|
+
const locator = page.locator(componentName);
|
|
368
|
+
const component = createAutocompleteTestDriver(locator);
|
|
369
|
+
await component.insertValue('fastmock');
|
|
370
|
+
let resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
371
|
+
hasText: 'The Adventures of ',
|
|
372
|
+
});
|
|
373
|
+
await resultsLoaded.waitFor();
|
|
374
|
+
await component.selectItem('mock');
|
|
375
|
+
expect(await component.getValue()).toBe(
|
|
376
|
+
'The Adventures of fastmock in Wonderland',
|
|
377
|
+
);
|
|
378
|
+
await expect(component.dropDown).not.toBeVisible();
|
|
379
|
+
await component.replaceSearchValue('fasthello');
|
|
380
|
+
resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
381
|
+
hasText: 'The Adventures of fasthello',
|
|
382
|
+
});
|
|
383
|
+
await resultsLoaded.waitFor();
|
|
384
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
385
|
+
await expect(component.dropDown).not.toBeVisible({ timeout: 1000 });
|
|
386
|
+
expect(await component.getValue()).toBe('fasthello');
|
|
387
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('select - not allow custom value', async ({ page }) => {
|
|
391
|
+
await page.goto(
|
|
392
|
+
getStoryUrl(storyName, { 'allow-custom-value': 'false' }),
|
|
393
|
+
);
|
|
394
|
+
const locator = page.locator(componentName);
|
|
395
|
+
const component = createAutocompleteTestDriver(locator);
|
|
396
|
+
await component.insertValue('fastmock');
|
|
397
|
+
let resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
398
|
+
hasText: 'The Adventures of ',
|
|
399
|
+
});
|
|
400
|
+
await resultsLoaded.waitFor();
|
|
401
|
+
await component.selectItem('mock');
|
|
402
|
+
expect(await component.getValue()).toBe(
|
|
403
|
+
'The Adventures of fastmock in Wonderland',
|
|
404
|
+
);
|
|
405
|
+
await expect(component.dropDown).not.toBeVisible();
|
|
406
|
+
await component.replaceSearchValue('fasthello');
|
|
407
|
+
resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
408
|
+
hasText: 'The Adventures of fasthello',
|
|
409
|
+
});
|
|
410
|
+
await resultsLoaded.waitFor();
|
|
411
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
412
|
+
await expect(component.dropDown).not.toBeVisible({ timeout: 1000 });
|
|
413
|
+
expect(await component.getValue()).toBe(
|
|
414
|
+
'The Adventures of fastmock in Wonderland',
|
|
415
|
+
);
|
|
416
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('no results', async ({ page }) => {
|
|
421
|
+
await page.goto(getStoryUrl(storyName, {}));
|
|
422
|
+
const component = createAutocompleteTestDriver(page.locator(componentName));
|
|
423
|
+
await component.insertValue('no ');
|
|
424
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
425
|
+
hasText: 'No results',
|
|
426
|
+
});
|
|
427
|
+
await resultsLoaded.waitFor();
|
|
428
|
+
await expect(component.dropDown).toBeVisible();
|
|
429
|
+
expect(await component.dropDown.screenshot()).toMatchSnapshot();
|
|
430
|
+
const item = page.locator(componentName).getByRole('option').first();
|
|
431
|
+
await expect(item).toHaveAttribute('disabled', 'true');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('error searching', async ({ page }) => {
|
|
435
|
+
await page.goto(getStoryUrl(storyName, {}));
|
|
436
|
+
const component = createAutocompleteTestDriver(page.locator(componentName));
|
|
437
|
+
await component.insertValue('error');
|
|
438
|
+
const resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
439
|
+
hasText: 'An error occurred',
|
|
440
|
+
});
|
|
441
|
+
await resultsLoaded.waitFor();
|
|
442
|
+
await expect(component.dropDown).toBeVisible();
|
|
443
|
+
expect(await component.dropDown.screenshot()).toMatchSnapshot();
|
|
444
|
+
const item = page.locator(componentName).getByRole('option').first();
|
|
445
|
+
await expect(item).toHaveAttribute('disabled', 'true');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('error validation - allow custom value', async ({
|
|
449
|
+
page,
|
|
450
|
+
browserName,
|
|
451
|
+
}) => {
|
|
452
|
+
// The test has to be skipped for webkit because when there's an error,
|
|
453
|
+
// the component collapses and it's not visible (only in Playwright).
|
|
454
|
+
test.skip(browserName === 'webkit');
|
|
455
|
+
await page.goto(
|
|
456
|
+
getStoryUrl(storyName, {
|
|
457
|
+
'allow-custom-value': 'true',
|
|
458
|
+
required: 'true',
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
const component = createAutocompleteTestDriver(page.locator(componentName));
|
|
462
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
463
|
+
let errorMessage = page.locator('div', {
|
|
464
|
+
hasText: 'Please fill out this field.',
|
|
465
|
+
});
|
|
466
|
+
// Empty input should show error message
|
|
467
|
+
await errorMessage.nth(0).waitFor();
|
|
468
|
+
|
|
469
|
+
// Enter custom value
|
|
470
|
+
await component.insertValue('test value');
|
|
471
|
+
let resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
472
|
+
hasText: 'The Adventures of test value in Wonderland',
|
|
473
|
+
});
|
|
474
|
+
await resultsLoaded.waitFor({ timeout: 1000 });
|
|
475
|
+
// The value is not selected
|
|
476
|
+
await component.blur();
|
|
477
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
478
|
+
expect(await component.getValue()).toBe('test value');
|
|
479
|
+
errorMessage = page.locator('div', {
|
|
480
|
+
hasText: 'Please fill out this field.',
|
|
481
|
+
});
|
|
482
|
+
await expect(errorMessage).not.toBeVisible();
|
|
483
|
+
|
|
484
|
+
// Clear the value and check that the error message is visible
|
|
485
|
+
await component.clearSelection();
|
|
486
|
+
await component.blur();
|
|
487
|
+
expect(await component.getValue()).toBe('');
|
|
488
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
489
|
+
errorMessage = page.locator('div', {
|
|
490
|
+
hasText: 'Please fill out this field.',
|
|
491
|
+
});
|
|
492
|
+
await errorMessage.nth(0).waitFor();
|
|
493
|
+
|
|
494
|
+
// Search and select a valid value
|
|
495
|
+
await page.goto(getStoryUrl(storyName, { 'allow-custom-value': 'true' }));
|
|
496
|
+
await component.insertValue('fastmock');
|
|
497
|
+
resultsLoaded = page.locator('vaadin-combo-box-item', {
|
|
498
|
+
hasText: 'The Adventures of ',
|
|
499
|
+
});
|
|
500
|
+
await resultsLoaded.waitFor();
|
|
501
|
+
await component.selectItem('mock');
|
|
502
|
+
expect(await component.getValue()).toBe(
|
|
503
|
+
'The Adventures of fastmock in Wonderland',
|
|
504
|
+
);
|
|
505
|
+
await page.getByRole('button').getByText('Submit').click();
|
|
506
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
507
|
+
});
|
|
508
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@descope-ui/descope-autocomplete-field",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": {
|
|
6
|
+
"import": "./src/component/index.js"
|
|
7
|
+
},
|
|
8
|
+
"./theme": {
|
|
9
|
+
"import": "./src/theme.js"
|
|
10
|
+
},
|
|
11
|
+
"./class": {
|
|
12
|
+
"import": "./src/component/AutocompleteFieldClass.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@playwright/test": "1.38.1",
|
|
17
|
+
"e2e-utils": "0.0.1",
|
|
18
|
+
"test-drivers": "0.0.1"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@vaadin/custom-field": "24.3.4",
|
|
22
|
+
"@descope-ui/common": "0.0.3",
|
|
23
|
+
"@descope-ui/theme-globals": "0.0.3",
|
|
24
|
+
"@descope-ui/theme-input-wrapper": "0.0.3",
|
|
25
|
+
"@descope-ui/descope-combo-box": "0.0.3"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"link-workspace-packages": false
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "echo 'No tests defined' && exit 0",
|
|
32
|
+
"test:e2e": "echo 'No e2e tests defined' && exit 0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@descope-ui/descope-autocomplete-field",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "packages/web-components/components/descope-autocomplete-field/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"targets": {
|
|
7
|
+
"version": {
|
|
8
|
+
"executor": "@jscutlery/semver:version",
|
|
9
|
+
"options": {
|
|
10
|
+
"trackDeps": true,
|
|
11
|
+
"push": false,
|
|
12
|
+
"preset": "conventional"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"tags": []
|
|
17
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStyleMixin,
|
|
3
|
+
draggableMixin,
|
|
4
|
+
createProxy,
|
|
5
|
+
proxyInputMixin,
|
|
6
|
+
componentNameValidationMixin,
|
|
7
|
+
} from '@descope-ui/common/components-mixins';
|
|
8
|
+
import {
|
|
9
|
+
forwardAttrs,
|
|
10
|
+
getComponentName,
|
|
11
|
+
syncAttrs,
|
|
12
|
+
} from '@descope-ui/common/components-helpers';
|
|
13
|
+
import { compose } from '@descope-ui/common/utils';
|
|
14
|
+
import { ComboBoxClass } from '@descope-ui/descope-combo-box/class';
|
|
15
|
+
import { componentName as descopeInternalComponentName } from './descope-autocomplete-field-internal/AutocompleteFieldInternal';
|
|
16
|
+
|
|
17
|
+
export const componentName = getComponentName('autocomplete-field');
|
|
18
|
+
|
|
19
|
+
const customMixin = (superclass) =>
|
|
20
|
+
class AutocompleteFieldMixinClass extends superclass {
|
|
21
|
+
get defaultValue() {
|
|
22
|
+
return this.getAttribute('default-value');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setDefaultValue() {
|
|
26
|
+
if (this.defaultValue) {
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
this.inputElement.value = this.defaultValue;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
init() {
|
|
34
|
+
super.init?.();
|
|
35
|
+
const template = document.createElement('template');
|
|
36
|
+
|
|
37
|
+
template.innerHTML = `
|
|
38
|
+
<${descopeInternalComponentName}
|
|
39
|
+
tabindex="-1"
|
|
40
|
+
></${descopeInternalComponentName}>
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
this.baseElement.appendChild(template.content.cloneNode(true));
|
|
44
|
+
|
|
45
|
+
this.inputElement = this.shadowRoot.querySelector(
|
|
46
|
+
descopeInternalComponentName,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
forwardAttrs(this, this.inputElement, {
|
|
50
|
+
includeAttrs: [
|
|
51
|
+
'size',
|
|
52
|
+
'bordered',
|
|
53
|
+
'label',
|
|
54
|
+
'required',
|
|
55
|
+
'label-type',
|
|
56
|
+
'placeholder',
|
|
57
|
+
'full-width',
|
|
58
|
+
'allow-custom-value',
|
|
59
|
+
'min-search-length',
|
|
60
|
+
'no-results-message',
|
|
61
|
+
'error-fetching-results-message',
|
|
62
|
+
'data-errormessage-value-missing',
|
|
63
|
+
'invalid',
|
|
64
|
+
'error-message',
|
|
65
|
+
'readonly',
|
|
66
|
+
'disabled',
|
|
67
|
+
'st-host-direction',
|
|
68
|
+
'st-error-message-icon',
|
|
69
|
+
'st-error-message-icon-size',
|
|
70
|
+
'st-error-message-icon-padding',
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// This is required since when we remove the invalid attribute from the internal mappings field,
|
|
75
|
+
// we want to reflect the change in the parent component
|
|
76
|
+
syncAttrs(this, this.inputElement, { includeAttrs: ['invalid'] });
|
|
77
|
+
this.inputElement.fetchResults = this.fetchResults;
|
|
78
|
+
this.setDefaultValue();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
set fetchResults(fetchResults) {
|
|
82
|
+
this.inputElement.fetchResults = fetchResults;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const { host } = {
|
|
87
|
+
host: { selector: () => ':host' },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const AutocompleteFieldClass = compose(
|
|
91
|
+
createStyleMixin({
|
|
92
|
+
componentNameOverride: getComponentName('input-wrapper'),
|
|
93
|
+
}),
|
|
94
|
+
createStyleMixin({
|
|
95
|
+
mappings: {
|
|
96
|
+
hostWidth: { ...host, property: 'width' },
|
|
97
|
+
hostDirection: { ...host, property: 'direction' },
|
|
98
|
+
fontSize: { ...host },
|
|
99
|
+
checkmarkDisplay: {
|
|
100
|
+
selector: ComboBoxClass.componentName,
|
|
101
|
+
property: ComboBoxClass.cssVarList.overlayCheckmarkDisplay,
|
|
102
|
+
},
|
|
103
|
+
itemPaddingInlineStart: {
|
|
104
|
+
selector: ComboBoxClass.componentName,
|
|
105
|
+
property: ComboBoxClass.cssVarList.overlayItemPaddingInlineStart,
|
|
106
|
+
},
|
|
107
|
+
itemPaddingInlineEnd: {
|
|
108
|
+
selector: ComboBoxClass.componentName,
|
|
109
|
+
property: ComboBoxClass.cssVarList.overlayItemPaddingInlineEnd,
|
|
110
|
+
},
|
|
111
|
+
selectedItemBackground: {
|
|
112
|
+
selector: ComboBoxClass.componentName,
|
|
113
|
+
property: ComboBoxClass.cssVarList.overlaySelectedItemBackground,
|
|
114
|
+
},
|
|
115
|
+
selectedItemHoverBackground: {
|
|
116
|
+
selector: ComboBoxClass.componentName,
|
|
117
|
+
property: ComboBoxClass.cssVarList.overlaySelectedItemHoverBackground,
|
|
118
|
+
},
|
|
119
|
+
selectedItemFocusBackground: {
|
|
120
|
+
selector: ComboBoxClass.componentName,
|
|
121
|
+
property: ComboBoxClass.cssVarList.overlaySelectedItemFocusBackground,
|
|
122
|
+
},
|
|
123
|
+
itemHoverBackground: {
|
|
124
|
+
selector: ComboBoxClass.componentName,
|
|
125
|
+
property: ComboBoxClass.cssVarList.overlayItemHoverBackground,
|
|
126
|
+
},
|
|
127
|
+
errorMessageIcon: {
|
|
128
|
+
selector: () => ComboBoxClass.componentName,
|
|
129
|
+
property: ComboBoxClass.cssVarList.errorMessageIcon,
|
|
130
|
+
},
|
|
131
|
+
errorMessageIconSize: {
|
|
132
|
+
selector: () => ComboBoxClass.componentName,
|
|
133
|
+
property: ComboBoxClass.cssVarList.errorMessageIconSize,
|
|
134
|
+
},
|
|
135
|
+
errorMessageIconPadding: {
|
|
136
|
+
selector: () => ComboBoxClass.componentName,
|
|
137
|
+
property: ComboBoxClass.cssVarList.errorMessageIconPadding,
|
|
138
|
+
},
|
|
139
|
+
errorMessageIconRepeat: {
|
|
140
|
+
selector: () => ComboBoxClass.componentName,
|
|
141
|
+
property: ComboBoxClass.cssVarList.errorMessageIconRepeat,
|
|
142
|
+
},
|
|
143
|
+
errorMessageIconPosition: {
|
|
144
|
+
selector: () => ComboBoxClass.componentName,
|
|
145
|
+
property: ComboBoxClass.cssVarList.errorMessageIconPosition,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
draggableMixin,
|
|
150
|
+
proxyInputMixin({
|
|
151
|
+
proxyProps: ['value', 'selectionStart'],
|
|
152
|
+
inputEvent: 'input',
|
|
153
|
+
proxyParentValidation: true,
|
|
154
|
+
}),
|
|
155
|
+
componentNameValidationMixin,
|
|
156
|
+
customMixin,
|
|
157
|
+
)(
|
|
158
|
+
createProxy({
|
|
159
|
+
slots: [],
|
|
160
|
+
wrappedEleName: 'vaadin-custom-field',
|
|
161
|
+
style: () => `
|
|
162
|
+
:host {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
max-width: 100%;
|
|
165
|
+
direction: ltr;
|
|
166
|
+
}
|
|
167
|
+
vaadin-custom-field {
|
|
168
|
+
line-height: unset;
|
|
169
|
+
width: 100%;
|
|
170
|
+
}
|
|
171
|
+
`,
|
|
172
|
+
excludeAttrsSync: ['tabindex', 'error-message', 'label'],
|
|
173
|
+
componentName,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createBaseInputClass } from '@descope-ui/common/base-classes';
|
|
2
|
+
import {
|
|
3
|
+
forwardAttrs,
|
|
4
|
+
getComponentName,
|
|
5
|
+
syncAttrs,
|
|
6
|
+
} from '@descope-ui/common/components-helpers';
|
|
7
|
+
import { asyncDebounce } from '@descope-ui/common/utils';
|
|
8
|
+
|
|
9
|
+
export const componentName = getComponentName('autocomplete-field-internal');
|
|
10
|
+
|
|
11
|
+
const BaseInputClass = createBaseInputClass({
|
|
12
|
+
componentName,
|
|
13
|
+
baseSelector: '',
|
|
14
|
+
});
|
|
15
|
+
const observedAttrs = [];
|
|
16
|
+
|
|
17
|
+
const INVALID_OPTION_VALUE = 'DESCOPE_INVALID_OPTION';
|
|
18
|
+
const DEBOUNCE_SEARCH_DELAY = 250;
|
|
19
|
+
|
|
20
|
+
class AutocompleteFieldInternal extends BaseInputClass {
|
|
21
|
+
static get observedAttributes() {
|
|
22
|
+
return [].concat(BaseInputClass.observedAttributes || [], observedAttrs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get allowCustomValue() {
|
|
26
|
+
return this.getAttribute('allow-custom-value') === 'true';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get minSearchLength() {
|
|
30
|
+
return this.getAttribute('min-search-length') || 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get noResultsFoundMessage() {
|
|
34
|
+
return this.getAttribute('no-results-message') || 'No results found';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get errorFetchingResultsMessage() {
|
|
38
|
+
return (
|
|
39
|
+
this.getAttribute('error-fetching-results-message') ||
|
|
40
|
+
'An error occurred fetching results'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
|
|
47
|
+
this.innerHTML = `
|
|
48
|
+
<style>
|
|
49
|
+
:host {
|
|
50
|
+
display: inline-block;
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
user-select: none;
|
|
53
|
+
max-width: 100%;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:host ::slotted {
|
|
57
|
+
padding: 0;
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
60
|
+
<div class="autocomplete-field">
|
|
61
|
+
<descope-combo-box clear-button-visible="true" auto-open-disabled="true" has-dynamic-data="true" item-label-path="data-name" item-value-path="data-id" hide-toggle-button="true"></descope-combo-box>
|
|
62
|
+
</div>
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
this.comboBox = this.querySelector('descope-combo-box');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get value() {
|
|
69
|
+
return this.comboBox.value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
set value(val) {
|
|
73
|
+
if (!this.allowCustomValue) {
|
|
74
|
+
this.comboBox.data = [{ label: val, value: val }];
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
this.comboBox.value = val;
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
this.comboBox.value = val;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get defaultValue() {
|
|
84
|
+
return this.getAttribute('default-value');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #autoCompleteSearch(value) {
|
|
88
|
+
const { results, error } = await this.fetchResults(value);
|
|
89
|
+
if (error) {
|
|
90
|
+
return { error };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
results,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// eslint-disable-next-line class-methods-use-this
|
|
98
|
+
#itemRenderer(displayName, value, label, disabled) {
|
|
99
|
+
return `<span data-name="${label}" data-id="${value}" ${disabled ? 'disabled="true"' : ''}>${
|
|
100
|
+
displayName || label
|
|
101
|
+
}</span>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#overrideComboBoxRenderers() {
|
|
105
|
+
this.comboBox.renderer = (root, _, model) => {
|
|
106
|
+
if (model.item.getAttribute('disabled') === 'true') {
|
|
107
|
+
root.setAttribute('disabled', 'true');
|
|
108
|
+
} else {
|
|
109
|
+
root.removeAttribute('disabled');
|
|
110
|
+
}
|
|
111
|
+
// eslint-disable-next-line no-param-reassign
|
|
112
|
+
root.innerHTML = model.item.outerHTML;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
this.comboBox.renderItem = ({ displayName, value, label, disabled }) =>
|
|
116
|
+
this.#itemRenderer(displayName, value, label, disabled);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
focus() {
|
|
120
|
+
this.comboBox.focus();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
init() {
|
|
124
|
+
// This event listener needs to be placed before the super.init() call
|
|
125
|
+
this.addEventListener('focus', (e) => {
|
|
126
|
+
// we want to ignore focus events we are dispatching
|
|
127
|
+
if (e.isTrusted) this.comboBox.focus();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
super.init?.();
|
|
131
|
+
this.#initComboBox();
|
|
132
|
+
// This is required since when we remove the invalid attribute from the internal mappings field,
|
|
133
|
+
// we want to reflect the change in the parent component
|
|
134
|
+
syncAttrs(this, this.comboBox, { includeAttrs: ['invalid'] });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#initComboBox() {
|
|
138
|
+
this.debouncedSearch = asyncDebounce(async (value) => {
|
|
139
|
+
try {
|
|
140
|
+
const { results, error } = await this.#autoCompleteSearch(value);
|
|
141
|
+
return { results, error, value };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { error, value };
|
|
144
|
+
}
|
|
145
|
+
}, DEBOUNCE_SEARCH_DELAY);
|
|
146
|
+
|
|
147
|
+
forwardAttrs(this, this.comboBox, {
|
|
148
|
+
includeAttrs: [
|
|
149
|
+
'size',
|
|
150
|
+
'bordered',
|
|
151
|
+
'label',
|
|
152
|
+
'label-type',
|
|
153
|
+
'placeholder',
|
|
154
|
+
'disabled',
|
|
155
|
+
'readonly',
|
|
156
|
+
'required',
|
|
157
|
+
'full-width',
|
|
158
|
+
'helper-text',
|
|
159
|
+
'invalid',
|
|
160
|
+
'error-message',
|
|
161
|
+
'data-errormessage-value-missing',
|
|
162
|
+
'st-host-direction',
|
|
163
|
+
'allow-custom-value',
|
|
164
|
+
'st-error-message-icon',
|
|
165
|
+
'st-error-message-icon-size',
|
|
166
|
+
'st-error-message-icon-padding',
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.comboBox.addEventListener('filter-changed', this.onSearch.bind(this));
|
|
171
|
+
this.#overrideComboBoxRenderers();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#getEmptyResultsData() {
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
label: this.noResultsFoundMessage,
|
|
178
|
+
value: INVALID_OPTION_VALUE,
|
|
179
|
+
disabled: true,
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#getErrorResultsData() {
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
label: this.errorFetchingResultsMessage,
|
|
188
|
+
value: INVALID_OPTION_VALUE,
|
|
189
|
+
disabled: true,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async onSearch(e) {
|
|
195
|
+
const searchValue = e.detail.value;
|
|
196
|
+
if (!searchValue || searchValue.length < this.minSearchLength) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.comboBox.loading = true;
|
|
200
|
+
this.comboBox.setAttribute('opened', 'true');
|
|
201
|
+
const response = await this.debouncedSearch(searchValue);
|
|
202
|
+
if (response && response.value === searchValue) {
|
|
203
|
+
this.comboBox.loading = false;
|
|
204
|
+
if (response.error) {
|
|
205
|
+
this.comboBox.data = this.#getErrorResultsData();
|
|
206
|
+
} else if (response.results?.length === 0) {
|
|
207
|
+
this.comboBox.data = this.#getEmptyResultsData();
|
|
208
|
+
} else {
|
|
209
|
+
this.comboBox.data = response.results || [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// eslint-disable-next-line class-methods-use-this,no-unused-vars
|
|
215
|
+
fetchResults(value) {
|
|
216
|
+
// eslint-disable-next-line no-console
|
|
217
|
+
console.warn('fetchResults', 'needs to be implemented');
|
|
218
|
+
return { results: [], error: undefined };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getValidity() {
|
|
222
|
+
if (this.isRequired && !this.value) {
|
|
223
|
+
return { valueMissing: true };
|
|
224
|
+
}
|
|
225
|
+
return { valid: true };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default AutocompleteFieldInternal;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import '@vaadin/custom-field';
|
|
2
|
+
import '@descope-ui/descope-combo-box';
|
|
3
|
+
import './descope-autocomplete-field-internal';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
componentName,
|
|
7
|
+
AutocompleteFieldClass,
|
|
8
|
+
} from './AutocompleteFieldClass';
|
|
9
|
+
|
|
10
|
+
customElements.define(componentName, AutocompleteFieldClass);
|
|
11
|
+
|
|
12
|
+
export { AutocompleteFieldClass, componentName };
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import globals from '@descope-ui/theme-globals';
|
|
2
|
+
import { AutocompleteFieldClass } from './component/AutocompleteFieldClass';
|
|
3
|
+
import { getThemeRefs } from '@descope-ui/common/theme-helpers';
|
|
4
|
+
import { refs } from '@descope-ui/theme-input-wrapper';
|
|
5
|
+
|
|
6
|
+
const vars = AutocompleteFieldClass.cssVarList;
|
|
7
|
+
const globalRefs = getThemeRefs(globals);
|
|
8
|
+
|
|
9
|
+
const autocompleteField = {
|
|
10
|
+
[vars.hostWidth]: refs.width,
|
|
11
|
+
[vars.hostDirection]: refs.direction,
|
|
12
|
+
[vars.fontSize]: refs.fontSize,
|
|
13
|
+
[vars.checkmarkDisplay]: 'none',
|
|
14
|
+
[vars.itemPaddingInlineStart]: globalRefs.spacing.lg,
|
|
15
|
+
[vars.itemPaddingInlineEnd]: globalRefs.spacing.lg,
|
|
16
|
+
[vars.selectedItemBackground]: globalRefs.colors.primary.light,
|
|
17
|
+
[vars.selectedItemHoverBackground]: globalRefs.colors.primary.light,
|
|
18
|
+
[vars.selectedItemFocusBackground]: globalRefs.colors.primary.light,
|
|
19
|
+
[vars.itemHoverBackground]: globalRefs.colors.primary.highlight,
|
|
20
|
+
|
|
21
|
+
// error message icon
|
|
22
|
+
[vars.errorMessageIcon]: refs.errorMessageIcon,
|
|
23
|
+
[vars.errorMessageIconSize]: refs.errorMessageIconSize,
|
|
24
|
+
[vars.errorMessageIconPadding]: refs.errorMessageIconPadding,
|
|
25
|
+
[vars.errorMessageIconRepeat]: refs.errorMessageIconRepeat,
|
|
26
|
+
[vars.errorMessageIconPosition]: refs.errorMessageIconPosition,
|
|
27
|
+
|
|
28
|
+
_fullWidth: {
|
|
29
|
+
[vars.hostWidth]: '100%',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default autocompleteField;
|
|
34
|
+
export { vars };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { componentName } from '../src/component';
|
|
2
|
+
|
|
3
|
+
import { withForm } from '@descope-ui/common/sb-helpers';
|
|
4
|
+
import {
|
|
5
|
+
labelControl,
|
|
6
|
+
placeholderControl,
|
|
7
|
+
sizeControl,
|
|
8
|
+
fullWidthControl,
|
|
9
|
+
directionControl,
|
|
10
|
+
disabledControl,
|
|
11
|
+
readOnlyControl,
|
|
12
|
+
requiredControl,
|
|
13
|
+
borderedControl,
|
|
14
|
+
errorMissingValueControl,
|
|
15
|
+
inputLabelTypeControl,
|
|
16
|
+
errorMessageIconControl,
|
|
17
|
+
errorMessageIconAttrs,
|
|
18
|
+
} from '@descope-ui/common/sb-controls';
|
|
19
|
+
|
|
20
|
+
const mockFetchResults = function (query) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let lowerQuery = query.toLowerCase();
|
|
23
|
+
const results = [
|
|
24
|
+
{
|
|
25
|
+
id: 'book-1',
|
|
26
|
+
value: 'The Lord of the Sample Rings: The Fellowship of the Ring',
|
|
27
|
+
},
|
|
28
|
+
{ id: 'book-2', value: "Harry Mock and the Philosopher's Stone" },
|
|
29
|
+
{ id: 'book-3', value: 'One Hundred Years of Test Mock' },
|
|
30
|
+
{ id: 'book-4', value: "The Hitchhiker's Example to the Galaxy Mock" },
|
|
31
|
+
{ id: 'book-5', value: 'Pride and Prejudice and Zombies Mock' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (!lowerQuery.startsWith('no')) {
|
|
35
|
+
results.unshift({
|
|
36
|
+
id: 'book-dynamic',
|
|
37
|
+
value: `The Adventures of ${query} in Wonderland`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let responseDelay = 100 + Math.random() * 200;
|
|
42
|
+
if (lowerQuery.startsWith('loading')) {
|
|
43
|
+
responseDelay = 5000;
|
|
44
|
+
} else if (lowerQuery.startsWith('fast')) {
|
|
45
|
+
responseDelay = 0;
|
|
46
|
+
lowerQuery = lowerQuery.replace('fast', '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (lowerQuery.startsWith('error')) {
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
reject({
|
|
52
|
+
error: 'something went wrong',
|
|
53
|
+
});
|
|
54
|
+
}, responseDelay);
|
|
55
|
+
} else {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
resolve({
|
|
58
|
+
results: results
|
|
59
|
+
.filter((value) => value.value.toLowerCase().includes(lowerQuery))
|
|
60
|
+
.map((value) => ({
|
|
61
|
+
label: value.value,
|
|
62
|
+
value: value.value,
|
|
63
|
+
})),
|
|
64
|
+
});
|
|
65
|
+
}, responseDelay);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const Template = ({
|
|
71
|
+
label,
|
|
72
|
+
placeholder,
|
|
73
|
+
size,
|
|
74
|
+
bordered,
|
|
75
|
+
direction,
|
|
76
|
+
required,
|
|
77
|
+
disabled,
|
|
78
|
+
readonly,
|
|
79
|
+
'default-value': defaultValue,
|
|
80
|
+
'full-width': fullWidth,
|
|
81
|
+
'label-type': labelType,
|
|
82
|
+
'data-errormessage-value-missing': customErrorMessage,
|
|
83
|
+
'allow-custom-value': allowCustomValue,
|
|
84
|
+
'min-search-length': minSearchLength,
|
|
85
|
+
errorMsgIcon,
|
|
86
|
+
}) => {
|
|
87
|
+
const overrideFetchResultsScript = `
|
|
88
|
+
<script>
|
|
89
|
+
document.querySelector("descope-autocomplete-field").fetchResults = ${mockFetchResults}
|
|
90
|
+
</script>`;
|
|
91
|
+
|
|
92
|
+
return withForm(`
|
|
93
|
+
<descope-autocomplete-field
|
|
94
|
+
size="${size}"
|
|
95
|
+
bordered="${bordered}"
|
|
96
|
+
label="${label || ''}"
|
|
97
|
+
label-type="${labelType || ''}"
|
|
98
|
+
disabled="${disabled || false}"
|
|
99
|
+
placeholder="${placeholder || ''}"
|
|
100
|
+
readonly="${readonly || false}"
|
|
101
|
+
required="${required || false}"
|
|
102
|
+
full-width="${fullWidth || false}"
|
|
103
|
+
st-host-direction="${direction ?? ''}"
|
|
104
|
+
default-value="${defaultValue || ''}"
|
|
105
|
+
data-errormessage-value-missing="${customErrorMessage || ''}"
|
|
106
|
+
allow-custom-value="${allowCustomValue || false}"
|
|
107
|
+
min-search-length="${minSearchLength || ''}"
|
|
108
|
+
${errorMsgIcon ? errorMessageIconAttrs : ''}
|
|
109
|
+
></descope-autocomplete-field>
|
|
110
|
+
${overrideFetchResultsScript}
|
|
111
|
+
`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
component: componentName,
|
|
116
|
+
title: 'descope-autocomplete-field',
|
|
117
|
+
argTypes: {
|
|
118
|
+
...labelControl,
|
|
119
|
+
...placeholderControl,
|
|
120
|
+
...inputLabelTypeControl,
|
|
121
|
+
...sizeControl,
|
|
122
|
+
...fullWidthControl,
|
|
123
|
+
...disabledControl,
|
|
124
|
+
...readOnlyControl,
|
|
125
|
+
...requiredControl,
|
|
126
|
+
...borderedControl,
|
|
127
|
+
...errorMissingValueControl,
|
|
128
|
+
...directionControl,
|
|
129
|
+
'min-search-length': {
|
|
130
|
+
name: 'Min Search Length',
|
|
131
|
+
control: { type: 'number' },
|
|
132
|
+
},
|
|
133
|
+
'allow-custom-value': {
|
|
134
|
+
name: 'Allow Custom Value',
|
|
135
|
+
control: { type: 'boolean' },
|
|
136
|
+
},
|
|
137
|
+
...errorMessageIconControl,
|
|
138
|
+
'default-value': {
|
|
139
|
+
name: 'Default Value',
|
|
140
|
+
control: { type: 'text' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const Default = Template.bind({});
|
|
146
|
+
|
|
147
|
+
Default.args = {
|
|
148
|
+
bordered: true,
|
|
149
|
+
size: 'md',
|
|
150
|
+
'allow-custom-value': false,
|
|
151
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Locator } from '@playwright/test';
|
|
2
|
+
import { createComponentTestDriver, createInputTestDriver } from 'test-drivers';
|
|
3
|
+
|
|
4
|
+
const createAutocompleteTestDriver = (locator: Locator) => {
|
|
5
|
+
const clearButton = locator.locator('#clearButton');
|
|
6
|
+
const dropDown = locator.getByRole('listbox');
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
...createComponentTestDriver(locator),
|
|
10
|
+
...createInputTestDriver(locator),
|
|
11
|
+
|
|
12
|
+
async focus() {
|
|
13
|
+
await locator.evaluate((node: HTMLInputElement) => {
|
|
14
|
+
node.focus();
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
selectItem(text: string) {
|
|
19
|
+
return dropDown.getByText(text).first().click();
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async openDropdown() {
|
|
23
|
+
await locator.page().keyboard.press('ArrowDown');
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async clearSelection() {
|
|
27
|
+
await clearButton.click();
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async replaceSearchValue(value: string) {
|
|
31
|
+
await locator
|
|
32
|
+
.page()
|
|
33
|
+
.keyboard.press(
|
|
34
|
+
`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`,
|
|
35
|
+
);
|
|
36
|
+
await locator.page().keyboard.press('Backspace');
|
|
37
|
+
await locator.page().keyboard.type(value);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async getItems() {
|
|
41
|
+
const items = await dropDown.getByRole('option').all();
|
|
42
|
+
return Promise.all(items.map((item) => item.textContent()));
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
dropDown,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default createAutocompleteTestDriver;
|