• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pushkar8723 / no-frills-ui / 20595403788

30 Dec 2025 11:22AM UTC coverage: 90.138%. Remained the same
20595403788

push

github

pushkar8723
0.0.14

1137 of 1394 branches covered (81.56%)

Branch coverage included in aggregate %.

1806 of 1871 relevant lines covered (96.53%)

23.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.47
/src/components/Input/Dropdown.tsx
1
import React, { useEffect, useRef, useState } from 'react';
14✔
2
import styled from '@emotion/styled';
14✔
3
import { ExpandMore } from '../../icons';
14✔
4
import { Menu } from '../Menu';
14✔
5
import { MenuItemProps } from '../Menu/MenuItem';
6
import { Popover, POPOVER_POSITION } from '../Popover';
14✔
7
import Input from './Input';
14✔
8

9
type DropdownCommonProps = {
10
    /** Label of the control */
11
    label?: string;
12
    /** Error message */
13
    errorText?: string;
14
    /** Makes field required */
15
    required?: boolean;
16
    /** Disables the field */
17
    disabled?: boolean;
18
    /** Callback called when dropdown is opened */
19
    onOpen?: () => void;
20
    /** Callback called when dropdown is closed */
21
    onClose?: () => void;
22
};
23

24
type DropdownMultiSelectProps<T> = {
25
    /** Value of the control */
26
    value?: T[];
27
    /**
28
     * If multiple elements can be selected
29
     */
30
    multiSelect?: true;
31
    /** Change handler */
32
    onChange?: (v: T[]) => void;
33
    /** Function to provide custom display value */
34
    displayNameProvider?: (value?: T[]) => string;
35
};
36

37
type DropdownSingleSelectProps<T> = {
38
    /** Value of the control */
39
    value?: T;
40
    /**
41
     * If multiple elements can be selected
42
     * @default false
43
     */
44
    multiSelect?: false;
45
    /** Change handler */
46
    onChange?: (v: T) => void;
47
    /** Function to provide custom display value */
48
    displayNameProvider?: (value?: T) => string;
49
};
50

51
export type DropdownProps<T> = React.PropsWithChildren<
52
    (DropdownSingleSelectProps<T> | DropdownMultiSelectProps<T>) & DropdownCommonProps
53
> &
54
    Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'>;
55

56
const ArrowContainer = styled.span`
14✔
57
    position: absolute;
58
    right: 12px;
59
    top: 16px;
60
    pointer-events: none;
61
`;
62

63
/**
64
 * DropdownTrigger Component
65
 */
66
const DropdownTrigger = React.forwardRef<
14✔
67
    HTMLInputElement,
68
    Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> & {
69
        displayValue: string;
70
        label?: string;
71
        errorText?: string;
72
        open: boolean;
73
        menuId: string;
74
        toggleOpen: () => void;
75
        onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
76
        forwardedRef?: React.Ref<HTMLInputElement>;
77
    }
78
>((props, ref) => {
79
    const {
80
        displayValue,
81
        label,
82
        errorText,
83
        open,
84
        menuId,
85
        toggleOpen,
86
        onKeyDown,
87
        forwardedRef,
88
        ...rest
53✔
89
    } = props;
53✔
90
    const triggerRef = React.useRef<HTMLInputElement | null>(null);
53✔
91

92
    // Helper to assign both internal triggerRef and external forwarded ref
93
    const assignRefs = React.useCallback(
53✔
94
        (node: HTMLInputElement | null) => {
95
            triggerRef.current = node;
120✔
96

97
            if (!forwardedRef) return;
120!
98
            if (typeof forwardedRef === 'function') {
120!
99
                forwardedRef(node);
120✔
100
            } else {
101
                // With react 19 the refs are forwarded as props.
102
                // But since we still support react 18, we need to handle both cases.
103
                // eslint-disable-next-line react-hooks/immutability
104
                (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
×
105
            }
106
        },
107
        [forwardedRef],
108
    );
109

110
    // Combine the ref passed by parent with our assignRefs so both are updated
111
    const combinedRef = React.useCallback(
53✔
112
        (node: HTMLInputElement | null) => {
113
            assignRefs(node);
120✔
114
            if (typeof ref === 'function') {
120!
115
                ref(node);
×
116
            } else if (ref) {
120!
117
                (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
120✔
118
            }
119
        },
120
        [assignRefs, ref],
121
    );
122

123
    return (
53✔
124
        <>
125
            <Input
126
                {...rest}
127
                ref={combinedRef}
128
                type="text"
129
                value={displayValue}
130
                label={label}
131
                errorText={errorText}
132
                onClick={toggleOpen}
133
                onKeyDown={onKeyDown}
134
                inputMode="none"
135
                role="combobox"
136
                aria-haspopup="listbox"
137
                aria-expanded={open}
138
                aria-controls={menuId}
139
            />
140
            <ArrowContainer aria-hidden="true">
141
                <ExpandMore />
142
            </ArrowContainer>
143
        </>
144
    );
145
});
146
DropdownTrigger.displayName = 'DropdownTrigger';
14✔
147

148
/**
149
 * Dropdown component that allows selection from a list of options.
150
 * Supports single and multi-select modes.
151
 *
152
 * @template T - The type of the value(s) in the dropdown.
153
 * @param props - The properties for the Dropdown component.
154
 * @returns The rendered Dropdown component.
155
 */
156
function DropdownComponent<T extends object>(
157
    props: DropdownProps<T>,
158
    outerRef: React.Ref<HTMLInputElement>,
159
) {
160
    const {
161
        multiSelect = false,
30✔
162
        onChange,
163
        children,
164
        value: propValue,
165
        label,
166
        errorText,
167
        required,
168
        disabled,
169
        onOpen,
170
        onClose,
171
        displayNameProvider,
172
        ...rest
34✔
173
    } = props;
34✔
174
    const [open, setOpen] = useState(false);
34✔
175
    const [value, setValue] = useState(propValue);
34✔
176
    const id = React.useId();
34✔
177
    const menuId = `${id}-menu`;
34✔
178
    const menuRef = React.useRef<HTMLDivElement | null>(null);
34✔
179
    const triggerRef = React.useRef<HTMLInputElement | null>(null);
34✔
180

181
    /**
182
     * Gets the display value for the dropdown based on the current value and children.
183
     *
184
     * @param currentValue - The current value of the dropdown.
185
     * @param currentChildren - The children of the dropdown.
186
     * @returns The display value.
187
     */
188
    const getDisplayValue = (
34✔
189
        currentValue: T | T[] | undefined,
190
        currentChildren: React.ReactNode,
191
    ): string => {
192
        if (currentValue === undefined || currentValue === null) return '';
34✔
193

194
        const findLabel = (val: T): string => {
4✔
195
            let label = '';
5✔
196
            React.Children.forEach(currentChildren, (child) => {
5✔
197
                if (React.isValidElement(child)) {
15!
198
                    const props = child.props as MenuItemProps<T> & React.PropsWithChildren;
15✔
199
                    if ('value' in props && props.value === val) {
15✔
200
                        label = String(props.children);
5✔
201
                    }
202
                }
203
            });
204
            return label;
5✔
205
        };
206

207
        if (Array.isArray(currentValue)) {
4✔
208
            return currentValue.map(findLabel).filter(Boolean).join(', ');
2✔
209
        }
210

211
        return findLabel(currentValue as T);
2✔
212
    };
213

214
    const displayValue =
215
        (multiSelect
34✔
216
            ? (displayNameProvider as (value?: T[]) => string)?.(value as T[])
12!
217
            : (displayNameProvider as (value?: T) => string)?.(value as T)) ||
90!
218
        getDisplayValue(value, children) ||
219
        (value !== null && value !== undefined ? String(value) : '');
90!
220

221
    // Sync prop value with state
222
    const prevValueRef = useRef<T | T[] | undefined>(undefined);
34✔
223
    useEffect(() => {
34✔
224
        if (propValue !== prevValueRef.current) {
20✔
225
            setValue(propValue);
1✔
226
            prevValueRef.current = propValue;
1✔
227
        }
228
    }, [propValue]);
229

230
    // Focus menu when opened
231
    useEffect(() => {
34✔
232
        if (open) {
30✔
233
            // Wait for Popover to fully open and focus itself first
234
            // Then move focus to the first menu item
235
            const timer = setTimeout(() => {
8✔
236
                const firstItem = menuRef.current?.querySelector('[role="option"]') as HTMLElement;
1!
237
                if (firstItem) {
1!
238
                    firstItem.focus();
1✔
239
                }
240
                onOpen?.();
1!
241
            }, 100); // Wait after Popover has set initial focus
242
            return () => clearTimeout(timer);
8✔
243
        } else {
244
            onClose?.();
22!
245
        }
246
    }, [open, onOpen, onClose]);
247

248
    /**
249
     * Handles keydown events on the input trigger.
250
     * Opens the menu on 'Enter', 'Space', 'ArrowDown', or 'ArrowUp'.
251
     *
252
     * @param {React.KeyboardEvent<HTMLInputElement>} e - The keyboard event.
253
     */
254
    const onKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
34✔
255
        if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
5✔
256
            e.preventDefault();
3✔
257
            setOpen(true);
3✔
258
        } else if (e.key !== 'Tab') {
2✔
259
            // Prevent typing to mimic readOnly behavior while allowing native validation
260
            e.preventDefault();
1✔
261
        }
262
    }, []);
263

264
    /**
265
     * Handles changes to the dropdown value.
266
     * Updates local state and calls the external onChange handler.
267
     * Closes the dropdown if not in multi-select mode.
268
     *
269
     * @param {T | T[]} val - The new value(s).
270
     */
271
    const changeHandler = (val: T | T[]) => {
34✔
272
        setValue(val);
3✔
273
        if (multiSelect) {
3✔
274
            (onChange as (v: T[]) => void)?.(val as T[]);
2!
275
        } else {
276
            (onChange as (v: T) => void)?.(val as T);
1!
277
        }
278

279
        // Close dropdown after selection if not multiSelect
280
        if (!multiSelect) {
3✔
281
            setOpen(false);
1✔
282
            triggerRef.current?.focus();
1!
283
        }
284
    };
285

286
    /**
287
     * Toggles the dropdown open state on click.
288
     */
289
    const clickHandler = React.useCallback(() => setOpen(true), []);
34✔
290

291
    /**
292
     * Forwarded ref handler for the trigger input.
293
     */
294
    const handleForwardedRef = React.useCallback(
34✔
295
        (node: HTMLInputElement | null) => {
296
            triggerRef.current = node;
120✔
297
            if (typeof outerRef === 'function') {
120✔
298
                outerRef(node);
2✔
299
            } else if (outerRef) {
118✔
300
                (outerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
4✔
301
            }
302
        },
303
        [outerRef],
304
    );
305

306
    return (
34✔
307
        <Popover
308
            position={POPOVER_POSITION.BOTTOM_LEFT}
309
            open={open}
310
            element={
311
                <DropdownTrigger
312
                    {...rest}
313
                    displayValue={displayValue}
314
                    label={label}
315
                    errorText={errorText}
316
                    open={open}
317
                    menuId={menuId}
318
                    toggleOpen={clickHandler}
319
                    onKeyDown={onKeyDown}
320
                    required={required}
321
                    disabled={disabled}
322
                    forwardedRef={handleForwardedRef}
323
                />
324
            }
325
            onClose={() => {
326
                setOpen(false);
2✔
327
                triggerRef.current?.focus();
2!
328
            }}
329
        >
330
            <Menu
331
                ref={menuRef}
332
                id={menuId}
333
                value={value}
334
                multiSelect={multiSelect}
335
                onChange={changeHandler}
336
            >
337
                {children}
338
            </Menu>
339
        </Popover>
340
    );
341
}
342

343
const Dropdown = React.forwardRef(DropdownComponent) as <T>(
14✔
344
    props: DropdownProps<T> & React.RefAttributes<HTMLInputElement>,
345
) => React.ReactElement | null;
346
export default Dropdown;
14✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc