Skip to content

Commit 502d039

Browse files
committed
feat(CDropdown): add autoClose and custom toggler
1 parent 0bd6d38 commit 502d039

File tree

5 files changed

+105
-33
lines changed

5 files changed

+105
-33
lines changed

packages/coreui-react/src/components/dropdown/CDropdown.tsx

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, {
33
ElementType,
44
forwardRef,
55
HTMLAttributes,
6+
RefObject,
67
useEffect,
78
useRef,
89
useState,
@@ -33,6 +34,14 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
3334
* @type 'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } | { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} | { xxl: 'start' | 'end'}
3435
*/
3536
alignment?: Alignments
37+
/**
38+
* Configure the auto close behavior of the dropdown:
39+
* - `true` - the dropdown will be closed by clicking outside or inside the dropdown menu.
40+
* - `false` - the dropdown will be closed by clicking the toggle button and manually calling hide or toggle method. (Also will not be closed by pressing esc key)
41+
* - `'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.
42+
* - `'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu.
43+
*/
44+
autoClose?: 'inside' | 'outside' | boolean
3645
/**
3746
* A string of all className you want applied to the base component.
3847
*/
@@ -80,6 +89,8 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
8089
}
8190

8291
interface ContextProps extends CDropdownProps {
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
dropdownToggleRef: RefObject<any> | undefined
8394
setVisible: React.Dispatch<React.SetStateAction<boolean | undefined>>
8495
}
8596

@@ -90,6 +101,7 @@ export const CDropdown = forwardRef<HTMLDivElement | HTMLLIElement, CDropdownPro
90101
{
91102
children,
92103
alignment,
104+
autoClose = true,
93105
className,
94106
dark,
95107
direction,
@@ -106,6 +118,7 @@ export const CDropdown = forwardRef<HTMLDivElement | HTMLLIElement, CDropdownPro
106118
) => {
107119
const [_visible, setVisible] = useState(visible)
108120
const dropdownRef = useRef<HTMLDivElement>(null)
121+
const dropdownToggleRef = useRef(null)
109122
const forkedRef = useForkedRef(ref, dropdownRef)
110123

111124
const Component = variant === 'nav-item' ? 'li' : component
@@ -117,8 +130,10 @@ export const CDropdown = forwardRef<HTMLDivElement | HTMLLIElement, CDropdownPro
117130

118131
const contextValues = {
119132
alignment,
133+
autoClose,
120134
dark,
121135
direction: direction,
136+
dropdownToggleRef,
122137
placement: placement,
123138
popper,
124139
variant,
@@ -135,19 +150,6 @@ export const CDropdown = forwardRef<HTMLDivElement | HTMLLIElement, CDropdownPro
135150
className,
136151
)
137152

138-
useEffect(() => {
139-
_visible &&
140-
setTimeout(() => {
141-
window.addEventListener('click', handleClickOutside)
142-
window.addEventListener('keyup', handleKeyup)
143-
})
144-
145-
return () => {
146-
window.removeEventListener('click', handleClickOutside)
147-
window.removeEventListener('keyup', handleKeyup)
148-
}
149-
}, [_visible])
150-
151153
useEffect(() => {
152154
setVisible(visible)
153155
}, [visible])
@@ -157,17 +159,6 @@ export const CDropdown = forwardRef<HTMLDivElement | HTMLLIElement, CDropdownPro
157159
!_visible && onHide && onHide()
158160
}, [_visible])
159161

160-
const handleKeyup = (event: Event) => {
161-
if (!dropdownRef.current?.contains(event.target as HTMLElement)) {
162-
setVisible(false)
163-
}
164-
}
165-
const handleClickOutside = (event: Event) => {
166-
if (!dropdownRef.current?.contains(event.target as HTMLElement)) {
167-
setVisible(false)
168-
}
169-
}
170-
171162
const dropdownContent = () => {
172163
return variant === 'input-group' ? (
173164
<>{children}</>
@@ -203,6 +194,10 @@ CDropdown.propTypes = {
203194
PropTypes.shape({ xl: alignmentDirection }),
204195
PropTypes.shape({ xxl: alignmentDirection }),
205196
]),
197+
autoClose: PropTypes.oneOfType([
198+
PropTypes.bool,
199+
PropTypes.oneOf<'inside' | 'outside'>(['inside', 'outside']),
200+
]),
206201
children: PropTypes.node,
207202
className: PropTypes.string,
208203
component: PropTypes.elementType,

packages/coreui-react/src/components/dropdown/CDropdownMenu.tsx

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import PropTypes from 'prop-types'
2-
import React, { ElementType, FC, HTMLAttributes, useContext } from 'react'
2+
import React, { ElementType, FC, HTMLAttributes, useContext, useEffect, useRef } from 'react'
33
import classNames from 'classnames'
44
import { Popper, PopperChildrenProps } from 'react-popper'
55

@@ -35,7 +35,57 @@ export const CDropdownMenu: FC<CDropdownMenuProps> = ({
3535
component: Component = 'ul',
3636
...rest
3737
}) => {
38-
const { alignment, dark, direction, placement, popper, visible } = useContext(CDropdownContext)
38+
const {
39+
alignment,
40+
autoClose,
41+
dark,
42+
direction,
43+
dropdownToggleRef,
44+
placement,
45+
popper,
46+
visible,
47+
setVisible,
48+
} = useContext(CDropdownContext)
49+
50+
const dropdownMenuRef = useRef<HTMLDivElement>(null)
51+
52+
useEffect(() => {
53+
visible && window.addEventListener('mouseup', handleMouseUp)
54+
visible && window.addEventListener('keyup', handleKeyup)
55+
56+
return () => {
57+
window.removeEventListener('mouseup', handleMouseUp)
58+
window.removeEventListener('keyup', handleKeyup)
59+
}
60+
}, [visible])
61+
62+
const handleKeyup = (event: Event) => {
63+
if (autoClose === false) {
64+
return
65+
}
66+
if (!dropdownMenuRef.current?.contains(event.target as HTMLElement)) {
67+
setVisible(false)
68+
}
69+
}
70+
const handleMouseUp = (event: Event) => {
71+
if (dropdownToggleRef && dropdownToggleRef.current.contains(event.target as HTMLElement)) {
72+
return
73+
}
74+
if (autoClose === true) {
75+
setVisible(false)
76+
return
77+
}
78+
if (autoClose === 'inside' && dropdownMenuRef.current?.contains(event.target as HTMLElement)) {
79+
setVisible(false)
80+
return
81+
}
82+
if (
83+
autoClose === 'outside' &&
84+
!dropdownMenuRef.current?.contains(event.target as HTMLElement)
85+
) {
86+
setVisible(false)
87+
}
88+
}
3989

4090
let _placement: Placements = placement
4191

@@ -101,7 +151,9 @@ export const CDropdownMenu: FC<CDropdownMenuProps> = ({
101151
}
102152

103153
return popper && visible ? (
104-
<Popper placement={_placement}>{({ ref, style }) => dropdownMenuComponent(style, ref)}</Popper>
154+
<Popper innerRef={dropdownMenuRef} placement={_placement}>
155+
{({ ref, style }) => dropdownMenuComponent(style, ref)}
156+
</Popper>
105157
) : (
106158
dropdownMenuComponent()
107159
)

packages/coreui-react/src/components/dropdown/CDropdownToggle.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types'
33
import classNames from 'classnames'
44
import { Reference } from 'react-popper'
55

6+
import { useForkedRef } from '../../utils/hooks'
7+
68
import { Triggers, triggerPropType } from '../Types'
79

810
import { CButton, CButtonProps } from '../button/CButton'
@@ -13,6 +15,10 @@ export interface CDropdownToggleProps extends Omit<CButtonProps, 'type'> {
1315
* Enables pseudo element caret on toggler.
1416
*/
1517
caret?: boolean
18+
/**
19+
* Create a custom toggler which accepts any content.
20+
*/
21+
custom?: boolean
1622
/**
1723
* Similarly, create split button dropdowns with virtually the same markup as single button dropdowns, but with the addition of `.dropdown-toggle-split` className for proper spacing around the dropdown caret.
1824
*/
@@ -28,12 +34,13 @@ export interface CDropdownToggleProps extends Omit<CButtonProps, 'type'> {
2834
export const CDropdownToggle: FC<CDropdownToggleProps> = ({
2935
children,
3036
caret = true,
37+
custom,
3138
className,
3239
split,
3340
trigger = 'click',
3441
...rest
3542
}) => {
36-
const { popper, variant, visible, setVisible } = useContext(CDropdownContext)
43+
const { dropdownToggleRef, popper, variant, visible, setVisible } = useContext(CDropdownContext)
3744
const _className = classNames(
3845
{
3946
'dropdown-toggle': caret,
@@ -59,31 +66,47 @@ export const CDropdownToggle: FC<CDropdownToggleProps> = ({
5966
const togglerProps = {
6067
className: _className,
6168
'aria-expanded': visible,
69+
...(!rest.disabled && { ...triggers }),
6270
...triggers,
6371
}
6472

6573
// We use any because Toggler can be `a` as well as `button`.
6674
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6775
const Toggler = (ref?: React.Ref<any>) => {
68-
return variant === 'nav-item' ? (
69-
<a href="#" {...togglerProps} ref={ref}>
76+
return custom && React.isValidElement(children) ? (
77+
<>
78+
{React.cloneElement(children, {
79+
'aria-expanded': visible,
80+
...(!rest.disabled && { ...triggers }),
81+
ref: useForkedRef(ref, dropdownToggleRef),
82+
})}
83+
</>
84+
) : variant === 'nav-item' ? (
85+
<a href="#" {...togglerProps} ref={useForkedRef(ref, dropdownToggleRef)}>
7086
{children}
7187
</a>
7288
) : (
73-
<CButton type="button" {...togglerProps} tabIndex={0} {...rest} ref={ref}>
89+
<CButton
90+
type="button"
91+
{...togglerProps}
92+
tabIndex={0}
93+
{...rest}
94+
ref={useForkedRef(ref, dropdownToggleRef)}
95+
>
7496
{children}
7597
{split && <span className="visually-hidden">Toggle Dropdown</span>}
7698
</CButton>
7799
)
78100
}
79101

80-
return popper ? <Reference>{({ ref }) => Toggler(ref)}</Reference> : Toggler()
102+
return popper ? <Reference>{({ ref }) => Toggler(ref)}</Reference> : Toggler(dropdownToggleRef)
81103
}
82104

83105
CDropdownToggle.propTypes = {
84106
caret: PropTypes.bool,
85107
children: PropTypes.node,
86108
className: PropTypes.string,
109+
custom: PropTypes.bool,
87110
split: PropTypes.bool,
88111
trigger: triggerPropType,
89112
}

packages/docs/content/4.1/api/CDropdown.api.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import CDropdown from '@coreui/react/src/components/dropdown/CDropdown'
88
| Property | Description | Type | Default |
99
| --- | --- | --- | --- |
1010
| **alignment** | Set aligment of dropdown menu. | `'start'` \| `'end'` \| `{ xs: 'start'` \| `'end' }` \| `{ sm: 'start'` \| `'end' }` \| `{ md: 'start'` \| `'end' }` \| `{ lg: 'start'` \| `'end' }` \| `{ xl: 'start'` \| `'end'}` \| `{ xxl: 'start'` \| `'end'}` | - |
11+
| **autoClose** | Configure the auto close behavior of the dropdown:<br/>- `true` - the dropdown will be closed by clicking outside or inside the dropdown menu.<br/>- `false` - the dropdown will be closed by clicking the toggle button and manually calling hide or toggle method. (Also will not be closed by pressing esc key)<br/>- `'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.<br/>- `'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu. | `boolean` \| `'inside'` \| `'outside'` | true |
1112
| **className** | A string of all className you want applied to the base component. | `string` | - |
1213
| **component** | Component used for the root node. Either a string to use a HTML element or a component. | `string` \| `ComponentClass<any, any>` \| `FunctionComponent<any>` | div |
1314
| **dark** | Sets a darker color scheme to match a dark navbar. | `boolean` | - |

packages/docs/content/4.1/api/CDropdownToggle.api.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import CDropdownToggle from '@coreui/react/src/components/dropdown/CDropdownTogg
1212
| **className** | A string of all className you want applied to the base component. | `string` | - |
1313
| **color** | Sets the color context of the component to one of CoreUI’s themed colors. | `'primary'` \| `'secondary'` \| `'success'` \| `'danger'` \| `'warning'` \| `'info'` \| `'dark'` \| `'light'` \| `string` | - |
1414
| **component** | Component used for the root node. Either a string to use a HTML element or a component. | `string` \| `ComponentClass<any, any>` \| `FunctionComponent<any>` | - |
15+
| **custom** | Create a custom toggler which accepts any content. | `boolean` | - |
1516
| **disabled** | Toggle the disabled state for the component. | `boolean` | - |
1617
| **href** | The href attribute specifies the URL of the page the link goes to. | `string` | - |
1718
| **role** | The role attribute describes the role of an element in programs that can make use of it, such as screen readers or magnifiers. | `string` | - |
1819
| **shape** | Select the shape of the component. | `'rounded'` \| `'rounded-top'` \| `'rounded-end'` \| `'rounded-bottom'` \| `'rounded-start'` \| `'rounded-circle'` \| `'rounded-pill'` \| `'rounded-0'` \| `'rounded-1'` \| `'rounded-2'` \| `'rounded-3'` \| `string` | - |
1920
| **size** | Size the component small or large. | `'sm'` \| `'lg'` | - |
2021
| **split** | Similarly, create split button dropdowns with virtually the same markup as single button dropdowns, but with the addition of `.dropdown-toggle-split` className for proper spacing around the dropdown caret. | `boolean` | - |
21-
| **trigger** | Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them. | `'hover'` \| `'focus'` \| `'click'` | click |
22+
| **trigger** | Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them. | `'hover'` \| `'focus'` \| `'click'` \| `'click-init'` | click |
2223
| **variant** | Set the button variant to an outlined button or a ghost button. | `'outline'` \| `'ghost'` | - |

0 commit comments

Comments
 (0)