gbs-add-block 1.2.8 → 1.2.10
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 +12 -3
- package/index.cjs +123 -79
- package/package.json +1 -1
- package/source/beta-components/combobox/README.md +170 -0
- package/source/beta-components/combobox/__tests__/core.test.ts +134 -0
- package/source/beta-components/combobox/core/filter.ts +155 -0
- package/source/beta-components/combobox/core/index.ts +16 -0
- package/source/beta-components/combobox/core/types.ts +48 -0
- package/source/beta-components/combobox/core/virtual.ts +67 -0
- package/source/beta-components/combobox/index.ts +13 -0
- package/source/beta-components/combobox/react/Listbox.tsx +302 -0
- package/source/beta-components/combobox/react/MultiSelect.tsx +263 -0
- package/source/beta-components/combobox/react/Popover.tsx +70 -0
- package/source/beta-components/combobox/react/Select.tsx +198 -0
- package/source/beta-components/combobox/react/icons.tsx +34 -0
- package/source/beta-components/combobox/react/locale.ts +16 -0
- package/source/beta-components/combobox/react/props.ts +86 -0
- package/source/beta-components/combobox/react/useCombobox.ts +399 -0
- package/source/beta-components/combobox/styles.css +417 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/README.md +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/core.test.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/export.test.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/columnHelper.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/columns.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/filtering.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/grid.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/index.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/rows.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/sorting.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/state.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/store.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/types.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/values.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/virtual.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/csv.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/download.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/pdf.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/table.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/xlsx.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/zip.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/index.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Cell.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/ColumnMenu.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/DataGrid.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/FilterForm.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/HeaderRow.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Pagination.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Popover.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Row.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Toolbar.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Viewport.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/context.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/hooks.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/icons.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/keyboard.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/locale.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/styles.css +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComboboxOption,
|
|
3
|
+
ListItem,
|
|
4
|
+
MatchRange,
|
|
5
|
+
OptionEntry,
|
|
6
|
+
OptionValue,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
/** Ranges of `text` matching every search term, or null when one term is missing. */
|
|
10
|
+
export function matchRanges(text: string, terms: readonly string[]): MatchRange[] | null {
|
|
11
|
+
if (terms.length === 0) return [];
|
|
12
|
+
const haystack = text.toLocaleLowerCase();
|
|
13
|
+
const ranges: MatchRange[] = [];
|
|
14
|
+
for (const term of terms) {
|
|
15
|
+
const start = haystack.indexOf(term);
|
|
16
|
+
if (start === -1) return null;
|
|
17
|
+
ranges.push([start, start + term.length]);
|
|
18
|
+
}
|
|
19
|
+
return mergeRanges(ranges);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mergeRanges(ranges: MatchRange[]): MatchRange[] {
|
|
23
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
|
|
24
|
+
const merged: MatchRange[] = [];
|
|
25
|
+
for (const range of sorted) {
|
|
26
|
+
const last = merged.at(-1);
|
|
27
|
+
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
|
|
28
|
+
else merged.push([...range]);
|
|
29
|
+
}
|
|
30
|
+
return merged;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const splitTerms = (search: string) =>
|
|
34
|
+
search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filters options by search text. The label is matched first; description and
|
|
38
|
+
* keywords also match but are not highlighted.
|
|
39
|
+
*/
|
|
40
|
+
export function filterOptions<V extends OptionValue>(
|
|
41
|
+
options: readonly ComboboxOption<V>[],
|
|
42
|
+
search: string,
|
|
43
|
+
filterFn?: (option: ComboboxOption<V>, search: string) => boolean,
|
|
44
|
+
): OptionEntry<V>[] {
|
|
45
|
+
const terms = splitTerms(search);
|
|
46
|
+
if (terms.length === 0 && !filterFn) {
|
|
47
|
+
return options.map((option) => ({ option, matches: [] }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const entries: OptionEntry<V>[] = [];
|
|
51
|
+
for (const option of options) {
|
|
52
|
+
if (filterFn) {
|
|
53
|
+
if (filterFn(option, search)) entries.push({ option, matches: matchRanges(option.label, terms) ?? [] });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const labelMatches = matchRanges(option.label, terms);
|
|
57
|
+
if (labelMatches) {
|
|
58
|
+
entries.push({ option, matches: labelMatches });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const extra = [option.description, ...(option.keywords ?? [])].filter(Boolean).join(" ");
|
|
62
|
+
if (extra && matchRanges(extra, terms)) entries.push({ option, matches: [] });
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Adds group headings, keeping the order in which groups first appear. */
|
|
68
|
+
export function buildListItems<V extends OptionValue>(entries: readonly OptionEntry<V>[]): ListItem<V>[] {
|
|
69
|
+
const hasGroups = entries.some((entry) => entry.option.group);
|
|
70
|
+
if (!hasGroups) {
|
|
71
|
+
return entries.map((entry, index) => ({ kind: "option", entry, index }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const groups = new Map<string, OptionEntry<V>[]>();
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const key = entry.option.group ?? "";
|
|
77
|
+
const list = groups.get(key);
|
|
78
|
+
if (list) list.push(entry);
|
|
79
|
+
else groups.set(key, [entry]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const items: ListItem<V>[] = [];
|
|
83
|
+
let index = 0;
|
|
84
|
+
for (const [group, list] of groups) {
|
|
85
|
+
if (group) items.push({ kind: "group", label: group });
|
|
86
|
+
for (const entry of list) items.push({ kind: "option", entry, index: index++ });
|
|
87
|
+
}
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Next selectable option index, skipping disabled options. Wraps around. */
|
|
92
|
+
export function nextEnabledIndex<V extends OptionValue>(
|
|
93
|
+
entries: readonly OptionEntry<V>[],
|
|
94
|
+
from: number,
|
|
95
|
+
delta: number,
|
|
96
|
+
): number {
|
|
97
|
+
const count = entries.length;
|
|
98
|
+
if (count === 0) return -1;
|
|
99
|
+
let index = from;
|
|
100
|
+
for (let step = 0; step < count; step++) {
|
|
101
|
+
index = index + delta;
|
|
102
|
+
if (index < 0) index = count - 1;
|
|
103
|
+
if (index >= count) index = 0;
|
|
104
|
+
if (!entries[index].option.disabled) return index;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** First selectable option at or after `from` (searching forward, no wrap). */
|
|
110
|
+
export function firstEnabledIndex<V extends OptionValue>(
|
|
111
|
+
entries: readonly OptionEntry<V>[],
|
|
112
|
+
from = 0,
|
|
113
|
+
): number {
|
|
114
|
+
for (let index = Math.max(0, from); index < entries.length; index++) {
|
|
115
|
+
if (!entries[index].option.disabled) return index;
|
|
116
|
+
}
|
|
117
|
+
for (let index = Math.min(from, entries.length) - 1; index >= 0; index--) {
|
|
118
|
+
if (!entries[index].option.disabled) return index;
|
|
119
|
+
}
|
|
120
|
+
return -1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function lastEnabledIndex<V extends OptionValue>(entries: readonly OptionEntry<V>[]): number {
|
|
124
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
125
|
+
if (!entries[index].option.disabled) return index;
|
|
126
|
+
}
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Adds or removes a value, respecting `max` (ignored when removing). */
|
|
131
|
+
export function toggleValue<V extends OptionValue>(
|
|
132
|
+
values: readonly V[],
|
|
133
|
+
value: V,
|
|
134
|
+
max?: number,
|
|
135
|
+
): V[] {
|
|
136
|
+
if (values.includes(value)) return values.filter((v) => v !== value);
|
|
137
|
+
if (max !== undefined && values.length >= max) return [...values];
|
|
138
|
+
return [...values, value];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Index of the first option matching a typed prefix, for type-ahead. */
|
|
142
|
+
export function findByPrefix<V extends OptionValue>(
|
|
143
|
+
entries: readonly OptionEntry<V>[],
|
|
144
|
+
prefix: string,
|
|
145
|
+
from: number,
|
|
146
|
+
): number {
|
|
147
|
+
const needle = prefix.toLocaleLowerCase();
|
|
148
|
+
const count = entries.length;
|
|
149
|
+
for (let step = 1; step <= count; step++) {
|
|
150
|
+
const index = (from + step + count) % count;
|
|
151
|
+
const { option } = entries[index];
|
|
152
|
+
if (!option.disabled && option.label.toLocaleLowerCase().startsWith(needle)) return index;
|
|
153
|
+
}
|
|
154
|
+
return -1;
|
|
155
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Framework-free helpers: the same filtering and list building the components
|
|
2
|
+
// use, so a server (or a test) can reproduce them.
|
|
3
|
+
export {
|
|
4
|
+
buildListItems,
|
|
5
|
+
filterOptions,
|
|
6
|
+
findByPrefix,
|
|
7
|
+
firstEnabledIndex,
|
|
8
|
+
lastEnabledIndex,
|
|
9
|
+
matchRanges,
|
|
10
|
+
nextEnabledIndex,
|
|
11
|
+
splitTerms,
|
|
12
|
+
toggleValue,
|
|
13
|
+
} from "./filter";
|
|
14
|
+
export { getVisibleRange, measureItems, scrollToItem } from "./virtual";
|
|
15
|
+
export type { ItemSizes, ListMetrics, Range } from "./virtual";
|
|
16
|
+
export type * from "./types";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type OptionValue = string | number;
|
|
4
|
+
|
|
5
|
+
export interface ComboboxOption<V extends OptionValue = string> {
|
|
6
|
+
value: V;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Secondary line shown under the label. */
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Options with the same group are listed under one heading. */
|
|
11
|
+
group?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Rendered before the label. */
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
/** Extra text the search should match (synonyms, codes). */
|
|
16
|
+
keywords?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Character range of a search hit, used to highlight the label. */
|
|
20
|
+
export type MatchRange = [start: number, end: number];
|
|
21
|
+
|
|
22
|
+
export interface OptionEntry<V extends OptionValue = string> {
|
|
23
|
+
option: ComboboxOption<V>;
|
|
24
|
+
/** Ranges in `option.label` that matched the search. */
|
|
25
|
+
matches: MatchRange[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One row of the rendered list: a group heading or an option. */
|
|
29
|
+
export type ListItem<V extends OptionValue = string> =
|
|
30
|
+
| { kind: "group"; label: string }
|
|
31
|
+
| { kind: "option"; entry: OptionEntry<V>; index: number };
|
|
32
|
+
|
|
33
|
+
export type ComboboxSize = "sm" | "md" | "lg";
|
|
34
|
+
|
|
35
|
+
export interface ComboboxLocaleText {
|
|
36
|
+
searchPlaceholder: string;
|
|
37
|
+
noResults: string;
|
|
38
|
+
loading: string;
|
|
39
|
+
loadMore: string;
|
|
40
|
+
clear: string;
|
|
41
|
+
clearAll: string;
|
|
42
|
+
selectAll: string;
|
|
43
|
+
createOption(label: string): string;
|
|
44
|
+
selectedCount(count: string): string;
|
|
45
|
+
moreCount(count: string): string;
|
|
46
|
+
removeOption(label: string): string;
|
|
47
|
+
maxReached(max: string): string;
|
|
48
|
+
}
|
|
@@ -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
|
+
}
|