@bspk/ui 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/components/DateInput/DateInput.js +1 -0
  2. package/dist/components/DateInput/DateInput.js.map +1 -1
  3. package/dist/components/ListItem/ListItem.d.ts +2 -2
  4. package/dist/components/ListItem/ListItem.js +11 -7
  5. package/dist/components/ListItem/ListItem.js.map +1 -1
  6. package/dist/components/ListItem/ListItemExample.d.ts +1 -1
  7. package/dist/components/ListItem/ListItemExample.js +72 -3
  8. package/dist/components/ListItem/ListItemExample.js.map +1 -1
  9. package/dist/components/ListItem/list-item.css +8 -6
  10. package/dist/components/ListItem/list-item.css.js +8 -6
  11. package/dist/components/ListItemMenu/ListItemMenu.d.ts +1 -1
  12. package/dist/components/ListItemMenu/ListItemMenu.js.map +1 -1
  13. package/dist/components/NumberInput/NumberInput.d.ts +2 -2
  14. package/dist/components/NumberInput/NumberInput.js +7 -20
  15. package/dist/components/NumberInput/NumberInput.js.map +1 -1
  16. package/dist/components/Popover/Popover.js +1 -0
  17. package/dist/components/Popover/Popover.js.map +1 -1
  18. package/dist/components/ProgressionStepper/ProgressionStepper.d.ts +3 -7
  19. package/dist/components/ProgressionStepper/ProgressionStepper.js +9 -5
  20. package/dist/components/ProgressionStepper/ProgressionStepper.js.map +1 -1
  21. package/dist/components/ProgressionStepper/ProgressionStepperExample.js +20 -4
  22. package/dist/components/ProgressionStepper/ProgressionStepperExample.js.map +1 -1
  23. package/dist/components/SearchBar/SearchBar.d.ts +36 -35
  24. package/dist/components/SearchBar/SearchBar.js +100 -49
  25. package/dist/components/SearchBar/SearchBar.js.map +1 -1
  26. package/dist/components/SearchBar/SearchBarExample.js +2 -1
  27. package/dist/components/SearchBar/SearchBarExample.js.map +1 -1
  28. package/dist/components/Select/Select.d.ts +2 -2
  29. package/dist/components/Select/Select.js +3 -5
  30. package/dist/components/Select/Select.js.map +1 -1
  31. package/dist/components/TabList/TabList.js +2 -1
  32. package/dist/components/TabList/TabList.js.map +1 -1
  33. package/dist/components/TabList/tab-list.css +6 -0
  34. package/dist/components/TabList/tab-list.css.js +6 -0
  35. package/dist/components/TimeInput/Segment.js +1 -1
  36. package/dist/components/TimeInput/Segment.js.map +1 -1
  37. package/dist/components/TimeInput/TimeInput.js +3 -1
  38. package/dist/components/TimeInput/TimeInput.js.map +1 -1
  39. package/dist/hooks/useArrowNavigation.js +6 -1
  40. package/dist/hooks/useArrowNavigation.js.map +1 -1
  41. package/dist/hooks/useOutsideClick.d.ts +4 -3
  42. package/dist/hooks/useOutsideClick.js +13 -2
  43. package/dist/hooks/useOutsideClick.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/DateInput/DateInput.tsx +1 -0
  46. package/src/components/ListItem/ListItem.rtl.test.tsx +4 -1
  47. package/src/components/ListItem/ListItem.tsx +19 -12
  48. package/src/components/ListItem/ListItemExample.tsx +74 -4
  49. package/src/components/ListItem/list-item.scss +21 -17
  50. package/src/components/ListItemMenu/ListItemMenu.tsx +4 -3
  51. package/src/components/NumberInput/NumberInput.tsx +14 -27
  52. package/src/components/Popover/Popover.tsx +1 -0
  53. package/src/components/ProgressionStepper/ProgressionStepper.tsx +24 -15
  54. package/src/components/ProgressionStepper/ProgressionStepperExample.tsx +20 -4
  55. package/src/components/SearchBar/SearchBar.rtl.test.tsx +0 -1
  56. package/src/components/SearchBar/SearchBar.tsx +192 -115
  57. package/src/components/SearchBar/SearchBarExample.tsx +2 -1
  58. package/src/components/Select/Select.tsx +6 -12
  59. package/src/components/TabList/TabList.tsx +2 -1
  60. package/src/components/TabList/tab-list.scss +12 -0
  61. package/src/components/TimeInput/Segment.tsx +1 -1
  62. package/src/components/TimeInput/TimeInput.tsx +3 -0
  63. package/src/hooks/useArrowNavigation.ts +6 -1
  64. package/src/hooks/useOutsideClick.ts +16 -3
@@ -1,17 +1,32 @@
1
1
  import './search-bar.scss';
2
2
  import { SvgSearch } from '@bspk/icons/Search';
3
- import { useEffect, useRef, useState } from 'react';
4
- import { ListItemMenu, ListItemMenuProps, MenuListItem } from '-/components/ListItemMenu';
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { ListItem, ListItemProps } from '-/components/ListItem';
5
+ import { Menu } from '-/components/Menu';
5
6
  import { TextInputProps, TextInput } from '-/components/TextInput';
6
7
  import { Txt } from '-/components/Txt';
8
+ import { useArrowNavigation } from '-/hooks/useArrowNavigation';
9
+ import { useFloating } from '-/hooks/useFloating';
7
10
  import { useId } from '-/hooks/useId';
11
+ import { useOutsideClick } from '-/hooks/useOutsideClick';
8
12
  import { useUIContext } from '-/hooks/useUIContext';
13
+ import { getElementById } from '-/utils/dom';
14
+ import { handleKeyDown } from '-/utils/handleKeyDown';
15
+ import { scrollListItemsStyle, ScrollListItemsStyleProps } from '-/utils/scrollListItemsStyle';
9
16
  import { useIds } from '-/utils/useIds';
10
17
 
11
- export type SearchOption = MenuListItem & { value: string };
18
+ /**
19
+ * An option in a SearchBar component.
20
+ *
21
+ * Essentially the props for a ListItem.
22
+ */
23
+ export type SearchBarOption = Pick<ListItemProps, 'label' | 'leading' | 'trailing'>;
12
24
 
13
- export type SearchBarProps = Pick<ListItemMenuProps, 'scrollLimit'> &
14
- Pick<TextInputProps, 'aria-label' | 'disabled' | 'id' | 'inputRef' | 'name' | 'size'> & {
25
+ export type SearchBarProps<O extends SearchBarOption = SearchBarOption> = Pick<
26
+ TextInputProps,
27
+ 'aria-label' | 'disabled' | 'id' | 'inputRef' | 'name' | 'size'
28
+ > &
29
+ ScrollListItemsStyleProps & {
15
30
  /** The current value of the search bar. */
16
31
  value?: string;
17
32
  /**
@@ -23,39 +38,33 @@ export type SearchBarProps = Pick<ListItemMenuProps, 'scrollLimit'> &
23
38
  */
24
39
  placeholder: string;
25
40
  /**
26
- * Handler for state updates.
27
- *
28
- * @type (value: String) => void
29
- * @required
30
- */
31
- onChange: (value: string) => void;
32
- /*
33
- * Handler for item selection.
41
+ * Handler for input value change. This is called on every key press in the input field and when a menu item is
42
+ * selected.
34
43
  *
35
- * @type (item: MenuListItem) => void
44
+ * @type (value: String, item?: SearchBarOption) => void
36
45
  * @required
37
46
  */
38
- onSelect: (item?: MenuListItem) => void;
47
+ onChange: (value: string, item?: O) => void;
39
48
  /**
40
49
  * Content to display in the menu.
41
50
  *
42
51
  * @example
43
52
  * [
44
- * { value: '1', label: 'Apple Pie' },
45
- * { value: '2', label: 'Banana Split' },
46
- * { value: '3', label: 'Cherry Tart' },
47
- * { value: '4', label: 'Dragonfruit Sorbet' },
48
- * { value: '5', label: 'Elderberry Jam' },
49
- * { value: '6', label: 'Fig Newton' },
50
- * { value: '7', label: 'Grape Soda' },
51
- * { value: '8', label: 'Honeydew Smoothie' },
52
- * { value: '9', label: 'Ice Cream Sandwich' },
53
- * { value: '10', label: 'Jackfruit Pudding' },
53
+ * { label: 'Apple Pie' },
54
+ * { label: 'Banana Split' },
55
+ * { label: 'Cherry Tart' },
56
+ * { label: 'Dragonfruit Sorbet' },
57
+ * { label: 'Elderberry Jam' },
58
+ * { label: 'Fig Newton' },
59
+ * { label: 'Grape Soda' },
60
+ * { label: 'Honeydew Smoothie' },
61
+ * { label: 'Ice Cream Sandwich' },
62
+ * { label: 'Jackfruit Pudding' },
54
63
  * ];
55
64
  *
56
- * @type Array<SearchOption>
65
+ * @type Array<SearchBarOption>
57
66
  */
58
- items?: SearchOption[];
67
+ items?: O[];
59
68
  /**
60
69
  * Message to display when no results are found
61
70
  *
@@ -74,28 +83,25 @@ export type SearchBarProps = Pick<ListItemMenuProps, 'scrollLimit'> &
74
83
  * export function Example() {
75
84
  * const [searchText, setSearchText] = useState<string>('');
76
85
  *
77
- * const handleItemSelect = (item) => console.log('Selected item:', item);
78
- *
79
86
  * return (
80
87
  * <SearchBar
81
88
  * aria-label="Example aria-label"
82
89
  * items={[
83
- * { value: '1', label: 'Apple Pie' },
84
- * { value: '2', label: 'Banana Split' },
85
- * { value: '3', label: 'Cherry Tart' },
86
- * { value: '4', label: 'Dragonfruit Sorbet' },
87
- * { value: '5', label: 'Elderberry Jam' },
88
- * { value: '6', label: 'Fig Newton' },
89
- * { value: '7', label: 'Grape Soda' },
90
- * { value: '8', label: 'Honeydew Smoothie' },
91
- * { value: '9', label: 'Ice Cream Sandwich' },
92
- * { value: '10', label: 'Jackfruit Pudding' },
90
+ * { label: 'Apple Pie' },
91
+ * { label: 'Banana Split' },
92
+ * { label: 'Cherry Tart' },
93
+ * { label: 'Dragonfruit Sorbet' },
94
+ * { label: 'Elderberry Jam' },
95
+ * { label: 'Fig Newton' },
96
+ * { label: 'Grape Soda' },
97
+ * { label: 'Honeydew Smoothie' },
98
+ * { label: 'Ice Cream Sandwich' },
99
+ * { label: 'Jackfruit Pudding' },
93
100
  * ]}
94
101
  * name="Example name"
95
102
  * placeholder="Search"
96
103
  * value={searchText}
97
104
  * onChange={setSearchText}
98
- * onSelect={handleItemSelect}
99
105
  * />
100
106
  * );
101
107
  * }
@@ -103,26 +109,31 @@ export type SearchBarProps = Pick<ListItemMenuProps, 'scrollLimit'> &
103
109
  * @name SearchBar
104
110
  * @phase UXReview
105
111
  */
106
- export function SearchBar({
112
+ export function SearchBar<O extends SearchBarOption>({
107
113
  items: itemsProp,
108
114
  noResultsMessage,
109
115
  placeholder = 'Search',
110
116
  'aria-label': ariaLabel,
111
- value: idProp,
117
+ id: idProp,
112
118
  inputRef,
113
119
  name,
114
120
  size = 'medium',
115
- onSelect,
116
121
  value,
117
122
  onChange,
118
123
  disabled = false,
119
124
  scrollLimit,
120
- }: SearchBarProps) {
125
+ }: SearchBarProps<O>) {
121
126
  const id = useId(idProp);
127
+ const menuId = `${id}-menu`;
122
128
 
123
129
  const items = useIds(`search-bar-${id}`, itemsProp || []);
124
130
 
125
- const inputElementRef = useRef<HTMLInputElement | null>(null);
131
+ const [hasFocus, setHasFocus] = useState(false);
132
+
133
+ const filteredItems = useMemo(() => {
134
+ const valueStr = value?.toString().trim().toLowerCase() || '';
135
+ return items.filter((item) => !valueStr || item.label.toLowerCase().includes(valueStr));
136
+ }, [items, value]);
126
137
 
127
138
  const { sendAriaLiveMessage } = useUIContext();
128
139
 
@@ -130,88 +141,154 @@ export function SearchBar({
130
141
  if (!items.length) sendAriaLiveMessage('No results found', 'assertive');
131
142
  }, [items.length, sendAriaLiveMessage, value]);
132
143
 
133
- const [textValue, setTextValue] = useState(value || '');
144
+ const { activeElementId, setActiveElementId, arrowKeyCallbacks } = useArrowNavigation({
145
+ ids: filteredItems.map((i) => i.id),
146
+ });
147
+
148
+ const closeMenu = () => setActiveElementId(null);
149
+ const open = Boolean(activeElementId);
150
+
151
+ const { elements, floatingStyles } = useFloating({
152
+ hide: !open,
153
+ offsetOptions: 4,
154
+ refWidth: true,
155
+ });
156
+
157
+ useOutsideClick({
158
+ elements: [elements.floating, elements.reference],
159
+ callback: () => {
160
+ setHasFocus(false);
161
+ closeMenu();
162
+ },
163
+ disabled: !open,
164
+ handleTabs: true,
165
+ });
166
+
167
+ const spaceEnter = () => {
168
+ if (!open) {
169
+ elements.reference?.click();
170
+ return;
171
+ }
172
+ if (activeElementId) getElementById(activeElementId)?.click();
173
+ };
134
174
 
135
175
  useEffect(() => {
136
- setTextValue(items.find((item) => item.value === value)?.label || '');
137
- }, [items, value]);
176
+ if (!hasFocus) {
177
+ setActiveElementId(null);
178
+ return;
179
+ }
180
+
181
+ if (activeElementId) return;
182
+
183
+ // If we have focus but no active element, set the first item as active (if there is one)
184
+ if (filteredItems.length) {
185
+ setActiveElementId(value?.trim().length ? filteredItems[0].id : null);
186
+ }
187
+ }, [hasFocus, filteredItems, activeElementId, setActiveElementId, value]);
138
188
 
139
189
  return (
140
190
  <>
141
191
  <div data-bspk="search-bar">
142
- <ListItemMenu
143
- arrowKeyNavigationCallback={(params) => {
144
- // maintain default behavior for arrow keys left/right
145
- return params.key !== 'ArrowLeft' && params.key !== 'ArrowRight';
146
- }}
192
+ <TextInput
193
+ aria-label={ariaLabel}
194
+ autoComplete="off"
195
+ containerRef={elements.setReference}
147
196
  disabled={disabled}
148
- itemOnClick={({ currentId, setShow }) => {
149
- const item = items.find((i) => i.id === currentId)!;
150
- onSelect(item);
151
- onChange(item.value);
152
- setTextValue(item.label);
153
- setShow(false);
197
+ id={id}
198
+ inputProps={{
199
+ 'aria-controls': open ? menuId : undefined,
200
+ 'aria-expanded': open,
201
+ 'aria-haspopup': 'listbox',
202
+ 'aria-activedescendant': activeElementId || undefined,
203
+ 'aria-autocomplete': 'list',
204
+ role: 'combobox',
205
+ spellCheck: 'false',
154
206
  }}
155
- items={items.map((item) => {
156
- return {
157
- ...item,
158
- 'aria-selected': item.value === value,
159
- };
160
- })}
161
- label="Search bar"
162
- leading={
163
- !!value?.length &&
164
- !items?.length && (
165
- <div data-bspk="no-items-found">
166
- <Txt as="div" variant="heading-h5">
167
- No results found
168
- </Txt>
169
- {noResultsMessage && (
170
- <Txt as="div" variant="body-base">
171
- {noResultsMessage}
172
- </Txt>
173
- )}
174
- </div>
175
- )
176
- }
177
- onClose={() => {
178
- setTimeout(() => {
179
- if (!inputElementRef.current) return;
180
- inputElementRef.current.focus();
181
- inputElementRef.current.setSelectionRange(0, inputElementRef.current.value.length);
182
- }, 100);
207
+ inputRef={(node) => {
208
+ if (!node) return;
209
+ inputRef?.(node);
183
210
  }}
211
+ leading={<SvgSearch />}
212
+ name={name}
213
+ onChange={(str) => onChange(str)}
214
+ onFocus={() => setHasFocus(true)}
215
+ onKeyDown={handleKeyDown(
216
+ {
217
+ ...arrowKeyCallbacks,
218
+ ArrowDown: (event) => {
219
+ if (!open) spaceEnter();
220
+ arrowKeyCallbacks.ArrowDown?.(event);
221
+ },
222
+ Space: spaceEnter,
223
+ Enter: spaceEnter,
224
+ 'Ctrl+Option+Space': spaceEnter,
225
+ },
226
+ { preventDefault: true, stopPropagation: true },
227
+ )}
184
228
  owner="search-bar"
185
- role="listbox"
186
- scrollLimit={scrollLimit}
187
- >
188
- {(toggleProps, { setRef, toggleMenu }) => (
189
- <TextInput
190
- aria-label={(items.length === 0 ? 'No results found' : '') + ariaLabel}
191
- autoComplete="off"
192
- containerRef={setRef}
193
- disabled={disabled}
194
- id={id}
195
- inputProps={{ ...toggleProps }}
196
- inputRef={(node) => {
197
- if (!node) return;
198
- inputRef?.(node);
199
- inputElementRef.current = node;
200
- }}
201
- leading={<SvgSearch />}
202
- name={name}
203
- onChange={(str) => {
204
- setTextValue(str);
205
- if (str.length) toggleMenu(true);
229
+ placeholder={placeholder}
230
+ size={size}
231
+ value={value}
232
+ />
233
+ </div>
234
+ <Menu
235
+ aria-autocomplete={undefined}
236
+ as="div"
237
+ id={menuId}
238
+ innerRef={elements.setFloating}
239
+ label="Search results"
240
+ onClickCapture={() => {
241
+ // Prevent the menu from closing when clicking inside it
242
+ // maintain focus on the select control
243
+ elements.reference?.focus();
244
+ }}
245
+ onFocus={() => {
246
+ elements.reference?.focus();
247
+ }}
248
+ owner="select"
249
+ role="listbox"
250
+ style={{
251
+ ...(open ? scrollListItemsStyle(scrollLimit, items.length) : {}),
252
+ ...floatingStyles,
253
+ }}
254
+ tabIndex={-1}
255
+ >
256
+ {!!value?.length && !items?.length && (
257
+ <div data-bspk="no-items-found">
258
+ <Txt as="div" variant="heading-h5">
259
+ No results found
260
+ </Txt>
261
+ {noResultsMessage && (
262
+ <Txt as="div" variant="body-base">
263
+ {noResultsMessage}
264
+ </Txt>
265
+ )}
266
+ </div>
267
+ )}
268
+ {filteredItems.map((item) => {
269
+ const isActive = activeElementId === item.id;
270
+ const isSelected = value == item.label;
271
+
272
+ return (
273
+ <ListItem
274
+ key={item.id}
275
+ {...item}
276
+ active={isActive || undefined}
277
+ aria-label={undefined}
278
+ aria-selected={isSelected}
279
+ as="li"
280
+ onClick={() => {
281
+ onChange(item.label, item);
282
+ closeMenu();
206
283
  }}
207
- owner="search-bar"
208
- placeholder={placeholder}
209
- size={size}
210
- value={textValue}
284
+ owner="select"
285
+ role="option"
286
+ tabIndex={-1} //show && isActive ? -1 : 0}
287
+ value={undefined}
211
288
  />
212
- )}
213
- </ListItemMenu>
214
- </div>
289
+ );
290
+ })}
291
+ </Menu>
215
292
  </>
216
293
  );
217
294
  }
@@ -1,8 +1,9 @@
1
1
  import { SearchBarProps } from '.';
2
2
  import { ComponentExample } from '-/utils/demo';
3
+ import { randomString } from '-/utils/random';
3
4
 
4
5
  export const SearchBarExample: ComponentExample<SearchBarProps> = {
5
6
  render: ({ props, Component }) => {
6
- return <Component {...props} />;
7
+ return <Component {...props} id={`search-bar-${randomString(8)}`} />;
7
8
  },
8
9
  };
@@ -15,9 +15,10 @@ import { scrollListItemsStyle, ScrollListItemsStyleProps } from '-/utils/scrollL
15
15
  /**
16
16
  * An option in a Select component.
17
17
  *
18
- * Essentially the props of ListItemProps. Except for `value` which is required.
18
+ * Essentially the props of ListItemProps.
19
19
  */
20
- export type SelectOption = Omit<ListItemProps, 'id' | 'onClick' | 'subText' | 'value'> & { value: string };
20
+ export type SelectOption = CommonProps<'disabled'> &
21
+ Omit<ListItemProps, 'id' | 'onClick' | 'subText' | 'value'> & { value: string };
21
22
 
22
23
  export type SelectItem = SelectOption & { id: string };
23
24
 
@@ -140,19 +141,13 @@ export function Select({
140
141
  return { items: nextItems, availableItems: nextItems.filter((item) => !item.disabled) };
141
142
  }, [optionsProp, id, value]);
142
143
 
143
- const closeMenu = () => {
144
- setActiveElementId(null);
145
- };
146
-
147
- const selectedItem = useMemo(
148
- (): SelectItem | undefined => items.find((o) => o.value === value?.[0]),
149
- [items, value],
150
- );
144
+ const selectedItem = useMemo((): SelectItem | undefined => items.find((o) => o.value === value), [items, value]);
151
145
 
152
146
  const { activeElementId, setActiveElementId, arrowKeyCallbacks } = useArrowNavigation({
153
147
  ids: availableItems.map((i) => i.id),
154
148
  });
155
149
 
150
+ const closeMenu = () => setActiveElementId(null);
156
151
  const open = Boolean(activeElementId);
157
152
 
158
153
  const { elements, floatingStyles } = useFloating({
@@ -172,7 +167,6 @@ export function Select({
172
167
  elements.reference?.click();
173
168
  return;
174
169
  }
175
-
176
170
  if (activeElementId) getElementById(activeElementId)?.click();
177
171
  };
178
172
 
@@ -284,7 +278,7 @@ export function Select({
284
278
  aria-selected={isSelected}
285
279
  as="li"
286
280
  onClick={() => {
287
- if (item.disabled || item.readOnly) return;
281
+ if (item.disabled) return;
288
282
  onChange(item.value);
289
283
  closeMenu();
290
284
  }}
@@ -208,6 +208,7 @@ export function TabList({
208
208
  const isSelected = item.value === value;
209
209
  const icon = isSelected ? item.iconSelected : item.icon;
210
210
  const isActive = (activeElementId && activeElementId === item.id) || undefined;
211
+ const focusable = (isSelected && !activeElementId) || isActive;
211
212
 
212
213
  return (
213
214
  <Fragment key={item.id}>
@@ -224,7 +225,7 @@ export function TabList({
224
225
  id={item.id}
225
226
  onClick={item.disabled ? undefined : handleClick(item)}
226
227
  role="tab"
227
- tabIndex={isActive ? 0 : -1}
228
+ tabIndex={focusable ? 0 : -1}
228
229
  >
229
230
  {icon && <span aria-hidden="true">{icon}</span>}
230
231
  {!iconsOnly && <Truncated data-label>{item.label}</Truncated>}
@@ -68,6 +68,18 @@ ul[data-bspk-utility='tab-list'] {
68
68
  width: var(--icon-size);
69
69
  }
70
70
  }
71
+
72
+ &:not([data-bspk]):focus-within {
73
+ li:not([aria-disabled]) {
74
+ &[data-active] {
75
+ background-color: var(--interactions-neutral-hover-opacity);
76
+ }
77
+
78
+ &[aria-selected='true'] {
79
+ background-color: var(--surface-brand-primary-highlight);
80
+ }
81
+ }
82
+ }
71
83
  }
72
84
 
73
85
  /** Copyright 2025 Anywhere Real Estate - CC BY 4.0 */
@@ -201,7 +201,7 @@ export function TimeInputSegment<T extends string>({
201
201
  }}
202
202
  role="spinbutton"
203
203
  spellCheck="false"
204
- tabIndex={0}
204
+ tabIndex={disabled || readOnly ? -1 : 0}
205
205
  />
206
206
  );
207
207
  }
@@ -147,6 +147,7 @@ export function TimeInput({
147
147
  data-value={inputValue || undefined}
148
148
  id={id}
149
149
  onClickCapture={() => {
150
+ if (disabled || readOnly) return;
150
151
  elements.reference?.querySelector<HTMLElement>('[tabIndex]')?.focus();
151
152
  }}
152
153
  onKeyDownCapture={handleKeyDown({ Escape: () => setOpen(false) })}
@@ -154,6 +155,7 @@ export function TimeInput({
154
155
  elements.setReference(node);
155
156
  }}
156
157
  role="group"
158
+ tabIndex={disabled || readOnly ? -1 : 0}
157
159
  >
158
160
  <TimeInputSegment
159
161
  disabled={disabled}
@@ -181,6 +183,7 @@ export function TimeInput({
181
183
  value={meridiem}
182
184
  />
183
185
  <Button
186
+ disabled={disabled || readOnly}
184
187
  icon={<SvgSchedule />}
185
188
  iconOnly
186
189
  innerRef={(node) => {
@@ -1,4 +1,4 @@
1
- import { useState, KeyboardEvent } from 'react';
1
+ import { useState, KeyboardEvent, useEffect } from 'react';
2
2
  import { getElementById } from '-/utils/dom';
3
3
  import { KeysCallback } from '-/utils/handleKeyDown';
4
4
  import { KeyboardEventCode } from '-/utils/keyboard';
@@ -66,6 +66,11 @@ export function useArrowNavigation({
66
66
  } {
67
67
  const [activeElementId, setActiveElementIdBase] = useState<string | null>(defaultActiveId || null);
68
68
 
69
+ useEffect(() => {
70
+ // If the active element is not in the list, reset the first ID as active
71
+ if (activeElementId && !ids.includes(activeElementId)) setActiveElementIdBase(ids[0]);
72
+ }, [ids, activeElementId]);
73
+
69
74
  const setActiveElementId = (id: string | null) => {
70
75
  setActiveElementIdBase(id);
71
76
  getElementById(id)?.scrollIntoView({
@@ -33,10 +33,12 @@ export function useOutsideClick({
33
33
  elements,
34
34
  callback,
35
35
  disabled,
36
+ handleTabs = false,
36
37
  }: {
37
38
  elements: (HTMLElement | null)[] | null;
38
- callback: (event?: MouseEvent) => void;
39
- disabled?: boolean;
39
+ callback: (event?: KeyboardEvent | MouseEvent) => void;
40
+ disabled: boolean;
41
+ handleTabs?: boolean;
40
42
  }) {
41
43
  useEffect(() => {
42
44
  if (!elements?.length || disabled) return;
@@ -46,11 +48,22 @@ export function useOutsideClick({
46
48
  callback(event);
47
49
  };
48
50
 
51
+ const handleOutsideTab = (event: KeyboardEvent) => {
52
+ if (!handleTabs || event.key !== 'Tab' || disabled) return;
53
+
54
+ setTimeout(() => {
55
+ if (elements?.some?.((element) => element?.contains?.(document.activeElement))) return;
56
+ callback(event);
57
+ }, 0);
58
+ };
59
+
49
60
  document.addEventListener('mousedown', handleClickOutside);
61
+ document.addEventListener('keydown', handleOutsideTab);
50
62
  return () => {
51
63
  document.removeEventListener('mousedown', handleClickOutside);
64
+ document.removeEventListener('keydown', handleOutsideTab);
52
65
  };
53
- }, [callback, disabled, elements]);
66
+ }, [callback, disabled, elements, handleTabs]);
54
67
  }
55
68
 
56
69
  /** Copyright 2025 Anywhere Real Estate - CC BY 4.0 */