inviton-powerduck 0.0.217 → 0.0.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ /* eslint-disable ts/no-this-alias */
2
+ /* eslint-disable no-useless-call */
3
+
4
+ import type { VNode } from 'vue';
5
+
6
+ import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item';
7
+ import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper';
8
+ import { Component, Prop, Watch } from 'vue-facing-decorator';
9
+ import PowerduckState from '../../app/powerduck-state';
10
+ import TsxComponent from '../../app/vuetsx';
11
+ import FormItemWrapper from '../form/form-item-wrapper';
12
+ import './css/smart-dropdown.scss';
13
+
14
+ // --- Resources ---
15
+ export class SmartDropdownResources {
16
+ static placeholderDefault = 'Vyberte...';
17
+ static doneButtonText = 'Hotovo';
18
+ static loadingTextShort = '...';
19
+ static loadingTextLong = 'Načítavam...';
20
+ static noResultsSearch = 'Nenašli sa žiadne výsledky.';
21
+ static noResultsDefault = 'Žiadne možnosti.';
22
+ static searchResultTitle = 'Výsledky vyhľadávania';
23
+ static selectedTitle = 'Vybrané';
24
+ static categoriesTitle = 'Kategórie';
25
+ static summarySuffix = ' vybrané';
26
+ static closeLabel = PowerduckState.getResourceValue('close');
27
+ }
28
+
29
+ // --- Interfaces ---
30
+ export interface SmartDropdownCategoryItem {
31
+ id: number | string;
32
+ text: string;
33
+ [key: string]: any;
34
+ }
35
+
36
+ export interface SmartDropdownSearchResultItem {
37
+ id: number | string;
38
+ text: string;
39
+ subtitle?: string;
40
+ imageUrl?: string;
41
+ [key: string]: any;
42
+ }
43
+
44
+ export type SmartDropdownItem = SmartDropdownCategoryItem | SmartDropdownSearchResultItem;
45
+
46
+ export interface SmartDropdownSection {
47
+ id: string;
48
+ title?: string;
49
+ items: SmartDropdownItem[];
50
+ }
51
+
52
+ interface SmartDropdownArgs extends FormItemWrapperArgs {
53
+ categories: SmartDropdownCategoryItem[];
54
+ customSections: SmartDropdownSection[];
55
+ searchData: (text: string) => Promise<SmartDropdownSearchResultItem[]>;
56
+ value: SmartDropdownItem[];
57
+ multiselect: boolean;
58
+ placeholder: string;
59
+ selectionDisplay: 'chips' | 'text';
60
+ buttonLayout: 'footer' | 'inline';
61
+ searchMode: 'dropdown' | 'input';
62
+ customTriggerScope?: 'mobile' | 'desktop' | 'all';
63
+ customSectionRender?: (item: SmartDropdownItem, selected: boolean) => VNode;
64
+ customTriggerRender?: () => VNode;
65
+ changed: (e: SmartDropdownItem[]) => void;
66
+ }
67
+
68
+ @Component
69
+ export default class SmartDropdown extends TsxComponent<SmartDropdownArgs> implements SmartDropdownArgs {
70
+ @Prop() label!: string;
71
+ @Prop() labelButtons!: DropdownButtonItemArgs[];
72
+ @Prop() subtitle!: string;
73
+ @Prop() cssClass!: string;
74
+ @Prop() mandatory!: boolean;
75
+ @Prop() disabled!: boolean;
76
+ @Prop() wrap!: boolean;
77
+ @Prop() hint: string;
78
+ @Prop() appendIcon: string;
79
+ @Prop() prependIcon: string;
80
+ @Prop() maxWidth?: number;
81
+ @Prop() marginType?: MarginType;
82
+ @Prop() appendClicked: () => void;
83
+ @Prop() prependClicked: () => void;
84
+ @Prop() prependIconClicked: () => void;
85
+ @Prop() appendIconClicked: () => void;
86
+ @Prop() keyDown: (e: KeyboardEvent) => void;
87
+ @Prop() keyUp: (e: KeyboardEvent) => void;
88
+ @Prop() enterPressed: (e: KeyboardEvent) => void;
89
+ @Prop() showClearValueButton!: boolean;
90
+
91
+ // --- Props from SmartDropdown---
92
+ @Prop({ type: Array, required: true }) readonly categories!: SmartDropdownCategoryItem[];
93
+ @Prop({ type: Array, default: () => [] }) readonly customSections!: SmartDropdownSection[];
94
+ @Prop({ type: Function, required: true }) readonly searchData!: (text: string) => Promise<SmartDropdownSearchResultItem[]>;
95
+ @Prop({ type: Array, default: () => [] }) readonly value!: SmartDropdownItem[];
96
+ @Prop({ type: Boolean, default: false }) readonly multiselect!: boolean;
97
+ @Prop({ type: String, default: () => SmartDropdownResources.placeholderDefault }) readonly placeholder!: string;
98
+ @Prop({ type: String, default: 'text' }) readonly selectionDisplay!: 'chips' | 'text';
99
+ @Prop({ type: String, default: 'footer' }) readonly buttonLayout!: 'footer' | 'inline';
100
+ @Prop({ type: String, default: 'dropdown' }) readonly searchMode!: 'dropdown' | 'input';
101
+ @Prop({ type: String, default: 'all' }) readonly customTriggerScope!: 'mobile' | 'desktop' | 'all';
102
+ @Prop({ type: Function }) readonly customSectionRender?: (item: SmartDropdownItem, selected: boolean) => VNode;
103
+ @Prop({ type: Function }) readonly customTriggerRender?: () => VNode;
104
+ @Prop() readonly changed: (e: SmartDropdownItem[]) => void;
105
+
106
+ // --- State ---
107
+ isOpen = false;
108
+ searchQuery = '';
109
+ searchResults: SmartDropdownSearchResultItem[] = [];
110
+ isLoading = false;
111
+ debounceTimer: number | null = null;
112
+ focusedIndex = -1;
113
+ triggerInputValue = '';
114
+
115
+ // Accessibility ID
116
+ uid = `smart-dd-${Math.random().toString(36).slice(2, 9)}`;
117
+
118
+ // --- Watchers ---
119
+ @Watch('value', { deep: true, immediate: true })
120
+ onSelectionChange() {
121
+ if (!this.isOpen || this.searchQuery === '') {
122
+ this.triggerInputValue = this.displayText;
123
+ }
124
+ }
125
+
126
+ // --- Computed ---
127
+ get listboxId(): string {
128
+ return `${this.uid}-listbox`;
129
+ }
130
+
131
+ get activeDescendantId(): string | undefined {
132
+ return this.focusedIndex >= 0 ? `${this.uid}-option-${this.focusedIndex}` : undefined;
133
+ }
134
+
135
+ get flattenedDisplayItems(): SmartDropdownItem[] {
136
+ // Helper to get flat list for keyboard navigation
137
+ if (this.isSearchActive) {
138
+ return this.searchResults;
139
+ }
140
+
141
+ const items: SmartDropdownItem[] = [];
142
+ if (this.pinnedSelectedItems.length > 0) {
143
+ items.push(...this.pinnedSelectedItems);
144
+ }
145
+
146
+ this.customSections.forEach(s => items.push(...s.items));
147
+ if (this.standardDisplayItems.length > 0) {
148
+ items.push(...this.standardDisplayItems);
149
+ }
150
+
151
+ return items;
152
+ }
153
+
154
+ get standardDisplayItems(): SmartDropdownItem[] {
155
+ if (this.multiselect && this.selectionDisplay === 'text' && this.value.length > 0) {
156
+ const selectedIds = new Set(this.value.map(i => i.id));
157
+ return this.categories.filter(c => !selectedIds.has(c.id));
158
+ }
159
+
160
+ return this.categories;
161
+ }
162
+
163
+ get pinnedSelectedItems(): SmartDropdownItem[] {
164
+ if (this.multiselect && this.selectionDisplay === 'text' && !this.isSearchActive) {
165
+ return this.value;
166
+ }
167
+
168
+ return [];
169
+ }
170
+
171
+ get isSearchActive(): boolean {
172
+ return this.searchQuery.trim().length > 0;
173
+ }
174
+
175
+ get displayText(): string {
176
+ if (this.value.length === 0) {
177
+ return '';
178
+ }
179
+
180
+ if (!this.multiselect) {
181
+ return this.value[0].text;
182
+ }
183
+
184
+ if (this.value.length === 1) {
185
+ return this.value[0].text;
186
+ }
187
+
188
+ return `${this.value.length}${SmartDropdownResources.summarySuffix}`;
189
+ }
190
+
191
+ beforeDestroy() {
192
+ this.removeEventHandlers();
193
+ }
194
+
195
+ // --- Methods ---
196
+
197
+ toggleDropdown(force?: boolean) {
198
+ const nextState = typeof force === 'boolean' ? force : !this.isOpen;
199
+ if (nextState) {
200
+ this.isOpen = true;
201
+ this.focusedIndex = -1; // Reset focus logic
202
+
203
+ this.$nextTick(() => {
204
+ const isMobile = window.innerWidth <= 768;
205
+ const dropdownInput = this.$el.querySelector('.dropdown-search-input') as HTMLInputElement;
206
+ const triggerInput = this.$el.querySelector('.trigger-input') as HTMLInputElement;
207
+
208
+ // Focus Logic
209
+ const shouldUseExternalFocus = this.searchMode === 'input' && !isMobile && this.customTriggerScope !== 'desktop';
210
+
211
+ if (shouldUseExternalFocus) {
212
+ triggerInput?.select();
213
+ } else {
214
+ dropdownInput?.focus();
215
+ }
216
+
217
+ // Initial Scroll Logic
218
+ if (this.pinnedSelectedItems.length > 0) {
219
+ const listContainer = this.$el.querySelector('.list-container') as HTMLElement;
220
+ const categoryHeader = this.$el.querySelector('.section-categories') as HTMLElement;
221
+ if (listContainer && categoryHeader) {
222
+ listContainer.scrollTop = categoryHeader.offsetTop;
223
+ }
224
+ }
225
+ });
226
+
227
+ this.removeEventHandlers();
228
+
229
+ const self = this;
230
+ this.handleClickOutside = (e: Event) => {
231
+ if (!self.$el.contains(e.target as Node)) {
232
+ self.closeDropdown.call(self);
233
+ }
234
+ };
235
+
236
+ this.handleGlobalKeydown = (e: KeyboardEvent) => {
237
+ if (!this.isOpen) {
238
+ // Allow opening with Enter or Down Arrow/Space if focused on trigger
239
+ if ((e.key === 'Enter' || e.key === 'ArrowDown' || e.key === ' ') && (e.target as HTMLElement).classList.contains('filter-trigger')) {
240
+ self.toggleDropdown.call(self, true);
241
+ e.preventDefault();
242
+ }
243
+
244
+ return;
245
+ }
246
+
247
+ const totalItems = self.flattenedDisplayItems.length;
248
+ switch (e.key) {
249
+ case 'Escape':
250
+ self.closeDropdown.call(self);
251
+ e.preventDefault();
252
+ break;
253
+ case 'Enter':
254
+ e.preventDefault();
255
+ if (self.focusedIndex >= 0) {
256
+ self.handleItemClick.call(self, self.flattenedDisplayItems[self.focusedIndex]);
257
+ } else {
258
+ self.confirmAndClose();
259
+ }
260
+
261
+ break;
262
+ case 'ArrowDown':
263
+ e.preventDefault();
264
+ self.focusedIndex = (self.focusedIndex + 1) % totalItems;
265
+ self.scrollItemIntoView.call(self, self.focusedIndex);
266
+ break;
267
+ case 'ArrowUp':
268
+ e.preventDefault();
269
+ self.focusedIndex = (self.focusedIndex - 1 + totalItems) % totalItems;
270
+ self.scrollItemIntoView.call(self, self.focusedIndex);
271
+ break;
272
+ case 'Home':
273
+ e.preventDefault();
274
+ self.focusedIndex = 0;
275
+ self.scrollItemIntoView.call(self, self.focusedIndex);
276
+ break;
277
+ case 'End':
278
+ e.preventDefault();
279
+ self.focusedIndex = totalItems - 1;
280
+ self.scrollItemIntoView.call(self, self.focusedIndex);
281
+ break;
282
+ case 'Tab':
283
+ self.closeDropdown.call(self); // Tab out closes dropdown
284
+ break;
285
+ }
286
+ };
287
+
288
+ document.addEventListener('click', this.handleClickOutside);
289
+ document.addEventListener('keydown', this.handleGlobalKeydown);
290
+ } else {
291
+ this.closeDropdown();
292
+ }
293
+ }
294
+
295
+ removeEventHandlers() {
296
+ if (this.handleClickOutside) {
297
+ document.removeEventListener('click', this.handleClickOutside);
298
+ }
299
+
300
+ if (this.handleGlobalKeydown) {
301
+ document.removeEventListener('keydown', this.handleGlobalKeydown);
302
+ }
303
+ }
304
+
305
+ closeDropdown() {
306
+ this.isOpen = false;
307
+ this.focusedIndex = -1;
308
+ this.triggerInputValue = this.displayText;
309
+ this.searchQuery = '';
310
+ }
311
+
312
+ confirmAndClose() {
313
+ if (this.isLoading) {
314
+ return;
315
+ }
316
+
317
+ this.searchQuery = '';
318
+ this.searchResults = [];
319
+ this.closeDropdown();
320
+ }
321
+
322
+ handleClickOutside = (e: Event) => {
323
+ if (!this.$el.contains(e.target as Node)) {
324
+ this.closeDropdown();
325
+ }
326
+ };
327
+
328
+ handleGlobalKeydown = (e: KeyboardEvent) => {
329
+ if (!this.isOpen) {
330
+ // Allow opening with Enter or Down Arrow/Space if focused on trigger
331
+ if ((e.key === 'Enter' || e.key === 'ArrowDown' || e.key === ' ') && (e.target as HTMLElement).classList.contains('filter-trigger')) {
332
+ this.toggleDropdown(true);
333
+ e.preventDefault();
334
+ }
335
+
336
+ return;
337
+ }
338
+
339
+ const totalItems = this.flattenedDisplayItems.length;
340
+
341
+ switch (e.key) {
342
+ case 'Escape':
343
+ this.closeDropdown();
344
+ e.preventDefault();
345
+ break;
346
+ case 'Enter':
347
+ e.preventDefault();
348
+ if (this.focusedIndex >= 0) {
349
+ this.handleItemClick(this.flattenedDisplayItems[this.focusedIndex]);
350
+ } else {
351
+ this.confirmAndClose();
352
+ }
353
+
354
+ break;
355
+ case 'ArrowDown':
356
+ e.preventDefault();
357
+ this.focusedIndex = (this.focusedIndex + 1) % totalItems;
358
+ this.scrollItemIntoView(this.focusedIndex);
359
+ break;
360
+ case 'ArrowUp':
361
+ e.preventDefault();
362
+ this.focusedIndex = (this.focusedIndex - 1 + totalItems) % totalItems;
363
+ this.scrollItemIntoView(this.focusedIndex);
364
+ break;
365
+ case 'Home':
366
+ e.preventDefault();
367
+ this.focusedIndex = 0;
368
+ this.scrollItemIntoView(this.focusedIndex);
369
+ break;
370
+ case 'End':
371
+ e.preventDefault();
372
+ this.focusedIndex = totalItems - 1;
373
+ this.scrollItemIntoView(this.focusedIndex);
374
+ break;
375
+ case 'Tab':
376
+ this.closeDropdown(); // Tab out closes dropdown
377
+ break;
378
+ }
379
+ };
380
+
381
+ scrollItemIntoView(index: number) {
382
+ // Use nextTick to ensure DOM is updated if virtualized (not here, but good practice)
383
+ this.$nextTick(() => {
384
+ const itemId = `${this.uid}-option-${index}`;
385
+ const el = document.getElementById(itemId);
386
+ el?.scrollIntoView({ block: 'nearest' });
387
+ });
388
+ }
389
+
390
+ handleInput(e: Event) {
391
+ const val = (e.target as HTMLInputElement).value;
392
+ this.triggerInputValue = val;
393
+ this.searchQuery = val;
394
+
395
+ if (this.debounceTimer) {
396
+ clearTimeout(this.debounceTimer);
397
+ }
398
+
399
+ if (val.trim().length === 0) {
400
+ this.isLoading = false;
401
+ this.searchResults = [];
402
+ return;
403
+ }
404
+
405
+ this.isLoading = true;
406
+ this.debounceTimer = window.setTimeout(async () => {
407
+ try {
408
+ this.searchResults = await this.searchData(val);
409
+ } catch (err) {
410
+ this.searchResults = [];
411
+ } finally {
412
+ this.isLoading = false;
413
+ }
414
+ }, 800);
415
+ }
416
+
417
+ handleItemClick(item: SmartDropdownItem) {
418
+ const isSelected = this.isSelected(item);
419
+ let newSelection: SmartDropdownItem[] = [];
420
+
421
+ if (this.multiselect) {
422
+ if (isSelected) {
423
+ newSelection = this.value.filter(i => i.id !== item.id);
424
+ } else {
425
+ newSelection = [
426
+ ...this.value,
427
+ item,
428
+ ];
429
+ }
430
+ } else {
431
+ newSelection = [item];
432
+ this.closeDropdown();
433
+ }
434
+
435
+ this.changed(newSelection);
436
+ this.$nextTick(() => {
437
+ this.triggerInputValue = this.displayText;
438
+ });
439
+ }
440
+
441
+ removeSelection(e: Event, item: SmartDropdownItem) {
442
+ e.stopPropagation();
443
+ const newSelection = this.value.filter(i => i.id !== item.id);
444
+ this.changed(newSelection);
445
+ }
446
+
447
+ isSelected(item: SmartDropdownItem): boolean {
448
+ return this.value.some(i => i.id === item.id);
449
+ }
450
+
451
+ // --- Renders ---
452
+
453
+ renderStandardTrigger() {
454
+ if (this.multiselect && this.selectionDisplay === 'chips' && this.value.length > 0) {
455
+ return (
456
+ <div class="trigger-content">
457
+ {this.value.map(item => (
458
+ <span class="chip" onClick={e => e.stopPropagation()} key={item.id}>
459
+ {item.text}
460
+ <span
461
+ class="chip-remove"
462
+ onClick={e => this.removeSelection(e, item)}
463
+ role="button"
464
+ aria-label={`Remove ${item.text}`}
465
+ tabindex={0}
466
+ >
467
+ &times;
468
+ </span>
469
+ </span>
470
+ ))}
471
+ {this.searchMode === 'input' && (
472
+ <input
473
+ class="trigger-input-inline"
474
+ value={this.triggerInputValue === this.displayText ? '' : this.triggerInputValue}
475
+ onInput={this.handleInput}
476
+ aria-label={this.placeholder}
477
+ />
478
+ )}
479
+ </div>
480
+ );
481
+ }
482
+
483
+ if (this.searchMode === 'input') {
484
+ return (
485
+ <input
486
+ class="trigger-input"
487
+ type="text"
488
+ placeholder={this.placeholder}
489
+ value={this.triggerInputValue}
490
+ onInput={this.handleInput}
491
+ onClick={e => (e.target as HTMLInputElement).select()}
492
+ aria-autocomplete="list"
493
+ aria-controls={this.listboxId}
494
+ aria-activedescendant={this.isOpen ? this.activeDescendantId : undefined}
495
+ aria-expanded={this.isOpen}
496
+ />
497
+ );
498
+ }
499
+
500
+ return (
501
+ <div class="trigger-content">
502
+ <span class={this.value.length === 0 ? 'placeholder-text' : ''}>
503
+ {this.displayText || this.placeholder}
504
+ </span>
505
+ </div>
506
+ );
507
+ }
508
+
509
+ renderTrigger() {
510
+ if (!this.customTriggerRender) {
511
+ return this.renderStandardTrigger();
512
+ }
513
+
514
+ const customEl = (
515
+ <div class="trigger-custom-wrapper">
516
+ {this.customTriggerRender()}
517
+ </div>
518
+ );
519
+
520
+ const standardEl = this.renderStandardTrigger();
521
+
522
+ switch (this.customTriggerScope) {
523
+ case 'all': return customEl;
524
+ case 'mobile': return [
525
+ <div class="view-mobile-only">{customEl}</div>,
526
+ <div class="view-desktop-only">{standardEl}</div>,
527
+ ];
528
+ case 'desktop': return [
529
+ <div class="view-desktop-only">{customEl}</div>,
530
+ <div class="view-mobile-only">{standardEl}</div>,
531
+ ];
532
+ default: return standardEl;
533
+ }
534
+ }
535
+
536
+ // Main List Renderer (Manages Groups)
537
+ renderList() {
538
+ if (this.isLoading) {
539
+ return <div class="loading-state" role="status">{SmartDropdownResources.loadingTextLong}</div>;
540
+ }
541
+
542
+ // --- Helper to track global index for keyboard nav ---
543
+ let globalIndex = 0;
544
+ const getIndex = () => globalIndex++;
545
+
546
+ // A. Search Results
547
+ if (this.isSearchActive) {
548
+ if (this.searchResults.length === 0) {
549
+ return <div class="empty-state" role="status">{SmartDropdownResources.noResultsSearch}</div>;
550
+ }
551
+
552
+ return (
553
+ <div class="list-group" role="group" aria-label={SmartDropdownResources.searchResultTitle}>
554
+ <div class="list-group-label" aria-hidden="true">{SmartDropdownResources.searchResultTitle}</div>
555
+ {this.searchResults.map(item => this.renderListItem(item, getIndex()))}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ const hasPinned = this.pinnedSelectedItems.length > 0;
561
+ const hasCustom = this.customSections.length > 0;
562
+ const hasStandard = this.standardDisplayItems.length > 0;
563
+
564
+ if (!hasPinned && !hasCustom && !hasStandard) {
565
+ return <div class="empty-state" role="status">{SmartDropdownResources.noResultsDefault}</div>;
566
+ }
567
+
568
+ return (
569
+ <div class="list-wrapper">
570
+ {/* 1. Pinned Selected */}
571
+ {hasPinned && (
572
+ <div class="list-group section-pinned" role="group" aria-label={SmartDropdownResources.selectedTitle}>
573
+ <div class="list-group-label sticky-selected" aria-hidden="true">
574
+ {SmartDropdownResources.selectedTitle}
575
+ {' '}
576
+ (
577
+ {this.value.length}
578
+ )
579
+ </div>
580
+ {this.pinnedSelectedItems.map(item => this.renderListItem(item, getIndex()))}
581
+ </div>
582
+ )}
583
+
584
+ {/* 2. Custom Sections */}
585
+ {this.customSections.map(section => (
586
+ <div class="list-group section-custom" key={section.id} role="group" aria-label={section.title || 'Section'}>
587
+ {section.title && <div class="list-group-label" aria-hidden="true">{section.title}</div>}
588
+ {section.items.map(item => this.renderListItem(item, getIndex()))}
589
+ </div>
590
+ ))}
591
+
592
+ {/* 3. Standard Categories */}
593
+ {hasStandard && (
594
+ <div class="list-group section-categories" role="group" aria-label={SmartDropdownResources.categoriesTitle}>
595
+ <div class="list-group-label" aria-hidden="true">{SmartDropdownResources.categoriesTitle}</div>
596
+ {this.standardDisplayItems.map(item => this.renderListItem(item, getIndex()))}
597
+ </div>
598
+ )}
599
+ </div>
600
+ );
601
+ }
602
+
603
+ renderListItem(item: SmartDropdownItem, index: number) {
604
+ const isSelected = this.isSelected(item);
605
+ const isFocused = this.focusedIndex === index;
606
+ const uniqueId = `${this.uid}-option-${index}`;
607
+
608
+ // Wrapper properties for accessibility
609
+ const wrapperProps = {
610
+ 'id': uniqueId,
611
+ 'class': {
612
+ 'list-item-custom-container': !!this.customSectionRender,
613
+ 'list-item': !this.customSectionRender,
614
+ 'selected': isSelected,
615
+ 'focused': isFocused, // Visual focus state
616
+ 'no-checkbox': !this.multiselect,
617
+ },
618
+ 'role': 'option',
619
+ 'aria-selected': isSelected,
620
+ 'onClick': () => this.handleItemClick(item),
621
+ 'key': item.id,
622
+ };
623
+
624
+ // 1. Custom Renderer
625
+ if (this.customSectionRender) {
626
+ return (
627
+ <div {...wrapperProps}>
628
+ {this.customSectionRender(item, isSelected)}
629
+ </div>
630
+ );
631
+ }
632
+
633
+ // 2. Default Renderer
634
+ const searchResult = item as SmartDropdownSearchResultItem;
635
+ const hasSubtitle = !!searchResult.subtitle;
636
+ const hasImage = !!searchResult.imageUrl;
637
+ const showCheckbox = this.multiselect && !this.isSearchActive && !hasImage;
638
+
639
+ return (
640
+ <div {...wrapperProps}>
641
+ {hasImage
642
+ ? (
643
+ <img src={searchResult.imageUrl} class="result-image" alt="" />
644
+ )
645
+ : (
646
+ showCheckbox && <div class={`checkbox-visual ${isSelected ? 'checked' : ''}`} aria-hidden="true"></div>
647
+ )}
648
+
649
+ <div class="item-content">
650
+ <span class="item-text">{item.text}</span>
651
+ {hasSubtitle && (
652
+ <span class="item-subtitle">{searchResult.subtitle}</span>
653
+ )}
654
+ </div>
655
+ </div>
656
+ );
657
+ }
658
+
659
+ render() {
660
+ const showCaret = this.searchMode !== 'input' && !this.customTriggerRender;
661
+
662
+ return (
663
+ <FormItemWrapper label={this.label} cssClass={this.cssClass} mandatory={this.mandatory} wrap={this.wrap} appendIcon={this.appendIcon} prependIcon={this.prependIcon} hint={this.hint} marginType={this.marginType} appendClicked={this.appendClicked} prependClicked={this.prependClicked} prependIconClicked={this.prependIconClicked} appendIconClicked={this.appendIconClicked} maxWidth={this.maxWidth} validationState={this.validationState} labelButtons={this.labelButtons} subtitle={this.subtitle} showClearValueButton={this.showClearValueButton}>
664
+ <div
665
+ class={`gopass-filter-wrapper mode-${this.searchMode}`}
666
+ // Only the outer wrapper acts as combobox if not in direct input mode
667
+ role={this.searchMode !== 'input' ? 'combobox' : undefined}
668
+ aria-expanded={this.isOpen}
669
+ aria-haspopup="listbox"
670
+ aria-controls={this.listboxId}
671
+ aria-label={this.placeholder}
672
+ >
673
+
674
+ {/* TRIGGER */}
675
+ <div
676
+ class={{ 'filter-trigger': true, 'is-open': this.isOpen }}
677
+ onClick={() => this.toggleDropdown(true)}
678
+ tabindex={this.searchMode === 'input' && !this.customTriggerRender ? undefined : 0}
679
+ >
680
+ {this.renderTrigger()}
681
+ {showCaret && <span class="caret" aria-hidden="true"></span>}
682
+ </div>
683
+
684
+ {/* DROPDOWN */}
685
+ {this.isOpen && (
686
+ <div class="filter-dropdown" onClick={e => e.stopPropagation()}>
687
+
688
+ {/* INTERNAL HEADER */}
689
+ <div class="search-header internal-search-header">
690
+ <input
691
+ type="text"
692
+ class="search-input dropdown-search-input"
693
+ placeholder={this.placeholder}
694
+ value={this.triggerInputValue}
695
+ onInput={this.handleInput}
696
+ // Accessibility for internal input
697
+ aria-autocomplete="list"
698
+ aria-controls={this.listboxId}
699
+ aria-activedescendant={this.activeDescendantId}
700
+ />
701
+ {this.buttonLayout === 'inline' && (
702
+ <button
703
+ class="btn-confirm"
704
+ disabled={this.isLoading}
705
+ onClick={() => this.confirmAndClose()}
706
+ >
707
+ {this.isLoading ? SmartDropdownResources.loadingTextShort : SmartDropdownResources.doneButtonText}
708
+ </button>
709
+ )}
710
+ </div>
711
+
712
+ {/* LIST */}
713
+ <div
714
+ class="list-container"
715
+ id={this.listboxId}
716
+ role="listbox"
717
+ aria-multiselectable={this.multiselect}
718
+ >
719
+ {this.renderList()}
720
+ </div>
721
+
722
+ {/* FOOTER */}
723
+ {this.buttonLayout === 'footer' && (
724
+ <div class="dropdown-footer">
725
+ <button
726
+ class="btn-confirm"
727
+ onClick={() => this.confirmAndClose()}
728
+ >
729
+ {SmartDropdownResources.doneButtonText}
730
+ </button>
731
+ </div>
732
+ )}
733
+ </div>
734
+ )}
735
+ </div>
736
+ </FormItemWrapper>
737
+ );
738
+ }
739
+ }