inviton-powerduck 0.0.216 → 0.0.218
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.
|
@@ -7,6 +7,7 @@ import type { AppMenuItem } from '../app/menu';
|
|
|
7
7
|
import type { CardHeaderDropdownArgs } from '../card/card-header-with-options';
|
|
8
8
|
import type { DropdownListOption } from '../dropdown';
|
|
9
9
|
import type { ImageDropdownDataItem } from '../dropdown/image-dropdown';
|
|
10
|
+
import type { SmartDropdownCategoryItem, SmartDropdownSearchResultItem } from '../dropdown/smart-dropdown';
|
|
10
11
|
import type { TimegridCalendarAddClickedArgs, TimegridCalendarEvent } from '../fullcalendar/timegrid-calendar';
|
|
11
12
|
import { Temporal } from '@js-temporal/polyfill';
|
|
12
13
|
import { Prop, toNative } from 'vue-facing-decorator';
|
|
@@ -30,6 +31,7 @@ import DropdownButtonItem from '../dropdown-button/dropdown-button-item';
|
|
|
30
31
|
import DropdownButtonSeparator from '../dropdown-button/dropdown-button-separator';
|
|
31
32
|
import CountryDropdown from '../dropdown/country-dropdown';
|
|
32
33
|
import ImageDropdown from '../dropdown/image-dropdown';
|
|
34
|
+
import SmartDropdown from '../dropdown/smart-dropdown';
|
|
33
35
|
import Fieldset from '../form/fieldset';
|
|
34
36
|
import FlexContainer from '../form/flex-container';
|
|
35
37
|
import FooterButtons from '../form/footer-buttons';
|
|
@@ -155,6 +157,81 @@ class TestAllComponentsPageComponent extends PowerduckViewModelBase {
|
|
|
155
157
|
});
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
// 1. Static Categories
|
|
161
|
+
categories: SmartDropdownCategoryItem[] = [
|
|
162
|
+
{ id: 101, text: 'Skipass' },
|
|
163
|
+
{ id: 102, text: 'Výlet lanovkou' },
|
|
164
|
+
{ id: 103, text: 'Aquapass' },
|
|
165
|
+
{ id: 104, text: 'Ubytovanie' },
|
|
166
|
+
{ id: 105, text: 'Parkovanie' },
|
|
167
|
+
{ id: 106, text: 'Eventy' },
|
|
168
|
+
{ id: 107, text: 'Zazitky' },
|
|
169
|
+
{ id: 108, text: 'Ine srandy' },
|
|
170
|
+
{ id: 109, text: 'Vouchery' },
|
|
171
|
+
{ id: 110, text: 'Pokemony' },
|
|
172
|
+
{ id: 111, text: 'Kino' },
|
|
173
|
+
{ id: 112, text: 'Restauracie' },
|
|
174
|
+
{ id: 113, text: 'Este insie srandy' },
|
|
175
|
+
{ id: 114, text: 'Sranda banda' },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// 2. Selected State
|
|
179
|
+
selectedItems: (SmartDropdownCategoryItem | SmartDropdownSearchResultItem)[] = [];
|
|
180
|
+
|
|
181
|
+
// 3. Search Data Logic (Simulated Backend)
|
|
182
|
+
async fetchSmartDropdownSearchData(query: string): Promise<SmartDropdownSearchResultItem[]> {
|
|
183
|
+
console.log('Fetching for:', query);
|
|
184
|
+
|
|
185
|
+
// Simulate network delay
|
|
186
|
+
await new Promise(resolve => setTimeout(resolve, 500)); // 500ms delay
|
|
187
|
+
|
|
188
|
+
const mockDb: SmartDropdownSearchResultItem[] = [
|
|
189
|
+
{ id: 1, text: 'Hotel Grand Jasná', subtitle: 'Ubytovanie • Jasná', imageUrl: 'https://picsum.photos/200' },
|
|
190
|
+
{ id: 2, text: 'Celosezónny Skipass', subtitle: 'Produkt • Nízke Tatry', imageUrl: 'https://picsum.photos/200' },
|
|
191
|
+
{ id: 3, text: 'Hotel Rotunda', subtitle: 'Ubytovanie • Chopok', imageUrl: 'https://picsum.photos/200' },
|
|
192
|
+
{ id: 4, text: 'Fresh Track', subtitle: 'Zážitok • Lomnica' },
|
|
193
|
+
{ id: 5, text: 'Wellness Hotel', subtitle: 'Ubytovanie • Bešeňová' },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
return mockDb.filter(item =>
|
|
197
|
+
item.text.toLowerCase().includes(query.toLowerCase()));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 4. Handle Change
|
|
201
|
+
onSmartDropdownSelectionChange(newSelection: any[]) {
|
|
202
|
+
console.log('Selection updated:', newSelection);
|
|
203
|
+
this.selectedItems = newSelection;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. Custom Sections (History & Inspiration)
|
|
207
|
+
get customSections() {
|
|
208
|
+
return [
|
|
209
|
+
{
|
|
210
|
+
id: 'history',
|
|
211
|
+
title: 'Nedávne vyhľadávania',
|
|
212
|
+
items: [
|
|
213
|
+
{ id: 'h1', text: 'Jasná', type: 'history' },
|
|
214
|
+
{ id: 'h2', text: 'Vysoké Tatry', type: 'history' },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'inspiration',
|
|
219
|
+
title: 'Váhate, kam cestovať najbližšie?',
|
|
220
|
+
items: [
|
|
221
|
+
{ id: 'insp1', text: 'Zadajte Kamkoľvek', type: 'special', icon: 'pin' },
|
|
222
|
+
{ id: 'insp2', text: 'V mojej lokalite', type: 'special', icon: 'target' },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 4. Custom Handler for History Delete
|
|
229
|
+
removeSmartDropdownHistory(e: Event, id: string | number) {
|
|
230
|
+
e.stopPropagation();
|
|
231
|
+
console.log('Removing history', id);
|
|
232
|
+
// logic to remove from customSections...
|
|
233
|
+
}
|
|
234
|
+
|
|
158
235
|
render(h) {
|
|
159
236
|
return (
|
|
160
237
|
<div>
|
|
@@ -333,11 +410,11 @@ class TestAllComponentsPageComponent extends PowerduckViewModelBase {
|
|
|
333
410
|
<p>Some modal content1</p>
|
|
334
411
|
</ModalSection>
|
|
335
412
|
{this.boolValue == true
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
413
|
+
&& (
|
|
414
|
+
<ModalSection icon="icon icon-settings" navCaption="Settings2">
|
|
415
|
+
<p>Some modal content2</p>
|
|
416
|
+
</ModalSection>
|
|
417
|
+
)}
|
|
341
418
|
|
|
342
419
|
<ModalSection icon="icon icon-settings" navCaption="Settings4">
|
|
343
420
|
<p>Some modal content4444</p>
|
|
@@ -411,16 +488,12 @@ class TestAllComponentsPageComponent extends PowerduckViewModelBase {
|
|
|
411
488
|
h,
|
|
412
489
|
item,
|
|
413
490
|
originator,
|
|
414
|
-
) => {
|
|
415
|
-
return (<CustomDropdownItem text={item.text} />);
|
|
416
|
-
}}
|
|
491
|
+
) => <CustomDropdownItem text={item.text} />}
|
|
417
492
|
customRenderSelectionResult={(
|
|
418
493
|
h,
|
|
419
494
|
item,
|
|
420
495
|
originator,
|
|
421
|
-
) => {
|
|
422
|
-
return (<CustomDropdownItem text={item.text} />);
|
|
423
|
-
}}
|
|
496
|
+
) => <CustomDropdownItem text={item.text} />}
|
|
424
497
|
changed={(v) => {
|
|
425
498
|
this.selectedOptions = v.id;
|
|
426
499
|
}}
|
|
@@ -479,6 +552,58 @@ class TestAllComponentsPageComponent extends PowerduckViewModelBase {
|
|
|
479
552
|
}}
|
|
480
553
|
/>
|
|
481
554
|
|
|
555
|
+
<SmartDropdown
|
|
556
|
+
label="Mudry dropdown"
|
|
557
|
+
wrap={false}
|
|
558
|
+
categories={this.categories}
|
|
559
|
+
searchData={this.fetchSmartDropdownSearchData}
|
|
560
|
+
customSections={this.customSections}
|
|
561
|
+
value={this.selectedItems}
|
|
562
|
+
buttonLayout="inline"
|
|
563
|
+
selectionDisplay="chips"
|
|
564
|
+
|
|
565
|
+
searchMode="input"
|
|
566
|
+
changed={this.onSmartDropdownSelectionChange} // Or use @selectionChanged in template
|
|
567
|
+
multiselect={true} // Toggle to false for single select
|
|
568
|
+
placeholder="Hľadajte hotel, službu..."
|
|
569
|
+
// customSectionRender={(item, selected) => {
|
|
570
|
+
// // Example: History Item
|
|
571
|
+
// if (item.type === 'history') {
|
|
572
|
+
// return (
|
|
573
|
+
// <div style="display:flex; justify-content:space-between; padding: 8px 16px;">
|
|
574
|
+
// <span>
|
|
575
|
+
// ↻
|
|
576
|
+
// <b>{item.text}</b>
|
|
577
|
+
// </span>
|
|
578
|
+
// <span onClick={e => this.removeHistory(e, item.id)}>x</span>
|
|
579
|
+
// </div>
|
|
580
|
+
// );
|
|
581
|
+
// }
|
|
582
|
+
|
|
583
|
+
// // Example: Default Checkbox style via simple HTML
|
|
584
|
+
// return (
|
|
585
|
+
// <div style="display:flex; align-items:center; padding: 8px 16px;">
|
|
586
|
+
// <div class={`checkbox-visual ${selected ? 'checked' : ''}`} style="margin-right:10px; width:16px; height:16px; border:1px solid #ccc;"></div>
|
|
587
|
+
// {item.text}
|
|
588
|
+
// </div>
|
|
589
|
+
// );
|
|
590
|
+
// }}
|
|
591
|
+
customTriggerScope="mobile"
|
|
592
|
+
customTriggerRender={() => (
|
|
593
|
+
<div style="display:flex; flex-direction:column; width:100%; gap:8px;">
|
|
594
|
+
{/* Mimicking the stacked mobile inputs */}
|
|
595
|
+
<div style="border:1px solid #eee; padding:8px; border-radius:8px; display:flex; justify-content:space-between;">
|
|
596
|
+
<span style="color:#999; font-size:12px">Kam</span>
|
|
597
|
+
<strong>Jasná</strong>
|
|
598
|
+
</div>
|
|
599
|
+
<div style="border:1px solid #eee; padding:8px; border-radius:8px; display:flex; justify-content:space-between;">
|
|
600
|
+
<span style="color:#999; font-size:12px">Čo hľadáte</span>
|
|
601
|
+
<strong>Skipass</strong>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
605
|
+
/>
|
|
606
|
+
|
|
482
607
|
<DropdownButton layout={ButtonLayout.Default} size={ButtonSize.Regular} text="Akcie">
|
|
483
608
|
<DropdownButtonItem icon="icon icon-settings" text="Edit" clicked={() => alert('clicked me')} />
|
|
484
609
|
<DropdownButtonSeparator />
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
.gopass-filter-wrapper {
|
|
2
|
+
/* ... [Previous variables and base styles] ... */
|
|
3
|
+
--bs-blue: #0d6efd;
|
|
4
|
+
--bs-btn-bg: #0d6efd;
|
|
5
|
+
--bs-btn-color: #fff;
|
|
6
|
+
--bs-btn-hover: #0b5ed7;
|
|
7
|
+
--bs-btn-disabled-bg: #0d6efd;
|
|
8
|
+
--bs-btn-disabled-opacity: 0.65;
|
|
9
|
+
--bs-border-radius: 0.375rem;
|
|
10
|
+
|
|
11
|
+
font-family:
|
|
12
|
+
system-ui,
|
|
13
|
+
-apple-system,
|
|
14
|
+
'Segoe UI',
|
|
15
|
+
Roboto,
|
|
16
|
+
sans-serif;
|
|
17
|
+
position: relative;
|
|
18
|
+
width: 100%;
|
|
19
|
+
|
|
20
|
+
* {
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* --- TRIGGER --- */
|
|
25
|
+
.filter-trigger {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
width: 100%;
|
|
30
|
+
min-height: 38px;
|
|
31
|
+
padding: 0.375rem 0.75rem;
|
|
32
|
+
font-size: 1rem;
|
|
33
|
+
color: #212529;
|
|
34
|
+
background: #fff;
|
|
35
|
+
border: 1px solid #ced4da;
|
|
36
|
+
border-radius: var(--bs-border-radius);
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
transition: all 0.15s;
|
|
39
|
+
|
|
40
|
+
&:hover {
|
|
41
|
+
border-color: #b3b3b3;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&.is-open {
|
|
45
|
+
border-color: #86b7fe;
|
|
46
|
+
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.result-image {
|
|
51
|
+
width: 32px;
|
|
52
|
+
height: 32px;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
/* Circle image */
|
|
55
|
+
object-fit: cover;
|
|
56
|
+
margin-right: 12px;
|
|
57
|
+
background-color: #f0f0f0;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* INPUT MODE TRIGGER STYLES */
|
|
62
|
+
.trigger-input {
|
|
63
|
+
width: 100%;
|
|
64
|
+
border: none;
|
|
65
|
+
outline: none;
|
|
66
|
+
background: transparent;
|
|
67
|
+
font-size: 1rem;
|
|
68
|
+
color: #212529;
|
|
69
|
+
padding: 0;
|
|
70
|
+
margin: 0;
|
|
71
|
+
|
|
72
|
+
&::placeholder {
|
|
73
|
+
color: #6c757d;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.trigger-input-inline {
|
|
78
|
+
border: none;
|
|
79
|
+
outline: none;
|
|
80
|
+
background: transparent;
|
|
81
|
+
font-size: 1rem;
|
|
82
|
+
min-width: 50px;
|
|
83
|
+
flex-grow: 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ... [Chips, Placeholder, Caret styles - No Change] ... */
|
|
87
|
+
.trigger-content {
|
|
88
|
+
flex-grow: 1;
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-wrap: wrap;
|
|
91
|
+
gap: 4px;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.placeholder-text {
|
|
96
|
+
color: #6c757d;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.caret {
|
|
100
|
+
margin-left: 8px;
|
|
101
|
+
border-top: 4px solid #666;
|
|
102
|
+
border-right: 4px solid transparent;
|
|
103
|
+
border-left: 4px solid transparent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.chip {
|
|
107
|
+
background: #e9ecef;
|
|
108
|
+
color: #495057;
|
|
109
|
+
padding: 2px 8px;
|
|
110
|
+
border-radius: 12px;
|
|
111
|
+
font-size: 0.85rem;
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
font-weight: 500;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.chip-remove {
|
|
118
|
+
margin-left: 6px;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
font-weight: bold;
|
|
121
|
+
color: #888;
|
|
122
|
+
width: 16px;
|
|
123
|
+
height: 16px;
|
|
124
|
+
display: flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
border-radius: 50%;
|
|
128
|
+
|
|
129
|
+
&:hover {
|
|
130
|
+
background: #ccc;
|
|
131
|
+
color: #000;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* --- DROPDOWN --- */
|
|
136
|
+
.filter-dropdown {
|
|
137
|
+
position: absolute;
|
|
138
|
+
top: calc(100% + 4px);
|
|
139
|
+
left: 0;
|
|
140
|
+
width: 100%;
|
|
141
|
+
min-width: 300px;
|
|
142
|
+
background: white;
|
|
143
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
144
|
+
border-radius: var(--bs-border-radius);
|
|
145
|
+
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
146
|
+
z-index: 1050;
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-direction: column;
|
|
149
|
+
max-height: 450px;
|
|
150
|
+
animation: fadeIn 0.1s ease-out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@keyframes fadeIn {
|
|
154
|
+
from {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
transform: translateY(-5px);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
to {
|
|
160
|
+
opacity: 1;
|
|
161
|
+
transform: translateY(0);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.search-header {
|
|
166
|
+
padding: 10px;
|
|
167
|
+
border-bottom: 1px solid #e9ecef;
|
|
168
|
+
background-color: #fff;
|
|
169
|
+
flex-shrink: 0;
|
|
170
|
+
display: flex;
|
|
171
|
+
gap: 8px;
|
|
172
|
+
align-items: center;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.search-input {
|
|
176
|
+
flex-grow: 1;
|
|
177
|
+
width: auto;
|
|
178
|
+
padding: 0.375rem 0.75rem 0.375rem 34px;
|
|
179
|
+
border: 1px solid #ced4da;
|
|
180
|
+
border-radius: 4px;
|
|
181
|
+
outline: none;
|
|
182
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23999' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'/%3E%3C/svg%3E");
|
|
183
|
+
background-repeat: no-repeat;
|
|
184
|
+
background-position: 10px center;
|
|
185
|
+
|
|
186
|
+
&:focus {
|
|
187
|
+
border-color: #86b7fe;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ... [Button, List, Footer, Item styles - No Change] ... */
|
|
192
|
+
.btn-confirm {
|
|
193
|
+
display: inline-block;
|
|
194
|
+
font-weight: 500;
|
|
195
|
+
text-align: center;
|
|
196
|
+
vertical-align: middle;
|
|
197
|
+
user-select: none;
|
|
198
|
+
border: 1px solid transparent;
|
|
199
|
+
padding: 0.375rem 0.75rem;
|
|
200
|
+
font-size: 1rem;
|
|
201
|
+
line-height: 1.5;
|
|
202
|
+
border-radius: var(--bs-border-radius);
|
|
203
|
+
color: var(--bs-btn-color);
|
|
204
|
+
background-color: var(--bs-btn-bg);
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
transition: all 0.15s;
|
|
207
|
+
white-space: nowrap;
|
|
208
|
+
|
|
209
|
+
&:hover:not(:disabled) {
|
|
210
|
+
background-color: var(--bs-btn-hover);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
&:active:not(:disabled) {
|
|
214
|
+
background-color: #0a58ca;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
&:disabled {
|
|
218
|
+
opacity: var(--bs-btn-disabled-opacity);
|
|
219
|
+
cursor: not-allowed;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.dropdown-footer {
|
|
224
|
+
padding: 10px;
|
|
225
|
+
border-top: 1px solid #e9ecef;
|
|
226
|
+
background-color: #f8f9fa;
|
|
227
|
+
flex-shrink: 0;
|
|
228
|
+
|
|
229
|
+
.btn-confirm {
|
|
230
|
+
width: 100%;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.list-container {
|
|
235
|
+
overflow-y: auto;
|
|
236
|
+
flex-grow: 1;
|
|
237
|
+
min-height: 150px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.list-group-label {
|
|
241
|
+
padding: 8px 16px;
|
|
242
|
+
font-size: 0.75rem;
|
|
243
|
+
font-weight: 700;
|
|
244
|
+
text-transform: uppercase;
|
|
245
|
+
color: #adb5bd;
|
|
246
|
+
background: #fff;
|
|
247
|
+
border-bottom: 1px solid #f0f0f0;
|
|
248
|
+
position: sticky;
|
|
249
|
+
top: 0;
|
|
250
|
+
z-index: 5;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.sticky-selected {
|
|
254
|
+
background: #f8f9fa;
|
|
255
|
+
color: var(--bs-blue);
|
|
256
|
+
border-bottom: 2px solid #e9ecef;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.list-item-custom-container {
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
border-bottom: 1px solid #f8f9fa;
|
|
262
|
+
transition: background-color 0.15s;
|
|
263
|
+
|
|
264
|
+
&:hover {
|
|
265
|
+
background-color: #e9ecef;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
&.selected {
|
|
269
|
+
background-color: #e7f1ff;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.list-item {
|
|
274
|
+
padding: 8px 16px;
|
|
275
|
+
cursor: pointer;
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
border-bottom: 1px solid #f8f9fa;
|
|
279
|
+
|
|
280
|
+
&:hover {
|
|
281
|
+
background: #e9ecef;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
&.selected {
|
|
285
|
+
background: #e7f1ff;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.checkbox-visual {
|
|
290
|
+
width: 16px;
|
|
291
|
+
height: 16px;
|
|
292
|
+
border: 1px solid #adb5bd;
|
|
293
|
+
border-radius: 3px;
|
|
294
|
+
margin-right: 12px;
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
justify-content: center;
|
|
298
|
+
background: #fff;
|
|
299
|
+
flex-shrink: 0;
|
|
300
|
+
|
|
301
|
+
&.checked {
|
|
302
|
+
background: var(--bs-blue);
|
|
303
|
+
border-color: var(--bs-blue);
|
|
304
|
+
|
|
305
|
+
&::after {
|
|
306
|
+
content: '';
|
|
307
|
+
width: 4px;
|
|
308
|
+
height: 8px;
|
|
309
|
+
border: solid white;
|
|
310
|
+
border-width: 0 2px 2px 0;
|
|
311
|
+
transform: rotate(45deg);
|
|
312
|
+
margin-top: -2px;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.no-checkbox .checkbox-visual {
|
|
318
|
+
display: none;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.item-content {
|
|
322
|
+
flex: 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.item-text {
|
|
326
|
+
display: block;
|
|
327
|
+
font-size: 0.95rem;
|
|
328
|
+
color: #212529;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.item-subtitle {
|
|
332
|
+
display: block;
|
|
333
|
+
font-size: 0.8rem;
|
|
334
|
+
color: #6c757d;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.loading-state,
|
|
338
|
+
.empty-state {
|
|
339
|
+
padding: 1.5rem;
|
|
340
|
+
text-align: center;
|
|
341
|
+
color: #6c757d;
|
|
342
|
+
font-size: 0.9rem;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.view-mobile-only {
|
|
346
|
+
width: 100%;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.view-desktop-only {
|
|
350
|
+
width: 100%;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* Desktop View ( > 768px ) */
|
|
354
|
+
@media (min-width: 769px) {
|
|
355
|
+
.view-mobile-only {
|
|
356
|
+
display: none !important;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.view-desktop-only {
|
|
360
|
+
display: block !important;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* Mobile View ( <= 768px ) */
|
|
365
|
+
@media (max-width: 768px) {
|
|
366
|
+
.view-mobile-only {
|
|
367
|
+
display: block !important;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.view-desktop-only {
|
|
371
|
+
display: none !important;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* --- MODE LOGIC (Desktop) --- */
|
|
376
|
+
|
|
377
|
+
/* When in 'input' mode on Desktop: Hide internal search header and footer */
|
|
378
|
+
@media (min-width: 769px) {
|
|
379
|
+
&.mode-input {
|
|
380
|
+
.internal-search-header {
|
|
381
|
+
display: none !important;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.dropdown-footer {
|
|
385
|
+
display: none !important;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* --- MOBILE RESPONSIVE --- */
|
|
391
|
+
@media (max-width: 768px) {
|
|
392
|
+
.filter-dropdown {
|
|
393
|
+
position: fixed;
|
|
394
|
+
top: 0;
|
|
395
|
+
left: 0;
|
|
396
|
+
width: 100vw;
|
|
397
|
+
height: 100vh;
|
|
398
|
+
border-radius: 0;
|
|
399
|
+
border: none;
|
|
400
|
+
z-index: 9999;
|
|
401
|
+
max-height: none;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.list-container {
|
|
405
|
+
height: auto;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* On Mobile, always show the internal search/footer even if in 'input' mode */
|
|
409
|
+
/* because the external input is behind the modal */
|
|
410
|
+
&.mode-input {
|
|
411
|
+
.internal-search-header {
|
|
412
|
+
display: flex !important;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.dropdown-footer {
|
|
416
|
+
display: block !important;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Optional: Hide external input caretaker on mobile if it looks weird */
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -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
|
+
×
|
|
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
|
+
}
|
|
@@ -42,6 +42,7 @@ export enum ModalSize {
|
|
|
42
42
|
ExtraLarge = 3,
|
|
43
43
|
FullWidth = 4,
|
|
44
44
|
NormalToLarge = 5,
|
|
45
|
+
SmallToNormal = 6,
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export enum ModalHeaderIcon {
|
|
@@ -104,6 +105,8 @@ class ModalComponent extends TsxComponent<ModalArgs> implements ModalArgs {
|
|
|
104
105
|
return ' modal-fw';
|
|
105
106
|
} else if (this.size == ModalSize.NormalToLarge) {
|
|
106
107
|
return ' modal-ntl';
|
|
108
|
+
} else if (this.size == ModalSize.SmallToNormal) {
|
|
109
|
+
return ' modal-stn';
|
|
107
110
|
} else {
|
|
108
111
|
return '';
|
|
109
112
|
}
|