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