feat: add data style attributes to popover (#62)

This commit is contained in:
Wesley Luyten
2025-10-18 20:52:35 -05:00
committed by GitHub
parent b63c22ef9f
commit 6d7325cedb
4 changed files with 112 additions and 12 deletions
@@ -8,6 +8,7 @@ export class MediaPopoverRoot extends HTMLElement {
#open = false;
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
#cleanup: (() => void) | null = null;
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
constructor() {
super();
@@ -22,6 +23,9 @@ export class MediaPopoverRoot extends HTMLElement {
disconnectedCallback(): void {
this.#clearHoverTimeout();
this.#cleanup?.();
this.#transitionStatus = 'unmounted';
this.#updateVisibility();
}
static get observedAttributes(): string[] {
@@ -60,6 +64,17 @@ export class MediaPopoverRoot extends HTMLElement {
if (this.#open === open) return;
this.#open = open;
if (open) {
this.#transitionStatus = 'initial';
requestAnimationFrame(() => {
this.#transitionStatus = 'open';
this.#updateVisibility();
});
} else {
this.#transitionStatus = 'close';
}
this.#updateVisibility();
if (open) {
@@ -71,11 +86,22 @@ export class MediaPopoverRoot extends HTMLElement {
}
#updateVisibility(): void {
this.toggleAttribute('data-open', this.#open);
this.style.display = 'contents';
if (this.#popupElement) {
this.#popupElement.style.display = this.#open ? 'block' : 'none';
const placement = this.#positionerElement?.side ?? 'top';
this.#popupElement.setAttribute('data-side', placement);
this.#popupElement.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
this.#popupElement.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
this.#popupElement.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
this.#popupElement.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
}
const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement;
if (triggerElement) {
triggerElement.setAttribute('aria-expanded', this.#open.toString());
triggerElement.toggleAttribute('data-popup-open', this.#open);
}
}
@@ -140,6 +166,43 @@ export class MediaPopoverTrigger extends HTMLElement {
if (triggerElement) {
triggerElement.setAttribute('aria-haspopup', 'true');
triggerElement.setAttribute('aria-expanded', 'false');
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes') {
const rootElement = this.closest('media-popover-root') as MediaPopoverRoot;
let popupElement = rootElement.querySelector('media-popover-popup') as MediaPopoverPopup;
if (!popupElement) {
const portalElement = rootElement.querySelector('media-popover-portal') as MediaPopoverPortal;
if (!portalElement) {
return;
}
popupElement = portalElement.querySelector('media-popover-popup') as MediaPopoverPopup;
if (!popupElement) {
return;
}
}
const attributeName = mutation.attributeName;
if (!attributeName || !attributeName.startsWith('data-')) {
return;
}
const attributeValue = triggerElement.getAttribute(attributeName);
if (attributeValue !== null) {
popupElement.setAttribute(attributeName, attributeValue);
} else {
popupElement.removeAttribute(attributeName);
}
}
});
});
mutationObserver.observe(triggerElement, {
attributes: true,
});
}
}
}
@@ -181,6 +181,19 @@ export function getTemplateHTML() {
media-popover-popup {
background: rgb(20 20 30 / .7);
padding: 14px 0;
--transition: .15s ease-in-out;
transition: transform var(--transition), scale var(--transition), opacity var(--transition);
}
media-popover-popup[data-starting-style] {
transition-duration: 0s;
transform: scale(0.9) translateY(8px);
opacity: 0;
}
media-popover-popup[data-ending-style] {
transform: scale(0.9) translateY(8px);
opacity: 0;
}
/* VolumeSlider Component Styles */
+32 -10
View File
@@ -14,6 +14,7 @@ import {
useHover,
useInteractions,
useRole,
useTransitionStatus,
} from '@floating-ui/react';
import {
@@ -36,6 +37,7 @@ interface PopoverContextType {
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps'];
context: ReturnType<typeof useFloating>['context'];
updatePositioning: (placement: Placement, sideOffset: number) => void;
transitionStatus: ReturnType<typeof useTransitionStatus>['status'];
}
interface PopoverRootProps {
@@ -89,6 +91,8 @@ function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children
whileElementsMounted: autoUpdate,
});
const { status: transitionStatus } = useTransitionStatus(context);
const hover = useHover(context, {
enabled: openOnHover,
delay: {
@@ -117,33 +121,31 @@ function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children
getFloatingProps,
context,
updatePositioning,
}), [open, refs, floatingStyles, getReferenceProps, getFloatingProps, context, updatePositioning]);
transitionStatus,
}), [open, refs, floatingStyles, getReferenceProps, getFloatingProps, context, updatePositioning, transitionStatus]);
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
}
function PopoverTrigger({ children }: PopoverTriggerProps): JSX.Element {
const { refs, getReferenceProps } = usePopoverContext();
const { refs, getReferenceProps, open } = usePopoverContext();
// eslint-disable-next-line react/no-clone-element, react/no-children-only
return cloneElement(Children.only(children) as JSX.Element, {
ref: refs.setReference,
...getReferenceProps(),
'data-popup-open': open ? '' : undefined,
});
}
function PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPositionerProps): JSX.Element | null {
const { open, refs, floatingStyles, updatePositioning } = usePopoverContext();
const { refs, floatingStyles, updatePositioning } = usePopoverContext();
// Update positioning when props change
useEffect(() => {
updatePositioning(side, sideOffset);
}, [side, sideOffset, updatePositioning]);
if (!open) {
return null;
}
return (
<div ref={refs.setFloating} style={floatingStyles}>
{children}
@@ -152,16 +154,36 @@ function PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPo
}
function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element {
const { getFloatingProps } = usePopoverContext();
const { getFloatingProps, context, transitionStatus } = usePopoverContext();
const { refs, placement } = context;
const triggerElement = refs.reference.current as HTMLElement | null;
// Copy data attributes from trigger element
const dataAttributes = triggerElement?.attributes
? Object.fromEntries(
Array.from(triggerElement.attributes)
.filter(attr => attr.name.startsWith('data-'))
.map(attr => [attr.name, attr.value]),
)
: {};
return (
<div className={className} {...getFloatingProps()}>
<div
className={className}
{...getFloatingProps()}
{...dataAttributes}
data-side={placement}
data-starting-style={transitionStatus === 'initial' ? '' : undefined}
data-open={transitionStatus === 'initial' || transitionStatus === 'open' ? '' : undefined}
data-ending-style={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined}
data-closed={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined}
>
{children}
</div>
);
}
function PopoverPortal({ children, root, rootId }: PopoverPortalProps): JSX.Element {
function PopoverPortal({ children, root, rootId = '@default_portal_id' }: PopoverPortalProps): JSX.Element {
return (
<FloatingPortal root={root as HTMLElement} id={rootId as string}>
{children}
@@ -157,6 +157,8 @@ const styles: FrostedSkinStyles = {
'relative px-1.5 py-3.5 rounded-full',
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
'ring ring-white/10 ring-inset shadow-sm shadow-black/15',
// Animation
'transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[instant]:duration-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0',
// Border to enhance contrast on lighter videos
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
// Reduced transparency for users with preference