@bspk/ui 1.3.2 → 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.
- package/dist/components/DateInput/DateInput.js +1 -0
- package/dist/components/DateInput/DateInput.js.map +1 -1
- package/dist/components/ListItem/ListItem.d.ts +2 -2
- package/dist/components/ListItem/ListItem.js +11 -7
- package/dist/components/ListItem/ListItem.js.map +1 -1
- package/dist/components/ListItem/ListItemExample.d.ts +1 -1
- package/dist/components/ListItem/ListItemExample.js +72 -3
- package/dist/components/ListItem/ListItemExample.js.map +1 -1
- package/dist/components/ListItem/list-item.css +8 -6
- package/dist/components/ListItem/list-item.css.js +8 -6
- package/dist/components/ListItemMenu/ListItemMenu.d.ts +1 -1
- package/dist/components/ListItemMenu/ListItemMenu.js.map +1 -1
- package/dist/components/NumberInput/NumberInput.d.ts +2 -2
- package/dist/components/NumberInput/NumberInput.js +7 -20
- package/dist/components/NumberInput/NumberInput.js.map +1 -1
- package/dist/components/Popover/Popover.js +1 -0
- package/dist/components/Popover/Popover.js.map +1 -1
- package/dist/components/ProgressionStepper/ProgressionStepper.d.ts +3 -7
- package/dist/components/ProgressionStepper/ProgressionStepper.js +9 -5
- package/dist/components/ProgressionStepper/ProgressionStepper.js.map +1 -1
- package/dist/components/ProgressionStepper/ProgressionStepperExample.js +20 -4
- package/dist/components/ProgressionStepper/ProgressionStepperExample.js.map +1 -1
- package/dist/components/SearchBar/SearchBar.d.ts +36 -35
- package/dist/components/SearchBar/SearchBar.js +100 -49
- package/dist/components/SearchBar/SearchBar.js.map +1 -1
- package/dist/components/SearchBar/SearchBarExample.js +2 -1
- package/dist/components/SearchBar/SearchBarExample.js.map +1 -1
- package/dist/components/Select/Select.d.ts +2 -2
- package/dist/components/Select/Select.js +3 -5
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/TabList/TabList.js +2 -1
- package/dist/components/TabList/TabList.js.map +1 -1
- package/dist/components/TabList/tab-list.css +6 -0
- package/dist/components/TabList/tab-list.css.js +6 -0
- package/dist/hooks/useArrowNavigation.js +6 -1
- package/dist/hooks/useArrowNavigation.js.map +1 -1
- package/dist/hooks/useOutsideClick.d.ts +4 -3
- package/dist/hooks/useOutsideClick.js +13 -2
- package/dist/hooks/useOutsideClick.js.map +1 -1
- package/package.json +1 -1
- package/src/components/DateInput/DateInput.tsx +1 -0
- package/src/components/ListItem/ListItem.rtl.test.tsx +4 -1
- package/src/components/ListItem/ListItem.tsx +19 -12
- package/src/components/ListItem/ListItemExample.tsx +74 -4
- package/src/components/ListItem/list-item.scss +21 -17
- package/src/components/ListItemMenu/ListItemMenu.tsx +4 -3
- package/src/components/NumberInput/NumberInput.tsx +14 -27
- package/src/components/Popover/Popover.tsx +1 -0
- package/src/components/ProgressionStepper/ProgressionStepper.tsx +24 -15
- package/src/components/ProgressionStepper/ProgressionStepperExample.tsx +20 -4
- package/src/components/SearchBar/SearchBar.rtl.test.tsx +0 -1
- package/src/components/SearchBar/SearchBar.tsx +192 -115
- package/src/components/SearchBar/SearchBarExample.tsx +2 -1
- package/src/components/Select/Select.tsx +6 -12
- package/src/components/TabList/TabList.tsx +2 -1
- package/src/components/TabList/tab-list.scss +12 -0
- package/src/hooks/useArrowNavigation.ts +6 -1
- 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,
|
|
4
|
-
import {
|
|
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
|
-
|
|
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<
|
|
14
|
-
|
|
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
|
|
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 (
|
|
44
|
+
* @type (value: String, item?: SearchBarOption) => void
|
|
36
45
|
* @required
|
|
37
46
|
*/
|
|
38
|
-
|
|
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
|
-
* {
|
|
45
|
-
* {
|
|
46
|
-
* {
|
|
47
|
-
* {
|
|
48
|
-
* {
|
|
49
|
-
* {
|
|
50
|
-
* {
|
|
51
|
-
* {
|
|
52
|
-
* {
|
|
53
|
-
* {
|
|
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<
|
|
65
|
+
* @type Array<SearchBarOption>
|
|
57
66
|
*/
|
|
58
|
-
items?:
|
|
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
|
-
* {
|
|
84
|
-
* {
|
|
85
|
-
* {
|
|
86
|
-
* {
|
|
87
|
-
* {
|
|
88
|
-
* {
|
|
89
|
-
* {
|
|
90
|
-
* {
|
|
91
|
-
* {
|
|
92
|
-
* {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}}
|
|
192
|
+
<TextInput
|
|
193
|
+
aria-label={ariaLabel}
|
|
194
|
+
autoComplete="off"
|
|
195
|
+
containerRef={elements.setReference}
|
|
147
196
|
disabled={disabled}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
return
|
|
157
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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="
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
value={
|
|
284
|
+
owner="select"
|
|
285
|
+
role="option"
|
|
286
|
+
tabIndex={-1} //show && isActive ? -1 : 0}
|
|
287
|
+
value={undefined}
|
|
211
288
|
/>
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
</
|
|
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.
|
|
18
|
+
* Essentially the props of ListItemProps.
|
|
19
19
|
*/
|
|
20
|
-
export type SelectOption =
|
|
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
|
|
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
|
|
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={
|
|
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 */
|
|
@@ -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
|
|
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 */
|