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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.8)
1
+ # GBS Building Blocks 2.0 (v1.2.9)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
@@ -6,9 +6,9 @@ Latest and upgraded version of GBS building blocks with headless UI and removed
6
6
 
7
7
  For detailed documentation on usage and props, Please visit: [Building Block Documentation v2.0](https://gramprokit.vercel.app)
8
8
 
9
- ## What's New 🎉 (Ver 1.2.8)
9
+ ## What's New 🎉 (Ver 1.2.9)
10
10
 
11
- - Canditate update for next major change 2.0.0
11
+ - Update Candidate for next major change 2.0.0
12
12
 
13
13
  ## Authors
14
14
 
package/index.cjs CHANGED
@@ -35,6 +35,7 @@ const CONFIG = {
35
35
  "UsePaginatedData",
36
36
  "UseUploader",
37
37
  "DataGridBeta",
38
+ "Combobox"
38
39
  ],
39
40
  // Define component dependencies
40
41
  dependencies: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,170 @@
1
+ # Combobox: Select and MultiSelect
2
+
3
+ Two value pickers with option search, built on one engine and styled to match
4
+ the DataGrid. No runtime dependencies besides React 19.
5
+
6
+ - **`<Select>`** — one value.
7
+ - **`<MultiSelect>`** — many values, shown as tags.
8
+
9
+ Both support client or server options, grouping, descriptions, disabled
10
+ options, virtualization for long lists, creating new options, forms, full
11
+ keyboard control, dark mode and right-to-left layouts.
12
+
13
+ ## Setup
14
+
15
+ ```ts
16
+ import { MultiSelect, Select } from "@/components/combobox";
17
+ import "@/components/combobox/styles.css";
18
+ ```
19
+
20
+ The stylesheet reuses the DataGrid's `--dg-*` variables when that stylesheet is
21
+ loaded, so both components share a theme, and falls back to the same palette
22
+ when used on its own.
23
+
24
+ ## Basic usage
25
+
26
+ ```tsx
27
+ const countries = [
28
+ { value: "in", label: "India", group: "Asia" },
29
+ { value: "de", label: "Germany", group: "Europe", description: "Berlin" },
30
+ { value: "mx", label: "Mexico", group: "Americas", disabled: true },
31
+ ];
32
+
33
+ const [country, setCountry] = useState<string | null>(null);
34
+
35
+ <Select label="Country" options={countries} value={country} onChange={setCountry} />;
36
+
37
+ const [tags, setTags] = useState<string[]>([]);
38
+ <MultiSelect label="Tags" options={tagOptions} value={tags} onChange={setTags} max={5} />;
39
+ ```
40
+
41
+ Both are controlled with `value` + `onChange`, or uncontrolled with
42
+ `defaultValue`. `onChange` also receives the full option objects:
43
+ `onChange={(value, option) => …}` (single) and `(values, options) => …` (multi).
44
+
45
+ ## Options
46
+
47
+ ```ts
48
+ interface ComboboxOption<V = string> {
49
+ value: V; // string or number
50
+ label: string;
51
+ description?: string; // second line
52
+ group?: string; // heading; groups appear in first-seen order
53
+ disabled?: boolean;
54
+ icon?: ReactNode;
55
+ keywords?: string[]; // extra search terms
56
+ }
57
+ ```
58
+
59
+ Search matches the label (and highlights it), the description and keywords.
60
+
61
+ ## Props
62
+
63
+ Shared by both components:
64
+
65
+ | Prop | Type | Default | Description |
66
+ | --- | --- | --- | --- |
67
+ | `options` | `ComboboxOption<V>[]` | required | Options to show. |
68
+ | `mode` | `"client"` \| `"server"` | `"client"` | `server` skips local filtering; `options` are the current results. |
69
+ | `loading` | `boolean` | `false` | Shows a spinner; keeps current options visible. |
70
+ | `onSearchChange` | `(search: string) => void` | — | Search text; debounced in server mode, and fired on open. |
71
+ | `searchDebounce` | `number` | `250` | Debounce in milliseconds (server mode). |
72
+ | `searchable` | `boolean` | `true` | Show the search box. When `false`, typing jumps to a matching option. |
73
+ | `hasMore` / `onLoadMore` | `boolean` / `() => void` | — | Paging: called when the list nears its end, plus a "Load more" button. |
74
+ | `filterFn` | `(option, search) => boolean` | — | Replaces the built-in matching (client mode). |
75
+ | `renderOption` | `(option, { selected, active }) => ReactNode` | — | Custom option content. |
76
+ | `allowCreate` / `onCreate` | `boolean` / `(label: string) => void` | — | Offers "Create …" when nothing matches. |
77
+ | `label`, `description`, `error` | `ReactNode` | — | Field label, hint and error message. `error` also marks the control invalid. |
78
+ | `placeholder` | `string` | `"Select…"` | Shown when nothing is selected. |
79
+ | `required`, `disabled` | `boolean` | `false` | Field states. |
80
+ | `clearable` | `boolean` | `true` | Show the clear button. |
81
+ | `size` | `"sm"` \| `"md"` \| `"lg"` | `"md"` | Control height and font size. |
82
+ | `name` | `string` | — | Posts hidden inputs for forms (one per value). |
83
+ | `maxHeight` | `number` | `280` | Max height of the list. |
84
+ | `virtualize` | `boolean` \| `number` | `true` | Virtualizes above 80 options; pass a number to change the threshold. |
85
+ | `emptyMessage` | `ReactNode` | "No options found" | Shown when nothing matches. |
86
+ | `className`, `classNames`, `style` | — | — | Styling hooks. Slots: `root`, `label`, `control`, `value`, `tag`, `popover`, `search`, `list`, `option`, `footer`. |
87
+ | `localeText` | `Partial<ComboboxLocaleText>` | English | Overrides UI text. |
88
+ | `onOpenChange` | `(open: boolean) => void` | — | Fires when the list opens or closes. |
89
+ | `ref` | `Ref<ComboboxHandle<V>>` | — | `open()`, `close()`, `toggle()`, `focus()`, `clear()`, `getValue()`, `getSelectedOptions()`. |
90
+
91
+ `Select` adds `value` / `defaultValue` (`V | null`) and `closeOnSelect`
92
+ (default `true`). `MultiSelect` adds `value` / `defaultValue` (`V[]`), `max`,
93
+ `maxVisibleTags` (default 3), `showSelectAll` (default `true`) and
94
+ `closeOnSelect` (default `false`).
95
+
96
+ ## Server options
97
+
98
+ ```tsx
99
+ const [search, setSearch] = useState("");
100
+ const [page, setPage] = useState(0);
101
+ const { data, isFetching } = useQuery({
102
+ queryKey: ["people", search, page],
103
+ queryFn: ({ signal }) => fetchPeople({ search, page, signal }),
104
+ placeholderData: keepPreviousData,
105
+ });
106
+
107
+ <MultiSelect
108
+ mode="server"
109
+ options={data?.options ?? []}
110
+ hasMore={data?.hasMore}
111
+ loading={isFetching}
112
+ onSearchChange={(term) => { setPage(0); setSearch(term); }}
113
+ onLoadMore={() => setPage((p) => p + 1)}
114
+ value={selected}
115
+ onChange={setSelected}
116
+ />;
117
+ ```
118
+
119
+ - `onSearchChange` fires once when the list opens (so you can load a first page)
120
+ and then debounced as the user types.
121
+ - Labels of chosen options are remembered, so tags stay readable after the
122
+ results change. If you set `value` from outside before the matching options
123
+ have loaded, the raw value is shown until they arrive.
124
+
125
+ ## Keyboard
126
+
127
+ | Keys | Action |
128
+ | --- | --- |
129
+ | **Enter**, **Space**, **↓**, **↑** | Open the list. |
130
+ | **↓** / **↑** | Move between options (disabled options are skipped). |
131
+ | **Home** / **End** | First / last option. |
132
+ | **Page Down** / **Page Up** | Move ten options. |
133
+ | **Enter** | Choose the highlighted option, or create. |
134
+ | **Escape** | Close and return focus to the control. |
135
+ | **Tab** | Close and move to the next field. |
136
+ | **Backspace** | MultiSelect: remove the last tag (when the search box is empty). |
137
+ | typing | Types into the search box, or jumps to a matching option when `searchable={false}`. |
138
+
139
+ The control is a `role="combobox"` that owns a `role="listbox"`; the active
140
+ option is tracked with `aria-activedescendant`, so focus stays in the search
141
+ box. The list renders in the top layer through the native Popover API, so it is
142
+ never clipped by a scrolling parent (including inside a DataGrid cell).
143
+
144
+ ## Theming
145
+
146
+ Override `--cb-*` variables (they default to the grid's `--dg-*`):
147
+
148
+ ```css
149
+ .cb-root { --cb-accent: #7c3aed; --cb-radius: 12px; }
150
+ ```
151
+
152
+ State attributes for styling: `data-state="open"`, `data-size`, `data-invalid`,
153
+ `data-disabled` on the root and control; `data-active`, `data-selected`,
154
+ `data-disabled` on options.
155
+
156
+ ## Headless use
157
+
158
+ `useCombobox()` holds the whole engine (filtering, active option, keyboard,
159
+ selection, search requests) and is exported if you want to build a different UI
160
+ on top. The framework-free helpers — `filterOptions`, `buildListItems`,
161
+ `nextEnabledIndex`, `toggleValue`, `measureItems`, `getVisibleRange` — are
162
+ exported from `@/components/combobox/core` and can also run on a server.
163
+
164
+ ## Known limits
165
+
166
+ - Option rows have a fixed height per `size` (taller when any option has a
167
+ description), because the list is virtualized.
168
+ - No async "load option by value": pass options that include the selected
169
+ values, or select them through the UI at least once.
170
+ - Tree or multi-level options are not supported.
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildListItems,
4
+ filterOptions,
5
+ findByPrefix,
6
+ firstEnabledIndex,
7
+ lastEnabledIndex,
8
+ matchRanges,
9
+ nextEnabledIndex,
10
+ toggleValue,
11
+ } from "../core/filter";
12
+ import type { ComboboxOption } from "../core/types";
13
+ import { getVisibleRange, measureItems, scrollToItem } from "../core/virtual";
14
+
15
+ const options: ComboboxOption[] = [
16
+ { value: "in", label: "India", group: "Asia", keywords: ["bharat"] },
17
+ { value: "jp", label: "Japan", group: "Asia" },
18
+ { value: "kr", label: "South Korea", group: "Asia", disabled: true },
19
+ { value: "de", label: "Germany", group: "Europe", description: "Berlin" },
20
+ { value: "fr", label: "France", group: "Europe" },
21
+ ];
22
+
23
+ const labels = (list: { option: ComboboxOption }[]) => list.map((entry) => entry.option.label);
24
+
25
+ describe("matching", () => {
26
+ it("returns merged, case-insensitive ranges for every term", () => {
27
+ expect(matchRanges("South Korea", ["korea"])).toEqual([[6, 11]]);
28
+ expect(matchRanges("Germany", ["ger", "man"])).toEqual([[0, 6]]);
29
+ expect(matchRanges("Germany", ["ger", "xyz"])).toBeNull();
30
+ expect(matchRanges("Germany", [])).toEqual([]);
31
+ });
32
+
33
+ it("filters on label, description and keywords", () => {
34
+ expect(labels(filterOptions(options, ""))).toHaveLength(5);
35
+ expect(labels(filterOptions(options, "an"))).toEqual(["Japan", "Germany", "France"]);
36
+ expect(labels(filterOptions(options, "berlin"))).toEqual(["Germany"]);
37
+ expect(labels(filterOptions(options, "bharat"))).toEqual(["India"]);
38
+ expect(labels(filterOptions(options, "nothing"))).toEqual([]);
39
+ });
40
+
41
+ it("highlights the label only", () => {
42
+ const [india] = filterOptions(options, "ind");
43
+ expect(india.matches).toEqual([[0, 3]]);
44
+ const [germany] = filterOptions(options, "berlin");
45
+ expect(germany.matches).toEqual([]);
46
+ });
47
+
48
+ it("uses a custom filter when given", () => {
49
+ const entries = filterOptions(options, "x", (option) => option.value === "jp");
50
+ expect(labels(entries)).toEqual(["Japan"]);
51
+ });
52
+ });
53
+
54
+ describe("list building", () => {
55
+ it("adds group headings and numbers options across groups", () => {
56
+ const items = buildListItems(filterOptions(options, ""));
57
+ expect(items.map((item) => (item.kind === "group" ? `# ${item.label}` : item.entry.option.label))).toEqual([
58
+ "# Asia",
59
+ "India",
60
+ "Japan",
61
+ "South Korea",
62
+ "# Europe",
63
+ "Germany",
64
+ "France",
65
+ ]);
66
+ const optionItems = items.filter((item) => item.kind === "option");
67
+ expect(optionItems.map((item) => (item.kind === "option" ? item.index : -1))).toEqual([0, 1, 2, 3, 4]);
68
+ });
69
+
70
+ it("skips headings when no option has a group", () => {
71
+ const flat = buildListItems(filterOptions([{ value: "a", label: "A" }], ""));
72
+ expect(flat).toHaveLength(1);
73
+ expect(flat[0].kind).toBe("option");
74
+ });
75
+ });
76
+
77
+ describe("keyboard helpers", () => {
78
+ const entries = filterOptions(options, "");
79
+
80
+ it("moves over enabled options and wraps", () => {
81
+ expect(nextEnabledIndex(entries, 0, 1)).toBe(1);
82
+ expect(nextEnabledIndex(entries, 1, 1)).toBe(3); // skips the disabled option
83
+ expect(nextEnabledIndex(entries, 4, 1)).toBe(0);
84
+ expect(nextEnabledIndex(entries, 0, -1)).toBe(4);
85
+ expect(nextEnabledIndex([], 0, 1)).toBe(-1);
86
+ });
87
+
88
+ it("finds the first and last enabled option", () => {
89
+ expect(firstEnabledIndex(entries)).toBe(0);
90
+ expect(firstEnabledIndex(entries, 2)).toBe(3);
91
+ expect(lastEnabledIndex(entries)).toBe(4);
92
+ expect(lastEnabledIndex([{ option: options[2], matches: [] }])).toBe(-1);
93
+ });
94
+
95
+ it("jumps to a typed prefix", () => {
96
+ expect(findByPrefix(entries, "ja", -1)).toBe(1);
97
+ expect(findByPrefix(entries, "s", -1)).toBe(-1); // South Korea is disabled
98
+ expect(findByPrefix(entries, "zz", -1)).toBe(-1);
99
+ });
100
+ });
101
+
102
+ describe("selection", () => {
103
+ it("adds, removes and respects max", () => {
104
+ expect(toggleValue(["a"], "b")).toEqual(["a", "b"]);
105
+ expect(toggleValue(["a", "b"], "a")).toEqual(["b"]);
106
+ expect(toggleValue(["a", "b"], "c", 2)).toEqual(["a", "b"]);
107
+ expect(toggleValue(["a", "b"], "b", 2)).toEqual(["a"]);
108
+ });
109
+ });
110
+
111
+ describe("virtualization", () => {
112
+ const items = buildListItems(filterOptions(options, ""));
113
+ const metrics = measureItems(items, { option: 34, group: 26 });
114
+
115
+ it("measures group and option rows", () => {
116
+ expect(metrics.total).toBe(2 * 26 + 5 * 34);
117
+ expect(metrics.offsets[0]).toBe(0);
118
+ expect(metrics.offsets[1]).toBe(26);
119
+ });
120
+
121
+ it("returns the visible window with overscan", () => {
122
+ expect(getVisibleRange(metrics, 0, 60, 0)).toEqual({ start: 0, end: 3 });
123
+ expect(getVisibleRange(metrics, 0, 0, 0)).toEqual({ start: 0, end: 7 });
124
+ const range = getVisibleRange(metrics, 100, 60, 1);
125
+ expect(range.start).toBeLessThanOrEqual(3);
126
+ expect(range.end).toBeGreaterThanOrEqual(5);
127
+ });
128
+
129
+ it("scrolls an item into view only when needed", () => {
130
+ expect(scrollToItem(metrics, 0, 0, 100)).toBeNull();
131
+ expect(scrollToItem(metrics, 6, 0, 100)).toBe(metrics.offsets[7] - 100);
132
+ expect(scrollToItem(metrics, 1, 40, 100)).toBe(26);
133
+ });
134
+ });
@@ -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";