gbs-add-block 1.2.8 → 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.
@@ -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
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, type ReactNode, type ToggleEvent } from "react";
4
+ import { cx } from "./props";
5
+
6
+ interface PopoverProps {
7
+ anchor: HTMLElement;
8
+ onClose(): void;
9
+ children: ReactNode;
10
+ className?: string;
11
+ /** Distance between the anchor and the popover. */
12
+ gap?: number;
13
+ }
14
+
15
+ /**
16
+ * Anchored popover on the native Popover API: top layer (never clipped by a
17
+ * scrolling parent or overflow), with light dismiss and Escape built in.
18
+ * It matches the anchor's width and flips above when space is tight.
19
+ */
20
+ export function ComboboxPopover({ anchor, onClose, children, className, gap = 4 }: PopoverProps) {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+
23
+ useLayoutEffect(() => {
24
+ const el = ref.current;
25
+ if (!el) return;
26
+
27
+ const position = () => {
28
+ const rect = anchor.getBoundingClientRect();
29
+ el.style.minWidth = `${rect.width}px`;
30
+ const height = el.offsetHeight;
31
+ const below = window.innerHeight - rect.bottom - gap - 8;
32
+ const flip = height > below && rect.top - gap - 8 > below;
33
+ const left = Math.min(Math.max(8, rect.left), window.innerWidth - el.offsetWidth - 8);
34
+ el.style.left = `${left}px`;
35
+ el.style.top = flip ? `${Math.max(8, rect.top - gap - height)}px` : `${rect.bottom + gap}px`;
36
+ el.style.maxHeight = `${Math.max(140, flip ? rect.top - gap - 8 : below)}px`;
37
+ };
38
+
39
+ el.showPopover();
40
+ position();
41
+ // Focus once the popover is actually shown; a popover is display:none until then.
42
+ el.querySelector<HTMLElement>("[data-autofocus], input, [tabindex]")?.focus({ preventScroll: true });
43
+
44
+ // Follow both sizes: the list as options filter, and the control as tags wrap.
45
+ const observer = new ResizeObserver(position);
46
+ observer.observe(el);
47
+ observer.observe(anchor);
48
+ window.addEventListener("resize", position);
49
+ window.addEventListener("scroll", position, true);
50
+ return () => {
51
+ observer.disconnect();
52
+ window.removeEventListener("resize", position);
53
+ window.removeEventListener("scroll", position, true);
54
+ if (el.matches(":popover-open")) el.hidePopover();
55
+ };
56
+ }, [anchor, gap]);
57
+
58
+ return (
59
+ <div
60
+ ref={ref}
61
+ popover="auto"
62
+ className={cx("cb-popover", className)}
63
+ onToggle={(event: ToggleEvent<HTMLDivElement>) => {
64
+ if (event.newState === "closed") onClose();
65
+ }}
66
+ >
67
+ {children}
68
+ </div>
69
+ );
70
+ }