@casinogate/ui 1.9.6 → 1.9.7

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.
@@ -262,6 +262,9 @@
262
262
  .cgui\:mt-4 {
263
263
  margin-top: calc(var(--cgui-spacing) * 4);
264
264
  }
265
+ .cgui\:mb-1 {
266
+ margin-bottom: calc(var(--cgui-spacing) * 1);
267
+ }
265
268
  .cgui\:mb-2 {
266
269
  margin-bottom: calc(var(--cgui-spacing) * 2);
267
270
  }
@@ -0,0 +1,403 @@
1
+ <script lang="ts">
2
+ import type { CommandCollection, CommandItem } from '../command/index.js';
3
+ import type { ComboboxAsyncProps } from './types.js';
4
+
5
+ import { boolAttr } from '../../internal/utils/attrs.js';
6
+ import { cn } from '../../internal/utils/common.js';
7
+ import { watch } from 'runed';
8
+ import type { AnyFn } from 'svelte-toolbelt';
9
+ import { onMountEffect } from 'svelte-toolbelt';
10
+ import { cubicInOut } from 'svelte/easing';
11
+ import { fly } from 'svelte/transition';
12
+ import { CommandApi, CommandPrimitive } from '../command/index.js';
13
+ import { Icon as IconComponent } from '../icons/index.js';
14
+ import { Kbd } from '../kbd/index.js';
15
+ import { PopoverPrimitive } from '../popover/index.js';
16
+ import { Spinner } from '../spinner/index.js';
17
+ import { comboboxTriggerVariants } from './styles.js';
18
+
19
+ let {
20
+ open = $bindable(false),
21
+ value = $bindable(''),
22
+ searchValue = $bindable(''),
23
+ groups = [],
24
+ empty,
25
+ placeholder = 'Select an option',
26
+ searchPlaceholder,
27
+ trigger: triggerSnippet,
28
+ item: itemSnippet,
29
+ portalDisabled = false,
30
+ allowDeselect = true,
31
+ triggerClass,
32
+ size = 'default',
33
+ variant = 'default',
34
+ rounded = 'default',
35
+ fullWidth = true,
36
+ closeOnSelect = true,
37
+
38
+ loading: loadingSnippet,
39
+ pageSize = 10,
40
+ callback,
41
+ initialItems = [],
42
+ loadImmediate = true,
43
+ searchDebounce = 300,
44
+ dependsOn,
45
+ clearOnDependencyChange = true,
46
+
47
+ ...restProps
48
+ }: ComboboxAsyncProps = $props();
49
+
50
+ let items = $state<CommandItem[]>(initialItems);
51
+ let isLoading = $state(false);
52
+ let hasMore = $state(true);
53
+ let currentPage = $state(1);
54
+ let error = $state<string | null>(null);
55
+ let viewportRef = $state<HTMLElement | null>(null);
56
+ let searchTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
57
+ let lastSearch = $state('');
58
+
59
+ const createCollection = (itemList: CommandItem[]): CommandCollection => {
60
+ const groupOrderMap = new Map<string, number>();
61
+ groups.forEach((g, index) => {
62
+ groupOrderMap.set(g.value, g.order ?? index);
63
+ });
64
+
65
+ return CommandApi.createCollection({
66
+ items: itemList,
67
+ groups: groups,
68
+ groupSort: (a, b) => {
69
+ const orderA = groupOrderMap.get(a) ?? Infinity;
70
+ const orderB = groupOrderMap.get(b) ?? Infinity;
71
+ if (orderA !== orderB) return orderA - orderB;
72
+ return a.localeCompare(b);
73
+ },
74
+ });
75
+ };
76
+
77
+ const collection = $derived(createCollection(items));
78
+ const groupMap = $derived(new Map(collection.groups.map((g) => [g.value, g])));
79
+ const groupedItems = $derived(collection.group());
80
+
81
+ const displayValue = $derived.by(() => {
82
+ const val = items.find((item) => item.value === value)?.label ?? value;
83
+ return val.trim() !== '' ? val : placeholder;
84
+ });
85
+
86
+ const isPlaceholder = $derived.by(() => {
87
+ return value.trim() === '';
88
+ });
89
+
90
+ const hasResults = $derived(collection.size > 0);
91
+
92
+ const fetchData = async (page: number, search: string, append = false) => {
93
+ if (isLoading) return;
94
+
95
+ isLoading = true;
96
+ error = null;
97
+
98
+ try {
99
+ const result = await callback({ page, pageSize, search });
100
+
101
+ if (append) {
102
+ items = [...items, ...result.items];
103
+ } else {
104
+ items = result.items;
105
+ }
106
+
107
+ currentPage = page;
108
+ hasMore = result.hasMore;
109
+ lastSearch = search;
110
+
111
+ // Check if viewport needs more items after render
112
+ requestAnimationFrame(() => {
113
+ checkNeedMoreItems();
114
+ });
115
+ } catch (err) {
116
+ error = err instanceof Error ? err.message : 'Failed to fetch data';
117
+ } finally {
118
+ isLoading = false;
119
+ }
120
+ };
121
+
122
+ // Auto-fill viewport if content doesn't overflow
123
+ const checkNeedMoreItems = () => {
124
+ if (!viewportRef || isLoading || !hasMore) return;
125
+
126
+ const { scrollHeight, clientHeight } = viewportRef;
127
+
128
+ // If content doesn't fill viewport, load more
129
+ if (scrollHeight <= clientHeight) {
130
+ loadMore();
131
+ }
132
+ };
133
+
134
+ // Debounced search handler
135
+ const debouncedSearch = (search: string) => {
136
+ if (searchTimeout) {
137
+ clearTimeout(searchTimeout);
138
+ }
139
+
140
+ searchTimeout = setTimeout(() => {
141
+ currentPage = 1;
142
+ fetchData(1, search, false);
143
+ }, searchDebounce);
144
+ };
145
+
146
+ // Watch for search value changes
147
+ watch(
148
+ () => searchValue,
149
+ () => {
150
+ if (searchValue !== lastSearch) {
151
+ debouncedSearch(searchValue);
152
+ }
153
+ }
154
+ );
155
+
156
+ // Fetch on first open or check viewport fill
157
+ watch(
158
+ () => open,
159
+ () => {
160
+ if (!open) return;
161
+
162
+ // If no items yet and not loading, fetch first page
163
+ if (items.length === 0 && !isLoading && !loadImmediate) {
164
+ fetchData(1, searchValue);
165
+ return;
166
+ }
167
+
168
+ // If already have items, check if viewport needs more after it renders
169
+ requestAnimationFrame(() => {
170
+ checkNeedMoreItems();
171
+ });
172
+ }
173
+ );
174
+
175
+ // Fetch immediate data on mount
176
+ onMountEffect(() => {
177
+ if (loadImmediate) fetchData(1, searchValue);
178
+ });
179
+
180
+ // Reset page on unmount
181
+ $effect(() => {
182
+ return () => {
183
+ currentPage = 1;
184
+ if (searchTimeout) {
185
+ clearTimeout(searchTimeout);
186
+ }
187
+ };
188
+ });
189
+
190
+ // Watch for dependency changes and reset
191
+ let previousDependency = $state<unknown>(dependsOn?.());
192
+ watch(
193
+ () => dependsOn?.(),
194
+ (newValue) => {
195
+ if (previousDependency === undefined && newValue === undefined) return;
196
+ if (previousDependency === newValue) return;
197
+
198
+ previousDependency = newValue;
199
+
200
+ // Reset state
201
+ items = [];
202
+ currentPage = 1;
203
+ hasMore = true;
204
+ error = null;
205
+ searchValue = '';
206
+ lastSearch = '';
207
+
208
+ // Clear selected value if enabled
209
+ if (clearOnDependencyChange) {
210
+ value = '';
211
+ }
212
+
213
+ // Reload data
214
+ fetchData(1, '');
215
+ }
216
+ );
217
+
218
+ const loadMore = () => {
219
+ if (!isLoading && hasMore) {
220
+ fetchData(currentPage + 1, searchValue, true);
221
+ }
222
+ };
223
+
224
+ const handleScroll = (event: Event) => {
225
+ const viewport = event.target as HTMLElement;
226
+ const scrollBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
227
+
228
+ if (scrollBottom < 100 && hasMore && !isLoading) {
229
+ loadMore();
230
+ }
231
+ };
232
+
233
+ const retry = () => {
234
+ error = null;
235
+ fetchData(currentPage, searchValue);
236
+ };
237
+
238
+ const scrollToTop = () => {
239
+ if (!viewportRef) return;
240
+
241
+ viewportRef.scrollTo({
242
+ top: 0,
243
+ behavior: 'smooth',
244
+ });
245
+ };
246
+
247
+ const onSelectItem = (item: Omit<CommandItem, 'onSelect'>, onSelect?: AnyFn) => () => {
248
+ onSelect?.();
249
+
250
+ if (item.value === value) {
251
+ value = allowDeselect ? '' : value;
252
+ } else {
253
+ value = item.value;
254
+ }
255
+
256
+ if (closeOnSelect) {
257
+ open = false;
258
+ }
259
+ };
260
+ </script>
261
+
262
+ {#snippet renderTrigger()}
263
+ {@const triggerProps = {
264
+ class: cn(comboboxTriggerVariants({ variant, size, rounded, fullWidth, class: triggerClass })),
265
+ 'data-placeholder': boolAttr(isPlaceholder),
266
+ }}
267
+ {#if triggerSnippet}
268
+ <PopoverPrimitive.Trigger {...triggerProps}>
269
+ {#snippet child({ props })}
270
+ {@render triggerSnippet?.({ props, displayValue })}
271
+ {/snippet}
272
+ </PopoverPrimitive.Trigger>
273
+ {:else}
274
+ <PopoverPrimitive.Trigger {...triggerProps}>
275
+ {displayValue}
276
+ </PopoverPrimitive.Trigger>
277
+ {/if}
278
+ {/snippet}
279
+
280
+ {#snippet renderItem(item: CommandItem)}
281
+ {@const { value: itemValue, label, icon: Icon, shortcut, onSelect, ...restItem } = item}
282
+ {@const itemAttrs = {
283
+ value: itemValue,
284
+ onSelect: onSelectItem(item, onSelect),
285
+ 'data-selected': boolAttr(itemValue === value),
286
+ ...restItem,
287
+ }}
288
+
289
+ {#if itemSnippet}
290
+ <CommandPrimitive.Item {...itemAttrs}>
291
+ {#snippet child({ props })}
292
+ {@render itemSnippet?.({ item, props })}
293
+ {/snippet}
294
+ </CommandPrimitive.Item>
295
+ {:else}
296
+ <CommandPrimitive.Item {...itemAttrs}>
297
+ {#if Icon}
298
+ <Icon width={16} height={16} />
299
+ {/if}
300
+ {label ?? value}
301
+ {#if shortcut && shortcut.length > 0}
302
+ <Kbd.Group>
303
+ {#each shortcut as shortcut (shortcut)}
304
+ <Kbd.Root>{shortcut}</Kbd.Root>
305
+ {/each}
306
+ </Kbd.Group>
307
+ {/if}
308
+ {#if itemValue === value}
309
+ <span
310
+ class="cgui:ms-auto cgui:text-icon-regular cgui:size-4 cgui:flex cgui:items-center cgui:justify-center cgui:shrink-0"
311
+ transition:fly={{ duration: 250, y: 4, easing: cubicInOut }}
312
+ >
313
+ <IconComponent.Checkmark class="cgui:ms-auto" width={16} height={16} />
314
+ </span>
315
+ {/if}
316
+ </CommandPrimitive.Item>
317
+ {/if}
318
+ {/snippet}
319
+
320
+ {#snippet loadingSpinner()}
321
+ <div class="cgui:p-4 cgui:flex cgui:items-center cgui:h-full cgui:justify-center cgui:text-icon-default">
322
+ <Spinner />
323
+ </div>
324
+ {/snippet}
325
+
326
+ {#snippet loadingMore()}
327
+ <div class="cgui:p-2 cgui:flex cgui:items-center cgui:justify-center cgui:text-icon-default">
328
+ <Spinner />
329
+ </div>
330
+ {/snippet}
331
+
332
+ {#snippet errorState()}
333
+ <div class="cgui:p-4 cgui:text-center cgui:text-body-2">
334
+ <p class="cgui:text-destructive cgui:mb-2">{error}</p>
335
+ <button type="button" class="cgui:text-sm cgui:text-primary cgui:underline cgui:hover:no-underline" onclick={retry}>
336
+ Try again
337
+ </button>
338
+ </div>
339
+ {/snippet}
340
+
341
+ <PopoverPrimitive.Root bind:open {...restProps}>
342
+ {@render renderTrigger()}
343
+
344
+ <PopoverPrimitive.Portal disabled={portalDisabled}>
345
+ <PopoverPrimitive.Content class="cgui:overflow-hidden cgui:rounded-md">
346
+ <CommandPrimitive.Root shouldFilter={false}>
347
+ <CommandPrimitive.Input bind:value={searchValue} placeholder={searchPlaceholder} />
348
+
349
+ <CommandPrimitive.List class="cgui:rounded-inherit">
350
+ <CommandPrimitive.Viewport onscroll={handleScroll} bind:ref={viewportRef}>
351
+ {#if error}
352
+ {@render errorState()}
353
+ {:else if isLoading && !hasResults}
354
+ {#if loadingSnippet}
355
+ {@render loadingSnippet()}
356
+ {:else}
357
+ {@render loadingSpinner()}
358
+ {/if}
359
+ {:else if hasResults}
360
+ {#each groupedItems as [groupKey, groupItems] (groupKey || '__ungrouped__')}
361
+ {#if groupKey}
362
+ {@const group = groupMap.get(groupKey)}
363
+ <CommandPrimitive.Group value={group?.value}>
364
+ <CommandPrimitive.GroupHeading>{group?.label ?? groupKey}</CommandPrimitive.GroupHeading>
365
+
366
+ <CommandPrimitive.GroupItems>
367
+ {#each groupItems as item (item.value)}
368
+ {@render renderItem(item)}
369
+ {/each}
370
+ </CommandPrimitive.GroupItems>
371
+
372
+ {#if group?.separator}
373
+ <CommandPrimitive.Separator />
374
+ {/if}
375
+ </CommandPrimitive.Group>
376
+ {:else}
377
+ {#each groupItems as item (item.value)}
378
+ {@render renderItem(item)}
379
+ {/each}
380
+ {/if}
381
+ {/each}
382
+
383
+ {#if isLoading}
384
+ {@render loadingMore()}
385
+ {/if}
386
+
387
+ {#if !hasMore && items.length > pageSize}
388
+ <button
389
+ class="cgui:w-full cgui:flex cgui:justify-center cgui:cursor-pointer cgui:items-center cgui:text-xs cgui:text-icon-default cgui:underline cgui:hover:no-underline"
390
+ onclick={scrollToTop}
391
+ >
392
+ <IconComponent.ChevronUp />
393
+ </button>
394
+ {/if}
395
+ {:else}
396
+ <CommandPrimitive.Empty>No results found</CommandPrimitive.Empty>
397
+ {/if}
398
+ </CommandPrimitive.Viewport>
399
+ </CommandPrimitive.List>
400
+ </CommandPrimitive.Root>
401
+ </PopoverPrimitive.Content>
402
+ </PopoverPrimitive.Portal>
403
+ </PopoverPrimitive.Root>
@@ -0,0 +1,4 @@
1
+ import type { ComboboxAsyncProps } from './types.js';
2
+ declare const Combobox: import("svelte").Component<ComboboxAsyncProps, {}, "value" | "open" | "searchValue">;
3
+ type Combobox = ReturnType<typeof Combobox>;
4
+ export default Combobox;
@@ -1 +1,2 @@
1
+ export { default as Async } from './combobox.async.svelte';
1
2
  export { default as Root } from './combobox.svelte';
@@ -1 +1,2 @@
1
+ export { default as Async } from './combobox.async.svelte';
1
2
  export { default as Root } from './combobox.svelte';
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { CommandProps } from '../command/index.js';
2
+ import type { CommandGroup, CommandItem, CommandProps } from '../command/index.js';
3
3
  import type { PrimitivePopoverRootProps } from '../popover/types.js';
4
4
  import type { ComboboxTriggerVariantsProps } from './styles.js';
5
5
  export type ComboboxRootProps = PrimitivePopoverRootProps;
@@ -20,3 +20,44 @@ export type ComboboxProps = PrimitivePopoverRootProps & ComboboxTriggerVariantsP
20
20
  }]>;
21
21
  item?: CommandProps['item'];
22
22
  };
23
+ /**
24
+ * Async Combobox Types
25
+ */
26
+ export type ComboboxAsyncCallbackParams = {
27
+ page: number;
28
+ pageSize: number;
29
+ search: string;
30
+ };
31
+ export type ComboboxAsyncCallbackResult = {
32
+ items: CommandItem[];
33
+ hasMore: boolean;
34
+ };
35
+ export type ComboboxAsyncCallback = (params: ComboboxAsyncCallbackParams) => Promise<ComboboxAsyncCallbackResult>;
36
+ export type ComboboxAsyncProps = Omit<ComboboxProps, 'collection'> & {
37
+ /** Loading state renderer */
38
+ loading?: Snippet;
39
+ /** Current page (default: 1) */
40
+ page?: number;
41
+ /** Page size for pagination (default: 20) */
42
+ pageSize?: number;
43
+ /** Async data callback */
44
+ callback: ComboboxAsyncCallback;
45
+ /** Initial items (optional) */
46
+ initialItems?: CommandItem[];
47
+ /** Load immediate data when combobox is mounted (default: true) */
48
+ loadImmediate?: boolean;
49
+ /** Group definitions (for labels/ordering) */
50
+ groups?: CommandGroup[];
51
+ /** Debounce delay for search in milliseconds (default: 300ms) */
52
+ searchDebounce?: number;
53
+ /**
54
+ * Getter function for dependency tracking. When the returned value changes,
55
+ * the component will reset items, page to 1, clear selected value, and reload data.
56
+ * @example dependsOn={() => parentSelectValue}
57
+ */
58
+ dependsOn?: () => unknown;
59
+ /**
60
+ * Whether to clear the selected value when dependsOn changes **(default: true)**
61
+ */
62
+ clearOnDependencyChange?: boolean;
63
+ };
@@ -17,11 +17,9 @@ export type DialogHostProps = {
17
17
  export type DialogComponent = Component<any>;
18
18
  export interface RegisteredDialogsOverride {
19
19
  }
20
- export type RegisteredDialogCollectionOverwritten = RegisteredDialogsOverride extends {
20
+ export type RegisteredDialogCollectionOverwritten = keyof RegisteredDialogsOverride extends never ? {
21
21
  dialogs: Record<string, DialogComponent>;
22
- } ? RegisteredDialogsOverride : {
23
- dialogs: Record<string, DialogComponent>;
24
- };
22
+ } : RegisteredDialogsOverride;
25
23
  export type RegisteredDialogCollection = RegisteredDialogCollectionOverwritten['dialogs'];
26
24
  export type RegisteredDialogKeys = keyof RegisteredDialogCollectionOverwritten['dialogs'];
27
25
  export type DialogRegistrationValue<TKey extends string = string, TComponent extends DialogComponent = DialogComponent> = {
@@ -1,3 +1,3 @@
1
1
  export * as SelectPrimitive from './exports-primitive.js';
2
2
  export * as Select from './exports.js';
3
- export type { SelectAsyncCallback, SelectAsyncCallbackParams, SelectAsyncCallbackResult, SelectAsyncProps, SelectCollection, SelectCollectionOptions, SelectContentProps, SelectGroup, SelectGroupHeadingProps, SelectGroupProps, SelectItem, SelectItemProps, SelectPortalProps, SelectProps, SelectRootProps, SelectTriggerProps, SelectViewportProps, } from './types.js';
3
+ export type * from './types.js';
@@ -39,6 +39,8 @@
39
39
  callback,
40
40
  initialItems = [],
41
41
  loadImmediate = true,
42
+ dependsOn,
43
+ clearOnDependencyChange = true,
42
44
 
43
45
  ...restProps
44
46
  }: SelectAsyncProps = $props();
@@ -112,6 +114,11 @@
112
114
 
113
115
  currentPage = page;
114
116
  hasMore = result.hasMore;
117
+
118
+ // Check if viewport needs more items after render
119
+ requestAnimationFrame(() => {
120
+ checkNeedMoreItems();
121
+ });
115
122
  } catch (err) {
116
123
  error = err instanceof Error ? err.message : 'Failed to fetch data';
117
124
  } finally {
@@ -119,12 +126,34 @@
119
126
  }
120
127
  };
121
128
 
122
- // Fetch on first open
129
+ // Auto-fill viewport if content doesn't overflow
130
+ const checkNeedMoreItems = () => {
131
+ if (!viewportRef || isLoading || !hasMore) return;
132
+
133
+ const { scrollHeight, clientHeight } = viewportRef;
134
+
135
+ // If content doesn't fill viewport, load more
136
+ if (scrollHeight <= clientHeight) {
137
+ loadMore();
138
+ }
139
+ };
140
+
141
+ // Fetch on first open or check viewport fill
123
142
  watch(
124
143
  () => open,
125
144
  () => {
126
- if (!open || items.length > 0 || isLoading || loadImmediate) return;
127
- fetchData(1);
145
+ if (!open) return;
146
+
147
+ // If no items yet and not loading, fetch first page
148
+ if (items.length === 0 && !isLoading && !loadImmediate) {
149
+ fetchData(1);
150
+ return;
151
+ }
152
+
153
+ // If already have items, check if viewport needs more after it renders
154
+ requestAnimationFrame(() => {
155
+ checkNeedMoreItems();
156
+ });
128
157
  }
129
158
  );
130
159
 
@@ -140,6 +169,32 @@
140
169
  };
141
170
  });
142
171
 
172
+ // Watch for dependency changes and reset
173
+ let previousDependency = $state<unknown>(dependsOn?.());
174
+ watch(
175
+ () => dependsOn?.(),
176
+ (newValue) => {
177
+ if (previousDependency === undefined && newValue === undefined) return;
178
+ if (previousDependency === newValue) return;
179
+
180
+ previousDependency = newValue;
181
+
182
+ // Reset state
183
+ items = [];
184
+ currentPage = 1;
185
+ hasMore = true;
186
+ error = null;
187
+
188
+ // Clear selected value if enabled
189
+ if (clearOnDependencyChange) {
190
+ value = '';
191
+ }
192
+
193
+ // Reload data
194
+ fetchData(1);
195
+ }
196
+ );
197
+
143
198
  const loadMore = () => {
144
199
  if (!isLoading && hasMore) {
145
200
  fetchData(currentPage + 1, true);
@@ -102,6 +102,18 @@ export type SelectAsyncProps = Omit<SelectProps, 'collection'> & {
102
102
  callback: SelectAsyncCallback;
103
103
  /** Initial items (optional) */
104
104
  initialItems?: SelectItem[];
105
- /** Load immediate data when select was mounted */
105
+ /**
106
+ * Load immediate data when select was mounted **(default: true)**
107
+ */
106
108
  loadImmediate?: boolean;
109
+ /**
110
+ * Getter function for dependency tracking. When the returned value changes,
111
+ * the component will reset items, page to 1, clear selected value, and reload data.
112
+ * @example dependsOn={() => parentSelectValue}
113
+ */
114
+ dependsOn?: () => unknown;
115
+ /**
116
+ * Whether to clear the selected value when dependsOn changes **(default: true)**
117
+ */
118
+ clearOnDependencyChange?: boolean;
107
119
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casinogate/ui",
3
- "version": "1.9.6",
3
+ "version": "1.9.7",
4
4
  "svelte": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "type": "module",