@diabolic/hangover 0.1.3 → 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 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
@@ -970,6 +971,7 @@ All visual properties are configurable via CSS custom properties:
970
971
  /* Layout */
971
972
  --hangover-nav-width: 172px;
972
973
  --hangover-content-max-width: 240px;
974
+ --hangover-panel-nonav-width: 240px;
973
975
  --hangover-list-max-height: 280px;
974
976
 
975
977
  --hangover-transition: 330ms ease;
package/dist/hangover.css CHANGED
@@ -27,6 +27,7 @@
27
27
  0 2px 4px 0 rgba(37, 45, 91, 0.04);
28
28
  --hangover-nav-width: 172px;
29
29
  --hangover-content-max-width: 240px;
30
+ --hangover-panel-nonav-width: 240px;
30
31
  --hangover-list-max-height: 280px;
31
32
  --hangover-transition: 330ms ease;
32
33
  }
@@ -96,7 +97,10 @@
96
97
  }
97
98
  .hangoverDropdown-panel.hasNoNav {
98
99
  min-width: unset;
99
- width: 240px;
100
+ width: var(--hangover-panel-nonav-width);
101
+ }
102
+ .hangoverDropdown-panel.hasNoNav .hangoverDropdown-column.forItems {
103
+ max-width: none;
100
104
  }
101
105
  .hangoverDropdown-panel-inner {
102
106
  display: flex;
@@ -459,7 +463,9 @@
459
463
  background: var(--hangover-color-bg-hover);
460
464
  }
461
465
  .hangoverDropdown-item:focus-visible {
462
- box-shadow: 0 0 0 2px var(--hangover-color-focus);
466
+ background: var(--hangover-color-bg-hover);
467
+ box-shadow: none;
468
+ outline: none;
463
469
  }
464
470
  .hangoverDropdown-item.isSelected {
465
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diabolic/hangover",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A headless-style, compound React dropdown/field-picker component library",
5
5
  "license": "MIT",
6
6
  "author": "bugrakaan",