@diabolic/hangover 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/hangover.css +3 -1
- package/dist/index.cjs.js +66 -1
- package/dist/index.esm.js +66 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A React 18 compound-component Dropdown / Field Picker library.
|
|
|
11
11
|
|
|
12
12
|
- **Compound Components** - Composable `Dropdown.Trigger`, `Panel`, `Navigation`, `Section`, `Group`, `Item` API
|
|
13
13
|
- **Fuzzy Search** - Built-in fuzzy filtering across items (powered by fuse.js)
|
|
14
|
+
- **Keyboard Navigation** - Arrow up/down to move between visible items (before and after searching), Enter to trigger
|
|
14
15
|
- **Two Display Modes** - Scroll-spy with smooth scroll or one-section-at-a-time tab mode
|
|
15
16
|
- **Left Navigation** - Optional nav column with auto-collapse and single-section auto-transform
|
|
16
17
|
- **Checkbox Items** - Multi-select with select-all support
|
package/dist/hangover.css
CHANGED
|
@@ -463,7 +463,9 @@
|
|
|
463
463
|
background: var(--hangover-color-bg-hover);
|
|
464
464
|
}
|
|
465
465
|
.hangoverDropdown-item:focus-visible {
|
|
466
|
-
|
|
466
|
+
background: var(--hangover-color-bg-hover);
|
|
467
|
+
box-shadow: none;
|
|
468
|
+
outline: none;
|
|
467
469
|
}
|
|
468
470
|
.hangoverDropdown-item.isSelected {
|
|
469
471
|
background: var(--hangover-color-bg-selected);
|
package/dist/index.cjs.js
CHANGED
|
@@ -647,6 +647,7 @@ function DropdownContent({
|
|
|
647
647
|
setScrollSpyActive,
|
|
648
648
|
t
|
|
649
649
|
} = useDropdownContext();
|
|
650
|
+
const searchInputRef = react.useRef(null);
|
|
650
651
|
|
|
651
652
|
// Scroll spy: update active nav based on scroll position
|
|
652
653
|
react.useEffect(() => {
|
|
@@ -700,6 +701,67 @@ function DropdownContent({
|
|
|
700
701
|
query: e.target.value
|
|
701
702
|
});
|
|
702
703
|
}
|
|
704
|
+
|
|
705
|
+
// Keyboard navigation: ArrowDown/ArrowUp move focus across the currently
|
|
706
|
+
// visible items (works before and after searching, since filtered items
|
|
707
|
+
// are removed from the DOM). Enter is handled by each item itself.
|
|
708
|
+
function scrollItemIntoView(el) {
|
|
709
|
+
const list = contentRef.current;
|
|
710
|
+
if (!list || !el) return;
|
|
711
|
+
|
|
712
|
+
// Offset by the sticky section header so the item stays fully visible
|
|
713
|
+
// when navigating upwards.
|
|
714
|
+
const stickyEl = list.querySelector('.hangoverDropdown-section-title');
|
|
715
|
+
const stickyHeight = stickyEl ? stickyEl.offsetHeight : 0;
|
|
716
|
+
const listRect = list.getBoundingClientRect();
|
|
717
|
+
const elRect = el.getBoundingClientRect();
|
|
718
|
+
let delta = 0;
|
|
719
|
+
if (elRect.top < listRect.top + stickyHeight) {
|
|
720
|
+
delta = elRect.top - (listRect.top + stickyHeight);
|
|
721
|
+
} else if (elRect.bottom > listRect.bottom) {
|
|
722
|
+
delta = elRect.bottom - listRect.bottom;
|
|
723
|
+
}
|
|
724
|
+
if (delta !== 0) {
|
|
725
|
+
list.scrollTo({
|
|
726
|
+
top: list.scrollTop + delta,
|
|
727
|
+
behavior: 'smooth'
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function moveItemFocus(direction) {
|
|
732
|
+
const list = contentRef.current;
|
|
733
|
+
if (!list) return;
|
|
734
|
+
const items = Array.from(list.querySelectorAll('.hangoverDropdown-item')).filter(el => el.offsetParent !== null);
|
|
735
|
+
if (items.length === 0) return;
|
|
736
|
+
const active = document.activeElement;
|
|
737
|
+
const idx = items.indexOf(active);
|
|
738
|
+
if (direction === 1) {
|
|
739
|
+
const next = idx < 0 ? items[0] : items[idx + 1];
|
|
740
|
+
if (next) {
|
|
741
|
+
next.focus({
|
|
742
|
+
preventScroll: true
|
|
743
|
+
});
|
|
744
|
+
scrollItemIntoView(next);
|
|
745
|
+
}
|
|
746
|
+
} else if (idx > 0) {
|
|
747
|
+
const prev = items[idx - 1];
|
|
748
|
+
prev.focus({
|
|
749
|
+
preventScroll: true
|
|
750
|
+
});
|
|
751
|
+
scrollItemIntoView(prev);
|
|
752
|
+
} else if (idx === 0 && searchInputRef.current) {
|
|
753
|
+
searchInputRef.current.focus();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function handleKeyNav(e) {
|
|
757
|
+
if (e.key === 'ArrowDown') {
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
moveItemFocus(1);
|
|
760
|
+
} else if (e.key === 'ArrowUp') {
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
moveItemFocus(-1);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
703
765
|
const isEmpty = react.Children.count(children) === 0;
|
|
704
766
|
const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
|
|
705
767
|
children: [!isEmpty && /*#__PURE__*/jsxRuntime.jsxs("label", {
|
|
@@ -713,12 +775,15 @@ function DropdownContent({
|
|
|
713
775
|
placeholder: t(searchPlaceholder),
|
|
714
776
|
"aria-label": t(searchPlaceholder),
|
|
715
777
|
value: searchQuery,
|
|
716
|
-
onChange: handleSearch
|
|
778
|
+
onChange: handleSearch,
|
|
779
|
+
onKeyDown: handleKeyNav,
|
|
780
|
+
ref: searchInputRef
|
|
717
781
|
})]
|
|
718
782
|
}), /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
719
783
|
role: "listbox",
|
|
720
784
|
className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
|
|
721
785
|
ref: contentRef,
|
|
786
|
+
onKeyDown: handleKeyNav,
|
|
722
787
|
children: isEmpty ? /*#__PURE__*/jsxRuntime.jsx("div", {
|
|
723
788
|
className: "hangoverDropdown-content-empty",
|
|
724
789
|
children: t(emptyText)
|
package/dist/index.esm.js
CHANGED
|
@@ -643,6 +643,7 @@ function DropdownContent({
|
|
|
643
643
|
setScrollSpyActive,
|
|
644
644
|
t
|
|
645
645
|
} = useDropdownContext();
|
|
646
|
+
const searchInputRef = useRef(null);
|
|
646
647
|
|
|
647
648
|
// Scroll spy: update active nav based on scroll position
|
|
648
649
|
useEffect(() => {
|
|
@@ -696,6 +697,67 @@ function DropdownContent({
|
|
|
696
697
|
query: e.target.value
|
|
697
698
|
});
|
|
698
699
|
}
|
|
700
|
+
|
|
701
|
+
// Keyboard navigation: ArrowDown/ArrowUp move focus across the currently
|
|
702
|
+
// visible items (works before and after searching, since filtered items
|
|
703
|
+
// are removed from the DOM). Enter is handled by each item itself.
|
|
704
|
+
function scrollItemIntoView(el) {
|
|
705
|
+
const list = contentRef.current;
|
|
706
|
+
if (!list || !el) return;
|
|
707
|
+
|
|
708
|
+
// Offset by the sticky section header so the item stays fully visible
|
|
709
|
+
// when navigating upwards.
|
|
710
|
+
const stickyEl = list.querySelector('.hangoverDropdown-section-title');
|
|
711
|
+
const stickyHeight = stickyEl ? stickyEl.offsetHeight : 0;
|
|
712
|
+
const listRect = list.getBoundingClientRect();
|
|
713
|
+
const elRect = el.getBoundingClientRect();
|
|
714
|
+
let delta = 0;
|
|
715
|
+
if (elRect.top < listRect.top + stickyHeight) {
|
|
716
|
+
delta = elRect.top - (listRect.top + stickyHeight);
|
|
717
|
+
} else if (elRect.bottom > listRect.bottom) {
|
|
718
|
+
delta = elRect.bottom - listRect.bottom;
|
|
719
|
+
}
|
|
720
|
+
if (delta !== 0) {
|
|
721
|
+
list.scrollTo({
|
|
722
|
+
top: list.scrollTop + delta,
|
|
723
|
+
behavior: 'smooth'
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function moveItemFocus(direction) {
|
|
728
|
+
const list = contentRef.current;
|
|
729
|
+
if (!list) return;
|
|
730
|
+
const items = Array.from(list.querySelectorAll('.hangoverDropdown-item')).filter(el => el.offsetParent !== null);
|
|
731
|
+
if (items.length === 0) return;
|
|
732
|
+
const active = document.activeElement;
|
|
733
|
+
const idx = items.indexOf(active);
|
|
734
|
+
if (direction === 1) {
|
|
735
|
+
const next = idx < 0 ? items[0] : items[idx + 1];
|
|
736
|
+
if (next) {
|
|
737
|
+
next.focus({
|
|
738
|
+
preventScroll: true
|
|
739
|
+
});
|
|
740
|
+
scrollItemIntoView(next);
|
|
741
|
+
}
|
|
742
|
+
} else if (idx > 0) {
|
|
743
|
+
const prev = items[idx - 1];
|
|
744
|
+
prev.focus({
|
|
745
|
+
preventScroll: true
|
|
746
|
+
});
|
|
747
|
+
scrollItemIntoView(prev);
|
|
748
|
+
} else if (idx === 0 && searchInputRef.current) {
|
|
749
|
+
searchInputRef.current.focus();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function handleKeyNav(e) {
|
|
753
|
+
if (e.key === 'ArrowDown') {
|
|
754
|
+
e.preventDefault();
|
|
755
|
+
moveItemFocus(1);
|
|
756
|
+
} else if (e.key === 'ArrowUp') {
|
|
757
|
+
e.preventDefault();
|
|
758
|
+
moveItemFocus(-1);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
699
761
|
const isEmpty = Children.count(children) === 0;
|
|
700
762
|
const inner = /*#__PURE__*/jsxs(Fragment, {
|
|
701
763
|
children: [!isEmpty && /*#__PURE__*/jsxs("label", {
|
|
@@ -709,12 +771,15 @@ function DropdownContent({
|
|
|
709
771
|
placeholder: t(searchPlaceholder),
|
|
710
772
|
"aria-label": t(searchPlaceholder),
|
|
711
773
|
value: searchQuery,
|
|
712
|
-
onChange: handleSearch
|
|
774
|
+
onChange: handleSearch,
|
|
775
|
+
onKeyDown: handleKeyNav,
|
|
776
|
+
ref: searchInputRef
|
|
713
777
|
})]
|
|
714
778
|
}), /*#__PURE__*/jsx("div", {
|
|
715
779
|
role: "listbox",
|
|
716
780
|
className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
|
|
717
781
|
ref: contentRef,
|
|
782
|
+
onKeyDown: handleKeyNav,
|
|
718
783
|
children: isEmpty ? /*#__PURE__*/jsx("div", {
|
|
719
784
|
className: "hangoverDropdown-content-empty",
|
|
720
785
|
children: t(emptyText)
|