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 CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.17)
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.17)
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,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.17",
3
+ "version": "1.2.18",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -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
- // 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
- }
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
+ }