@smileid/web-components 2.0.0 → 2.0.2

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.
Files changed (61) hide show
  1. package/package.json +58 -58
  2. package/src/components/README.md +14 -14
  3. package/src/components/attribution/PoweredBySmileId.js +42 -42
  4. package/src/components/camera-permission/CameraPermission.js +140 -140
  5. package/src/components/camera-permission/CameraPermission.stories.js +27 -27
  6. package/src/components/combobox/src/Combobox.js +589 -589
  7. package/src/components/combobox/src/index.js +1 -1
  8. package/src/components/document/src/DocumentCaptureScreens.js +409 -409
  9. package/src/components/document/src/DocumentCaptureScreens.stories.js +57 -57
  10. package/src/components/document/src/README.md +111 -111
  11. package/src/components/document/src/document-capture/DocumentCapture.js +760 -760
  12. package/src/components/document/src/document-capture/DocumentCapture.stories.js +78 -78
  13. package/src/components/document/src/document-capture/README.md +90 -90
  14. package/src/components/document/src/document-capture/index.js +3 -3
  15. package/src/components/document/src/document-capture-instructions/DocumentCaptureInstructions.js +499 -499
  16. package/src/components/document/src/document-capture-instructions/DocumentCaptureInstructions.stories.js +24 -24
  17. package/src/components/document/src/document-capture-instructions/README.md +56 -56
  18. package/src/components/document/src/document-capture-instructions/index.js +3 -3
  19. package/src/components/document/src/document-capture-review/DocumentCaptureReview.js +362 -362
  20. package/src/components/document/src/document-capture-review/DocumentCaptureReview.stories.js +24 -24
  21. package/src/components/document/src/document-capture-review/README.md +79 -79
  22. package/src/components/document/src/document-capture-review/index.js +3 -3
  23. package/src/components/document/src/index.js +3 -3
  24. package/src/components/end-user-consent/src/EndUserConsent.js +795 -795
  25. package/src/components/end-user-consent/src/EndUserConsent.stories.js +29 -29
  26. package/src/components/end-user-consent/src/index.js +4 -4
  27. package/src/components/navigation/src/Navigation.js +171 -171
  28. package/src/components/navigation/src/Navigation.stories.js +24 -24
  29. package/src/components/navigation/src/index.js +3 -3
  30. package/src/components/selfie/README.md +225 -225
  31. package/src/components/selfie/src/SelfieCaptureScreens.js +282 -282
  32. package/src/components/selfie/src/SelfieCaptureScreens.stories.js +29 -29
  33. package/src/components/selfie/src/index.js +5 -5
  34. package/src/components/selfie/src/selfie-capture/SelfieCapture.js +1041 -1010
  35. package/src/components/selfie/src/selfie-capture/SelfieCapture.stories.js +36 -36
  36. package/src/components/selfie/src/selfie-capture/index.js +3 -3
  37. package/src/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.js +657 -648
  38. package/src/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.stories.js +23 -23
  39. package/src/components/selfie/src/selfie-capture-instructions/index.js +3 -3
  40. package/src/components/selfie/src/selfie-capture-review/SelfieCaptureReview.js +347 -347
  41. package/src/components/selfie/src/selfie-capture-review/SelfieCaptureReview.stories.js +24 -24
  42. package/src/components/selfie/src/selfie-capture-review/index.js +3 -3
  43. package/src/components/signature-pad/package-lock.json +3009 -3009
  44. package/src/components/signature-pad/package.json +30 -30
  45. package/src/components/signature-pad/src/SignaturePad.js +484 -484
  46. package/src/components/signature-pad/src/SignaturePad.stories.js +32 -32
  47. package/src/components/signature-pad/src/index.js +3 -3
  48. package/src/components/smart-camera-web/src/README.md +207 -207
  49. package/src/components/smart-camera-web/src/SmartCameraWeb.js +299 -299
  50. package/src/components/smart-camera-web/src/SmartCameraWeb.stories.js +57 -57
  51. package/src/components/totp-consent/src/TotpConsent.js +949 -949
  52. package/src/components/totp-consent/src/index.js +4 -4
  53. package/src/domain/camera/src/README.md +38 -38
  54. package/src/domain/camera/src/SmartCamera.js +109 -109
  55. package/src/domain/constants/src/Constants.js +27 -27
  56. package/src/domain/file-upload/README.md +35 -35
  57. package/src/domain/file-upload/src/SmartFileUpload.js +65 -65
  58. package/src/index.js +5 -5
  59. package/src/styles/README.md +3 -3
  60. package/src/styles/src/styles.js +359 -359
  61. package/src/styles/src/typography.js +52 -52
@@ -1,589 +1,589 @@
1
- function generateId(prefix) {
2
- const id = [...Array(30)].map(() => Math.random().toString(36)[3]).join('');
3
- return `${prefix}-${id}`;
4
- }
5
-
6
- // check if element is visible in browser view port
7
- function isElementInView(element) {
8
- const bounding = element.getBoundingClientRect();
9
-
10
- return (
11
- bounding.top >= 0 &&
12
- bounding.left >= 0 &&
13
- bounding.bottom <=
14
- (window.innerHeight || document.documentElement.clientHeight) &&
15
- bounding.right <=
16
- (window.innerWidth || document.documentElement.clientWidth)
17
- );
18
- }
19
-
20
- // check if an element is currently scrollable
21
- function isScrollable(element) {
22
- return element && element.clientHeight < element.scrollHeight;
23
- }
24
-
25
- // ensure a given child element is within the parent's visible scroll area
26
- // if the child is not visible, scroll the parent
27
- function maintainScrollVisibility(activeElement, scrollParent) {
28
- const { offsetHeight, offsetTop } = activeElement;
29
- const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
30
-
31
- const isAbove = offsetTop < scrollTop;
32
- const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight;
33
-
34
- if (isAbove) {
35
- scrollParent.scrollTo(0, offsetTop);
36
- } else if (isBelow) {
37
- scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
38
- }
39
- }
40
-
41
- class ComboboxRoot extends HTMLElement {
42
- constructor() {
43
- super();
44
-
45
- this.handleRoaming = this.handleRoaming.bind(this);
46
- }
47
-
48
- connectedCallback() {
49
- this.trigger = this.querySelector('smileid-combobox-trigger');
50
-
51
- document.addEventListener('click', this.handleRoaming);
52
- this.addEventListener('focusout', this.handleRoaming);
53
- this.addEventListener('blur', this.handleRoaming);
54
- }
55
-
56
- disconnectedCallback() {
57
- document.removeEventListener('click', this.handleRoaming);
58
- this.removeEventListener('focusout', this.handleRoaming);
59
- this.removeEventListener('blur', this.handleRoaming);
60
- }
61
-
62
- handleRoaming(event) {
63
- const target = event.relatedTarget || event.target;
64
- if (this.contains(target)) {
65
- return;
66
- }
67
-
68
- if (this.trigger.getAttribute('expanded') === 'true') {
69
- this.trigger.setAttribute('expanded', 'false');
70
- }
71
- }
72
- }
73
-
74
- class ComboboxTrigger extends HTMLElement {
75
- constructor() {
76
- super();
77
-
78
- this.handleKeyUp = this.handleKeyUp.bind(this);
79
- this.handleKeyDown = this.handleKeyDown.bind(this);
80
- this.handleSelection = this.handleSelection.bind(this);
81
-
82
- this.toggleExpansionState = this.toggleExpansionState.bind(this);
83
- }
84
-
85
- get type() {
86
- return this.getAttribute('type') || 'text';
87
- }
88
-
89
- get label() {
90
- return this.getAttribute('label') || '';
91
- }
92
-
93
- get value() {
94
- return this.getAttribute('value') || '';
95
- }
96
-
97
- get disabled() {
98
- return this.hasAttribute('disabled');
99
- }
100
-
101
- connectedCallback() {
102
- if (!this.label) {
103
- throw new Error('<combobox-trigger>: a label attribute is required');
104
- }
105
-
106
- this.innerHTML = `${
107
- this.type === 'text'
108
- ? `
109
- <div>
110
- <input ${this.value ? `value="${this.value}" ` : ''}${
111
- this.disabled ? ' disabled ' : ''
112
- }type="text" placeholder="${this.label}" />
113
- <button ${this.disabled ? 'disabled ' : ''}tabindex='-1' type='button'>
114
- <span class="visually-hidden">Toggle</span>
115
- </button>
116
- </div>
117
- `
118
- : `<button ${this.disabled ? 'disabled ' : ''}type="button">${
119
- this.value || this.label
120
- }</button>`
121
- }`;
122
-
123
- this.setAttribute('expanded', false);
124
-
125
- this.inputTrigger = this.querySelector('input');
126
- this.buttonTrigger = this.querySelector('button');
127
-
128
- this.buttonTrigger.setAttribute('aria-expanded', false);
129
- this.buttonTrigger.setAttribute('role', 'combobox');
130
-
131
- this.buttonTrigger.addEventListener('keydown', this.handleKeyDown);
132
- this.buttonTrigger.addEventListener('click', this.toggleExpansionState);
133
-
134
- if (this.inputTrigger) {
135
- this.inputTrigger.setAttribute('aria-expanded', false);
136
- this.inputTrigger.setAttribute('role', 'combobox');
137
-
138
- this.inputTrigger.addEventListener('keydown', this.handleKeyDown);
139
- this.inputTrigger.addEventListener('keyup', this.handleKeyUp);
140
- this.inputTrigger.addEventListener('change', (e) => e.stopPropagation());
141
- }
142
-
143
- this.listbox = this.parentElement.querySelector('smileid-combobox-listbox');
144
-
145
- this.options = Array.from(
146
- this.parentElement.querySelectorAll('smileid-combobox-option'),
147
- );
148
- this.options.forEach((node) => {
149
- node.addEventListener('combobox.option.select', this.handleSelection);
150
- });
151
- }
152
-
153
- disconnectedCallback() {
154
- this.buttonTrigger.removeEventListener('keydown', this.handleKeyDown);
155
- this.buttonTrigger.removeEventListener('click', this.toggleExpansionState);
156
-
157
- if (this.inputTrigger) {
158
- this.inputTrigger.removeEventListener('keydown', this.handleKeyDown);
159
- this.inputTrigger.removeEventListener('keyup', this.handleKeyUp);
160
- this.inputTrigger.removeEventListener('change', (e) =>
161
- e.stopPropagation(),
162
- );
163
- }
164
-
165
- if (this.options) {
166
- this.options.forEach((node) => {
167
- node.removeEventListener(
168
- 'combobox.option.select',
169
- this.handleSelection,
170
- );
171
- });
172
- }
173
- }
174
-
175
- handleKeyDown(event) {
176
- if (event.ctrlKey || event.shiftKey) {
177
- return;
178
- }
179
-
180
- const { key } = event;
181
-
182
- switch (key) {
183
- case 'Enter':
184
- case 'Space':
185
- case ' ':
186
- if (this.getAttribute('expanded') === 'true') {
187
- if (this.inputTrigger && (key === 'Space' || key === ' ')) {
188
- this.resetListbox();
189
- } else {
190
- event.preventDefault();
191
- const selectedOption = this.buttonTrigger.getAttribute(
192
- 'aria-activedescendant',
193
- );
194
- if (selectedOption) {
195
- document.getElementById(selectedOption).click();
196
- }
197
- }
198
- } else {
199
- event.preventDefault();
200
- this.toggleExpansionState();
201
- }
202
- break;
203
- case 'Esc':
204
- case 'Escape':
205
- event.preventDefault();
206
- if (this.getAttribute('expanded') === 'true') {
207
- this.toggleExpansionState();
208
- }
209
- break;
210
- case 'Down':
211
- case 'ArrowDown':
212
- event.preventDefault();
213
- if (this.getAttribute('expanded') !== 'true') {
214
- this.toggleExpansionState();
215
- this.focusListbox('First');
216
- } else {
217
- this.focusListbox('Down');
218
- }
219
- break;
220
- case 'Up':
221
- case 'ArrowUp':
222
- event.preventDefault();
223
- if (this.getAttribute('expanded') !== 'true') {
224
- this.toggleExpansionState();
225
- this.focusListbox('Last');
226
- } else {
227
- this.focusListbox('Up');
228
- }
229
- break;
230
- case 'Left':
231
- case 'ArrowLeft':
232
- case 'Right':
233
- case 'ArrowRight':
234
- case 'Home':
235
- case 'End':
236
- this.resetListbox();
237
- break;
238
- case 'Tab':
239
- break;
240
- default:
241
- break;
242
- }
243
- }
244
-
245
- handleKeyUp(event) {
246
- const { key } = event;
247
-
248
- const isPrintableCharacter = (str) => str.length === 1 && str.match(/\S| /);
249
-
250
- if (event.key === 'Escape' || event.key === 'Esc') {
251
- event.preventDefault();
252
- if (this.getAttribute('expanded') === 'true') {
253
- this.toggleExpansionState();
254
- } else if (this.inputTrigger) {
255
- this.inputTrigger.value = '';
256
-
257
- this.listbox.dispatchEvent(
258
- new CustomEvent('combobox.listbox.filter', { detail: '' }),
259
- );
260
- }
261
- }
262
-
263
- if (isPrintableCharacter(key) || key === 'Backspace') {
264
- this.resetListbox();
265
- this.filterListbox(event.target.value);
266
- }
267
- }
268
-
269
- toggleExpansionState() {
270
- const listboxIsExpanded = this.getAttribute('expanded') === 'true';
271
- this.setAttribute('expanded', !listboxIsExpanded);
272
- this.buttonTrigger.setAttribute('aria-expanded', !listboxIsExpanded);
273
- if (this.inputTrigger) {
274
- this.inputTrigger.setAttribute('aria-expanded', !listboxIsExpanded);
275
- }
276
- }
277
-
278
- handleSelection(event) {
279
- if (this.inputTrigger) {
280
- this.inputTrigger.value = event.detail.label;
281
- } else {
282
- this.buttonTrigger.textContent = event.detail.label;
283
- }
284
-
285
- this.toggleExpansionState();
286
- this.parentElement.dispatchEvent(
287
- new CustomEvent('combobox.change', {
288
- detail: {
289
- value: event.detail.value,
290
- },
291
- }),
292
- );
293
- }
294
-
295
- filterListbox(value) {
296
- if (this.getAttribute('expanded') !== 'true') {
297
- this.toggleExpansionState();
298
- }
299
-
300
- this.listbox.dispatchEvent(
301
- new CustomEvent('combobox.listbox.filter', { detail: value }),
302
- );
303
- }
304
-
305
- focusListbox(direction) {
306
- this.resetListbox();
307
- this.listbox.dispatchEvent(
308
- new CustomEvent('combobox.listbox.focus', {
309
- detail: {
310
- direction,
311
- },
312
- }),
313
- );
314
- }
315
-
316
- resetListbox() {
317
- this.listbox.dispatchEvent(new CustomEvent('combobox.listbox.reset'));
318
- }
319
- }
320
-
321
- class ComboboxListbox extends HTMLElement {
322
- constructor() {
323
- super();
324
-
325
- this.handleFilter = this.handleFilter.bind(this);
326
- this.handleFocus = this.handleFocus.bind(this);
327
- this.handleReset = this.handleReset.bind(this);
328
-
329
- this.handleOptionSelection = this.handleOptionSelection.bind(this);
330
- }
331
-
332
- get emptyLabel() {
333
- return this.getAttribute('empty-label');
334
- }
335
-
336
- get emptyState() {
337
- return `
338
- <p id='empty-state' style="text-align: center;">
339
- ${this.emptyLabel || 'No items'}
340
- </p>
341
- `;
342
- }
343
-
344
- connectedCallback() {
345
- this.setAttribute('role', 'listbox');
346
- this.setAttribute('id', generateId('listbox'));
347
-
348
- this.addEventListener('combobox.listbox.filter', this.handleFilter);
349
- this.addEventListener('combobox.listbox.focus', this.handleFocus);
350
- this.addEventListener('combobox.listbox.reset', this.handleReset);
351
-
352
- this.triggers = Array.from(
353
- this.parentElement.querySelectorAll(
354
- 'smileid-combobox-trigger input, smileid-combobox-trigger button',
355
- ),
356
- );
357
- this.triggers.forEach((node) =>
358
- node.setAttribute('aria-controls', this.getAttribute('id')),
359
- );
360
-
361
- this.optionNodes = Array.from(
362
- this.querySelectorAll('smileid-combobox-option'),
363
- );
364
- this.selectedNode =
365
- this.optionNodes.find(
366
- (node) =>
367
- !node.hasAttribute('hidden') && node.hasAttribute('aria-selected'),
368
- ) || this.optionNodes.filter((node) => !node.hasAttribute('hidden'))[0];
369
- this.selectedNode.setAttribute('tabindex', '0');
370
-
371
- this.optionNodes.forEach((node) => {
372
- node.addEventListener(
373
- 'combobox.option.select',
374
- this.handleOptionSelection,
375
- );
376
- });
377
-
378
- if (this.optionNodes.length === 0) {
379
- this.innerHTML = this.emptyState;
380
- }
381
- }
382
-
383
- disconnectedCallback() {
384
- this.removeEventListener('combobox.listbox.filter', this.handleFilter);
385
- this.removeEventListener('combobox.listbox.focus', this.handleFocus);
386
- this.removeEventListener('combobox.listbox.reset', this.handleReset);
387
- this.optionNodes.forEach((node) => {
388
- node.removeEventListener(
389
- 'combobox.option.select',
390
- this.handleOptionSelection,
391
- );
392
- });
393
- }
394
-
395
- static get observedAttributes() {
396
- return ['search-term'];
397
- }
398
-
399
- attributeChangedCallback(name, oldValue, newValue) {
400
- switch (name) {
401
- case 'search-term':
402
- if (oldValue && !newValue) {
403
- this.optionNodes.forEach((node) => {
404
- node.removeAttribute('hidden');
405
- });
406
- } else if (newValue) {
407
- this.filterNodes(newValue);
408
- }
409
- break;
410
- default:
411
- break;
412
- }
413
- }
414
-
415
- filterNodes(searchTerm) {
416
- this.optionNodes.forEach((node) => {
417
- const value = node.getAttribute('value').toLowerCase();
418
- const label = node.getAttribute('label').toLowerCase();
419
-
420
- const containsSearchTerm =
421
- value.includes(searchTerm.toLowerCase()) ||
422
- label.includes(searchTerm.toLowerCase());
423
-
424
- if (containsSearchTerm) {
425
- node.removeAttribute('hidden');
426
- } else {
427
- node.setAttribute('hidden', true);
428
- }
429
- });
430
-
431
- const optionsVisible = this.optionNodes.find(
432
- (node) => !node.hasAttribute('hidden'),
433
- );
434
- const emptyState = this.querySelector('#empty-state');
435
-
436
- if (!optionsVisible && !emptyState) {
437
- this.insertAdjacentHTML('afterbegin', this.emptyState);
438
- } else if (optionsVisible && emptyState) {
439
- this.removeChild(emptyState);
440
- }
441
- }
442
-
443
- handleFilter(event) {
444
- const searchTerm = event.detail;
445
- this.setAttribute('search-term', searchTerm);
446
- }
447
-
448
- handleFocus(event) {
449
- this.setSelected(event.detail.direction);
450
- }
451
-
452
- handleReset() {
453
- this.optionNodes.forEach((node) => node.setAttribute('tabindex', '-1'));
454
- }
455
-
456
- handleOptionSelection(event) {
457
- const inputTrigger = this.triggers.filter(
458
- (node) => node.tagName === 'INPUT',
459
- )[0];
460
-
461
- if (inputTrigger) {
462
- this.setAttribute('search-term', event.detail.label);
463
- }
464
- }
465
-
466
- setSelected(direction) {
467
- const visibleOptions = this.optionNodes.filter(
468
- (node) => !node.hasAttribute('hidden'),
469
- );
470
- this.selectedNode.setAttribute('tabindex', '0');
471
- const currentIndex = visibleOptions.findIndex(
472
- (node) => node === this.selectedNode,
473
- );
474
- const lastIndex = visibleOptions.length - 1;
475
-
476
- let nextIndex;
477
- switch (direction) {
478
- case 'First':
479
- nextIndex = 0;
480
- break;
481
- case 'Last':
482
- nextIndex = lastIndex;
483
- break;
484
- case 'Up':
485
- if (currentIndex === 0) {
486
- nextIndex = lastIndex;
487
- } else {
488
- nextIndex = currentIndex - 1;
489
- }
490
- break;
491
- default:
492
- if (currentIndex === lastIndex) {
493
- nextIndex = 0;
494
- } else {
495
- nextIndex = currentIndex + 1;
496
- }
497
- break;
498
- }
499
-
500
- if (currentIndex !== nextIndex) {
501
- this.swapSelected(this.selectedNode, visibleOptions[nextIndex]);
502
- }
503
- }
504
-
505
- swapSelected(currentNode, newNode) {
506
- currentNode.setAttribute('tabindex', '-1');
507
- newNode.setAttribute('tabindex', '0');
508
-
509
- this.selectedNode = newNode;
510
-
511
- // ACTION: ensure the new option is in view
512
- if (isScrollable(this)) {
513
- maintainScrollVisibility(this.selectedNode, this);
514
- }
515
-
516
- // ACTION: scroll into view if node is not visible
517
- if (!isElementInView(newNode)) {
518
- newNode.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
519
- }
520
-
521
- this.triggers.forEach((node) =>
522
- node.setAttribute('aria-activedescendant', newNode.id),
523
- );
524
- }
525
- }
526
-
527
- class ComboboxOption extends HTMLElement {
528
- connectedCallback() {
529
- this.setAttribute('role', 'option');
530
- this.setAttribute('tabindex', '-1');
531
- this.setAttribute('id', generateId('option'));
532
-
533
- this.options = Array.from(
534
- this.parentElement.querySelectorAll('smileid-combobox-option'),
535
- );
536
- this.addEventListener('click', this.select);
537
- }
538
-
539
- disconnectedCallback() {
540
- this.removeEventListener('click', this.select);
541
- }
542
-
543
- get value() {
544
- return this.getAttribute('value');
545
- }
546
-
547
- get label() {
548
- return this.getAttribute('label');
549
- }
550
-
551
- select() {
552
- const selectedOption = this.options.find((node) =>
553
- node.getAttribute('aria-selected'),
554
- );
555
-
556
- if (selectedOption) {
557
- selectedOption.removeAttribute('aria-selected');
558
- }
559
-
560
- this.setAttribute('aria-selected', true);
561
-
562
- this.dispatchEvent(
563
- new CustomEvent('combobox.option.select', {
564
- detail: {
565
- id: this.getAttribute('id'),
566
- label: this.label,
567
- value: this.value,
568
- },
569
- }),
570
- );
571
- }
572
- }
573
-
574
- const Root = ComboboxRoot;
575
- const Trigger = ComboboxTrigger;
576
- const List = ComboboxListbox;
577
- const Option = ComboboxOption;
578
-
579
- if (
580
- 'customElements' in window &&
581
- !window.customElements.get('smileid-combobox')
582
- ) {
583
- window.customElements.define('smileid-combobox', Root);
584
- window.customElements.define('smileid-combobox-trigger', Trigger);
585
- window.customElements.define('smileid-combobox-listbox', List);
586
- window.customElements.define('smileid-combobox-option', Option);
587
- }
588
-
589
- export { Root, Trigger, List, Option };
1
+ function generateId(prefix) {
2
+ const id = [...Array(30)].map(() => Math.random().toString(36)[3]).join('');
3
+ return `${prefix}-${id}`;
4
+ }
5
+
6
+ // check if element is visible in browser view port
7
+ function isElementInView(element) {
8
+ const bounding = element.getBoundingClientRect();
9
+
10
+ return (
11
+ bounding.top >= 0 &&
12
+ bounding.left >= 0 &&
13
+ bounding.bottom <=
14
+ (window.innerHeight || document.documentElement.clientHeight) &&
15
+ bounding.right <=
16
+ (window.innerWidth || document.documentElement.clientWidth)
17
+ );
18
+ }
19
+
20
+ // check if an element is currently scrollable
21
+ function isScrollable(element) {
22
+ return element && element.clientHeight < element.scrollHeight;
23
+ }
24
+
25
+ // ensure a given child element is within the parent's visible scroll area
26
+ // if the child is not visible, scroll the parent
27
+ function maintainScrollVisibility(activeElement, scrollParent) {
28
+ const { offsetHeight, offsetTop } = activeElement;
29
+ const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
30
+
31
+ const isAbove = offsetTop < scrollTop;
32
+ const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight;
33
+
34
+ if (isAbove) {
35
+ scrollParent.scrollTo(0, offsetTop);
36
+ } else if (isBelow) {
37
+ scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
38
+ }
39
+ }
40
+
41
+ class ComboboxRoot extends HTMLElement {
42
+ constructor() {
43
+ super();
44
+
45
+ this.handleRoaming = this.handleRoaming.bind(this);
46
+ }
47
+
48
+ connectedCallback() {
49
+ this.trigger = this.querySelector('smileid-combobox-trigger');
50
+
51
+ document.addEventListener('click', this.handleRoaming);
52
+ this.addEventListener('focusout', this.handleRoaming);
53
+ this.addEventListener('blur', this.handleRoaming);
54
+ }
55
+
56
+ disconnectedCallback() {
57
+ document.removeEventListener('click', this.handleRoaming);
58
+ this.removeEventListener('focusout', this.handleRoaming);
59
+ this.removeEventListener('blur', this.handleRoaming);
60
+ }
61
+
62
+ handleRoaming(event) {
63
+ const target = event.relatedTarget || event.target;
64
+ if (this.contains(target)) {
65
+ return;
66
+ }
67
+
68
+ if (this.trigger.getAttribute('expanded') === 'true') {
69
+ this.trigger.setAttribute('expanded', 'false');
70
+ }
71
+ }
72
+ }
73
+
74
+ class ComboboxTrigger extends HTMLElement {
75
+ constructor() {
76
+ super();
77
+
78
+ this.handleKeyUp = this.handleKeyUp.bind(this);
79
+ this.handleKeyDown = this.handleKeyDown.bind(this);
80
+ this.handleSelection = this.handleSelection.bind(this);
81
+
82
+ this.toggleExpansionState = this.toggleExpansionState.bind(this);
83
+ }
84
+
85
+ get type() {
86
+ return this.getAttribute('type') || 'text';
87
+ }
88
+
89
+ get label() {
90
+ return this.getAttribute('label') || '';
91
+ }
92
+
93
+ get value() {
94
+ return this.getAttribute('value') || '';
95
+ }
96
+
97
+ get disabled() {
98
+ return this.hasAttribute('disabled');
99
+ }
100
+
101
+ connectedCallback() {
102
+ if (!this.label) {
103
+ throw new Error('<combobox-trigger>: a label attribute is required');
104
+ }
105
+
106
+ this.innerHTML = `${
107
+ this.type === 'text'
108
+ ? `
109
+ <div>
110
+ <input ${this.value ? `value="${this.value}" ` : ''}${
111
+ this.disabled ? ' disabled ' : ''
112
+ }type="text" placeholder="${this.label}" />
113
+ <button ${this.disabled ? 'disabled ' : ''}tabindex='-1' type='button'>
114
+ <span class="visually-hidden">Toggle</span>
115
+ </button>
116
+ </div>
117
+ `
118
+ : `<button ${this.disabled ? 'disabled ' : ''}type="button">${
119
+ this.value || this.label
120
+ }</button>`
121
+ }`;
122
+
123
+ this.setAttribute('expanded', false);
124
+
125
+ this.inputTrigger = this.querySelector('input');
126
+ this.buttonTrigger = this.querySelector('button');
127
+
128
+ this.buttonTrigger.setAttribute('aria-expanded', false);
129
+ this.buttonTrigger.setAttribute('role', 'combobox');
130
+
131
+ this.buttonTrigger.addEventListener('keydown', this.handleKeyDown);
132
+ this.buttonTrigger.addEventListener('click', this.toggleExpansionState);
133
+
134
+ if (this.inputTrigger) {
135
+ this.inputTrigger.setAttribute('aria-expanded', false);
136
+ this.inputTrigger.setAttribute('role', 'combobox');
137
+
138
+ this.inputTrigger.addEventListener('keydown', this.handleKeyDown);
139
+ this.inputTrigger.addEventListener('keyup', this.handleKeyUp);
140
+ this.inputTrigger.addEventListener('change', (e) => e.stopPropagation());
141
+ }
142
+
143
+ this.listbox = this.parentElement.querySelector('smileid-combobox-listbox');
144
+
145
+ this.options = Array.from(
146
+ this.parentElement.querySelectorAll('smileid-combobox-option'),
147
+ );
148
+ this.options.forEach((node) => {
149
+ node.addEventListener('combobox.option.select', this.handleSelection);
150
+ });
151
+ }
152
+
153
+ disconnectedCallback() {
154
+ this.buttonTrigger.removeEventListener('keydown', this.handleKeyDown);
155
+ this.buttonTrigger.removeEventListener('click', this.toggleExpansionState);
156
+
157
+ if (this.inputTrigger) {
158
+ this.inputTrigger.removeEventListener('keydown', this.handleKeyDown);
159
+ this.inputTrigger.removeEventListener('keyup', this.handleKeyUp);
160
+ this.inputTrigger.removeEventListener('change', (e) =>
161
+ e.stopPropagation(),
162
+ );
163
+ }
164
+
165
+ if (this.options) {
166
+ this.options.forEach((node) => {
167
+ node.removeEventListener(
168
+ 'combobox.option.select',
169
+ this.handleSelection,
170
+ );
171
+ });
172
+ }
173
+ }
174
+
175
+ handleKeyDown(event) {
176
+ if (event.ctrlKey || event.shiftKey) {
177
+ return;
178
+ }
179
+
180
+ const { key } = event;
181
+
182
+ switch (key) {
183
+ case 'Enter':
184
+ case 'Space':
185
+ case ' ':
186
+ if (this.getAttribute('expanded') === 'true') {
187
+ if (this.inputTrigger && (key === 'Space' || key === ' ')) {
188
+ this.resetListbox();
189
+ } else {
190
+ event.preventDefault();
191
+ const selectedOption = this.buttonTrigger.getAttribute(
192
+ 'aria-activedescendant',
193
+ );
194
+ if (selectedOption) {
195
+ document.getElementById(selectedOption).click();
196
+ }
197
+ }
198
+ } else {
199
+ event.preventDefault();
200
+ this.toggleExpansionState();
201
+ }
202
+ break;
203
+ case 'Esc':
204
+ case 'Escape':
205
+ event.preventDefault();
206
+ if (this.getAttribute('expanded') === 'true') {
207
+ this.toggleExpansionState();
208
+ }
209
+ break;
210
+ case 'Down':
211
+ case 'ArrowDown':
212
+ event.preventDefault();
213
+ if (this.getAttribute('expanded') !== 'true') {
214
+ this.toggleExpansionState();
215
+ this.focusListbox('First');
216
+ } else {
217
+ this.focusListbox('Down');
218
+ }
219
+ break;
220
+ case 'Up':
221
+ case 'ArrowUp':
222
+ event.preventDefault();
223
+ if (this.getAttribute('expanded') !== 'true') {
224
+ this.toggleExpansionState();
225
+ this.focusListbox('Last');
226
+ } else {
227
+ this.focusListbox('Up');
228
+ }
229
+ break;
230
+ case 'Left':
231
+ case 'ArrowLeft':
232
+ case 'Right':
233
+ case 'ArrowRight':
234
+ case 'Home':
235
+ case 'End':
236
+ this.resetListbox();
237
+ break;
238
+ case 'Tab':
239
+ break;
240
+ default:
241
+ break;
242
+ }
243
+ }
244
+
245
+ handleKeyUp(event) {
246
+ const { key } = event;
247
+
248
+ const isPrintableCharacter = (str) => str.length === 1 && str.match(/\S| /);
249
+
250
+ if (event.key === 'Escape' || event.key === 'Esc') {
251
+ event.preventDefault();
252
+ if (this.getAttribute('expanded') === 'true') {
253
+ this.toggleExpansionState();
254
+ } else if (this.inputTrigger) {
255
+ this.inputTrigger.value = '';
256
+
257
+ this.listbox.dispatchEvent(
258
+ new CustomEvent('combobox.listbox.filter', { detail: '' }),
259
+ );
260
+ }
261
+ }
262
+
263
+ if (isPrintableCharacter(key) || key === 'Backspace') {
264
+ this.resetListbox();
265
+ this.filterListbox(event.target.value);
266
+ }
267
+ }
268
+
269
+ toggleExpansionState() {
270
+ const listboxIsExpanded = this.getAttribute('expanded') === 'true';
271
+ this.setAttribute('expanded', !listboxIsExpanded);
272
+ this.buttonTrigger.setAttribute('aria-expanded', !listboxIsExpanded);
273
+ if (this.inputTrigger) {
274
+ this.inputTrigger.setAttribute('aria-expanded', !listboxIsExpanded);
275
+ }
276
+ }
277
+
278
+ handleSelection(event) {
279
+ if (this.inputTrigger) {
280
+ this.inputTrigger.value = event.detail.label;
281
+ } else {
282
+ this.buttonTrigger.textContent = event.detail.label;
283
+ }
284
+
285
+ this.toggleExpansionState();
286
+ this.parentElement.dispatchEvent(
287
+ new CustomEvent('combobox.change', {
288
+ detail: {
289
+ value: event.detail.value,
290
+ },
291
+ }),
292
+ );
293
+ }
294
+
295
+ filterListbox(value) {
296
+ if (this.getAttribute('expanded') !== 'true') {
297
+ this.toggleExpansionState();
298
+ }
299
+
300
+ this.listbox.dispatchEvent(
301
+ new CustomEvent('combobox.listbox.filter', { detail: value }),
302
+ );
303
+ }
304
+
305
+ focusListbox(direction) {
306
+ this.resetListbox();
307
+ this.listbox.dispatchEvent(
308
+ new CustomEvent('combobox.listbox.focus', {
309
+ detail: {
310
+ direction,
311
+ },
312
+ }),
313
+ );
314
+ }
315
+
316
+ resetListbox() {
317
+ this.listbox.dispatchEvent(new CustomEvent('combobox.listbox.reset'));
318
+ }
319
+ }
320
+
321
+ class ComboboxListbox extends HTMLElement {
322
+ constructor() {
323
+ super();
324
+
325
+ this.handleFilter = this.handleFilter.bind(this);
326
+ this.handleFocus = this.handleFocus.bind(this);
327
+ this.handleReset = this.handleReset.bind(this);
328
+
329
+ this.handleOptionSelection = this.handleOptionSelection.bind(this);
330
+ }
331
+
332
+ get emptyLabel() {
333
+ return this.getAttribute('empty-label');
334
+ }
335
+
336
+ get emptyState() {
337
+ return `
338
+ <p id='empty-state' style="text-align: center;">
339
+ ${this.emptyLabel || 'No items'}
340
+ </p>
341
+ `;
342
+ }
343
+
344
+ connectedCallback() {
345
+ this.setAttribute('role', 'listbox');
346
+ this.setAttribute('id', generateId('listbox'));
347
+
348
+ this.addEventListener('combobox.listbox.filter', this.handleFilter);
349
+ this.addEventListener('combobox.listbox.focus', this.handleFocus);
350
+ this.addEventListener('combobox.listbox.reset', this.handleReset);
351
+
352
+ this.triggers = Array.from(
353
+ this.parentElement.querySelectorAll(
354
+ 'smileid-combobox-trigger input, smileid-combobox-trigger button',
355
+ ),
356
+ );
357
+ this.triggers.forEach((node) =>
358
+ node.setAttribute('aria-controls', this.getAttribute('id')),
359
+ );
360
+
361
+ this.optionNodes = Array.from(
362
+ this.querySelectorAll('smileid-combobox-option'),
363
+ );
364
+ this.selectedNode =
365
+ this.optionNodes.find(
366
+ (node) =>
367
+ !node.hasAttribute('hidden') && node.hasAttribute('aria-selected'),
368
+ ) || this.optionNodes.filter((node) => !node.hasAttribute('hidden'))[0];
369
+ this.selectedNode.setAttribute('tabindex', '0');
370
+
371
+ this.optionNodes.forEach((node) => {
372
+ node.addEventListener(
373
+ 'combobox.option.select',
374
+ this.handleOptionSelection,
375
+ );
376
+ });
377
+
378
+ if (this.optionNodes.length === 0) {
379
+ this.innerHTML = this.emptyState;
380
+ }
381
+ }
382
+
383
+ disconnectedCallback() {
384
+ this.removeEventListener('combobox.listbox.filter', this.handleFilter);
385
+ this.removeEventListener('combobox.listbox.focus', this.handleFocus);
386
+ this.removeEventListener('combobox.listbox.reset', this.handleReset);
387
+ this.optionNodes.forEach((node) => {
388
+ node.removeEventListener(
389
+ 'combobox.option.select',
390
+ this.handleOptionSelection,
391
+ );
392
+ });
393
+ }
394
+
395
+ static get observedAttributes() {
396
+ return ['search-term'];
397
+ }
398
+
399
+ attributeChangedCallback(name, oldValue, newValue) {
400
+ switch (name) {
401
+ case 'search-term':
402
+ if (oldValue && !newValue) {
403
+ this.optionNodes.forEach((node) => {
404
+ node.removeAttribute('hidden');
405
+ });
406
+ } else if (newValue) {
407
+ this.filterNodes(newValue);
408
+ }
409
+ break;
410
+ default:
411
+ break;
412
+ }
413
+ }
414
+
415
+ filterNodes(searchTerm) {
416
+ this.optionNodes.forEach((node) => {
417
+ const value = node.getAttribute('value').toLowerCase();
418
+ const label = node.getAttribute('label').toLowerCase();
419
+
420
+ const containsSearchTerm =
421
+ value.includes(searchTerm.toLowerCase()) ||
422
+ label.includes(searchTerm.toLowerCase());
423
+
424
+ if (containsSearchTerm) {
425
+ node.removeAttribute('hidden');
426
+ } else {
427
+ node.setAttribute('hidden', true);
428
+ }
429
+ });
430
+
431
+ const optionsVisible = this.optionNodes.find(
432
+ (node) => !node.hasAttribute('hidden'),
433
+ );
434
+ const emptyState = this.querySelector('#empty-state');
435
+
436
+ if (!optionsVisible && !emptyState) {
437
+ this.insertAdjacentHTML('afterbegin', this.emptyState);
438
+ } else if (optionsVisible && emptyState) {
439
+ this.removeChild(emptyState);
440
+ }
441
+ }
442
+
443
+ handleFilter(event) {
444
+ const searchTerm = event.detail;
445
+ this.setAttribute('search-term', searchTerm);
446
+ }
447
+
448
+ handleFocus(event) {
449
+ this.setSelected(event.detail.direction);
450
+ }
451
+
452
+ handleReset() {
453
+ this.optionNodes.forEach((node) => node.setAttribute('tabindex', '-1'));
454
+ }
455
+
456
+ handleOptionSelection(event) {
457
+ const inputTrigger = this.triggers.filter(
458
+ (node) => node.tagName === 'INPUT',
459
+ )[0];
460
+
461
+ if (inputTrigger) {
462
+ this.setAttribute('search-term', event.detail.label);
463
+ }
464
+ }
465
+
466
+ setSelected(direction) {
467
+ const visibleOptions = this.optionNodes.filter(
468
+ (node) => !node.hasAttribute('hidden'),
469
+ );
470
+ this.selectedNode.setAttribute('tabindex', '0');
471
+ const currentIndex = visibleOptions.findIndex(
472
+ (node) => node === this.selectedNode,
473
+ );
474
+ const lastIndex = visibleOptions.length - 1;
475
+
476
+ let nextIndex;
477
+ switch (direction) {
478
+ case 'First':
479
+ nextIndex = 0;
480
+ break;
481
+ case 'Last':
482
+ nextIndex = lastIndex;
483
+ break;
484
+ case 'Up':
485
+ if (currentIndex === 0) {
486
+ nextIndex = lastIndex;
487
+ } else {
488
+ nextIndex = currentIndex - 1;
489
+ }
490
+ break;
491
+ default:
492
+ if (currentIndex === lastIndex) {
493
+ nextIndex = 0;
494
+ } else {
495
+ nextIndex = currentIndex + 1;
496
+ }
497
+ break;
498
+ }
499
+
500
+ if (currentIndex !== nextIndex) {
501
+ this.swapSelected(this.selectedNode, visibleOptions[nextIndex]);
502
+ }
503
+ }
504
+
505
+ swapSelected(currentNode, newNode) {
506
+ currentNode.setAttribute('tabindex', '-1');
507
+ newNode.setAttribute('tabindex', '0');
508
+
509
+ this.selectedNode = newNode;
510
+
511
+ // ACTION: ensure the new option is in view
512
+ if (isScrollable(this)) {
513
+ maintainScrollVisibility(this.selectedNode, this);
514
+ }
515
+
516
+ // ACTION: scroll into view if node is not visible
517
+ if (!isElementInView(newNode)) {
518
+ newNode.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
519
+ }
520
+
521
+ this.triggers.forEach((node) =>
522
+ node.setAttribute('aria-activedescendant', newNode.id),
523
+ );
524
+ }
525
+ }
526
+
527
+ class ComboboxOption extends HTMLElement {
528
+ connectedCallback() {
529
+ this.setAttribute('role', 'option');
530
+ this.setAttribute('tabindex', '-1');
531
+ this.setAttribute('id', generateId('option'));
532
+
533
+ this.options = Array.from(
534
+ this.parentElement.querySelectorAll('smileid-combobox-option'),
535
+ );
536
+ this.addEventListener('click', this.select);
537
+ }
538
+
539
+ disconnectedCallback() {
540
+ this.removeEventListener('click', this.select);
541
+ }
542
+
543
+ get value() {
544
+ return this.getAttribute('value');
545
+ }
546
+
547
+ get label() {
548
+ return this.getAttribute('label');
549
+ }
550
+
551
+ select() {
552
+ const selectedOption = this.options.find((node) =>
553
+ node.getAttribute('aria-selected'),
554
+ );
555
+
556
+ if (selectedOption) {
557
+ selectedOption.removeAttribute('aria-selected');
558
+ }
559
+
560
+ this.setAttribute('aria-selected', true);
561
+
562
+ this.dispatchEvent(
563
+ new CustomEvent('combobox.option.select', {
564
+ detail: {
565
+ id: this.getAttribute('id'),
566
+ label: this.label,
567
+ value: this.value,
568
+ },
569
+ }),
570
+ );
571
+ }
572
+ }
573
+
574
+ const Root = ComboboxRoot;
575
+ const Trigger = ComboboxTrigger;
576
+ const List = ComboboxListbox;
577
+ const Option = ComboboxOption;
578
+
579
+ if (
580
+ 'customElements' in window &&
581
+ !window.customElements.get('smileid-combobox')
582
+ ) {
583
+ window.customElements.define('smileid-combobox', Root);
584
+ window.customElements.define('smileid-combobox-trigger', Trigger);
585
+ window.customElements.define('smileid-combobox-listbox', List);
586
+ window.customElements.define('smileid-combobox-option', Option);
587
+ }
588
+
589
+ export { Root, Trigger, List, Option };