Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/UniqueProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const UniqueProvider = ({
children,
postTriggerProps,
}: UniqueProviderProps) => {
const [trigger, open, options, onTargetVisibleChanged] = useTargetState();
const [trigger, open, options, onTargetVisibleChanged, releaseTarget] =
useTargetState();

// ========================== Options ===========================
const mergedOptions = React.useMemo(() => {
Expand Down Expand Up @@ -55,34 +56,55 @@ const UniqueProvider = ({
});

// ========================== Register ==========================
// Store the isOpen function from the latest show call
const isOpenRef = React.useRef<(() => boolean) | null>(null);
const ownerRef = React.useRef<string | null>(null);
const delayedOwnerRef = React.useRef<string | null>(null);
const isOpenRef = React.useRef(new Map<string, () => boolean>());

const delayInvoke = useDelay();

const show = useEvent(
(showOptions: UniqueShowOptions, isOpen: () => boolean) => {
// Store the isOpen function for later use in hide
isOpenRef.current = isOpen;
ownerRef.current = showOptions.id;
isOpenRef.current.set(showOptions.id, isOpen);

delayedOwnerRef.current = showOptions.id;
delayInvoke(() => {
delayedOwnerRef.current = null;
trigger(showOptions);
}, showOptions.delay);
},
);

const hide = (delay: number) => {
const hide = useEvent((id: string, delay: number) => {
if (ownerRef.current !== id) {
return;
}

delayedOwnerRef.current = id;
delayInvoke(() => {
// Check if we should still hide by calling the isOpen function
// If isOpen returns true, it means another trigger wants to keep it open
if (isOpenRef.current?.()) {
return; // Don't hide if something else wants it open
delayedOwnerRef.current = null;
// A controlled owner may still need to keep its popup open.
if (ownerRef.current !== id || isOpenRef.current.get(id)?.()) {
return;
}

trigger(false);
// Don't clear target, currentNode, options immediately, wait until animation completes
}, delay);
};
});

const release = useEvent((id: string) => {
// Another owner may already have a show or hide scheduled.
if (delayedOwnerRef.current === id) {
delayInvoke(() => {}, 0);
delayedOwnerRef.current = null;
}
isOpenRef.current.delete(id);
const remainingOwner = releaseTarget(id);
if (ownerRef.current === id) {
ownerRef.current = remainingOwner ?? null;
}
});

// Callback after animation completes
const onVisibleChanged = useEvent((visible: boolean) => {
Expand Down Expand Up @@ -142,8 +164,9 @@ const UniqueProvider = ({
() => ({
show,
hide,
release,
}),
[],
[show, hide, release],
);

// =========================== Align ============================
Expand Down
37 changes: 33 additions & 4 deletions src/UniqueProvider/useTargetState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,34 @@ export default function useTargetState(): [
/* Will always cache last which is not null */
cacheOptions: UniqueShowOptions | null,
onVisibleChanged: (visible: boolean) => void,
release: (id: string) => string | undefined,
] {
const [options, setOptions] = React.useState<UniqueShowOptions | null>(null);
const [open, setOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
// Unlike cached options, ownership must update between batched cleanups.
const currentOptionsRef = React.useRef<UniqueShowOptions | null>(null);
const pendingOptionsRef = React.useRef<UniqueShowOptions | null>(null);

const updateOptions = (nextOptions: UniqueShowOptions) => {
currentOptionsRef.current = nextOptions;
setOptions(nextOptions);
};

const trigger = useEvent((nextOptions: UniqueShowOptions | false) => {
if (nextOptions === false) {
// Clear pending options when hiding
pendingOptionsRef.current = null;
currentOptionsRef.current = null;
setOpen(false);
} else {
if (isAnimating && open) {
if (isAnimating && open && currentOptionsRef.current) {
// If animating (appear or enter), cache new options
pendingOptionsRef.current = nextOptions;
} else {
setOpen(true);
// Set new options
setOptions(nextOptions);
updateOptions(nextOptions);
pendingOptionsRef.current = null;

// Only mark as animating when transitioning from closed to open
Expand All @@ -52,7 +61,7 @@ export default function useTargetState(): [
setIsAnimating(false);
if (pendingOptionsRef.current) {
// Apply pending options
setOptions(pendingOptionsRef.current);
updateOptions(pendingOptionsRef.current);
pendingOptionsRef.current = null;
}
} else {
Expand All @@ -62,5 +71,25 @@ export default function useTargetState(): [
}
});

return [trigger, open, options, onVisibleChanged];
const release = useEvent((id: string) => {
if (pendingOptionsRef.current?.id === id) {
pendingOptionsRef.current = null;
}

if (currentOptionsRef.current?.id === id) {
const nextOptions = pendingOptionsRef.current;
pendingOptionsRef.current = null;
if (nextOptions) {
updateOptions(nextOptions);
} else {
currentOptionsRef.current = null;
setOpen(false);
}
return nextOptions?.id;
}

return pendingOptionsRef.current?.id ?? currentOptionsRef.current?.id;
});

return [trigger, open, options, onVisibleChanged, release];
}
3 changes: 2 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export interface UniqueShowOptions {

export interface UniqueContextProps {
show: (options: UniqueShowOptions, isOpen: () => boolean) => void;
hide: (delay: number) => void;
hide: (id: string, delay: number) => void;
release: (id: string) => void;
}

export const UniqueContext = React.createContext<UniqueContextProps | null>(
Expand Down
12 changes: 10 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,14 @@ export function generateTrigger(
onEsc,
}));

useLayoutEffect(() => {
return () => {
if (unique && !parentContext) {
uniqueContext?.release(id);
}
};
}, [uniqueContext, unique, parentContext, id]);

// Handle controlled state changes for UniqueProvider
// Only sync to UniqueProvider when it's controlled mode
// If there is a parentContext, don't call uniqueContext methods
Expand All @@ -378,7 +386,7 @@ export function generateTrigger(
if (mergedOpen) {
uniqueContext.show(getUniqueOptions(mouseEnterDelay), isOpen);
} else {
uniqueContext.hide(mouseLeaveDelay);
uniqueContext.hide(id, mouseLeaveDelay);
}
}
}, [mergedOpen, targetEle]);
Expand Down Expand Up @@ -415,7 +423,7 @@ export function generateTrigger(
if (nextOpen) {
uniqueContext.show(getUniqueOptions(delay), isOpen);
} else {
uniqueContext.hide(delay);
uniqueContext.hide(id, delay);
}
return;
}
Expand Down
Loading