+123
src/__tests__/useDismissable.test.tsx
+123
src/__tests__/useDismissable.test.tsx
···
1
+
import React, { useState, useRef } from 'react';
2
+
import { mount } from '@cypress/react';
3
+
4
+
import { useDismissable } from '../useDismissable';
5
+
6
+
const Dialog = ({ focusLoss }: { focusLoss?: boolean }) => {
7
+
const [visible, setVisible] = useState(true);
8
+
const ref = useRef<HTMLDivElement>(null);
9
+
10
+
const onDismiss = () => setVisible(false);
11
+
useDismissable(ref, onDismiss, { focusLoss, disabled: !visible });
12
+
13
+
return (
14
+
<div ref={ref} role="dialog" style={{ display: visible ? 'block' : 'none' }}>
15
+
<button className="inside">focusable</button>
16
+
</div>
17
+
);
18
+
};
19
+
20
+
it('is dismissed by clicking outside', () => {
21
+
mount(
22
+
<main>
23
+
<button className="outside">outside</button>
24
+
<Dialog />
25
+
</main>
26
+
);
27
+
28
+
cy.get('.inside').as('inside').click();
29
+
cy.get('@inside').should('be.visible');
30
+
cy.get('.outside').first().click();
31
+
cy.get('@inside').should('not.be.visible');
32
+
});
33
+
34
+
it('is not dismissed by clicking outside when it does not have priority', () => {
35
+
mount(
36
+
<main>
37
+
<button className="outside">outside</button>
38
+
<Dialog />
39
+
<Dialog />
40
+
</main>
41
+
);
42
+
43
+
cy.get('.inside').as('inside').should('be.visible');
44
+
// at first not dismissed
45
+
cy.get('.outside').first().click();
46
+
cy.get('@inside').should('be.visible');
47
+
// dismissed when the second Dialog loses focus
48
+
cy.get('.outside').first().click();
49
+
cy.get('@inside').should('not.be.visible');
50
+
});
51
+
52
+
it('is dismissed by pressing Escape', () => {
53
+
mount(
54
+
<main>
55
+
<button className="outside">outside</button>
56
+
<Dialog />
57
+
</main>
58
+
);
59
+
60
+
cy.get('.inside').as('inside').should('be.visible');
61
+
cy.realPress('Escape');
62
+
cy.get('@inside').should('not.be.visible');
63
+
});
64
+
65
+
it('is not dismissed by pressing Escape when it does not have priority', () => {
66
+
mount(
67
+
<main>
68
+
<button className="outside">outside</button>
69
+
<Dialog />
70
+
<Dialog />
71
+
</main>
72
+
);
73
+
74
+
cy.get('.inside').as('inside').should('be.visible');
75
+
// at first not dismissed
76
+
cy.realPress('Escape');
77
+
cy.get('@inside').should('be.visible');
78
+
// dismissed when the second Dialog loses focus
79
+
cy.realPress('Escape');
80
+
cy.get('@inside').should('not.be.visible');
81
+
});
82
+
83
+
it('is dismissed without priority when it has focus', () => {
84
+
const Second = () => {
85
+
const ref = useRef<HTMLDivElement>(null);
86
+
useDismissable(ref, () => {});
87
+
return <div ref={ref} />;
88
+
};
89
+
90
+
mount(
91
+
<main>
92
+
<button className="outside">outside</button>
93
+
<Dialog />
94
+
<Second />
95
+
</main>
96
+
);
97
+
98
+
cy.get('.inside').as('inside').should('be.visible');
99
+
// not dismissed with escape press
100
+
cy.realPress('Escape');
101
+
cy.get('@inside').should('be.visible');
102
+
// is dismissed when it has focus
103
+
cy.get('@inside').focus();
104
+
cy.realPress('Escape');
105
+
cy.get('@inside').should('not.be.visible');
106
+
});
107
+
108
+
it('is dismissed when focus moves out of it, with focus loss active', () => {
109
+
mount(
110
+
<main>
111
+
<button className="outside">outside</button>
112
+
<Dialog focusLoss />
113
+
</main>
114
+
);
115
+
116
+
cy.get('.inside').as('inside').should('be.visible');
117
+
cy.get('@inside').focus();
118
+
cy.get('@inside').should('be.visible');
119
+
// is dismissed when it loses focus
120
+
cy.realPress(['Shift', 'Tab']);
121
+
cy.focused().contains('outside');
122
+
cy.get('@inside').should('not.be.visible');
123
+
});
+14
-6
src/useDismissable.ts
+14
-6
src/useDismissable.ts
···
1
+
import { useRef } from 'react';
1
2
import { useLayoutEffect } from './utils/react';
2
3
import { contains } from './utils/element';
3
4
import { makePriorityHook } from './usePriority';
···
7
8
8
9
export interface DismissableOptions {
9
10
focusLoss?: boolean;
11
+
disabled?: boolean;
10
12
}
11
13
12
14
export function useDismissable<T extends HTMLElement>(
···
15
17
options?: DismissableOptions
16
18
) {
17
19
const focusLoss = !!(options && options.focusLoss);
18
-
const hasPriority = usePriority(ref);
20
+
const disabled = !!(options && options.disabled);
21
+
const hasPriority = usePriority(ref, disabled);
22
+
const onDismissRef = useRef(onDismiss);
19
23
20
24
useLayoutEffect(() => {
21
-
if (!ref.current || !hasPriority) return;
25
+
onDismissRef.current = onDismiss;
26
+
}, [onDismiss]);
27
+
28
+
useLayoutEffect(() => {
29
+
if (!ref.current || disabled) return;
22
30
23
31
function onFocusOut(event: FocusEvent) {
24
32
if (event.defaultPrevented) return;
···
28
36
contains(ref.current, target) &&
29
37
!contains(ref.current, relatedTarget)
30
38
) {
31
-
onDismiss();
39
+
onDismissRef.current();
32
40
}
33
41
}
34
42
···
39
47
const active = document.activeElement;
40
48
if (hasPriority || (active && contains(ref.current, active))) {
41
49
event.preventDefault();
42
-
onDismiss();
50
+
onDismissRef.current();
43
51
}
44
52
}
45
53
}
···
55
63
const active = document.activeElement;
56
64
if (hasPriority || (active && contains(ref.current, active))) {
57
65
event.preventDefault();
58
-
onDismiss();
66
+
onDismissRef.current();
59
67
}
60
68
}
61
69
···
72
80
document.removeEventListener('touchstart', onClick);
73
81
document.removeEventListener('keydown', onKey);
74
82
};
75
-
}, [ref, hasPriority, focusLoss, onDismiss]);
83
+
}, [ref, hasPriority, disabled, focusLoss]);
76
84
}