gbs-add-block 1.2.17 → 1.2.18
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/README.md +3 -3
- package/package.json +1 -1
- package/source/beta-components/combobox/react/Listbox.tsx +310 -302
- package/source/beta-components/combobox/react/useCombobox.ts +409 -399
- package/source/beta-components/data-grid/__tests__/core.test.ts +356 -341
- package/source/beta-components/data-grid/core/sorting.ts +133 -111
- package/source/beta-components/data-grid/react/DataGrid.tsx +296 -296
- package/source/beta-components/data-grid/react/HeaderRow.tsx +332 -306
- package/source/beta-components/data-grid/react/hooks.ts +83 -80
- package/source/beta-components/date-picker/__tests__/core.test.ts +273 -220
- package/source/beta-components/date-picker/core/calendar.ts +266 -263
- package/source/beta-components/date-picker/core/index.ts +41 -40
- package/source/beta-components/date-picker/core/today.ts +72 -0
- package/source/beta-components/date-picker/react/useDatePicker.ts +557 -547
- package/source/beta-components/file-uploader/core/transport.ts +154 -145
- package/source/beta-components/shared/__tests__/position.test.ts +170 -143
- package/source/beta-components/shared/core/position.ts +15 -2
- package/source/beta-components/shared/react/Popover.tsx +18 -5
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.2.
|
|
1
|
+
# GBS Building Blocks 2.0 (v1.2.18)
|
|
2
2
|
|
|
3
3
|
Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
|
|
4
4
|
|
|
@@ -7,10 +7,10 @@ Latest and upgraded version of GBS building blocks with headless UI and removed
|
|
|
7
7
|
For detailed documentation on usage and props, Please visit: [Building Block Documentation](https://gramprokit.vercel.app)
|
|
8
8
|
|
|
9
9
|
## Beta Components
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
|
|
12
12
|
|
|
13
|
-
## What's New 🎉 (Ver 1.2.
|
|
13
|
+
## What's New 🎉 (Ver 1.2.18)
|
|
14
14
|
|
|
15
15
|
- Update Candidate for next major change 2.0.0
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -1,302 +1,310 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
useLayoutEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
type CSSProperties,
|
|
9
|
-
type KeyboardEvent,
|
|
10
|
-
type ReactNode,
|
|
11
|
-
type RefObject,
|
|
12
|
-
type UIEvent,
|
|
13
|
-
} from "react";
|
|
14
|
-
import type {
|
|
15
|
-
ComboboxLocaleText,
|
|
16
|
-
ComboboxOption,
|
|
17
|
-
ComboboxSize,
|
|
18
|
-
ListItem,
|
|
19
|
-
MatchRange,
|
|
20
|
-
OptionValue,
|
|
21
|
-
} from "../core/types";
|
|
22
|
-
import { getVisibleRange, measureItems, scrollToItem } from "../core/virtual";
|
|
23
|
-
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon } from "./icons";
|
|
24
|
-
import { cx, type ComboboxSlot, type OptionState } from "./props";
|
|
25
|
-
|
|
26
|
-
const OPTION_HEIGHT: Record<ComboboxSize, number> = { sm: 30, md: 34, lg: 40 };
|
|
27
|
-
const GROUP_HEIGHT = 26;
|
|
28
|
-
const DESCRIPTION_EXTRA = 16;
|
|
29
|
-
const VIRTUALIZE_FROM = 80;
|
|
30
|
-
|
|
31
|
-
interface ListboxProps<V extends OptionValue> {
|
|
32
|
-
listboxId: string;
|
|
33
|
-
labelledBy: string;
|
|
34
|
-
items: ListItem<V>[];
|
|
35
|
-
optionCount: number;
|
|
36
|
-
activeIndex: number;
|
|
37
|
-
multiple: boolean;
|
|
38
|
-
size: ComboboxSize;
|
|
39
|
-
maxHeight: number;
|
|
40
|
-
virtualize: boolean | number;
|
|
41
|
-
loading: boolean;
|
|
42
|
-
hasMore?: boolean;
|
|
43
|
-
searchable: boolean;
|
|
44
|
-
search: string;
|
|
45
|
-
searchRef: RefObject<HTMLInputElement | null>;
|
|
46
|
-
listRef: RefObject<HTMLDivElement | null>;
|
|
47
|
-
text: ComboboxLocaleText;
|
|
48
|
-
classNames: Partial<Record<ComboboxSlot, string>> | undefined;
|
|
49
|
-
emptyMessage: ReactNode;
|
|
50
|
-
createLabel: string | null;
|
|
51
|
-
footer?: ReactNode;
|
|
52
|
-
getOptionId(index: number): string;
|
|
53
|
-
isSelected(value: V): boolean;
|
|
54
|
-
onSelect(option: ComboboxOption<V>): void;
|
|
55
|
-
onActiveIndexChange(index: number): void;
|
|
56
|
-
onSearchChange(search: string): void;
|
|
57
|
-
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
|
|
58
|
-
onLoadMore?(): void;
|
|
59
|
-
onCreate(): void;
|
|
60
|
-
renderOption?(option: ComboboxOption<V>, state: OptionState): ReactNode;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function Highlight({ text, matches }: { text: string; matches: MatchRange[] }) {
|
|
64
|
-
if (matches.length === 0) return text;
|
|
65
|
-
const parts: ReactNode[] = [];
|
|
66
|
-
let cursor = 0;
|
|
67
|
-
matches.forEach(([start, end], i) => {
|
|
68
|
-
if (start > cursor) parts.push(text.slice(cursor, start));
|
|
69
|
-
parts.push(
|
|
70
|
-
<mark className="cb-mark" key={i}>
|
|
71
|
-
{text.slice(start, end)}
|
|
72
|
-
</mark>,
|
|
73
|
-
);
|
|
74
|
-
cursor = end;
|
|
75
|
-
});
|
|
76
|
-
if (cursor < text.length) parts.push(text.slice(cursor));
|
|
77
|
-
return parts;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function Listbox<V extends OptionValue>({
|
|
81
|
-
listboxId,
|
|
82
|
-
labelledBy,
|
|
83
|
-
items,
|
|
84
|
-
optionCount,
|
|
85
|
-
activeIndex,
|
|
86
|
-
multiple,
|
|
87
|
-
size,
|
|
88
|
-
maxHeight,
|
|
89
|
-
virtualize,
|
|
90
|
-
loading,
|
|
91
|
-
hasMore,
|
|
92
|
-
searchable,
|
|
93
|
-
search,
|
|
94
|
-
searchRef,
|
|
95
|
-
listRef,
|
|
96
|
-
text,
|
|
97
|
-
classNames,
|
|
98
|
-
emptyMessage,
|
|
99
|
-
createLabel,
|
|
100
|
-
footer,
|
|
101
|
-
getOptionId,
|
|
102
|
-
isSelected,
|
|
103
|
-
onSelect,
|
|
104
|
-
onActiveIndexChange,
|
|
105
|
-
onSearchChange,
|
|
106
|
-
onKeyDown,
|
|
107
|
-
onLoadMore,
|
|
108
|
-
onCreate,
|
|
109
|
-
renderOption,
|
|
110
|
-
}: ListboxProps<V>) {
|
|
111
|
-
const [scrollTop, setScrollTop] = useState(0);
|
|
112
|
-
|
|
113
|
-
const hasDescription = useMemo(
|
|
114
|
-
() => items.some((item) => item.kind === "option" && item.entry.option.description),
|
|
115
|
-
[items],
|
|
116
|
-
);
|
|
117
|
-
const sizes = useMemo(
|
|
118
|
-
() => ({
|
|
119
|
-
option: OPTION_HEIGHT[size] + (hasDescription ? DESCRIPTION_EXTRA : 0),
|
|
120
|
-
group: GROUP_HEIGHT,
|
|
121
|
-
}),
|
|
122
|
-
[size, hasDescription],
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const isVirtual =
|
|
126
|
-
typeof virtualize === "number" ? optionCount > virtualize : virtualize && optionCount > VIRTUALIZE_FROM;
|
|
127
|
-
const metrics = useMemo(() => measureItems(items, sizes), [items, sizes]);
|
|
128
|
-
const listHeight = Math.min(maxHeight, metrics.total + 8);
|
|
129
|
-
|
|
130
|
-
// Item index of each option, so the active option can be scrolled into view.
|
|
131
|
-
const itemIndexByOption = useMemo(() => {
|
|
132
|
-
const map = new Array<number>(optionCount);
|
|
133
|
-
items.forEach((item, index) => {
|
|
134
|
-
if (item.kind === "option") map[item.index] = index;
|
|
135
|
-
});
|
|
136
|
-
return map;
|
|
137
|
-
}, [items, optionCount]);
|
|
138
|
-
|
|
139
|
-
useLayoutEffect(() => {
|
|
140
|
-
const el = listRef.current;
|
|
141
|
-
const itemIndex = itemIndexByOption[activeIndex];
|
|
142
|
-
if (!el || activeIndex < 0 || itemIndex === undefined) return;
|
|
143
|
-
const next = scrollToItem(metrics, itemIndex, el.scrollTop, el.clientHeight);
|
|
144
|
-
if (next !== null) {
|
|
145
|
-
el.scrollTop = next;
|
|
146
|
-
setScrollTop(next);
|
|
147
|
-
}
|
|
148
|
-
}, [activeIndex, itemIndexByOption, metrics, listRef]);
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (hasMore
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
{
|
|
209
|
-
{
|
|
210
|
-
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
{
|
|
266
|
-
|
|
267
|
-
{
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
</div>
|
|
289
|
-
)}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
type RefObject,
|
|
12
|
+
type UIEvent,
|
|
13
|
+
} from "react";
|
|
14
|
+
import type {
|
|
15
|
+
ComboboxLocaleText,
|
|
16
|
+
ComboboxOption,
|
|
17
|
+
ComboboxSize,
|
|
18
|
+
ListItem,
|
|
19
|
+
MatchRange,
|
|
20
|
+
OptionValue,
|
|
21
|
+
} from "../core/types";
|
|
22
|
+
import { getVisibleRange, measureItems, scrollToItem } from "../core/virtual";
|
|
23
|
+
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon } from "./icons";
|
|
24
|
+
import { cx, type ComboboxSlot, type OptionState } from "./props";
|
|
25
|
+
|
|
26
|
+
const OPTION_HEIGHT: Record<ComboboxSize, number> = { sm: 30, md: 34, lg: 40 };
|
|
27
|
+
const GROUP_HEIGHT = 26;
|
|
28
|
+
const DESCRIPTION_EXTRA = 16;
|
|
29
|
+
const VIRTUALIZE_FROM = 80;
|
|
30
|
+
|
|
31
|
+
interface ListboxProps<V extends OptionValue> {
|
|
32
|
+
listboxId: string;
|
|
33
|
+
labelledBy: string;
|
|
34
|
+
items: ListItem<V>[];
|
|
35
|
+
optionCount: number;
|
|
36
|
+
activeIndex: number;
|
|
37
|
+
multiple: boolean;
|
|
38
|
+
size: ComboboxSize;
|
|
39
|
+
maxHeight: number;
|
|
40
|
+
virtualize: boolean | number;
|
|
41
|
+
loading: boolean;
|
|
42
|
+
hasMore?: boolean;
|
|
43
|
+
searchable: boolean;
|
|
44
|
+
search: string;
|
|
45
|
+
searchRef: RefObject<HTMLInputElement | null>;
|
|
46
|
+
listRef: RefObject<HTMLDivElement | null>;
|
|
47
|
+
text: ComboboxLocaleText;
|
|
48
|
+
classNames: Partial<Record<ComboboxSlot, string>> | undefined;
|
|
49
|
+
emptyMessage: ReactNode;
|
|
50
|
+
createLabel: string | null;
|
|
51
|
+
footer?: ReactNode;
|
|
52
|
+
getOptionId(index: number): string;
|
|
53
|
+
isSelected(value: V): boolean;
|
|
54
|
+
onSelect(option: ComboboxOption<V>): void;
|
|
55
|
+
onActiveIndexChange(index: number): void;
|
|
56
|
+
onSearchChange(search: string): void;
|
|
57
|
+
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
|
|
58
|
+
onLoadMore?(): void;
|
|
59
|
+
onCreate(): void;
|
|
60
|
+
renderOption?(option: ComboboxOption<V>, state: OptionState): ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Highlight({ text, matches }: { text: string; matches: MatchRange[] }) {
|
|
64
|
+
if (matches.length === 0) return text;
|
|
65
|
+
const parts: ReactNode[] = [];
|
|
66
|
+
let cursor = 0;
|
|
67
|
+
matches.forEach(([start, end], i) => {
|
|
68
|
+
if (start > cursor) parts.push(text.slice(cursor, start));
|
|
69
|
+
parts.push(
|
|
70
|
+
<mark className="cb-mark" key={i}>
|
|
71
|
+
{text.slice(start, end)}
|
|
72
|
+
</mark>,
|
|
73
|
+
);
|
|
74
|
+
cursor = end;
|
|
75
|
+
});
|
|
76
|
+
if (cursor < text.length) parts.push(text.slice(cursor));
|
|
77
|
+
return parts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function Listbox<V extends OptionValue>({
|
|
81
|
+
listboxId,
|
|
82
|
+
labelledBy,
|
|
83
|
+
items,
|
|
84
|
+
optionCount,
|
|
85
|
+
activeIndex,
|
|
86
|
+
multiple,
|
|
87
|
+
size,
|
|
88
|
+
maxHeight,
|
|
89
|
+
virtualize,
|
|
90
|
+
loading,
|
|
91
|
+
hasMore,
|
|
92
|
+
searchable,
|
|
93
|
+
search,
|
|
94
|
+
searchRef,
|
|
95
|
+
listRef,
|
|
96
|
+
text,
|
|
97
|
+
classNames,
|
|
98
|
+
emptyMessage,
|
|
99
|
+
createLabel,
|
|
100
|
+
footer,
|
|
101
|
+
getOptionId,
|
|
102
|
+
isSelected,
|
|
103
|
+
onSelect,
|
|
104
|
+
onActiveIndexChange,
|
|
105
|
+
onSearchChange,
|
|
106
|
+
onKeyDown,
|
|
107
|
+
onLoadMore,
|
|
108
|
+
onCreate,
|
|
109
|
+
renderOption,
|
|
110
|
+
}: ListboxProps<V>) {
|
|
111
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
112
|
+
|
|
113
|
+
const hasDescription = useMemo(
|
|
114
|
+
() => items.some((item) => item.kind === "option" && item.entry.option.description),
|
|
115
|
+
[items],
|
|
116
|
+
);
|
|
117
|
+
const sizes = useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
option: OPTION_HEIGHT[size] + (hasDescription ? DESCRIPTION_EXTRA : 0),
|
|
120
|
+
group: GROUP_HEIGHT,
|
|
121
|
+
}),
|
|
122
|
+
[size, hasDescription],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const isVirtual =
|
|
126
|
+
typeof virtualize === "number" ? optionCount > virtualize : virtualize && optionCount > VIRTUALIZE_FROM;
|
|
127
|
+
const metrics = useMemo(() => measureItems(items, sizes), [items, sizes]);
|
|
128
|
+
const listHeight = Math.min(maxHeight, metrics.total + 8);
|
|
129
|
+
|
|
130
|
+
// Item index of each option, so the active option can be scrolled into view.
|
|
131
|
+
const itemIndexByOption = useMemo(() => {
|
|
132
|
+
const map = new Array<number>(optionCount);
|
|
133
|
+
items.forEach((item, index) => {
|
|
134
|
+
if (item.kind === "option") map[item.index] = index;
|
|
135
|
+
});
|
|
136
|
+
return map;
|
|
137
|
+
}, [items, optionCount]);
|
|
138
|
+
|
|
139
|
+
useLayoutEffect(() => {
|
|
140
|
+
const el = listRef.current;
|
|
141
|
+
const itemIndex = itemIndexByOption[activeIndex];
|
|
142
|
+
if (!el || activeIndex < 0 || itemIndex === undefined) return;
|
|
143
|
+
const next = scrollToItem(metrics, itemIndex, el.scrollTop, el.clientHeight);
|
|
144
|
+
if (next !== null) {
|
|
145
|
+
el.scrollTop = next;
|
|
146
|
+
setScrollTop(next);
|
|
147
|
+
}
|
|
148
|
+
}, [activeIndex, itemIndexByOption, metrics, listRef]);
|
|
149
|
+
|
|
150
|
+
// The next page is asked for at most once per list length. `loading` is not
|
|
151
|
+
// enough on its own: it belongs to the caller and only turns on a render or
|
|
152
|
+
// more later, while scroll events fire many times a second, so a single flick
|
|
153
|
+
// at the end of the list would otherwise send a burst of identical requests.
|
|
154
|
+
const requestedAt = useRef(-1);
|
|
155
|
+
const loadMore = () => {
|
|
156
|
+
if (!hasMore || loading || !onLoadMore || requestedAt.current === items.length) return;
|
|
157
|
+
requestedAt.current = items.length;
|
|
158
|
+
onLoadMore();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// If the list is already scrolled to the end when new options arrive, no
|
|
162
|
+
// scroll event follows, so ask from here instead.
|
|
163
|
+
useLayoutEffect(() => {
|
|
164
|
+
const el = listRef.current;
|
|
165
|
+
if (!el || !hasMore || loading || !onLoadMore || requestedAt.current === items.length) return;
|
|
166
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
|
|
167
|
+
requestedAt.current = items.length;
|
|
168
|
+
onLoadMore();
|
|
169
|
+
}
|
|
170
|
+
}, [hasMore, loading, onLoadMore, items.length, listRef]);
|
|
171
|
+
|
|
172
|
+
const onScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
173
|
+
const el = event.currentTarget;
|
|
174
|
+
setScrollTop(el.scrollTop);
|
|
175
|
+
if (el.scrollTop + el.clientHeight >= metrics.total - 80) loadMore();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const range = isVirtual
|
|
179
|
+
? getVisibleRange(metrics, scrollTop, listHeight)
|
|
180
|
+
: { start: 0, end: items.length };
|
|
181
|
+
|
|
182
|
+
const renderItem = (item: ListItem<V>, itemIndex: number): ReactNode => {
|
|
183
|
+
const style: CSSProperties | undefined = isVirtual
|
|
184
|
+
? { top: metrics.offsets[itemIndex], height: item.kind === "group" ? sizes.group : sizes.option }
|
|
185
|
+
: undefined;
|
|
186
|
+
|
|
187
|
+
if (item.kind === "group") {
|
|
188
|
+
return (
|
|
189
|
+
<div key={`group-${item.label}-${itemIndex}`} className="cb-group" role="presentation" style={style}>
|
|
190
|
+
{item.label}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { option, matches } = item.entry;
|
|
196
|
+
const selected = isSelected(option.value);
|
|
197
|
+
const active = activeIndex === item.index;
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
key={String(option.value)}
|
|
201
|
+
id={getOptionId(item.index)}
|
|
202
|
+
role="option"
|
|
203
|
+
aria-selected={selected}
|
|
204
|
+
aria-disabled={option.disabled || undefined}
|
|
205
|
+
className={cx("cb-item", classNames?.option)}
|
|
206
|
+
style={style}
|
|
207
|
+
data-active={active || undefined}
|
|
208
|
+
data-selected={selected || undefined}
|
|
209
|
+
data-disabled={option.disabled || undefined}
|
|
210
|
+
// Keep focus in the search box so typing continues to work.
|
|
211
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
212
|
+
onPointerMove={() => !option.disabled && !active && onActiveIndexChange(item.index)}
|
|
213
|
+
onClick={() => !option.disabled && onSelect(option)}
|
|
214
|
+
>
|
|
215
|
+
<CheckIcon className="cb-item-check" />
|
|
216
|
+
{option.icon && <span className="cb-item-icon">{option.icon}</span>}
|
|
217
|
+
{renderOption ? (
|
|
218
|
+
renderOption(option, { selected, active })
|
|
219
|
+
) : (
|
|
220
|
+
<span className="cb-item-body">
|
|
221
|
+
<span className="cb-item-label">
|
|
222
|
+
<Highlight text={option.label} matches={matches} />
|
|
223
|
+
</span>
|
|
224
|
+
{option.description && <span className="cb-item-description">{option.description}</span>}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const empty = items.length === 0 && !createLabel;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<>
|
|
235
|
+
{searchable && (
|
|
236
|
+
<div className={cx("cb-search", classNames?.search)}>
|
|
237
|
+
<SearchIcon />
|
|
238
|
+
<input
|
|
239
|
+
ref={searchRef}
|
|
240
|
+
type="text"
|
|
241
|
+
role="combobox"
|
|
242
|
+
aria-expanded="true"
|
|
243
|
+
aria-controls={listboxId}
|
|
244
|
+
aria-autocomplete="list"
|
|
245
|
+
aria-activedescendant={activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
|
|
246
|
+
aria-label={text.searchPlaceholder}
|
|
247
|
+
autoComplete="off"
|
|
248
|
+
spellCheck={false}
|
|
249
|
+
data-autofocus=""
|
|
250
|
+
placeholder={text.searchPlaceholder}
|
|
251
|
+
value={search}
|
|
252
|
+
onChange={(event) => onSearchChange(event.target.value)}
|
|
253
|
+
onKeyDown={onKeyDown}
|
|
254
|
+
/>
|
|
255
|
+
{loading && <SpinnerIcon aria-label={text.loading} />}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<div
|
|
260
|
+
ref={listRef}
|
|
261
|
+
id={listboxId}
|
|
262
|
+
role="listbox"
|
|
263
|
+
aria-labelledby={labelledBy}
|
|
264
|
+
aria-multiselectable={multiple || undefined}
|
|
265
|
+
className={cx("cb-list", classNames?.list)}
|
|
266
|
+
style={{ height: empty ? undefined : listHeight }}
|
|
267
|
+
data-virtual={isVirtual || undefined}
|
|
268
|
+
tabIndex={searchable ? -1 : 0}
|
|
269
|
+
onScroll={onScroll}
|
|
270
|
+
onKeyDown={searchable ? undefined : onKeyDown}
|
|
271
|
+
>
|
|
272
|
+
{isVirtual && <div style={{ height: metrics.total }} aria-hidden="true" />}
|
|
273
|
+
{items.slice(range.start, range.end).map((item, i) => renderItem(item, range.start + i))}
|
|
274
|
+
|
|
275
|
+
{createLabel && (
|
|
276
|
+
<div
|
|
277
|
+
role="option"
|
|
278
|
+
aria-selected={false}
|
|
279
|
+
className="cb-item cb-create"
|
|
280
|
+
data-active={activeIndex === -1 || undefined}
|
|
281
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
282
|
+
onClick={onCreate}
|
|
283
|
+
>
|
|
284
|
+
<PlusIcon className="cb-item-check" style={{ opacity: 1 }} />
|
|
285
|
+
<span className="cb-item-body">
|
|
286
|
+
<span className="cb-item-label">{text.createOption(createLabel)}</span>
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{empty && !loading && <div className="cb-empty">{emptyMessage ?? text.noResults}</div>}
|
|
292
|
+
{loading && items.length === 0 && (
|
|
293
|
+
<div className="cb-status">
|
|
294
|
+
<SpinnerIcon />
|
|
295
|
+
{text.loading}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
{hasMore && items.length > 0 && (
|
|
299
|
+
<div className="cb-status">
|
|
300
|
+
<button type="button" className="cb-link-button" disabled={loading} onClick={() => onLoadMore?.()}>
|
|
301
|
+
{loading ? text.loading : text.loadMore}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{footer && <div className={cx("cb-footer", classNames?.footer)}>{footer}</div>}
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
}
|