@aquera/nile-elements 1.7.1 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.js +1396 -413
- package/dist/nile-combobox/index.cjs.js +2 -0
- package/dist/nile-combobox/index.cjs.js.map +1 -0
- package/dist/nile-combobox/index.esm.js +1 -0
- package/dist/nile-combobox/nile-combobox.cjs.js +2 -0
- package/dist/nile-combobox/nile-combobox.cjs.js.map +1 -0
- package/dist/nile-combobox/nile-combobox.css.cjs.js +2 -0
- package/dist/nile-combobox/nile-combobox.css.cjs.js.map +1 -0
- package/dist/nile-combobox/nile-combobox.css.esm.js +642 -0
- package/dist/nile-combobox/nile-combobox.esm.js +233 -0
- package/dist/nile-combobox/portal-manager.cjs.js +2 -0
- package/dist/nile-combobox/portal-manager.cjs.js.map +1 -0
- package/dist/nile-combobox/portal-manager.esm.js +1 -0
- package/dist/nile-combobox/renderer.cjs.js +2 -0
- package/dist/nile-combobox/renderer.cjs.js.map +1 -0
- package/dist/nile-combobox/renderer.esm.js +105 -0
- package/dist/nile-combobox/search-manager.cjs.js +2 -0
- package/dist/nile-combobox/search-manager.cjs.js.map +1 -0
- package/dist/nile-combobox/search-manager.esm.js +1 -0
- package/dist/nile-combobox/selection-manager.cjs.js +2 -0
- package/dist/nile-combobox/selection-manager.cjs.js.map +1 -0
- package/dist/nile-combobox/selection-manager.esm.js +1 -0
- package/dist/nile-combobox/types.cjs.js +2 -0
- package/dist/nile-combobox/types.cjs.js.map +1 -0
- package/dist/nile-combobox/types.esm.js +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/nile-combobox/index.d.ts +1 -0
- package/dist/src/nile-combobox/index.js +2 -0
- package/dist/src/nile-combobox/index.js.map +1 -0
- package/dist/src/nile-combobox/nile-combobox.css.d.ts +9 -0
- package/dist/src/nile-combobox/nile-combobox.css.js +651 -0
- package/dist/src/nile-combobox/nile-combobox.css.js.map +1 -0
- package/dist/src/nile-combobox/nile-combobox.d.ts +287 -0
- package/dist/src/nile-combobox/nile-combobox.js +1602 -0
- package/dist/src/nile-combobox/nile-combobox.js.map +1 -0
- package/dist/src/nile-combobox/nile-combobox.test.d.ts +1 -0
- package/dist/src/nile-combobox/nile-combobox.test.js +551 -0
- package/dist/src/nile-combobox/nile-combobox.test.js.map +1 -0
- package/dist/src/nile-combobox/portal-manager.d.ts +26 -0
- package/dist/src/nile-combobox/portal-manager.js +218 -0
- package/dist/src/nile-combobox/portal-manager.js.map +1 -0
- package/dist/src/nile-combobox/renderer.d.ts +20 -0
- package/dist/src/nile-combobox/renderer.js +210 -0
- package/dist/src/nile-combobox/renderer.js.map +1 -0
- package/dist/src/nile-combobox/search-manager.d.ts +15 -0
- package/dist/src/nile-combobox/search-manager.js +41 -0
- package/dist/src/nile-combobox/search-manager.js.map +1 -0
- package/dist/src/nile-combobox/selection-manager.d.ts +12 -0
- package/dist/src/nile-combobox/selection-manager.js +44 -0
- package/dist/src/nile-combobox/selection-manager.js.map +1 -0
- package/dist/src/nile-combobox/types.d.ts +23 -0
- package/dist/src/nile-combobox/types.js +8 -0
- package/dist/src/nile-combobox/types.js.map +1 -0
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
- package/src/index.ts +1 -0
- package/src/nile-combobox/index.ts +1 -0
- package/src/nile-combobox/nile-combobox.css.ts +653 -0
- package/src/nile-combobox/nile-combobox.test.ts +704 -0
- package/src/nile-combobox/nile-combobox.ts +1663 -0
- package/src/nile-combobox/portal-manager.ts +263 -0
- package/src/nile-combobox/renderer.ts +349 -0
- package/src/nile-combobox/search-manager.ts +53 -0
- package/src/nile-combobox/selection-manager.ts +57 -0
- package/src/nile-combobox/types.ts +27 -0
- package/vscode-html-custom-data.json +306 -4
- package/web-dev-server.config.mjs +9 -0
- package/web-test-runner.config.mjs +11 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Aquera Inc 2025
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the BSD-3-Clause license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
autoUpdate,
|
|
10
|
+
computePosition,
|
|
11
|
+
flip,
|
|
12
|
+
offset,
|
|
13
|
+
shift,
|
|
14
|
+
size,
|
|
15
|
+
platform,
|
|
16
|
+
type Placement,
|
|
17
|
+
type MiddlewareData,
|
|
18
|
+
type ComputePositionConfig,
|
|
19
|
+
} from '@floating-ui/dom';
|
|
20
|
+
import { PortalUtils } from '../nile-select/portal-utils';
|
|
21
|
+
|
|
22
|
+
export class ComboboxPortalManager {
|
|
23
|
+
private portalContainer: HTMLElement | null = null;
|
|
24
|
+
private originalListboxParent: HTMLElement | null = null;
|
|
25
|
+
private measuredPopupHeight: number | null = null;
|
|
26
|
+
private component: any;
|
|
27
|
+
private cleanupAutoUpdate: (() => void) | null = null;
|
|
28
|
+
private currentPlacement: Placement = 'bottom';
|
|
29
|
+
|
|
30
|
+
constructor(component: any) {
|
|
31
|
+
this.component = component;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get portalContainerElement(): HTMLElement | null {
|
|
35
|
+
return this.portalContainer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private createPortalContainer(): HTMLElement {
|
|
39
|
+
const container = document.createElement('div');
|
|
40
|
+
container.style.position = 'absolute';
|
|
41
|
+
container.style.zIndex = '9999';
|
|
42
|
+
container.style.pointerEvents = 'none';
|
|
43
|
+
container.className = 'nile-combobox-portal';
|
|
44
|
+
return container;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async computePosition(): Promise<void> {
|
|
48
|
+
if (!this.portalContainer) return;
|
|
49
|
+
|
|
50
|
+
const referenceElement = this.component.combobox || this.component;
|
|
51
|
+
const floatingElement = this.portalContainer;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const boundary = PortalUtils.findBoundaryElements(referenceElement);
|
|
55
|
+
const initialPlacement = PortalUtils.getOptimalPlacement(referenceElement);
|
|
56
|
+
|
|
57
|
+
const { x, y, placement } = await computePosition(
|
|
58
|
+
referenceElement,
|
|
59
|
+
floatingElement,
|
|
60
|
+
{
|
|
61
|
+
placement: initialPlacement,
|
|
62
|
+
strategy: 'fixed',
|
|
63
|
+
middleware: [
|
|
64
|
+
offset(4),
|
|
65
|
+
size({
|
|
66
|
+
apply: ({ availableWidth, availableHeight, elements, rects }) => {
|
|
67
|
+
const maxHeight = PortalUtils.calculateOptimalHeight(
|
|
68
|
+
rects.reference,
|
|
69
|
+
window.innerHeight,
|
|
70
|
+
this.currentPlacement,
|
|
71
|
+
);
|
|
72
|
+
elements.floating.style.maxWidth = `${availableWidth}px`;
|
|
73
|
+
elements.floating.style.maxHeight = `${maxHeight}px`;
|
|
74
|
+
elements.floating.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
|
|
75
|
+
elements.floating.style.setProperty('--auto-size-available-height', `${maxHeight}px`);
|
|
76
|
+
},
|
|
77
|
+
padding: 10,
|
|
78
|
+
boundary,
|
|
79
|
+
}),
|
|
80
|
+
flip({
|
|
81
|
+
fallbackPlacements: ['bottom', 'top', 'bottom-start', 'top-start'],
|
|
82
|
+
fallbackStrategy: 'bestFit',
|
|
83
|
+
padding: 10,
|
|
84
|
+
boundary,
|
|
85
|
+
}),
|
|
86
|
+
shift({ padding: 10, crossAxis: true, boundary }),
|
|
87
|
+
],
|
|
88
|
+
platform,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const referenceRect = referenceElement.getBoundingClientRect();
|
|
93
|
+
Object.assign(floatingElement.style, {
|
|
94
|
+
left: `${x}px`,
|
|
95
|
+
top: `${y}px`,
|
|
96
|
+
position: 'fixed',
|
|
97
|
+
pointerEvents: 'auto',
|
|
98
|
+
width: `${referenceRect.width}px`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.currentPlacement = placement;
|
|
102
|
+
const placementClass = placement.split('-')[0];
|
|
103
|
+
floatingElement.className = `nile-combobox-portal combobox__listbox--${placementClass}`;
|
|
104
|
+
} catch {
|
|
105
|
+
this.fallbackPositioning();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fallbackPositioning(): void {
|
|
110
|
+
if (!this.portalContainer) return;
|
|
111
|
+
|
|
112
|
+
const ref = this.component.combobox || this.component;
|
|
113
|
+
const rect = ref.getBoundingClientRect();
|
|
114
|
+
const vh = window.innerHeight;
|
|
115
|
+
const spaceBelow = vh - rect.bottom;
|
|
116
|
+
const spaceAbove = rect.top;
|
|
117
|
+
|
|
118
|
+
let top: number;
|
|
119
|
+
let maxH: number;
|
|
120
|
+
let cls: string;
|
|
121
|
+
|
|
122
|
+
if (spaceAbove > spaceBelow) {
|
|
123
|
+
maxH = Math.max(spaceAbove - 20, 100);
|
|
124
|
+
top = Math.max(rect.top - maxH - 4, 10);
|
|
125
|
+
cls = 'top';
|
|
126
|
+
} else {
|
|
127
|
+
maxH = Math.max(spaceBelow - 20, 100);
|
|
128
|
+
top = rect.bottom + 4;
|
|
129
|
+
cls = 'bottom';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
Object.assign(this.portalContainer.style, {
|
|
133
|
+
left: `${rect.left}px`,
|
|
134
|
+
top: `${top}px`,
|
|
135
|
+
width: `${rect.width}px`,
|
|
136
|
+
maxHeight: `${maxH}px`,
|
|
137
|
+
pointerEvents: 'auto',
|
|
138
|
+
});
|
|
139
|
+
this.portalContainer.className = `nile-combobox-portal combobox__listbox--${cls}`;
|
|
140
|
+
this.portalContainer.style.setProperty('--auto-size-available-height', `${maxH}px`);
|
|
141
|
+
this.portalContainer.style.setProperty('--auto-size-available-width', `${rect.width}px`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private extractStylesAsCSS(styles: any): string {
|
|
145
|
+
if (typeof styles === 'string') return styles;
|
|
146
|
+
if (Array.isArray(styles)) return styles.map(s => this.extractStylesAsCSS(s)).join('\n');
|
|
147
|
+
if (styles && typeof styles === 'object' && styles.cssText) return styles.cssText;
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private injectStyles(): void {
|
|
152
|
+
if (!this.portalContainer) return;
|
|
153
|
+
const styleId = `nile-combobox-styles-${Math.random().toString(36).substring(2, 11)}`;
|
|
154
|
+
if (document.getElementById(styleId)) return;
|
|
155
|
+
|
|
156
|
+
const componentStyles = (this.component.constructor as any).styles;
|
|
157
|
+
if (!componentStyles) return;
|
|
158
|
+
|
|
159
|
+
const el = document.createElement('style');
|
|
160
|
+
el.id = styleId;
|
|
161
|
+
el.textContent = this.extractStylesAsCSS(componentStyles);
|
|
162
|
+
document.head.appendChild(el);
|
|
163
|
+
(this.portalContainer as any).__injectedStyleId = styleId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setupPortal(): void {
|
|
167
|
+
if (!this.component.portal) return;
|
|
168
|
+
|
|
169
|
+
this.component.updateComplete.then(() => {
|
|
170
|
+
const listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement;
|
|
171
|
+
if (!listbox) return;
|
|
172
|
+
|
|
173
|
+
this.originalListboxParent = listbox.parentElement as HTMLElement;
|
|
174
|
+
this.portalContainer = this.createPortalContainer();
|
|
175
|
+
this.portalContainer.appendChild(listbox);
|
|
176
|
+
document.body.appendChild(this.portalContainer);
|
|
177
|
+
this.injectStyles();
|
|
178
|
+
listbox.style.display = '';
|
|
179
|
+
this.computePosition();
|
|
180
|
+
|
|
181
|
+
this.cleanupAutoUpdate = autoUpdate(
|
|
182
|
+
this.component,
|
|
183
|
+
this.portalContainer,
|
|
184
|
+
() => this.computePosition(),
|
|
185
|
+
{ ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, animationFrame: true },
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
cleanupPortal(): void {
|
|
191
|
+
if (this.cleanupAutoUpdate) {
|
|
192
|
+
this.cleanupAutoUpdate();
|
|
193
|
+
this.cleanupAutoUpdate = null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (this.portalContainer && this.portalContainer.parentNode) {
|
|
197
|
+
const listbox = this.portalContainer.querySelector('#listbox') as HTMLElement;
|
|
198
|
+
if (listbox && this.originalListboxParent) {
|
|
199
|
+
this.originalListboxParent.appendChild(listbox);
|
|
200
|
+
listbox.style.display = this.component.portal ? 'none' : '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const sid = (this.portalContainer as any).__injectedStyleId;
|
|
204
|
+
if (sid) document.getElementById(sid)?.remove();
|
|
205
|
+
|
|
206
|
+
this.portalContainer.parentNode.removeChild(this.portalContainer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.portalContainer = null;
|
|
210
|
+
this.originalListboxParent = null;
|
|
211
|
+
this.measuredPopupHeight = null;
|
|
212
|
+
this.currentPlacement = 'bottom';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
updatePosition(): void {
|
|
216
|
+
if (this.component.portal && this.portalContainer) {
|
|
217
|
+
this.computePosition();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
resetMeasuredHeight(): void {
|
|
222
|
+
this.measuredPopupHeight = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async resetScrollPosition(): Promise<void> {
|
|
226
|
+
await this.component.updateComplete;
|
|
227
|
+
|
|
228
|
+
requestAnimationFrame(() => {
|
|
229
|
+
let listbox: HTMLElement | null = null;
|
|
230
|
+
|
|
231
|
+
if (this.component.portal && this.portalContainer) {
|
|
232
|
+
listbox = this.portalContainer.querySelector('#listbox') as HTMLElement;
|
|
233
|
+
} else {
|
|
234
|
+
listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!listbox || !listbox.isConnected) return;
|
|
238
|
+
listbox.scrollTop = 0;
|
|
239
|
+
|
|
240
|
+
const virtualized = listbox.querySelector('.virtualized') as HTMLElement;
|
|
241
|
+
if (virtualized && virtualized.isConnected) {
|
|
242
|
+
const fewItems = this.component.filteredData?.length < 5;
|
|
243
|
+
if (fewItems) {
|
|
244
|
+
virtualized.style.overflowY = 'hidden';
|
|
245
|
+
virtualized.style.maxHeight = 'none';
|
|
246
|
+
listbox.style.overflowY = 'hidden';
|
|
247
|
+
listbox.style.maxHeight = 'fit-content';
|
|
248
|
+
} else {
|
|
249
|
+
virtualized.style.overflowY = 'auto';
|
|
250
|
+
virtualized.style.maxHeight = '';
|
|
251
|
+
listbox.style.overflowY = 'auto';
|
|
252
|
+
listbox.style.maxHeight = '';
|
|
253
|
+
}
|
|
254
|
+
virtualized.scrollTop = 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const virtualizer = listbox.querySelector('lit-virtualizer') as HTMLElement;
|
|
258
|
+
if (virtualizer && virtualizer.isConnected) {
|
|
259
|
+
virtualizer.scrollTop = 0;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Aquera Inc 2025
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the BSD-3-Clause license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, type TemplateResult } from 'lit';
|
|
9
|
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
|
10
|
+
import { classMap } from 'lit/directives/class-map.js';
|
|
11
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
12
|
+
import type { VirtualItem } from '@tanstack/virtual-core';
|
|
13
|
+
|
|
14
|
+
export class ComboboxRenderer {
|
|
15
|
+
|
|
16
|
+
static renderVirtualizedOptions(
|
|
17
|
+
virtualItems: VirtualItem[],
|
|
18
|
+
totalSize: number,
|
|
19
|
+
data: any[],
|
|
20
|
+
value: string | string[],
|
|
21
|
+
multiple: boolean,
|
|
22
|
+
getDisplayText: (item: any) => string,
|
|
23
|
+
getItemValue: (item: any) => string,
|
|
24
|
+
isLoading: boolean,
|
|
25
|
+
allowHtmlLabel: boolean,
|
|
26
|
+
measureElement: (el: Element | null) => void,
|
|
27
|
+
getItemDescription?: (item: any) => string,
|
|
28
|
+
getItemPrefix?: (item: any) => string,
|
|
29
|
+
getItemSuffix?: (item: any) => string,
|
|
30
|
+
enableDescription?: boolean,
|
|
31
|
+
): TemplateResult {
|
|
32
|
+
const offsetTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
|
|
33
|
+
|
|
34
|
+
return html`
|
|
35
|
+
<div
|
|
36
|
+
part="select-options"
|
|
37
|
+
class="combobox__options ${isLoading ? 'loading' : ''}"
|
|
38
|
+
>
|
|
39
|
+
<div style="position:relative;height:${totalSize}px;width:100%;">
|
|
40
|
+
<div style="position:absolute;top:0;left:0;width:100%;transform:translateY(${offsetTop}px);">
|
|
41
|
+
${repeat(
|
|
42
|
+
virtualItems,
|
|
43
|
+
(vItem) => vItem.key,
|
|
44
|
+
(vItem) => {
|
|
45
|
+
const item = data[vItem.index];
|
|
46
|
+
return ComboboxRenderer.renderMeasuredItem(
|
|
47
|
+
item, vItem.index, value, multiple, getDisplayText, getItemValue,
|
|
48
|
+
allowHtmlLabel, measureElement, getItemDescription, getItemPrefix,
|
|
49
|
+
getItemSuffix, enableDescription,
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static renderPlainOptions(
|
|
60
|
+
data: any[],
|
|
61
|
+
value: string | string[],
|
|
62
|
+
multiple: boolean,
|
|
63
|
+
getDisplayText: (item: any) => string,
|
|
64
|
+
getItemValue: (item: any) => string,
|
|
65
|
+
showNoResults: boolean,
|
|
66
|
+
noResultsMessage: string,
|
|
67
|
+
isLoading: boolean,
|
|
68
|
+
onScroll: (e: Event) => void,
|
|
69
|
+
allowHtmlLabel: boolean,
|
|
70
|
+
getItemDescription?: (item: any) => string,
|
|
71
|
+
getItemPrefix?: (item: any) => string,
|
|
72
|
+
getItemSuffix?: (item: any) => string,
|
|
73
|
+
enableDescription?: boolean,
|
|
74
|
+
noResultsSubtitle?: string,
|
|
75
|
+
): TemplateResult {
|
|
76
|
+
if (showNoResults && !isLoading && data.length === 0) {
|
|
77
|
+
return ComboboxRenderer.renderNoResults(noResultsMessage, noResultsSubtitle);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return html`
|
|
81
|
+
<div
|
|
82
|
+
part="select-options"
|
|
83
|
+
class="combobox__options ${isLoading ? 'loading' : ''}"
|
|
84
|
+
>
|
|
85
|
+
<div class="combobox__options-plain" @scroll=${onScroll}>
|
|
86
|
+
${data.map((item: any) =>
|
|
87
|
+
ComboboxRenderer.renderItem(
|
|
88
|
+
item, value, multiple, getDisplayText, getItemValue,
|
|
89
|
+
allowHtmlLabel, getItemDescription, getItemPrefix,
|
|
90
|
+
getItemSuffix, enableDescription,
|
|
91
|
+
),
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static renderNoResults(noResultsMessage: string, noResultsSubtitle?: string): TemplateResult {
|
|
99
|
+
return html`
|
|
100
|
+
<div part="select-options" class="combobox__options">
|
|
101
|
+
<div part="no-results" class="combobox__no-results">
|
|
102
|
+
<div part="no-results-title" class="combobox__no-results-title">
|
|
103
|
+
${noResultsMessage || 'No result found'}
|
|
104
|
+
</div>
|
|
105
|
+
${noResultsSubtitle
|
|
106
|
+
? html`<div part="no-results-subtitle" class="combobox__no-results-subtitle">${noResultsSubtitle}</div>`
|
|
107
|
+
: ''}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static renderNoData(noDataMessage: string): TemplateResult {
|
|
114
|
+
return html`
|
|
115
|
+
<div part="select-options" class="combobox__options">
|
|
116
|
+
<div part="no-data" class="combobox__no-results">
|
|
117
|
+
<div part="no-data-title" class="combobox__no-results-title">
|
|
118
|
+
${noDataMessage || 'No data available'}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private static renderMeasuredItem(
|
|
126
|
+
item: any,
|
|
127
|
+
index: number,
|
|
128
|
+
value: string | string[],
|
|
129
|
+
multiple: boolean,
|
|
130
|
+
getDisplayText: (item: any) => string,
|
|
131
|
+
getItemValue: (item: any) => string,
|
|
132
|
+
allowHtmlLabel: boolean,
|
|
133
|
+
measureElement: (el: Element | null) => void,
|
|
134
|
+
getItemDescription?: (item: any) => string,
|
|
135
|
+
getItemPrefix?: (item: any) => string,
|
|
136
|
+
getItemSuffix?: (item: any) => string,
|
|
137
|
+
enableDescription?: boolean,
|
|
138
|
+
): TemplateResult {
|
|
139
|
+
if (!item) return html``;
|
|
140
|
+
|
|
141
|
+
const optionValue = getItemValue(item);
|
|
142
|
+
const displayText = getDisplayText(item);
|
|
143
|
+
const isDisabled = item?.disabled || false;
|
|
144
|
+
const className = item?.className;
|
|
145
|
+
const description = getItemDescription ? getItemDescription(item) : (item?.description ?? '');
|
|
146
|
+
const prefix = getItemPrefix ? getItemPrefix(item) : (item?.prefix ?? '');
|
|
147
|
+
const suffix = getItemSuffix ? getItemSuffix(item) : (item?.suffix ?? '');
|
|
148
|
+
|
|
149
|
+
let isSelected = false;
|
|
150
|
+
if (multiple) {
|
|
151
|
+
isSelected = Array.isArray(value) && value.some(v => String(v) === String(optionValue));
|
|
152
|
+
} else {
|
|
153
|
+
isSelected = String(Array.isArray(value) ? value[0] : value) === String(optionValue);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return html`
|
|
157
|
+
<nile-option
|
|
158
|
+
data-index=${index}
|
|
159
|
+
value=${optionValue}
|
|
160
|
+
.selected=${isSelected}
|
|
161
|
+
.disabled=${isDisabled}
|
|
162
|
+
.showCheckbox=${multiple}
|
|
163
|
+
class=${classMap({
|
|
164
|
+
option: enableDescription ?? false,
|
|
165
|
+
[className ?? '']: !!className,
|
|
166
|
+
})}
|
|
167
|
+
.description=${description}
|
|
168
|
+
.isDescriptionEnabled=${enableDescription}
|
|
169
|
+
>
|
|
170
|
+
${unsafeHTML(prefix)}
|
|
171
|
+
${allowHtmlLabel ? unsafeHTML(displayText) : displayText}
|
|
172
|
+
${unsafeHTML(suffix)}
|
|
173
|
+
</nile-option>
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static renderItem(
|
|
178
|
+
item: any,
|
|
179
|
+
value: string | string[],
|
|
180
|
+
multiple: boolean,
|
|
181
|
+
getDisplayText: (item: any) => string,
|
|
182
|
+
getItemValue: (item: any) => string,
|
|
183
|
+
allowHtmlLabel: boolean,
|
|
184
|
+
getItemDescription?: (item: any) => string,
|
|
185
|
+
getItemPrefix?: (item: any) => string,
|
|
186
|
+
getItemSuffix?: (item: any) => string,
|
|
187
|
+
enableDescription?: boolean,
|
|
188
|
+
): TemplateResult {
|
|
189
|
+
if (!item) return html``;
|
|
190
|
+
|
|
191
|
+
const optionValue = getItemValue(item);
|
|
192
|
+
const displayText = getDisplayText(item);
|
|
193
|
+
const isDisabled = item?.disabled || false;
|
|
194
|
+
const className = item?.className;
|
|
195
|
+
const description = getItemDescription ? getItemDescription(item) : (item?.description ?? '');
|
|
196
|
+
const prefix = getItemPrefix ? getItemPrefix(item) : (item?.prefix ?? '');
|
|
197
|
+
const suffix = getItemSuffix ? getItemSuffix(item) : (item?.suffix ?? '');
|
|
198
|
+
|
|
199
|
+
let isSelected = false;
|
|
200
|
+
if (multiple) {
|
|
201
|
+
isSelected = Array.isArray(value) && value.some(v => String(v) === String(optionValue));
|
|
202
|
+
} else {
|
|
203
|
+
isSelected = String(Array.isArray(value) ? value[0] : value) === String(optionValue);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return html`
|
|
207
|
+
<nile-option
|
|
208
|
+
value=${optionValue}
|
|
209
|
+
.selected=${isSelected}
|
|
210
|
+
.disabled=${isDisabled}
|
|
211
|
+
.showCheckbox=${multiple}
|
|
212
|
+
class=${classMap({
|
|
213
|
+
option: enableDescription ?? false,
|
|
214
|
+
[className ?? '']: !!className,
|
|
215
|
+
})}
|
|
216
|
+
.description=${description}
|
|
217
|
+
.isDescriptionEnabled=${enableDescription}
|
|
218
|
+
>
|
|
219
|
+
${unsafeHTML(prefix)}
|
|
220
|
+
${allowHtmlLabel ? unsafeHTML(displayText) : displayText}
|
|
221
|
+
${unsafeHTML(suffix)}
|
|
222
|
+
</nile-option>
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
static renderVirtualizedGrid(
|
|
227
|
+
virtualItems: VirtualItem[],
|
|
228
|
+
totalSize: number,
|
|
229
|
+
data: any[],
|
|
230
|
+
value: string | string[],
|
|
231
|
+
multiple: boolean,
|
|
232
|
+
gridColumns: number,
|
|
233
|
+
getDisplayText: (item: any) => string,
|
|
234
|
+
getItemValue: (item: any) => string,
|
|
235
|
+
isLoading: boolean,
|
|
236
|
+
allowHtmlLabel: boolean,
|
|
237
|
+
getItemDescription?: (item: any) => string,
|
|
238
|
+
getItemPrefix?: (item: any) => string,
|
|
239
|
+
getItemSuffix?: (item: any) => string,
|
|
240
|
+
gridColumnWidth?: number,
|
|
241
|
+
): TemplateResult {
|
|
242
|
+
const offsetTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
|
|
243
|
+
const colTemplate = gridColumnWidth
|
|
244
|
+
? `repeat(${gridColumns}, ${gridColumnWidth}px)`
|
|
245
|
+
: `repeat(${gridColumns}, 1fr)`;
|
|
246
|
+
|
|
247
|
+
return html`
|
|
248
|
+
<div
|
|
249
|
+
part="select-options"
|
|
250
|
+
class="combobox__options ${isLoading ? 'loading' : ''}"
|
|
251
|
+
>
|
|
252
|
+
<div style="position:relative;height:${totalSize}px;width:${gridColumnWidth ? 'max-content' : '100%'};min-width:100%;">
|
|
253
|
+
<div style="position:absolute;top:0;left:0;width:${gridColumnWidth ? 'max-content' : '100%'};min-width:100%;transform:translateY(${offsetTop}px);">
|
|
254
|
+
${repeat(
|
|
255
|
+
virtualItems,
|
|
256
|
+
(vItem) => vItem.key,
|
|
257
|
+
(vItem) => {
|
|
258
|
+
const rowStart = vItem.index * gridColumns;
|
|
259
|
+
const rowItems = data.slice(rowStart, rowStart + gridColumns);
|
|
260
|
+
return html`
|
|
261
|
+
<div class="combobox__grid-row" style="display:grid;grid-template-columns:${colTemplate};gap:4px;">
|
|
262
|
+
${rowItems.map((item: any) =>
|
|
263
|
+
ComboboxRenderer.renderItem(
|
|
264
|
+
item, value, multiple, getDisplayText, getItemValue,
|
|
265
|
+
allowHtmlLabel, getItemDescription, getItemPrefix,
|
|
266
|
+
getItemSuffix,
|
|
267
|
+
),
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
`;
|
|
271
|
+
},
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
static renderHorizontalGrid(
|
|
280
|
+
virtualItems: VirtualItem[],
|
|
281
|
+
totalSize: number,
|
|
282
|
+
data: any[],
|
|
283
|
+
value: string | string[],
|
|
284
|
+
multiple: boolean,
|
|
285
|
+
gridRows: number,
|
|
286
|
+
gridColumnWidth: number,
|
|
287
|
+
getDisplayText: (item: any) => string,
|
|
288
|
+
getItemValue: (item: any) => string,
|
|
289
|
+
isLoading: boolean,
|
|
290
|
+
allowHtmlLabel: boolean,
|
|
291
|
+
getItemDescription?: (item: any) => string,
|
|
292
|
+
getItemPrefix?: (item: any) => string,
|
|
293
|
+
getItemSuffix?: (item: any) => string,
|
|
294
|
+
): TemplateResult {
|
|
295
|
+
const offsetLeft = virtualItems.length > 0 ? virtualItems[0].start : 0;
|
|
296
|
+
const rowHeight = 38;
|
|
297
|
+
|
|
298
|
+
return html`
|
|
299
|
+
<div
|
|
300
|
+
part="select-options"
|
|
301
|
+
class="combobox__options combobox__options--horizontal ${isLoading ? 'loading' : ''}"
|
|
302
|
+
>
|
|
303
|
+
<div style="position:relative;width:${totalSize}px;height:${rowHeight * gridRows}px;">
|
|
304
|
+
<div style="position:absolute;top:0;left:0;height:100%;display:flex;transform:translateX(${offsetLeft}px);">
|
|
305
|
+
${repeat(
|
|
306
|
+
virtualItems,
|
|
307
|
+
(vCol) => vCol.key,
|
|
308
|
+
(vCol) => {
|
|
309
|
+
const colStart = vCol.index * gridRows;
|
|
310
|
+
const colItems = data.slice(colStart, colStart + gridRows);
|
|
311
|
+
return html`
|
|
312
|
+
<div class="combobox__grid-col" style="width:${gridColumnWidth}px;flex-shrink:0;display:flex;flex-direction:column;">
|
|
313
|
+
${colItems.map((item: any) =>
|
|
314
|
+
ComboboxRenderer.renderItem(
|
|
315
|
+
item, value, multiple, getDisplayText, getItemValue,
|
|
316
|
+
allowHtmlLabel, getItemDescription, getItemPrefix,
|
|
317
|
+
getItemSuffix,
|
|
318
|
+
),
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
`;
|
|
322
|
+
},
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
static renderAddCustomOption(
|
|
331
|
+
searchValue: string,
|
|
332
|
+
multiple: boolean,
|
|
333
|
+
): TemplateResult {
|
|
334
|
+
return html`
|
|
335
|
+
<nile-option
|
|
336
|
+
value=${searchValue}
|
|
337
|
+
class="combobox__add-option"
|
|
338
|
+
.showCheckbox=${multiple}
|
|
339
|
+
>
|
|
340
|
+
+ Add "${searchValue}"
|
|
341
|
+
</nile-option>
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
static shouldUseVirtualizer(data: any[], gridColumns = 1): boolean {
|
|
346
|
+
if (gridColumns > 1) return true;
|
|
347
|
+
return data.length >= 5;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Aquera Inc 2025
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the BSD-3-Clause license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class ComboboxSearchManager {
|
|
9
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
10
|
+
|
|
11
|
+
filter(
|
|
12
|
+
searchValue: string,
|
|
13
|
+
originalItems: any[],
|
|
14
|
+
getSearchText: (item: any) => string,
|
|
15
|
+
): { filteredItems: any[]; showNoResults: boolean } {
|
|
16
|
+
if (!originalItems || originalItems.length === 0) {
|
|
17
|
+
return { filteredItems: [], showNoResults: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!searchValue || searchValue.trim() === '') {
|
|
21
|
+
return { filteredItems: [...originalItems], showNoResults: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const needle = searchValue.toLowerCase();
|
|
25
|
+
const filteredItems = originalItems.filter((item: any) => {
|
|
26
|
+
const text = getSearchText(item);
|
|
27
|
+
return text.toLowerCase().includes(needle);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return { filteredItems, showNoResults: filteredItems.length === 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
debounceSearch(
|
|
34
|
+
callback: (query: string) => void,
|
|
35
|
+
query: string,
|
|
36
|
+
debounceMs: number,
|
|
37
|
+
): void {
|
|
38
|
+
if (this.debounceTimer) {
|
|
39
|
+
clearTimeout(this.debounceTimer);
|
|
40
|
+
}
|
|
41
|
+
this.debounceTimer = setTimeout(() => {
|
|
42
|
+
callback(query);
|
|
43
|
+
this.debounceTimer = null;
|
|
44
|
+
}, debounceMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cancelDebounce(): void {
|
|
48
|
+
if (this.debounceTimer) {
|
|
49
|
+
clearTimeout(this.debounceTimer);
|
|
50
|
+
this.debounceTimer = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|