gbs-add-block 1.2.7 → 1.2.9
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/index.cjs +10 -9
- package/package.json +1 -1
- package/source/components/combobox/README.md +170 -0
- package/source/components/combobox/__tests__/core.test.ts +134 -0
- package/source/components/combobox/core/filter.ts +155 -0
- package/source/components/combobox/core/index.ts +16 -0
- package/source/components/combobox/core/types.ts +48 -0
- package/source/components/combobox/core/virtual.ts +67 -0
- package/source/components/combobox/index.ts +13 -0
- package/source/components/combobox/react/Listbox.tsx +302 -0
- package/source/components/combobox/react/MultiSelect.tsx +263 -0
- package/source/components/combobox/react/Popover.tsx +70 -0
- package/source/components/combobox/react/Select.tsx +198 -0
- package/source/components/combobox/react/icons.tsx +34 -0
- package/source/components/combobox/react/locale.ts +16 -0
- package/source/components/combobox/react/props.ts +86 -0
- package/source/components/combobox/react/useCombobox.ts +399 -0
- package/source/components/combobox/styles.css +417 -0
- package/source/components/datagridbeta/README.md +232 -0
- package/source/components/datagridbeta/__tests__/core.test.ts +341 -0
- package/source/components/datagridbeta/__tests__/export.test.ts +70 -0
- package/source/components/datagridbeta/core/columnHelper.ts +21 -0
- package/source/components/datagridbeta/core/columns.ts +288 -0
- package/source/components/datagridbeta/core/filtering.ts +237 -0
- package/source/components/datagridbeta/core/grid.ts +616 -0
- package/source/components/datagridbeta/core/index.ts +14 -0
- package/source/components/datagridbeta/core/rows.ts +64 -0
- package/source/components/datagridbeta/core/sorting.ts +111 -0
- package/source/components/datagridbeta/core/state.ts +82 -0
- package/source/components/datagridbeta/core/store.ts +52 -0
- package/source/components/datagridbeta/core/types.ts +266 -0
- package/source/components/datagridbeta/core/values.ts +98 -0
- package/source/components/datagridbeta/core/virtual.ts +85 -0
- package/source/components/datagridbeta/export/csv.ts +32 -0
- package/source/components/datagridbeta/export/download.ts +12 -0
- package/source/components/datagridbeta/export/pdf.ts +61 -0
- package/source/components/datagridbeta/export/table.ts +73 -0
- package/source/components/datagridbeta/export/xlsx.ts +145 -0
- package/source/components/datagridbeta/export/zip.ts +87 -0
- package/source/components/datagridbeta/index.ts +7 -0
- package/source/components/datagridbeta/react/Cell.tsx +282 -0
- package/source/components/datagridbeta/react/ColumnMenu.tsx +135 -0
- package/source/components/datagridbeta/react/DataGrid.tsx +296 -0
- package/source/components/datagridbeta/react/FilterForm.tsx +142 -0
- package/source/components/datagridbeta/react/HeaderRow.tsx +306 -0
- package/source/components/datagridbeta/react/Pagination.tsx +107 -0
- package/source/components/datagridbeta/react/Popover.tsx +79 -0
- package/source/components/datagridbeta/react/Row.tsx +85 -0
- package/source/components/datagridbeta/react/Toolbar.tsx +266 -0
- package/source/components/datagridbeta/react/Viewport.tsx +167 -0
- package/source/components/datagridbeta/react/context.ts +57 -0
- package/source/components/datagridbeta/react/hooks.ts +80 -0
- package/source/components/datagridbeta/react/icons.tsx +72 -0
- package/source/components/datagridbeta/react/keyboard.ts +110 -0
- package/source/components/datagridbeta/react/locale.ts +128 -0
- package/source/components/datagridbeta/styles.css +717 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ListItem, OptionValue } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface ItemSizes {
|
|
4
|
+
option: number;
|
|
5
|
+
group: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ListMetrics {
|
|
9
|
+
/** Offset of each item, plus the total height as the last entry. */
|
|
10
|
+
offsets: number[];
|
|
11
|
+
total: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function measureItems<V extends OptionValue>(
|
|
15
|
+
items: readonly ListItem<V>[],
|
|
16
|
+
sizes: ItemSizes,
|
|
17
|
+
): ListMetrics {
|
|
18
|
+
const offsets = new Array<number>(items.length + 1);
|
|
19
|
+
let offset = 0;
|
|
20
|
+
for (let i = 0; i < items.length; i++) {
|
|
21
|
+
offsets[i] = offset;
|
|
22
|
+
offset += items[i].kind === "group" ? sizes.group : sizes.option;
|
|
23
|
+
}
|
|
24
|
+
offsets[items.length] = offset;
|
|
25
|
+
return { offsets, total: offset };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Range {
|
|
29
|
+
start: number;
|
|
30
|
+
end: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Items intersecting the visible window, plus overscan. */
|
|
34
|
+
export function getVisibleRange(metrics: ListMetrics, scrollTop: number, height: number, overscan = 4): Range {
|
|
35
|
+
const count = metrics.offsets.length - 1;
|
|
36
|
+
if (count === 0 || height === 0) return { start: 0, end: count };
|
|
37
|
+
|
|
38
|
+
const find = (position: number) => {
|
|
39
|
+
let lo = 0;
|
|
40
|
+
let hi = count;
|
|
41
|
+
while (lo < hi) {
|
|
42
|
+
const mid = (lo + hi) >> 1;
|
|
43
|
+
if (metrics.offsets[mid + 1] <= position) lo = mid + 1;
|
|
44
|
+
else hi = mid;
|
|
45
|
+
}
|
|
46
|
+
return lo;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const start = find(scrollTop);
|
|
50
|
+
const end = find(scrollTop + height) + 1;
|
|
51
|
+
return { start: Math.max(0, start - overscan), end: Math.min(count, end + overscan) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Scroll position that brings an item fully into view, or null if it already is. */
|
|
55
|
+
export function scrollToItem(
|
|
56
|
+
metrics: ListMetrics,
|
|
57
|
+
itemIndex: number,
|
|
58
|
+
scrollTop: number,
|
|
59
|
+
height: number,
|
|
60
|
+
): number | null {
|
|
61
|
+
const top = metrics.offsets[itemIndex];
|
|
62
|
+
const bottom = metrics.offsets[itemIndex + 1];
|
|
63
|
+
if (top === undefined || bottom === undefined) return null;
|
|
64
|
+
if (top < scrollTop) return top;
|
|
65
|
+
if (bottom > scrollTop + height) return bottom - height;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { Select } from "./react/Select";
|
|
2
|
+
export type { SelectProps } from "./react/Select";
|
|
3
|
+
export { MultiSelect } from "./react/MultiSelect";
|
|
4
|
+
export type { MultiSelectProps } from "./react/MultiSelect";
|
|
5
|
+
export { useCombobox } from "./react/useCombobox";
|
|
6
|
+
export { defaultComboboxText } from "./react/locale";
|
|
7
|
+
export type {
|
|
8
|
+
ComboboxHandle,
|
|
9
|
+
ComboboxSharedProps,
|
|
10
|
+
ComboboxSlot,
|
|
11
|
+
OptionState,
|
|
12
|
+
} from "./react/props";
|
|
13
|
+
export * from "./core";
|
|
@@ -0,0 +1,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
|
+
// If the list is already scrolled to the end when new options arrive, no
|
|
151
|
+
// scroll event follows, so ask for the next page once per list length.
|
|
152
|
+
const autoLoadedAt = useRef(-1);
|
|
153
|
+
useLayoutEffect(() => {
|
|
154
|
+
const el = listRef.current;
|
|
155
|
+
if (!el || !hasMore || loading || !onLoadMore || autoLoadedAt.current === items.length) return;
|
|
156
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
|
|
157
|
+
autoLoadedAt.current = items.length;
|
|
158
|
+
onLoadMore();
|
|
159
|
+
}
|
|
160
|
+
}, [hasMore, loading, onLoadMore, items.length, listRef]);
|
|
161
|
+
|
|
162
|
+
const onScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
163
|
+
const el = event.currentTarget;
|
|
164
|
+
setScrollTop(el.scrollTop);
|
|
165
|
+
if (hasMore && !loading && onLoadMore && el.scrollTop + el.clientHeight >= metrics.total - 80) {
|
|
166
|
+
onLoadMore();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const range = isVirtual
|
|
171
|
+
? getVisibleRange(metrics, scrollTop, listHeight)
|
|
172
|
+
: { start: 0, end: items.length };
|
|
173
|
+
|
|
174
|
+
const renderItem = (item: ListItem<V>, itemIndex: number): ReactNode => {
|
|
175
|
+
const style: CSSProperties | undefined = isVirtual
|
|
176
|
+
? { top: metrics.offsets[itemIndex], height: item.kind === "group" ? sizes.group : sizes.option }
|
|
177
|
+
: undefined;
|
|
178
|
+
|
|
179
|
+
if (item.kind === "group") {
|
|
180
|
+
return (
|
|
181
|
+
<div key={`group-${item.label}-${itemIndex}`} className="cb-group" role="presentation" style={style}>
|
|
182
|
+
{item.label}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { option, matches } = item.entry;
|
|
188
|
+
const selected = isSelected(option.value);
|
|
189
|
+
const active = activeIndex === item.index;
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
key={String(option.value)}
|
|
193
|
+
id={getOptionId(item.index)}
|
|
194
|
+
role="option"
|
|
195
|
+
aria-selected={selected}
|
|
196
|
+
aria-disabled={option.disabled || undefined}
|
|
197
|
+
className={cx("cb-item", classNames?.option)}
|
|
198
|
+
style={style}
|
|
199
|
+
data-active={active || undefined}
|
|
200
|
+
data-selected={selected || undefined}
|
|
201
|
+
data-disabled={option.disabled || undefined}
|
|
202
|
+
// Keep focus in the search box so typing continues to work.
|
|
203
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
204
|
+
onPointerMove={() => !option.disabled && !active && onActiveIndexChange(item.index)}
|
|
205
|
+
onClick={() => !option.disabled && onSelect(option)}
|
|
206
|
+
>
|
|
207
|
+
<CheckIcon className="cb-item-check" />
|
|
208
|
+
{option.icon && <span className="cb-item-icon">{option.icon}</span>}
|
|
209
|
+
{renderOption ? (
|
|
210
|
+
renderOption(option, { selected, active })
|
|
211
|
+
) : (
|
|
212
|
+
<span className="cb-item-body">
|
|
213
|
+
<span className="cb-item-label">
|
|
214
|
+
<Highlight text={option.label} matches={matches} />
|
|
215
|
+
</span>
|
|
216
|
+
{option.description && <span className="cb-item-description">{option.description}</span>}
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const empty = items.length === 0 && !createLabel;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
{searchable && (
|
|
228
|
+
<div className={cx("cb-search", classNames?.search)}>
|
|
229
|
+
<SearchIcon />
|
|
230
|
+
<input
|
|
231
|
+
ref={searchRef}
|
|
232
|
+
type="text"
|
|
233
|
+
role="combobox"
|
|
234
|
+
aria-expanded="true"
|
|
235
|
+
aria-controls={listboxId}
|
|
236
|
+
aria-autocomplete="list"
|
|
237
|
+
aria-activedescendant={activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
|
|
238
|
+
aria-label={text.searchPlaceholder}
|
|
239
|
+
autoComplete="off"
|
|
240
|
+
spellCheck={false}
|
|
241
|
+
data-autofocus=""
|
|
242
|
+
placeholder={text.searchPlaceholder}
|
|
243
|
+
value={search}
|
|
244
|
+
onChange={(event) => onSearchChange(event.target.value)}
|
|
245
|
+
onKeyDown={onKeyDown}
|
|
246
|
+
/>
|
|
247
|
+
{loading && <SpinnerIcon aria-label={text.loading} />}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
<div
|
|
252
|
+
ref={listRef}
|
|
253
|
+
id={listboxId}
|
|
254
|
+
role="listbox"
|
|
255
|
+
aria-labelledby={labelledBy}
|
|
256
|
+
aria-multiselectable={multiple || undefined}
|
|
257
|
+
className={cx("cb-list", classNames?.list)}
|
|
258
|
+
style={{ height: empty ? undefined : listHeight }}
|
|
259
|
+
data-virtual={isVirtual || undefined}
|
|
260
|
+
tabIndex={searchable ? -1 : 0}
|
|
261
|
+
onScroll={onScroll}
|
|
262
|
+
onKeyDown={searchable ? undefined : onKeyDown}
|
|
263
|
+
>
|
|
264
|
+
{isVirtual && <div style={{ height: metrics.total }} aria-hidden="true" />}
|
|
265
|
+
{items.slice(range.start, range.end).map((item, i) => renderItem(item, range.start + i))}
|
|
266
|
+
|
|
267
|
+
{createLabel && (
|
|
268
|
+
<div
|
|
269
|
+
role="option"
|
|
270
|
+
aria-selected={false}
|
|
271
|
+
className="cb-item cb-create"
|
|
272
|
+
data-active={activeIndex === -1 || undefined}
|
|
273
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
274
|
+
onClick={onCreate}
|
|
275
|
+
>
|
|
276
|
+
<PlusIcon className="cb-item-check" style={{ opacity: 1 }} />
|
|
277
|
+
<span className="cb-item-body">
|
|
278
|
+
<span className="cb-item-label">{text.createOption(createLabel)}</span>
|
|
279
|
+
</span>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{empty && !loading && <div className="cb-empty">{emptyMessage ?? text.noResults}</div>}
|
|
284
|
+
{loading && items.length === 0 && (
|
|
285
|
+
<div className="cb-status">
|
|
286
|
+
<SpinnerIcon />
|
|
287
|
+
{text.loading}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
{hasMore && items.length > 0 && (
|
|
291
|
+
<div className="cb-status">
|
|
292
|
+
<button type="button" className="cb-link-button" disabled={loading} onClick={() => onLoadMore?.()}>
|
|
293
|
+
{loading ? text.loading : text.loadMore}
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{footer && <div className={cx("cb-footer", classNames?.footer)}>{footer}</div>}
|
|
300
|
+
</>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useImperativeHandle, useMemo, useRef, type Ref } from "react";
|
|
4
|
+
import type { ComboboxOption, OptionValue } from "../core/types";
|
|
5
|
+
import { ChevronIcon, XIcon } from "./icons";
|
|
6
|
+
import { Listbox } from "./Listbox";
|
|
7
|
+
import { defaultComboboxText } from "./locale";
|
|
8
|
+
import { ComboboxPopover } from "./Popover";
|
|
9
|
+
import { cx, type ComboboxHandle, type ComboboxSharedProps } from "./props";
|
|
10
|
+
import { useCombobox } from "./useCombobox";
|
|
11
|
+
|
|
12
|
+
export interface MultiSelectProps<V extends OptionValue = string> extends ComboboxSharedProps<V> {
|
|
13
|
+
value?: V[];
|
|
14
|
+
defaultValue?: V[];
|
|
15
|
+
onChange?(values: NoInfer<V>[], options: ComboboxOption<NoInfer<V>>[]): void;
|
|
16
|
+
/** Maximum number of selections. */
|
|
17
|
+
max?: number;
|
|
18
|
+
/** Tags shown before collapsing into "+N more". Default 3. */
|
|
19
|
+
maxVisibleTags?: number;
|
|
20
|
+
/** Show "Select all" / "Clear all" under the list. Default true. */
|
|
21
|
+
showSelectAll?: boolean;
|
|
22
|
+
/** Close the list after each choice. Default false. */
|
|
23
|
+
closeOnSelect?: boolean;
|
|
24
|
+
ref?: Ref<ComboboxHandle<V>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Multi-select combobox: tags, option search and select-all. */
|
|
28
|
+
export function MultiSelect<V extends OptionValue = string>(props: MultiSelectProps<V>) {
|
|
29
|
+
const {
|
|
30
|
+
ref,
|
|
31
|
+
value,
|
|
32
|
+
defaultValue,
|
|
33
|
+
onChange,
|
|
34
|
+
max,
|
|
35
|
+
maxVisibleTags = 3,
|
|
36
|
+
showSelectAll = true,
|
|
37
|
+
closeOnSelect = false,
|
|
38
|
+
options,
|
|
39
|
+
label,
|
|
40
|
+
placeholder = "Select…",
|
|
41
|
+
description,
|
|
42
|
+
error,
|
|
43
|
+
required = false,
|
|
44
|
+
disabled = false,
|
|
45
|
+
clearable = true,
|
|
46
|
+
size = "md",
|
|
47
|
+
name,
|
|
48
|
+
id: idProp,
|
|
49
|
+
className,
|
|
50
|
+
classNames,
|
|
51
|
+
style,
|
|
52
|
+
localeText,
|
|
53
|
+
maxHeight = 280,
|
|
54
|
+
virtualize = true,
|
|
55
|
+
emptyMessage,
|
|
56
|
+
searchable = true,
|
|
57
|
+
loading = false,
|
|
58
|
+
hasMore,
|
|
59
|
+
onLoadMore,
|
|
60
|
+
renderOption,
|
|
61
|
+
} = props;
|
|
62
|
+
|
|
63
|
+
const reactId = useId();
|
|
64
|
+
const id = idProp ?? reactId;
|
|
65
|
+
const text = useMemo(() => ({ ...defaultComboboxText, ...localeText }), [localeText]);
|
|
66
|
+
|
|
67
|
+
const controlRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
69
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
|
|
71
|
+
const combobox = useCombobox<V>({
|
|
72
|
+
controlRef,
|
|
73
|
+
searchRef,
|
|
74
|
+
listRef,
|
|
75
|
+
...props,
|
|
76
|
+
multiple: true,
|
|
77
|
+
closeOnSelect,
|
|
78
|
+
max,
|
|
79
|
+
value,
|
|
80
|
+
defaultValue: defaultValue ?? [],
|
|
81
|
+
onValuesChange: (values, selectedOptions) => onChange?.(values, selectedOptions),
|
|
82
|
+
text,
|
|
83
|
+
id,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { selectedOptions } = combobox;
|
|
87
|
+
const hidden = Math.max(0, selectedOptions.length - maxVisibleTags);
|
|
88
|
+
const visibleTags = hidden > 0 ? selectedOptions.slice(0, maxVisibleTags) : selectedOptions;
|
|
89
|
+
|
|
90
|
+
useImperativeHandle(ref, () => combobox.handle, [combobox.handle]);
|
|
91
|
+
|
|
92
|
+
const selectableVisible = combobox.entries.filter(
|
|
93
|
+
(entry) => !entry.option.disabled && !combobox.isSelected(entry.option.value),
|
|
94
|
+
);
|
|
95
|
+
const canSelectAll =
|
|
96
|
+
showSelectAll &&
|
|
97
|
+
selectableVisible.length > 0 &&
|
|
98
|
+
(max === undefined || selectedOptions.length + selectableVisible.length <= max);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
className={cx("cb-root", classNames?.root, className)}
|
|
103
|
+
style={style}
|
|
104
|
+
data-size={size}
|
|
105
|
+
data-state={combobox.open ? "open" : "closed"}
|
|
106
|
+
data-invalid={error ? "" : undefined}
|
|
107
|
+
>
|
|
108
|
+
{label && (
|
|
109
|
+
<span id={`${id}-label`} className={cx("cb-label", classNames?.label)} data-required={required || undefined}>
|
|
110
|
+
{label}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<div
|
|
115
|
+
ref={controlRef}
|
|
116
|
+
id={id}
|
|
117
|
+
role="combobox"
|
|
118
|
+
tabIndex={disabled ? -1 : 0}
|
|
119
|
+
aria-expanded={combobox.open}
|
|
120
|
+
aria-haspopup="listbox"
|
|
121
|
+
aria-controls={combobox.open ? combobox.listboxId : undefined}
|
|
122
|
+
aria-labelledby={label ? `${id}-label` : undefined}
|
|
123
|
+
aria-describedby={cx(description ? `${id}-description` : "", error ? `${id}-error` : "") || undefined}
|
|
124
|
+
aria-required={required || undefined}
|
|
125
|
+
aria-invalid={error ? true : undefined}
|
|
126
|
+
aria-disabled={disabled || undefined}
|
|
127
|
+
className={cx("cb-control", classNames?.control)}
|
|
128
|
+
data-disabled={disabled || undefined}
|
|
129
|
+
data-invalid={error ? "" : undefined}
|
|
130
|
+
onPointerDown={combobox.onControlPointerDown}
|
|
131
|
+
onClick={combobox.onControlClick}
|
|
132
|
+
onKeyDown={combobox.onKeyDown}
|
|
133
|
+
>
|
|
134
|
+
{selectedOptions.length === 0 ? (
|
|
135
|
+
<span className={cx("cb-value", classNames?.value)}>
|
|
136
|
+
<span className="cb-placeholder">{placeholder}</span>
|
|
137
|
+
</span>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="cb-tags">
|
|
140
|
+
{visibleTags.map((option) => (
|
|
141
|
+
<span key={String(option.value)} className={cx("cb-tag", classNames?.tag)}>
|
|
142
|
+
<span className="cb-tag-label">{option.label}</span>
|
|
143
|
+
{!disabled && (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
tabIndex={-1}
|
|
147
|
+
className="cb-tag-remove"
|
|
148
|
+
aria-label={text.removeOption(option.label)}
|
|
149
|
+
onClick={(event) => {
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
combobox.removeValue(option.value);
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<XIcon width={12} height={12} />
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</span>
|
|
158
|
+
))}
|
|
159
|
+
{hidden > 0 && <span className="cb-tag-overflow">{text.moreCount(String(hidden))}</span>}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
<span className="cb-actions">
|
|
164
|
+
{clearable && selectedOptions.length > 0 && !disabled && (
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
tabIndex={-1}
|
|
168
|
+
className="cb-icon-button"
|
|
169
|
+
aria-label={text.clearAll}
|
|
170
|
+
onClick={(event) => {
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
combobox.clear();
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<XIcon width={14} height={14} />
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
<ChevronIcon className="cb-chevron" />
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{name &&
|
|
183
|
+
selectedOptions.map((option) => (
|
|
184
|
+
<input key={String(option.value)} type="hidden" name={name} value={option.value} />
|
|
185
|
+
))}
|
|
186
|
+
{description && (
|
|
187
|
+
<span id={`${id}-description`} className="cb-description">
|
|
188
|
+
{description}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
{error && (
|
|
192
|
+
<span id={`${id}-error`} className="cb-error" role="alert">
|
|
193
|
+
{error}
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{combobox.anchor && (
|
|
198
|
+
<ComboboxPopover
|
|
199
|
+
anchor={combobox.anchor}
|
|
200
|
+
onClose={combobox.onPopoverClose}
|
|
201
|
+
className={classNames?.popover}
|
|
202
|
+
>
|
|
203
|
+
<Listbox<V>
|
|
204
|
+
listboxId={combobox.listboxId}
|
|
205
|
+
labelledBy={label ? `${id}-label` : id}
|
|
206
|
+
items={combobox.items}
|
|
207
|
+
optionCount={combobox.entries.length}
|
|
208
|
+
activeIndex={combobox.activeIndex}
|
|
209
|
+
multiple
|
|
210
|
+
size={size}
|
|
211
|
+
maxHeight={maxHeight}
|
|
212
|
+
virtualize={virtualize}
|
|
213
|
+
loading={loading}
|
|
214
|
+
hasMore={hasMore}
|
|
215
|
+
searchable={searchable}
|
|
216
|
+
search={combobox.search}
|
|
217
|
+
searchRef={searchRef}
|
|
218
|
+
listRef={listRef}
|
|
219
|
+
text={text}
|
|
220
|
+
classNames={classNames}
|
|
221
|
+
emptyMessage={emptyMessage}
|
|
222
|
+
createLabel={combobox.createLabel}
|
|
223
|
+
getOptionId={combobox.getOptionId}
|
|
224
|
+
isSelected={combobox.isSelected}
|
|
225
|
+
onSelect={combobox.selectOption}
|
|
226
|
+
onActiveIndexChange={combobox.setActiveIndex}
|
|
227
|
+
onSearchChange={combobox.setSearch}
|
|
228
|
+
onKeyDown={combobox.onKeyDown}
|
|
229
|
+
onLoadMore={onLoadMore}
|
|
230
|
+
onCreate={combobox.commitCreate}
|
|
231
|
+
renderOption={renderOption}
|
|
232
|
+
footer={
|
|
233
|
+
<>
|
|
234
|
+
<span className="cb-footer-count">
|
|
235
|
+
{max !== undefined && selectedOptions.length >= max
|
|
236
|
+
? text.maxReached(String(max))
|
|
237
|
+
: text.selectedCount(String(selectedOptions.length))}
|
|
238
|
+
</span>
|
|
239
|
+
<span>
|
|
240
|
+
{canSelectAll && (
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
className="cb-link-button"
|
|
244
|
+
onClick={() => combobox.selectAllVisible()}
|
|
245
|
+
>
|
|
246
|
+
{text.selectAll}
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
{selectedOptions.length > 0 && (
|
|
250
|
+
<button type="button" className="cb-link-button" onClick={() => combobox.clear()}>
|
|
251
|
+
{text.clearAll}
|
|
252
|
+
</button>
|
|
253
|
+
)}
|
|
254
|
+
</span>
|
|
255
|
+
</>
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</ComboboxPopover>
|
|
259
|
+
)}
|
|
260
|
+
{options.length === 0 && loading && <span className="cb-sr-only">{text.loading}</span>}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|