@diabolic/hangover 0.1.5 → 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/dist/hangover.css +28 -1
- package/dist/index.cjs.js +74 -0
- package/dist/index.esm.js +74 -0
- package/package.json +1 -1
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
|
-
|
|
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,48 @@ 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
|
+
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]);
|
|
699
773
|
function handleSearch(e) {
|
|
700
774
|
fireEvent('search', {
|
|
701
775
|
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,48 @@ 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
|
+
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]);
|
|
695
769
|
function handleSearch(e) {
|
|
696
770
|
fireEvent('search', {
|
|
697
771
|
query: e.target.value
|