@diabolic/hangover 0.1.0
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 +980 -0
- package/dist/hangover.css +547 -0
- package/dist/index.cjs.js +4269 -0
- package/dist/index.esm.js +4263 -0
- package/package.json +77 -0
|
@@ -0,0 +1,4269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var react = require('react');
|
|
6
|
+
var reactDom = require('react-dom');
|
|
7
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
8
|
+
|
|
9
|
+
const DropdownContext = /*#__PURE__*/react.createContext(null);
|
|
10
|
+
function useDropdownContext() {
|
|
11
|
+
const ctx = react.useContext(DropdownContext);
|
|
12
|
+
if (!ctx) throw new Error('useDropdownContext must be used inside <Dropdown>');
|
|
13
|
+
return ctx;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Passed down from DropdownSection so DropdownGroup/Item know their forId
|
|
17
|
+
const SectionContext = /*#__PURE__*/react.createContext(null);
|
|
18
|
+
|
|
19
|
+
// Passed down from DropdownSection so DropdownGroup can register for expand/collapse all
|
|
20
|
+
const SectionControlContext = /*#__PURE__*/react.createContext(null);
|
|
21
|
+
|
|
22
|
+
// Passed down from DropdownGroup so DropdownItem knows its groupLabel + auto-color index
|
|
23
|
+
const GroupContext = /*#__PURE__*/react.createContext(null);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* DropdownTrigger
|
|
27
|
+
*
|
|
28
|
+
* Wraps any single child and turns it into the dropdown trigger.
|
|
29
|
+
* Injects: ref, onClick, aria-expanded, aria-haspopup
|
|
30
|
+
*/
|
|
31
|
+
function DropdownTrigger({
|
|
32
|
+
children
|
|
33
|
+
}) {
|
|
34
|
+
const {
|
|
35
|
+
triggerRef,
|
|
36
|
+
isOpen,
|
|
37
|
+
fireEvent
|
|
38
|
+
} = useDropdownContext();
|
|
39
|
+
const child = react.Children.only(children);
|
|
40
|
+
function handleClick(e) {
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
fireEvent('close', {
|
|
43
|
+
trigger: 'click'
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
fireEvent('open', {
|
|
47
|
+
trigger: 'click'
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
child.props.onClick?.(e);
|
|
51
|
+
}
|
|
52
|
+
return /*#__PURE__*/react.cloneElement(child, {
|
|
53
|
+
ref: triggerRef,
|
|
54
|
+
onClick: handleClick,
|
|
55
|
+
'aria-expanded': isOpen,
|
|
56
|
+
'aria-haspopup': 'dialog'
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* calculatePosition
|
|
62
|
+
* Pure function — no DOM side effects.
|
|
63
|
+
*
|
|
64
|
+
* @param {DOMRect} triggerRect
|
|
65
|
+
* @param {DOMRect} popoverRect
|
|
66
|
+
* @param {string} placement "bottom-start" | "bottom-end" | "bottom" |
|
|
67
|
+
* "top-start" | "top-end" | "top" |
|
|
68
|
+
* "left" | "right"
|
|
69
|
+
* @param {number} offset gap between trigger and popover (px)
|
|
70
|
+
* @param {number} viewportPadding min distance from viewport edge (px)
|
|
71
|
+
* @returns {{ top: number, left: number, actualPlacement: string }}
|
|
72
|
+
*
|
|
73
|
+
* Coordinates are viewport-relative (for position:fixed).
|
|
74
|
+
*/
|
|
75
|
+
function calculatePosition(triggerRect, popoverRect, placement = 'bottom-start', offset = 8, viewportPadding = 8) {
|
|
76
|
+
const vw = window.innerWidth;
|
|
77
|
+
const vh = window.innerHeight;
|
|
78
|
+
const [side, align] = placement.split('-'); // e.g. "bottom", "start"
|
|
79
|
+
|
|
80
|
+
// --- Candidate positions for each side ---
|
|
81
|
+
function coords(s, a) {
|
|
82
|
+
let top, left;
|
|
83
|
+
switch (s) {
|
|
84
|
+
case 'bottom':
|
|
85
|
+
top = triggerRect.bottom + offset;
|
|
86
|
+
break;
|
|
87
|
+
case 'top':
|
|
88
|
+
top = triggerRect.top - popoverRect.height - offset;
|
|
89
|
+
break;
|
|
90
|
+
case 'left':
|
|
91
|
+
left = triggerRect.left - popoverRect.width - offset;
|
|
92
|
+
top = _alignCross(triggerRect, popoverRect, a);
|
|
93
|
+
return {
|
|
94
|
+
top,
|
|
95
|
+
left
|
|
96
|
+
};
|
|
97
|
+
case 'right':
|
|
98
|
+
left = triggerRect.right + offset;
|
|
99
|
+
top = _alignCross(triggerRect, popoverRect, a);
|
|
100
|
+
return {
|
|
101
|
+
top,
|
|
102
|
+
left
|
|
103
|
+
};
|
|
104
|
+
default:
|
|
105
|
+
top = triggerRect.bottom + offset;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// horizontal alignment for top/bottom
|
|
109
|
+
switch (a) {
|
|
110
|
+
case 'start':
|
|
111
|
+
left = triggerRect.left;
|
|
112
|
+
break;
|
|
113
|
+
case 'end':
|
|
114
|
+
left = triggerRect.right - popoverRect.width;
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
// center
|
|
118
|
+
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
top,
|
|
122
|
+
left
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Fits check: all four edges ---
|
|
127
|
+
function fitsInViewport(pos) {
|
|
128
|
+
return pos.top >= viewportPadding && pos.top + popoverRect.height <= vh - viewportPadding && pos.left >= viewportPadding && pos.left + popoverRect.width <= vw - viewportPadding;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- All 8 candidate placements ---
|
|
132
|
+
const ALL_PLACEMENTS = [['bottom', 'start'], ['bottom', undefined], ['bottom', 'end'], ['top', 'start'], ['top', undefined], ['top', 'end'], ['left', undefined], ['right', undefined]];
|
|
133
|
+
const originalPos = coords(side, align);
|
|
134
|
+
let resolvedSide = side;
|
|
135
|
+
let resolvedAlign = align;
|
|
136
|
+
let pos = originalPos;
|
|
137
|
+
let fitted = fitsInViewport(originalPos);
|
|
138
|
+
if (!fitted) {
|
|
139
|
+
// Among all fitting candidates, pick the one closest to the original position
|
|
140
|
+
let bestDist = Infinity;
|
|
141
|
+
for (const [s, a] of ALL_PLACEMENTS) {
|
|
142
|
+
const p = coords(s, a);
|
|
143
|
+
if (!fitsInViewport(p)) continue;
|
|
144
|
+
const dx = p.left - originalPos.left;
|
|
145
|
+
const dy = p.top - originalPos.top;
|
|
146
|
+
const dist = dx * dx + dy * dy;
|
|
147
|
+
if (dist < bestDist) {
|
|
148
|
+
bestDist = dist;
|
|
149
|
+
resolvedSide = s;
|
|
150
|
+
resolvedAlign = a;
|
|
151
|
+
pos = p;
|
|
152
|
+
fitted = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// if nothing fits, fitted stays false — caller handles fallback
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Clamp only when a fitting placement was found
|
|
160
|
+
if (fitted) {
|
|
161
|
+
pos.top = Math.min(Math.max(pos.top, viewportPadding), vh - popoverRect.height - viewportPadding);
|
|
162
|
+
pos.left = Math.min(Math.max(pos.left, viewportPadding), vw - popoverRect.width - viewportPadding);
|
|
163
|
+
}
|
|
164
|
+
const actualPlacement = resolvedAlign ? `${resolvedSide}-${resolvedAlign}` : resolvedSide;
|
|
165
|
+
return {
|
|
166
|
+
top: pos.top,
|
|
167
|
+
left: pos.left,
|
|
168
|
+
actualPlacement,
|
|
169
|
+
fitted
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Cross-axis alignment helper for left/right sides
|
|
174
|
+
function _alignCross(triggerRect, popoverRect, align, axis) {
|
|
175
|
+
{
|
|
176
|
+
switch (align) {
|
|
177
|
+
case 'start':
|
|
178
|
+
return triggerRect.top;
|
|
179
|
+
case 'end':
|
|
180
|
+
return triggerRect.bottom - popoverRect.height;
|
|
181
|
+
default:
|
|
182
|
+
return triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Convert placement string to CSS class suffix.
|
|
189
|
+
* "bottom-start" → "forBottomStart"
|
|
190
|
+
*/
|
|
191
|
+
function placementToClass(placement) {
|
|
192
|
+
return 'for' + placement.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns scrollable ancestors of an element (including window).
|
|
197
|
+
*/
|
|
198
|
+
function getScrollableAncestors(el) {
|
|
199
|
+
const ancestors = [];
|
|
200
|
+
let current = el.parentElement;
|
|
201
|
+
while (current && current !== document.documentElement) {
|
|
202
|
+
const {
|
|
203
|
+
overflow,
|
|
204
|
+
overflowY,
|
|
205
|
+
overflowX
|
|
206
|
+
} = getComputedStyle(current);
|
|
207
|
+
if (/auto|scroll/.test(overflow + overflowY + overflowX)) {
|
|
208
|
+
ancestors.push(current);
|
|
209
|
+
}
|
|
210
|
+
current = current.parentElement;
|
|
211
|
+
}
|
|
212
|
+
ancestors.push(window);
|
|
213
|
+
return ancestors;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* usePositioner
|
|
218
|
+
*
|
|
219
|
+
* Keeps a floating panel anchored to a trigger element.
|
|
220
|
+
* Uses position:fixed so scroll doesn't shift the panel — but recalculates
|
|
221
|
+
* whenever the trigger moves (scroll, resize, layout shift).
|
|
222
|
+
*
|
|
223
|
+
* @param {React.RefObject} triggerRef
|
|
224
|
+
* @param {React.RefObject} panelRef
|
|
225
|
+
* @param {string} placement e.g. "bottom-start"
|
|
226
|
+
* @param {number} offset gap in px
|
|
227
|
+
* @param {boolean} isOpen
|
|
228
|
+
* @returns {{ style: CSSProperties, actualPlacement: string }}
|
|
229
|
+
*/
|
|
230
|
+
function usePositioner(triggerRef, panelRef, placement, offset, isOpen) {
|
|
231
|
+
const [actualPlacement, setActualPlacement] = react.useState(placement);
|
|
232
|
+
const rafId = react.useRef(null);
|
|
233
|
+
const lastFittedPlacementRef = react.useRef(placement);
|
|
234
|
+
const resolvedPlacementRef = react.useRef(placement);
|
|
235
|
+
const initializedRef = react.useRef(false);
|
|
236
|
+
const recalculate = react.useCallback(() => {
|
|
237
|
+
if (!triggerRef.current || !panelRef.current) return;
|
|
238
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
239
|
+
const panelRect = panelRef.current.getBoundingClientRect();
|
|
240
|
+
let result = calculatePosition(triggerRect, panelRect, placement, offset);
|
|
241
|
+
if (result.fitted) {
|
|
242
|
+
lastFittedPlacementRef.current = result.actualPlacement;
|
|
243
|
+
} else {
|
|
244
|
+
result = calculatePosition(triggerRect, panelRect, lastFittedPlacementRef.current, offset);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Apply position directly to the DOM — bypasses React re-render for
|
|
248
|
+
// smoother scroll tracking (no setState → reconciliation → commit cycle)
|
|
249
|
+
const el = panelRef.current;
|
|
250
|
+
el.style.top = result.top + 'px';
|
|
251
|
+
el.style.left = result.left + 'px';
|
|
252
|
+
if (!initializedRef.current) {
|
|
253
|
+
el.style.visibility = '';
|
|
254
|
+
initializedRef.current = true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Only trigger a React re-render when the placement class needs to change
|
|
258
|
+
if (result.actualPlacement !== resolvedPlacementRef.current) {
|
|
259
|
+
resolvedPlacementRef.current = result.actualPlacement;
|
|
260
|
+
setActualPlacement(result.actualPlacement);
|
|
261
|
+
}
|
|
262
|
+
}, [triggerRef, panelRef, placement, offset]);
|
|
263
|
+
const scheduleRecalc = react.useCallback(() => {
|
|
264
|
+
if (rafId.current !== null) return;
|
|
265
|
+
rafId.current = requestAnimationFrame(() => {
|
|
266
|
+
rafId.current = null;
|
|
267
|
+
recalculate();
|
|
268
|
+
});
|
|
269
|
+
}, [recalculate]);
|
|
270
|
+
react.useEffect(() => {
|
|
271
|
+
if (!isOpen) {
|
|
272
|
+
initializedRef.current = false;
|
|
273
|
+
lastFittedPlacementRef.current = placement;
|
|
274
|
+
resolvedPlacementRef.current = placement;
|
|
275
|
+
setActualPlacement(placement);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Initial calc after panel mounts (needs 1 rAF so panel has dimensions)
|
|
280
|
+
rafId.current = requestAnimationFrame(() => {
|
|
281
|
+
rafId.current = null;
|
|
282
|
+
recalculate();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ResizeObserver on trigger + panel
|
|
286
|
+
const ro = new ResizeObserver(scheduleRecalc);
|
|
287
|
+
if (triggerRef.current) ro.observe(triggerRef.current);
|
|
288
|
+
if (panelRef.current) ro.observe(panelRef.current);
|
|
289
|
+
|
|
290
|
+
// Scrollable ancestors
|
|
291
|
+
const ancestors = triggerRef.current ? getScrollableAncestors(triggerRef.current) : [window];
|
|
292
|
+
const opts = {
|
|
293
|
+
passive: true
|
|
294
|
+
};
|
|
295
|
+
ancestors.forEach(el => el.addEventListener('scroll', scheduleRecalc, opts));
|
|
296
|
+
window.addEventListener('resize', scheduleRecalc, opts);
|
|
297
|
+
return () => {
|
|
298
|
+
if (rafId.current !== null) {
|
|
299
|
+
cancelAnimationFrame(rafId.current);
|
|
300
|
+
rafId.current = null;
|
|
301
|
+
}
|
|
302
|
+
ro.disconnect();
|
|
303
|
+
ancestors.forEach(el => el.removeEventListener('scroll', scheduleRecalc, opts));
|
|
304
|
+
window.removeEventListener('resize', scheduleRecalc, opts);
|
|
305
|
+
};
|
|
306
|
+
}, [isOpen, recalculate, scheduleRecalc, triggerRef, panelRef]);
|
|
307
|
+
return {
|
|
308
|
+
style: {
|
|
309
|
+
position: 'fixed',
|
|
310
|
+
top: 0,
|
|
311
|
+
left: 0,
|
|
312
|
+
visibility: 'hidden',
|
|
313
|
+
zIndex: 9999
|
|
314
|
+
},
|
|
315
|
+
actualPlacement
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* useOutsideClick
|
|
321
|
+
*
|
|
322
|
+
* Calls `callback` when a mousedown event occurs outside ALL of the
|
|
323
|
+
* provided refs.
|
|
324
|
+
*
|
|
325
|
+
* @param {React.RefObject[]} refs
|
|
326
|
+
* @param {function} callback
|
|
327
|
+
*/
|
|
328
|
+
function useOutsideClick(refs, callback) {
|
|
329
|
+
react.useEffect(() => {
|
|
330
|
+
function handleMouseDown(e) {
|
|
331
|
+
const isOutside = refs.every(ref => {
|
|
332
|
+
if (!ref.current) return true;
|
|
333
|
+
return !ref.current.contains(e.target);
|
|
334
|
+
});
|
|
335
|
+
if (isOutside) callback(e);
|
|
336
|
+
}
|
|
337
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
338
|
+
return () => document.removeEventListener('mousedown', handleMouseDown);
|
|
339
|
+
}, [refs, callback]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function DropdownPanel({
|
|
343
|
+
placement = 'bottom-start',
|
|
344
|
+
offset = 8,
|
|
345
|
+
anchor,
|
|
346
|
+
component: Comp,
|
|
347
|
+
children,
|
|
348
|
+
...rest
|
|
349
|
+
}) {
|
|
350
|
+
const resolvedOffset = typeof offset === 'string' ? parseFloat(offset) : offset;
|
|
351
|
+
const {
|
|
352
|
+
isOpen,
|
|
353
|
+
triggerRef,
|
|
354
|
+
fireEvent,
|
|
355
|
+
hasNav,
|
|
356
|
+
darkMode
|
|
357
|
+
} = useDropdownContext();
|
|
358
|
+
const panelRef = react.useRef(null);
|
|
359
|
+
const anchorRef = anchor ?? triggerRef;
|
|
360
|
+
const {
|
|
361
|
+
style,
|
|
362
|
+
actualPlacement
|
|
363
|
+
} = usePositioner(anchorRef, panelRef, placement, resolvedOffset, isOpen);
|
|
364
|
+
|
|
365
|
+
// Outside click
|
|
366
|
+
useOutsideClick([anchorRef, panelRef], () => {
|
|
367
|
+
if (isOpen) fireEvent('close', {
|
|
368
|
+
trigger: 'outside'
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Escape key
|
|
373
|
+
react.useEffect(() => {
|
|
374
|
+
if (!isOpen) return;
|
|
375
|
+
function handleKeyDown(e) {
|
|
376
|
+
if (e.key === 'Escape') {
|
|
377
|
+
fireEvent('close', {
|
|
378
|
+
trigger: 'escape'
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
383
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
384
|
+
}, [isOpen, fireEvent]);
|
|
385
|
+
if (!isOpen) return null;
|
|
386
|
+
const placementClass = placementToClass(actualPlacement);
|
|
387
|
+
const classNames = `hangoverDropdown-panel ${placementClass} isOpen${hasNav ? '' : ' hasNoNav'}${darkMode ? ' hangoverDropdown--dark' : ''}`;
|
|
388
|
+
const content = Comp ? /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
389
|
+
ref: panelRef,
|
|
390
|
+
isOpen: isOpen,
|
|
391
|
+
placement: actualPlacement,
|
|
392
|
+
style: style,
|
|
393
|
+
className: classNames,
|
|
394
|
+
...rest,
|
|
395
|
+
children: children
|
|
396
|
+
}) : /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
397
|
+
ref: panelRef,
|
|
398
|
+
className: classNames,
|
|
399
|
+
style: style,
|
|
400
|
+
role: "dialog",
|
|
401
|
+
"aria-modal": "true",
|
|
402
|
+
"aria-label": "Dropdown",
|
|
403
|
+
...rest,
|
|
404
|
+
children: /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
405
|
+
className: "hangoverDropdown-panel-inner",
|
|
406
|
+
children: children
|
|
407
|
+
})
|
|
408
|
+
});
|
|
409
|
+
return /*#__PURE__*/reactDom.createPortal(content, document.body);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Renders an icon that can be either:
|
|
414
|
+
* - A React element (instance): <MyIcon /> → returned as-is
|
|
415
|
+
* - A React component (FC/class): MyIcon → instantiated with createElement
|
|
416
|
+
*
|
|
417
|
+
* @param {React.ReactNode|React.ComponentType} icon
|
|
418
|
+
* @returns {React.ReactNode|null}
|
|
419
|
+
*/
|
|
420
|
+
function renderIcon(icon) {
|
|
421
|
+
if (!icon) return null;
|
|
422
|
+
if (/*#__PURE__*/react.isValidElement(icon)) return icon;
|
|
423
|
+
if (typeof icon === 'function') return /*#__PURE__*/react.createElement(icon);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function DropdownNavItem({
|
|
428
|
+
id,
|
|
429
|
+
icon,
|
|
430
|
+
children,
|
|
431
|
+
component: Comp,
|
|
432
|
+
...rest
|
|
433
|
+
}) {
|
|
434
|
+
const {
|
|
435
|
+
activeNavId,
|
|
436
|
+
fireEvent,
|
|
437
|
+
displayMode,
|
|
438
|
+
contentRef,
|
|
439
|
+
sectionRefs,
|
|
440
|
+
registerNavLabel
|
|
441
|
+
} = useDropdownContext();
|
|
442
|
+
const isActive = activeNavId === id;
|
|
443
|
+
react.useEffect(() => {
|
|
444
|
+
registerNavLabel(id, typeof children === 'string' ? children : '');
|
|
445
|
+
}, [id, children, registerNavLabel]);
|
|
446
|
+
function handleClick() {
|
|
447
|
+
fireEvent('navChange', {
|
|
448
|
+
id
|
|
449
|
+
});
|
|
450
|
+
if (displayMode === 'scroll') {
|
|
451
|
+
const sectionEl = sectionRefs.get(id);
|
|
452
|
+
const scrollContainer = contentRef.current;
|
|
453
|
+
if (sectionEl && scrollContainer) {
|
|
454
|
+
const containerTop = scrollContainer.getBoundingClientRect().top;
|
|
455
|
+
const sectionTop = sectionEl.getBoundingClientRect().top;
|
|
456
|
+
const offset = sectionTop - containerTop + scrollContainer.scrollTop;
|
|
457
|
+
scrollContainer.scrollTo({
|
|
458
|
+
top: offset,
|
|
459
|
+
behavior: 'smooth'
|
|
460
|
+
});
|
|
461
|
+
} else if (id === '__all__' && scrollContainer) {
|
|
462
|
+
scrollContainer.scrollTo({
|
|
463
|
+
top: 0,
|
|
464
|
+
behavior: 'smooth'
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const {
|
|
470
|
+
onClick: userOnClick,
|
|
471
|
+
...navItemRest
|
|
472
|
+
} = rest;
|
|
473
|
+
const bindingProps = {
|
|
474
|
+
isActive,
|
|
475
|
+
onClick: () => {
|
|
476
|
+
handleClick();
|
|
477
|
+
userOnClick?.();
|
|
478
|
+
},
|
|
479
|
+
id,
|
|
480
|
+
children
|
|
481
|
+
};
|
|
482
|
+
if (Comp) {
|
|
483
|
+
return /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
484
|
+
...bindingProps,
|
|
485
|
+
"data-ho-active": isActive,
|
|
486
|
+
...navItemRest
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return /*#__PURE__*/jsxRuntime.jsxs("button", {
|
|
490
|
+
type: "button",
|
|
491
|
+
className: `hangoverDropdown-nav-item${isActive ? ' isActive' : ''}`,
|
|
492
|
+
onClick: () => {
|
|
493
|
+
handleClick();
|
|
494
|
+
userOnClick?.();
|
|
495
|
+
},
|
|
496
|
+
title: typeof children === 'string' ? children : undefined,
|
|
497
|
+
"data-ho-active": isActive,
|
|
498
|
+
...navItemRest,
|
|
499
|
+
children: [icon && /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
500
|
+
className: "hangoverDropdown-nav-item-icon",
|
|
501
|
+
"aria-hidden": "true",
|
|
502
|
+
children: renderIcon(icon)
|
|
503
|
+
}), /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
504
|
+
className: "hangoverDropdown-nav-item-label",
|
|
505
|
+
children: children
|
|
506
|
+
})]
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function DropdownNav({
|
|
511
|
+
showAll = false,
|
|
512
|
+
allLabel = 'All',
|
|
513
|
+
allIcon,
|
|
514
|
+
children,
|
|
515
|
+
component: Comp,
|
|
516
|
+
collapsed = false,
|
|
517
|
+
autoCollapse = false,
|
|
518
|
+
...rest
|
|
519
|
+
}) {
|
|
520
|
+
const {
|
|
521
|
+
setHasNav
|
|
522
|
+
} = useDropdownContext();
|
|
523
|
+
const wrapperRef = react.useRef(null);
|
|
524
|
+
const naturalWidthRef = react.useRef(null);
|
|
525
|
+
const [isCollapsed, setIsCollapsed] = react.useState(collapsed);
|
|
526
|
+
const childCount = react.Children.count(children);
|
|
527
|
+
const isSingle = childCount <= 1;
|
|
528
|
+
react.useEffect(() => {
|
|
529
|
+
setHasNav(!isSingle);
|
|
530
|
+
return () => setHasNav(false);
|
|
531
|
+
}, [setHasNav, isSingle]);
|
|
532
|
+
|
|
533
|
+
// collapsed prop always sets the base state
|
|
534
|
+
react.useEffect(() => {
|
|
535
|
+
setIsCollapsed(collapsed);
|
|
536
|
+
}, [collapsed]);
|
|
537
|
+
react.useEffect(() => {
|
|
538
|
+
if (!autoCollapse) return;
|
|
539
|
+
function getNaturalWidth() {
|
|
540
|
+
const styles = getComputedStyle(document.documentElement);
|
|
541
|
+
const navWidth = parseFloat(styles.getPropertyValue('--hangover-nav-width')) || 0;
|
|
542
|
+
const contentMaxWidth = parseFloat(styles.getPropertyValue('--hangover-content-max-width')) || 0;
|
|
543
|
+
return navWidth + contentMaxWidth;
|
|
544
|
+
}
|
|
545
|
+
function check() {
|
|
546
|
+
if (naturalWidthRef.current === null) {
|
|
547
|
+
naturalWidthRef.current = getNaturalWidth();
|
|
548
|
+
}
|
|
549
|
+
const panelTooWide = window.innerWidth < naturalWidthRef.current + 32;
|
|
550
|
+
setIsCollapsed(panelTooWide || collapsed);
|
|
551
|
+
}
|
|
552
|
+
window.addEventListener('resize', check);
|
|
553
|
+
window.addEventListener('scroll', check, {
|
|
554
|
+
passive: true
|
|
555
|
+
});
|
|
556
|
+
check();
|
|
557
|
+
return () => {
|
|
558
|
+
window.removeEventListener('resize', check);
|
|
559
|
+
window.removeEventListener('scroll', check);
|
|
560
|
+
naturalWidthRef.current = null;
|
|
561
|
+
setIsCollapsed(collapsed);
|
|
562
|
+
};
|
|
563
|
+
}, [autoCollapse, collapsed]);
|
|
564
|
+
const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
|
|
565
|
+
children: [showAll && /*#__PURE__*/jsxRuntime.jsx(DropdownNavItem, {
|
|
566
|
+
id: "__all__",
|
|
567
|
+
icon: allIcon,
|
|
568
|
+
children: allLabel
|
|
569
|
+
}), children]
|
|
570
|
+
});
|
|
571
|
+
const colClass = `hangoverDropdown-column forNavigation${isCollapsed ? ' isCollapsed' : ''}`;
|
|
572
|
+
if (isSingle) return null;
|
|
573
|
+
if (Comp) {
|
|
574
|
+
return /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
575
|
+
ref: wrapperRef,
|
|
576
|
+
className: colClass,
|
|
577
|
+
...rest,
|
|
578
|
+
children: /*#__PURE__*/jsxRuntime.jsx("nav", {
|
|
579
|
+
className: "hangoverDropdown-nav",
|
|
580
|
+
children: inner
|
|
581
|
+
})
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
585
|
+
ref: wrapperRef,
|
|
586
|
+
className: colClass,
|
|
587
|
+
...rest,
|
|
588
|
+
children: /*#__PURE__*/jsxRuntime.jsx("nav", {
|
|
589
|
+
className: "hangoverDropdown-nav",
|
|
590
|
+
children: inner
|
|
591
|
+
})
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function DefaultSearchIcon() {
|
|
596
|
+
return /*#__PURE__*/jsxRuntime.jsx("svg", {
|
|
597
|
+
width: "20",
|
|
598
|
+
height: "20",
|
|
599
|
+
viewBox: "0 0 20 20",
|
|
600
|
+
fill: "none",
|
|
601
|
+
"aria-hidden": "true",
|
|
602
|
+
children: /*#__PURE__*/jsxRuntime.jsx("path", {
|
|
603
|
+
fillRule: "evenodd",
|
|
604
|
+
clipRule: "evenodd",
|
|
605
|
+
d: "M13.1355 14.3129C11.9293 15.2651 10.406 15.8334 8.74999 15.8334C4.83797 15.8334 1.66666 12.662 1.66666 8.75002C1.66666 4.838 4.83797 1.66669 8.74999 1.66669C12.662 1.66669 15.8333 4.838 15.8333 8.75002C15.8333 10.406 15.265 11.9293 14.3129 13.1355C14.3218 13.1437 14.3306 13.1521 14.3392 13.1608L18.0892 16.9108C18.4147 17.2362 18.4147 17.7638 18.0892 18.0893C17.7638 18.4147 17.2362 18.4147 16.9107 18.0893L13.1607 14.3393C13.1521 14.3306 13.1437 14.3218 13.1355 14.3129ZM14.1667 8.75002C14.1667 11.7416 11.7415 14.1667 8.74999 14.1667C5.75845 14.1667 3.33332 11.7416 3.33332 8.75002C3.33332 5.75848 5.75845 3.33335 8.74999 3.33335C11.7415 3.33335 14.1667 5.75848 14.1667 8.75002Z",
|
|
606
|
+
fill: "currentColor"
|
|
607
|
+
})
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* DropdownContent
|
|
613
|
+
*
|
|
614
|
+
* Right column: section title + search input + scrollable list.
|
|
615
|
+
*
|
|
616
|
+
* Props:
|
|
617
|
+
* searchPlaceholder string (default "Search")
|
|
618
|
+
* title string — overrides active nav label as section title
|
|
619
|
+
* component custom wrapper component
|
|
620
|
+
* children DropdownSection / DropdownGroup / DropdownItem elements
|
|
621
|
+
*/
|
|
622
|
+
function DropdownContent({
|
|
623
|
+
searchPlaceholder = 'Search',
|
|
624
|
+
component: Comp,
|
|
625
|
+
children,
|
|
626
|
+
...rest
|
|
627
|
+
}) {
|
|
628
|
+
const {
|
|
629
|
+
searchQuery,
|
|
630
|
+
fireEvent,
|
|
631
|
+
contentRef,
|
|
632
|
+
displayMode,
|
|
633
|
+
activeNavId,
|
|
634
|
+
setScrollSpyActive
|
|
635
|
+
} = useDropdownContext();
|
|
636
|
+
|
|
637
|
+
// Scroll spy: update active nav based on scroll position
|
|
638
|
+
react.useEffect(() => {
|
|
639
|
+
if (displayMode !== 'scroll') return;
|
|
640
|
+
const scrollEl = contentRef.current;
|
|
641
|
+
if (!scrollEl) return;
|
|
642
|
+
function updateSpy() {
|
|
643
|
+
const {
|
|
644
|
+
scrollTop,
|
|
645
|
+
scrollHeight,
|
|
646
|
+
clientHeight
|
|
647
|
+
} = scrollEl;
|
|
648
|
+
|
|
649
|
+
// En üstteyken → All
|
|
650
|
+
if (scrollTop <= 2) {
|
|
651
|
+
setScrollSpyActive('__all__');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// En alttayken → son section
|
|
656
|
+
if (scrollTop + clientHeight >= scrollHeight - 2) {
|
|
657
|
+
const sections = Array.from(scrollEl.querySelectorAll('[data-section-for]'));
|
|
658
|
+
const last = sections[sections.length - 1];
|
|
659
|
+
if (last) setScrollSpyActive(last.dataset.sectionFor);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Ortadayken → top'u geçen son section
|
|
664
|
+
const sections = Array.from(scrollEl.querySelectorAll('[data-section-for]'));
|
|
665
|
+
const containerRect = scrollEl.getBoundingClientRect();
|
|
666
|
+
let activeId = null;
|
|
667
|
+
for (const el of sections) {
|
|
668
|
+
const top = el.getBoundingClientRect().top - containerRect.top;
|
|
669
|
+
if (top <= 8) activeId = el.dataset.sectionFor;
|
|
670
|
+
}
|
|
671
|
+
if (activeId) setScrollSpyActive(activeId);
|
|
672
|
+
}
|
|
673
|
+
scrollEl.addEventListener('scroll', updateSpy, {
|
|
674
|
+
passive: true
|
|
675
|
+
});
|
|
676
|
+
return () => scrollEl.removeEventListener('scroll', updateSpy);
|
|
677
|
+
}, [displayMode, contentRef, setScrollSpyActive]);
|
|
678
|
+
|
|
679
|
+
// Tab mode: reset scroll position when active tab changes
|
|
680
|
+
react.useEffect(() => {
|
|
681
|
+
if (displayMode !== 'tab') return;
|
|
682
|
+
if (contentRef.current) contentRef.current.scrollTop = 0;
|
|
683
|
+
}, [displayMode, activeNavId, contentRef]);
|
|
684
|
+
function handleSearch(e) {
|
|
685
|
+
fireEvent('search', {
|
|
686
|
+
query: e.target.value
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
|
|
690
|
+
children: [/*#__PURE__*/jsxRuntime.jsxs("label", {
|
|
691
|
+
className: "hangoverDropdown-search",
|
|
692
|
+
children: [/*#__PURE__*/jsxRuntime.jsx("span", {
|
|
693
|
+
className: "hangoverDropdown-search-icon",
|
|
694
|
+
children: /*#__PURE__*/jsxRuntime.jsx(DefaultSearchIcon, {})
|
|
695
|
+
}), /*#__PURE__*/jsxRuntime.jsx("input", {
|
|
696
|
+
type: "text",
|
|
697
|
+
className: "hangoverDropdown-search-input",
|
|
698
|
+
placeholder: searchPlaceholder,
|
|
699
|
+
"aria-label": searchPlaceholder,
|
|
700
|
+
value: searchQuery,
|
|
701
|
+
onChange: handleSearch
|
|
702
|
+
})]
|
|
703
|
+
}), /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
704
|
+
role: "listbox",
|
|
705
|
+
className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
|
|
706
|
+
ref: contentRef,
|
|
707
|
+
children: children
|
|
708
|
+
})]
|
|
709
|
+
});
|
|
710
|
+
if (Comp) {
|
|
711
|
+
return /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
712
|
+
className: "hangoverDropdown-column forItems",
|
|
713
|
+
searchQuery: searchQuery,
|
|
714
|
+
onSearchChange: handleSearch,
|
|
715
|
+
...rest,
|
|
716
|
+
children: inner
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
720
|
+
className: "hangoverDropdown-column forItems",
|
|
721
|
+
...rest,
|
|
722
|
+
children: inner
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function DropdownSection({
|
|
727
|
+
for: forProp,
|
|
728
|
+
forId: forIdProp,
|
|
729
|
+
title,
|
|
730
|
+
children,
|
|
731
|
+
...rest
|
|
732
|
+
}) {
|
|
733
|
+
const {
|
|
734
|
+
activeNavId,
|
|
735
|
+
displayMode,
|
|
736
|
+
registerSectionRef,
|
|
737
|
+
hasNav
|
|
738
|
+
} = useDropdownContext();
|
|
739
|
+
const sectionRef = react.useRef(null);
|
|
740
|
+
const forId = forProp || forIdProp || '__all__';
|
|
741
|
+
|
|
742
|
+
// Group registry for expand/collapse all
|
|
743
|
+
const groupTogglersRef = react.useRef(new Map());
|
|
744
|
+
const [, tick] = react.useState(0);
|
|
745
|
+
const forceUpdate = react.useCallback(() => tick(n => n + 1), []);
|
|
746
|
+
const registerGroup = react.useCallback((key, set, initial) => {
|
|
747
|
+
groupTogglersRef.current.set(key, {
|
|
748
|
+
set,
|
|
749
|
+
isExpanded: initial
|
|
750
|
+
});
|
|
751
|
+
forceUpdate();
|
|
752
|
+
}, [forceUpdate]);
|
|
753
|
+
const unregisterGroup = react.useCallback(key => {
|
|
754
|
+
groupTogglersRef.current.delete(key);
|
|
755
|
+
forceUpdate();
|
|
756
|
+
}, [forceUpdate]);
|
|
757
|
+
const notifyGroupState = react.useCallback((key, isExpanded) => {
|
|
758
|
+
const entry = groupTogglersRef.current.get(key);
|
|
759
|
+
if (!entry || entry.isExpanded === isExpanded) return;
|
|
760
|
+
entry.isExpanded = isExpanded;
|
|
761
|
+
forceUpdate();
|
|
762
|
+
}, [forceUpdate]);
|
|
763
|
+
const sectionControlValue = react.useMemo(() => ({
|
|
764
|
+
registerGroup,
|
|
765
|
+
unregisterGroup,
|
|
766
|
+
notifyGroupState
|
|
767
|
+
}), [registerGroup, unregisterGroup, notifyGroupState]);
|
|
768
|
+
|
|
769
|
+
// Register this element so DropdownNavItem can scroll to it
|
|
770
|
+
react.useEffect(() => {
|
|
771
|
+
registerSectionRef(forId, sectionRef.current);
|
|
772
|
+
return () => registerSectionRef(forId, null);
|
|
773
|
+
}, [forId, registerSectionRef]);
|
|
774
|
+
|
|
775
|
+
// Tab mode: hide sections that don't match active nav
|
|
776
|
+
if (displayMode === 'tab' && activeNavId !== '__all__' && activeNavId !== forId) {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
const groups = [...groupTogglersRef.current.values()];
|
|
780
|
+
const hasGroups = groups.length > 0;
|
|
781
|
+
const allExpanded = hasGroups && groups.every(e => e.isExpanded);
|
|
782
|
+
function handleToggleAll() {
|
|
783
|
+
const next = !allExpanded;
|
|
784
|
+
groupTogglersRef.current.forEach(({
|
|
785
|
+
set
|
|
786
|
+
}) => set(next));
|
|
787
|
+
}
|
|
788
|
+
return /*#__PURE__*/jsxRuntime.jsx(SectionContext.Provider, {
|
|
789
|
+
value: {
|
|
790
|
+
forId
|
|
791
|
+
},
|
|
792
|
+
children: /*#__PURE__*/jsxRuntime.jsx(SectionControlContext.Provider, {
|
|
793
|
+
value: sectionControlValue,
|
|
794
|
+
children: /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
795
|
+
className: "hangoverDropdown-section",
|
|
796
|
+
ref: sectionRef,
|
|
797
|
+
"data-section-for": forId,
|
|
798
|
+
...rest,
|
|
799
|
+
children: [title && hasNav && !(displayMode === 'tab' && activeNavId === '__all__') && /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
800
|
+
className: `hangoverDropdown-section-title${hasGroups ? ' isClickable' : ''}`,
|
|
801
|
+
onClick: hasGroups ? handleToggleAll : undefined,
|
|
802
|
+
"aria-label": hasGroups ? allExpanded ? 'Collapse all groups' : 'Expand all groups' : undefined,
|
|
803
|
+
role: hasGroups ? 'button' : undefined,
|
|
804
|
+
tabIndex: hasGroups ? 0 : undefined,
|
|
805
|
+
onKeyDown: hasGroups ? e => {
|
|
806
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
807
|
+
e.preventDefault();
|
|
808
|
+
handleToggleAll();
|
|
809
|
+
}
|
|
810
|
+
} : undefined,
|
|
811
|
+
children: /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
812
|
+
children: title
|
|
813
|
+
})
|
|
814
|
+
}), children]
|
|
815
|
+
})
|
|
816
|
+
})
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Fuse.js v7.3.0 - Lightweight fuzzy-search (http://fusejs.io)
|
|
822
|
+
*
|
|
823
|
+
* Copyright (c) 2026 Kiro Risk (http://kiro.me)
|
|
824
|
+
* All Rights Reserved. Apache Software License 2.0
|
|
825
|
+
*
|
|
826
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
827
|
+
*/
|
|
828
|
+
|
|
829
|
+
function isArray(value) {
|
|
830
|
+
return !Array.isArray ? getTag(value) === '[object Array]' : Array.isArray(value);
|
|
831
|
+
}
|
|
832
|
+
function baseToString(value) {
|
|
833
|
+
// Exit early for strings to avoid a performance hit in some environments.
|
|
834
|
+
if (typeof value == 'string') {
|
|
835
|
+
return value;
|
|
836
|
+
}
|
|
837
|
+
if (typeof value === 'bigint') {
|
|
838
|
+
return value.toString();
|
|
839
|
+
}
|
|
840
|
+
const result = value + '';
|
|
841
|
+
return result == '0' && 1 / value == -Infinity ? '-0' : result;
|
|
842
|
+
}
|
|
843
|
+
function toString(value) {
|
|
844
|
+
return value == null ? '' : baseToString(value);
|
|
845
|
+
}
|
|
846
|
+
function isString(value) {
|
|
847
|
+
return typeof value === 'string';
|
|
848
|
+
}
|
|
849
|
+
function isNumber(value) {
|
|
850
|
+
return typeof value === 'number';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Adapted from: https://github.com/lodash/lodash/blob/master/isBoolean.js
|
|
854
|
+
function isBoolean(value) {
|
|
855
|
+
return value === true || value === false || isObjectLike(value) && getTag(value) == '[object Boolean]';
|
|
856
|
+
}
|
|
857
|
+
function isObject(value) {
|
|
858
|
+
return typeof value === 'object';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Checks if `value` is object-like.
|
|
862
|
+
function isObjectLike(value) {
|
|
863
|
+
return isObject(value) && value !== null;
|
|
864
|
+
}
|
|
865
|
+
function isDefined(value) {
|
|
866
|
+
return value !== undefined && value !== null;
|
|
867
|
+
}
|
|
868
|
+
function isBlank(value) {
|
|
869
|
+
return !value.trim().length;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Gets the `toStringTag` of `value`.
|
|
873
|
+
// Adapted from: https://github.com/lodash/lodash/blob/master/.internal/getTag.js
|
|
874
|
+
function getTag(value) {
|
|
875
|
+
return value == null ? value === undefined ? '[object Undefined]' : '[object Null]' : Object.prototype.toString.call(value);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const INCORRECT_INDEX_TYPE = "Incorrect 'index' type";
|
|
879
|
+
const LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY = key => `Invalid value for key ${key}`;
|
|
880
|
+
const PATTERN_LENGTH_TOO_LARGE = max => `Pattern length exceeds max of ${max}.`;
|
|
881
|
+
const MISSING_KEY_PROPERTY = name => `Missing ${name} property in key`;
|
|
882
|
+
const INVALID_KEY_WEIGHT_VALUE = key => `Property 'weight' in key '${key}' must be a positive integer`;
|
|
883
|
+
|
|
884
|
+
const hasOwn = Object.prototype.hasOwnProperty;
|
|
885
|
+
class KeyStore {
|
|
886
|
+
constructor(keys) {
|
|
887
|
+
this._keys = [];
|
|
888
|
+
this._keyMap = {};
|
|
889
|
+
let totalWeight = 0;
|
|
890
|
+
keys.forEach(key => {
|
|
891
|
+
const obj = createKey(key);
|
|
892
|
+
this._keys.push(obj);
|
|
893
|
+
this._keyMap[obj.id] = obj;
|
|
894
|
+
totalWeight += obj.weight;
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Normalize weights so that their sum is equal to 1
|
|
898
|
+
this._keys.forEach(key => {
|
|
899
|
+
key.weight /= totalWeight;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
get(keyId) {
|
|
903
|
+
return this._keyMap[keyId];
|
|
904
|
+
}
|
|
905
|
+
keys() {
|
|
906
|
+
return this._keys;
|
|
907
|
+
}
|
|
908
|
+
toJSON() {
|
|
909
|
+
return JSON.stringify(this._keys);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function createKey(key) {
|
|
913
|
+
let path = null;
|
|
914
|
+
let id = null;
|
|
915
|
+
let src = null;
|
|
916
|
+
let weight = 1;
|
|
917
|
+
let getFn = null;
|
|
918
|
+
if (isString(key) || isArray(key)) {
|
|
919
|
+
src = key;
|
|
920
|
+
path = createKeyPath(key);
|
|
921
|
+
id = createKeyId(key);
|
|
922
|
+
} else {
|
|
923
|
+
if (!hasOwn.call(key, 'name')) {
|
|
924
|
+
throw new Error(MISSING_KEY_PROPERTY('name'));
|
|
925
|
+
}
|
|
926
|
+
const name = key.name;
|
|
927
|
+
src = name;
|
|
928
|
+
if (hasOwn.call(key, 'weight')) {
|
|
929
|
+
weight = key.weight;
|
|
930
|
+
if (weight <= 0) {
|
|
931
|
+
throw new Error(INVALID_KEY_WEIGHT_VALUE(name));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
path = createKeyPath(name);
|
|
935
|
+
id = createKeyId(name);
|
|
936
|
+
getFn = key.getFn;
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
path: path,
|
|
940
|
+
id: id,
|
|
941
|
+
weight,
|
|
942
|
+
src: src,
|
|
943
|
+
getFn
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function createKeyPath(key) {
|
|
947
|
+
return isArray(key) ? key : key.split('.');
|
|
948
|
+
}
|
|
949
|
+
function createKeyId(key) {
|
|
950
|
+
return isArray(key) ? key.join('.') : key;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function get(obj, path) {
|
|
954
|
+
const list = [];
|
|
955
|
+
let arr = false;
|
|
956
|
+
const deepGet = (obj, path, index, arrayIndex) => {
|
|
957
|
+
if (!isDefined(obj)) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!path[index]) {
|
|
961
|
+
// If there's no path left, we've arrived at the object we care about.
|
|
962
|
+
list.push(arrayIndex !== undefined ? {
|
|
963
|
+
v: obj,
|
|
964
|
+
i: arrayIndex
|
|
965
|
+
} : obj);
|
|
966
|
+
} else {
|
|
967
|
+
const key = path[index];
|
|
968
|
+
const value = obj[key];
|
|
969
|
+
if (!isDefined(value)) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// If we're at the last value in the path, and if it's a string/number/bool,
|
|
974
|
+
// add it to the list
|
|
975
|
+
if (index === path.length - 1 && (isString(value) || isNumber(value) || isBoolean(value) || typeof value === 'bigint')) {
|
|
976
|
+
list.push(arrayIndex !== undefined ? {
|
|
977
|
+
v: toString(value),
|
|
978
|
+
i: arrayIndex
|
|
979
|
+
} : toString(value));
|
|
980
|
+
} else if (isArray(value)) {
|
|
981
|
+
arr = true;
|
|
982
|
+
// Search each item in the array.
|
|
983
|
+
for (let i = 0, len = value.length; i < len; i += 1) {
|
|
984
|
+
deepGet(value[i], path, index + 1, i);
|
|
985
|
+
}
|
|
986
|
+
} else if (path.length) {
|
|
987
|
+
// An object. Recurse further.
|
|
988
|
+
deepGet(value, path, index + 1, arrayIndex);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// Backwards compatibility (since path used to be a string)
|
|
994
|
+
deepGet(obj, isString(path) ? path.split('.') : path, 0);
|
|
995
|
+
return arr ? list : list[0];
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const MatchOptions = {
|
|
999
|
+
includeMatches: false,
|
|
1000
|
+
findAllMatches: false,
|
|
1001
|
+
minMatchCharLength: 1
|
|
1002
|
+
};
|
|
1003
|
+
const BasicOptions = {
|
|
1004
|
+
isCaseSensitive: false,
|
|
1005
|
+
ignoreDiacritics: false,
|
|
1006
|
+
includeScore: false,
|
|
1007
|
+
keys: [],
|
|
1008
|
+
shouldSort: true,
|
|
1009
|
+
sortFn: (a, b) => a.score === b.score ? a.idx < b.idx ? -1 : 1 : a.score < b.score ? -1 : 1
|
|
1010
|
+
};
|
|
1011
|
+
const FuzzyOptions = {
|
|
1012
|
+
location: 0,
|
|
1013
|
+
threshold: 0.6,
|
|
1014
|
+
distance: 100
|
|
1015
|
+
};
|
|
1016
|
+
const AdvancedOptions = {
|
|
1017
|
+
useExtendedSearch: false,
|
|
1018
|
+
useTokenSearch: false,
|
|
1019
|
+
getFn: get,
|
|
1020
|
+
ignoreLocation: false,
|
|
1021
|
+
ignoreFieldNorm: false,
|
|
1022
|
+
fieldNormWeight: 1
|
|
1023
|
+
};
|
|
1024
|
+
const Config = Object.freeze({
|
|
1025
|
+
...BasicOptions,
|
|
1026
|
+
...MatchOptions,
|
|
1027
|
+
...FuzzyOptions,
|
|
1028
|
+
...AdvancedOptions
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
const SPACE = /[^ ]+/g;
|
|
1032
|
+
|
|
1033
|
+
// Field-length norm: the shorter the field, the higher the weight.
|
|
1034
|
+
// Set to 3 decimals to reduce index size.
|
|
1035
|
+
function norm(weight = 1, mantissa = 3) {
|
|
1036
|
+
const cache = new Map();
|
|
1037
|
+
const m = Math.pow(10, mantissa);
|
|
1038
|
+
return {
|
|
1039
|
+
get(value) {
|
|
1040
|
+
const numTokens = value.match(SPACE).length;
|
|
1041
|
+
if (cache.has(numTokens)) {
|
|
1042
|
+
return cache.get(numTokens);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Default function is 1/sqrt(x), weight makes that variable
|
|
1046
|
+
const norm = 1 / Math.pow(numTokens, 0.5 * weight);
|
|
1047
|
+
|
|
1048
|
+
// In place of `toFixed(mantissa)`, for faster computation
|
|
1049
|
+
const n = parseFloat(Math.round(norm * m) / m);
|
|
1050
|
+
cache.set(numTokens, n);
|
|
1051
|
+
return n;
|
|
1052
|
+
},
|
|
1053
|
+
clear() {
|
|
1054
|
+
cache.clear();
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
class FuseIndex {
|
|
1060
|
+
constructor({
|
|
1061
|
+
getFn = Config.getFn,
|
|
1062
|
+
fieldNormWeight = Config.fieldNormWeight
|
|
1063
|
+
} = {}) {
|
|
1064
|
+
this.norm = norm(fieldNormWeight, 3);
|
|
1065
|
+
this.getFn = getFn;
|
|
1066
|
+
this.isCreated = false;
|
|
1067
|
+
this.docs = [];
|
|
1068
|
+
this.keys = [];
|
|
1069
|
+
this._keysMap = {};
|
|
1070
|
+
this.setIndexRecords();
|
|
1071
|
+
}
|
|
1072
|
+
setSources(docs = []) {
|
|
1073
|
+
this.docs = docs;
|
|
1074
|
+
}
|
|
1075
|
+
setIndexRecords(records = []) {
|
|
1076
|
+
this.records = records;
|
|
1077
|
+
}
|
|
1078
|
+
setKeys(keys = []) {
|
|
1079
|
+
this.keys = keys;
|
|
1080
|
+
this._keysMap = {};
|
|
1081
|
+
keys.forEach((key, idx) => {
|
|
1082
|
+
this._keysMap[key.id] = idx;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
create() {
|
|
1086
|
+
if (this.isCreated || !this.docs.length) {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
this.isCreated = true;
|
|
1090
|
+
|
|
1091
|
+
// List is Array<String>
|
|
1092
|
+
if (isString(this.docs[0])) {
|
|
1093
|
+
this.docs.forEach((doc, docIndex) => {
|
|
1094
|
+
this._addString(doc, docIndex);
|
|
1095
|
+
});
|
|
1096
|
+
} else {
|
|
1097
|
+
// List is Array<Object>
|
|
1098
|
+
this.docs.forEach((doc, docIndex) => {
|
|
1099
|
+
this._addObject(doc, docIndex);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
this.norm.clear();
|
|
1103
|
+
}
|
|
1104
|
+
// Adds a doc to the end of the index
|
|
1105
|
+
add(doc) {
|
|
1106
|
+
const idx = this.size();
|
|
1107
|
+
if (isString(doc)) {
|
|
1108
|
+
this._addString(doc, idx);
|
|
1109
|
+
} else {
|
|
1110
|
+
this._addObject(doc, idx);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Removes the doc at the specified index of the index
|
|
1114
|
+
removeAt(idx) {
|
|
1115
|
+
this.records.splice(idx, 1);
|
|
1116
|
+
|
|
1117
|
+
// Change ref index of every subsquent doc
|
|
1118
|
+
for (let i = idx, len = this.size(); i < len; i += 1) {
|
|
1119
|
+
this.records[i].i -= 1;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// Removes docs at the specified indices (must be sorted ascending)
|
|
1123
|
+
removeAll(indices) {
|
|
1124
|
+
// Remove in reverse order to avoid index shifting during splice
|
|
1125
|
+
for (let i = indices.length - 1; i >= 0; i -= 1) {
|
|
1126
|
+
this.records.splice(indices[i], 1);
|
|
1127
|
+
}
|
|
1128
|
+
// Single re-index pass
|
|
1129
|
+
for (let i = 0, len = this.records.length; i < len; i += 1) {
|
|
1130
|
+
this.records[i].i = i;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
getValueForItemAtKeyId(item, keyId) {
|
|
1134
|
+
return item[this._keysMap[keyId]];
|
|
1135
|
+
}
|
|
1136
|
+
size() {
|
|
1137
|
+
return this.records.length;
|
|
1138
|
+
}
|
|
1139
|
+
_addString(doc, docIndex) {
|
|
1140
|
+
if (!isDefined(doc) || isBlank(doc)) {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const record = {
|
|
1144
|
+
v: doc,
|
|
1145
|
+
i: docIndex,
|
|
1146
|
+
n: this.norm.get(doc)
|
|
1147
|
+
};
|
|
1148
|
+
this.records.push(record);
|
|
1149
|
+
}
|
|
1150
|
+
_addObject(doc, docIndex) {
|
|
1151
|
+
const record = {
|
|
1152
|
+
i: docIndex,
|
|
1153
|
+
$: {}
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
// Iterate over every key (i.e, path), and fetch the value at that key
|
|
1157
|
+
this.keys.forEach((key, keyIndex) => {
|
|
1158
|
+
const value = key.getFn ? key.getFn(doc) : this.getFn(doc, key.path);
|
|
1159
|
+
if (!isDefined(value)) {
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (isArray(value)) {
|
|
1163
|
+
const subRecords = [];
|
|
1164
|
+
for (let i = 0, len = value.length; i < len; i += 1) {
|
|
1165
|
+
const item = value[i];
|
|
1166
|
+
if (!isDefined(item)) {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (isString(item)) {
|
|
1170
|
+
// Custom getFn returning plain string array (backward compat)
|
|
1171
|
+
if (!isBlank(item)) {
|
|
1172
|
+
const subRecord = {
|
|
1173
|
+
v: item,
|
|
1174
|
+
i: i,
|
|
1175
|
+
n: this.norm.get(item)
|
|
1176
|
+
};
|
|
1177
|
+
subRecords.push(subRecord);
|
|
1178
|
+
}
|
|
1179
|
+
} else if (isDefined(item.v)) {
|
|
1180
|
+
// Default get() returns {v, i} objects with original array indices
|
|
1181
|
+
const text = isString(item.v) ? item.v : toString(item.v);
|
|
1182
|
+
if (!isBlank(text)) {
|
|
1183
|
+
const subRecord = {
|
|
1184
|
+
v: text,
|
|
1185
|
+
i: item.i,
|
|
1186
|
+
n: this.norm.get(text)
|
|
1187
|
+
};
|
|
1188
|
+
subRecords.push(subRecord);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
record.$[keyIndex] = subRecords;
|
|
1193
|
+
} else if (isString(value) && !isBlank(value)) {
|
|
1194
|
+
const subRecord = {
|
|
1195
|
+
v: value,
|
|
1196
|
+
n: this.norm.get(value)
|
|
1197
|
+
};
|
|
1198
|
+
record.$[keyIndex] = subRecord;
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
this.records.push(record);
|
|
1202
|
+
}
|
|
1203
|
+
toJSON() {
|
|
1204
|
+
return {
|
|
1205
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1206
|
+
keys: this.keys.map(({
|
|
1207
|
+
getFn,
|
|
1208
|
+
...key
|
|
1209
|
+
}) => key),
|
|
1210
|
+
records: this.records
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
function createIndex(keys, docs, {
|
|
1215
|
+
getFn = Config.getFn,
|
|
1216
|
+
fieldNormWeight = Config.fieldNormWeight
|
|
1217
|
+
} = {}) {
|
|
1218
|
+
const myIndex = new FuseIndex({
|
|
1219
|
+
getFn,
|
|
1220
|
+
fieldNormWeight
|
|
1221
|
+
});
|
|
1222
|
+
myIndex.setKeys(keys.map(createKey));
|
|
1223
|
+
myIndex.setSources(docs);
|
|
1224
|
+
myIndex.create();
|
|
1225
|
+
return myIndex;
|
|
1226
|
+
}
|
|
1227
|
+
function parseIndex(data, {
|
|
1228
|
+
getFn = Config.getFn,
|
|
1229
|
+
fieldNormWeight = Config.fieldNormWeight
|
|
1230
|
+
} = {}) {
|
|
1231
|
+
const {
|
|
1232
|
+
keys,
|
|
1233
|
+
records
|
|
1234
|
+
} = data;
|
|
1235
|
+
const myIndex = new FuseIndex({
|
|
1236
|
+
getFn,
|
|
1237
|
+
fieldNormWeight
|
|
1238
|
+
});
|
|
1239
|
+
myIndex.setKeys(keys);
|
|
1240
|
+
myIndex.setIndexRecords(records);
|
|
1241
|
+
return myIndex;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function convertMaskToIndices(matchmask = [], minMatchCharLength = Config.minMatchCharLength) {
|
|
1245
|
+
const indices = [];
|
|
1246
|
+
let start = -1;
|
|
1247
|
+
let end = -1;
|
|
1248
|
+
let i = 0;
|
|
1249
|
+
for (let len = matchmask.length; i < len; i += 1) {
|
|
1250
|
+
const match = matchmask[i];
|
|
1251
|
+
if (match && start === -1) {
|
|
1252
|
+
start = i;
|
|
1253
|
+
} else if (!match && start !== -1) {
|
|
1254
|
+
end = i - 1;
|
|
1255
|
+
if (end - start + 1 >= minMatchCharLength) {
|
|
1256
|
+
indices.push([start, end]);
|
|
1257
|
+
}
|
|
1258
|
+
start = -1;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// (i-1 - start) + 1 => i - start
|
|
1263
|
+
if (matchmask[i - 1] && i - start >= minMatchCharLength) {
|
|
1264
|
+
indices.push([start, i - 1]);
|
|
1265
|
+
}
|
|
1266
|
+
return indices;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Machine word size
|
|
1270
|
+
const MAX_BITS = 32;
|
|
1271
|
+
|
|
1272
|
+
function search(text, pattern, patternAlphabet, {
|
|
1273
|
+
location = Config.location,
|
|
1274
|
+
distance = Config.distance,
|
|
1275
|
+
threshold = Config.threshold,
|
|
1276
|
+
findAllMatches = Config.findAllMatches,
|
|
1277
|
+
minMatchCharLength = Config.minMatchCharLength,
|
|
1278
|
+
includeMatches = Config.includeMatches,
|
|
1279
|
+
ignoreLocation = Config.ignoreLocation
|
|
1280
|
+
} = {}) {
|
|
1281
|
+
if (pattern.length > MAX_BITS) {
|
|
1282
|
+
throw new Error(PATTERN_LENGTH_TOO_LARGE(MAX_BITS));
|
|
1283
|
+
}
|
|
1284
|
+
const patternLen = pattern.length;
|
|
1285
|
+
// Set starting location at beginning text and initialize the alphabet.
|
|
1286
|
+
const textLen = text.length;
|
|
1287
|
+
// Handle the case when location > text.length
|
|
1288
|
+
const expectedLocation = Math.max(0, Math.min(location, textLen));
|
|
1289
|
+
// Highest score beyond which we give up.
|
|
1290
|
+
let currentThreshold = threshold;
|
|
1291
|
+
// Is there a nearby exact match? (speedup)
|
|
1292
|
+
let bestLocation = expectedLocation;
|
|
1293
|
+
|
|
1294
|
+
// Inlined score computation — avoids object allocation per call in hot loops.
|
|
1295
|
+
// See ./computeScore.ts for the documented version of this formula.
|
|
1296
|
+
const calcScore = (errors, currentLocation) => {
|
|
1297
|
+
const accuracy = errors / patternLen;
|
|
1298
|
+
if (ignoreLocation) return accuracy;
|
|
1299
|
+
const proximity = Math.abs(expectedLocation - currentLocation);
|
|
1300
|
+
if (!distance) return proximity ? 1.0 : accuracy;
|
|
1301
|
+
return accuracy + proximity / distance;
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// Performance: only computer matches when the minMatchCharLength > 1
|
|
1305
|
+
// OR if `includeMatches` is true.
|
|
1306
|
+
const computeMatches = minMatchCharLength > 1 || includeMatches;
|
|
1307
|
+
// A mask of the matches, used for building the indices
|
|
1308
|
+
const matchMask = computeMatches ? Array(textLen) : [];
|
|
1309
|
+
let index;
|
|
1310
|
+
|
|
1311
|
+
// Get all exact matches, here for speed up
|
|
1312
|
+
while ((index = text.indexOf(pattern, bestLocation)) > -1) {
|
|
1313
|
+
const score = calcScore(0, index);
|
|
1314
|
+
currentThreshold = Math.min(score, currentThreshold);
|
|
1315
|
+
bestLocation = index + patternLen;
|
|
1316
|
+
if (computeMatches) {
|
|
1317
|
+
let i = 0;
|
|
1318
|
+
while (i < patternLen) {
|
|
1319
|
+
matchMask[index + i] = 1;
|
|
1320
|
+
i += 1;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Reset the best location
|
|
1326
|
+
bestLocation = -1;
|
|
1327
|
+
let lastBitArr = [];
|
|
1328
|
+
let finalScore = 1;
|
|
1329
|
+
let binMax = patternLen + textLen;
|
|
1330
|
+
const mask = 1 << patternLen - 1;
|
|
1331
|
+
for (let i = 0; i < patternLen; i += 1) {
|
|
1332
|
+
// Scan for the best match; each iteration allows for one more error.
|
|
1333
|
+
// Run a binary search to determine how far from the match location we can stray
|
|
1334
|
+
// at this error level.
|
|
1335
|
+
let binMin = 0;
|
|
1336
|
+
let binMid = binMax;
|
|
1337
|
+
while (binMin < binMid) {
|
|
1338
|
+
const score = calcScore(i, expectedLocation + binMid);
|
|
1339
|
+
if (score <= currentThreshold) {
|
|
1340
|
+
binMin = binMid;
|
|
1341
|
+
} else {
|
|
1342
|
+
binMax = binMid;
|
|
1343
|
+
}
|
|
1344
|
+
binMid = Math.floor((binMax - binMin) / 2 + binMin);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Use the result from this iteration as the maximum for the next.
|
|
1348
|
+
binMax = binMid;
|
|
1349
|
+
let start = Math.max(1, expectedLocation - binMid + 1);
|
|
1350
|
+
const finish = findAllMatches ? textLen : Math.min(expectedLocation + binMid, textLen) + patternLen;
|
|
1351
|
+
|
|
1352
|
+
// Initialize the bit array
|
|
1353
|
+
const bitArr = Array(finish + 2);
|
|
1354
|
+
bitArr[finish + 1] = (1 << i) - 1;
|
|
1355
|
+
for (let j = finish; j >= start; j -= 1) {
|
|
1356
|
+
const currentLocation = j - 1;
|
|
1357
|
+
const charMatch = patternAlphabet[text[currentLocation]];
|
|
1358
|
+
if (computeMatches) {
|
|
1359
|
+
// Speed up: quick bool to int conversion (i.e, `charMatch ? 1 : 0`)
|
|
1360
|
+
matchMask[currentLocation] = +!!charMatch;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// First pass: exact match
|
|
1364
|
+
bitArr[j] = (bitArr[j + 1] << 1 | 1) & charMatch;
|
|
1365
|
+
|
|
1366
|
+
// Subsequent passes: fuzzy match
|
|
1367
|
+
if (i) {
|
|
1368
|
+
bitArr[j] |= (lastBitArr[j + 1] | lastBitArr[j]) << 1 | 1 | lastBitArr[j + 1];
|
|
1369
|
+
}
|
|
1370
|
+
if (bitArr[j] & mask) {
|
|
1371
|
+
finalScore = calcScore(i, currentLocation);
|
|
1372
|
+
|
|
1373
|
+
// This match will almost certainly be better than any existing match.
|
|
1374
|
+
// But check anyway.
|
|
1375
|
+
if (finalScore <= currentThreshold) {
|
|
1376
|
+
// Indeed it is
|
|
1377
|
+
currentThreshold = finalScore;
|
|
1378
|
+
bestLocation = currentLocation;
|
|
1379
|
+
|
|
1380
|
+
// Already passed `loc`, downhill from here on in.
|
|
1381
|
+
if (bestLocation <= expectedLocation) {
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// When passing `bestLocation`, don't exceed our current distance from `expectedLocation`.
|
|
1386
|
+
start = Math.max(1, 2 * expectedLocation - bestLocation);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// No hope for a (better) match at greater error levels.
|
|
1392
|
+
const score = calcScore(i + 1, expectedLocation);
|
|
1393
|
+
if (score > currentThreshold) {
|
|
1394
|
+
break;
|
|
1395
|
+
}
|
|
1396
|
+
lastBitArr = bitArr;
|
|
1397
|
+
}
|
|
1398
|
+
const result = {
|
|
1399
|
+
isMatch: bestLocation >= 0,
|
|
1400
|
+
// Count exact matches (those with a score of 0) to be "almost" exact
|
|
1401
|
+
score: Math.max(0.001, finalScore)
|
|
1402
|
+
};
|
|
1403
|
+
if (computeMatches) {
|
|
1404
|
+
const indices = convertMaskToIndices(matchMask, minMatchCharLength);
|
|
1405
|
+
if (!indices.length) {
|
|
1406
|
+
result.isMatch = false;
|
|
1407
|
+
} else if (includeMatches) {
|
|
1408
|
+
result.indices = indices;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return result;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function createPatternAlphabet(pattern) {
|
|
1415
|
+
const mask = {};
|
|
1416
|
+
for (let i = 0, len = pattern.length; i < len; i += 1) {
|
|
1417
|
+
const char = pattern.charAt(i);
|
|
1418
|
+
mask[char] = (mask[char] || 0) | 1 << len - i - 1;
|
|
1419
|
+
}
|
|
1420
|
+
return mask;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function mergeIndices(indices) {
|
|
1424
|
+
if (indices.length <= 1) return indices;
|
|
1425
|
+
indices.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
1426
|
+
const merged = [indices[0]];
|
|
1427
|
+
for (let i = 1, len = indices.length; i < len; i += 1) {
|
|
1428
|
+
const last = merged[merged.length - 1];
|
|
1429
|
+
const curr = indices[i];
|
|
1430
|
+
if (curr[0] <= last[1] + 1) {
|
|
1431
|
+
last[1] = Math.max(last[1], curr[1]);
|
|
1432
|
+
} else {
|
|
1433
|
+
merged.push(curr);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return merged;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Characters that survive NFD normalization unchanged and need explicit mapping
|
|
1440
|
+
const NON_DECOMPOSABLE_MAP = {
|
|
1441
|
+
'\u0142': 'l',
|
|
1442
|
+
// ł
|
|
1443
|
+
'\u0141': 'L',
|
|
1444
|
+
// Ł
|
|
1445
|
+
'\u0111': 'd',
|
|
1446
|
+
// đ
|
|
1447
|
+
'\u0110': 'D',
|
|
1448
|
+
// Đ
|
|
1449
|
+
'\u00F8': 'o',
|
|
1450
|
+
// ø
|
|
1451
|
+
'\u00D8': 'O',
|
|
1452
|
+
// Ø
|
|
1453
|
+
'\u0127': 'h',
|
|
1454
|
+
// ħ
|
|
1455
|
+
'\u0126': 'H',
|
|
1456
|
+
// Ħ
|
|
1457
|
+
'\u0167': 't',
|
|
1458
|
+
// ŧ
|
|
1459
|
+
'\u0166': 'T',
|
|
1460
|
+
// Ŧ
|
|
1461
|
+
'\u0131': 'i',
|
|
1462
|
+
// ı
|
|
1463
|
+
'\u00DF': 'ss' // ß
|
|
1464
|
+
};
|
|
1465
|
+
const NON_DECOMPOSABLE_RE = new RegExp('[' + Object.keys(NON_DECOMPOSABLE_MAP).join('') + ']', 'g');
|
|
1466
|
+
const stripDiacritics = String.prototype.normalize ? str => str.normalize('NFD').replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g, '').replace(NON_DECOMPOSABLE_RE, ch => NON_DECOMPOSABLE_MAP[ch]) : str => str;
|
|
1467
|
+
|
|
1468
|
+
class BitapSearch {
|
|
1469
|
+
constructor(pattern, {
|
|
1470
|
+
location = Config.location,
|
|
1471
|
+
threshold = Config.threshold,
|
|
1472
|
+
distance = Config.distance,
|
|
1473
|
+
includeMatches = Config.includeMatches,
|
|
1474
|
+
findAllMatches = Config.findAllMatches,
|
|
1475
|
+
minMatchCharLength = Config.minMatchCharLength,
|
|
1476
|
+
isCaseSensitive = Config.isCaseSensitive,
|
|
1477
|
+
ignoreDiacritics = Config.ignoreDiacritics,
|
|
1478
|
+
ignoreLocation = Config.ignoreLocation
|
|
1479
|
+
} = {}) {
|
|
1480
|
+
this.options = {
|
|
1481
|
+
location,
|
|
1482
|
+
threshold,
|
|
1483
|
+
distance,
|
|
1484
|
+
includeMatches,
|
|
1485
|
+
findAllMatches,
|
|
1486
|
+
minMatchCharLength,
|
|
1487
|
+
isCaseSensitive,
|
|
1488
|
+
ignoreDiacritics,
|
|
1489
|
+
ignoreLocation
|
|
1490
|
+
};
|
|
1491
|
+
pattern = isCaseSensitive ? pattern : pattern.toLowerCase();
|
|
1492
|
+
pattern = ignoreDiacritics ? stripDiacritics(pattern) : pattern;
|
|
1493
|
+
this.pattern = pattern;
|
|
1494
|
+
this.chunks = [];
|
|
1495
|
+
if (!this.pattern.length) {
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const addChunk = (pattern, startIndex) => {
|
|
1499
|
+
this.chunks.push({
|
|
1500
|
+
pattern,
|
|
1501
|
+
alphabet: createPatternAlphabet(pattern),
|
|
1502
|
+
startIndex
|
|
1503
|
+
});
|
|
1504
|
+
};
|
|
1505
|
+
const len = this.pattern.length;
|
|
1506
|
+
if (len > MAX_BITS) {
|
|
1507
|
+
let i = 0;
|
|
1508
|
+
const remainder = len % MAX_BITS;
|
|
1509
|
+
const end = len - remainder;
|
|
1510
|
+
while (i < end) {
|
|
1511
|
+
addChunk(this.pattern.substr(i, MAX_BITS), i);
|
|
1512
|
+
i += MAX_BITS;
|
|
1513
|
+
}
|
|
1514
|
+
if (remainder) {
|
|
1515
|
+
const startIndex = len - MAX_BITS;
|
|
1516
|
+
addChunk(this.pattern.substr(startIndex), startIndex);
|
|
1517
|
+
}
|
|
1518
|
+
} else {
|
|
1519
|
+
addChunk(this.pattern, 0);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
searchIn(text) {
|
|
1523
|
+
const {
|
|
1524
|
+
isCaseSensitive,
|
|
1525
|
+
ignoreDiacritics,
|
|
1526
|
+
includeMatches
|
|
1527
|
+
} = this.options;
|
|
1528
|
+
text = isCaseSensitive ? text : text.toLowerCase();
|
|
1529
|
+
text = ignoreDiacritics ? stripDiacritics(text) : text;
|
|
1530
|
+
|
|
1531
|
+
// Exact match
|
|
1532
|
+
if (this.pattern === text) {
|
|
1533
|
+
const result = {
|
|
1534
|
+
isMatch: true,
|
|
1535
|
+
score: 0
|
|
1536
|
+
};
|
|
1537
|
+
if (includeMatches) {
|
|
1538
|
+
result.indices = [[0, text.length - 1]];
|
|
1539
|
+
}
|
|
1540
|
+
return result;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Otherwise, use Bitap algorithm
|
|
1544
|
+
const {
|
|
1545
|
+
location,
|
|
1546
|
+
distance,
|
|
1547
|
+
threshold,
|
|
1548
|
+
findAllMatches,
|
|
1549
|
+
minMatchCharLength,
|
|
1550
|
+
ignoreLocation
|
|
1551
|
+
} = this.options;
|
|
1552
|
+
const allIndices = [];
|
|
1553
|
+
let totalScore = 0;
|
|
1554
|
+
let hasMatches = false;
|
|
1555
|
+
this.chunks.forEach(({
|
|
1556
|
+
pattern,
|
|
1557
|
+
alphabet,
|
|
1558
|
+
startIndex
|
|
1559
|
+
}) => {
|
|
1560
|
+
const {
|
|
1561
|
+
isMatch,
|
|
1562
|
+
score,
|
|
1563
|
+
indices
|
|
1564
|
+
} = search(text, pattern, alphabet, {
|
|
1565
|
+
location: location + startIndex,
|
|
1566
|
+
distance,
|
|
1567
|
+
threshold,
|
|
1568
|
+
findAllMatches,
|
|
1569
|
+
minMatchCharLength,
|
|
1570
|
+
includeMatches,
|
|
1571
|
+
ignoreLocation
|
|
1572
|
+
});
|
|
1573
|
+
if (isMatch) {
|
|
1574
|
+
hasMatches = true;
|
|
1575
|
+
}
|
|
1576
|
+
totalScore += score;
|
|
1577
|
+
if (isMatch && indices) {
|
|
1578
|
+
allIndices.push(...indices);
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
const result = {
|
|
1582
|
+
isMatch: hasMatches,
|
|
1583
|
+
score: hasMatches ? totalScore / this.chunks.length : 1
|
|
1584
|
+
};
|
|
1585
|
+
if (hasMatches && includeMatches) {
|
|
1586
|
+
result.indices = mergeIndices(allIndices);
|
|
1587
|
+
}
|
|
1588
|
+
return result;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
class BaseMatch {
|
|
1593
|
+
constructor(pattern) {
|
|
1594
|
+
this.pattern = pattern;
|
|
1595
|
+
}
|
|
1596
|
+
static isMultiMatch(pattern) {
|
|
1597
|
+
return getMatch(pattern, this.multiRegex);
|
|
1598
|
+
}
|
|
1599
|
+
static isSingleMatch(pattern) {
|
|
1600
|
+
return getMatch(pattern, this.singleRegex);
|
|
1601
|
+
}
|
|
1602
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1603
|
+
search(_text) {
|
|
1604
|
+
return {
|
|
1605
|
+
isMatch: false,
|
|
1606
|
+
score: 1
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
function getMatch(pattern, exp) {
|
|
1611
|
+
const matches = pattern.match(exp);
|
|
1612
|
+
return matches ? matches[1] : null;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Token: 'file
|
|
1616
|
+
// Match type: exact-match
|
|
1617
|
+
// Description: Items that are `file`
|
|
1618
|
+
|
|
1619
|
+
class ExactMatch extends BaseMatch {
|
|
1620
|
+
constructor(pattern) {
|
|
1621
|
+
super(pattern);
|
|
1622
|
+
}
|
|
1623
|
+
static get type() {
|
|
1624
|
+
return 'exact';
|
|
1625
|
+
}
|
|
1626
|
+
static get multiRegex() {
|
|
1627
|
+
return /^="(.*)"$/;
|
|
1628
|
+
}
|
|
1629
|
+
static get singleRegex() {
|
|
1630
|
+
return /^=(.*)$/;
|
|
1631
|
+
}
|
|
1632
|
+
search(text) {
|
|
1633
|
+
const isMatch = text === this.pattern;
|
|
1634
|
+
return {
|
|
1635
|
+
isMatch,
|
|
1636
|
+
score: isMatch ? 0 : 1,
|
|
1637
|
+
indices: [0, this.pattern.length - 1]
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Token: !fire
|
|
1643
|
+
// Match type: inverse-exact-match
|
|
1644
|
+
// Description: Items that do not include `fire`
|
|
1645
|
+
|
|
1646
|
+
class InverseExactMatch extends BaseMatch {
|
|
1647
|
+
constructor(pattern) {
|
|
1648
|
+
super(pattern);
|
|
1649
|
+
}
|
|
1650
|
+
static get type() {
|
|
1651
|
+
return 'inverse-exact';
|
|
1652
|
+
}
|
|
1653
|
+
static get multiRegex() {
|
|
1654
|
+
return /^!"(.*)"$/;
|
|
1655
|
+
}
|
|
1656
|
+
static get singleRegex() {
|
|
1657
|
+
return /^!(.*)$/;
|
|
1658
|
+
}
|
|
1659
|
+
search(text) {
|
|
1660
|
+
const index = text.indexOf(this.pattern);
|
|
1661
|
+
const isMatch = index === -1;
|
|
1662
|
+
return {
|
|
1663
|
+
isMatch,
|
|
1664
|
+
score: isMatch ? 0 : 1,
|
|
1665
|
+
indices: [0, text.length - 1]
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Token: ^file
|
|
1671
|
+
// Match type: prefix-exact-match
|
|
1672
|
+
// Description: Items that start with `file`
|
|
1673
|
+
class PrefixExactMatch extends BaseMatch {
|
|
1674
|
+
constructor(pattern) {
|
|
1675
|
+
super(pattern);
|
|
1676
|
+
}
|
|
1677
|
+
static get type() {
|
|
1678
|
+
return 'prefix-exact';
|
|
1679
|
+
}
|
|
1680
|
+
static get multiRegex() {
|
|
1681
|
+
return /^\^"(.*)"$/;
|
|
1682
|
+
}
|
|
1683
|
+
static get singleRegex() {
|
|
1684
|
+
return /^\^(.*)$/;
|
|
1685
|
+
}
|
|
1686
|
+
search(text) {
|
|
1687
|
+
const isMatch = text.startsWith(this.pattern);
|
|
1688
|
+
return {
|
|
1689
|
+
isMatch,
|
|
1690
|
+
score: isMatch ? 0 : 1,
|
|
1691
|
+
indices: [0, this.pattern.length - 1]
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Token: !^fire
|
|
1697
|
+
// Match type: inverse-prefix-exact-match
|
|
1698
|
+
// Description: Items that do not start with `fire`
|
|
1699
|
+
|
|
1700
|
+
class InversePrefixExactMatch extends BaseMatch {
|
|
1701
|
+
constructor(pattern) {
|
|
1702
|
+
super(pattern);
|
|
1703
|
+
}
|
|
1704
|
+
static get type() {
|
|
1705
|
+
return 'inverse-prefix-exact';
|
|
1706
|
+
}
|
|
1707
|
+
static get multiRegex() {
|
|
1708
|
+
return /^!\^"(.*)"$/;
|
|
1709
|
+
}
|
|
1710
|
+
static get singleRegex() {
|
|
1711
|
+
return /^!\^(.*)$/;
|
|
1712
|
+
}
|
|
1713
|
+
search(text) {
|
|
1714
|
+
const isMatch = !text.startsWith(this.pattern);
|
|
1715
|
+
return {
|
|
1716
|
+
isMatch,
|
|
1717
|
+
score: isMatch ? 0 : 1,
|
|
1718
|
+
indices: [0, text.length - 1]
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Token: .file$
|
|
1724
|
+
// Match type: suffix-exact-match
|
|
1725
|
+
// Description: Items that end with `.file`
|
|
1726
|
+
class SuffixExactMatch extends BaseMatch {
|
|
1727
|
+
constructor(pattern) {
|
|
1728
|
+
super(pattern);
|
|
1729
|
+
}
|
|
1730
|
+
static get type() {
|
|
1731
|
+
return 'suffix-exact';
|
|
1732
|
+
}
|
|
1733
|
+
static get multiRegex() {
|
|
1734
|
+
return /^"(.*)"\$$/;
|
|
1735
|
+
}
|
|
1736
|
+
static get singleRegex() {
|
|
1737
|
+
return /^(.*)\$$/;
|
|
1738
|
+
}
|
|
1739
|
+
search(text) {
|
|
1740
|
+
const isMatch = text.endsWith(this.pattern);
|
|
1741
|
+
return {
|
|
1742
|
+
isMatch,
|
|
1743
|
+
score: isMatch ? 0 : 1,
|
|
1744
|
+
indices: [text.length - this.pattern.length, text.length - 1]
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// Token: !.file$
|
|
1750
|
+
// Match type: inverse-suffix-exact-match
|
|
1751
|
+
// Description: Items that do not end with `.file`
|
|
1752
|
+
class InverseSuffixExactMatch extends BaseMatch {
|
|
1753
|
+
constructor(pattern) {
|
|
1754
|
+
super(pattern);
|
|
1755
|
+
}
|
|
1756
|
+
static get type() {
|
|
1757
|
+
return 'inverse-suffix-exact';
|
|
1758
|
+
}
|
|
1759
|
+
static get multiRegex() {
|
|
1760
|
+
return /^!"(.*)"\$$/;
|
|
1761
|
+
}
|
|
1762
|
+
static get singleRegex() {
|
|
1763
|
+
return /^!(.*)\$$/;
|
|
1764
|
+
}
|
|
1765
|
+
search(text) {
|
|
1766
|
+
const isMatch = !text.endsWith(this.pattern);
|
|
1767
|
+
return {
|
|
1768
|
+
isMatch,
|
|
1769
|
+
score: isMatch ? 0 : 1,
|
|
1770
|
+
indices: [0, text.length - 1]
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
class FuzzyMatch extends BaseMatch {
|
|
1776
|
+
constructor(pattern, {
|
|
1777
|
+
location = Config.location,
|
|
1778
|
+
threshold = Config.threshold,
|
|
1779
|
+
distance = Config.distance,
|
|
1780
|
+
includeMatches = Config.includeMatches,
|
|
1781
|
+
findAllMatches = Config.findAllMatches,
|
|
1782
|
+
minMatchCharLength = Config.minMatchCharLength,
|
|
1783
|
+
isCaseSensitive = Config.isCaseSensitive,
|
|
1784
|
+
ignoreDiacritics = Config.ignoreDiacritics,
|
|
1785
|
+
ignoreLocation = Config.ignoreLocation
|
|
1786
|
+
} = {}) {
|
|
1787
|
+
super(pattern);
|
|
1788
|
+
this._bitapSearch = new BitapSearch(pattern, {
|
|
1789
|
+
location,
|
|
1790
|
+
threshold,
|
|
1791
|
+
distance,
|
|
1792
|
+
includeMatches,
|
|
1793
|
+
findAllMatches,
|
|
1794
|
+
minMatchCharLength,
|
|
1795
|
+
isCaseSensitive,
|
|
1796
|
+
ignoreDiacritics,
|
|
1797
|
+
ignoreLocation
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
static get type() {
|
|
1801
|
+
return 'fuzzy';
|
|
1802
|
+
}
|
|
1803
|
+
static get multiRegex() {
|
|
1804
|
+
return /^"(.*)"$/;
|
|
1805
|
+
}
|
|
1806
|
+
static get singleRegex() {
|
|
1807
|
+
return /^(.*)$/;
|
|
1808
|
+
}
|
|
1809
|
+
search(text) {
|
|
1810
|
+
return this._bitapSearch.searchIn(text);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Token: 'file
|
|
1815
|
+
// Match type: include-match
|
|
1816
|
+
// Description: Items that include `file`
|
|
1817
|
+
|
|
1818
|
+
class IncludeMatch extends BaseMatch {
|
|
1819
|
+
constructor(pattern) {
|
|
1820
|
+
super(pattern);
|
|
1821
|
+
}
|
|
1822
|
+
static get type() {
|
|
1823
|
+
return 'include';
|
|
1824
|
+
}
|
|
1825
|
+
static get multiRegex() {
|
|
1826
|
+
return /^'"(.*)"$/;
|
|
1827
|
+
}
|
|
1828
|
+
static get singleRegex() {
|
|
1829
|
+
return /^'(.*)$/;
|
|
1830
|
+
}
|
|
1831
|
+
search(text) {
|
|
1832
|
+
let location = 0;
|
|
1833
|
+
let index;
|
|
1834
|
+
const indices = [];
|
|
1835
|
+
const patternLen = this.pattern.length;
|
|
1836
|
+
|
|
1837
|
+
// Get all exact matches
|
|
1838
|
+
while ((index = text.indexOf(this.pattern, location)) > -1) {
|
|
1839
|
+
location = index + patternLen;
|
|
1840
|
+
indices.push([index, location - 1]);
|
|
1841
|
+
}
|
|
1842
|
+
const isMatch = !!indices.length;
|
|
1843
|
+
return {
|
|
1844
|
+
isMatch,
|
|
1845
|
+
score: isMatch ? 0 : 1,
|
|
1846
|
+
indices
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// ❗Order is important. DO NOT CHANGE.
|
|
1852
|
+
const searchers = [ExactMatch, IncludeMatch, PrefixExactMatch, InversePrefixExactMatch, InverseSuffixExactMatch, SuffixExactMatch, InverseExactMatch, FuzzyMatch];
|
|
1853
|
+
const searchersLen = searchers.length;
|
|
1854
|
+
const ESCAPED_PIPE = '\u0000'; // placeholder for escaped \|
|
|
1855
|
+
const OR_TOKEN = '|';
|
|
1856
|
+
|
|
1857
|
+
// Tokenize a query string into individual search terms.
|
|
1858
|
+
// Respects multi-match quoted tokens like ="said "test"" or ^"hello world"$
|
|
1859
|
+
// where inner spaces and quotes are part of the token.
|
|
1860
|
+
function tokenize(pattern) {
|
|
1861
|
+
const tokens = [];
|
|
1862
|
+
const len = pattern.length;
|
|
1863
|
+
let i = 0;
|
|
1864
|
+
while (i < len) {
|
|
1865
|
+
// Skip spaces
|
|
1866
|
+
while (i < len && pattern[i] === ' ') i++;
|
|
1867
|
+
if (i >= len) break;
|
|
1868
|
+
|
|
1869
|
+
// Scan past prefix characters (=, !, ^, ') to see if a quote follows
|
|
1870
|
+
let j = i;
|
|
1871
|
+
while (j < len && pattern[j] !== ' ' && pattern[j] !== '"') j++;
|
|
1872
|
+
if (j < len && pattern[j] === '"') {
|
|
1873
|
+
// Multi-match token: prefix + "content" (possibly with inner quotes)
|
|
1874
|
+
// Find the closing " that ends this token:
|
|
1875
|
+
// it must be followed by optional $, then space or end-of-string
|
|
1876
|
+
j++; // skip opening quote
|
|
1877
|
+
while (j < len) {
|
|
1878
|
+
if (pattern[j] === '"') {
|
|
1879
|
+
// Check if this is the closing quote
|
|
1880
|
+
const next = j + 1;
|
|
1881
|
+
if (next >= len || pattern[next] === ' ') {
|
|
1882
|
+
j++; // include closing quote
|
|
1883
|
+
break;
|
|
1884
|
+
}
|
|
1885
|
+
if (pattern[next] === '$' && (next + 1 >= len || pattern[next + 1] === ' ')) {
|
|
1886
|
+
j += 2; // include "$
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
j++;
|
|
1891
|
+
}
|
|
1892
|
+
tokens.push(pattern.substring(i, j));
|
|
1893
|
+
i = j;
|
|
1894
|
+
} else {
|
|
1895
|
+
// Regular (unquoted) token: read until space or end
|
|
1896
|
+
while (j < len && pattern[j] !== ' ') j++;
|
|
1897
|
+
tokens.push(pattern.substring(i, j));
|
|
1898
|
+
i = j;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return tokens;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Return a 2D array representation of the query, for simpler parsing.
|
|
1905
|
+
// Example:
|
|
1906
|
+
// "^core go$ | rb$ | py$ xy$" => [["^core", "go$"], ["rb$"], ["py$", "xy$"]]
|
|
1907
|
+
function parseQuery(pattern, options = {}) {
|
|
1908
|
+
// Replace escaped \| with placeholder before splitting on |
|
|
1909
|
+
const escaped = pattern.replace(/\\\|/g, ESCAPED_PIPE);
|
|
1910
|
+
return escaped.split(OR_TOKEN).map(item => {
|
|
1911
|
+
// Restore escaped pipes in each OR group
|
|
1912
|
+
const restored = item.replace(/\u0000/g, '|');
|
|
1913
|
+
const query = tokenize(restored.trim()).filter(item => item && !!item.trim());
|
|
1914
|
+
const results = [];
|
|
1915
|
+
for (let i = 0, len = query.length; i < len; i += 1) {
|
|
1916
|
+
const queryItem = query[i];
|
|
1917
|
+
|
|
1918
|
+
// 1. Handle multiple query match (i.e, once that are quoted, like `"hello world"`)
|
|
1919
|
+
let found = false;
|
|
1920
|
+
let idx = -1;
|
|
1921
|
+
while (!found && ++idx < searchersLen) {
|
|
1922
|
+
const searcher = searchers[idx];
|
|
1923
|
+
const token = searcher.isMultiMatch(queryItem);
|
|
1924
|
+
if (token) {
|
|
1925
|
+
results.push(new searcher(token, options));
|
|
1926
|
+
found = true;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (found) {
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// 2. Handle single query matches (i.e, once that are *not* quoted)
|
|
1934
|
+
idx = -1;
|
|
1935
|
+
while (++idx < searchersLen) {
|
|
1936
|
+
const searcher = searchers[idx];
|
|
1937
|
+
const token = searcher.isSingleMatch(queryItem);
|
|
1938
|
+
if (token) {
|
|
1939
|
+
results.push(new searcher(token, options));
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return results;
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// These extended matchers can return an array of matches, as opposed
|
|
1949
|
+
// to a singl match
|
|
1950
|
+
const MultiMatchSet = new Set([FuzzyMatch.type, IncludeMatch.type]);
|
|
1951
|
+
class ExtendedSearch {
|
|
1952
|
+
constructor(pattern, {
|
|
1953
|
+
isCaseSensitive = Config.isCaseSensitive,
|
|
1954
|
+
ignoreDiacritics = Config.ignoreDiacritics,
|
|
1955
|
+
includeMatches = Config.includeMatches,
|
|
1956
|
+
minMatchCharLength = Config.minMatchCharLength,
|
|
1957
|
+
ignoreLocation = Config.ignoreLocation,
|
|
1958
|
+
findAllMatches = Config.findAllMatches,
|
|
1959
|
+
location = Config.location,
|
|
1960
|
+
threshold = Config.threshold,
|
|
1961
|
+
distance = Config.distance
|
|
1962
|
+
} = {}) {
|
|
1963
|
+
this.query = null;
|
|
1964
|
+
this.options = {
|
|
1965
|
+
isCaseSensitive,
|
|
1966
|
+
ignoreDiacritics,
|
|
1967
|
+
includeMatches,
|
|
1968
|
+
minMatchCharLength,
|
|
1969
|
+
findAllMatches,
|
|
1970
|
+
ignoreLocation,
|
|
1971
|
+
location,
|
|
1972
|
+
threshold,
|
|
1973
|
+
distance
|
|
1974
|
+
};
|
|
1975
|
+
pattern = isCaseSensitive ? pattern : pattern.toLowerCase();
|
|
1976
|
+
pattern = ignoreDiacritics ? stripDiacritics(pattern) : pattern;
|
|
1977
|
+
this.pattern = pattern;
|
|
1978
|
+
this.query = parseQuery(this.pattern, this.options);
|
|
1979
|
+
}
|
|
1980
|
+
static condition(_, options) {
|
|
1981
|
+
return options.useExtendedSearch;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Note: searchIn operates on a single text value and sets hasInverse on the
|
|
1985
|
+
// result when inverse patterns are involved. _searchObjectList uses this to
|
|
1986
|
+
// switch from "ANY key" to "ALL keys" aggregation. See #712.
|
|
1987
|
+
searchIn(text) {
|
|
1988
|
+
const query = this.query;
|
|
1989
|
+
if (!query) {
|
|
1990
|
+
return {
|
|
1991
|
+
isMatch: false,
|
|
1992
|
+
score: 1
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
const {
|
|
1996
|
+
includeMatches,
|
|
1997
|
+
isCaseSensitive,
|
|
1998
|
+
ignoreDiacritics
|
|
1999
|
+
} = this.options;
|
|
2000
|
+
text = isCaseSensitive ? text : text.toLowerCase();
|
|
2001
|
+
text = ignoreDiacritics ? stripDiacritics(text) : text;
|
|
2002
|
+
let numMatches = 0;
|
|
2003
|
+
const allIndices = [];
|
|
2004
|
+
let totalScore = 0;
|
|
2005
|
+
let hasInverse = false;
|
|
2006
|
+
|
|
2007
|
+
// ORs
|
|
2008
|
+
for (let i = 0, qLen = query.length; i < qLen; i += 1) {
|
|
2009
|
+
const searchers = query[i];
|
|
2010
|
+
|
|
2011
|
+
// Reset indices
|
|
2012
|
+
allIndices.length = 0;
|
|
2013
|
+
numMatches = 0;
|
|
2014
|
+
hasInverse = false;
|
|
2015
|
+
|
|
2016
|
+
// ANDs
|
|
2017
|
+
for (let j = 0, pLen = searchers.length; j < pLen; j += 1) {
|
|
2018
|
+
const searcher = searchers[j];
|
|
2019
|
+
const {
|
|
2020
|
+
isMatch,
|
|
2021
|
+
indices,
|
|
2022
|
+
score
|
|
2023
|
+
} = searcher.search(text);
|
|
2024
|
+
if (isMatch) {
|
|
2025
|
+
numMatches += 1;
|
|
2026
|
+
totalScore += score;
|
|
2027
|
+
const type = searcher.constructor.type;
|
|
2028
|
+
if (type.startsWith('inverse')) {
|
|
2029
|
+
hasInverse = true;
|
|
2030
|
+
}
|
|
2031
|
+
if (includeMatches) {
|
|
2032
|
+
if (MultiMatchSet.has(type)) {
|
|
2033
|
+
allIndices.push(...indices);
|
|
2034
|
+
} else {
|
|
2035
|
+
allIndices.push(indices);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
} else {
|
|
2039
|
+
totalScore = 0;
|
|
2040
|
+
numMatches = 0;
|
|
2041
|
+
allIndices.length = 0;
|
|
2042
|
+
hasInverse = false;
|
|
2043
|
+
break;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// OR condition, so if TRUE, return
|
|
2048
|
+
if (numMatches) {
|
|
2049
|
+
const result = {
|
|
2050
|
+
isMatch: true,
|
|
2051
|
+
score: totalScore / numMatches
|
|
2052
|
+
};
|
|
2053
|
+
if (hasInverse) {
|
|
2054
|
+
result.hasInverse = true;
|
|
2055
|
+
}
|
|
2056
|
+
if (includeMatches) {
|
|
2057
|
+
result.indices = mergeIndices(allIndices);
|
|
2058
|
+
}
|
|
2059
|
+
return result;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// Nothing was matched
|
|
2064
|
+
return {
|
|
2065
|
+
isMatch: false,
|
|
2066
|
+
score: 1
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
const registeredSearchers = [];
|
|
2072
|
+
function register(...args) {
|
|
2073
|
+
registeredSearchers.push(...args);
|
|
2074
|
+
}
|
|
2075
|
+
function createSearcher(pattern, options) {
|
|
2076
|
+
for (let i = 0, len = registeredSearchers.length; i < len; i += 1) {
|
|
2077
|
+
const searcherClass = registeredSearchers[i];
|
|
2078
|
+
if (searcherClass.condition(pattern, options)) {
|
|
2079
|
+
return new searcherClass(pattern, options);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return new BitapSearch(pattern, options);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const LogicalOperator = {
|
|
2086
|
+
AND: '$and',
|
|
2087
|
+
OR: '$or'
|
|
2088
|
+
};
|
|
2089
|
+
const KeyType = {
|
|
2090
|
+
PATH: '$path',
|
|
2091
|
+
PATTERN: '$val'
|
|
2092
|
+
};
|
|
2093
|
+
const isExpression = query => !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]);
|
|
2094
|
+
const isPath = query => !!query[KeyType.PATH];
|
|
2095
|
+
const isLeaf = query => !isArray(query) && isObject(query) && !isExpression(query);
|
|
2096
|
+
const convertToExplicit = query => ({
|
|
2097
|
+
[LogicalOperator.AND]: Object.keys(query).map(key => ({
|
|
2098
|
+
[key]: query[key]
|
|
2099
|
+
}))
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
// When `auto` is `true`, the parse function will infer and initialize and add
|
|
2103
|
+
// the appropriate `Searcher` instance
|
|
2104
|
+
function parse(query, options, {
|
|
2105
|
+
auto = true
|
|
2106
|
+
} = {}) {
|
|
2107
|
+
const next = query => {
|
|
2108
|
+
// Keyless string entry: search across all keys
|
|
2109
|
+
if (isString(query)) {
|
|
2110
|
+
const obj = {
|
|
2111
|
+
keyId: null,
|
|
2112
|
+
pattern: query
|
|
2113
|
+
};
|
|
2114
|
+
if (auto) {
|
|
2115
|
+
obj.searcher = createSearcher(query, options);
|
|
2116
|
+
}
|
|
2117
|
+
return obj;
|
|
2118
|
+
}
|
|
2119
|
+
const keys = Object.keys(query);
|
|
2120
|
+
const isQueryPath = isPath(query);
|
|
2121
|
+
if (!isQueryPath && keys.length > 1 && !isExpression(query)) {
|
|
2122
|
+
return next(convertToExplicit(query));
|
|
2123
|
+
}
|
|
2124
|
+
if (isLeaf(query)) {
|
|
2125
|
+
const key = isQueryPath ? query[KeyType.PATH] : keys[0];
|
|
2126
|
+
const pattern = isQueryPath ? query[KeyType.PATTERN] : query[key];
|
|
2127
|
+
if (!isString(pattern)) {
|
|
2128
|
+
throw new Error(LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key));
|
|
2129
|
+
}
|
|
2130
|
+
const obj = {
|
|
2131
|
+
keyId: createKeyId(key),
|
|
2132
|
+
pattern
|
|
2133
|
+
};
|
|
2134
|
+
if (auto) {
|
|
2135
|
+
obj.searcher = createSearcher(pattern, options);
|
|
2136
|
+
}
|
|
2137
|
+
return obj;
|
|
2138
|
+
}
|
|
2139
|
+
const node = {
|
|
2140
|
+
children: [],
|
|
2141
|
+
operator: keys[0]
|
|
2142
|
+
};
|
|
2143
|
+
keys.forEach(key => {
|
|
2144
|
+
const value = query[key];
|
|
2145
|
+
if (isArray(value)) {
|
|
2146
|
+
value.forEach(item => {
|
|
2147
|
+
node.children.push(next(item));
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
return node;
|
|
2152
|
+
};
|
|
2153
|
+
if (!isExpression(query)) {
|
|
2154
|
+
query = convertToExplicit(query);
|
|
2155
|
+
}
|
|
2156
|
+
return next(query);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function computeScoreSingle(matches, {
|
|
2160
|
+
ignoreFieldNorm = Config.ignoreFieldNorm
|
|
2161
|
+
}) {
|
|
2162
|
+
let totalScore = 1;
|
|
2163
|
+
matches.forEach(({
|
|
2164
|
+
key,
|
|
2165
|
+
norm,
|
|
2166
|
+
score
|
|
2167
|
+
}) => {
|
|
2168
|
+
const weight = key ? key.weight : null;
|
|
2169
|
+
totalScore *= Math.pow(score === 0 && weight ? Number.EPSILON : score, (weight || 1) * (ignoreFieldNorm ? 1 : norm));
|
|
2170
|
+
});
|
|
2171
|
+
return totalScore;
|
|
2172
|
+
}
|
|
2173
|
+
function computeScore(results, {
|
|
2174
|
+
ignoreFieldNorm = Config.ignoreFieldNorm
|
|
2175
|
+
}) {
|
|
2176
|
+
results.forEach(result => {
|
|
2177
|
+
result.score = computeScoreSingle(result.matches, {
|
|
2178
|
+
ignoreFieldNorm
|
|
2179
|
+
});
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// Max-heap by score: keeps the worst (highest) score at the top
|
|
2184
|
+
// so we can efficiently evict it when a better result arrives.
|
|
2185
|
+
class MaxHeap {
|
|
2186
|
+
constructor(limit) {
|
|
2187
|
+
this.limit = limit;
|
|
2188
|
+
this.heap = [];
|
|
2189
|
+
}
|
|
2190
|
+
get size() {
|
|
2191
|
+
return this.heap.length;
|
|
2192
|
+
}
|
|
2193
|
+
shouldInsert(score) {
|
|
2194
|
+
return this.size < this.limit || score < this.heap[0].score;
|
|
2195
|
+
}
|
|
2196
|
+
insert(item) {
|
|
2197
|
+
if (this.size < this.limit) {
|
|
2198
|
+
this.heap.push(item);
|
|
2199
|
+
this._bubbleUp(this.size - 1);
|
|
2200
|
+
} else if (item.score < this.heap[0].score) {
|
|
2201
|
+
this.heap[0] = item;
|
|
2202
|
+
this._sinkDown(0);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
extractSorted(sortFn) {
|
|
2206
|
+
return this.heap.sort(sortFn);
|
|
2207
|
+
}
|
|
2208
|
+
_bubbleUp(i) {
|
|
2209
|
+
const heap = this.heap;
|
|
2210
|
+
while (i > 0) {
|
|
2211
|
+
const parent = i - 1 >> 1;
|
|
2212
|
+
if (heap[i].score <= heap[parent].score) break;
|
|
2213
|
+
const tmp = heap[i];
|
|
2214
|
+
heap[i] = heap[parent];
|
|
2215
|
+
heap[parent] = tmp;
|
|
2216
|
+
i = parent;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
_sinkDown(i) {
|
|
2220
|
+
const heap = this.heap;
|
|
2221
|
+
const len = heap.length;
|
|
2222
|
+
let largest = i;
|
|
2223
|
+
do {
|
|
2224
|
+
i = largest;
|
|
2225
|
+
const left = 2 * i + 1;
|
|
2226
|
+
const right = 2 * i + 2;
|
|
2227
|
+
if (left < len && heap[left].score > heap[largest].score) {
|
|
2228
|
+
largest = left;
|
|
2229
|
+
}
|
|
2230
|
+
if (right < len && heap[right].score > heap[largest].score) {
|
|
2231
|
+
largest = right;
|
|
2232
|
+
}
|
|
2233
|
+
if (largest !== i) {
|
|
2234
|
+
const tmp = heap[i];
|
|
2235
|
+
heap[i] = heap[largest];
|
|
2236
|
+
heap[largest] = tmp;
|
|
2237
|
+
}
|
|
2238
|
+
} while (largest !== i);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function transformMatches(result, data) {
|
|
2243
|
+
const matches = result.matches;
|
|
2244
|
+
data.matches = [];
|
|
2245
|
+
if (!isDefined(matches)) {
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
matches.forEach(match => {
|
|
2249
|
+
if (!isDefined(match.indices) || !match.indices.length) {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
const {
|
|
2253
|
+
indices,
|
|
2254
|
+
value
|
|
2255
|
+
} = match;
|
|
2256
|
+
const obj = {
|
|
2257
|
+
indices,
|
|
2258
|
+
value
|
|
2259
|
+
};
|
|
2260
|
+
if (match.key) {
|
|
2261
|
+
obj.key = match.key.src;
|
|
2262
|
+
}
|
|
2263
|
+
if (match.idx > -1) {
|
|
2264
|
+
obj.refIndex = match.idx;
|
|
2265
|
+
}
|
|
2266
|
+
data.matches.push(obj);
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function transformScore(result, data) {
|
|
2271
|
+
data.score = result.score;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function format(results, docs, {
|
|
2275
|
+
includeMatches = Config.includeMatches,
|
|
2276
|
+
includeScore = Config.includeScore
|
|
2277
|
+
} = {}) {
|
|
2278
|
+
const transformers = [];
|
|
2279
|
+
if (includeMatches) transformers.push(transformMatches);
|
|
2280
|
+
if (includeScore) transformers.push(transformScore);
|
|
2281
|
+
return results.map(result => {
|
|
2282
|
+
const {
|
|
2283
|
+
idx
|
|
2284
|
+
} = result;
|
|
2285
|
+
const data = {
|
|
2286
|
+
item: docs[idx],
|
|
2287
|
+
refIndex: idx
|
|
2288
|
+
};
|
|
2289
|
+
if (transformers.length) {
|
|
2290
|
+
transformers.forEach(transformer => {
|
|
2291
|
+
transformer(result, data);
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
return data;
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
const WORD = /\b\w+\b/g;
|
|
2299
|
+
function createAnalyzer({
|
|
2300
|
+
isCaseSensitive = false,
|
|
2301
|
+
ignoreDiacritics = false
|
|
2302
|
+
} = {}) {
|
|
2303
|
+
return {
|
|
2304
|
+
tokenize(text) {
|
|
2305
|
+
if (!isCaseSensitive) {
|
|
2306
|
+
text = text.toLowerCase();
|
|
2307
|
+
}
|
|
2308
|
+
if (ignoreDiacritics) {
|
|
2309
|
+
text = stripDiacritics(text);
|
|
2310
|
+
}
|
|
2311
|
+
return text.match(WORD) || [];
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function buildInvertedIndex(records, keyCount, analyzer) {
|
|
2317
|
+
const terms = new Map();
|
|
2318
|
+
const df = new Map();
|
|
2319
|
+
let fieldCount = 0;
|
|
2320
|
+
function addField(text, docIdx, keyIdx, subIdx) {
|
|
2321
|
+
const tokens = analyzer.tokenize(text);
|
|
2322
|
+
if (!tokens.length) return;
|
|
2323
|
+
fieldCount++;
|
|
2324
|
+
|
|
2325
|
+
// Count term frequencies in this field
|
|
2326
|
+
const termFreqs = new Map();
|
|
2327
|
+
for (const token of tokens) {
|
|
2328
|
+
termFreqs.set(token, (termFreqs.get(token) || 0) + 1);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Track which terms we've already counted for df in this field
|
|
2332
|
+
for (const [term, tf] of termFreqs) {
|
|
2333
|
+
const posting = {
|
|
2334
|
+
docIdx,
|
|
2335
|
+
keyIdx,
|
|
2336
|
+
subIdx,
|
|
2337
|
+
tf
|
|
2338
|
+
};
|
|
2339
|
+
let postings = terms.get(term);
|
|
2340
|
+
if (!postings) {
|
|
2341
|
+
postings = [];
|
|
2342
|
+
terms.set(term, postings);
|
|
2343
|
+
}
|
|
2344
|
+
postings.push(posting);
|
|
2345
|
+
df.set(term, (df.get(term) || 0) + 1);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
for (const record of records) {
|
|
2349
|
+
const {
|
|
2350
|
+
i: docIdx,
|
|
2351
|
+
v,
|
|
2352
|
+
$: fields
|
|
2353
|
+
} = record;
|
|
2354
|
+
|
|
2355
|
+
// String list
|
|
2356
|
+
if (v !== undefined) {
|
|
2357
|
+
addField(v, docIdx, -1, -1);
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Object list
|
|
2362
|
+
if (fields) {
|
|
2363
|
+
for (let keyIdx = 0; keyIdx < keyCount; keyIdx++) {
|
|
2364
|
+
const value = fields[keyIdx];
|
|
2365
|
+
if (!value) continue;
|
|
2366
|
+
if (Array.isArray(value)) {
|
|
2367
|
+
for (const sub of value) {
|
|
2368
|
+
addField(sub.v, docIdx, keyIdx, sub.i ?? -1);
|
|
2369
|
+
}
|
|
2370
|
+
} else {
|
|
2371
|
+
addField(value.v, docIdx, keyIdx, -1);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return {
|
|
2377
|
+
terms,
|
|
2378
|
+
fieldCount,
|
|
2379
|
+
df
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
function addToInvertedIndex(index, record, keyCount, analyzer) {
|
|
2383
|
+
const {
|
|
2384
|
+
i: docIdx,
|
|
2385
|
+
v,
|
|
2386
|
+
$: fields
|
|
2387
|
+
} = record;
|
|
2388
|
+
function addField(text, keyIdx, subIdx) {
|
|
2389
|
+
const tokens = analyzer.tokenize(text);
|
|
2390
|
+
if (!tokens.length) return;
|
|
2391
|
+
index.fieldCount++;
|
|
2392
|
+
const termFreqs = new Map();
|
|
2393
|
+
for (const token of tokens) {
|
|
2394
|
+
termFreqs.set(token, (termFreqs.get(token) || 0) + 1);
|
|
2395
|
+
}
|
|
2396
|
+
for (const [term, tf] of termFreqs) {
|
|
2397
|
+
const posting = {
|
|
2398
|
+
docIdx,
|
|
2399
|
+
keyIdx,
|
|
2400
|
+
subIdx,
|
|
2401
|
+
tf
|
|
2402
|
+
};
|
|
2403
|
+
let postings = index.terms.get(term);
|
|
2404
|
+
if (!postings) {
|
|
2405
|
+
postings = [];
|
|
2406
|
+
index.terms.set(term, postings);
|
|
2407
|
+
}
|
|
2408
|
+
postings.push(posting);
|
|
2409
|
+
index.df.set(term, (index.df.get(term) || 0) + 1);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
if (v !== undefined) {
|
|
2413
|
+
addField(v, -1, -1);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
if (fields) {
|
|
2417
|
+
for (let keyIdx = 0; keyIdx < keyCount; keyIdx++) {
|
|
2418
|
+
const value = fields[keyIdx];
|
|
2419
|
+
if (!value) continue;
|
|
2420
|
+
if (Array.isArray(value)) {
|
|
2421
|
+
for (const sub of value) {
|
|
2422
|
+
addField(sub.v, keyIdx, sub.i ?? -1);
|
|
2423
|
+
}
|
|
2424
|
+
} else {
|
|
2425
|
+
addField(value.v, keyIdx, -1);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
function removeFromInvertedIndex(index, docIdx) {
|
|
2431
|
+
for (const [term, postings] of index.terms) {
|
|
2432
|
+
const filtered = postings.filter(p => p.docIdx !== docIdx);
|
|
2433
|
+
const removed = postings.length - filtered.length;
|
|
2434
|
+
if (removed > 0) {
|
|
2435
|
+
index.fieldCount -= removed;
|
|
2436
|
+
index.df.set(term, (index.df.get(term) || 0) - removed);
|
|
2437
|
+
if (filtered.length === 0) {
|
|
2438
|
+
index.terms.delete(term);
|
|
2439
|
+
index.df.delete(term);
|
|
2440
|
+
} else {
|
|
2441
|
+
index.terms.set(term, filtered);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
class Fuse {
|
|
2448
|
+
// Statics are assigned in entry.ts
|
|
2449
|
+
|
|
2450
|
+
constructor(docs, options, index) {
|
|
2451
|
+
this.options = {
|
|
2452
|
+
...Config,
|
|
2453
|
+
...options
|
|
2454
|
+
};
|
|
2455
|
+
if (this.options.useExtendedSearch && false) ;
|
|
2456
|
+
if (this.options.useTokenSearch && false) ;
|
|
2457
|
+
this._keyStore = new KeyStore(this.options.keys);
|
|
2458
|
+
this._docs = docs;
|
|
2459
|
+
this._myIndex = null;
|
|
2460
|
+
this._invertedIndex = null;
|
|
2461
|
+
this.setCollection(docs, index);
|
|
2462
|
+
this._lastQuery = null;
|
|
2463
|
+
this._lastSearcher = null;
|
|
2464
|
+
}
|
|
2465
|
+
_getSearcher(query) {
|
|
2466
|
+
if (this._lastQuery === query) {
|
|
2467
|
+
return this._lastSearcher;
|
|
2468
|
+
}
|
|
2469
|
+
const opts = this._invertedIndex ? {
|
|
2470
|
+
...this.options,
|
|
2471
|
+
_invertedIndex: this._invertedIndex
|
|
2472
|
+
} : this.options;
|
|
2473
|
+
const searcher = createSearcher(query, opts);
|
|
2474
|
+
this._lastQuery = query;
|
|
2475
|
+
this._lastSearcher = searcher;
|
|
2476
|
+
return searcher;
|
|
2477
|
+
}
|
|
2478
|
+
setCollection(docs, index) {
|
|
2479
|
+
this._docs = docs;
|
|
2480
|
+
if (index && !(index instanceof FuseIndex)) {
|
|
2481
|
+
throw new Error(INCORRECT_INDEX_TYPE);
|
|
2482
|
+
}
|
|
2483
|
+
this._myIndex = index || createIndex(this.options.keys, this._docs, {
|
|
2484
|
+
getFn: this.options.getFn,
|
|
2485
|
+
fieldNormWeight: this.options.fieldNormWeight
|
|
2486
|
+
});
|
|
2487
|
+
if (this.options.useTokenSearch) {
|
|
2488
|
+
const analyzer = createAnalyzer({
|
|
2489
|
+
isCaseSensitive: this.options.isCaseSensitive,
|
|
2490
|
+
ignoreDiacritics: this.options.ignoreDiacritics
|
|
2491
|
+
});
|
|
2492
|
+
this._invertedIndex = buildInvertedIndex(this._myIndex.records, this._myIndex.keys.length, analyzer);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
add(doc) {
|
|
2496
|
+
if (!isDefined(doc)) {
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
this._docs.push(doc);
|
|
2500
|
+
this._myIndex.add(doc);
|
|
2501
|
+
if (this._invertedIndex) {
|
|
2502
|
+
const record = this._myIndex.records[this._myIndex.records.length - 1];
|
|
2503
|
+
const analyzer = createAnalyzer({
|
|
2504
|
+
isCaseSensitive: this.options.isCaseSensitive,
|
|
2505
|
+
ignoreDiacritics: this.options.ignoreDiacritics
|
|
2506
|
+
});
|
|
2507
|
+
addToInvertedIndex(this._invertedIndex, record, this._myIndex.keys.length, analyzer);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
remove(predicate = () => false) {
|
|
2511
|
+
const results = [];
|
|
2512
|
+
const indicesToRemove = [];
|
|
2513
|
+
for (let i = 0, len = this._docs.length; i < len; i += 1) {
|
|
2514
|
+
if (predicate(this._docs[i], i)) {
|
|
2515
|
+
results.push(this._docs[i]);
|
|
2516
|
+
indicesToRemove.push(i);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
if (indicesToRemove.length) {
|
|
2520
|
+
if (this._invertedIndex) {
|
|
2521
|
+
for (const idx of indicesToRemove) {
|
|
2522
|
+
removeFromInvertedIndex(this._invertedIndex, idx);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// Remove from docs in reverse to preserve indices
|
|
2527
|
+
for (let i = indicesToRemove.length - 1; i >= 0; i -= 1) {
|
|
2528
|
+
this._docs.splice(indicesToRemove[i], 1);
|
|
2529
|
+
}
|
|
2530
|
+
this._myIndex.removeAll(indicesToRemove);
|
|
2531
|
+
}
|
|
2532
|
+
return results;
|
|
2533
|
+
}
|
|
2534
|
+
removeAt(idx) {
|
|
2535
|
+
if (this._invertedIndex) {
|
|
2536
|
+
removeFromInvertedIndex(this._invertedIndex, idx);
|
|
2537
|
+
}
|
|
2538
|
+
const doc = this._docs.splice(idx, 1)[0];
|
|
2539
|
+
this._myIndex.removeAt(idx);
|
|
2540
|
+
return doc;
|
|
2541
|
+
}
|
|
2542
|
+
getIndex() {
|
|
2543
|
+
return this._myIndex;
|
|
2544
|
+
}
|
|
2545
|
+
search(query, options) {
|
|
2546
|
+
const {
|
|
2547
|
+
limit = -1
|
|
2548
|
+
} = options || {};
|
|
2549
|
+
const {
|
|
2550
|
+
includeMatches,
|
|
2551
|
+
includeScore,
|
|
2552
|
+
shouldSort,
|
|
2553
|
+
sortFn,
|
|
2554
|
+
ignoreFieldNorm
|
|
2555
|
+
} = this.options;
|
|
2556
|
+
|
|
2557
|
+
// Empty string query returns all docs (useful for search UIs)
|
|
2558
|
+
if (isString(query) && !query.trim()) {
|
|
2559
|
+
let docs = this._docs.map((item, idx) => ({
|
|
2560
|
+
item,
|
|
2561
|
+
refIndex: idx
|
|
2562
|
+
}));
|
|
2563
|
+
if (isNumber(limit) && limit > -1) {
|
|
2564
|
+
docs = docs.slice(0, limit);
|
|
2565
|
+
}
|
|
2566
|
+
return docs;
|
|
2567
|
+
}
|
|
2568
|
+
const useHeap = isNumber(limit) && limit > 0 && isString(query);
|
|
2569
|
+
let results;
|
|
2570
|
+
if (useHeap) {
|
|
2571
|
+
const heap = new MaxHeap(limit);
|
|
2572
|
+
if (isString(this._docs[0])) {
|
|
2573
|
+
this._searchStringList(query, {
|
|
2574
|
+
heap,
|
|
2575
|
+
ignoreFieldNorm
|
|
2576
|
+
});
|
|
2577
|
+
} else {
|
|
2578
|
+
this._searchObjectList(query, {
|
|
2579
|
+
heap,
|
|
2580
|
+
ignoreFieldNorm
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
results = heap.extractSorted(sortFn);
|
|
2584
|
+
} else {
|
|
2585
|
+
results = isString(query) ? isString(this._docs[0]) ? this._searchStringList(query) : this._searchObjectList(query) : this._searchLogical(query);
|
|
2586
|
+
computeScore(results, {
|
|
2587
|
+
ignoreFieldNorm
|
|
2588
|
+
});
|
|
2589
|
+
if (shouldSort) {
|
|
2590
|
+
results.sort(sortFn);
|
|
2591
|
+
}
|
|
2592
|
+
if (isNumber(limit) && limit > -1) {
|
|
2593
|
+
results = results.slice(0, limit);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
return format(results, this._docs, {
|
|
2597
|
+
includeMatches,
|
|
2598
|
+
includeScore
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
_searchStringList(query, {
|
|
2602
|
+
heap,
|
|
2603
|
+
ignoreFieldNorm
|
|
2604
|
+
} = {}) {
|
|
2605
|
+
const searcher = this._getSearcher(query);
|
|
2606
|
+
const {
|
|
2607
|
+
records
|
|
2608
|
+
} = this._myIndex;
|
|
2609
|
+
const results = heap ? null : [];
|
|
2610
|
+
|
|
2611
|
+
// Iterate over every string in the index
|
|
2612
|
+
records.forEach(({
|
|
2613
|
+
v: text,
|
|
2614
|
+
i: idx,
|
|
2615
|
+
n: norm
|
|
2616
|
+
}) => {
|
|
2617
|
+
if (!isDefined(text)) {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
const {
|
|
2621
|
+
isMatch,
|
|
2622
|
+
score,
|
|
2623
|
+
indices
|
|
2624
|
+
} = searcher.searchIn(text);
|
|
2625
|
+
if (isMatch) {
|
|
2626
|
+
const result = {
|
|
2627
|
+
item: text,
|
|
2628
|
+
idx,
|
|
2629
|
+
matches: [{
|
|
2630
|
+
score,
|
|
2631
|
+
value: text,
|
|
2632
|
+
norm: norm,
|
|
2633
|
+
indices
|
|
2634
|
+
}]
|
|
2635
|
+
};
|
|
2636
|
+
if (heap) {
|
|
2637
|
+
result.score = computeScoreSingle(result.matches, {
|
|
2638
|
+
ignoreFieldNorm
|
|
2639
|
+
});
|
|
2640
|
+
if (heap.shouldInsert(result.score)) {
|
|
2641
|
+
heap.insert(result);
|
|
2642
|
+
}
|
|
2643
|
+
} else {
|
|
2644
|
+
results.push(result);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
});
|
|
2648
|
+
return results;
|
|
2649
|
+
}
|
|
2650
|
+
_searchLogical(query) {
|
|
2651
|
+
const expression = parse(query, this.options);
|
|
2652
|
+
const evaluate = (node, item, idx) => {
|
|
2653
|
+
if (!('children' in node)) {
|
|
2654
|
+
const {
|
|
2655
|
+
keyId,
|
|
2656
|
+
searcher
|
|
2657
|
+
} = node;
|
|
2658
|
+
let matches;
|
|
2659
|
+
if (keyId === null) {
|
|
2660
|
+
// Keyless entry: search across all keys
|
|
2661
|
+
matches = [];
|
|
2662
|
+
this._myIndex.keys.forEach((key, keyIndex) => {
|
|
2663
|
+
matches.push(...this._findMatches({
|
|
2664
|
+
key,
|
|
2665
|
+
value: item[keyIndex],
|
|
2666
|
+
searcher: searcher
|
|
2667
|
+
}));
|
|
2668
|
+
});
|
|
2669
|
+
} else {
|
|
2670
|
+
matches = this._findMatches({
|
|
2671
|
+
key: this._keyStore.get(keyId),
|
|
2672
|
+
value: this._myIndex.getValueForItemAtKeyId(item, keyId),
|
|
2673
|
+
searcher: searcher
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
if (matches && matches.length) {
|
|
2677
|
+
return [{
|
|
2678
|
+
idx,
|
|
2679
|
+
item,
|
|
2680
|
+
matches
|
|
2681
|
+
}];
|
|
2682
|
+
}
|
|
2683
|
+
return [];
|
|
2684
|
+
}
|
|
2685
|
+
const {
|
|
2686
|
+
children,
|
|
2687
|
+
operator
|
|
2688
|
+
} = node;
|
|
2689
|
+
const res = [];
|
|
2690
|
+
for (let i = 0, len = children.length; i < len; i += 1) {
|
|
2691
|
+
const child = children[i];
|
|
2692
|
+
const result = evaluate(child, item, idx);
|
|
2693
|
+
if (result.length) {
|
|
2694
|
+
res.push(...result);
|
|
2695
|
+
} else if (operator === LogicalOperator.AND) {
|
|
2696
|
+
return [];
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
return res;
|
|
2700
|
+
};
|
|
2701
|
+
const records = this._myIndex.records;
|
|
2702
|
+
const resultMap = new Map();
|
|
2703
|
+
const results = [];
|
|
2704
|
+
records.forEach(({
|
|
2705
|
+
$: item,
|
|
2706
|
+
i: idx
|
|
2707
|
+
}) => {
|
|
2708
|
+
if (isDefined(item)) {
|
|
2709
|
+
const expResults = evaluate(expression, item, idx);
|
|
2710
|
+
if (expResults.length) {
|
|
2711
|
+
// Dedupe when adding
|
|
2712
|
+
if (!resultMap.has(idx)) {
|
|
2713
|
+
resultMap.set(idx, {
|
|
2714
|
+
idx,
|
|
2715
|
+
item,
|
|
2716
|
+
matches: []
|
|
2717
|
+
});
|
|
2718
|
+
results.push(resultMap.get(idx));
|
|
2719
|
+
}
|
|
2720
|
+
expResults.forEach(({
|
|
2721
|
+
matches
|
|
2722
|
+
}) => {
|
|
2723
|
+
resultMap.get(idx).matches.push(...matches);
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
return results;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// When a search involves inverse patterns (e.g. !Syrup), the aggregation
|
|
2732
|
+
// across keys switches from "ANY key matches" to "ALL keys must match."
|
|
2733
|
+
// This is signaled by hasInverse on the SearchResult from ExtendedSearch.
|
|
2734
|
+
//
|
|
2735
|
+
// For mixed patterns like "^hello !Syrup", a key failure is ambiguous —
|
|
2736
|
+
// it could be the positive or inverse term that failed. In that case we
|
|
2737
|
+
// conservatively exclude the item, which is strictly better than the old
|
|
2738
|
+
// behavior of including it. See: https://github.com/krisk/Fuse/issues/712
|
|
2739
|
+
_searchObjectList(query, {
|
|
2740
|
+
heap,
|
|
2741
|
+
ignoreFieldNorm
|
|
2742
|
+
} = {}) {
|
|
2743
|
+
const searcher = this._getSearcher(query);
|
|
2744
|
+
const {
|
|
2745
|
+
keys,
|
|
2746
|
+
records
|
|
2747
|
+
} = this._myIndex;
|
|
2748
|
+
const results = heap ? null : [];
|
|
2749
|
+
|
|
2750
|
+
// List is Array<Object>
|
|
2751
|
+
records.forEach(({
|
|
2752
|
+
$: item,
|
|
2753
|
+
i: idx
|
|
2754
|
+
}) => {
|
|
2755
|
+
if (!isDefined(item)) {
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const matches = [];
|
|
2759
|
+
let anyKeyFailed = false;
|
|
2760
|
+
let hasInverse = false;
|
|
2761
|
+
|
|
2762
|
+
// Iterate over every key (i.e, path), and fetch the value at that key
|
|
2763
|
+
keys.forEach((key, keyIndex) => {
|
|
2764
|
+
const keyMatches = this._findMatches({
|
|
2765
|
+
key,
|
|
2766
|
+
value: item[keyIndex],
|
|
2767
|
+
searcher
|
|
2768
|
+
});
|
|
2769
|
+
if (keyMatches.length) {
|
|
2770
|
+
matches.push(...keyMatches);
|
|
2771
|
+
if (keyMatches[0].hasInverse) {
|
|
2772
|
+
hasInverse = true;
|
|
2773
|
+
}
|
|
2774
|
+
} else {
|
|
2775
|
+
anyKeyFailed = true;
|
|
2776
|
+
}
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
// If the search involves inverse patterns, ALL keys must match
|
|
2780
|
+
if (hasInverse && anyKeyFailed) {
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
if (matches.length) {
|
|
2784
|
+
const result = {
|
|
2785
|
+
idx,
|
|
2786
|
+
item,
|
|
2787
|
+
matches
|
|
2788
|
+
};
|
|
2789
|
+
if (heap) {
|
|
2790
|
+
result.score = computeScoreSingle(result.matches, {
|
|
2791
|
+
ignoreFieldNorm
|
|
2792
|
+
});
|
|
2793
|
+
if (heap.shouldInsert(result.score)) {
|
|
2794
|
+
heap.insert(result);
|
|
2795
|
+
}
|
|
2796
|
+
} else {
|
|
2797
|
+
results.push(result);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
return results;
|
|
2802
|
+
}
|
|
2803
|
+
_findMatches({
|
|
2804
|
+
key,
|
|
2805
|
+
value,
|
|
2806
|
+
searcher
|
|
2807
|
+
}) {
|
|
2808
|
+
if (!isDefined(value)) {
|
|
2809
|
+
return [];
|
|
2810
|
+
}
|
|
2811
|
+
const matches = [];
|
|
2812
|
+
if (isArray(value)) {
|
|
2813
|
+
value.forEach(({
|
|
2814
|
+
v: text,
|
|
2815
|
+
i: idx,
|
|
2816
|
+
n: norm
|
|
2817
|
+
}) => {
|
|
2818
|
+
if (!isDefined(text)) {
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
const {
|
|
2822
|
+
isMatch,
|
|
2823
|
+
score,
|
|
2824
|
+
indices,
|
|
2825
|
+
hasInverse
|
|
2826
|
+
} = searcher.searchIn(text);
|
|
2827
|
+
if (isMatch) {
|
|
2828
|
+
matches.push({
|
|
2829
|
+
score,
|
|
2830
|
+
key,
|
|
2831
|
+
value: text,
|
|
2832
|
+
idx,
|
|
2833
|
+
norm,
|
|
2834
|
+
indices,
|
|
2835
|
+
hasInverse
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2839
|
+
} else {
|
|
2840
|
+
const {
|
|
2841
|
+
v: text,
|
|
2842
|
+
n: norm
|
|
2843
|
+
} = value;
|
|
2844
|
+
const {
|
|
2845
|
+
isMatch,
|
|
2846
|
+
score,
|
|
2847
|
+
indices,
|
|
2848
|
+
hasInverse
|
|
2849
|
+
} = searcher.searchIn(text);
|
|
2850
|
+
if (isMatch) {
|
|
2851
|
+
matches.push({
|
|
2852
|
+
score,
|
|
2853
|
+
key,
|
|
2854
|
+
value: text,
|
|
2855
|
+
norm,
|
|
2856
|
+
indices,
|
|
2857
|
+
hasInverse
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
return matches;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
class TokenSearch {
|
|
2866
|
+
static condition(_, options) {
|
|
2867
|
+
return options.useTokenSearch;
|
|
2868
|
+
}
|
|
2869
|
+
constructor(pattern, options) {
|
|
2870
|
+
this.options = options;
|
|
2871
|
+
this.analyzer = createAnalyzer({
|
|
2872
|
+
isCaseSensitive: options.isCaseSensitive,
|
|
2873
|
+
ignoreDiacritics: options.ignoreDiacritics
|
|
2874
|
+
});
|
|
2875
|
+
const queryTerms = this.analyzer.tokenize(pattern);
|
|
2876
|
+
const invertedIndex = options._invertedIndex;
|
|
2877
|
+
const {
|
|
2878
|
+
df,
|
|
2879
|
+
fieldCount
|
|
2880
|
+
} = invertedIndex;
|
|
2881
|
+
this.termSearchers = [];
|
|
2882
|
+
this.idfWeights = [];
|
|
2883
|
+
for (const term of queryTerms) {
|
|
2884
|
+
this.termSearchers.push(new BitapSearch(term, {
|
|
2885
|
+
location: options.location,
|
|
2886
|
+
threshold: options.threshold,
|
|
2887
|
+
distance: options.distance,
|
|
2888
|
+
includeMatches: options.includeMatches,
|
|
2889
|
+
findAllMatches: options.findAllMatches,
|
|
2890
|
+
minMatchCharLength: options.minMatchCharLength,
|
|
2891
|
+
isCaseSensitive: options.isCaseSensitive,
|
|
2892
|
+
ignoreDiacritics: options.ignoreDiacritics,
|
|
2893
|
+
ignoreLocation: true
|
|
2894
|
+
}));
|
|
2895
|
+
const docFreq = df.get(term) || 0;
|
|
2896
|
+
const idf = Math.log(1 + (fieldCount - docFreq + 0.5) / (docFreq + 0.5));
|
|
2897
|
+
this.idfWeights.push(idf);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
searchIn(text) {
|
|
2901
|
+
if (!this.termSearchers.length) {
|
|
2902
|
+
return {
|
|
2903
|
+
isMatch: false,
|
|
2904
|
+
score: 1
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
const allIndices = [];
|
|
2908
|
+
let weightedScore = 0;
|
|
2909
|
+
let maxPossibleScore = 0;
|
|
2910
|
+
let matchedCount = 0;
|
|
2911
|
+
for (let i = 0; i < this.termSearchers.length; i++) {
|
|
2912
|
+
const result = this.termSearchers[i].searchIn(text);
|
|
2913
|
+
const idf = this.idfWeights[i];
|
|
2914
|
+
maxPossibleScore += idf;
|
|
2915
|
+
if (result.isMatch) {
|
|
2916
|
+
matchedCount++;
|
|
2917
|
+
weightedScore += idf * (1 - result.score);
|
|
2918
|
+
if (result.indices) {
|
|
2919
|
+
allIndices.push(...result.indices);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
if (matchedCount === 0) {
|
|
2924
|
+
return {
|
|
2925
|
+
isMatch: false,
|
|
2926
|
+
score: 1
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
const normalized = maxPossibleScore > 0 ? 1 - weightedScore / maxPossibleScore : 0;
|
|
2930
|
+
const searchResult = {
|
|
2931
|
+
isMatch: true,
|
|
2932
|
+
score: Math.max(0.001, normalized)
|
|
2933
|
+
};
|
|
2934
|
+
if (this.options.includeMatches && allIndices.length) {
|
|
2935
|
+
searchResult.indices = mergeIndices(allIndices);
|
|
2936
|
+
}
|
|
2937
|
+
return searchResult;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
Fuse.version = '7.3.0';
|
|
2942
|
+
Fuse.createIndex = createIndex;
|
|
2943
|
+
Fuse.parseIndex = parseIndex;
|
|
2944
|
+
Fuse.config = Config;
|
|
2945
|
+
Fuse.match = function (pattern, text, options) {
|
|
2946
|
+
const searcher = createSearcher(pattern, {
|
|
2947
|
+
...Config,
|
|
2948
|
+
...options
|
|
2949
|
+
});
|
|
2950
|
+
return searcher.searchIn(text);
|
|
2951
|
+
};
|
|
2952
|
+
{
|
|
2953
|
+
Fuse.parseQuery = parse;
|
|
2954
|
+
}
|
|
2955
|
+
{
|
|
2956
|
+
register(ExtendedSearch);
|
|
2957
|
+
}
|
|
2958
|
+
{
|
|
2959
|
+
register(TokenSearch);
|
|
2960
|
+
}
|
|
2961
|
+
Fuse.use = function (...plugins) {
|
|
2962
|
+
plugins.forEach(plugin => register(plugin));
|
|
2963
|
+
};
|
|
2964
|
+
|
|
2965
|
+
const FUSE_OPTIONS = {
|
|
2966
|
+
keys: ['label'],
|
|
2967
|
+
threshold: 0.4,
|
|
2968
|
+
ignoreLocation: true,
|
|
2969
|
+
shouldSort: false
|
|
2970
|
+
};
|
|
2971
|
+
function getMatchingItemIds(items, query) {
|
|
2972
|
+
const normalizedQuery = query?.trim();
|
|
2973
|
+
if (!normalizedQuery) {
|
|
2974
|
+
return null;
|
|
2975
|
+
}
|
|
2976
|
+
const searchableItems = items.filter(item => item.id && item.label);
|
|
2977
|
+
if (searchableItems.length === 0) {
|
|
2978
|
+
return new Set();
|
|
2979
|
+
}
|
|
2980
|
+
const fuse = new Fuse(searchableItems, FUSE_OPTIONS);
|
|
2981
|
+
return new Set(fuse.search(normalizedQuery).map(result => result.item.id));
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function DefaultCheckIcon() {
|
|
2985
|
+
return /*#__PURE__*/jsxRuntime.jsx("svg", {
|
|
2986
|
+
width: "16",
|
|
2987
|
+
height: "16",
|
|
2988
|
+
viewBox: "0 0 16 16",
|
|
2989
|
+
fill: "none",
|
|
2990
|
+
"aria-hidden": "true",
|
|
2991
|
+
children: /*#__PURE__*/jsxRuntime.jsx("path", {
|
|
2992
|
+
fillRule: "evenodd",
|
|
2993
|
+
clipRule: "evenodd",
|
|
2994
|
+
d: "M12.4714 4.86195C12.7317 5.1223 12.7317 5.54441 12.4714 5.80476L7.13805 11.1381C6.8777 11.3984 6.45559 11.3984 6.19524 11.1381L3.52858 8.47142C3.26823 8.21108 3.26823 7.78897 3.52858 7.52862C3.78892 7.26827 4.21103 7.26827 4.47138 7.52862L6.66665 9.72388L11.5286 4.86195C11.7889 4.6016 12.211 4.6016 12.4714 4.86195Z",
|
|
2995
|
+
fill: "currentColor"
|
|
2996
|
+
})
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* DropdownItem
|
|
3002
|
+
*
|
|
3003
|
+
* A single selectable or checkable item inside a DropdownGroup.
|
|
3004
|
+
*
|
|
3005
|
+
* Props:
|
|
3006
|
+
* id string (required)
|
|
3007
|
+
* type "click" (default) | "checkbox"
|
|
3008
|
+
* defaultChecked bool (used when uncontrolled, type="checkbox")
|
|
3009
|
+
* checkIcon ReactNode | FC — replaces default ✓ icon
|
|
3010
|
+
* component custom component — receives all binding props
|
|
3011
|
+
* children label text
|
|
3012
|
+
*/
|
|
3013
|
+
function DropdownItem({
|
|
3014
|
+
id,
|
|
3015
|
+
type = 'click',
|
|
3016
|
+
defaultChecked = false,
|
|
3017
|
+
icon,
|
|
3018
|
+
checkIcon,
|
|
3019
|
+
actions,
|
|
3020
|
+
component: Comp,
|
|
3021
|
+
children,
|
|
3022
|
+
...rest
|
|
3023
|
+
}) {
|
|
3024
|
+
const {
|
|
3025
|
+
selectedItem,
|
|
3026
|
+
checkedItems,
|
|
3027
|
+
searchQuery,
|
|
3028
|
+
fireEvent
|
|
3029
|
+
} = useDropdownContext();
|
|
3030
|
+
const groupCtx = react.useContext(GroupContext);
|
|
3031
|
+
const groupLabel = groupCtx?.groupLabel ?? '';
|
|
3032
|
+
const groupId = groupCtx?.groupId ?? '';
|
|
3033
|
+
const visibleItemIds = groupCtx?.visibleItemIds;
|
|
3034
|
+
const label = typeof children === 'string' ? children : '';
|
|
3035
|
+
|
|
3036
|
+
// Search filtering
|
|
3037
|
+
if (searchQuery && visibleItemIds && !visibleItemIds.has(id)) {
|
|
3038
|
+
return null;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// Derived state
|
|
3042
|
+
const isSelected = type === 'click' && selectedItem?.id === id;
|
|
3043
|
+
const isChecked = type === 'checkbox' ? checkedItems.get(id) ?? defaultChecked : false;
|
|
3044
|
+
const actionContext = {
|
|
3045
|
+
id,
|
|
3046
|
+
label,
|
|
3047
|
+
type,
|
|
3048
|
+
groupId,
|
|
3049
|
+
groupLabel,
|
|
3050
|
+
isSelected,
|
|
3051
|
+
isChecked
|
|
3052
|
+
};
|
|
3053
|
+
const actionsNode = typeof actions === 'function' ? actions(actionContext) : actions;
|
|
3054
|
+
function handleClick() {
|
|
3055
|
+
if (type === 'checkbox') {
|
|
3056
|
+
fireEvent('check', {
|
|
3057
|
+
id,
|
|
3058
|
+
label,
|
|
3059
|
+
groupId,
|
|
3060
|
+
groupLabel
|
|
3061
|
+
});
|
|
3062
|
+
} else {
|
|
3063
|
+
fireEvent('select', {
|
|
3064
|
+
id,
|
|
3065
|
+
label,
|
|
3066
|
+
groupLabel
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
function handleKeyDown(e) {
|
|
3071
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3072
|
+
e.preventDefault();
|
|
3073
|
+
handleClick();
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
const {
|
|
3077
|
+
onClick: userOnClick,
|
|
3078
|
+
onKeyDown: userOnKeyDown,
|
|
3079
|
+
...domRest
|
|
3080
|
+
} = rest;
|
|
3081
|
+
|
|
3082
|
+
// Binding props for custom component
|
|
3083
|
+
const bindingProps = {
|
|
3084
|
+
isSelected,
|
|
3085
|
+
isActive: false,
|
|
3086
|
+
// hover managed by CSS :hover
|
|
3087
|
+
isChecked,
|
|
3088
|
+
onClick: () => {
|
|
3089
|
+
handleClick();
|
|
3090
|
+
userOnClick?.();
|
|
3091
|
+
},
|
|
3092
|
+
onKeyDown: e => {
|
|
3093
|
+
handleKeyDown(e);
|
|
3094
|
+
userOnKeyDown?.(e);
|
|
3095
|
+
},
|
|
3096
|
+
id,
|
|
3097
|
+
children,
|
|
3098
|
+
actions: actionsNode
|
|
3099
|
+
};
|
|
3100
|
+
if (Comp) {
|
|
3101
|
+
return /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
3102
|
+
...bindingProps,
|
|
3103
|
+
"data-ho-selected": isSelected,
|
|
3104
|
+
"data-ho-checked": isChecked,
|
|
3105
|
+
...domRest
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
const checkIconNode = checkIcon ? renderIcon(checkIcon) : /*#__PURE__*/jsxRuntime.jsx(DefaultCheckIcon, {});
|
|
3109
|
+
const classNames = ['hangoverDropdown-item', isSelected ? 'isSelected' : '', isChecked ? 'isChecked' : '', type === 'checkbox' ? 'isCheckboxType' : ''].filter(Boolean).join(' ');
|
|
3110
|
+
return /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
3111
|
+
role: type === 'checkbox' ? 'checkbox' : 'option',
|
|
3112
|
+
"aria-selected": type === 'click' ? isSelected : undefined,
|
|
3113
|
+
"aria-checked": type === 'checkbox' ? isChecked : undefined,
|
|
3114
|
+
tabIndex: 0,
|
|
3115
|
+
className: classNames,
|
|
3116
|
+
title: label || undefined,
|
|
3117
|
+
onClick: () => {
|
|
3118
|
+
handleClick();
|
|
3119
|
+
userOnClick?.();
|
|
3120
|
+
},
|
|
3121
|
+
onKeyDown: e => {
|
|
3122
|
+
handleKeyDown(e);
|
|
3123
|
+
userOnKeyDown?.(e);
|
|
3124
|
+
},
|
|
3125
|
+
"data-ho-selected": isSelected,
|
|
3126
|
+
"data-ho-checked": isChecked,
|
|
3127
|
+
...domRest,
|
|
3128
|
+
children: [icon && /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3129
|
+
className: "hangoverDropdown-item-icon",
|
|
3130
|
+
children: renderIcon(icon)
|
|
3131
|
+
}), /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3132
|
+
className: "hangoverDropdown-item-label",
|
|
3133
|
+
children: children
|
|
3134
|
+
}), actionsNode && /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3135
|
+
className: "hangoverDropdown-item-actions",
|
|
3136
|
+
onClick: e => e.stopPropagation(),
|
|
3137
|
+
onKeyDown: e => e.stopPropagation(),
|
|
3138
|
+
children: actionsNode
|
|
3139
|
+
}), type === 'checkbox' && /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3140
|
+
className: `hangoverDropdown-item-check-icon${isChecked ? ' isVisible' : ''}`,
|
|
3141
|
+
children: isChecked && checkIconNode
|
|
3142
|
+
})]
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const GROUP_PALETTE = ['#16A34A',
|
|
3147
|
+
// green
|
|
3148
|
+
'#7C3AED',
|
|
3149
|
+
// purple
|
|
3150
|
+
'#0EA5E9',
|
|
3151
|
+
// sky
|
|
3152
|
+
'#F59E0B',
|
|
3153
|
+
// amber
|
|
3154
|
+
'#EC4899',
|
|
3155
|
+
// pink
|
|
3156
|
+
'#EF4444',
|
|
3157
|
+
// red
|
|
3158
|
+
'#84CC16',
|
|
3159
|
+
// lime
|
|
3160
|
+
'#06B6D4' // cyan
|
|
3161
|
+
];
|
|
3162
|
+
|
|
3163
|
+
// Single chevron — CSS handles rotation
|
|
3164
|
+
function Chevron() {
|
|
3165
|
+
return /*#__PURE__*/jsxRuntime.jsx("svg", {
|
|
3166
|
+
width: "16",
|
|
3167
|
+
height: "16",
|
|
3168
|
+
viewBox: "0 0 16 16",
|
|
3169
|
+
fill: "none",
|
|
3170
|
+
"aria-hidden": "true",
|
|
3171
|
+
children: /*#__PURE__*/jsxRuntime.jsx("path", {
|
|
3172
|
+
fillRule: "evenodd",
|
|
3173
|
+
clipRule: "evenodd",
|
|
3174
|
+
d: "M8.47143 6.19526C8.21108 5.93491 7.78897 5.93491 7.52862 6.19526L4.86195 8.86193C4.67129 9.05259 4.61425 9.33934 4.71744 9.58846C4.82063 9.83757 5.06372 10 5.33336 10H10.6667C10.9363 10 11.1794 9.83757 11.2826 9.58846C11.3858 9.33934 11.3288 9.05259 11.1381 8.86193L8.47143 6.19526Z",
|
|
3175
|
+
fill: "currentColor"
|
|
3176
|
+
})
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// Module-level counter for color cycling.
|
|
3181
|
+
// Incremented on each group mount — sufficient for stable color assignment
|
|
3182
|
+
// within a single panel open session.
|
|
3183
|
+
let _groupColorIndex = 0;
|
|
3184
|
+
|
|
3185
|
+
// Reset counter when all groups unmount (panel closes)
|
|
3186
|
+
let _mountedGroupCount = 0;
|
|
3187
|
+
function onGroupMount() {
|
|
3188
|
+
if (_mountedGroupCount === 0) {
|
|
3189
|
+
_groupColorIndex = 0;
|
|
3190
|
+
}
|
|
3191
|
+
_mountedGroupCount++;
|
|
3192
|
+
}
|
|
3193
|
+
function onGroupUnmount() {
|
|
3194
|
+
_mountedGroupCount--;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* DropdownGroup
|
|
3199
|
+
*
|
|
3200
|
+
* A collapsible group of DropdownItems with a colored left border.
|
|
3201
|
+
*
|
|
3202
|
+
* Props:
|
|
3203
|
+
* label string (required)
|
|
3204
|
+
* color string — CSS color for left border accent
|
|
3205
|
+
* showSelectAll bool (default false)
|
|
3206
|
+
* selectAllPosition "top" | "bottom" (default "bottom")
|
|
3207
|
+
* component custom wrapper component
|
|
3208
|
+
* children DropdownItem elements
|
|
3209
|
+
*/
|
|
3210
|
+
function DropdownGroup({
|
|
3211
|
+
id,
|
|
3212
|
+
label,
|
|
3213
|
+
icon,
|
|
3214
|
+
color,
|
|
3215
|
+
defaultExpanded = undefined,
|
|
3216
|
+
showSelectAll = false,
|
|
3217
|
+
selectAllPosition = 'bottom',
|
|
3218
|
+
emptyText = 'Nothing to show here',
|
|
3219
|
+
noResultsText = 'No results',
|
|
3220
|
+
component: Comp,
|
|
3221
|
+
children,
|
|
3222
|
+
...rest
|
|
3223
|
+
}) {
|
|
3224
|
+
const {
|
|
3225
|
+
fireEvent,
|
|
3226
|
+
checkedItems,
|
|
3227
|
+
firstGroupClaimedRef,
|
|
3228
|
+
defaultGroupExpanded,
|
|
3229
|
+
displayMode,
|
|
3230
|
+
activeNavId,
|
|
3231
|
+
registerGroupItems,
|
|
3232
|
+
searchQuery
|
|
3233
|
+
} = useDropdownContext();
|
|
3234
|
+
|
|
3235
|
+
// Determine initial expanded state
|
|
3236
|
+
const expandedInitRef = react.useRef(null);
|
|
3237
|
+
if (expandedInitRef.current === null) {
|
|
3238
|
+
if (defaultExpanded !== undefined) {
|
|
3239
|
+
// explicit prop always wins
|
|
3240
|
+
expandedInitRef.current = defaultExpanded;
|
|
3241
|
+
} else if (defaultGroupExpanded === true) {
|
|
3242
|
+
expandedInitRef.current = true;
|
|
3243
|
+
} else if (defaultGroupExpanded === false) {
|
|
3244
|
+
expandedInitRef.current = false;
|
|
3245
|
+
} else {
|
|
3246
|
+
// 'first' — only the first group across all sections
|
|
3247
|
+
if (firstGroupClaimedRef && !firstGroupClaimedRef.current) {
|
|
3248
|
+
firstGroupClaimedRef.current = true;
|
|
3249
|
+
expandedInitRef.current = true;
|
|
3250
|
+
} else {
|
|
3251
|
+
expandedInitRef.current = false;
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
const [isExpanded, setIsExpanded] = react.useState(expandedInitRef.current);
|
|
3256
|
+
|
|
3257
|
+
// Register with parent section for expand/collapse all
|
|
3258
|
+
const sectionControl = react.useContext(SectionControlContext);
|
|
3259
|
+
const groupKeyRef = react.useRef(null);
|
|
3260
|
+
if (groupKeyRef.current === null) {
|
|
3261
|
+
groupKeyRef.current = Math.random().toString(36).slice(2);
|
|
3262
|
+
}
|
|
3263
|
+
react.useEffect(() => {
|
|
3264
|
+
if (!sectionControl) return;
|
|
3265
|
+
const key = groupKeyRef.current;
|
|
3266
|
+
sectionControl.registerGroup(key, setIsExpanded, expandedInitRef.current);
|
|
3267
|
+
return () => sectionControl.unregisterGroup(key);
|
|
3268
|
+
}, [sectionControl]);
|
|
3269
|
+
react.useEffect(() => {
|
|
3270
|
+
if (!sectionControl) return;
|
|
3271
|
+
sectionControl.notifyGroupState(groupKeyRef.current, isExpanded);
|
|
3272
|
+
}, [isExpanded, sectionControl]);
|
|
3273
|
+
|
|
3274
|
+
// Tab mode: reset to default when active tab changes
|
|
3275
|
+
react.useEffect(() => {
|
|
3276
|
+
if (displayMode === 'tab') {
|
|
3277
|
+
setIsExpanded(expandedInitRef.current);
|
|
3278
|
+
}
|
|
3279
|
+
}, [activeNavId, displayMode]);
|
|
3280
|
+
|
|
3281
|
+
// Assign auto color
|
|
3282
|
+
const colorIndexRef = react.useRef(null);
|
|
3283
|
+
if (colorIndexRef.current === null) {
|
|
3284
|
+
colorIndexRef.current = _groupColorIndex++;
|
|
3285
|
+
}
|
|
3286
|
+
react.useEffect(() => {
|
|
3287
|
+
onGroupMount();
|
|
3288
|
+
return () => onGroupUnmount();
|
|
3289
|
+
}, []);
|
|
3290
|
+
const resolvedColor = color || GROUP_PALETTE[colorIndexRef.current % GROUP_PALETTE.length];
|
|
3291
|
+
|
|
3292
|
+
// Collect child item ids for selectAll
|
|
3293
|
+
const itemIds = react.Children.toArray(children).filter(c => c?.props?.id).map(c => c.props.id);
|
|
3294
|
+
const groupId = id ?? label.replace(/\s+/g, '_').toLowerCase();
|
|
3295
|
+
|
|
3296
|
+
// Register group items in main context (for imperative selectAll)
|
|
3297
|
+
react.useEffect(() => {
|
|
3298
|
+
return registerGroupItems(groupId, itemIds, label);
|
|
3299
|
+
}, [groupId, itemIds, label, registerGroupItems]);
|
|
3300
|
+
function handleToggle() {
|
|
3301
|
+
const next = !isExpanded;
|
|
3302
|
+
setIsExpanded(next);
|
|
3303
|
+
fireEvent('groupToggle', {
|
|
3304
|
+
groupId,
|
|
3305
|
+
groupLabel: label,
|
|
3306
|
+
expanded: next
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
const selectAllChecked = checkedItems.get(groupId + '__all') ?? false;
|
|
3310
|
+
function handleSelectAll() {
|
|
3311
|
+
fireEvent('selectAll', {
|
|
3312
|
+
groupId,
|
|
3313
|
+
groupLabel: label,
|
|
3314
|
+
itemIds
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
const selectAllItem = /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
3318
|
+
role: "checkbox",
|
|
3319
|
+
"aria-checked": selectAllChecked,
|
|
3320
|
+
tabIndex: 0,
|
|
3321
|
+
title: "Select all",
|
|
3322
|
+
className: `hangoverDropdown-item isCheckboxType${selectAllChecked ? ' isChecked' : ''}`,
|
|
3323
|
+
onClick: handleSelectAll,
|
|
3324
|
+
onKeyDown: e => {
|
|
3325
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3326
|
+
e.preventDefault();
|
|
3327
|
+
handleSelectAll();
|
|
3328
|
+
}
|
|
3329
|
+
},
|
|
3330
|
+
children: [/*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3331
|
+
className: "hangoverDropdown-item-label",
|
|
3332
|
+
children: "Select all"
|
|
3333
|
+
}), /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3334
|
+
className: `hangoverDropdown-item-check-icon${selectAllChecked ? ' isVisible' : ''}`,
|
|
3335
|
+
children: selectAllChecked && /*#__PURE__*/jsxRuntime.jsx("svg", {
|
|
3336
|
+
width: "16",
|
|
3337
|
+
height: "16",
|
|
3338
|
+
viewBox: "0 0 16 16",
|
|
3339
|
+
fill: "none",
|
|
3340
|
+
"aria-hidden": "true",
|
|
3341
|
+
children: /*#__PURE__*/jsxRuntime.jsx("path", {
|
|
3342
|
+
fillRule: "evenodd",
|
|
3343
|
+
clipRule: "evenodd",
|
|
3344
|
+
d: "M12.4714 4.86195C12.7317 5.1223 12.7317 5.54441 12.4714 5.80476L7.13805 11.1381C6.8777 11.3984 6.45559 11.3984 6.19524 11.1381L3.52858 8.47142C3.26823 8.21108 3.26823 7.78897 3.52858 7.52862C3.78892 7.26827 4.21103 7.26827 4.47138 7.52862L6.66665 9.72388L11.5286 4.86195C11.7889 4.6016 12.211 4.6016 12.4714 4.86195Z",
|
|
3345
|
+
fill: "currentColor"
|
|
3346
|
+
})
|
|
3347
|
+
})
|
|
3348
|
+
})]
|
|
3349
|
+
}, "__selectAll__");
|
|
3350
|
+
const visibleItemIds = react.useMemo(() => {
|
|
3351
|
+
const searchableItems = react.Children.toArray(children).map(child => ({
|
|
3352
|
+
id: child?.props?.id,
|
|
3353
|
+
label: typeof child?.props?.children === 'string' ? child.props.children : ''
|
|
3354
|
+
}));
|
|
3355
|
+
return getMatchingItemIds(searchableItems, searchQuery);
|
|
3356
|
+
}, [children, searchQuery]);
|
|
3357
|
+
const groupContextValue = {
|
|
3358
|
+
groupLabel: label,
|
|
3359
|
+
groupId,
|
|
3360
|
+
resolvedColor,
|
|
3361
|
+
visibleItemIds
|
|
3362
|
+
};
|
|
3363
|
+
const header = /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
3364
|
+
className: `hangoverDropdown-group-header${isExpanded ? ' isExpanded' : ''}`,
|
|
3365
|
+
onClick: handleToggle,
|
|
3366
|
+
role: "button",
|
|
3367
|
+
tabIndex: 0,
|
|
3368
|
+
onKeyDown: e => {
|
|
3369
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
3370
|
+
handleToggle();
|
|
3371
|
+
}
|
|
3372
|
+
},
|
|
3373
|
+
"aria-expanded": isExpanded,
|
|
3374
|
+
"aria-label": `${label} — ${isExpanded ? 'collapse' : 'expand'}`,
|
|
3375
|
+
title: label,
|
|
3376
|
+
children: [/*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3377
|
+
className: "hangoverDropdown-group-header-accent"
|
|
3378
|
+
}), /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3379
|
+
className: "hangoverDropdown-group-header-body",
|
|
3380
|
+
children: /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
3381
|
+
className: "hangoverDropdown-group-header-inner",
|
|
3382
|
+
children: [icon && /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3383
|
+
className: "hangoverDropdown-group-header-icon",
|
|
3384
|
+
children: renderIcon(icon)
|
|
3385
|
+
}), /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3386
|
+
className: "hangoverDropdown-group-header-label",
|
|
3387
|
+
children: label
|
|
3388
|
+
}), /*#__PURE__*/jsxRuntime.jsx("span", {
|
|
3389
|
+
className: "hangoverDropdown-group-header-chevron",
|
|
3390
|
+
children: /*#__PURE__*/jsxRuntime.jsx(Chevron, {})
|
|
3391
|
+
})]
|
|
3392
|
+
})
|
|
3393
|
+
})]
|
|
3394
|
+
});
|
|
3395
|
+
const hasChildren = react.Children.count(children) > 0;
|
|
3396
|
+
const hasVisibleItems = !searchQuery || visibleItemIds.size > 0;
|
|
3397
|
+
const items = /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3398
|
+
className: `hangoverDropdown-group-items-wrap${isExpanded ? ' isExpanded' : ''}`,
|
|
3399
|
+
children: /*#__PURE__*/jsxRuntime.jsxs("div", {
|
|
3400
|
+
role: "group",
|
|
3401
|
+
"aria-label": label,
|
|
3402
|
+
className: "hangoverDropdown-group-items",
|
|
3403
|
+
children: [showSelectAll && selectAllPosition === 'top' && selectAllItem, hasChildren ? hasVisibleItems ? children : /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3404
|
+
className: "hangoverDropdown-group-empty",
|
|
3405
|
+
children: noResultsText
|
|
3406
|
+
}) : /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3407
|
+
className: "hangoverDropdown-group-empty",
|
|
3408
|
+
children: emptyText
|
|
3409
|
+
}), showSelectAll && selectAllPosition === 'bottom' && selectAllItem]
|
|
3410
|
+
})
|
|
3411
|
+
});
|
|
3412
|
+
const groupContent = /*#__PURE__*/jsxRuntime.jsxs(GroupContext.Provider, {
|
|
3413
|
+
value: groupContextValue,
|
|
3414
|
+
children: [header, items]
|
|
3415
|
+
});
|
|
3416
|
+
if (Comp) {
|
|
3417
|
+
return /*#__PURE__*/jsxRuntime.jsx(Comp, {
|
|
3418
|
+
isExpanded: isExpanded,
|
|
3419
|
+
onToggle: handleToggle,
|
|
3420
|
+
label: label,
|
|
3421
|
+
style: {
|
|
3422
|
+
'--hangover-group-color': resolvedColor
|
|
3423
|
+
},
|
|
3424
|
+
className: `hangoverDropdown-group${isExpanded ? ' isExpanded' : ' isCollapsed'}`,
|
|
3425
|
+
...rest,
|
|
3426
|
+
children: groupContent
|
|
3427
|
+
});
|
|
3428
|
+
}
|
|
3429
|
+
return /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
3430
|
+
className: `hangoverDropdown-group${isExpanded ? ' isExpanded' : ' isCollapsed'}`,
|
|
3431
|
+
style: {
|
|
3432
|
+
'--hangover-group-color': resolvedColor
|
|
3433
|
+
},
|
|
3434
|
+
"data-group-label": label,
|
|
3435
|
+
...rest,
|
|
3436
|
+
children: groupContent
|
|
3437
|
+
});
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
function toGeneratedId(value, fallback) {
|
|
3441
|
+
if (typeof value !== 'string') {
|
|
3442
|
+
return fallback;
|
|
3443
|
+
}
|
|
3444
|
+
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
3445
|
+
return normalized || fallback;
|
|
3446
|
+
}
|
|
3447
|
+
function normalizeConfig(config) {
|
|
3448
|
+
const {
|
|
3449
|
+
trigger,
|
|
3450
|
+
panel = {},
|
|
3451
|
+
navigation,
|
|
3452
|
+
items: rootItems,
|
|
3453
|
+
content,
|
|
3454
|
+
showAll,
|
|
3455
|
+
allLabel,
|
|
3456
|
+
allIcon,
|
|
3457
|
+
collapsed,
|
|
3458
|
+
autoCollapse,
|
|
3459
|
+
...rootConfig
|
|
3460
|
+
} = config;
|
|
3461
|
+
const navigationAliases = {
|
|
3462
|
+
...(showAll !== undefined ? {
|
|
3463
|
+
showAll
|
|
3464
|
+
} : {}),
|
|
3465
|
+
...(allLabel !== undefined ? {
|
|
3466
|
+
allLabel
|
|
3467
|
+
} : {}),
|
|
3468
|
+
...(allIcon !== undefined ? {
|
|
3469
|
+
allIcon
|
|
3470
|
+
} : {}),
|
|
3471
|
+
...(collapsed !== undefined ? {
|
|
3472
|
+
collapsed
|
|
3473
|
+
} : {}),
|
|
3474
|
+
...(autoCollapse !== undefined ? {
|
|
3475
|
+
autoCollapse
|
|
3476
|
+
} : {})
|
|
3477
|
+
};
|
|
3478
|
+
const resolvedNavigation = Array.isArray(rootItems) ? {
|
|
3479
|
+
...(navigation ?? {}),
|
|
3480
|
+
...navigationAliases,
|
|
3481
|
+
items: rootItems
|
|
3482
|
+
} : navigation ? {
|
|
3483
|
+
...navigation,
|
|
3484
|
+
...navigationAliases
|
|
3485
|
+
} : null;
|
|
3486
|
+
const rawNavigationItems = resolvedNavigation?.items ?? [];
|
|
3487
|
+
const explicitSections = content?.sections ?? [];
|
|
3488
|
+
const explicitSectionsByFor = new Map(explicitSections.filter(section => section?.for || section?.forId).map(section => [section.for ?? section.forId, section]));
|
|
3489
|
+
const consumedSectionIds = new Set();
|
|
3490
|
+
const navigationItems = rawNavigationItems.map((item, index) => {
|
|
3491
|
+
const {
|
|
3492
|
+
id: rawId,
|
|
3493
|
+
label,
|
|
3494
|
+
icon,
|
|
3495
|
+
title,
|
|
3496
|
+
groups,
|
|
3497
|
+
items: nestedItems,
|
|
3498
|
+
content: nestedContent,
|
|
3499
|
+
section,
|
|
3500
|
+
...navItemRest
|
|
3501
|
+
} = item;
|
|
3502
|
+
const id = rawId ?? toGeneratedId(label, `section-${index + 1}`);
|
|
3503
|
+
const nestedSection = section ?? nestedContent ?? (groups || nestedItems ? {
|
|
3504
|
+
title,
|
|
3505
|
+
items: nestedItems,
|
|
3506
|
+
groups
|
|
3507
|
+
} : null);
|
|
3508
|
+
if (explicitSectionsByFor.has(id)) {
|
|
3509
|
+
consumedSectionIds.add(id);
|
|
3510
|
+
}
|
|
3511
|
+
return {
|
|
3512
|
+
id,
|
|
3513
|
+
label,
|
|
3514
|
+
icon,
|
|
3515
|
+
navItemRest,
|
|
3516
|
+
derivedSection: explicitSectionsByFor.get(id) ?? (nestedSection ? {
|
|
3517
|
+
...(typeof nestedSection === 'object' ? nestedSection : {}),
|
|
3518
|
+
for: id,
|
|
3519
|
+
title: nestedSection?.title ?? title ?? label,
|
|
3520
|
+
items: nestedSection?.items ?? nestedSection?.groups ?? nestedItems ?? groups ?? []
|
|
3521
|
+
} : null)
|
|
3522
|
+
};
|
|
3523
|
+
});
|
|
3524
|
+
const remainingSections = explicitSections.filter(section => {
|
|
3525
|
+
const sectionId = section?.for ?? section?.forId;
|
|
3526
|
+
return !sectionId || !consumedSectionIds.has(sectionId);
|
|
3527
|
+
});
|
|
3528
|
+
const sections = [...navigationItems.map(item => item.derivedSection).filter(Boolean), ...remainingSections];
|
|
3529
|
+
return {
|
|
3530
|
+
rootConfig,
|
|
3531
|
+
trigger,
|
|
3532
|
+
panel,
|
|
3533
|
+
navigation: navigation ? {
|
|
3534
|
+
...resolvedNavigation,
|
|
3535
|
+
items: navigationItems
|
|
3536
|
+
} : resolvedNavigation ? {
|
|
3537
|
+
...resolvedNavigation,
|
|
3538
|
+
items: navigationItems
|
|
3539
|
+
} : null,
|
|
3540
|
+
content: {
|
|
3541
|
+
...(content ?? {}),
|
|
3542
|
+
sections
|
|
3543
|
+
}
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
function renderTriggerNode(trigger) {
|
|
3547
|
+
let triggerNode;
|
|
3548
|
+
if (trigger == null) {
|
|
3549
|
+
triggerNode = null;
|
|
3550
|
+
} else if (typeof trigger === 'string') {
|
|
3551
|
+
triggerNode = /*#__PURE__*/jsxRuntime.jsx(DropdownTrigger, {
|
|
3552
|
+
children: /*#__PURE__*/jsxRuntime.jsx("button", {
|
|
3553
|
+
type: "button",
|
|
3554
|
+
children: trigger
|
|
3555
|
+
})
|
|
3556
|
+
});
|
|
3557
|
+
} else if (typeof trigger === 'function') {
|
|
3558
|
+
const TriggerComp = trigger;
|
|
3559
|
+
triggerNode = /*#__PURE__*/jsxRuntime.jsx(DropdownTrigger, {
|
|
3560
|
+
children: /*#__PURE__*/jsxRuntime.jsx(TriggerComp, {})
|
|
3561
|
+
});
|
|
3562
|
+
} else if (typeof trigger === 'object' && trigger.label !== undefined && !trigger.$$typeof) {
|
|
3563
|
+
const {
|
|
3564
|
+
label,
|
|
3565
|
+
className,
|
|
3566
|
+
component: TriggerComp
|
|
3567
|
+
} = trigger;
|
|
3568
|
+
triggerNode = /*#__PURE__*/jsxRuntime.jsx(DropdownTrigger, {
|
|
3569
|
+
children: TriggerComp ? /*#__PURE__*/jsxRuntime.jsx(TriggerComp, {
|
|
3570
|
+
className: className,
|
|
3571
|
+
children: label
|
|
3572
|
+
}) : /*#__PURE__*/jsxRuntime.jsx("button", {
|
|
3573
|
+
type: "button",
|
|
3574
|
+
className: className,
|
|
3575
|
+
children: label
|
|
3576
|
+
})
|
|
3577
|
+
});
|
|
3578
|
+
} else {
|
|
3579
|
+
triggerNode = trigger;
|
|
3580
|
+
}
|
|
3581
|
+
return triggerNode;
|
|
3582
|
+
}
|
|
3583
|
+
function renderNavigationNode(navigation) {
|
|
3584
|
+
if (!navigation) {
|
|
3585
|
+
return null;
|
|
3586
|
+
}
|
|
3587
|
+
const {
|
|
3588
|
+
items = [],
|
|
3589
|
+
showAll,
|
|
3590
|
+
allLabel,
|
|
3591
|
+
allIcon,
|
|
3592
|
+
collapsed,
|
|
3593
|
+
autoCollapse,
|
|
3594
|
+
...navRest
|
|
3595
|
+
} = navigation;
|
|
3596
|
+
return /*#__PURE__*/jsxRuntime.jsx(DropdownNav, {
|
|
3597
|
+
showAll: showAll,
|
|
3598
|
+
allLabel: allLabel,
|
|
3599
|
+
allIcon: allIcon,
|
|
3600
|
+
collapsed: collapsed,
|
|
3601
|
+
autoCollapse: autoCollapse,
|
|
3602
|
+
...navRest,
|
|
3603
|
+
children: items.map(({
|
|
3604
|
+
id,
|
|
3605
|
+
label,
|
|
3606
|
+
icon,
|
|
3607
|
+
navItemRest = {}
|
|
3608
|
+
}) => /*#__PURE__*/jsxRuntime.jsx(DropdownNavItem, {
|
|
3609
|
+
id: id,
|
|
3610
|
+
icon: icon,
|
|
3611
|
+
...navItemRest,
|
|
3612
|
+
children: label
|
|
3613
|
+
}, id))
|
|
3614
|
+
});
|
|
3615
|
+
}
|
|
3616
|
+
function renderSectionNodes(sections) {
|
|
3617
|
+
return sections.map((section, si) => {
|
|
3618
|
+
const {
|
|
3619
|
+
for: forId,
|
|
3620
|
+
forId: forIdProp,
|
|
3621
|
+
title,
|
|
3622
|
+
groups,
|
|
3623
|
+
items: sectionItems,
|
|
3624
|
+
...sectionRest
|
|
3625
|
+
} = section;
|
|
3626
|
+
const sectionKey = forId ?? forIdProp ?? si;
|
|
3627
|
+
const groupConfigs = groups ?? sectionItems ?? [];
|
|
3628
|
+
const groupNodes = groupConfigs.map((group, gi) => {
|
|
3629
|
+
const {
|
|
3630
|
+
id: groupId,
|
|
3631
|
+
label: groupLabel,
|
|
3632
|
+
defaultExpanded,
|
|
3633
|
+
items: groupItems = [],
|
|
3634
|
+
...groupRest
|
|
3635
|
+
} = group;
|
|
3636
|
+
const itemNodes = groupItems.map(item => {
|
|
3637
|
+
const {
|
|
3638
|
+
id,
|
|
3639
|
+
label,
|
|
3640
|
+
type,
|
|
3641
|
+
icon,
|
|
3642
|
+
defaultChecked,
|
|
3643
|
+
checkIcon,
|
|
3644
|
+
component,
|
|
3645
|
+
...itemRest
|
|
3646
|
+
} = item;
|
|
3647
|
+
return /*#__PURE__*/jsxRuntime.jsx(DropdownItem, {
|
|
3648
|
+
id: id,
|
|
3649
|
+
type: type,
|
|
3650
|
+
icon: icon,
|
|
3651
|
+
defaultChecked: defaultChecked,
|
|
3652
|
+
checkIcon: checkIcon,
|
|
3653
|
+
component: component,
|
|
3654
|
+
...itemRest,
|
|
3655
|
+
children: label
|
|
3656
|
+
}, id);
|
|
3657
|
+
});
|
|
3658
|
+
return /*#__PURE__*/jsxRuntime.jsx(DropdownGroup, {
|
|
3659
|
+
id: groupId,
|
|
3660
|
+
label: groupLabel,
|
|
3661
|
+
defaultExpanded: defaultExpanded,
|
|
3662
|
+
...groupRest,
|
|
3663
|
+
children: itemNodes
|
|
3664
|
+
}, groupId ?? gi);
|
|
3665
|
+
});
|
|
3666
|
+
return /*#__PURE__*/jsxRuntime.jsx(DropdownSection, {
|
|
3667
|
+
for: forId ?? forIdProp,
|
|
3668
|
+
title: title,
|
|
3669
|
+
...sectionRest,
|
|
3670
|
+
children: groupNodes
|
|
3671
|
+
}, sectionKey);
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
/**
|
|
3676
|
+
* buildFromConfig — builds only the panel children (trigger + panel).
|
|
3677
|
+
* Used internally by Dropdown root when `fromConfig` prop is passed.
|
|
3678
|
+
* Does NOT wrap with a root <Dropdown> — avoids circular imports.
|
|
3679
|
+
*/
|
|
3680
|
+
function buildFromConfig(config) {
|
|
3681
|
+
const {
|
|
3682
|
+
trigger,
|
|
3683
|
+
panel,
|
|
3684
|
+
navigation,
|
|
3685
|
+
content
|
|
3686
|
+
} = normalizeConfig(config);
|
|
3687
|
+
const triggerNode = renderTriggerNode(trigger);
|
|
3688
|
+
const navNode = renderNavigationNode(navigation);
|
|
3689
|
+
const {
|
|
3690
|
+
searchPlaceholder,
|
|
3691
|
+
sections = [],
|
|
3692
|
+
...contentRest
|
|
3693
|
+
} = content || {};
|
|
3694
|
+
const {
|
|
3695
|
+
placement: panelPlacement,
|
|
3696
|
+
offset: panelOffset,
|
|
3697
|
+
...panelRest
|
|
3698
|
+
} = panel;
|
|
3699
|
+
return /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
|
|
3700
|
+
children: [triggerNode, /*#__PURE__*/jsxRuntime.jsxs(DropdownPanel, {
|
|
3701
|
+
placement: panelPlacement,
|
|
3702
|
+
offset: panelOffset,
|
|
3703
|
+
...panelRest,
|
|
3704
|
+
children: [navNode, /*#__PURE__*/jsxRuntime.jsx(DropdownContent, {
|
|
3705
|
+
searchPlaceholder: searchPlaceholder,
|
|
3706
|
+
...contentRest,
|
|
3707
|
+
children: renderSectionNodes(sections)
|
|
3708
|
+
})]
|
|
3709
|
+
})]
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
|
|
3714
|
+
displayMode: displayModeProp = 'scroll',
|
|
3715
|
+
defaultOpen: defaultOpenProp = false,
|
|
3716
|
+
defaultGroupExpanded: defaultGroupExpandedProp = true,
|
|
3717
|
+
hideOnSelection: hideOnSelectionProp = true,
|
|
3718
|
+
onEvent: onEventProp,
|
|
3719
|
+
fromConfig,
|
|
3720
|
+
darkMode = false,
|
|
3721
|
+
searchQuery: searchQueryProp,
|
|
3722
|
+
defaultSearchQuery = '',
|
|
3723
|
+
children,
|
|
3724
|
+
...rest
|
|
3725
|
+
}, ref) {
|
|
3726
|
+
const displayMode = fromConfig?.displayMode ?? displayModeProp;
|
|
3727
|
+
const defaultOpen = fromConfig?.defaultOpen ?? defaultOpenProp;
|
|
3728
|
+
const defaultGroupExpanded = fromConfig?.defaultGroupExpanded ?? defaultGroupExpandedProp;
|
|
3729
|
+
const hideOnSelection = fromConfig?.hideOnSelection ?? hideOnSelectionProp;
|
|
3730
|
+
const onEvent = fromConfig?.onEvent ?? onEventProp;
|
|
3731
|
+
const [isOpen, setIsOpen] = react.useState(defaultOpen);
|
|
3732
|
+
const [selectedItem, setSelectedItem] = react.useState(null);
|
|
3733
|
+
const [checkedItems, setCheckedItems] = react.useState(() => new Map());
|
|
3734
|
+
const [activeNavId, setActiveNavId] = react.useState('__all__');
|
|
3735
|
+
const [activeNavLabel, setActiveNavLabel] = react.useState('');
|
|
3736
|
+
const [searchQuery, setSearchQuery] = react.useState(defaultSearchQuery);
|
|
3737
|
+
const [hasNav, setHasNav] = react.useState(false);
|
|
3738
|
+
|
|
3739
|
+
// Controlled searchQuery — sync internal state whenever the prop changes
|
|
3740
|
+
const isControlledSearch = searchQueryProp !== undefined;
|
|
3741
|
+
react.useEffect(() => {
|
|
3742
|
+
if (isControlledSearch) setSearchQuery(searchQueryProp);
|
|
3743
|
+
}, [isControlledSearch, searchQueryProp]);
|
|
3744
|
+
const triggerRef = react.useRef(null);
|
|
3745
|
+
const contentRef = react.useRef(null); // scroll container inside DropdownContent
|
|
3746
|
+
const firstGroupClaimedRef = react.useRef(false);
|
|
3747
|
+
|
|
3748
|
+
// Sync refs — give stable callbacks access to current state without
|
|
3749
|
+
// closing over it. Updated synchronously during render (not in effects),
|
|
3750
|
+
// so they're always current by the time any event handler runs.
|
|
3751
|
+
const isOpenRef = react.useRef(isOpen);
|
|
3752
|
+
const selectedItemRef = react.useRef(selectedItem);
|
|
3753
|
+
const checkedItemsRef = react.useRef(checkedItems);
|
|
3754
|
+
const activeNavIdRef = react.useRef(activeNavId);
|
|
3755
|
+
const searchQueryRef = react.useRef(searchQuery);
|
|
3756
|
+
const hideOnSelectionRef = react.useRef(hideOnSelection);
|
|
3757
|
+
const onEventRef = react.useRef(onEvent);
|
|
3758
|
+
isOpenRef.current = isOpen;
|
|
3759
|
+
selectedItemRef.current = selectedItem;
|
|
3760
|
+
checkedItemsRef.current = checkedItems;
|
|
3761
|
+
activeNavIdRef.current = activeNavId;
|
|
3762
|
+
searchQueryRef.current = searchQuery;
|
|
3763
|
+
hideOnSelectionRef.current = hideOnSelection;
|
|
3764
|
+
onEventRef.current = onEvent;
|
|
3765
|
+
|
|
3766
|
+
// Group registry: Map<groupId, { itemIds, groupLabel }>
|
|
3767
|
+
const groupItemsRegistry = react.useRef(new Map());
|
|
3768
|
+
const registerGroupItems = react.useCallback((groupId, itemIds, groupLabel) => {
|
|
3769
|
+
groupItemsRegistry.current.set(groupId, {
|
|
3770
|
+
itemIds,
|
|
3771
|
+
groupLabel
|
|
3772
|
+
});
|
|
3773
|
+
return () => groupItemsRegistry.current.delete(groupId);
|
|
3774
|
+
}, []);
|
|
3775
|
+
|
|
3776
|
+
// Reset first-group claim whenever the dropdown opens
|
|
3777
|
+
react.useEffect(() => {
|
|
3778
|
+
if (isOpen) {
|
|
3779
|
+
firstGroupClaimedRef.current = false;
|
|
3780
|
+
}
|
|
3781
|
+
}, [isOpen]);
|
|
3782
|
+
|
|
3783
|
+
// Nav label registry: Map<id, label> (populated by DropdownNavItem on mount)
|
|
3784
|
+
const navLabels = react.useRef(new Map());
|
|
3785
|
+
const registerNavLabel = react.useCallback((id, label) => {
|
|
3786
|
+
navLabels.current.set(id, label);
|
|
3787
|
+
}, []);
|
|
3788
|
+
|
|
3789
|
+
// Section refs registry: Map<forId, HTMLElement> (populated by DropdownSection on mount)
|
|
3790
|
+
const sectionRefs = react.useRef(new Map());
|
|
3791
|
+
const registerSectionRef = react.useCallback((forId, el) => {
|
|
3792
|
+
if (el) {
|
|
3793
|
+
sectionRefs.current.set(forId, el);
|
|
3794
|
+
} else {
|
|
3795
|
+
sectionRefs.current.delete(forId);
|
|
3796
|
+
}
|
|
3797
|
+
}, []);
|
|
3798
|
+
|
|
3799
|
+
/**
|
|
3800
|
+
* fireEvent — central event dispatcher.
|
|
3801
|
+
*
|
|
3802
|
+
* 1. Compute `prev` snapshot.
|
|
3803
|
+
* 2. Call onEvent callback, capture return value.
|
|
3804
|
+
* 3. Apply state update based on return (null = cancel).
|
|
3805
|
+
* 4. Dispatch native CustomEvent on trigger element.
|
|
3806
|
+
*/
|
|
3807
|
+
const fireEvent = react.useCallback((type, payload) => {
|
|
3808
|
+
// Read from sync refs so this callback is stable ([] deps) while always
|
|
3809
|
+
// seeing the current state values at call time.
|
|
3810
|
+
const selectedItem = selectedItemRef.current;
|
|
3811
|
+
const checkedItems = checkedItemsRef.current;
|
|
3812
|
+
const activeNavId = activeNavIdRef.current;
|
|
3813
|
+
const searchQuery = searchQueryRef.current;
|
|
3814
|
+
const hideOnSelection = hideOnSelectionRef.current;
|
|
3815
|
+
const onEvent = onEventRef.current;
|
|
3816
|
+
|
|
3817
|
+
// Build prev snapshot
|
|
3818
|
+
const prev = (() => {
|
|
3819
|
+
switch (type) {
|
|
3820
|
+
case 'select':
|
|
3821
|
+
return selectedItem ? {
|
|
3822
|
+
...selectedItem
|
|
3823
|
+
} : null;
|
|
3824
|
+
case 'check':
|
|
3825
|
+
return {
|
|
3826
|
+
checked: checkedItems.get(payload.id) ?? false
|
|
3827
|
+
};
|
|
3828
|
+
case 'selectAll':
|
|
3829
|
+
return {
|
|
3830
|
+
checked: checkedItems.get(payload.groupId + '__all') ?? false
|
|
3831
|
+
};
|
|
3832
|
+
case 'navChange':
|
|
3833
|
+
return {
|
|
3834
|
+
id: activeNavId
|
|
3835
|
+
};
|
|
3836
|
+
case 'search':
|
|
3837
|
+
return {
|
|
3838
|
+
query: searchQuery
|
|
3839
|
+
};
|
|
3840
|
+
default:
|
|
3841
|
+
return null;
|
|
3842
|
+
}
|
|
3843
|
+
})();
|
|
3844
|
+
|
|
3845
|
+
// Call user callback
|
|
3846
|
+
const result = onEvent ? onEvent({
|
|
3847
|
+
type,
|
|
3848
|
+
payload,
|
|
3849
|
+
prev
|
|
3850
|
+
}) : undefined;
|
|
3851
|
+
|
|
3852
|
+
// Apply state changes
|
|
3853
|
+
switch (type) {
|
|
3854
|
+
case 'open':
|
|
3855
|
+
setIsOpen(true);
|
|
3856
|
+
break;
|
|
3857
|
+
case 'close':
|
|
3858
|
+
setIsOpen(false);
|
|
3859
|
+
setSearchQuery('');
|
|
3860
|
+
break;
|
|
3861
|
+
case 'select':
|
|
3862
|
+
{
|
|
3863
|
+
// null return = cancel; undefined = uncontrolled (use payload)
|
|
3864
|
+
if (result === null) {
|
|
3865
|
+
break;
|
|
3866
|
+
}
|
|
3867
|
+
const next = result !== undefined ? result : {
|
|
3868
|
+
id: payload.id,
|
|
3869
|
+
label: payload.label
|
|
3870
|
+
};
|
|
3871
|
+
setSelectedItem(next);
|
|
3872
|
+
if (hideOnSelection) {
|
|
3873
|
+
setIsOpen(false);
|
|
3874
|
+
}
|
|
3875
|
+
break;
|
|
3876
|
+
}
|
|
3877
|
+
case 'check':
|
|
3878
|
+
{
|
|
3879
|
+
if (result === null) {
|
|
3880
|
+
break;
|
|
3881
|
+
}
|
|
3882
|
+
const nextChecked = result !== undefined ? Boolean(result) : !checkedItems.get(payload.id);
|
|
3883
|
+
setCheckedItems(prev => {
|
|
3884
|
+
const m = new Map(prev);
|
|
3885
|
+
m.set(payload.id, nextChecked);
|
|
3886
|
+
return m;
|
|
3887
|
+
});
|
|
3888
|
+
break;
|
|
3889
|
+
}
|
|
3890
|
+
case 'selectAll':
|
|
3891
|
+
{
|
|
3892
|
+
if (result === null) {
|
|
3893
|
+
break;
|
|
3894
|
+
}
|
|
3895
|
+
const currentAll = checkedItems.get(payload.groupId + '__all') ?? false;
|
|
3896
|
+
const nextAll = result !== undefined ? Boolean(result) : payload._checked !== undefined ? payload._checked : !currentAll;
|
|
3897
|
+
setCheckedItems(prev => {
|
|
3898
|
+
const m = new Map(prev);
|
|
3899
|
+
// toggle all items in the group
|
|
3900
|
+
payload.itemIds.forEach(id => m.set(id, nextAll));
|
|
3901
|
+
m.set(payload.groupId + '__all', nextAll);
|
|
3902
|
+
return m;
|
|
3903
|
+
});
|
|
3904
|
+
break;
|
|
3905
|
+
}
|
|
3906
|
+
case 'navChange':
|
|
3907
|
+
{
|
|
3908
|
+
setActiveNavId(payload.id);
|
|
3909
|
+
const label = navLabels.current.get(payload.id) ?? '';
|
|
3910
|
+
setActiveNavLabel(label);
|
|
3911
|
+
break;
|
|
3912
|
+
}
|
|
3913
|
+
case 'search':
|
|
3914
|
+
{
|
|
3915
|
+
setSearchQuery(payload.query);
|
|
3916
|
+
break;
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
// Dispatch native CustomEvent on trigger element
|
|
3921
|
+
if (triggerRef.current) {
|
|
3922
|
+
triggerRef.current.dispatchEvent(new CustomEvent(`HO:${type}`, {
|
|
3923
|
+
detail: {
|
|
3924
|
+
payload,
|
|
3925
|
+
prev
|
|
3926
|
+
},
|
|
3927
|
+
bubbles: true,
|
|
3928
|
+
composed: true
|
|
3929
|
+
}));
|
|
3930
|
+
}
|
|
3931
|
+
return result;
|
|
3932
|
+
}, []); // stable — reads state via sync refs, all setters are stable
|
|
3933
|
+
|
|
3934
|
+
// Expose imperative handle — stable because fireEvent is stable and all
|
|
3935
|
+
// state is read from sync refs at call time.
|
|
3936
|
+
react.useImperativeHandle(ref, () => ({
|
|
3937
|
+
open() {
|
|
3938
|
+
fireEvent('open', {
|
|
3939
|
+
trigger: 'imperative'
|
|
3940
|
+
});
|
|
3941
|
+
},
|
|
3942
|
+
close() {
|
|
3943
|
+
fireEvent('close', {
|
|
3944
|
+
trigger: 'imperative'
|
|
3945
|
+
});
|
|
3946
|
+
},
|
|
3947
|
+
toggle() {
|
|
3948
|
+
if (isOpenRef.current) {
|
|
3949
|
+
fireEvent('close', {
|
|
3950
|
+
trigger: 'imperative'
|
|
3951
|
+
});
|
|
3952
|
+
} else {
|
|
3953
|
+
fireEvent('open', {
|
|
3954
|
+
trigger: 'imperative'
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
3957
|
+
},
|
|
3958
|
+
isOpen() {
|
|
3959
|
+
return isOpenRef.current;
|
|
3960
|
+
},
|
|
3961
|
+
getSelected() {
|
|
3962
|
+
return selectedItemRef.current;
|
|
3963
|
+
},
|
|
3964
|
+
getChecked() {
|
|
3965
|
+
return new Map(checkedItemsRef.current);
|
|
3966
|
+
},
|
|
3967
|
+
getActiveNavItem() {
|
|
3968
|
+
return activeNavIdRef.current;
|
|
3969
|
+
},
|
|
3970
|
+
setSearch(query) {
|
|
3971
|
+
fireEvent('search', {
|
|
3972
|
+
query
|
|
3973
|
+
});
|
|
3974
|
+
},
|
|
3975
|
+
selectAll(groupId, checked) {
|
|
3976
|
+
const entry = groupItemsRegistry.current.get(groupId);
|
|
3977
|
+
if (!entry) return;
|
|
3978
|
+
fireEvent('selectAll', {
|
|
3979
|
+
groupId,
|
|
3980
|
+
groupLabel: entry.groupLabel,
|
|
3981
|
+
itemIds: entry.itemIds,
|
|
3982
|
+
_checked: checked
|
|
3983
|
+
});
|
|
3984
|
+
}
|
|
3985
|
+
}), [fireEvent]); // fireEvent is stable, handle is created once
|
|
3986
|
+
|
|
3987
|
+
const setScrollSpyActive = react.useCallback(id => {
|
|
3988
|
+
setActiveNavId(id);
|
|
3989
|
+
const label = navLabels.current.get(id) ?? '';
|
|
3990
|
+
setActiveNavLabel(label);
|
|
3991
|
+
}, []);
|
|
3992
|
+
|
|
3993
|
+
// useMemo so context object identity is stable when nothing relevant changed.
|
|
3994
|
+
// All callbacks/refs inside are already stable — only the 7 state values and
|
|
3995
|
+
// 2 props below can trigger consumer re-renders.
|
|
3996
|
+
const contextValue = react.useMemo(() => ({
|
|
3997
|
+
// State
|
|
3998
|
+
isOpen,
|
|
3999
|
+
selectedItem,
|
|
4000
|
+
checkedItems,
|
|
4001
|
+
activeNavId,
|
|
4002
|
+
activeNavLabel,
|
|
4003
|
+
searchQuery,
|
|
4004
|
+
displayMode,
|
|
4005
|
+
hasNav,
|
|
4006
|
+
darkMode,
|
|
4007
|
+
setHasNav,
|
|
4008
|
+
// Refs
|
|
4009
|
+
triggerRef,
|
|
4010
|
+
contentRef,
|
|
4011
|
+
firstGroupClaimedRef,
|
|
4012
|
+
// Config
|
|
4013
|
+
defaultGroupExpanded,
|
|
4014
|
+
// Actions
|
|
4015
|
+
fireEvent,
|
|
4016
|
+
registerGroupItems,
|
|
4017
|
+
setActiveNavId,
|
|
4018
|
+
setScrollSpyActive,
|
|
4019
|
+
setSearchQuery,
|
|
4020
|
+
// Registries
|
|
4021
|
+
navLabels: navLabels.current,
|
|
4022
|
+
registerNavLabel,
|
|
4023
|
+
sectionRefs: sectionRefs.current,
|
|
4024
|
+
registerSectionRef
|
|
4025
|
+
}), [isOpen, selectedItem, checkedItems, activeNavId, activeNavLabel, searchQuery, hasNav, displayMode, defaultGroupExpanded, darkMode,
|
|
4026
|
+
// all others are stable references
|
|
4027
|
+
fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef]);
|
|
4028
|
+
const resolvedChildren = (() => {
|
|
4029
|
+
if (fromConfig && children) {
|
|
4030
|
+
console.warn('[Dropdown] `fromConfig` and `children` cannot be used together. ' + '`fromConfig` takes precedence — `children` will be ignored.');
|
|
4031
|
+
return buildFromConfig(fromConfig);
|
|
4032
|
+
}
|
|
4033
|
+
if (fromConfig) return buildFromConfig(fromConfig);
|
|
4034
|
+
return children;
|
|
4035
|
+
})();
|
|
4036
|
+
return /*#__PURE__*/jsxRuntime.jsx(DropdownContext.Provider, {
|
|
4037
|
+
value: contextValue,
|
|
4038
|
+
children: /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
4039
|
+
className: `hangoverDropdown${darkMode ? ' hangoverDropdown--dark' : ''}`,
|
|
4040
|
+
...rest,
|
|
4041
|
+
children: resolvedChildren
|
|
4042
|
+
})
|
|
4043
|
+
});
|
|
4044
|
+
});
|
|
4045
|
+
|
|
4046
|
+
/**
|
|
4047
|
+
* fromConfig — render a full Dropdown tree from a plain JS config object.
|
|
4048
|
+
*
|
|
4049
|
+
* @param {object} config
|
|
4050
|
+
* @param {React.Ref} [ref] — forwarded to the root Dropdown ref
|
|
4051
|
+
* @returns JSX
|
|
4052
|
+
*
|
|
4053
|
+
* Config schema:
|
|
4054
|
+
* {
|
|
4055
|
+
* // Root props (all optional)
|
|
4056
|
+
* displayMode?: 'scroll' | 'tab'
|
|
4057
|
+
* defaultOpen?: boolean
|
|
4058
|
+
* defaultGroupExpanded?: boolean | 'first'
|
|
4059
|
+
* hideOnSelection?: boolean
|
|
4060
|
+
* onEvent?: ({ type, payload, prev }) => any
|
|
4061
|
+
*
|
|
4062
|
+
* // Trigger
|
|
4063
|
+
* trigger: ReactNode | string | {
|
|
4064
|
+
* label: string
|
|
4065
|
+
* className?: string
|
|
4066
|
+
* component?: ComponentType
|
|
4067
|
+
* }
|
|
4068
|
+
*
|
|
4069
|
+
* // Panel (optional — defaults used if omitted)
|
|
4070
|
+
* panel?: {
|
|
4071
|
+
* placement?: string // default 'bottom-start'
|
|
4072
|
+
* offset?: number // default 8
|
|
4073
|
+
* }
|
|
4074
|
+
*
|
|
4075
|
+
* // Navigation column (optional)
|
|
4076
|
+
* navigation?: { ... } // legacy alias for nav config
|
|
4077
|
+
* items?: Array<{ // preferred alias for navigation.items
|
|
4078
|
+
* id?: string
|
|
4079
|
+
* label: string
|
|
4080
|
+
* icon?: ReactNode | FC
|
|
4081
|
+
* }>
|
|
4082
|
+
* showAll?: boolean // preferred alias for navigation.showAll
|
|
4083
|
+
* allLabel?: string
|
|
4084
|
+
* allIcon?: ReactNode | FC
|
|
4085
|
+
* collapsed?: boolean
|
|
4086
|
+
* autoCollapse?: boolean
|
|
4087
|
+
*
|
|
4088
|
+
* // Content column
|
|
4089
|
+
* content: {
|
|
4090
|
+
* searchPlaceholder?: string // include search bar when provided
|
|
4091
|
+
* sections: Array<{
|
|
4092
|
+
* for?: string // matches navigation item id
|
|
4093
|
+
* title?: string
|
|
4094
|
+
* items?: Array<{
|
|
4095
|
+
* id?: string
|
|
4096
|
+
* label?: string
|
|
4097
|
+
* defaultExpanded?: boolean
|
|
4098
|
+
* items: Array<{
|
|
4099
|
+
* id: string
|
|
4100
|
+
* label: string
|
|
4101
|
+
* type?: 'click' | 'checkbox'
|
|
4102
|
+
* icon?: ReactNode | FC
|
|
4103
|
+
* defaultChecked?: boolean
|
|
4104
|
+
* checkIcon?: ReactNode | FC
|
|
4105
|
+
* component?: ComponentType
|
|
4106
|
+
* }>
|
|
4107
|
+
* }>
|
|
4108
|
+
* }>
|
|
4109
|
+
* }
|
|
4110
|
+
* }
|
|
4111
|
+
*/
|
|
4112
|
+
function renderFromConfig(config, ref) {
|
|
4113
|
+
const {
|
|
4114
|
+
rootConfig,
|
|
4115
|
+
trigger,
|
|
4116
|
+
panel,
|
|
4117
|
+
navigation,
|
|
4118
|
+
content
|
|
4119
|
+
} = normalizeConfig(config);
|
|
4120
|
+
const {
|
|
4121
|
+
displayMode,
|
|
4122
|
+
defaultOpen,
|
|
4123
|
+
defaultGroupExpanded,
|
|
4124
|
+
hideOnSelection,
|
|
4125
|
+
onEvent,
|
|
4126
|
+
...rootRest
|
|
4127
|
+
} = rootConfig;
|
|
4128
|
+
const triggerNode = renderTriggerNode(trigger);
|
|
4129
|
+
const navNode = renderNavigationNode(navigation);
|
|
4130
|
+
const {
|
|
4131
|
+
searchPlaceholder,
|
|
4132
|
+
sections = [],
|
|
4133
|
+
...contentRest
|
|
4134
|
+
} = content || {};
|
|
4135
|
+
const contentNode = /*#__PURE__*/jsxRuntime.jsx(DropdownContent, {
|
|
4136
|
+
searchPlaceholder: searchPlaceholder,
|
|
4137
|
+
...contentRest,
|
|
4138
|
+
children: renderSectionNodes(sections)
|
|
4139
|
+
});
|
|
4140
|
+
const {
|
|
4141
|
+
placement: panelPlacement,
|
|
4142
|
+
offset: panelOffset,
|
|
4143
|
+
...panelRest
|
|
4144
|
+
} = panel;
|
|
4145
|
+
const panelChildren = /*#__PURE__*/jsxRuntime.jsxs(DropdownPanel, {
|
|
4146
|
+
placement: panelPlacement,
|
|
4147
|
+
offset: panelOffset,
|
|
4148
|
+
...panelRest,
|
|
4149
|
+
children: [navNode, contentNode]
|
|
4150
|
+
});
|
|
4151
|
+
return /*#__PURE__*/jsxRuntime.jsxs(Dropdown$1, {
|
|
4152
|
+
ref: ref,
|
|
4153
|
+
displayMode: displayMode,
|
|
4154
|
+
defaultOpen: defaultOpen,
|
|
4155
|
+
defaultGroupExpanded: defaultGroupExpanded,
|
|
4156
|
+
hideOnSelection: hideOnSelection,
|
|
4157
|
+
onEvent: onEvent,
|
|
4158
|
+
...rootRest,
|
|
4159
|
+
children: [triggerNode, panelChildren]
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
/**
|
|
4164
|
+
* DropdownFromConfig — React component wrapper.
|
|
4165
|
+
* <DropdownFromConfig config={myConfig} />
|
|
4166
|
+
*/
|
|
4167
|
+
const DropdownFromConfig = /*#__PURE__*/react.forwardRef(function DropdownFromConfig({
|
|
4168
|
+
config
|
|
4169
|
+
}, ref) {
|
|
4170
|
+
return renderFromConfig(config, ref);
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
/**
|
|
4174
|
+
* fromConfig(config, ref?) — imperative helper, returns JSX directly.
|
|
4175
|
+
*/
|
|
4176
|
+
function fromConfig(config, ref) {
|
|
4177
|
+
return renderFromConfig(config, ref);
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
const Dropdown = Object.assign(Dropdown$1, {
|
|
4181
|
+
Trigger: DropdownTrigger,
|
|
4182
|
+
Panel: DropdownPanel,
|
|
4183
|
+
Navigation: DropdownNav,
|
|
4184
|
+
NavigationItem: DropdownNavItem,
|
|
4185
|
+
Content: DropdownContent,
|
|
4186
|
+
Section: DropdownSection,
|
|
4187
|
+
Group: DropdownGroup,
|
|
4188
|
+
Item: DropdownItem,
|
|
4189
|
+
fromConfig,
|
|
4190
|
+
FromConfig: DropdownFromConfig
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
/**
|
|
4194
|
+
* useDropdown — public hook for reading and controlling a <Dropdown> from
|
|
4195
|
+
* any component rendered inside its tree.
|
|
4196
|
+
*
|
|
4197
|
+
* Must be called inside a <Dropdown> (or a component passed via `component` prop).
|
|
4198
|
+
*
|
|
4199
|
+
* Returns:
|
|
4200
|
+
*
|
|
4201
|
+
* State (reactive — triggers re-render on change):
|
|
4202
|
+
* isOpen boolean
|
|
4203
|
+
* selectedItem { id, label } | null
|
|
4204
|
+
* checkedItems Map<id, boolean>
|
|
4205
|
+
* activeNavId string
|
|
4206
|
+
* activeNavLabel string
|
|
4207
|
+
* searchQuery string
|
|
4208
|
+
* displayMode 'scroll' | 'tab'
|
|
4209
|
+
* darkMode boolean
|
|
4210
|
+
*
|
|
4211
|
+
* Actions (stable references — safe to use in dependency arrays):
|
|
4212
|
+
* open() Open the panel
|
|
4213
|
+
* close() Close the panel
|
|
4214
|
+
* toggle() Toggle open/closed
|
|
4215
|
+
* setSearch(query) Programmatically update the search query
|
|
4216
|
+
*
|
|
4217
|
+
* Escape hatch:
|
|
4218
|
+
* fireEvent(type, payload) Fire any internal event directly.
|
|
4219
|
+
* Return null from onEvent to cancel it.
|
|
4220
|
+
*/
|
|
4221
|
+
function useDropdown() {
|
|
4222
|
+
const {
|
|
4223
|
+
isOpen,
|
|
4224
|
+
selectedItem,
|
|
4225
|
+
checkedItems,
|
|
4226
|
+
activeNavId,
|
|
4227
|
+
activeNavLabel,
|
|
4228
|
+
searchQuery,
|
|
4229
|
+
displayMode,
|
|
4230
|
+
darkMode,
|
|
4231
|
+
fireEvent
|
|
4232
|
+
} = useDropdownContext();
|
|
4233
|
+
const open = react.useCallback(() => fireEvent('open', {
|
|
4234
|
+
trigger: 'imperative'
|
|
4235
|
+
}), [fireEvent]);
|
|
4236
|
+
const close = react.useCallback(() => fireEvent('close', {
|
|
4237
|
+
trigger: 'imperative'
|
|
4238
|
+
}), [fireEvent]);
|
|
4239
|
+
|
|
4240
|
+
// isOpen is reactive so toggle always reflects current state
|
|
4241
|
+
const toggle = react.useCallback(() => fireEvent(isOpen ? 'close' : 'open', {
|
|
4242
|
+
trigger: 'imperative'
|
|
4243
|
+
}), [fireEvent, isOpen]);
|
|
4244
|
+
const setSearch = react.useCallback(query => fireEvent('search', {
|
|
4245
|
+
query
|
|
4246
|
+
}), [fireEvent]);
|
|
4247
|
+
return {
|
|
4248
|
+
// State
|
|
4249
|
+
isOpen,
|
|
4250
|
+
selectedItem,
|
|
4251
|
+
checkedItems,
|
|
4252
|
+
activeNavId,
|
|
4253
|
+
activeNavLabel,
|
|
4254
|
+
searchQuery,
|
|
4255
|
+
displayMode,
|
|
4256
|
+
darkMode,
|
|
4257
|
+
// Actions
|
|
4258
|
+
open,
|
|
4259
|
+
close,
|
|
4260
|
+
toggle,
|
|
4261
|
+
setSearch,
|
|
4262
|
+
// Escape hatch for advanced / unforeseen use cases
|
|
4263
|
+
fireEvent
|
|
4264
|
+
};
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
exports.Dropdown = Dropdown;
|
|
4268
|
+
exports.default = Dropdown;
|
|
4269
|
+
exports.useDropdown = useDropdown;
|