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

yext / search-ui-react / 21009538011

14 Jan 2026 08:55PM UTC coverage: 85.297% (-0.08%) from 85.376%
21009538011

push

github

k-gerner
update storybook test to not use tab to cycle through dropdown

998 of 1383 branches covered (72.16%)

Branch coverage included in aggregate %.

2245 of 2419 relevant lines covered (92.81%)

149.66 hits per line

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

88.44
/src/components/Dropdown/Dropdown.tsx
1
import { useTranslation } from 'react-i18next';
8✔
2
import React, {
8✔
3
  createElement,
4
  isValidElement,
5
  KeyboardEvent,
6
  PropsWithChildren,
7
  ReactNode,
8
  useEffect,
9
  useMemo,
10
  useRef,
11
  useState
12
} from 'react';
13
import { DropdownContext, DropdownContextType } from './DropdownContext';
8✔
14
import { InputContext, InputContextType } from './InputContext';
8✔
15
import useRootClosePkg from '@restart/ui/useRootClose';
8✔
16
import { FocusContext, FocusContextType } from './FocusContext';
8✔
17
import { ScreenReader } from '../ScreenReader';
8✔
18
import { recursivelyMapChildren } from '../utils/recursivelyMapChildren';
8✔
19
import { DropdownItem, DropdownItemProps, DropdownItemWithIndex } from './DropdownItem';
8✔
20
import { useLayoutEffect } from '../../hooks/useLayoutEffect';
8✔
21
import { useId } from '../../hooks/useId';
8✔
22

23
const useRootClose = typeof useRootClosePkg === 'function' ? useRootClosePkg : useRootClosePkg['default'];
20!
24

25
interface DropdownItemData {
26
  value: string,
27
  itemData?: Record<string, unknown>
28
}
29

30
export interface DropdownProps {
31
  screenReaderText: string,
32
  screenReaderInstructions?: string,
33
  parentQuery?: string,
34
  onSelect?: (value: string, index: number, focusedItemData: Record<string, unknown> | undefined) => void,
35
  onToggle?: (
36
    isActive: boolean,
37
    prevValue: string,
38
    value: string,
39
    index: number,
40
    focusedItemData: Record<string, unknown> | undefined
41
  ) => void,
42
  className?: string,
43
  activeClassName?: string,
44
  alwaysSelectOption?: boolean
45
}
46

47
/**
48
 * Dropdown is the parent component for a set of Dropdown-related components.
49
 *
50
 * @remarks
51
 * It provides multiple shared contexts, which are consumed by its child components,
52
 * and also registers some global event listeners.
53
 */
54
export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Element {
8✔
55
  const { t } = useTranslation();
976✔
56
  const {
57
    children,
58
    screenReaderText,
59
    screenReaderInstructions,
60
    onSelect,
61
    onToggle,
62
    className,
63
    activeClassName,
64
    parentQuery,
65
    alwaysSelectOption = false
521✔
66
  } = props;
976✔
67

68
  const containerRef = useRef<HTMLDivElement>(null);
976✔
69
  const screenReaderUUID = useId('dropdown');
976✔
70
  const dropdownListUUID = useId('dropdown-list');
976✔
71
  const [screenReaderKey, setScreenReaderKey] = useState<number>(0);
976✔
72
  const [hasTyped, setHasTyped] = useState<boolean>(false);
976✔
73
  const [childrenWithDropdownItemsTransformed, items] = useMemo(() => {
976✔
74
    return getTransformedChildrenAndItemData(children);
688✔
75
  }, [children]);
76

77
  const inputContext = useInputContextInstance();
976✔
78
  const { value, setValue, lastTypedOrSubmittedValue, setLastTypedOrSubmittedValue } = inputContext;
976✔
79

80
  const focusContext = useFocusContextInstance(
613✔
81
    items,
82
    lastTypedOrSubmittedValue,
83
    setValue,
84
    screenReaderKey,
85
    setScreenReaderKey,
86
    alwaysSelectOption
87
  );
88
  const { focusedIndex, focusedItemData, updateFocusedItem } = focusContext;
976✔
89

90
  const dropdownContext = useDropdownContextInstance(
613✔
91
    lastTypedOrSubmittedValue,
92
    value,
93
    focusedIndex,
94
    focusedItemData,
95
    screenReaderUUID,
96
    dropdownListUUID,
97
    setHasTyped,
98
    onToggle,
99
    onSelect
100
  );
101
  const { toggleDropdown, isActive } = dropdownContext;
976✔
102

103
  useLayoutEffect(() => {
613✔
104
    if (parentQuery !== undefined && parentQuery !== lastTypedOrSubmittedValue) {
558!
105
      setLastTypedOrSubmittedValue(parentQuery);
22✔
106
      updateFocusedItem(-1, parentQuery);
22✔
107
    }
108
  }, [
109
    parentQuery,
110
    lastTypedOrSubmittedValue,
111
    updateFocusedItem,
112
    setLastTypedOrSubmittedValue
113
  ]);
114

115
  useRootClose(containerRef as React.RefObject<Element>, () => {
613✔
116
    toggleDropdown(false);
4✔
117
  }, { disabled: !isActive });
118

119
  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
120
    if (!isActive) {
224!
121
      return;
×
122
    }
123

124
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
224✔
125
      e.preventDefault();
10✔
126
    }
127

128
    if (e.key === 'ArrowDown') {
224✔
129
      if (alwaysSelectOption && focusedIndex === items.length - 1) {
10!
130
        updateFocusedItem(0);
×
131
      } else {
132
        updateFocusedItem(focusedIndex + 1);
10✔
133
      }
134
    } else if (e.key === 'ArrowUp') {
214!
135
      if (alwaysSelectOption && focusedIndex === 0) {
1!
136
        updateFocusedItem(items.length - 1);
×
137
      } else {
138
        updateFocusedItem(focusedIndex - 1);
1✔
139
      }
140
    } else if (e.key === 'Tab' && !e.shiftKey) {
214!
141
      updateFocusedItem(-1);
1✔
142
      toggleDropdown(false);
1✔
143
    } else if (!hasTyped) {
214✔
144
      setHasTyped(true);
43✔
145
    }
146
  }
147

148
  return (
613✔
149
    <div ref={containerRef} className={isActive ? activeClassName : className} onKeyDown={handleKeyDown}>
976✔
150
      <DropdownContext.Provider value={dropdownContext}>
151
        <InputContext.Provider value={inputContext}>
152
          <FocusContext.Provider value={focusContext}>
153
            {childrenWithDropdownItemsTransformed}
154
          </FocusContext.Provider>
155
        </InputContext.Provider>
156
      </DropdownContext.Provider>
157

158
      <ScreenReader
159
        announcementKey={screenReaderKey}
160
        announcementText={isActive && (hasTyped || items.length || value) ? screenReaderText : ''}
2,905✔
161
        instructionsId={screenReaderUUID}
162
        instructions={screenReaderInstructions ?? t('dropDownScreenReaderInstructions')}
1,952✔
163
      />
164
    </div>
165
  );
166
}
167

168
function useInputContextInstance(): InputContextType {
169
  const [value, setValue] = useState('');
976✔
170
  const [lastTypedOrSubmittedValue, setLastTypedOrSubmittedValue] = useState('');
976✔
171
  return {
613✔
172
    value,
173
    setValue,
174
    lastTypedOrSubmittedValue,
175
    setLastTypedOrSubmittedValue
176
  };
177
}
178

179
function useFocusContextInstance(
180
  items: DropdownItemData[],
181
  lastTypedOrSubmittedValue: string,
182
  setValue: (newValue: string) => void,
183
  screenReaderKey: number,
184
  setScreenReaderKey: (newKey: number) => void,
185
  alwaysSelectOption: boolean
186
): FocusContextType {
187
  const [focusedIndex, setFocusedIndex] = useState(-1);
976✔
188
  const [focusedValue, setFocusedValue] = useState<string | null>(null);
976✔
189
  const [focusedItemData, setFocusedItemData] = useState<Record<string, unknown> | undefined>(undefined);
976✔
190
  useEffect(() => {
613✔
191
    if (alwaysSelectOption) {
357✔
192
      if (items.length > 0) {
181✔
193
        const index = focusedIndex === -1 || focusedIndex >= items.length ? 0 : focusedIndex;
68✔
194
        setFocusedIndex(index);
57✔
195
        setFocusedValue(items[index].value);
57✔
196
        setFocusedItemData(items[index].itemData);
57✔
197
      } else {
198
        setFocusedIndex(-1);
124✔
199
        setFocusedValue(null);
124✔
200
        setFocusedItemData(undefined);
124✔
201
      }
202
    }
203
  }, [alwaysSelectOption, focusedIndex, items]);
204

205
  function updateFocusedItem(updatedFocusedIndex: number, value?: string) {
206
    const numItems = items.length;
546✔
207
    let updatedValue;
208
    if (updatedFocusedIndex === -1 || updatedFocusedIndex >= numItems || numItems === 0) {
273✔
209
      updatedValue = value ?? lastTypedOrSubmittedValue;
261✔
210
      if (alwaysSelectOption && numItems !== 0) {
261✔
211
        setFocusedIndex(0);
27✔
212
        setFocusedItemData(items[0].itemData);
27✔
213
        setScreenReaderKey(screenReaderKey + 1);
27✔
214
      } else {
215
        setFocusedIndex(-1);
260✔
216
        setFocusedItemData(undefined);
260✔
217
        setScreenReaderKey(screenReaderKey + 1);
260✔
218
      }
219
    } else if (updatedFocusedIndex < -1) {
24!
220
      const loopedAroundIndex = (numItems + updatedFocusedIndex + 1) % numItems;
×
221
      updatedValue = value ?? items[loopedAroundIndex].value;
×
222
      setFocusedIndex(loopedAroundIndex);
×
223
      setFocusedItemData(items[loopedAroundIndex].itemData);
×
224
    } else {
225
      updatedValue = value ?? items[updatedFocusedIndex].value;
24✔
226
      setFocusedIndex(updatedFocusedIndex);
24✔
227
      setFocusedItemData(items[updatedFocusedIndex].itemData);
24✔
228
    }
229
    setFocusedValue(updatedValue);
273✔
230
    setValue(alwaysSelectOption ? (value ?? lastTypedOrSubmittedValue) : updatedValue);
273✔
231
  }
232

233
  return {
613✔
234
    focusedIndex,
235
    focusedValue,
236
    focusedItemData,
237
    updateFocusedItem
238
  };
239
}
240

241
function useDropdownContextInstance(
242
  prevValue: string,
243
  value: string,
244
  index: number,
245
  focusedItemData: Record<string, unknown> | undefined,
246
  screenReaderUUID: string | undefined,
247
  dropdownListUUID: string | undefined,
248
  setHasTyped: (hasTyped: boolean) => void,
249
  onToggle?: (
250
    isActive: boolean,
251
    prevValue: string,
252
    value: string,
253
    index: number,
254
    focusedItemData: Record<string, unknown> | undefined
255
  ) => void,
256
  onSelect?: (value: string, index: number, focusedItemData: Record<string, unknown> | undefined) => void
257
): DropdownContextType {
258
  const [isActive, _toggleDropdown] = useState(false);
976✔
259
  const toggleDropdown = (willBeOpen: boolean) => {
976✔
260
    if (!willBeOpen) {
590✔
261
      setHasTyped(false);
397✔
262
    }
263
    _toggleDropdown(willBeOpen);
590✔
264
    onToggle?.(willBeOpen, prevValue, value, index, focusedItemData);
590✔
265
  };
266
  return {
613✔
267
    isActive,
268
    toggleDropdown,
269
    onSelect,
270
    screenReaderUUID,
271
    dropdownListUUID
272
  };
273
}
274

275
function getTransformedChildrenAndItemData(children: ReactNode): [ReactNode, DropdownItemData[]] {
276
  const items: DropdownItemData [] = [];
598✔
277
  const childrenWithDropdownItemsTransformed = recursivelyMapChildren(children, (child => {
598✔
278
    if (!(isValidElement(child) && child.type === DropdownItem)) {
3,498✔
279
      return child;
3,185✔
280
    }
281
    const props = child.props as DropdownItemProps;
484✔
282
    items.push({
444✔
283
      value: props.value,
284
      itemData: props.itemData
285
    });
286
    return createElement(DropdownItemWithIndex, { ...props, index: items.length - 1 });
444✔
287
  }));
288
  return [childrenWithDropdownItemsTransformed, items];
325✔
289
}
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