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

alma-oss / spirit-design-system / 29920932531

22 Jul 2026 12:38PM UTC coverage: 85.616% (-0.8%) from 86.381%
29920932531

Pull #2814

github

pavelklibani
fixup! feat(web-react): introduce `UNSTABLE_Combobox` component #DS-2304
Pull Request #2814: 🚧 [WIP] feat(web-react): introduce `UNSTABLE_Combobox` component #DS-2304

2869 of 3878 branches covered (73.98%)

Branch coverage included in aggregate %.

401 of 492 new or added lines in 16 files covered. (81.5%)

7738 of 8511 relevant lines covered (90.92%)

226.44 hits per line

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

48.3
/packages/web-react/src/components/UNSTABLE_Combobox/useComboboxOptionGridKeyboard.ts
1
'use client';
2

3
import { type KeyboardEvent, type RefObject, useCallback } from 'react';
174✔
4
import { getVisibleOptionRows } from './utils';
174✔
5

6
export interface UseComboboxOptionGridKeyboardProps {
7
  listboxRef: RefObject<HTMLElement | null>;
8
  inputRef: RefObject<HTMLInputElement | null>;
9
  isOpen: boolean;
10
  isDisabled?: boolean;
11
  inputValue: string;
12
  onInputChange: (value: string) => void;
13
  onOpen: () => void;
14
  onClose: () => void;
15
  onToggleOption: (optionId: string) => void;
16
  setActiveDescendantId: (id: string | undefined) => void;
17
}
18

19
/**
20
 * Focus an option row and sync `aria-activedescendant` on the combobox input.
21
 *
22
 * @param rowEl Option row
23
 * @param inputRef Combobox input
24
 * @param setActiveDescendantId State setter for activedescendant
25
 */
26
const focusOptionRow = (
174✔
27
  rowEl: HTMLElement | null | undefined,
28
  inputRef: RefObject<HTMLInputElement | null>,
29
  setActiveDescendantId: (id: string | undefined) => void,
30
) => {
31
  if (!rowEl) {
3!
NEW
32
    return;
×
33
  }
34

35
  rowEl.focus();
3✔
36
  rowEl.scrollIntoView({ block: 'nearest' });
3✔
37

38
  if (rowEl.id) {
3!
39
    setActiveDescendantId(rowEl.id);
3✔
40
    inputRef.current?.setAttribute('aria-activedescendant', rowEl.id);
3✔
41
  }
42
};
43

44
/**
45
 * Keyboard navigation for the combobox option grid and the filter input.
46
 *
47
 * @param props Hook configuration
48
 * @param props.listboxRef
49
 * @param props.inputRef
50
 * @param props.isOpen
51
 * @param props.isDisabled
52
 * @param props.inputValue
53
 * @param props.onInputChange
54
 * @param props.onOpen
55
 * @param props.onClose
56
 * @param props.onToggleOption
57
 * @param props.setActiveDescendantId
58
 * @returns {{ onInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void, onListboxKeyDown: (event: KeyboardEvent<HTMLElement>) => void }}
59
 */
60
export const useComboboxOptionGridKeyboard = ({
174✔
61
  listboxRef,
62
  inputRef,
63
  isOpen,
64
  isDisabled = false,
×
65
  inputValue,
66
  onInputChange,
67
  onOpen,
68
  onClose,
69
  onToggleOption,
70
  setActiveDescendantId,
71
}: UseComboboxOptionGridKeyboardProps): {
72
  onInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
73
  onListboxKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
74
} => {
75
  const clearActiveDescendant = useCallback(() => {
51✔
76
    setActiveDescendantId(undefined);
1✔
77
    inputRef.current?.removeAttribute('aria-activedescendant');
1✔
78
  }, [inputRef, setActiveDescendantId]);
79

80
  const onInputKeyDown = useCallback(
51✔
81
    (event: KeyboardEvent<HTMLInputElement>) => {
82
      if (isDisabled) {
4!
NEW
83
        return;
×
84
      }
85

86
      if (event.key === 'Escape') {
4✔
87
        event.preventDefault();
1✔
88
        clearActiveDescendant();
1✔
89
        onClose();
1✔
90

91
        return;
1✔
92
      }
93

94
      if (event.key === 'Tab') {
3!
NEW
95
        clearActiveDescendant();
×
NEW
96
        onClose();
×
97

NEW
98
        return;
×
99
      }
100

101
      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3!
102
        event.preventDefault();
3✔
103

104
        if (!isOpen) {
3!
NEW
105
          onOpen();
×
106
        }
107

108
        const visible = getVisibleOptionRows(listboxRef.current);
3✔
109

110
        if (!visible.length) {
3!
NEW
111
          return;
×
112
        }
113

114
        focusOptionRow(
3✔
115
          event.key === 'ArrowDown' ? visible[0] : visible[visible.length - 1],
3!
116
          inputRef,
117
          setActiveDescendantId,
118
        );
119
      }
120
    },
121
    [clearActiveDescendant, inputRef, isDisabled, isOpen, listboxRef, onClose, onOpen, setActiveDescendantId],
122
  );
123

124
  const onListboxKeyDown = useCallback(
51✔
125
    (event: KeyboardEvent<HTMLElement>) => {
126
      if (isDisabled) {
1!
NEW
127
        return;
×
128
      }
129

130
      const visible = getVisibleOptionRows(listboxRef.current);
1✔
131
      const focused = document.activeElement;
1✔
132
      const currentIndex = focused instanceof HTMLElement ? visible.indexOf(focused) : -1;
1!
133

134
      if (currentIndex === -1) {
1!
NEW
135
        return;
×
136
      }
137

138
      if (event.key === 'Escape') {
1!
NEW
139
        event.preventDefault();
×
NEW
140
        clearActiveDescendant();
×
NEW
141
        onClose();
×
NEW
142
        inputRef.current?.focus();
×
143

NEW
144
        return;
×
145
      }
146

147
      if (event.key === 'Tab') {
1!
NEW
148
        if (event.shiftKey) {
×
NEW
149
          event.preventDefault();
×
NEW
150
          clearActiveDescendant();
×
NEW
151
          onClose();
×
NEW
152
          inputRef.current?.focus();
×
153
        } else {
NEW
154
          clearActiveDescendant();
×
NEW
155
          onClose();
×
156
        }
157

NEW
158
        return;
×
159
      }
160

161
      if (event.key === 'ArrowDown') {
1!
NEW
162
        event.preventDefault();
×
163

NEW
164
        if (currentIndex < visible.length - 1) {
×
NEW
165
          focusOptionRow(visible[currentIndex + 1], inputRef, setActiveDescendantId);
×
166
        }
167

NEW
168
        return;
×
169
      }
170

171
      if (event.key === 'ArrowUp') {
1!
NEW
172
        event.preventDefault();
×
173

NEW
174
        if (currentIndex > 0) {
×
NEW
175
          focusOptionRow(visible[currentIndex - 1], inputRef, setActiveDescendantId);
×
176
        } else {
NEW
177
          clearActiveDescendant();
×
NEW
178
          inputRef.current?.focus();
×
179
        }
180

NEW
181
        return;
×
182
      }
183

184
      if (event.key === 'Home') {
1!
NEW
185
        event.preventDefault();
×
NEW
186
        focusOptionRow(visible[0], inputRef, setActiveDescendantId);
×
187

NEW
188
        return;
×
189
      }
190

191
      if (event.key === 'End') {
1!
NEW
192
        event.preventDefault();
×
NEW
193
        focusOptionRow(visible[visible.length - 1], inputRef, setActiveDescendantId);
×
194

NEW
195
        return;
×
196
      }
197

198
      if (event.key === 'Enter' || event.key === ' ') {
1!
199
        event.preventDefault();
1✔
200

201
        const optionId = visible[currentIndex]?.id;
1✔
202

203
        if (optionId && visible[currentIndex]?.getAttribute('aria-disabled') !== 'true') {
1!
204
          onToggleOption(optionId);
1✔
205
        }
206

207
        return;
1✔
208
      }
209

NEW
210
      if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
×
NEW
211
        onInputChange(inputValue + event.key);
×
NEW
212
        clearActiveDescendant();
×
NEW
213
        inputRef.current?.focus();
×
214
      }
215
    },
216
    [
217
      clearActiveDescendant,
218
      inputRef,
219
      inputValue,
220
      isDisabled,
221
      listboxRef,
222
      onClose,
223
      onInputChange,
224
      onToggleOption,
225
      setActiveDescendantId,
226
    ],
227
  );
228

229
  return { onInputKeyDown, onListboxKeyDown };
51✔
230
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc