feat: add popover React component (#33)

This commit is contained in:
Wesley Luyten
2025-10-02 11:03:44 -05:00
committed by GitHub
parent 7b79854304
commit f775a3daf7
8 changed files with 332 additions and 90 deletions
+3 -2
View File
@@ -31,6 +31,7 @@
],
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/react": "^0.27.16",
"@vjs-10/core": "workspace:*",
"@vjs-10/media": "workspace:*",
"@vjs-10/media-store": "workspace:*",
@@ -42,8 +43,8 @@
"react": ">=16.8.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"react": "^18.0.0",
"@types/react": "^19.0.0",
"react": "^19.0.0",
"tsdown": "^0.15.4",
"typescript": "^5.9.2"
},
@@ -0,0 +1,158 @@
import type { Placement } from '@floating-ui/react';
import type { ReactNode } from 'react';
import React, { createContext, useContext, useState } from 'react';
import {
autoUpdate,
flip,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
} from '@floating-ui/react';
interface PopoverContextType {
open: boolean;
setOpen: (open: boolean) => void;
refs: ReturnType<typeof useFloating>['refs'];
floatingStyles: ReturnType<typeof useFloating>['floatingStyles'];
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps'];
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps'];
context: ReturnType<typeof useFloating>['context'];
updatePositioning: (placement: Placement, sideOffset: number) => void;
}
interface PopoverRootProps {
openOnHover?: boolean;
delay?: number;
closeDelay?: number;
children: ReactNode;
}
interface PopoverTriggerProps {
children: ReactNode;
}
interface PopoverPositionerProps {
side?: Placement;
sideOffset?: number;
children: ReactNode;
}
interface PopoverPopupProps {
className?: string;
children: ReactNode;
}
const PopoverContext = createContext<PopoverContextType | null>(null);
function usePopoverContext(): PopoverContextType {
const context = useContext(PopoverContext);
if (!context) {
throw new Error('Popover components must be used within PopoverRoot');
}
return context;
}
function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children }: PopoverRootProps): JSX.Element {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<Placement>('top');
const [sideOffset, setSideOffset] = useState(5);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware: [offset(sideOffset), flip(), shift()],
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, {
enabled: openOnHover,
delay: {
open: delay,
close: closeDelay,
},
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
const updatePositioning = (newPlacement: Placement, newSideOffset: number) => {
setPlacement(newPlacement);
setSideOffset(newSideOffset);
};
const value: PopoverContextType = {
open,
setOpen,
refs,
floatingStyles,
getReferenceProps,
getFloatingProps,
context,
updatePositioning,
};
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
}
function PopoverTrigger({ children }: PopoverTriggerProps): JSX.Element {
const { refs, getReferenceProps } = usePopoverContext();
return React.cloneElement(React.Children.only(children) as JSX.Element, {
ref: refs.setReference,
...getReferenceProps(),
});
}
function PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPositionerProps): JSX.Element | null {
const { open, refs, floatingStyles, updatePositioning } = usePopoverContext();
// Update positioning when props change
React.useEffect(() => {
updatePositioning(side, sideOffset);
}, [side, sideOffset, updatePositioning]);
if (!open) {
return null;
}
return (
<div ref={refs.setFloating} style={floatingStyles}>
{children}
</div>
);
}
function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element {
const { getFloatingProps } = usePopoverContext();
return (
<div className={className} {...getFloatingProps()}>
{children}
</div>
);
}
// Export compound component
export const Popover: {
Root: typeof PopoverRoot;
Trigger: typeof PopoverTrigger;
Positioner: typeof PopoverPositioner;
Popup: typeof PopoverPopup;
} = {
Root: PopoverRoot,
Trigger: PopoverTrigger,
Positioner: PopoverPositioner,
Popup: PopoverPopup,
};
export default Popover;
@@ -54,6 +54,7 @@ export const useTimeRangeRootProps = (props: TimeRange.Props, state: TimeRange.S
return {
ref: useCallback((el: HTMLDivElement) => {
if (!el) return;
state.core?.attach(el);
}, []),
id,
@@ -58,6 +58,7 @@ export const useVolumeRangeRootProps = (props: VolumeRange.Props, state: VolumeR
return {
ref: useCallback((el: HTMLDivElement) => {
if (!el) return;
state.core?.attach(el);
}, []),
id,
+1
View File
@@ -9,3 +9,4 @@ export { VolumeRange } from './components/VolumeRange';
export { FullscreenButton } from './components/FullscreenButton';
export { DurationDisplay } from './components/DurationDisplay';
export { CurrentTimeDisplay } from './components/CurrentTimeDisplay';
export { Popover } from './components/Popover';
@@ -15,6 +15,7 @@ import { DurationDisplay } from '../../components/DurationDisplay';
import { FullscreenButton } from '../../components/FullscreenButton';
import { MediaContainer } from '../../components/MediaContainer';
import MuteButton from '../../components/MuteButton';
import { Popover } from '../../components/Popover';
import PlayButton from '../../components/PlayButton';
import { TimeRange } from '../../components/TimeRange';
import { VolumeRange } from '../../components/VolumeRange';
@@ -56,18 +57,25 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
<DurationDisplay className={styles.TimeDisplay} />
</div>
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.VolumeButton}`}>
<VolumeHighIcon className={styles.VolumeHighIcon} />
<VolumeLowIcon className={styles.VolumeLowIcon} />
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
<VolumeRange.Root className={styles.VolumeRangeRoot} orientation="vertical">
<VolumeRange.Track className={styles.VolumeRangeTrack}>
<VolumeRange.Progress className={styles.VolumeRangeProgress} />
</VolumeRange.Track>
<VolumeRange.Thumb className={styles.VolumeRangeThumb} />
</VolumeRange.Root>
<Popover.Root openOnHover delay={200} closeDelay={100}>
<Popover.Trigger>
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.VolumeButton}`}>
<VolumeHighIcon className={styles.VolumeHighIcon} />
<VolumeLowIcon className={styles.VolumeLowIcon} />
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
</Popover.Trigger>
<Popover.Positioner side="top" sideOffset={8}>
<Popover.Popup className={styles.VolumePopup}>
<VolumeRange.Root className={styles.VolumeRangeRoot} orientation="vertical">
<VolumeRange.Track className={styles.VolumeRangeTrack}>
<VolumeRange.Progress className={styles.VolumeRangeProgress} />
</VolumeRange.Track>
<VolumeRange.Thumb className={styles.VolumeRangeThumb} />
</VolumeRange.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
@@ -26,6 +26,7 @@ export interface MediaDefaultSkinStyles {
readonly TimeRangeProgress: string;
readonly TimeRangePointer: string;
readonly TimeRangeThumb: string;
readonly VolumePopup: string;
readonly VolumeRangeRoot: string;
readonly VolumeRangeTrack: string;
readonly VolumeRangeProgress: string;
@@ -139,6 +140,17 @@ const styles: MediaDefaultSkinStyles = {
'group-hover/slider:opacity-100 group-focus-within/slider:opacity-100',
'size-2.5 active:size-3 group-active/slider:size-3'
),
VolumePopup: cn(
'relative z-30 px-2 py-4 rounded-2xl',
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
'ring ring-white/10 ring-inset shadow-sm shadow-black/15',
// 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
'reduced-transparency:bg-black/70 reduced-transparency:ring-black reduced-transparency:after:ring-white/20',
// High contrast mode
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
),
VolumeRangeRoot: cn('flex [&[data-orientation="horizontal"]]:w-20 [&[data-orientation="horizontal"]]:h-5 [&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20 items-center justify-center group/slider relative'),
VolumeRangeTrack: cn('[&[data-orientation="horizontal"]]:h-1 [&[data-orientation="vertical"]]:w-1 w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5'),
VolumeRangeProgress: cn('bg-white rounded-[inherit]'),