@diabolic/hangover 0.1.4 → 0.1.6

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
package/dist/hangover.css CHANGED
@@ -59,6 +59,12 @@
59
59
  .hangoverDropdown--dark .hangoverDropdown-list:hover::-webkit-scrollbar-thumb, .hangoverDropdown--dark .hangoverDropdown-list:focus-within::-webkit-scrollbar-thumb {
60
60
  background: rgba(255, 255, 255, 0.18);
61
61
  }
62
+ .hangoverDropdown--dark .hangoverDropdown-column.forNavigation:hover, .hangoverDropdown--dark .hangoverDropdown-column.forNavigation:focus-within {
63
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
64
+ }
65
+ .hangoverDropdown--dark .hangoverDropdown-column.forNavigation:hover::-webkit-scrollbar-thumb, .hangoverDropdown--dark .hangoverDropdown-column.forNavigation:focus-within::-webkit-scrollbar-thumb {
66
+ background: rgba(255, 255, 255, 0.18);
67
+ }
62
68
 
63
69
  @keyframes hangoverDropdownIn {
64
70
  from {
@@ -147,7 +153,28 @@
147
153
  box-sizing: border-box;
148
154
  flex-shrink: 0;
149
155
  flex: none;
150
- transition: width 330ms ease, min-width 330ms ease;
156
+ overflow-y: auto;
157
+ overscroll-behavior: none;
158
+ scrollbar-width: none;
159
+ scrollbar-color: transparent transparent;
160
+ transition: width 330ms ease, min-width 330ms ease, scrollbar-color 330ms ease;
161
+ }
162
+ .hangoverDropdown-column.forNavigation:hover, .hangoverDropdown-column.forNavigation:focus-within {
163
+ scrollbar-color: rgba(0, 0, 0, 0.18) transparent;
164
+ }
165
+ .hangoverDropdown-column.forNavigation::-webkit-scrollbar {
166
+ width: 4px;
167
+ }
168
+ .hangoverDropdown-column.forNavigation::-webkit-scrollbar-track {
169
+ background: transparent;
170
+ }
171
+ .hangoverDropdown-column.forNavigation::-webkit-scrollbar-thumb {
172
+ background: transparent;
173
+ border-radius: 2px;
174
+ transition: background 330ms ease;
175
+ }
176
+ .hangoverDropdown-column.forNavigation:hover::-webkit-scrollbar-thumb, .hangoverDropdown-column.forNavigation:focus-within::-webkit-scrollbar-thumb {
177
+ background: rgba(0, 0, 0, 0.18);
151
178
  }
152
179
  .hangoverDropdown-column.forNavigation .hangoverDropdown-nav-item {
153
180
  transition: width 330ms ease, padding 330ms ease, justify-content 330ms ease, gap 330ms ease, background 330ms ease;
@@ -463,7 +490,9 @@
463
490
  background: var(--hangover-color-bg-hover);
464
491
  }
465
492
  .hangoverDropdown-item:focus-visible {
466
- box-shadow: 0 0 0 2px var(--hangover-color-focus);
493
+ background: var(--hangover-color-bg-hover);
494
+ box-shadow: none;
495
+ outline: none;
467
496
  }
468
497
  .hangoverDropdown-item.isSelected {
469
498
  background: var(--hangover-color-bg-selected);
package/dist/index.cjs.js CHANGED
@@ -450,9 +450,39 @@ function DropdownNavItem({
450
450
  t
451
451
  } = useDropdownContext();
452
452
  const isActive = activeNavId === id;
453
+ const buttonRef = react.useRef(null);
453
454
  react.useEffect(() => {
454
455
  registerNavLabel(id, typeof children === 'string' ? children : '');
455
456
  }, [id, children, registerNavLabel]);
457
+
458
+ // When this item becomes active (e.g. via scroll-spy on the right column),
459
+ // keep it visible inside the scrollable nav column.
460
+ react.useEffect(() => {
461
+ if (!isActive) return;
462
+ const el = buttonRef.current;
463
+ if (!el) return;
464
+ const container = el.closest('.hangoverDropdown-column.forNavigation');
465
+ if (!container) return;
466
+
467
+ // Leave a gap-sized breathing space so the active item never sits flush
468
+ // against the top/bottom edge of the nav column.
469
+ const navList = el.parentElement;
470
+ const gap = navList ? parseFloat(getComputedStyle(navList).rowGap || getComputedStyle(navList).gap) || 0 : 0;
471
+ const containerRect = container.getBoundingClientRect();
472
+ const elRect = el.getBoundingClientRect();
473
+ let delta = 0;
474
+ if (elRect.top < containerRect.top + gap) {
475
+ delta = elRect.top - containerRect.top - gap;
476
+ } else if (elRect.bottom > containerRect.bottom - gap) {
477
+ delta = elRect.bottom - containerRect.bottom + gap;
478
+ }
479
+ if (delta !== 0) {
480
+ container.scrollTo({
481
+ top: container.scrollTop + delta,
482
+ behavior: 'smooth'
483
+ });
484
+ }
485
+ }, [isActive]);
456
486
  function handleClick() {
457
487
  fireEvent('navChange', {
458
488
  id
@@ -498,6 +528,7 @@ function DropdownNavItem({
498
528
  }
499
529
  return /*#__PURE__*/jsxRuntime.jsxs("button", {
500
530
  type: "button",
531
+ ref: buttonRef,
501
532
  className: `hangoverDropdown-nav-item${isActive ? ' isActive' : ''}`,
502
533
  onClick: () => {
503
534
  handleClick();
@@ -647,6 +678,8 @@ function DropdownContent({
647
678
  setScrollSpyActive,
648
679
  t
649
680
  } = useDropdownContext();
681
+ const searchInputRef = react.useRef(null);
682
+ const bottomPadRef = react.useRef(0);
650
683
 
651
684
  // Scroll spy: update active nav based on scroll position
652
685
  react.useEffect(() => {
@@ -695,11 +728,114 @@ function DropdownContent({
695
728
  if (displayMode !== 'tab') return;
696
729
  if (contentRef.current) contentRef.current.scrollTop = 0;
697
730
  }, [displayMode, activeNavId, contentRef]);
731
+
732
+ // Scroll mode: reserve enough space at the bottom of the list so the last
733
+ // section (even a short single-entry one) can be scrolled all the way to the
734
+ // top. Without this, a short last section can never reach the top because the
735
+ // container has already hit its maximum scroll position.
736
+ react.useEffect(() => {
737
+ if (displayMode !== 'scroll') return;
738
+ const list = contentRef.current;
739
+ if (!list) return;
740
+ function updateBottomSpace() {
741
+ const sections = list.querySelectorAll('[data-section-for]');
742
+ const lastSection = sections[sections.length - 1];
743
+ if (!lastSection) {
744
+ list.style.paddingBottom = '';
745
+ bottomPadRef.current = 0;
746
+ return;
747
+ }
748
+
749
+ // Derive the natural content height by subtracting our own reserved
750
+ // space, so recomputes never compound. Using real geometry for the last
751
+ // section's top keeps this margin-safe (bottom padding sits after it, so
752
+ // it never shifts the section's top position).
753
+ const available = list.clientHeight;
754
+ const naturalScrollHeight = list.scrollHeight - bottomPadRef.current;
755
+ const listTop = list.getBoundingClientRect().top;
756
+ const lastTopWithinContent = lastSection.getBoundingClientRect().top - listTop + list.scrollTop;
757
+ const spaceBelowLastTop = naturalScrollHeight - lastTopWithinContent;
758
+ const pad = Math.max(0, available - spaceBelowLastTop);
759
+ bottomPadRef.current = pad;
760
+ list.style.paddingBottom = `${pad}px`;
761
+ }
762
+ updateBottomSpace();
763
+
764
+ // Observe both the container (viewport resize) and every section (group
765
+ // expand/collapse, late reflows) so the reserved space stays accurate.
766
+ const observer = new ResizeObserver(updateBottomSpace);
767
+ observer.observe(list);
768
+ list.querySelectorAll('[data-section-for]').forEach(section => {
769
+ observer.observe(section);
770
+ });
771
+ return () => observer.disconnect();
772
+ }, [displayMode, contentRef, children, searchQuery]);
698
773
  function handleSearch(e) {
699
774
  fireEvent('search', {
700
775
  query: e.target.value
701
776
  });
702
777
  }
778
+
779
+ // Keyboard navigation: ArrowDown/ArrowUp move focus across the currently
780
+ // visible items (works before and after searching, since filtered items
781
+ // are removed from the DOM). Enter is handled by each item itself.
782
+ function scrollItemIntoView(el) {
783
+ const list = contentRef.current;
784
+ if (!list || !el) return;
785
+
786
+ // Offset by the sticky section header so the item stays fully visible
787
+ // when navigating upwards.
788
+ const stickyEl = list.querySelector('.hangoverDropdown-section-title');
789
+ const stickyHeight = stickyEl ? stickyEl.offsetHeight : 0;
790
+ const listRect = list.getBoundingClientRect();
791
+ const elRect = el.getBoundingClientRect();
792
+ let delta = 0;
793
+ if (elRect.top < listRect.top + stickyHeight) {
794
+ delta = elRect.top - (listRect.top + stickyHeight);
795
+ } else if (elRect.bottom > listRect.bottom) {
796
+ delta = elRect.bottom - listRect.bottom;
797
+ }
798
+ if (delta !== 0) {
799
+ list.scrollTo({
800
+ top: list.scrollTop + delta,
801
+ behavior: 'smooth'
802
+ });
803
+ }
804
+ }
805
+ function moveItemFocus(direction) {
806
+ const list = contentRef.current;
807
+ if (!list) return;
808
+ const items = Array.from(list.querySelectorAll('.hangoverDropdown-item')).filter(el => el.offsetParent !== null);
809
+ if (items.length === 0) return;
810
+ const active = document.activeElement;
811
+ const idx = items.indexOf(active);
812
+ if (direction === 1) {
813
+ const next = idx < 0 ? items[0] : items[idx + 1];
814
+ if (next) {
815
+ next.focus({
816
+ preventScroll: true
817
+ });
818
+ scrollItemIntoView(next);
819
+ }
820
+ } else if (idx > 0) {
821
+ const prev = items[idx - 1];
822
+ prev.focus({
823
+ preventScroll: true
824
+ });
825
+ scrollItemIntoView(prev);
826
+ } else if (idx === 0 && searchInputRef.current) {
827
+ searchInputRef.current.focus();
828
+ }
829
+ }
830
+ function handleKeyNav(e) {
831
+ if (e.key === 'ArrowDown') {
832
+ e.preventDefault();
833
+ moveItemFocus(1);
834
+ } else if (e.key === 'ArrowUp') {
835
+ e.preventDefault();
836
+ moveItemFocus(-1);
837
+ }
838
+ }
703
839
  const isEmpty = react.Children.count(children) === 0;
704
840
  const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
705
841
  children: [!isEmpty && /*#__PURE__*/jsxRuntime.jsxs("label", {
@@ -713,12 +849,15 @@ function DropdownContent({
713
849
  placeholder: t(searchPlaceholder),
714
850
  "aria-label": t(searchPlaceholder),
715
851
  value: searchQuery,
716
- onChange: handleSearch
852
+ onChange: handleSearch,
853
+ onKeyDown: handleKeyNav,
854
+ ref: searchInputRef
717
855
  })]
718
856
  }), /*#__PURE__*/jsxRuntime.jsx("div", {
719
857
  role: "listbox",
720
858
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
721
859
  ref: contentRef,
860
+ onKeyDown: handleKeyNav,
722
861
  children: isEmpty ? /*#__PURE__*/jsxRuntime.jsx("div", {
723
862
  className: "hangoverDropdown-content-empty",
724
863
  children: t(emptyText)
package/dist/index.esm.js CHANGED
@@ -446,9 +446,39 @@ function DropdownNavItem({
446
446
  t
447
447
  } = useDropdownContext();
448
448
  const isActive = activeNavId === id;
449
+ const buttonRef = useRef(null);
449
450
  useEffect(() => {
450
451
  registerNavLabel(id, typeof children === 'string' ? children : '');
451
452
  }, [id, children, registerNavLabel]);
453
+
454
+ // When this item becomes active (e.g. via scroll-spy on the right column),
455
+ // keep it visible inside the scrollable nav column.
456
+ useEffect(() => {
457
+ if (!isActive) return;
458
+ const el = buttonRef.current;
459
+ if (!el) return;
460
+ const container = el.closest('.hangoverDropdown-column.forNavigation');
461
+ if (!container) return;
462
+
463
+ // Leave a gap-sized breathing space so the active item never sits flush
464
+ // against the top/bottom edge of the nav column.
465
+ const navList = el.parentElement;
466
+ const gap = navList ? parseFloat(getComputedStyle(navList).rowGap || getComputedStyle(navList).gap) || 0 : 0;
467
+ const containerRect = container.getBoundingClientRect();
468
+ const elRect = el.getBoundingClientRect();
469
+ let delta = 0;
470
+ if (elRect.top < containerRect.top + gap) {
471
+ delta = elRect.top - containerRect.top - gap;
472
+ } else if (elRect.bottom > containerRect.bottom - gap) {
473
+ delta = elRect.bottom - containerRect.bottom + gap;
474
+ }
475
+ if (delta !== 0) {
476
+ container.scrollTo({
477
+ top: container.scrollTop + delta,
478
+ behavior: 'smooth'
479
+ });
480
+ }
481
+ }, [isActive]);
452
482
  function handleClick() {
453
483
  fireEvent('navChange', {
454
484
  id
@@ -494,6 +524,7 @@ function DropdownNavItem({
494
524
  }
495
525
  return /*#__PURE__*/jsxs("button", {
496
526
  type: "button",
527
+ ref: buttonRef,
497
528
  className: `hangoverDropdown-nav-item${isActive ? ' isActive' : ''}`,
498
529
  onClick: () => {
499
530
  handleClick();
@@ -643,6 +674,8 @@ function DropdownContent({
643
674
  setScrollSpyActive,
644
675
  t
645
676
  } = useDropdownContext();
677
+ const searchInputRef = useRef(null);
678
+ const bottomPadRef = useRef(0);
646
679
 
647
680
  // Scroll spy: update active nav based on scroll position
648
681
  useEffect(() => {
@@ -691,11 +724,114 @@ function DropdownContent({
691
724
  if (displayMode !== 'tab') return;
692
725
  if (contentRef.current) contentRef.current.scrollTop = 0;
693
726
  }, [displayMode, activeNavId, contentRef]);
727
+
728
+ // Scroll mode: reserve enough space at the bottom of the list so the last
729
+ // section (even a short single-entry one) can be scrolled all the way to the
730
+ // top. Without this, a short last section can never reach the top because the
731
+ // container has already hit its maximum scroll position.
732
+ useEffect(() => {
733
+ if (displayMode !== 'scroll') return;
734
+ const list = contentRef.current;
735
+ if (!list) return;
736
+ function updateBottomSpace() {
737
+ const sections = list.querySelectorAll('[data-section-for]');
738
+ const lastSection = sections[sections.length - 1];
739
+ if (!lastSection) {
740
+ list.style.paddingBottom = '';
741
+ bottomPadRef.current = 0;
742
+ return;
743
+ }
744
+
745
+ // Derive the natural content height by subtracting our own reserved
746
+ // space, so recomputes never compound. Using real geometry for the last
747
+ // section's top keeps this margin-safe (bottom padding sits after it, so
748
+ // it never shifts the section's top position).
749
+ const available = list.clientHeight;
750
+ const naturalScrollHeight = list.scrollHeight - bottomPadRef.current;
751
+ const listTop = list.getBoundingClientRect().top;
752
+ const lastTopWithinContent = lastSection.getBoundingClientRect().top - listTop + list.scrollTop;
753
+ const spaceBelowLastTop = naturalScrollHeight - lastTopWithinContent;
754
+ const pad = Math.max(0, available - spaceBelowLastTop);
755
+ bottomPadRef.current = pad;
756
+ list.style.paddingBottom = `${pad}px`;
757
+ }
758
+ updateBottomSpace();
759
+
760
+ // Observe both the container (viewport resize) and every section (group
761
+ // expand/collapse, late reflows) so the reserved space stays accurate.
762
+ const observer = new ResizeObserver(updateBottomSpace);
763
+ observer.observe(list);
764
+ list.querySelectorAll('[data-section-for]').forEach(section => {
765
+ observer.observe(section);
766
+ });
767
+ return () => observer.disconnect();
768
+ }, [displayMode, contentRef, children, searchQuery]);
694
769
  function handleSearch(e) {
695
770
  fireEvent('search', {
696
771
  query: e.target.value
697
772
  });
698
773
  }
774
+
775
+ // Keyboard navigation: ArrowDown/ArrowUp move focus across the currently
776
+ // visible items (works before and after searching, since filtered items
777
+ // are removed from the DOM). Enter is handled by each item itself.
778
+ function scrollItemIntoView(el) {
779
+ const list = contentRef.current;
780
+ if (!list || !el) return;
781
+
782
+ // Offset by the sticky section header so the item stays fully visible
783
+ // when navigating upwards.
784
+ const stickyEl = list.querySelector('.hangoverDropdown-section-title');
785
+ const stickyHeight = stickyEl ? stickyEl.offsetHeight : 0;
786
+ const listRect = list.getBoundingClientRect();
787
+ const elRect = el.getBoundingClientRect();
788
+ let delta = 0;
789
+ if (elRect.top < listRect.top + stickyHeight) {
790
+ delta = elRect.top - (listRect.top + stickyHeight);
791
+ } else if (elRect.bottom > listRect.bottom) {
792
+ delta = elRect.bottom - listRect.bottom;
793
+ }
794
+ if (delta !== 0) {
795
+ list.scrollTo({
796
+ top: list.scrollTop + delta,
797
+ behavior: 'smooth'
798
+ });
799
+ }
800
+ }
801
+ function moveItemFocus(direction) {
802
+ const list = contentRef.current;
803
+ if (!list) return;
804
+ const items = Array.from(list.querySelectorAll('.hangoverDropdown-item')).filter(el => el.offsetParent !== null);
805
+ if (items.length === 0) return;
806
+ const active = document.activeElement;
807
+ const idx = items.indexOf(active);
808
+ if (direction === 1) {
809
+ const next = idx < 0 ? items[0] : items[idx + 1];
810
+ if (next) {
811
+ next.focus({
812
+ preventScroll: true
813
+ });
814
+ scrollItemIntoView(next);
815
+ }
816
+ } else if (idx > 0) {
817
+ const prev = items[idx - 1];
818
+ prev.focus({
819
+ preventScroll: true
820
+ });
821
+ scrollItemIntoView(prev);
822
+ } else if (idx === 0 && searchInputRef.current) {
823
+ searchInputRef.current.focus();
824
+ }
825
+ }
826
+ function handleKeyNav(e) {
827
+ if (e.key === 'ArrowDown') {
828
+ e.preventDefault();
829
+ moveItemFocus(1);
830
+ } else if (e.key === 'ArrowUp') {
831
+ e.preventDefault();
832
+ moveItemFocus(-1);
833
+ }
834
+ }
699
835
  const isEmpty = Children.count(children) === 0;
700
836
  const inner = /*#__PURE__*/jsxs(Fragment, {
701
837
  children: [!isEmpty && /*#__PURE__*/jsxs("label", {
@@ -709,12 +845,15 @@ function DropdownContent({
709
845
  placeholder: t(searchPlaceholder),
710
846
  "aria-label": t(searchPlaceholder),
711
847
  value: searchQuery,
712
- onChange: handleSearch
848
+ onChange: handleSearch,
849
+ onKeyDown: handleKeyNav,
850
+ ref: searchInputRef
713
851
  })]
714
852
  }), /*#__PURE__*/jsx("div", {
715
853
  role: "listbox",
716
854
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
717
855
  ref: contentRef,
856
+ onKeyDown: handleKeyNav,
718
857
  children: isEmpty ? /*#__PURE__*/jsx("div", {
719
858
  className: "hangoverDropdown-content-empty",
720
859
  children: t(emptyText)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diabolic/hangover",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A headless-style, compound React dropdown/field-picker component library",
5
5
  "license": "MIT",
6
6
  "author": "bugrakaan",