@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 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,5 @@
1
+ import AutocompleteFieldInternal, {
2
+ componentName,
3
+ } from './AutocompleteFieldInternal';
4
+
5
+ customElements.define(componentName, 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;