Files
klz-cables.com/.pnpm-store/v10/files/11/7b57d40478494d5bbde2bac0817ad4da432218b47502ea42c5e412c3ad992a0b91c9292f0d31fd189b5fcad665ab760b81f0155cb9a6e30879bdd122589aca
Marc Mintel 5397309103
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
fix(products): fix breadcrumbs and product filtering (backport from main)
2026-02-24 16:04:21 +01:00

314 lines
13 KiB
Plaintext

'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export * as PopupList from './PopupButtonList/index.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useEffectEvent } from '../../hooks/useEffectEvent.js';
import './index.scss';
import { PopupTrigger } from './PopupTrigger/index.js';
const baseClass = 'popup';
/**
* Selector for all elements the browser considers tabbable.
*/
const TABBABLE_SELECTOR = ['a[href]', 'button:not(:disabled)', 'input:not(:disabled):not([type="hidden"])', 'select:not(:disabled)', 'textarea:not(:disabled)', '[tabindex]', '[contenteditable]:not([contenteditable="false"])', 'audio[controls]', 'video[controls]', 'summary'].map(s => `${s}:not([tabindex="-1"])`).join(', ');
/**
* Component that renders a popup, as well as a button that triggers the popup.
*
* The popup is rendered in a portal, and is automatically positioned above / below the trigger,
* depending on the verticalAlign prop and the space available.
*/
export const Popup = props => {
const {
id,
button,
buttonClassName,
buttonSize,
buttonType = 'default',
caret = true,
children,
className,
disabled,
forceOpen,
horizontalAlign = 'left',
initActive = false,
noBackground,
onToggleClose,
onToggleOpen,
portalClassName,
render,
showOnHover = false,
showScrollbar = false,
size = 'medium',
verticalAlign = 'bottom'
} = props;
const popupRef = useRef(null);
const triggerRef = useRef(null);
/**
* Keeps track of whether the popup was opened via keyboard.
* This is used to determine whether to autofocus the first element in the popup.
* If the popup was opened via mouse, we do not want to autofocus the first element.
*/
const openedViaKeyboardRef = useRef(false);
const [mounted, setMounted] = useState(false);
const [active, setActiveInternal] = useState(initActive);
const [isOnTop, setIsOnTop] = useState(verticalAlign === 'top');
// Track when component is mounted to avoid SSR/client hydration mismatch
useEffect(() => {
setMounted(true);
}, []);
const setActive = useCallback((isActive, viaKeyboard = false) => {
if (isActive) {
openedViaKeyboardRef.current = viaKeyboard;
onToggleOpen?.(true);
} else {
onToggleClose?.();
}
setActiveInternal(isActive);
}, [onToggleClose, onToggleOpen]);
// /////////////////////////////////////
// Position Calculation
//
// Calculates and applies popup position relative to trigger.
// Always checks viewport bounds (for flipping), but only updates
// styles if the calculated position differs from current position.
// /////////////////////////////////////
const updatePosition = useEffectEvent(() => {
const trigger = triggerRef.current;
const popup = popupRef.current;
if (!trigger || !popup) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
// Gap between the popup and the trigger/viewport edges (in pixels)
const offset = 10;
// /////////////////////////////////////
// Vertical Positioning
// Calculates the `top` position in absolute page coordinates.
// Uses `verticalAlign` prop as the preferred direction, but flips
// to the opposite side if there's not enough viewport space.
// /////////////////////////////////////
let top;
let onTop = verticalAlign === 'top';
if (verticalAlign === 'bottom') {
top = triggerRect.bottom + window.scrollY + offset;
if (triggerRect.bottom + popupRect.height + offset > window.innerHeight) {
top = triggerRect.top + window.scrollY - popupRect.height - offset;
onTop = true;
}
} else {
top = triggerRect.top + window.scrollY - popupRect.height - offset;
if (triggerRect.top - popupRect.height - offset < 0) {
top = triggerRect.bottom + window.scrollY + offset;
onTop = false;
}
}
setIsOnTop(onTop);
// /////////////////////////////////////
// Horizontal Positioning
// Calculates the `left` position based on `horizontalAlign` prop:
// - 'left': aligns popup's left edge with trigger's left edge
// - 'right': aligns popup's right edge with trigger's right edge
// - 'center': centers popup horizontally relative to trigger
// Then clamps to keep the popup within viewport bounds.
// /////////////////////////////////////
let left = horizontalAlign === 'right' ? triggerRect.right - popupRect.width : horizontalAlign === 'center' ? triggerRect.left + triggerRect.width / 2 - popupRect.width / 2 : triggerRect.left;
left = Math.max(offset, Math.min(left, window.innerWidth - popupRect.width - offset));
// /////////////////////////////////////
// Caret Positioning
// Positions the caret arrow to point at the trigger's horizontal center.
// Clamps between 12px from edges to prevent caret from overflowing the popup.
// /////////////////////////////////////
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const caretLeft = Math.max(12, Math.min(triggerCenter - left, popupRect.width - 12));
// /////////////////////////////////////
// Apply Styles (only if changed)
// Compares calculated position with current styles to avoid unnecessary
// DOM updates during scroll. This prevents visual lag by relying on the absolute
// positioning where possible (popup slightly lags behind when scrolling really fast),
// while still allowing position changes when needed (e.g., sticky parent, viewport flip).
// Values are rounded to match browser's CSS precision and avoid false updates.
// /////////////////////////////////////
const newTop = `${Math.round(top)}px`;
const newLeft = `${Math.round(left + window.scrollX)}px`;
const newCaretLeft = `${Math.round(caretLeft)}px`;
if (popup.style.top !== newTop) {
popup.style.top = newTop;
}
if (popup.style.left !== newLeft) {
popup.style.left = newLeft;
}
if (popup.style.getPropertyValue('--caret-left') !== newCaretLeft) {
popup.style.setProperty('--caret-left', newCaretLeft);
}
});
// /////////////////////////////////////
// Click Outside Handler
// Closes popup when clicking outside both the popup and trigger.
// /////////////////////////////////////
const handleClickOutside = useEffectEvent(e => {
const isOutsidePopup = !popupRef.current?.contains(e.target);
const isOutsideTrigger = !triggerRef.current?.contains(e.target);
if (isOutsidePopup && isOutsideTrigger) {
setActive(false);
}
});
// /////////////////////////////////////
// Keyboard Navigation
// Handles keyboard interactions when popup is open:
// - Escape: closes popup and returns focus to trigger
// - Tab/Shift+Tab: cycles through focusable items with wrapping
// - ArrowUp/ArrowDown: same as Shift+Tab/Tab for menu-style navigation
// Focus is managed manually to support elements the browser might skip.
// /////////////////////////////////////
const handleKeyDown = useEffectEvent(e_0 => {
const popup_0 = popupRef.current;
if (!popup_0 || !active) {
return;
}
if (e_0.key === 'Escape') {
e_0.preventDefault();
setActive(false);
triggerRef.current?.querySelector('button, [tabindex="0"]')?.focus();
return;
}
if (e_0.key === 'Tab' || e_0.key === 'ArrowDown' || e_0.key === 'ArrowUp') {
const focusable = Array.from(popup_0.querySelectorAll(TABBABLE_SELECTOR));
if (focusable.length === 0) {
return;
}
e_0.preventDefault();
const currentIndex = focusable.findIndex(el => el === document.activeElement);
const goBackward = e_0.key === 'ArrowUp' || e_0.key === 'Tab' && e_0.shiftKey;
let nextIndex;
if (currentIndex === -1) {
nextIndex = goBackward ? focusable.length - 1 : 0;
} else if (goBackward) {
nextIndex = currentIndex === 0 ? focusable.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex === focusable.length - 1 ? 0 : currentIndex + 1;
}
focusable[nextIndex].focus();
}
});
// /////////////////////////////////////
// Click Handler for Actionable Elements
// Closes popup when buttons/links inside are clicked (includes Enter/Space activation).
// /////////////////////////////////////
const handleActionableClick = useEffectEvent(e_1 => {
const target = e_1.target;
// Allow opting out with data-popup-prevent-close attribute on element or ancestor
if (target.closest('[data-popup-prevent-close]')) {
return;
}
// Check if the clicked element or any ancestor is an actionable element
const actionable = target.closest('button, a[href], [role="button"], [role="menuitem"]');
if (actionable && popupRef.current?.contains(actionable)) {
setActive(false);
}
});
// /////////////////////////////////////
// Effect: Setup/Teardown position and focus management
// /////////////////////////////////////
useEffect(() => {
if (!active) {
return;
}
const popup_1 = popupRef.current;
if (!popup_1) {
return;
}
// /////////////////////////////////////
// Initial Position
// Calculate and apply popup position immediately on open.
// /////////////////////////////////////
updatePosition();
// /////////////////////////////////////
// Focus Management
// When opened via keyboard, autofocus the first focusable button.
// When opened via mouse, skip autofocus to avoid unwanted highlights.
// /////////////////////////////////////
if (openedViaKeyboardRef.current) {
// Use requestAnimationFrame to ensure DOM is ready.
requestAnimationFrame(() => {
const firstFocusable = popup_1.querySelector(TABBABLE_SELECTOR);
firstFocusable?.focus();
});
}
// /////////////////////////////////////
// Event Listeners
// - resize/scroll: recalculate position (only applies styles if changed)
// - mousedown: detect clicks outside to close
// - keydown: handle keyboard navigation
// /////////////////////////////////////
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, {
capture: true,
passive: true
});
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
popup_1.addEventListener('click', handleActionableClick);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, {
capture: true
});
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
popup_1.removeEventListener('click', handleActionableClick);
};
}, [active]);
useEffect(() => {
if (forceOpen !== undefined) {
setActive(forceOpen);
}
}, [forceOpen, setActive]);
const Trigger = /*#__PURE__*/_jsx(PopupTrigger, {
active: active,
button: button,
buttonType: buttonType,
className: buttonClassName,
disabled: disabled,
noBackground: noBackground,
setActive: setActive,
size: buttonSize
});
return /*#__PURE__*/_jsxs("div", {
className: [baseClass, className].filter(Boolean).join(' '),
id: id,
children: [/*#__PURE__*/_jsx("div", {
className: `${baseClass}__trigger-wrap`,
ref: triggerRef,
children: showOnHover ? /*#__PURE__*/_jsx("div", {
className: `${baseClass}__on-hover-watch`,
onMouseEnter: () => setActive(true),
onMouseLeave: () => setActive(false),
role: "button",
tabIndex: 0,
children: Trigger
}) : Trigger
}), mounted ?
// This ensures that components within the popup, like modals, do not unmount when the popup closes.
// Otherwise, modals opened from the popup will close unexpectedly when clicking within the modal, since
// that closes the popup due to the click outside handler.
/*#__PURE__*/
createPortal(/*#__PURE__*/_jsxs("div", {
className: active ? [`${baseClass}__content`, `${baseClass}--size-${size}`, isOnTop ? `${baseClass}--v-top` : `${baseClass}--v-bottom`, portalClassName].filter(Boolean).join(' ') :
// tests do not accidentally target inactive popups.
`${baseClass}__hidden-content`,
"data-popup-id": id || undefined,
ref: popupRef,
children: [/*#__PURE__*/_jsxs("div", {
className: `${baseClass}__scroll-container${showScrollbar ? ` ${baseClass}__scroll-container--show-scrollbar` : ''}`,
children: [render?.({
close: () => setActive(false)
}), children]
}), caret && /*#__PURE__*/_jsx("div", {
className: `${baseClass}__caret`
})]
}), document.body) : null]
});
};
//# sourceMappingURL=index.js.map