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