mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add popover React component (#33)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]'),
|
||||
|
||||
Reference in New Issue
Block a user