@diabolic/hangover 0.1.5 → 0.1.7

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/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;
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();
@@ -648,6 +679,7 @@ function DropdownContent({
648
679
  t
649
680
  } = useDropdownContext();
650
681
  const searchInputRef = react.useRef(null);
682
+ const bottomPadRef = react.useRef(0);
651
683
 
652
684
  // Scroll spy: update active nav based on scroll position
653
685
  react.useEffect(() => {
@@ -696,6 +728,66 @@ function DropdownContent({
696
728
  if (displayMode !== 'tab') return;
697
729
  if (contentRef.current) contentRef.current.scrollTop = 0;
698
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
+ let rafId = null;
741
+ function computeBottomSpace() {
742
+ rafId = null;
743
+ const sections = list.querySelectorAll('[data-section-for]');
744
+ const lastSection = sections[sections.length - 1];
745
+ if (!lastSection) {
746
+ if (bottomPadRef.current !== 0) {
747
+ list.style.paddingBottom = '';
748
+ bottomPadRef.current = 0;
749
+ }
750
+ return;
751
+ }
752
+
753
+ // Derive the natural content height by subtracting our own reserved
754
+ // space, so recomputes never compound. Using real geometry for the last
755
+ // section's top keeps this margin-safe (bottom padding sits after it, so
756
+ // it never shifts the section's top position).
757
+ const available = list.clientHeight;
758
+ const naturalScrollHeight = list.scrollHeight - bottomPadRef.current;
759
+ const listTop = list.getBoundingClientRect().top;
760
+ const lastTopWithinContent = lastSection.getBoundingClientRect().top - listTop + list.scrollTop;
761
+ const spaceBelowLastTop = naturalScrollHeight - lastTopWithinContent;
762
+ const pad = Math.max(0, Math.round(available - spaceBelowLastTop));
763
+
764
+ // Only touch the DOM when the reserved space actually changes by a whole
765
+ // pixel — this ignores sub-pixel/floating jitter from the observer and
766
+ // keeps the value stable.
767
+ if (Math.abs(pad - bottomPadRef.current) < 1) return;
768
+ bottomPadRef.current = pad;
769
+ list.style.paddingBottom = `${pad}px`;
770
+ }
771
+
772
+ // Coalesce bursts of observer callbacks into a single measurement per frame.
773
+ function scheduleBottomSpace() {
774
+ if (rafId !== null) return;
775
+ rafId = requestAnimationFrame(computeBottomSpace);
776
+ }
777
+ scheduleBottomSpace();
778
+
779
+ // Observe both the container (viewport resize) and every section (group
780
+ // expand/collapse, late reflows) so the reserved space stays accurate.
781
+ const observer = new ResizeObserver(scheduleBottomSpace);
782
+ observer.observe(list);
783
+ list.querySelectorAll('[data-section-for]').forEach(section => {
784
+ observer.observe(section);
785
+ });
786
+ return () => {
787
+ if (rafId !== null) cancelAnimationFrame(rafId);
788
+ observer.disconnect();
789
+ };
790
+ }, [displayMode, contentRef, children, searchQuery]);
699
791
  function handleSearch(e) {
700
792
  fireEvent('search', {
701
793
  query: e.target.value
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();
@@ -644,6 +675,7 @@ function DropdownContent({
644
675
  t
645
676
  } = useDropdownContext();
646
677
  const searchInputRef = useRef(null);
678
+ const bottomPadRef = useRef(0);
647
679
 
648
680
  // Scroll spy: update active nav based on scroll position
649
681
  useEffect(() => {
@@ -692,6 +724,66 @@ function DropdownContent({
692
724
  if (displayMode !== 'tab') return;
693
725
  if (contentRef.current) contentRef.current.scrollTop = 0;
694
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
+ let rafId = null;
737
+ function computeBottomSpace() {
738
+ rafId = null;
739
+ const sections = list.querySelectorAll('[data-section-for]');
740
+ const lastSection = sections[sections.length - 1];
741
+ if (!lastSection) {
742
+ if (bottomPadRef.current !== 0) {
743
+ list.style.paddingBottom = '';
744
+ bottomPadRef.current = 0;
745
+ }
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, Math.round(available - spaceBelowLastTop));
759
+
760
+ // Only touch the DOM when the reserved space actually changes by a whole
761
+ // pixel — this ignores sub-pixel/floating jitter from the observer and
762
+ // keeps the value stable.
763
+ if (Math.abs(pad - bottomPadRef.current) < 1) return;
764
+ bottomPadRef.current = pad;
765
+ list.style.paddingBottom = `${pad}px`;
766
+ }
767
+
768
+ // Coalesce bursts of observer callbacks into a single measurement per frame.
769
+ function scheduleBottomSpace() {
770
+ if (rafId !== null) return;
771
+ rafId = requestAnimationFrame(computeBottomSpace);
772
+ }
773
+ scheduleBottomSpace();
774
+
775
+ // Observe both the container (viewport resize) and every section (group
776
+ // expand/collapse, late reflows) so the reserved space stays accurate.
777
+ const observer = new ResizeObserver(scheduleBottomSpace);
778
+ observer.observe(list);
779
+ list.querySelectorAll('[data-section-for]').forEach(section => {
780
+ observer.observe(section);
781
+ });
782
+ return () => {
783
+ if (rafId !== null) cancelAnimationFrame(rafId);
784
+ observer.disconnect();
785
+ };
786
+ }, [displayMode, contentRef, children, searchQuery]);
695
787
  function handleSearch(e) {
696
788
  fireEvent('search', {
697
789
  query: e.target.value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diabolic/hangover",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A headless-style, compound React dropdown/field-picker component library",
5
5
  "license": "MIT",
6
6
  "author": "bugrakaan",