1
- import React , { useState } from "react" ;
1
+ import React , { useState , useCallback , useMemo , forwardRef , memo } from "react" ;
2
2
import Dropdown from "react-bootstrap/Dropdown" ;
3
3
import ButtonGroup from "react-bootstrap/ButtonGroup" ;
4
4
import { ChevronIcon } from "../SvgIcons/index" ;
5
5
6
- interface DropdownItemConfig {
6
+ /**
7
+ * Dropdown item descriptor for `V8CustomDropdownButton`.
8
+ */
9
+ export interface DropdownItemConfig {
10
+ /** Text label or translation key for the item */
7
11
label : string ;
12
+ /** Value associated with this item */
8
13
value ?: string ;
14
+ /** Called when this item is clicked */
9
15
onClick ?: ( ) => void ;
16
+ /** Test ID for automated testing */
10
17
dataTestId ?: string ;
18
+ /** Accessible label for screen readers */
11
19
ariaLabel ?: string ;
12
20
}
13
21
14
- interface V8CustomDropdownButtonProps {
22
+ /**
23
+ * Props for `V8CustomDropdownButton` component.
24
+ * Optimized, accessible dropdown button with separate label and dropdown actions.
25
+ */
26
+ export interface V8CustomDropdownButtonProps
27
+ extends Omit < React . ComponentPropsWithoutRef < "div" > , "onClick" > {
28
+ /** Button label text */
15
29
label ?: string ;
30
+ /** Array of dropdown menu items */
16
31
dropdownItems : DropdownItemConfig [ ] ;
32
+ /** Visual style variant */
17
33
variant ?: "primary" | "secondary" ;
34
+ /** Disables the entire dropdown button */
18
35
disabled ?: boolean ;
36
+ /** Additional CSS classes */
19
37
className ?: string ;
38
+ /** Test ID for automated testing */
20
39
dataTestId ?: string ;
40
+ /** Accessible label for screen readers */
21
41
ariaLabel ?: string ;
22
- menuPosition ?: "left" | "right" ; // controls dropdown menu alignment
42
+ /** Dropdown menu alignment */
43
+ menuPosition ?: "left" | "right" ;
44
+ /** Called when the label is clicked (separate from dropdown) */
45
+ onLabelClick ?: ( ) => void ;
23
46
}
24
47
25
- export const V8CustomDropdownButton : React . FC < V8CustomDropdownButtonProps > = ( {
48
+ /**
49
+ * Utility function to build className string
50
+ */
51
+ const buildClassNames = ( ...classes : ( string | boolean | undefined ) [ ] ) : string => {
52
+ return classes . filter ( Boolean ) . join ( " " ) ;
53
+ } ;
54
+
55
+ /**
56
+ * V8CustomDropdownButton: Accessible, memoized dropdown button with separate label and dropdown actions.
57
+ *
58
+ * Usage:
59
+ * <V8CustomDropdownButton
60
+ * label="Actions"
61
+ * dropdownItems={[
62
+ * { label: 'Edit', value: 'edit', onClick: handleEdit },
63
+ * { label: 'Delete', value: 'delete', onClick: handleDelete }
64
+ * ]}
65
+ * onLabelClick={handlePrimaryAction}
66
+ * variant="primary"
67
+ * />
68
+ */
69
+ const V8CustomDropdownButtonComponent = forwardRef < HTMLDivElement , V8CustomDropdownButtonProps > ( ( {
26
70
label = "Edit" ,
27
71
dropdownItems,
28
72
variant = "primary" ,
@@ -31,65 +75,145 @@ export const V8CustomDropdownButton: React.FC<V8CustomDropdownButtonProps> = ({
31
75
dataTestId = "v8-dropdown" ,
32
76
ariaLabel = "Custom dropdown" ,
33
77
menuPosition = "left" ,
34
- } ) => {
78
+ onLabelClick,
79
+ ...restProps
80
+ } , ref ) => {
81
+ // State management
35
82
const [ open , setOpen ] = useState ( false ) ;
36
83
const [ selectedValue , setSelectedValue ] = useState < string | null > ( null ) ;
37
84
38
- const handleItemClick = ( item : DropdownItemConfig ) => {
85
+ // Memoized dropdown items to prevent unnecessary re-renders
86
+ const memoizedDropdownItems = useMemo ( ( ) => dropdownItems , [ dropdownItems ] ) ;
87
+
88
+ // Memoized click handlers for better performance
89
+ const handleItemClick = useCallback ( ( item : DropdownItemConfig ) => {
39
90
setSelectedValue ( item . value || item . label ) ;
40
91
item . onClick ?.( ) ;
41
- setOpen ( false ) ; // close after selecting
42
- } ;
92
+ setOpen ( false ) ; // Close dropdown after selection
93
+ } , [ ] ) ;
94
+
95
+ const handleLabelClick = useCallback ( ( e : React . MouseEvent ) => {
96
+ e . preventDefault ( ) ;
97
+ e . stopPropagation ( ) ;
98
+ if ( ! disabled && onLabelClick ) {
99
+ onLabelClick ( ) ;
100
+ }
101
+ } , [ disabled , onLabelClick ] ) ;
102
+
103
+ const handleDropdownIconClick = useCallback ( ( e : React . MouseEvent ) => {
104
+ e . preventDefault ( ) ;
105
+ e . stopPropagation ( ) ;
106
+ if ( ! disabled ) {
107
+ setOpen ( ! open ) ;
108
+ }
109
+ } , [ disabled , open ] ) ;
110
+
111
+ // Memoized dropdown toggle handler
112
+ const handleDropdownToggle = useCallback ( ( isOpen : boolean ) => {
113
+ if ( ! disabled ) {
114
+ setOpen ( isOpen ) ;
115
+ }
116
+ } , [ disabled ] ) ;
117
+
118
+ // Memoized container className
119
+ const containerClassName = useMemo ( ( ) => buildClassNames (
120
+ "v8-custom-dropdown" ,
121
+ `menu-${ menuPosition } ` ,
122
+ className
123
+ ) , [ menuPosition , className ] ) ;
124
+
125
+ // Memoized toggle button className
126
+ const toggleClassName = useMemo ( ( ) => buildClassNames (
127
+ "v8-dropdown-toggle" ,
128
+ open && "open"
129
+ ) , [ open ] ) ;
43
130
44
131
return (
45
132
< Dropdown
46
133
as = { ButtonGroup }
47
134
show = { open }
48
- onToggle = { ( isOpen ) => setOpen ( isOpen ) }
49
- className = { `v8-custom-dropdown menu-${ menuPosition } ${ className } ` }
135
+ onToggle = { handleDropdownToggle }
136
+ className = { containerClassName }
137
+ ref = { ref }
50
138
{ ...( dataTestId ? { "data-testid" : dataTestId } : { } ) }
139
+ { ...restProps }
51
140
>
52
141
< Dropdown . Toggle
53
142
variant = { variant }
54
143
disabled = { disabled }
55
- className = { `v8-dropdown-toggle ${ open ? "open" : "" } ` }
144
+ className = { toggleClassName }
56
145
aria-haspopup = "listbox"
57
146
aria-expanded = { open }
58
147
{ ...( ariaLabel ? { "aria-label" : ariaLabel } : { } ) }
59
148
{ ...( dataTestId ? { "data-testid" : `${ dataTestId } -toggle` } : { } ) }
60
149
>
61
- < div className = "label-div" >
150
+ { /* Label section - triggers separate action */ }
151
+ < div
152
+ className = "label-div"
153
+ onClick = { handleLabelClick }
154
+ data-testid = { `${ dataTestId } -label` }
155
+ role = "button"
156
+ tabIndex = { disabled ? - 1 : 0 }
157
+ aria-label = { `${ label } action` }
158
+ >
62
159
< span className = "dropdown-label" > { label } </ span >
63
160
</ div >
161
+
162
+ { /* Visual divider */ }
64
163
< span className = "v8-dropdown-divider" aria-hidden = "true" />
65
- < div className = "dropdown-icon" >
164
+
165
+ { /* Dropdown icon section - toggles menu */ }
166
+ < div
167
+ className = "dropdown-icon"
168
+ onClick = { handleDropdownIconClick }
169
+ data-testid = { `${ dataTestId } -icon` }
170
+ role = "button"
171
+ tabIndex = { disabled ? - 1 : 0 }
172
+ aria-label = "Toggle dropdown menu"
173
+ >
66
174
< span className = "chevron-icon" >
67
175
< ChevronIcon />
68
176
</ span >
69
177
</ div >
70
178
</ Dropdown . Toggle >
71
179
180
+ { /* Dropdown menu */ }
72
181
< Dropdown . Menu
73
182
className = "v8-dropdown-menu"
74
183
role = "listbox"
75
184
{ ...( dataTestId ? { "data-testid" : `${ dataTestId } -menu` } : { } ) }
76
185
>
77
- { dropdownItems . map ( ( item ) => (
78
- < Dropdown . Item
79
- key = { item . value || item . label }
80
- onClick = { ( ) => handleItemClick ( item ) }
81
- className = { `v8-dropdown-item ${
82
- selectedValue === ( item . value || item . label ) ? "selected" : ""
83
- } `}
84
- role = "option"
85
- aria-selected = { selectedValue === ( item . value || item . label ) }
86
- { ...( item . ariaLabel ? { "aria-label" : item . ariaLabel } : { } ) }
87
- { ...( item . dataTestId ? { "data-testid" : item . dataTestId } : { } ) }
88
- >
89
- { item . label }
90
- </ Dropdown . Item >
91
- ) ) }
186
+ { memoizedDropdownItems . map ( ( item , index ) => {
187
+ const itemKey = item . value || item . label || index ;
188
+ const isSelected = selectedValue === ( item . value || item . label ) ;
189
+
190
+ return (
191
+ < Dropdown . Item
192
+ key = { itemKey }
193
+ onClick = { ( ) => handleItemClick ( item ) }
194
+ className = { buildClassNames (
195
+ "v8-dropdown-item" ,
196
+ isSelected && "selected"
197
+ ) }
198
+ role = "option"
199
+ aria-selected = { isSelected }
200
+ { ...( item . ariaLabel ? { "aria-label" : item . ariaLabel } : { } ) }
201
+ { ...( item . dataTestId ? { "data-testid" : item . dataTestId } : { } ) }
202
+ >
203
+ { item . label }
204
+ </ Dropdown . Item >
205
+ ) ;
206
+ } ) }
92
207
</ Dropdown . Menu >
93
208
</ Dropdown >
94
209
) ;
95
- } ;
210
+ } ) ;
211
+
212
+ // Set display name for better debugging
213
+ V8CustomDropdownButtonComponent . displayName = "V8CustomDropdownButton" ;
214
+
215
+ // Export memoized component for performance optimization
216
+ export const V8CustomDropdownButton = memo ( V8CustomDropdownButtonComponent ) ;
217
+
218
+ // Export types for consumers
219
+ export type { V8CustomDropdownButtonProps } ;
0 commit comments