From 6d7325cedb182c37b955e729d32204e4afbba948 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Sat, 18 Oct 2025 20:52:35 -0500 Subject: [PATCH] feat: add data style attributes to popover (#62) --- .../html/html/src/components/media-popover.ts | 67 ++++++++++++++++++- .../html/html/src/skins/media-skin-default.ts | 13 ++++ .../react/react/src/components/Popover.tsx | 42 +++++++++--- .../react/react/src/skins/frosted/styles.ts | 2 + 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts index 1e578887..b02f26ff 100644 --- a/packages/html/html/src/components/media-popover.ts +++ b/packages/html/html/src/components/media-popover.ts @@ -8,6 +8,7 @@ export class MediaPopoverRoot extends HTMLElement { #open = false; #hoverTimeout: ReturnType | 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, + }); } } } diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 61328e04..8d116fad 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -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 */ diff --git a/packages/react/react/src/components/Popover.tsx b/packages/react/react/src/components/Popover.tsx index 44da2672..17ff5ed8 100644 --- a/packages/react/react/src/components/Popover.tsx +++ b/packages/react/react/src/components/Popover.tsx @@ -14,6 +14,7 @@ import { useHover, useInteractions, useRole, + useTransitionStatus, } from '@floating-ui/react'; import { @@ -36,6 +37,7 @@ interface PopoverContextType { getFloatingProps: ReturnType['getFloatingProps']; context: ReturnType['context']; updatePositioning: (placement: Placement, sideOffset: number) => void; + transitionStatus: ReturnType['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 {children}; } 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 (
{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 ( -
+
{children}
); } -function PopoverPortal({ children, root, rootId }: PopoverPortalProps): JSX.Element { +function PopoverPortal({ children, root, rootId = '@default_portal_id' }: PopoverPortalProps): JSX.Element { return ( {children} diff --git a/packages/react/react/src/skins/frosted/styles.ts b/packages/react/react/src/skins/frosted/styles.ts index c06db070..1f044ccf 100644 --- a/packages/react/react/src/skins/frosted/styles.ts +++ b/packages/react/react/src/skins/frosted/styles.ts @@ -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