Skip to content

Commit 9335127

Browse files
committed
feat: support disabled prop
1 parent 8e1a491 commit 9335127

5 files changed

Lines changed: 270 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Then open `http://localhost:8000`.
7575
| blurDelay | Delay before hiding on blur, in seconds. | `number` | - |
7676
| builtinPlacements | Named placement presets. | `BuildInPlacements` | `{}` |
7777
| defaultPopupVisible | Initial uncontrolled visibility. | `boolean` | `false` |
78+
| disabled | Temporarily suppress popup visibility without resetting the current open state. | `boolean` | `false` |
7879
| focusDelay | Delay before showing on focus, in seconds. | `number` | - |
7980
| forceRender | Render popup before it is first shown. | `boolean` | `false` |
8081
| fresh | Keep popup content updated while closed. | `boolean` | - |

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ npm start
7575
| blurDelay | `blur` 后隐藏前的延迟,单位为秒。 | `number` | - |
7676
| builtinPlacements | 命名位置预设。 | `BuildInPlacements` | `{}` |
7777
| defaultPopupVisible | 非受控初始显示状态。 | `boolean` | `false` |
78+
| disabled | 临时隐藏弹层,但不主动重置当前打开状态。 | `boolean` | `false` |
7879
| focusDelay | `focus` 后显示前的延迟,单位为秒。 | `number` | - |
7980
| forceRender | 首次显示前渲染弹层。 | `boolean` | `false` |
8081
| fresh | 关闭时仍保持弹层内容更新。 | `boolean` | - |

src/index.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export interface TriggerProps {
6565
action?: ActionType | ActionType[];
6666
showAction?: ActionType[];
6767
hideAction?: ActionType[];
68+
/** Temporarily suppress popup visibility without resetting the current open state. */
69+
disabled?: boolean;
6870

6971
prefixCls?: string;
7072

@@ -159,6 +161,7 @@ export function generateTrigger(
159161
action = 'hover',
160162
showAction,
161163
hideAction,
164+
disabled = false,
162165

163166
// Open
164167
popupVisible,
@@ -319,7 +322,10 @@ export function generateTrigger(
319322
popupVisible,
320323
);
321324

322-
const mergedOpen = internalOpen || false;
325+
const rawOpen = internalOpen || false;
326+
const mergedOpen = rawOpen && !disabled;
327+
328+
const uniqueOpenRef = React.useRef(false);
323329

324330
// ========================== Children ==========================
325331
const child = React.useMemo(() => {
@@ -333,7 +339,11 @@ export function generateTrigger(
333339
const originChildProps = child?.props || {};
334340

335341
// Support ref
336-
const isOpen = useEvent(() => mergedOpen);
342+
const isOpen = useEvent(() =>
343+
uniqueContext && unique && openUncontrolled && !parentContext
344+
? uniqueOpenRef.current && !disabled
345+
: mergedOpen,
346+
);
337347

338348
// Extract common options for UniqueProvider
339349
const getUniqueOptions = useEvent((delay: number = 0) => ({
@@ -360,31 +370,35 @@ export function generateTrigger(
360370
onEsc,
361371
}));
362372

363-
// Handle controlled state changes for UniqueProvider
364-
// Only sync to UniqueProvider when it's controlled mode
373+
// Sync controlled state and disabled changes to UniqueProvider
365374
// If there is a parentContext, don't call uniqueContext methods
366375
useLayoutEffect(() => {
367-
if (
368-
uniqueContext &&
369-
unique &&
370-
targetEle &&
371-
!openUncontrolled &&
372-
!parentContext
373-
) {
374-
if (mergedOpen) {
375-
uniqueContext.show(getUniqueOptions(mouseEnterDelay), isOpen);
376+
if (uniqueContext && unique && targetEle && !parentContext) {
377+
const uniqueOpen = openUncontrolled
378+
? uniqueOpenRef.current && !disabled
379+
: mergedOpen;
380+
381+
if (uniqueOpen) {
382+
uniqueContext.show(
383+
getUniqueOptions(openUncontrolled ? 0 : mouseEnterDelay),
384+
isOpen,
385+
);
376386
} else {
377-
uniqueContext.hide(mouseLeaveDelay);
387+
uniqueContext.hide(disabled ? 0 : mouseLeaveDelay);
378388
}
379389
}
380-
}, [mergedOpen, targetEle]);
390+
}, [mergedOpen, targetEle, disabled]);
381391

382-
const openRef = React.useRef(mergedOpen);
383-
openRef.current = mergedOpen;
392+
const openRef = React.useRef(rawOpen);
393+
openRef.current = rawOpen;
384394

385395
const internalTriggerOpen = useEvent((nextOpen: boolean) => {
396+
if (disabled && nextOpen) {
397+
return;
398+
}
399+
386400
flushSync(() => {
387-
if (mergedOpen !== nextOpen) {
401+
if (rawOpen !== nextOpen) {
388402
setInternalOpen(nextOpen);
389403
onOpenChange?.(nextOpen);
390404
onPopupVisibleChange?.(nextOpen);
@@ -396,6 +410,10 @@ export function generateTrigger(
396410
const delayInvoke = useDelay();
397411

398412
const triggerOpen = (nextOpen: boolean, delay = 0) => {
413+
if (disabled && nextOpen) {
414+
return;
415+
}
416+
399417
// If it's controlled mode, always use internal trigger logic
400418
// UniqueProvider will be synced through useLayoutEffect
401419
if (popupVisible !== undefined) {
@@ -408,6 +426,8 @@ export function generateTrigger(
408426
// If UniqueContext exists and not controlled, pass delay to Provider instead of handling it internally
409427
// If there is a parentContext, don't call uniqueContext methods
410428
if (uniqueContext && unique && openUncontrolled && !parentContext) {
429+
uniqueOpenRef.current = nextOpen;
430+
411431
if (nextOpen) {
412432
uniqueContext.show(getUniqueOptions(delay), isOpen);
413433
} else {
@@ -645,7 +665,7 @@ export function generateTrigger(
645665

646666
// Click to hide is special action since click popup element should not hide
647667
const onPopupPointerDown = useWinClick(
648-
mergedOpen,
668+
rawOpen,
649669
clickToHide || touchToHide,
650670
targetEle,
651671
popupEle,

tests/basic.test.jsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,81 @@ describe('Trigger.Basic', () => {
179179
trigger(document, '.rc-trigger-popup', 'pointerEnter');
180180
expect(isPopupHidden()).toBeFalsy();
181181
});
182+
183+
it('temporarily hides while disabled and restores without mouse leave', () => {
184+
const onOpenChange = jest.fn();
185+
const Demo = ({ disabled = false }) => (
186+
<Trigger
187+
action={['hover']}
188+
disabled={disabled}
189+
onOpenChange={onOpenChange}
190+
popup={<strong>trigger</strong>}
191+
>
192+
<div className="target">hover</div>
193+
</Trigger>
194+
);
195+
196+
const { container, rerender } = render(<Demo />);
197+
198+
trigger(container, '.target', 'mouseEnter');
199+
expect(isPopupHidden()).toBeFalsy();
200+
expect(onOpenChange).toHaveBeenCalledWith(true);
201+
onOpenChange.mockReset();
202+
203+
rerender(<Demo disabled />);
204+
expect(isPopupHidden()).toBeTruthy();
205+
expect(onOpenChange).not.toHaveBeenCalled();
206+
207+
rerender(<Demo />);
208+
expect(isPopupHidden()).toBeFalsy();
209+
expect(onOpenChange).not.toHaveBeenCalled();
210+
});
211+
212+
it('does not restore after mouse leave while disabled', () => {
213+
const Demo = ({ disabled = false }) => (
214+
<Trigger
215+
action={['hover']}
216+
disabled={disabled}
217+
popup={<strong>trigger</strong>}
218+
>
219+
<div className="target">hover</div>
220+
</Trigger>
221+
);
222+
223+
const { container, rerender } = render(<Demo />);
224+
225+
trigger(container, '.target', 'mouseEnter');
226+
rerender(<Demo disabled />);
227+
trigger(container, '.target', 'mouseLeave');
228+
rerender(<Demo />);
229+
230+
expect(isPopupHidden()).toBeTruthy();
231+
});
232+
233+
it('ignores pending and direct show actions while disabled', () => {
234+
const Demo = ({ disabled = false }) => (
235+
<Trigger
236+
action="hover"
237+
disabled={disabled}
238+
mouseEnterDelay={1}
239+
popup={<strong>trigger</strong>}
240+
>
241+
<div className="target">hover</div>
242+
</Trigger>
243+
);
244+
245+
const { container, rerender } = render(<Demo />);
246+
const target = container.querySelector('.target');
247+
248+
fireEvent.mouseEnter(target);
249+
rerender(<Demo disabled />);
250+
act(() => jest.runAllTimers());
251+
expect(document.querySelector('.rc-trigger-popup')).toBeFalsy();
252+
253+
fireEvent.mouseEnter(target);
254+
act(() => jest.runAllTimers());
255+
expect(document.querySelector('.rc-trigger-popup')).toBeFalsy();
256+
});
182257
});
183258

184259
it('contextMenu works', () => {
@@ -1009,7 +1084,62 @@ describe('Trigger.Basic', () => {
10091084
expect(document.querySelector('.rc-trigger-popup')).toBeTruthy();
10101085
});
10111086

1087+
it('temporarily hides a controlled popup without changing its open state', () => {
1088+
const onOpenChange = jest.fn();
1089+
const Demo = ({ disabled = false }) => (
1090+
<Trigger
1091+
disabled={disabled}
1092+
onOpenChange={onOpenChange}
1093+
popup={<strong>trigger</strong>}
1094+
popupVisible
1095+
>
1096+
{({ open }) => <div data-open={open} />}
1097+
</Trigger>
1098+
);
1099+
1100+
const { container, rerender } = render(<Demo />);
1101+
1102+
expect(container.firstChild).toHaveAttribute('data-open', 'true');
1103+
expect(isPopupHidden()).toBeFalsy();
1104+
1105+
rerender(<Demo disabled />);
1106+
expect(container.firstChild).toHaveAttribute('data-open', 'false');
1107+
expect(isPopupHidden()).toBeTruthy();
1108+
1109+
rerender(<Demo />);
1110+
expect(container.firstChild).toHaveAttribute('data-open', 'true');
1111+
expect(isPopupHidden()).toBeFalsy();
1112+
expect(onOpenChange).not.toHaveBeenCalled();
1113+
});
1114+
10121115
describe('click window to hide', () => {
1116+
it('clears the open state while disabled', async () => {
1117+
const Demo = ({ disabled = false }) => (
1118+
<Trigger
1119+
action="click"
1120+
disabled={disabled}
1121+
popup={<strong>trigger</strong>}
1122+
>
1123+
<div className="target" />
1124+
</Trigger>
1125+
);
1126+
1127+
const { container, rerender } = render(<Demo />);
1128+
1129+
fireEvent.click(container.querySelector('.target'));
1130+
await awaitFakeTimer();
1131+
expect(isPopupHidden()).toBeFalsy();
1132+
1133+
rerender(<Demo disabled />);
1134+
expect(isPopupHidden()).toBeTruthy();
1135+
1136+
fireEvent.mouseDown(document.body);
1137+
await awaitFakeTimer();
1138+
rerender(<Demo />);
1139+
1140+
expect(isPopupHidden()).toBeTruthy();
1141+
});
1142+
10131143
it('should hide', async () => {
10141144
const onOpenChange = jest.fn();
10151145
const onPopupVisibleChange = jest.fn();

tests/unique.test.tsx

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,104 @@ describe('Trigger.Unique', () => {
6969
jest.restoreAllMocks();
7070
});
7171

72+
it('restores a disabled hovered popup without mouse leave', async () => {
73+
const Demo = ({ disabled = false }) => (
74+
<UniqueProvider>
75+
<Trigger
76+
action="hover"
77+
disabled={disabled}
78+
popup={<strong>trigger</strong>}
79+
unique
80+
>
81+
<div className="target">hover</div>
82+
</Trigger>
83+
</UniqueProvider>
84+
);
85+
86+
const { container, rerender } = render(<Demo />);
87+
88+
fireEvent.mouseEnter(container.querySelector('.target'));
89+
await awaitFakeTimer();
90+
expect(document.querySelector('.rc-trigger-popup')).not.toHaveClass(
91+
'rc-trigger-popup-hidden',
92+
);
93+
94+
rerender(<Demo disabled />);
95+
await awaitFakeTimer();
96+
expect(document.querySelector('.rc-trigger-popup')).toHaveClass(
97+
'rc-trigger-popup-hidden',
98+
);
99+
100+
rerender(<Demo />);
101+
await awaitFakeTimer();
102+
expect(document.querySelector('.rc-trigger-popup')).not.toHaveClass(
103+
'rc-trigger-popup-hidden',
104+
);
105+
});
106+
107+
it('ignores leave delay when controlled popup is disabled', async () => {
108+
const Demo = ({ disabled = false }) => (
109+
<UniqueProvider>
110+
<Trigger
111+
disabled={disabled}
112+
mouseLeaveDelay={10}
113+
popup={<strong>trigger</strong>}
114+
popupVisible
115+
unique
116+
>
117+
<div className="target" />
118+
</Trigger>
119+
</UniqueProvider>
120+
);
121+
122+
const { rerender } = render(<Demo />);
123+
await awaitFakeTimer();
124+
expect(document.querySelector('.rc-trigger-popup')).not.toHaveClass(
125+
'rc-trigger-popup-hidden',
126+
);
127+
128+
rerender(<Demo disabled />);
129+
expect(document.querySelector('.rc-trigger-popup')).toHaveClass(
130+
'rc-trigger-popup-hidden',
131+
);
132+
133+
rerender(<Demo />);
134+
expect(document.querySelector('.rc-trigger-popup')).not.toHaveClass(
135+
'rc-trigger-popup-hidden',
136+
);
137+
});
138+
139+
it('closes immediately when disabled during leave delay', async () => {
140+
const Demo = ({ disabled = false }) => (
141+
<UniqueProvider>
142+
<Trigger
143+
action="hover"
144+
disabled={disabled}
145+
mouseLeaveDelay={10}
146+
popup={<strong>trigger</strong>}
147+
unique
148+
>
149+
<div className="target" />
150+
</Trigger>
151+
</UniqueProvider>
152+
);
153+
154+
const { container, rerender } = render(<Demo />);
155+
156+
fireEvent.mouseEnter(container.querySelector('.target'));
157+
await awaitFakeTimer();
158+
expect(document.querySelector('.rc-trigger-popup')).not.toHaveClass(
159+
'rc-trigger-popup-hidden',
160+
);
161+
162+
fireEvent.mouseLeave(container.querySelector('.target'));
163+
rerender(<Demo disabled />);
164+
165+
expect(document.querySelector('.rc-trigger-popup')).toHaveClass(
166+
'rc-trigger-popup-hidden',
167+
);
168+
});
169+
72170
it('moving will not hide the popup', async () => {
73171
const { container } = render(
74172
<UniqueProvider>
@@ -376,7 +474,7 @@ describe('Trigger.Unique', () => {
376474
});
377475

378476
it('esc should close unique popup', async () => {
379-
const { container,baseElement } = render(
477+
const { container, baseElement } = render(
380478
<UniqueProvider>
381479
<Trigger action={['click']} popup={<div>Popup</div>} unique>
382480
<div className="target" />

0 commit comments

Comments
 (0)