@styloviz/select-dropdown 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/fesm2022/styloviz-select-dropdown.mjs +638 -0
- package/fesm2022/styloviz-select-dropdown.mjs.map +1 -0
- package/package.json +43 -0
- package/types/styloviz-select-dropdown.d.ts +217 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { model, input, output, signal, computed, viewChild, inject, ElementRef, ApplicationRef, effect, TemplateRef, forwardRef, HostListener, ViewChild, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { NgClass } from '@angular/common';
|
|
4
|
+
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
5
|
+
|
|
6
|
+
/** Process-wide counter for generating unique fallback ids. */
|
|
7
|
+
let _svSelectUid = 0;
|
|
8
|
+
/**
|
|
9
|
+
* SelectDropdown — A fully-featured, accessible select component for dashboards.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Single and multi-select modes via the `multiple` input
|
|
13
|
+
* - Flat option list and grouped option list (or both together)
|
|
14
|
+
* - Typeahead search with query filtering
|
|
15
|
+
* - Keyboard navigation (↑ ↓ Enter Escape Tab) with scroll-into-view
|
|
16
|
+
* - Chip display for multi-select (up to 3 visible, +N overflow badge)
|
|
17
|
+
* - Clearable selection via an inline × button
|
|
18
|
+
* - Avatar / icon / sublabel / badge per option row
|
|
19
|
+
* - Max-selection cap (`maxSelected`) with disabled-state enforcement
|
|
20
|
+
* - 3 sizes × 3 visual variants (default · filled · flushed)
|
|
21
|
+
* - Full `ControlValueAccessor` integration — works with `FormControl` and `ngModel`
|
|
22
|
+
* - `setDisabledState()` honours both `[disabled]` input and `formControl.disable()`
|
|
23
|
+
* - Full dark-mode support
|
|
24
|
+
* - Accessible: `role="combobox"`, `aria-expanded`, `aria-selected`, `aria-disabled`
|
|
25
|
+
*/
|
|
26
|
+
class SvSelectDropdownComponent {
|
|
27
|
+
// ── Inputs ─────────────────────────────────────────────────────────────────
|
|
28
|
+
/** Selected value(s) — string for single, string[] for multiple (two-way). */
|
|
29
|
+
value = model('', ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
30
|
+
/** Flat list of options. Use `groups` instead for sectioned lists. */
|
|
31
|
+
options = input([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
|
|
32
|
+
/** Grouped options with section labels. */
|
|
33
|
+
groups = input([], ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
|
|
34
|
+
/** Placeholder shown when nothing is selected. @default 'Select an option' */
|
|
35
|
+
placeholder = input('Select an option', ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
36
|
+
/** Field label. */
|
|
37
|
+
label = input('', ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
38
|
+
/** Helper text shown below the field. */
|
|
39
|
+
hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : /* istanbul ignore next */ []));
|
|
40
|
+
/** Error message; puts the field in an error state when set. */
|
|
41
|
+
errorMessage = input('', ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
|
|
42
|
+
/** Field size: sm, md or lg. @default 'md' */
|
|
43
|
+
size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
44
|
+
/** Visual variant. @default 'default' */
|
|
45
|
+
variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
|
|
46
|
+
/** Allow selecting multiple options. @default false */
|
|
47
|
+
multiple = input(false, ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
|
|
48
|
+
/** Show a search box to filter options. @default false */
|
|
49
|
+
searchable = input(false, ...(ngDevMode ? [{ debugName: "searchable" }] : /* istanbul ignore next */ []));
|
|
50
|
+
/** Placeholder for the search box. @default 'Search…' */
|
|
51
|
+
searchPlaceholder = input('Search…', ...(ngDevMode ? [{ debugName: "searchPlaceholder" }] : /* istanbul ignore next */ []));
|
|
52
|
+
/** Text shown when a search yields no matches. @default 'No results found' */
|
|
53
|
+
noResultsText = input('No results found', ...(ngDevMode ? [{ debugName: "noResultsText" }] : /* istanbul ignore next */ []));
|
|
54
|
+
/** Show a clear (×) control. @default false */
|
|
55
|
+
clearable = input(false, ...(ngDevMode ? [{ debugName: "clearable" }] : /* istanbul ignore next */ []));
|
|
56
|
+
/** Disable the field. @default false */
|
|
57
|
+
disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
|
|
58
|
+
/** Show a loading state. @default false */
|
|
59
|
+
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
60
|
+
/** Mark the field as required. @default false */
|
|
61
|
+
required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
|
|
62
|
+
/** Show an "optional" tag next to the label. @default false */
|
|
63
|
+
optionalTag = input(false, ...(ngDevMode ? [{ debugName: "optionalTag" }] : /* istanbul ignore next */ []));
|
|
64
|
+
/** Max selectable options in multiple mode (0 = unlimited). @default 0 */
|
|
65
|
+
maxSelected = input(0, ...(ngDevMode ? [{ debugName: "maxSelected" }] : /* istanbul ignore next */ []));
|
|
66
|
+
/** Explicit id for the control (for external <label for>). */
|
|
67
|
+
inputId = input('', ...(ngDevMode ? [{ debugName: "inputId" }] : /* istanbul ignore next */ []));
|
|
68
|
+
/**
|
|
69
|
+
* Accessible name for the trigger when there is no visible `label`.
|
|
70
|
+
* Use this instead of a native `aria-label` on the host element (which is
|
|
71
|
+
* not a labelable role and would be an ARIA violation). Falls back to the
|
|
72
|
+
* `placeholder` so the combobox always has an accessible name.
|
|
73
|
+
*/
|
|
74
|
+
ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
75
|
+
/** Extra CSS classes for the control. */
|
|
76
|
+
customClass = input('', ...(ngDevMode ? [{ debugName: "customClass" }] : /* istanbul ignore next */ []));
|
|
77
|
+
// ── Outputs ────────────────────────────────────────────────────────────────
|
|
78
|
+
/** Emitted with the new selection (string or string[]). */
|
|
79
|
+
selectionChange = output();
|
|
80
|
+
/** Emitted when the dropdown opens. */
|
|
81
|
+
dropdownOpen = output();
|
|
82
|
+
/** Emitted when the dropdown closes. */
|
|
83
|
+
dropdownClose = output();
|
|
84
|
+
/** Emitted with the search query whenever it changes (for remote filtering). */
|
|
85
|
+
searchChange = output();
|
|
86
|
+
// ── Internal state ─────────────────────────────────────────────────────────
|
|
87
|
+
isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
|
|
88
|
+
searchQuery = signal('', ...(ngDevMode ? [{ debugName: "searchQuery" }] : /* istanbul ignore next */ []));
|
|
89
|
+
activeIndex = signal(-1, ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
|
|
90
|
+
isFocused = signal(false, ...(ngDevMode ? [{ debugName: "isFocused" }] : /* istanbul ignore next */ []));
|
|
91
|
+
/** How many chips to render; 999 = uncapped (all visible). */
|
|
92
|
+
visibleChipCount = signal(999, ...(ngDevMode ? [{ debugName: "visibleChipCount" }] : /* istanbul ignore next */ []));
|
|
93
|
+
// ── Panel portal style (set in computePanelPosition, consumed by the portal template) ──
|
|
94
|
+
panelStyle = signal({}, ...(ngDevMode ? [{ debugName: "panelStyle" }] : /* istanbul ignore next */ []));
|
|
95
|
+
/** Tracks disabled state set programmatically via formControl.disable(). */
|
|
96
|
+
_formDisabled = signal(false, ...(ngDevMode ? [{ debugName: "_formDisabled" }] : /* istanbul ignore next */ []));
|
|
97
|
+
/**
|
|
98
|
+
* Merged disabled state — true when either the [disabled] input or the
|
|
99
|
+
* parent FormControl is disabled. Use this everywhere instead of disabled().
|
|
100
|
+
*/
|
|
101
|
+
resolvedDisabled = computed(() => this.disabled() || this._formDisabled(), ...(ngDevMode ? [{ debugName: "resolvedDisabled" }] : /* istanbul ignore next */ []));
|
|
102
|
+
/** Stable unique fallback id so label + ARIA associations always work. */
|
|
103
|
+
_autoId = `sv-select-${++_svSelectUid}`;
|
|
104
|
+
/** Effective id used by the combobox, label and ARIA references. */
|
|
105
|
+
resolvedId = computed(() => this.inputId() || this._autoId, ...(ngDevMode ? [{ debugName: "resolvedId" }] : /* istanbul ignore next */ []));
|
|
106
|
+
/** id of the listbox popup (referenced by aria-controls). */
|
|
107
|
+
listboxId = computed(() => `${this.resolvedId()}-listbox`, ...(ngDevMode ? [{ debugName: "listboxId" }] : /* istanbul ignore next */ []));
|
|
108
|
+
/** id of the visible label (referenced by aria-labelledby). */
|
|
109
|
+
labelId = computed(() => `${this.resolvedId()}-label`, ...(ngDevMode ? [{ debugName: "labelId" }] : /* istanbul ignore next */ []));
|
|
110
|
+
/** id of the hint/error description (referenced by aria-describedby). */
|
|
111
|
+
describedById = computed(() => `${this.resolvedId()}-desc`, ...(ngDevMode ? [{ debugName: "describedById" }] : /* istanbul ignore next */ []));
|
|
112
|
+
/** True when a hint or error description is rendered. */
|
|
113
|
+
hasDescription = computed(() => !!this.errorMessage() || !!this.hint(), ...(ngDevMode ? [{ debugName: "hasDescription" }] : /* istanbul ignore next */ []));
|
|
114
|
+
/** DOM id for an option row — used by aria-activedescendant. */
|
|
115
|
+
optionDomId(value) {
|
|
116
|
+
return `${this.resolvedId()}-opt-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
|
117
|
+
}
|
|
118
|
+
/** id of the currently keyboard-active option, or null. */
|
|
119
|
+
activeDescendantId = computed(() => {
|
|
120
|
+
const active = this.activeOption();
|
|
121
|
+
return active ? this.optionDomId(active.value) : null;
|
|
122
|
+
}, ...(ngDevMode ? [{ debugName: "activeDescendantId" }] : /* istanbul ignore next */ []));
|
|
123
|
+
panelTpl;
|
|
124
|
+
searchRef = viewChild('searchInput', ...(ngDevMode ? [{ debugName: "searchRef" }] : /* istanbul ignore next */ []));
|
|
125
|
+
triggerRef = viewChild('trigger', ...(ngDevMode ? [{ debugName: "triggerRef" }] : /* istanbul ignore next */ []));
|
|
126
|
+
listRef = viewChild('listbox', ...(ngDevMode ? [{ debugName: "listRef" }] : /* istanbul ignore next */ []));
|
|
127
|
+
chipWrapperRef = viewChild('chipWrapper', ...(ngDevMode ? [{ debugName: "chipWrapperRef" }] : /* istanbul ignore next */ []));
|
|
128
|
+
host = inject((ElementRef));
|
|
129
|
+
appRef = inject(ApplicationRef);
|
|
130
|
+
/** Container div appended to document.body while the panel is open. */
|
|
131
|
+
portalEl = null;
|
|
132
|
+
/** Embedded view holding the panel template nodes. */
|
|
133
|
+
portalView = null;
|
|
134
|
+
/** Capture-phase scroll handler — registered once, removed on destroy. */
|
|
135
|
+
_boundScrollClose;
|
|
136
|
+
constructor() {
|
|
137
|
+
// When the selected values change in uncapped multi-mode, reset visible
|
|
138
|
+
// count to all, then measure which chips actually fit after the next render.
|
|
139
|
+
effect(() => {
|
|
140
|
+
this.selectedValues(); // track
|
|
141
|
+
if (this.multiple() && !this.maxSelected()) {
|
|
142
|
+
this.visibleChipCount.set(999);
|
|
143
|
+
setTimeout(() => this.measureVisibleChips());
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
this._boundScrollClose = (e) => {
|
|
147
|
+
if (!this.isOpen())
|
|
148
|
+
return;
|
|
149
|
+
if (this.portalEl?.contains(e.target))
|
|
150
|
+
return;
|
|
151
|
+
this.closeDropdown();
|
|
152
|
+
};
|
|
153
|
+
document.addEventListener('scroll', this._boundScrollClose, { capture: true, passive: true });
|
|
154
|
+
}
|
|
155
|
+
// CVA callbacks
|
|
156
|
+
onChange = () => { };
|
|
157
|
+
onTouched = () => { };
|
|
158
|
+
// ── Flatten all options (groups + flat) ────────────────────────────────────
|
|
159
|
+
allOptions = computed(() => {
|
|
160
|
+
const flat = this.options();
|
|
161
|
+
const grouped = this.groups().flatMap(g => g.options);
|
|
162
|
+
return [...flat, ...grouped];
|
|
163
|
+
}, ...(ngDevMode ? [{ debugName: "allOptions" }] : /* istanbul ignore next */ []));
|
|
164
|
+
// ── Filtered options / groups ──────────────────────────────────────────────
|
|
165
|
+
filteredOptions = computed(() => {
|
|
166
|
+
const q = this.searchQuery().toLowerCase().trim();
|
|
167
|
+
const opts = this.options();
|
|
168
|
+
if (!q)
|
|
169
|
+
return opts;
|
|
170
|
+
return opts.filter(o => o.label.toLowerCase().includes(q) || o.sublabel?.toLowerCase().includes(q));
|
|
171
|
+
}, ...(ngDevMode ? [{ debugName: "filteredOptions" }] : /* istanbul ignore next */ []));
|
|
172
|
+
filteredGroups = computed(() => {
|
|
173
|
+
const q = this.searchQuery().toLowerCase().trim();
|
|
174
|
+
const grps = this.groups();
|
|
175
|
+
if (!q)
|
|
176
|
+
return grps;
|
|
177
|
+
return grps
|
|
178
|
+
.map(g => ({
|
|
179
|
+
...g,
|
|
180
|
+
options: g.options.filter(o => o.label.toLowerCase().includes(q) || o.sublabel?.toLowerCase().includes(q)),
|
|
181
|
+
}))
|
|
182
|
+
.filter(g => g.options.length > 0);
|
|
183
|
+
}, ...(ngDevMode ? [{ debugName: "filteredGroups" }] : /* istanbul ignore next */ []));
|
|
184
|
+
noResults = computed(() => this.filteredOptions().length === 0 && this.filteredGroups().length === 0, ...(ngDevMode ? [{ debugName: "noResults" }] : /* istanbul ignore next */ []));
|
|
185
|
+
// ── Flat navigable list (for keyboard nav) ─────────────────────────────────
|
|
186
|
+
navigableOptions = computed(() => [
|
|
187
|
+
...this.filteredOptions(),
|
|
188
|
+
...this.filteredGroups().flatMap(g => g.options),
|
|
189
|
+
], ...(ngDevMode ? [{ debugName: "navigableOptions" }] : /* istanbul ignore next */ []));
|
|
190
|
+
// ── Selected option helpers ────────────────────────────────────────────────
|
|
191
|
+
selectedValues = computed(() => {
|
|
192
|
+
const v = this.value();
|
|
193
|
+
if (Array.isArray(v))
|
|
194
|
+
return v;
|
|
195
|
+
return v ? [v] : [];
|
|
196
|
+
}, ...(ngDevMode ? [{ debugName: "selectedValues" }] : /* istanbul ignore next */ []));
|
|
197
|
+
selectedOptions = computed(() => this.allOptions().filter(o => this.selectedValues().includes(o.value)), ...(ngDevMode ? [{ debugName: "selectedOptions" }] : /* istanbul ignore next */ []));
|
|
198
|
+
displayLabel = computed(() => {
|
|
199
|
+
const sel = this.selectedOptions();
|
|
200
|
+
if (!sel.length)
|
|
201
|
+
return '';
|
|
202
|
+
if (this.multiple())
|
|
203
|
+
return '';
|
|
204
|
+
return sel[0].label;
|
|
205
|
+
}, ...(ngDevMode ? [{ debugName: "displayLabel" }] : /* istanbul ignore next */ []));
|
|
206
|
+
hasValue = computed(() => this.selectedValues().length > 0, ...(ngDevMode ? [{ debugName: "hasValue" }] : /* istanbul ignore next */ []));
|
|
207
|
+
showClear = computed(() => this.clearable() && this.hasValue() && !this.resolvedDisabled() && !this.loading(), ...(ngDevMode ? [{ debugName: "showClear" }] : /* istanbul ignore next */ []));
|
|
208
|
+
// ── Overflow chip count ────────────────────────────────────────────────────
|
|
209
|
+
visibleChips = computed(() => {
|
|
210
|
+
const sel = this.selectedOptions();
|
|
211
|
+
// When maxSelected is set use legacy cap of 3; otherwise use measured count.
|
|
212
|
+
const cap = this.maxSelected() > 0 ? 3 : this.visibleChipCount();
|
|
213
|
+
return sel.slice(0, cap);
|
|
214
|
+
}, ...(ngDevMode ? [{ debugName: "visibleChips" }] : /* istanbul ignore next */ []));
|
|
215
|
+
overflowCount = computed(() => {
|
|
216
|
+
const total = this.selectedOptions().length;
|
|
217
|
+
const cap = this.maxSelected() > 0 ? 3 : this.visibleChipCount();
|
|
218
|
+
return Math.max(0, total - cap);
|
|
219
|
+
}, ...(ngDevMode ? [{ debugName: "overflowCount" }] : /* istanbul ignore next */ []));
|
|
220
|
+
// ── Max reached ───────────────────────────────────────────────────────────
|
|
221
|
+
maxReached = computed(() => this.multiple() && this.maxSelected() > 0 && this.selectedValues().length >= this.maxSelected(), ...(ngDevMode ? [{ debugName: "maxReached" }] : /* istanbul ignore next */ []));
|
|
222
|
+
// ── Trigger label ─────────────────────────────────────────────────────────
|
|
223
|
+
triggerText = computed(() => {
|
|
224
|
+
if (this.multiple())
|
|
225
|
+
return '';
|
|
226
|
+
return this.displayLabel() || '';
|
|
227
|
+
}, ...(ngDevMode ? [{ debugName: "triggerText" }] : /* istanbul ignore next */ []));
|
|
228
|
+
// ── CSS helpers ───────────────────────────────────────────────────────────
|
|
229
|
+
labelSizeClass = computed(() => ({
|
|
230
|
+
sm: 'text-xs',
|
|
231
|
+
md: 'text-sm',
|
|
232
|
+
lg: 'text-base',
|
|
233
|
+
})[this.size()], ...(ngDevMode ? [{ debugName: "labelSizeClass" }] : /* istanbul ignore next */ []));
|
|
234
|
+
triggerClasses = computed(() => {
|
|
235
|
+
const s = this.size();
|
|
236
|
+
const v = this.variant();
|
|
237
|
+
const err = !!this.errorMessage();
|
|
238
|
+
const focus = this.isFocused();
|
|
239
|
+
const dis = this.resolvedDisabled();
|
|
240
|
+
const isFloat = false;
|
|
241
|
+
const heights = isFloat
|
|
242
|
+
? { sm: 'min-h-12', md: 'min-h-14', lg: 'min-h-16' }
|
|
243
|
+
: { sm: 'min-h-8', md: 'min-h-10', lg: 'min-h-11' };
|
|
244
|
+
const pads = isFloat
|
|
245
|
+
? { sm: 'px-2.5 pt-1 pb-1', md: 'px-3 pt-3 pb-2', lg: 'px-4 pt-3 pb-2' }
|
|
246
|
+
: { sm: 'px-2.5 py-1', md: 'px-3 py-2', lg: 'px-4 py-2.5' };
|
|
247
|
+
const text = { sm: 'text-xs', md: 'text-sm', lg: 'text-base' };
|
|
248
|
+
const base = [
|
|
249
|
+
'relative flex w-full items-center gap-2 text-left transition-all duration-150',
|
|
250
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
251
|
+
heights[s], pads[s], text[s],
|
|
252
|
+
];
|
|
253
|
+
if (v === 'flushed') {
|
|
254
|
+
base.push('rounded-none border-0 border-b bg-transparent px-0', err
|
|
255
|
+
? 'border-red-500 dark:border-red-400'
|
|
256
|
+
: focus
|
|
257
|
+
? 'border-blue-500 dark:border-blue-400'
|
|
258
|
+
: 'border-gray-300 dark:border-gray-600', 'focus:outline-none');
|
|
259
|
+
}
|
|
260
|
+
else if (v === 'filled') {
|
|
261
|
+
base.push('rounded-md border', 'bg-gray-100 dark:bg-gray-800/60', err
|
|
262
|
+
? 'border-red-400 dark:border-red-500'
|
|
263
|
+
: focus
|
|
264
|
+
? 'border-blue-500 ring-2 ring-blue-500/20 dark:border-blue-400 dark:ring-blue-400/20'
|
|
265
|
+
: 'border-transparent hover:border-gray-300 dark:hover:border-gray-600', 'focus:outline-none');
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// default and floating — both use a bordered card style
|
|
269
|
+
base.push('rounded-md border bg-white dark:bg-gray-900', err
|
|
270
|
+
? 'border-red-400 dark:border-red-500 ring-1 ring-red-400/30'
|
|
271
|
+
: focus
|
|
272
|
+
? 'border-blue-500 ring-2 ring-blue-500/20 dark:border-blue-400 dark:ring-blue-400/20'
|
|
273
|
+
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600', 'focus:outline-none');
|
|
274
|
+
}
|
|
275
|
+
if (dis)
|
|
276
|
+
base.push('bg-gray-50 dark:bg-gray-800/40');
|
|
277
|
+
return base.join(' ');
|
|
278
|
+
}, ...(ngDevMode ? [{ debugName: "triggerClasses" }] : /* istanbul ignore next */ []));
|
|
279
|
+
iconSizeClass = computed(() => ({ sm: 'h-3.5 w-3.5', md: 'h-4 w-4', lg: 'h-5 w-5' })[this.size()], ...(ngDevMode ? [{ debugName: "iconSizeClass" }] : /* istanbul ignore next */ []));
|
|
280
|
+
chevronSizeClass = computed(() => ({ sm: 'h-3 w-3', md: 'h-4 w-4', lg: 'h-4 w-4' })[this.size()], ...(ngDevMode ? [{ debugName: "chevronSizeClass" }] : /* istanbul ignore next */ []));
|
|
281
|
+
chipTextClass = computed(() => ({ sm: 'text-xs', md: 'text-xs', lg: 'text-sm' })[this.size()], ...(ngDevMode ? [{ debugName: "chipTextClass" }] : /* istanbul ignore next */ []));
|
|
282
|
+
optionPadClass = computed(() => ({ sm: 'px-2.5 py-1.5', md: 'px-3 py-2', lg: 'px-4 py-2.5' })[this.size()], ...(ngDevMode ? [{ debugName: "optionPadClass" }] : /* istanbul ignore next */ []));
|
|
283
|
+
optionTextClass = computed(() => ({ sm: 'text-xs', md: 'text-sm', lg: 'text-base' })[this.size()], ...(ngDevMode ? [{ debugName: "optionTextClass" }] : /* istanbul ignore next */ []));
|
|
284
|
+
/**
|
|
285
|
+
* Static color-class map for option badge chips.
|
|
286
|
+
* `readonly` so the reference is stable; accessed directly in the template
|
|
287
|
+
* as `badgeColorMap[opt.badgeColor ?? 'gray']` — no method call needed.
|
|
288
|
+
*/
|
|
289
|
+
badgeColorMap = {
|
|
290
|
+
gray: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
|
291
|
+
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
292
|
+
green: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
|
293
|
+
red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
|
294
|
+
yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
|
295
|
+
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
|
296
|
+
};
|
|
297
|
+
// ── Dropdown / keyboard ────────────────────────────────────────────────────
|
|
298
|
+
openDropdown() {
|
|
299
|
+
if (this.resolvedDisabled() || this.loading())
|
|
300
|
+
return;
|
|
301
|
+
this.computePanelPosition();
|
|
302
|
+
this.isOpen.set(true);
|
|
303
|
+
this.activeIndex.set(-1);
|
|
304
|
+
this.dropdownOpen.emit();
|
|
305
|
+
if (this.panelTpl) {
|
|
306
|
+
this.portalEl = document.createElement('div');
|
|
307
|
+
this.portalEl.className = 'sv-sd-portal';
|
|
308
|
+
document.body.appendChild(this.portalEl);
|
|
309
|
+
this.portalView = this.panelTpl.createEmbeddedView({});
|
|
310
|
+
this.appRef.attachView(this.portalView);
|
|
311
|
+
this.portalView.rootNodes.forEach((n) => this.portalEl.appendChild(n));
|
|
312
|
+
this.portalView.detectChanges();
|
|
313
|
+
}
|
|
314
|
+
if (this.searchable()) {
|
|
315
|
+
setTimeout(() => this.searchRef()?.nativeElement.focus(), 30);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
closeDropdown() {
|
|
319
|
+
this.destroyPortal();
|
|
320
|
+
this.isOpen.set(false);
|
|
321
|
+
this.searchQuery.set('');
|
|
322
|
+
this.activeIndex.set(-1);
|
|
323
|
+
this.isFocused.set(false);
|
|
324
|
+
this.dropdownClose.emit();
|
|
325
|
+
}
|
|
326
|
+
toggleDropdown() {
|
|
327
|
+
this.isOpen() ? this.closeDropdown() : this.openDropdown();
|
|
328
|
+
}
|
|
329
|
+
ngOnDestroy() {
|
|
330
|
+
this.destroyPortal();
|
|
331
|
+
document.removeEventListener('scroll', this._boundScrollClose, { capture: true });
|
|
332
|
+
}
|
|
333
|
+
destroyPortal() {
|
|
334
|
+
if (this.portalView) {
|
|
335
|
+
this.appRef.detachView(this.portalView);
|
|
336
|
+
this.portalView.destroy();
|
|
337
|
+
this.portalView = null;
|
|
338
|
+
}
|
|
339
|
+
if (this.portalEl) {
|
|
340
|
+
this.portalEl.remove();
|
|
341
|
+
this.portalEl = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
onDocumentMousedown(e) {
|
|
345
|
+
const t = e.target;
|
|
346
|
+
if (this.host.nativeElement.contains(t))
|
|
347
|
+
return;
|
|
348
|
+
if (this.portalEl?.contains(t))
|
|
349
|
+
return;
|
|
350
|
+
this.closeDropdown();
|
|
351
|
+
}
|
|
352
|
+
onWindowScroll(e) {
|
|
353
|
+
if (this.isOpen() && !this.portalEl?.contains(e.target)) {
|
|
354
|
+
this.closeDropdown();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
onWindowResize() {
|
|
358
|
+
if (this.isOpen())
|
|
359
|
+
this.closeDropdown();
|
|
360
|
+
}
|
|
361
|
+
computePanelPosition() {
|
|
362
|
+
const trigger = this.triggerRef()?.nativeElement;
|
|
363
|
+
if (!trigger)
|
|
364
|
+
return;
|
|
365
|
+
const rect = trigger.getBoundingClientRect();
|
|
366
|
+
const vw = window.innerWidth;
|
|
367
|
+
const vh = window.innerHeight;
|
|
368
|
+
const MARGIN = 8;
|
|
369
|
+
const GAP = 4;
|
|
370
|
+
const PREFERRED_HEIGHT = 240;
|
|
371
|
+
const spaceBelow = vh - rect.bottom - MARGIN;
|
|
372
|
+
const spaceAbove = rect.top - MARGIN;
|
|
373
|
+
const openUpward = spaceBelow < PREFERRED_HEIGHT && spaceAbove > spaceBelow;
|
|
374
|
+
const alignRight = rect.left + rect.width > vw - MARGIN;
|
|
375
|
+
const maxHeight = Math.max(120, openUpward ? spaceAbove : spaceBelow);
|
|
376
|
+
const style = {
|
|
377
|
+
position: 'fixed',
|
|
378
|
+
zIndex: '9999',
|
|
379
|
+
width: `${rect.width}px`,
|
|
380
|
+
maxHeight: `${maxHeight}px`,
|
|
381
|
+
};
|
|
382
|
+
style[openUpward ? 'bottom' : 'top'] = openUpward
|
|
383
|
+
? `${vh - rect.top + GAP}px`
|
|
384
|
+
: `${rect.bottom + GAP}px`;
|
|
385
|
+
style[alignRight ? 'right' : 'left'] = alignRight
|
|
386
|
+
? `${vw - rect.right}px`
|
|
387
|
+
: `${rect.left}px`;
|
|
388
|
+
this.panelStyle.set(style);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Measures the chip wrapper element to determine how many chips fit in one
|
|
392
|
+
* row, then updates `visibleChipCount` accordingly. Called after render via
|
|
393
|
+
* a `setTimeout` inside the `effect` so DOM positions are available.
|
|
394
|
+
*/
|
|
395
|
+
measureVisibleChips() {
|
|
396
|
+
const wrapper = this.chipWrapperRef()?.nativeElement;
|
|
397
|
+
if (!wrapper)
|
|
398
|
+
return;
|
|
399
|
+
const chips = Array.from(wrapper.querySelectorAll('[data-chip]'));
|
|
400
|
+
if (!chips.length) {
|
|
401
|
+
this.visibleChipCount.set(0);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const wrapperRight = wrapper.getBoundingClientRect().right;
|
|
405
|
+
// Check if every chip fits inside the wrapper (no overflow)
|
|
406
|
+
const allFit = chips.every(c => c.getBoundingClientRect().right <= wrapperRight + 1);
|
|
407
|
+
if (allFit) {
|
|
408
|
+
this.visibleChipCount.set(chips.length);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Some chips overflow — find how many fit while leaving ~60 px for "+N more"
|
|
412
|
+
const BADGE_RESERVE = 60;
|
|
413
|
+
const availRight = wrapperRight - BADGE_RESERVE;
|
|
414
|
+
let count = 0;
|
|
415
|
+
for (const chip of chips) {
|
|
416
|
+
if (chip.getBoundingClientRect().right > availRight)
|
|
417
|
+
break;
|
|
418
|
+
count++;
|
|
419
|
+
}
|
|
420
|
+
this.visibleChipCount.set(Math.max(1, count));
|
|
421
|
+
}
|
|
422
|
+
onTriggerKeydown(e) {
|
|
423
|
+
if (['ArrowDown', 'ArrowUp', ' ', 'Enter', 'Home', 'End'].includes(e.key)) {
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
if (!this.isOpen()) {
|
|
426
|
+
this.openDropdown();
|
|
427
|
+
this.activeIndex.set(0);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
this.navigateList(e.key);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (e.key === 'Escape')
|
|
434
|
+
this.closeDropdown();
|
|
435
|
+
if (e.key === 'Tab')
|
|
436
|
+
this.closeDropdown();
|
|
437
|
+
}
|
|
438
|
+
onSearchKeydown(e) {
|
|
439
|
+
if (e.key === 'ArrowDown') {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
this.navigateList('ArrowDown');
|
|
442
|
+
}
|
|
443
|
+
if (e.key === 'ArrowUp') {
|
|
444
|
+
e.preventDefault();
|
|
445
|
+
this.navigateList('ArrowUp');
|
|
446
|
+
}
|
|
447
|
+
if (e.key === 'Home') {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
this.navigateList('Home');
|
|
450
|
+
}
|
|
451
|
+
if (e.key === 'End') {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
this.navigateList('End');
|
|
454
|
+
}
|
|
455
|
+
if (e.key === 'Enter') {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
this.selectActive();
|
|
458
|
+
}
|
|
459
|
+
if (e.key === 'Escape') {
|
|
460
|
+
this.closeDropdown();
|
|
461
|
+
this.triggerRef()?.nativeElement.focus();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** Updates the search query and notifies listeners (for remote filtering). */
|
|
465
|
+
setSearch(q) {
|
|
466
|
+
this.searchQuery.set(q);
|
|
467
|
+
this.activeIndex.set(-1);
|
|
468
|
+
this.searchChange.emit(q);
|
|
469
|
+
}
|
|
470
|
+
navigateList(key) {
|
|
471
|
+
const opts = this.navigableOptions().filter(o => !o.disabled);
|
|
472
|
+
if (!opts.length)
|
|
473
|
+
return;
|
|
474
|
+
const cur = this.activeIndex();
|
|
475
|
+
let next = cur;
|
|
476
|
+
if (key === 'ArrowDown' || key === ' ')
|
|
477
|
+
next = cur < opts.length - 1 ? cur + 1 : 0;
|
|
478
|
+
if (key === 'ArrowUp')
|
|
479
|
+
next = cur > 0 ? cur - 1 : opts.length - 1;
|
|
480
|
+
if (key === 'Home')
|
|
481
|
+
next = 0;
|
|
482
|
+
if (key === 'End')
|
|
483
|
+
next = opts.length - 1;
|
|
484
|
+
if (key === 'Enter') {
|
|
485
|
+
this.selectActive();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.activeIndex.set(next);
|
|
489
|
+
this.scrollActiveIntoView();
|
|
490
|
+
}
|
|
491
|
+
selectActive() {
|
|
492
|
+
const opts = this.navigableOptions().filter(o => !o.disabled);
|
|
493
|
+
const idx = this.activeIndex();
|
|
494
|
+
if (idx >= 0 && idx < opts.length)
|
|
495
|
+
this.selectOption(opts[idx]);
|
|
496
|
+
}
|
|
497
|
+
scrollActiveIntoView() {
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
const list = this.listRef()?.nativeElement;
|
|
500
|
+
if (!list)
|
|
501
|
+
return;
|
|
502
|
+
const active = list.querySelector('[data-active="true"]');
|
|
503
|
+
active?.scrollIntoView({ block: 'nearest' });
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
selectOption(opt) {
|
|
507
|
+
if (opt.disabled)
|
|
508
|
+
return;
|
|
509
|
+
if (this.multiple()) {
|
|
510
|
+
const current = [...this.selectedValues()];
|
|
511
|
+
const idx = current.indexOf(opt.value);
|
|
512
|
+
if (idx > -1) {
|
|
513
|
+
current.splice(idx, 1);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
if (this.maxReached())
|
|
517
|
+
return;
|
|
518
|
+
current.push(opt.value);
|
|
519
|
+
}
|
|
520
|
+
this.value.set(current);
|
|
521
|
+
this.onChange(current);
|
|
522
|
+
this.selectionChange.emit(current);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
this.value.set(opt.value);
|
|
526
|
+
this.onChange(opt.value);
|
|
527
|
+
this.selectionChange.emit(opt.value);
|
|
528
|
+
this.closeDropdown();
|
|
529
|
+
this.triggerRef()?.nativeElement.focus();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
removeChip(value, e) {
|
|
533
|
+
e.stopPropagation();
|
|
534
|
+
if (this.resolvedDisabled())
|
|
535
|
+
return;
|
|
536
|
+
const current = this.selectedValues().filter(v => v !== value);
|
|
537
|
+
this.value.set(current);
|
|
538
|
+
this.onChange(current);
|
|
539
|
+
this.selectionChange.emit(current);
|
|
540
|
+
}
|
|
541
|
+
clearAll(e) {
|
|
542
|
+
e.stopPropagation();
|
|
543
|
+
const empty = this.multiple() ? [] : '';
|
|
544
|
+
this.value.set(empty);
|
|
545
|
+
this.onChange(empty);
|
|
546
|
+
this.selectionChange.emit(empty);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The currently keyboard-active option, or `null` when nothing is active.
|
|
550
|
+
* Computed once per change-detection cycle so the template can do a cheap
|
|
551
|
+
* identity check (`opt === activeOption()`) instead of calling the O(n)
|
|
552
|
+
* `isActive(opt)` method for every option row.
|
|
553
|
+
*/
|
|
554
|
+
activeOption = computed(() => {
|
|
555
|
+
const opts = this.navigableOptions().filter(o => !o.disabled);
|
|
556
|
+
const idx = this.activeIndex();
|
|
557
|
+
return (idx >= 0 && idx < opts.length) ? opts[idx] : null;
|
|
558
|
+
}, ...(ngDevMode ? [{ debugName: "activeOption" }] : /* istanbul ignore next */ []));
|
|
559
|
+
isSelected(value) {
|
|
560
|
+
return this.selectedValues().includes(value);
|
|
561
|
+
}
|
|
562
|
+
// ── Floating label helpers ────────────────────────────────────────────
|
|
563
|
+
isFloating = computed(() => false, ...(ngDevMode ? [{ debugName: "isFloating" }] : /* istanbul ignore next */ []));
|
|
564
|
+
labelIsFloated = computed(() => this.isOpen() || this.hasValue(), ...(ngDevMode ? [{ debugName: "labelIsFloated" }] : /* istanbul ignore next */ []));
|
|
565
|
+
floatingLabelClasses = computed(() => {
|
|
566
|
+
const floated = this.labelIsFloated();
|
|
567
|
+
const err = !!this.errorMessage();
|
|
568
|
+
const active = this.isFocused() || this.isOpen();
|
|
569
|
+
const notFloatedLeft = 'left-3.5';
|
|
570
|
+
const floatedLeft = 'left-3';
|
|
571
|
+
const leftClass = floated ? floatedLeft : notFloatedLeft;
|
|
572
|
+
const topClass = floated ? '-top-px' : 'top-1/2';
|
|
573
|
+
const textSize = floated ? 'text-[0.7rem] leading-none' : this.labelSizeClass();
|
|
574
|
+
const bg = floated ? 'px-1 bg-white dark:bg-gray-900' : '';
|
|
575
|
+
let color;
|
|
576
|
+
if (floated && active) {
|
|
577
|
+
color = err ? 'text-red-500' : 'text-blue-600 dark:text-blue-400';
|
|
578
|
+
}
|
|
579
|
+
else if (floated) {
|
|
580
|
+
color = err ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-gray-400';
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
color = 'text-gray-500 dark:text-gray-400';
|
|
584
|
+
}
|
|
585
|
+
return [
|
|
586
|
+
'absolute z-10 pointer-events-none select-none -translate-y-1/2',
|
|
587
|
+
'transition-[top,left,font-size,color,padding,background-color] duration-200 ease-in-out',
|
|
588
|
+
`${leftClass} ${topClass}`,
|
|
589
|
+
textSize, bg, color,
|
|
590
|
+
].filter(Boolean).join(' ');
|
|
591
|
+
}, ...(ngDevMode ? [{ debugName: "floatingLabelClasses" }] : /* istanbul ignore next */ []));
|
|
592
|
+
onFocus() { this.isFocused.set(true); }
|
|
593
|
+
onBlur() { this.onTouched(); }
|
|
594
|
+
// ── ControlValueAccessor ───────────────────────────────────────────────────
|
|
595
|
+
writeValue(v) {
|
|
596
|
+
this.value.set(v ?? (this.multiple() ? [] : ''));
|
|
597
|
+
}
|
|
598
|
+
registerOnChange(fn) { this.onChange = fn; }
|
|
599
|
+
registerOnTouched(fn) { this.onTouched = fn; }
|
|
600
|
+
setDisabledState(isDisabled) { this._formDisabled.set(isDisabled); }
|
|
601
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvSelectDropdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
602
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: SvSelectDropdownComponent, isStandalone: true, selector: "sv-select-dropdown", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, groups: { classPropertyName: "groups", publicName: "groups", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, errorMessage: { classPropertyName: "errorMessage", publicName: "errorMessage", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, searchable: { classPropertyName: "searchable", publicName: "searchable", isSignal: true, isRequired: false, transformFunction: null }, searchPlaceholder: { classPropertyName: "searchPlaceholder", publicName: "searchPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, clearable: { classPropertyName: "clearable", publicName: "clearable", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, optionalTag: { classPropertyName: "optionalTag", publicName: "optionalTag", isSignal: true, isRequired: false, transformFunction: null }, maxSelected: { classPropertyName: "maxSelected", publicName: "maxSelected", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, customClass: { classPropertyName: "customClass", publicName: "customClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", selectionChange: "selectionChange", dropdownOpen: "dropdownOpen", dropdownClose: "dropdownClose", searchChange: "searchChange" }, host: { listeners: { "document:mousedown": "onDocumentMousedown($event)", "window:scroll": "onWindowScroll($event)", "window:resize": "onWindowResize()" } }, providers: [
|
|
603
|
+
{
|
|
604
|
+
provide: NG_VALUE_ACCESSOR,
|
|
605
|
+
useExisting: forwardRef(() => SvSelectDropdownComponent),
|
|
606
|
+
multi: true,
|
|
607
|
+
},
|
|
608
|
+
], viewQueries: [{ propertyName: "searchRef", first: true, predicate: ["searchInput"], descendants: true, isSignal: true }, { propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }, { propertyName: "listRef", first: true, predicate: ["listbox"], descendants: true, isSignal: true }, { propertyName: "chipWrapperRef", first: true, predicate: ["chipWrapper"], descendants: true, isSignal: true }, { propertyName: "panelTpl", first: true, predicate: ["panelTpl"], descendants: true, read: TemplateRef }], ngImport: i0, template: "<!-- Root wrapper -->\n<div [ngClass]=\"['flex flex-col gap-1.5 w-full', customClass()]\">\n\n <!-- Label row (hidden for floating variant) -->\n @if (label()) {\n <div class=\"flex items-baseline justify-between gap-2\">\n <label\n [attr.id]=\"labelId()\"\n [ngClass]=\"['font-medium text-gray-700 dark:text-gray-200', labelSizeClass()]\"\n [attr.for]=\"resolvedId()\"\n >\n {{ label() }}\n @if (required()) {\n <span class=\"ml-0.5 text-red-500\" aria-hidden=\"true\">*</span>\n }\n @if (optionalTag() && !required()) {\n <span class=\"ml-1.5 font-normal text-gray-500 dark:text-gray-400 text-xs\">(optional)</span>\n }\n </label>\n </div>\n }\n\n <!-- Trigger button -->\n <div class=\"relative\">\n\n <!-- Floating label (renders inside trigger wrapper, animates to border on open/value) -->\n @if (isFloating() && label()) {\n <label\n [attr.id]=\"labelId()\"\n [ngClass]=\"floatingLabelClasses()\"\n [attr.for]=\"resolvedId()\"\n >\n {{ label() }}\n @if (required()) {\n <span class=\"ml-0.5 text-red-500\" aria-hidden=\"true\">*</span>\n }\n @if (optionalTag() && !required()) {\n <span class=\"ml-1 font-normal text-[0.65rem]\" aria-hidden=\"true\">(opt)</span>\n }\n </label>\n }\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n [attr.id]=\"resolvedId()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? listboxId() : null\"\n [attr.aria-activedescendant]=\"isOpen() ? activeDescendantId() : null\"\n [attr.aria-required]=\"required() || null\"\n [attr.aria-invalid]=\"!!errorMessage() || null\"\n [attr.aria-labelledby]=\"label() ? labelId() : null\"\n [attr.aria-label]=\"label() ? null : (ariaLabel() || placeholder())\"\n [attr.aria-describedby]=\"hasDescription() ? describedById() : null\"\n [disabled]=\"resolvedDisabled() || loading()\"\n [ngClass]=\"triggerClasses()\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onTriggerKeydown($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n >\n\n <!-- Loading spinner -->\n @if (loading()) {\n <span class=\"flex shrink-0 items-center text-gray-400\" aria-hidden=\"true\">\n <svg [ngClass]=\"iconSizeClass()\" class=\"animate-spin\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"/>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"/>\n </svg>\n </span>\n }\n\n <!-- Selected value(s) display -->\n <span class=\"flex min-w-0 flex-1 items-center gap-1.5\">\n\n @if (multiple()) {\n <!-- Multi: single-row chip strip (clips overflow visually) -->\n <span #chipWrapper\n class=\"flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-hidden\">\n @for (chip of visibleChips(); track chip.value) {\n <span\n data-chip\n [ngClass]=\"[\n 'inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-100',\n 'text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200',\n chipTextClass(), 'px-1.5 py-0.5 font-medium leading-none'\n ]\"\n >\n @if (chip.iconPath) {\n <svg [ngClass]=\"'h-3 w-3'\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"chip.iconPath\"/>\n </svg>\n }\n {{ chip.label }}\n @if (!resolvedDisabled()) {\n <button\n type=\"button\"\n class=\"ml-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200\n focus:outline-none focus-visible:ring-1 focus-visible:ring-primary-400\"\n [attr.aria-label]=\"'Remove ' + chip.label\"\n (click)=\"removeChip(chip.value, $event)\"\n tabindex=\"-1\"\n >\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </button>\n }\n </span>\n }\n\n @if (!hasValue() && (!isFloating() || labelIsFloated())) {\n <span class=\"text-gray-500 dark:text-gray-400 truncate\">{{ placeholder() }}</span>\n }\n </span>\n\n <!-- Overflow badge \u2014 lives OUTSIDE the clipping wrapper so it's always visible -->\n @if (overflowCount() > 0) {\n <span [ngClass]=\"[chipTextClass(), 'shrink-0 text-gray-500 dark:text-gray-400 font-medium']\">\n +{{ overflowCount() }} more\n </span>\n }\n\n } @else {\n <!-- Single: label or placeholder -->\n @if (displayLabel()) {\n @let selectedOpt = selectedOptions()[0];\n <span class=\"flex min-w-0 items-center gap-2\">\n @if (selectedOpt?.avatarUrl) {\n <img [src]=\"selectedOpt.avatarUrl\" alt=\"\"\n class=\"h-5 w-5 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (selectedOpt?.iconPath) {\n <svg [ngClass]=\"iconSizeClass()\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"selectedOpt.iconPath\"/>\n </svg>\n }\n <span class=\"truncate text-gray-900 dark:text-gray-100\">{{ displayLabel() }}</span>\n </span>\n } @else if (!isFloating() || labelIsFloated()) {\n <span class=\"truncate text-gray-500 dark:text-gray-400\">{{ placeholder() }}</span>\n }\n }\n </span>\n\n <!-- Right-side controls -->\n <span class=\"ml-auto flex shrink-0 items-center gap-1 pl-1\">\n\n <!-- Clear button -->\n @if (showClear()) {\n <span\n role=\"button\"\n tabindex=\"-1\"\n class=\"flex items-center rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\n focus:outline-none focus-visible:ring-1 focus-visible:ring-primary-400\"\n aria-label=\"Clear selection\"\n (click)=\"clearAll($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg [ngClass]=\"chevronSizeClass()\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </span>\n <span class=\"h-4 w-px bg-gray-300 dark:bg-gray-600\"></span>\n }\n\n <!-- Chevron -->\n <svg\n [ngClass]=\"[chevronSizeClass(), 'text-gray-400 transition-transform duration-200', isOpen() ? 'rotate-180' : '']\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m6 9 6 6 6-6\"/>\n </svg>\n\n </span>\n </button>\n\n <!-- \u2500\u2500 Dropdown panel \u2014 portaled into document.body \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <ng-template #panelTpl>\n <div\n class=\"flex flex-col overflow-hidden rounded-xl border\n border-gray-200 bg-white shadow-xl shadow-gray-900/10\n dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-900/40\"\n [style.position]=\"panelStyle()['position']\"\n [style.zIndex]=\"panelStyle()['zIndex']\"\n [style.top]=\"panelStyle()['top']\"\n [style.bottom]=\"panelStyle()['bottom']\"\n [style.left]=\"panelStyle()['left']\"\n [style.right]=\"panelStyle()['right']\"\n [style.width]=\"panelStyle()['width']\"\n [style.maxHeight]=\"panelStyle()['maxHeight']\"\n role=\"dialog\"\n aria-modal=\"false\"\n >\n\n <!-- Search input -->\n @if (searchable()) {\n <div class=\"border-b border-gray-100 px-3 py-2.5 dark:border-gray-800\">\n <div class=\"flex items-center gap-2\">\n <svg class=\"h-4 w-4 shrink-0 text-gray-400\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"m21 21-4.35-4.35\"/>\n </svg>\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n [attr.aria-expanded]=\"true\"\n [attr.aria-controls]=\"listboxId()\"\n [attr.aria-activedescendant]=\"activeDescendantId()\"\n [attr.aria-label]=\"label() || 'Search options'\"\n class=\"flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400\n outline-none dark:text-gray-100 dark:placeholder-gray-500\"\n [attr.placeholder]=\"searchPlaceholder()\"\n autocomplete=\"off\"\n [value]=\"searchQuery()\"\n (input)=\"setSearch($any($event.target).value)\"\n (keydown)=\"onSearchKeydown($event)\"\n />\n @if (searchQuery()) {\n <button type=\"button\"\n class=\"text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\"\n (click)=\"setSearch('')\" tabindex=\"-1\" aria-label=\"Clear search\">\n <svg class=\"h-3.5 w-3.5\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </button>\n }\n </div>\n </div>\n }\n\n <!-- Multi-select count bar -->\n @if (multiple() && maxSelected() > 0) {\n <div class=\"border-b border-gray-100 px-3 py-2 dark:border-gray-800\">\n <p class=\"text-xs text-gray-500 dark:text-gray-400\" aria-live=\"polite\">\n {{ selectedValues().length }} / {{ maxSelected() }} selected\n </p>\n </div>\n }\n\n <!-- Options list -->\n <ul\n #listbox\n [attr.id]=\"listboxId()\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.aria-label]=\"label() || placeholder()\"\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain py-1.5\"\n >\n\n @if (noResults()) {\n <li class=\"px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400\">\n <svg class=\"mx-auto mb-2 h-8 w-8 opacity-40\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"1.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"m21 21-4.35-4.35\"/>\n </svg>\n {{ noResultsText() }}\n </li>\n }\n\n <!-- Flat options -->\n @for (opt of filteredOptions(); track opt.value) {\n <li\n role=\"option\"\n [attr.id]=\"optionDomId(opt.value)\"\n [attr.aria-selected]=\"isSelected(opt.value)\"\n [attr.aria-disabled]=\"opt.disabled || null\"\n [attr.data-active]=\"opt === activeOption()\"\n [ngClass]=\"[\n 'flex cursor-pointer items-center gap-3 transition-colors duration-100',\n optionPadClass(), optionTextClass(),\n opt.disabled\n ? 'cursor-not-allowed opacity-40'\n : opt === activeOption()\n ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'\n : 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/60'\n ]\"\n (click)=\"selectOption(opt)\"\n (mouseenter)=\"activeIndex.set(navigableOptions().indexOf(opt))\"\n >\n\n <!-- Checkbox for multi -->\n @if (multiple()) {\n <span\n [ngClass]=\"[\n 'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',\n isSelected(opt.value)\n ? 'border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400'\n : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'\n ]\"\n aria-hidden=\"true\"\n >\n @if (isSelected(opt.value)) {\n <svg class=\"h-2.5 w-2.5 text-white dark:text-gray-900\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"3\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </span>\n }\n\n <!-- Avatar -->\n @if (opt.avatarUrl) {\n <img [src]=\"opt.avatarUrl\" alt=\"\" class=\"h-6 w-6 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (opt.iconPath) {\n <svg [ngClass]=\"['shrink-0', iconSizeClass()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"opt.iconPath\"/>\n </svg>\n }\n\n <!-- Label + sublabel -->\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate font-medium\">{{ opt.label }}</span>\n @if (opt.sublabel) {\n <span class=\"block truncate text-xs text-gray-500 dark:text-gray-400\">{{ opt.sublabel }}</span>\n }\n </span>\n\n <!-- Badge -->\n @if (opt.badge) {\n <span [ngClass]=\"['shrink-0 rounded-full px-2 py-0.5 text-xs font-medium', badgeColorMap[opt.badgeColor ?? 'gray'] ?? badgeColorMap['gray']]\">\n {{ opt.badge }}\n </span>\n }\n\n <!-- Checkmark for single -->\n @if (!multiple() && isSelected(opt.value)) {\n <svg class=\"ml-auto h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n\n </li>\n }\n\n <!-- Grouped options -->\n @for (group of filteredGroups(); track group.label) {\n <li role=\"presentation\">\n <p class=\"px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 first:pt-1.5\">\n {{ group.label }}\n </p>\n <ul role=\"group\" [attr.aria-label]=\"group.label\">\n @for (opt of group.options; track opt.value) {\n <li\n role=\"option\"\n [attr.id]=\"optionDomId(opt.value)\"\n [attr.aria-selected]=\"isSelected(opt.value)\"\n [attr.aria-disabled]=\"opt.disabled || null\"\n [attr.data-active]=\"opt === activeOption()\"\n [ngClass]=\"[\n 'flex cursor-pointer items-center gap-3 transition-colors duration-100',\n optionPadClass(), optionTextClass(),\n opt.disabled\n ? 'cursor-not-allowed opacity-40'\n : opt === activeOption()\n ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'\n : 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/60'\n ]\"\n (click)=\"selectOption(opt)\"\n (mouseenter)=\"activeIndex.set(navigableOptions().indexOf(opt))\"\n >\n @if (multiple()) {\n <span\n [ngClass]=\"[\n 'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',\n isSelected(opt.value)\n ? 'border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400'\n : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'\n ]\"\n aria-hidden=\"true\"\n >\n @if (isSelected(opt.value)) {\n <svg class=\"h-2.5 w-2.5 text-white dark:text-gray-900\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"3\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </span>\n }\n @if (opt.avatarUrl) {\n <img [src]=\"opt.avatarUrl\" alt=\"\" class=\"h-6 w-6 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (opt.iconPath) {\n <svg [ngClass]=\"['shrink-0', iconSizeClass()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"opt.iconPath\"/>\n </svg>\n }\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate font-medium\">{{ opt.label }}</span>\n @if (opt.sublabel) {\n <span class=\"block truncate text-xs text-gray-500 dark:text-gray-400\">{{ opt.sublabel }}</span>\n }\n </span>\n @if (opt.badge) {\n <span [ngClass]=\"['shrink-0 rounded-full px-2 py-0.5 text-xs font-medium', badgeColorMap[opt.badgeColor ?? 'gray'] ?? badgeColorMap['gray']]\">\n {{ opt.badge }}\n </span>\n }\n @if (!multiple() && isSelected(opt.value)) {\n <svg class=\"ml-auto h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </li>\n }\n </ul>\n </li>\n }\n\n </ul>\n\n <!-- Footer slot: multi select-all / clear -->\n @if (multiple() && hasValue()) {\n <div class=\"border-t border-gray-100 px-3 py-2 dark:border-gray-800\">\n <button\n type=\"button\"\n class=\"text-xs font-medium text-gray-500 hover:text-gray-700\n dark:text-gray-400 dark:hover:text-gray-200 transition-colors\"\n (click)=\"clearAll($event)\"\n >\n Clear all ({{ selectedValues().length }})\n </button>\n </div>\n }\n\n </div>\n </ng-template>\n\n </div><!-- /relative -->\n\n <!-- Hint / error -->\n @if (errorMessage() || hint()) {\n <div>\n @if (errorMessage()) {\n <p [attr.id]=\"describedById()\" class=\"flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400\" role=\"alert\">\n <svg class=\"h-3.5 w-3.5 shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/>\n <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n {{ errorMessage() }}\n </p>\n } @else if (hint()) {\n <p [attr.id]=\"describedById()\" class=\"text-xs text-gray-500 dark:text-gray-400\">{{ hint() }}</p>\n }\n </div>\n }\n\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
609
|
+
}
|
|
610
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvSelectDropdownComponent, decorators: [{
|
|
611
|
+
type: Component,
|
|
612
|
+
args: [{ selector: 'sv-select-dropdown', standalone: true, imports: [NgClass], changeDetection: ChangeDetectionStrategy.OnPush, providers: [
|
|
613
|
+
{
|
|
614
|
+
provide: NG_VALUE_ACCESSOR,
|
|
615
|
+
useExisting: forwardRef(() => SvSelectDropdownComponent),
|
|
616
|
+
multi: true,
|
|
617
|
+
},
|
|
618
|
+
], template: "<!-- Root wrapper -->\n<div [ngClass]=\"['flex flex-col gap-1.5 w-full', customClass()]\">\n\n <!-- Label row (hidden for floating variant) -->\n @if (label()) {\n <div class=\"flex items-baseline justify-between gap-2\">\n <label\n [attr.id]=\"labelId()\"\n [ngClass]=\"['font-medium text-gray-700 dark:text-gray-200', labelSizeClass()]\"\n [attr.for]=\"resolvedId()\"\n >\n {{ label() }}\n @if (required()) {\n <span class=\"ml-0.5 text-red-500\" aria-hidden=\"true\">*</span>\n }\n @if (optionalTag() && !required()) {\n <span class=\"ml-1.5 font-normal text-gray-500 dark:text-gray-400 text-xs\">(optional)</span>\n }\n </label>\n </div>\n }\n\n <!-- Trigger button -->\n <div class=\"relative\">\n\n <!-- Floating label (renders inside trigger wrapper, animates to border on open/value) -->\n @if (isFloating() && label()) {\n <label\n [attr.id]=\"labelId()\"\n [ngClass]=\"floatingLabelClasses()\"\n [attr.for]=\"resolvedId()\"\n >\n {{ label() }}\n @if (required()) {\n <span class=\"ml-0.5 text-red-500\" aria-hidden=\"true\">*</span>\n }\n @if (optionalTag() && !required()) {\n <span class=\"ml-1 font-normal text-[0.65rem]\" aria-hidden=\"true\">(opt)</span>\n }\n </label>\n }\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n [attr.id]=\"resolvedId()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? listboxId() : null\"\n [attr.aria-activedescendant]=\"isOpen() ? activeDescendantId() : null\"\n [attr.aria-required]=\"required() || null\"\n [attr.aria-invalid]=\"!!errorMessage() || null\"\n [attr.aria-labelledby]=\"label() ? labelId() : null\"\n [attr.aria-label]=\"label() ? null : (ariaLabel() || placeholder())\"\n [attr.aria-describedby]=\"hasDescription() ? describedById() : null\"\n [disabled]=\"resolvedDisabled() || loading()\"\n [ngClass]=\"triggerClasses()\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onTriggerKeydown($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n >\n\n <!-- Loading spinner -->\n @if (loading()) {\n <span class=\"flex shrink-0 items-center text-gray-400\" aria-hidden=\"true\">\n <svg [ngClass]=\"iconSizeClass()\" class=\"animate-spin\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"/>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"/>\n </svg>\n </span>\n }\n\n <!-- Selected value(s) display -->\n <span class=\"flex min-w-0 flex-1 items-center gap-1.5\">\n\n @if (multiple()) {\n <!-- Multi: single-row chip strip (clips overflow visually) -->\n <span #chipWrapper\n class=\"flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-hidden\">\n @for (chip of visibleChips(); track chip.value) {\n <span\n data-chip\n [ngClass]=\"[\n 'inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-100',\n 'text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200',\n chipTextClass(), 'px-1.5 py-0.5 font-medium leading-none'\n ]\"\n >\n @if (chip.iconPath) {\n <svg [ngClass]=\"'h-3 w-3'\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"chip.iconPath\"/>\n </svg>\n }\n {{ chip.label }}\n @if (!resolvedDisabled()) {\n <button\n type=\"button\"\n class=\"ml-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200\n focus:outline-none focus-visible:ring-1 focus-visible:ring-primary-400\"\n [attr.aria-label]=\"'Remove ' + chip.label\"\n (click)=\"removeChip(chip.value, $event)\"\n tabindex=\"-1\"\n >\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </button>\n }\n </span>\n }\n\n @if (!hasValue() && (!isFloating() || labelIsFloated())) {\n <span class=\"text-gray-500 dark:text-gray-400 truncate\">{{ placeholder() }}</span>\n }\n </span>\n\n <!-- Overflow badge \u2014 lives OUTSIDE the clipping wrapper so it's always visible -->\n @if (overflowCount() > 0) {\n <span [ngClass]=\"[chipTextClass(), 'shrink-0 text-gray-500 dark:text-gray-400 font-medium']\">\n +{{ overflowCount() }} more\n </span>\n }\n\n } @else {\n <!-- Single: label or placeholder -->\n @if (displayLabel()) {\n @let selectedOpt = selectedOptions()[0];\n <span class=\"flex min-w-0 items-center gap-2\">\n @if (selectedOpt?.avatarUrl) {\n <img [src]=\"selectedOpt.avatarUrl\" alt=\"\"\n class=\"h-5 w-5 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (selectedOpt?.iconPath) {\n <svg [ngClass]=\"iconSizeClass()\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"selectedOpt.iconPath\"/>\n </svg>\n }\n <span class=\"truncate text-gray-900 dark:text-gray-100\">{{ displayLabel() }}</span>\n </span>\n } @else if (!isFloating() || labelIsFloated()) {\n <span class=\"truncate text-gray-500 dark:text-gray-400\">{{ placeholder() }}</span>\n }\n }\n </span>\n\n <!-- Right-side controls -->\n <span class=\"ml-auto flex shrink-0 items-center gap-1 pl-1\">\n\n <!-- Clear button -->\n @if (showClear()) {\n <span\n role=\"button\"\n tabindex=\"-1\"\n class=\"flex items-center rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\n focus:outline-none focus-visible:ring-1 focus-visible:ring-primary-400\"\n aria-label=\"Clear selection\"\n (click)=\"clearAll($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg [ngClass]=\"chevronSizeClass()\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </span>\n <span class=\"h-4 w-px bg-gray-300 dark:bg-gray-600\"></span>\n }\n\n <!-- Chevron -->\n <svg\n [ngClass]=\"[chevronSizeClass(), 'text-gray-400 transition-transform duration-200', isOpen() ? 'rotate-180' : '']\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m6 9 6 6 6-6\"/>\n </svg>\n\n </span>\n </button>\n\n <!-- \u2500\u2500 Dropdown panel \u2014 portaled into document.body \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <ng-template #panelTpl>\n <div\n class=\"flex flex-col overflow-hidden rounded-xl border\n border-gray-200 bg-white shadow-xl shadow-gray-900/10\n dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-900/40\"\n [style.position]=\"panelStyle()['position']\"\n [style.zIndex]=\"panelStyle()['zIndex']\"\n [style.top]=\"panelStyle()['top']\"\n [style.bottom]=\"panelStyle()['bottom']\"\n [style.left]=\"panelStyle()['left']\"\n [style.right]=\"panelStyle()['right']\"\n [style.width]=\"panelStyle()['width']\"\n [style.maxHeight]=\"panelStyle()['maxHeight']\"\n role=\"dialog\"\n aria-modal=\"false\"\n >\n\n <!-- Search input -->\n @if (searchable()) {\n <div class=\"border-b border-gray-100 px-3 py-2.5 dark:border-gray-800\">\n <div class=\"flex items-center gap-2\">\n <svg class=\"h-4 w-4 shrink-0 text-gray-400\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"m21 21-4.35-4.35\"/>\n </svg>\n <input\n #searchInput\n type=\"text\"\n role=\"combobox\"\n [attr.aria-expanded]=\"true\"\n [attr.aria-controls]=\"listboxId()\"\n [attr.aria-activedescendant]=\"activeDescendantId()\"\n [attr.aria-label]=\"label() || 'Search options'\"\n class=\"flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400\n outline-none dark:text-gray-100 dark:placeholder-gray-500\"\n [attr.placeholder]=\"searchPlaceholder()\"\n autocomplete=\"off\"\n [value]=\"searchQuery()\"\n (input)=\"setSearch($any($event.target).value)\"\n (keydown)=\"onSearchKeydown($event)\"\n />\n @if (searchQuery()) {\n <button type=\"button\"\n class=\"text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\"\n (click)=\"setSearch('')\" tabindex=\"-1\" aria-label=\"Clear search\">\n <svg class=\"h-3.5 w-3.5\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18 6 6 18M6 6l12 12\"/>\n </svg>\n </button>\n }\n </div>\n </div>\n }\n\n <!-- Multi-select count bar -->\n @if (multiple() && maxSelected() > 0) {\n <div class=\"border-b border-gray-100 px-3 py-2 dark:border-gray-800\">\n <p class=\"text-xs text-gray-500 dark:text-gray-400\" aria-live=\"polite\">\n {{ selectedValues().length }} / {{ maxSelected() }} selected\n </p>\n </div>\n }\n\n <!-- Options list -->\n <ul\n #listbox\n [attr.id]=\"listboxId()\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.aria-label]=\"label() || placeholder()\"\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain py-1.5\"\n >\n\n @if (noResults()) {\n <li class=\"px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400\">\n <svg class=\"mx-auto mb-2 h-8 w-8 opacity-40\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"1.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"m21 21-4.35-4.35\"/>\n </svg>\n {{ noResultsText() }}\n </li>\n }\n\n <!-- Flat options -->\n @for (opt of filteredOptions(); track opt.value) {\n <li\n role=\"option\"\n [attr.id]=\"optionDomId(opt.value)\"\n [attr.aria-selected]=\"isSelected(opt.value)\"\n [attr.aria-disabled]=\"opt.disabled || null\"\n [attr.data-active]=\"opt === activeOption()\"\n [ngClass]=\"[\n 'flex cursor-pointer items-center gap-3 transition-colors duration-100',\n optionPadClass(), optionTextClass(),\n opt.disabled\n ? 'cursor-not-allowed opacity-40'\n : opt === activeOption()\n ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'\n : 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/60'\n ]\"\n (click)=\"selectOption(opt)\"\n (mouseenter)=\"activeIndex.set(navigableOptions().indexOf(opt))\"\n >\n\n <!-- Checkbox for multi -->\n @if (multiple()) {\n <span\n [ngClass]=\"[\n 'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',\n isSelected(opt.value)\n ? 'border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400'\n : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'\n ]\"\n aria-hidden=\"true\"\n >\n @if (isSelected(opt.value)) {\n <svg class=\"h-2.5 w-2.5 text-white dark:text-gray-900\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"3\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </span>\n }\n\n <!-- Avatar -->\n @if (opt.avatarUrl) {\n <img [src]=\"opt.avatarUrl\" alt=\"\" class=\"h-6 w-6 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (opt.iconPath) {\n <svg [ngClass]=\"['shrink-0', iconSizeClass()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"opt.iconPath\"/>\n </svg>\n }\n\n <!-- Label + sublabel -->\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate font-medium\">{{ opt.label }}</span>\n @if (opt.sublabel) {\n <span class=\"block truncate text-xs text-gray-500 dark:text-gray-400\">{{ opt.sublabel }}</span>\n }\n </span>\n\n <!-- Badge -->\n @if (opt.badge) {\n <span [ngClass]=\"['shrink-0 rounded-full px-2 py-0.5 text-xs font-medium', badgeColorMap[opt.badgeColor ?? 'gray'] ?? badgeColorMap['gray']]\">\n {{ opt.badge }}\n </span>\n }\n\n <!-- Checkmark for single -->\n @if (!multiple() && isSelected(opt.value)) {\n <svg class=\"ml-auto h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n\n </li>\n }\n\n <!-- Grouped options -->\n @for (group of filteredGroups(); track group.label) {\n <li role=\"presentation\">\n <p class=\"px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 first:pt-1.5\">\n {{ group.label }}\n </p>\n <ul role=\"group\" [attr.aria-label]=\"group.label\">\n @for (opt of group.options; track opt.value) {\n <li\n role=\"option\"\n [attr.id]=\"optionDomId(opt.value)\"\n [attr.aria-selected]=\"isSelected(opt.value)\"\n [attr.aria-disabled]=\"opt.disabled || null\"\n [attr.data-active]=\"opt === activeOption()\"\n [ngClass]=\"[\n 'flex cursor-pointer items-center gap-3 transition-colors duration-100',\n optionPadClass(), optionTextClass(),\n opt.disabled\n ? 'cursor-not-allowed opacity-40'\n : opt === activeOption()\n ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'\n : 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/60'\n ]\"\n (click)=\"selectOption(opt)\"\n (mouseenter)=\"activeIndex.set(navigableOptions().indexOf(opt))\"\n >\n @if (multiple()) {\n <span\n [ngClass]=\"[\n 'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',\n isSelected(opt.value)\n ? 'border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400'\n : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'\n ]\"\n aria-hidden=\"true\"\n >\n @if (isSelected(opt.value)) {\n <svg class=\"h-2.5 w-2.5 text-white dark:text-gray-900\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"3\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </span>\n }\n @if (opt.avatarUrl) {\n <img [src]=\"opt.avatarUrl\" alt=\"\" class=\"h-6 w-6 shrink-0 rounded-full object-cover\" aria-hidden=\"true\"/>\n } @else if (opt.iconPath) {\n <svg [ngClass]=\"['shrink-0', iconSizeClass()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"opt.iconPath\"/>\n </svg>\n }\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate font-medium\">{{ opt.label }}</span>\n @if (opt.sublabel) {\n <span class=\"block truncate text-xs text-gray-500 dark:text-gray-400\">{{ opt.sublabel }}</span>\n }\n </span>\n @if (opt.badge) {\n <span [ngClass]=\"['shrink-0 rounded-full px-2 py-0.5 text-xs font-medium', badgeColorMap[opt.badgeColor ?? 'gray'] ?? badgeColorMap['gray']]\">\n {{ opt.badge }}\n </span>\n }\n @if (!multiple() && isSelected(opt.value)) {\n <svg class=\"ml-auto h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400\"\n viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"m20 6-11 11-5-5\"/>\n </svg>\n }\n </li>\n }\n </ul>\n </li>\n }\n\n </ul>\n\n <!-- Footer slot: multi select-all / clear -->\n @if (multiple() && hasValue()) {\n <div class=\"border-t border-gray-100 px-3 py-2 dark:border-gray-800\">\n <button\n type=\"button\"\n class=\"text-xs font-medium text-gray-500 hover:text-gray-700\n dark:text-gray-400 dark:hover:text-gray-200 transition-colors\"\n (click)=\"clearAll($event)\"\n >\n Clear all ({{ selectedValues().length }})\n </button>\n </div>\n }\n\n </div>\n </ng-template>\n\n </div><!-- /relative -->\n\n <!-- Hint / error -->\n @if (errorMessage() || hint()) {\n <div>\n @if (errorMessage()) {\n <p [attr.id]=\"describedById()\" class=\"flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400\" role=\"alert\">\n <svg class=\"h-3.5 w-3.5 shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/>\n <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n {{ errorMessage() }}\n </p>\n } @else if (hint()) {\n <p [attr.id]=\"describedById()\" class=\"text-xs text-gray-500 dark:text-gray-400\">{{ hint() }}</p>\n }\n </div>\n }\n\n</div>\n" }]
|
|
619
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], groups: [{ type: i0.Input, args: [{ isSignal: true, alias: "groups", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], errorMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorMessage", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], searchable: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchable", required: false }] }], searchPlaceholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchPlaceholder", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], optionalTag: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionalTag", required: false }] }], maxSelected: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSelected", required: false }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], customClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "customClass", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], dropdownOpen: [{ type: i0.Output, args: ["dropdownOpen"] }], dropdownClose: [{ type: i0.Output, args: ["dropdownClose"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], panelTpl: [{
|
|
620
|
+
type: ViewChild,
|
|
621
|
+
args: ['panelTpl', { read: TemplateRef }]
|
|
622
|
+
}], searchRef: [{ type: i0.ViewChild, args: ['searchInput', { isSignal: true }] }], triggerRef: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }], listRef: [{ type: i0.ViewChild, args: ['listbox', { isSignal: true }] }], chipWrapperRef: [{ type: i0.ViewChild, args: ['chipWrapper', { isSignal: true }] }], onDocumentMousedown: [{
|
|
623
|
+
type: HostListener,
|
|
624
|
+
args: ['document:mousedown', ['$event']]
|
|
625
|
+
}], onWindowScroll: [{
|
|
626
|
+
type: HostListener,
|
|
627
|
+
args: ['window:scroll', ['$event']]
|
|
628
|
+
}], onWindowResize: [{
|
|
629
|
+
type: HostListener,
|
|
630
|
+
args: ['window:resize']
|
|
631
|
+
}] } });
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Generated bundle index. Do not edit.
|
|
635
|
+
*/
|
|
636
|
+
|
|
637
|
+
export { SvSelectDropdownComponent };
|
|
638
|
+
//# sourceMappingURL=styloviz-select-dropdown.mjs.map
|