compote-ui 0.47.1 → 0.47.3

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.
@@ -8,6 +8,7 @@
8
8
  import { getReactiveTableState, type DataTableInstance } from '../data-table-utils';
9
9
  import NumberInput from '../../number-input/number-input.svelte';
10
10
  import * as Field from '../../field';
11
+ import { PhX, PhMagnifyingGlass } from '../../../icons';
11
12
 
12
13
  type Props = {
13
14
  table: DataTableInstance<T>;
@@ -19,13 +20,33 @@
19
20
  let localText: Record<string, string> = $state({});
20
21
  let localNumMin: Record<string, number> = $state({});
21
22
  let localNumMax: Record<string, number> = $state({});
23
+ let localSelectSearch: Record<string, string> = $state({});
22
24
  const timers: Record<string, ReturnType<typeof setTimeout>> = {};
23
25
 
24
26
  const columnFilters = $derived(getReactiveTableState(table).columnFilters);
25
27
  const activeCount = $derived(columnFilters.length);
26
- const filterableColumns = $derived.by(() => {
28
+
29
+ let activeFilterIds: string[] = $derived(columnFilters.map((f) => f.id));
30
+ let showColumnPicker = $state(false);
31
+ let columnSearchText = $state('');
32
+
33
+ const activeColumns = $derived.by(() => {
34
+ getReactiveTableState(table);
35
+ return activeFilterIds
36
+ .map((id) => table.getColumn(id))
37
+ .filter((col): col is Column<T, unknown> => col != null);
38
+ });
39
+
40
+ const availableColumns = $derived.by(() => {
27
41
  getReactiveTableState(table);
28
- return table.getAllLeafColumns().filter((col) => col.getCanFilter());
42
+ return table
43
+ .getAllLeafColumns()
44
+ .filter((col) => col.getCanFilter() && !activeFilterIds.includes(col.id))
45
+ .filter(
46
+ (col) =>
47
+ !columnSearchText ||
48
+ getColumnLabel(col).toLowerCase().includes(columnSearchText.toLowerCase())
49
+ );
29
50
  });
30
51
 
31
52
  onDestroy(() => {
@@ -42,12 +63,33 @@
42
63
  return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
43
64
  }
44
65
 
66
+ function addFilter(column: Column<T, unknown>) {
67
+ activeFilterIds = [...activeFilterIds, column.id];
68
+ showColumnPicker = false;
69
+ columnSearchText = '';
70
+ }
71
+
72
+ function removeFilter(column: Column<T, unknown>) {
73
+ activeFilterIds = activeFilterIds.filter((id) => id !== column.id);
74
+ column.setFilterValue(undefined);
75
+ delete localText[column.id];
76
+ delete localNumMin[column.id];
77
+ delete localNumMax[column.id];
78
+ delete localSelectSearch[column.id];
79
+ clearTimeout(timers[column.id]);
80
+ clearTimeout(timers[`${column.id}_min`]);
81
+ clearTimeout(timers[`${column.id}_max`]);
82
+ }
83
+
45
84
  function clearFilters() {
46
85
  Object.values(timers).forEach(clearTimeout);
47
86
  for (const key of Object.keys(timers)) delete timers[key];
48
87
  localText = {};
49
88
  localNumMin = {};
50
89
  localNumMax = {};
90
+ localSelectSearch = {};
91
+ showColumnPicker = false;
92
+ columnSearchText = '';
51
93
  table.resetColumnFilters();
52
94
  }
53
95
 
@@ -117,8 +159,8 @@
117
159
  {/if}
118
160
  </Popover.Trigger>
119
161
 
120
- <Popover.Content class="w-72 px-0" showArrow={false}>
121
- <div class="mb-2 flex items-center justify-between px-4">
162
+ <Popover.Content class="w-72 p-0" showArrow={false}>
163
+ <div class="flex items-center justify-between px-3 py-2.5">
122
164
  <span class="text-sm font-medium text-ink">Filters</span>
123
165
  {#if activeCount > 0}
124
166
  <button type="button" onclick={clearFilters} class="text-xs text-primary hover:underline">
@@ -126,123 +168,208 @@
126
168
  </button>
127
169
  {/if}
128
170
  </div>
129
- <div class="mb-2 border-b border-surface-3"></div>
130
-
131
- <ScrollArea.Root class="h-80">
132
- <ScrollArea.Viewport>
133
- <ScrollArea.Content>
134
- {#each filterableColumns as column (column.id)}
135
- <div class="mb-3">
136
- <p class="mb-1 text-xs font-medium text-ink">{getColumnLabel(column)}</p>
137
-
138
- {#if getColumnType(column) === 'number' || getColumnType(column) === 'currency' || getColumnType(column) === 'percent'}
139
- {@const [facetMin, facetMax] = getFacetedMinMax(column)}
140
- {@const colFormatOptions = getColumnFormatOptions(column)}
141
- <div class="flex gap-1.5">
142
- <div class="min-w-0 flex-1">
143
- <p class="mb-1 text-xs text-ink-dim">From</p>
144
- <NumberInput
145
- value={localNumMin[column.id] ?? facetMin ?? null}
146
- min={facetMin}
147
- max={facetMax}
148
- formatOptions={colFormatOptions}
149
- onValueChange={({ valueAsNumber }) =>
150
- handleNumericInput(
151
- column,
152
- 'min',
153
- isNaN(valueAsNumber) ? null : valueAsNumber
154
- )}
155
- />
171
+
172
+ {#if activeColumns.length > 0}
173
+ <div class="overflow-hidden border-t border-surface-3">
174
+ <ScrollArea.Root class="h-96">
175
+ <ScrollArea.Viewport>
176
+ <ScrollArea.Content>
177
+ {#each activeColumns as column (column.id)}
178
+ <div class="border border-surface-3 p-3">
179
+ <div class="mb-2 flex items-center justify-between">
180
+ <span class="text-sm font-medium text-ink">{getColumnLabel(column)}</span>
181
+ <button
182
+ type="button"
183
+ onclick={() => removeFilter(column)}
184
+ class="text-ink-dim transition-colors hover:text-ink"
185
+ >
186
+ <PhX class="size-3.5" />
187
+ </button>
156
188
  </div>
157
- <div class="min-w-0 flex-1">
158
- <p class="mb-1 text-xs text-ink-dim">To</p>
159
- <NumberInput
160
- value={localNumMax[column.id] ?? facetMax ?? null}
161
- min={facetMin}
162
- max={facetMax}
163
- formatOptions={colFormatOptions}
164
- onValueChange={({ valueAsNumber }) =>
165
- handleNumericInput(
166
- column,
167
- 'max',
168
- isNaN(valueAsNumber) ? null : valueAsNumber
189
+
190
+ {#if getColumnType(column) === 'number' || getColumnType(column) === 'currency' || getColumnType(column) === 'percent'}
191
+ {@const [facetMin, facetMax] = getFacetedMinMax(column)}
192
+ {@const colFormatOptions = getColumnFormatOptions(column)}
193
+ <div class="flex flex-col gap-1.5">
194
+ <div class="min-w-0 flex-1">
195
+ <NumberInput
196
+ layout="horizontal"
197
+ label="From"
198
+ value={localNumMin[column.id] ?? facetMin ?? null}
199
+ min={facetMin}
200
+ max={facetMax}
201
+ formatOptions={colFormatOptions}
202
+ onValueChange={({ valueAsNumber }) =>
203
+ handleNumericInput(
204
+ column,
205
+ 'min',
206
+ isNaN(valueAsNumber) ? null : valueAsNumber
207
+ )}
208
+ />
209
+ </div>
210
+ <div class="min-w-0 flex-1">
211
+ <NumberInput
212
+ layout="horizontal"
213
+ label="To"
214
+ value={localNumMax[column.id] ?? facetMax ?? null}
215
+ min={facetMin}
216
+ max={facetMax}
217
+ formatOptions={colFormatOptions}
218
+ onValueChange={({ valueAsNumber }) =>
219
+ handleNumericInput(
220
+ column,
221
+ 'max',
222
+ isNaN(valueAsNumber) ? null : valueAsNumber
223
+ )}
224
+ />
225
+ </div>
226
+ </div>
227
+ {:else if getColumnType(column) === 'boolean'}
228
+ {@const boolFilter = column.getFilterValue() as boolean | undefined}
229
+ <div class="flex overflow-hidden rounded border border-border text-xs">
230
+ <button
231
+ type="button"
232
+ onclick={() => column.setFilterValue(undefined)}
233
+ class={cn(
234
+ 'flex-1 px-2 py-1',
235
+ boolFilter === undefined
236
+ ? 'bg-surface-3 font-medium text-ink'
237
+ : 'text-ink-dim hover:bg-surface-2'
169
238
  )}
170
- />
171
- </div>
172
- </div>
173
- {:else if getColumnType(column) === 'boolean'}
174
- {@const boolFilter = column.getFilterValue() as boolean | undefined}
175
- <div class="flex overflow-hidden rounded border border-border text-xs">
176
- <button
177
- type="button"
178
- onclick={() => column.setFilterValue(undefined)}
179
- class={cn(
180
- 'flex-1 px-2 py-1',
181
- boolFilter === undefined
182
- ? 'bg-surface-3 font-medium text-ink'
183
- : 'text-ink-dim hover:bg-surface-2'
184
- )}
185
- >
186
- All
187
- </button>
188
- <button
189
- type="button"
190
- onclick={() => column.setFilterValue(boolFilter === true ? undefined : true)}
191
- class={cn(
192
- 'flex-1 border-x border-border px-2 py-1',
193
- boolFilter === true
194
- ? 'bg-surface-3 font-medium text-ink'
195
- : 'text-ink-dim hover:bg-surface-2'
196
- )}
197
- >
198
- Yes
199
- </button>
200
- <button
201
- type="button"
202
- onclick={() => column.setFilterValue(boolFilter === false ? undefined : false)}
203
- class={cn(
204
- 'flex-1 px-2 py-1',
205
- boolFilter === false
206
- ? 'bg-surface-3 font-medium text-ink'
207
- : 'text-ink-dim hover:bg-surface-2'
208
- )}
209
- >
210
- No
211
- </button>
212
- </div>
213
- {:else if getColumnType(column) === 'select'}
214
- {@const options = getFacetedValues(column)}
215
- {@const selected = getSelectValues(column)}
216
- <div class="flex flex-col gap-0.5">
217
- {#each options as option (option)}
218
- <Checkbox
219
- size="sm"
220
- label={option}
221
- class="min-h-7 rounded-sm px-2 hover:bg-surface-2"
222
- checked={selected.includes(option)}
223
- onCheckedChange={({ checked }) =>
224
- handleSelectChange(column, option, checked === true)}
225
- />
226
- {/each}
239
+ >
240
+ All
241
+ </button>
242
+ <button
243
+ type="button"
244
+ onclick={() =>
245
+ column.setFilterValue(boolFilter === true ? undefined : true)}
246
+ class={cn(
247
+ 'flex-1 border-x border-border px-2 py-1',
248
+ boolFilter === true
249
+ ? 'bg-surface-3 font-medium text-ink'
250
+ : 'text-ink-dim hover:bg-surface-2'
251
+ )}
252
+ >
253
+ Yes
254
+ </button>
255
+ <button
256
+ type="button"
257
+ onclick={() =>
258
+ column.setFilterValue(boolFilter === false ? undefined : false)}
259
+ class={cn(
260
+ 'flex-1 px-2 py-1',
261
+ boolFilter === false
262
+ ? 'bg-surface-3 font-medium text-ink'
263
+ : 'text-ink-dim hover:bg-surface-2'
264
+ )}
265
+ >
266
+ No
267
+ </button>
268
+ </div>
269
+ {:else if getColumnType(column) === 'select'}
270
+ {@const allOptions = getFacetedValues(column)}
271
+ {@const search = localSelectSearch[column.id] ?? ''}
272
+ {@const options = search
273
+ ? allOptions.filter((o) => o.toLowerCase().includes(search.toLowerCase()))
274
+ : allOptions}
275
+ {@const selected = getSelectValues(column)}
276
+ <div class="flex flex-col gap-1">
277
+ <Field.Root>
278
+ <Field.Input
279
+ placeholder="Search..."
280
+ value={search}
281
+ oninput={(e: Event) => {
282
+ localSelectSearch[column.id] = (
283
+ e.currentTarget as HTMLInputElement
284
+ ).value;
285
+ }}
286
+ />
287
+ </Field.Root>
288
+ <div class="max-h-44 overflow-hidden">
289
+ <ScrollArea.Root class="h-full">
290
+ <ScrollArea.Viewport>
291
+ <ScrollArea.Content>
292
+ <div class="flex flex-col gap-0.5">
293
+ {#each options as option (option)}
294
+ <Checkbox
295
+ size="sm"
296
+ label={option}
297
+ class="min-h-7 rounded-sm px-2 hover:bg-surface-2"
298
+ checked={selected.includes(option)}
299
+ onCheckedChange={({ checked }) =>
300
+ handleSelectChange(column, option, checked === true)}
301
+ />
302
+ {/each}
303
+ </div>
304
+ </ScrollArea.Content>
305
+ </ScrollArea.Viewport>
306
+ <ScrollArea.Scrollbar orientation="vertical">
307
+ <ScrollArea.Thumb />
308
+ </ScrollArea.Scrollbar>
309
+ <ScrollArea.Corner />
310
+ </ScrollArea.Root>
311
+ </div>
312
+ </div>
313
+ {:else}
314
+ <Field.Root>
315
+ <Field.Input
316
+ placeholder="Search..."
317
+ value={localText[column.id] ?? ''}
318
+ oninput={(e: Event) =>
319
+ handleTextInput(column, (e.currentTarget as HTMLInputElement).value)}
320
+ />
321
+ </Field.Root>
322
+ {/if}
227
323
  </div>
228
- {:else}
229
- <Field.Root>
230
- <Field.Input
231
- placeholder="Search..."
232
- value={localText[column.id] ?? ''}
233
- oninput={(e: Event) =>
234
- handleTextInput(column, (e.currentTarget as HTMLInputElement).value)}
235
- />
236
- </Field.Root>
237
- {/if}
238
- </div>
324
+ {/each}
325
+ </ScrollArea.Content>
326
+ </ScrollArea.Viewport>
327
+ <ScrollArea.Scrollbar orientation="vertical">
328
+ <ScrollArea.Thumb />
329
+ </ScrollArea.Scrollbar>
330
+ <ScrollArea.Corner />
331
+ </ScrollArea.Root>
332
+ </div>
333
+ {/if}
334
+
335
+ {#if showColumnPicker}
336
+ <div class="border-t border-surface-3 p-3">
337
+ <div class="relative mb-1">
338
+ <PhMagnifyingGlass
339
+ class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-dim"
340
+ />
341
+ <input
342
+ type="text"
343
+ placeholder="Search columns..."
344
+ bind:value={columnSearchText}
345
+ class="h-8 w-full rounded border border-border bg-surface-1 pr-3 pl-7 text-sm text-ink outline-none placeholder:text-ink-dim focus:ring-1 focus:ring-ring"
346
+ />
347
+ </div>
348
+ <div class="max-h-48 overflow-y-auto">
349
+ {#each availableColumns as column (column.id)}
350
+ <button
351
+ type="button"
352
+ onclick={() => addFilter(column)}
353
+ class="w-full rounded px-2 py-1.5 text-left text-sm text-ink hover:bg-surface-2"
354
+ >
355
+ {getColumnLabel(column)}
356
+ </button>
357
+ {:else}
358
+ <p class="px-2 py-3 text-center text-sm text-ink-dim">No more columns</p>
239
359
  {/each}
240
- </ScrollArea.Content>
241
- </ScrollArea.Viewport>
242
- <ScrollArea.Scrollbar orientation="vertical">
243
- <ScrollArea.Thumb />
244
- </ScrollArea.Scrollbar>
245
- <ScrollArea.Corner />
246
- </ScrollArea.Root>
360
+ </div>
361
+ </div>
362
+ {:else}
363
+ <div class="border-t border-surface-3">
364
+ <button
365
+ type="button"
366
+ onclick={() => (showColumnPicker = true)}
367
+ class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-ink-dim hover:bg-surface-2 hover:text-ink"
368
+ >
369
+ <span class="text-base leading-none">+</span>
370
+ Add Filter
371
+ </button>
372
+ </div>
373
+ {/if}
247
374
  </Popover.Content>
248
375
  </Popover.Root>
@@ -2,13 +2,20 @@
2
2
  import { Drawer } from '@ark-ui/svelte/drawer';
3
3
  import type { DrawerRootProps } from '@ark-ui/svelte/drawer';
4
4
 
5
- interface Props extends Omit<DrawerRootProps, 'open' | 'onOpenChange'> {
5
+ interface Props extends Omit<DrawerRootProps, 'open'> {
6
6
  open?: boolean;
7
7
  }
8
8
 
9
- let { children, open = $bindable(), ...rest }: Props = $props();
9
+ let { children, open = $bindable(), onOpenChange, ...rest }: Props = $props();
10
10
  </script>
11
11
 
12
- <Drawer.Root bind:open {...rest}>
12
+ <Drawer.Root
13
+ {open}
14
+ onOpenChange={(details) => {
15
+ open = details.open;
16
+ onOpenChange?.(details);
17
+ }}
18
+ {...rest}
19
+ >
13
20
  {@render children?.()}
14
21
  </Drawer.Root>
@@ -1,5 +1,5 @@
1
1
  import type { DrawerRootProps } from '@ark-ui/svelte/drawer';
2
- interface Props extends Omit<DrawerRootProps, 'open' | 'onOpenChange'> {
2
+ interface Props extends Omit<DrawerRootProps, 'open'> {
3
3
  open?: boolean;
4
4
  }
5
5
  declare const DrawerRoot: import("svelte").Component<Props, {}, "open">;
@@ -17,7 +17,7 @@
17
17
  const locale = useLocaleContext();
18
18
  const rootClass = $derived(
19
19
  layout === 'horizontal'
20
- ? 'flex items-center gap-1.5 w-full max-w-48 data-disabled:opacity-50 data-disabled:grayscale'
20
+ ? 'flex items-center justify-between gap-1.5 w-full data-disabled:opacity-50 data-disabled:grayscale'
21
21
  : 'flex flex-col gap-1.5 w-full max-w-48 data-disabled:opacity-50 data-disabled:grayscale'
22
22
  );
23
23
  </script>
@@ -28,7 +28,12 @@
28
28
  {...restProps}
29
29
  allowMouseWheel
30
30
  locale={locale().locale}
31
- value={value != null ? new Intl.NumberFormat(locale().locale, { useGrouping: false, maximumFractionDigits: 20 }).format(value) : undefined}
31
+ value={value != null
32
+ ? new Intl.NumberFormat(locale().locale, {
33
+ useGrouping: false,
34
+ maximumFractionDigits: 20
35
+ }).format(value)
36
+ : undefined}
32
37
  readOnly={readonly}
33
38
  onValueChange={(valueChangeDetails) => {
34
39
  onValueChange?.(valueChangeDetails);
@@ -48,7 +53,7 @@
48
53
  {/if}
49
54
  <NumberInput.Control class="relative isolate">
50
55
  <NumberInput.Input
51
- class="h-9 w-full rounded-md border bg-surface-1 px-3 pr-8 text-right text-sm font-medium tabular-nums shadow-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none data-invalid:border-danger data-invalid:focus-visible:ring-danger"
56
+ class="h-9 w-full max-w-48 rounded-md border bg-surface-1 px-3 pr-8 text-right text-sm font-medium tabular-nums shadow-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none data-invalid:border-danger data-invalid:focus-visible:ring-danger"
52
57
  />
53
58
  <div
54
59
  class="absolute top-px right-px bottom-px z-10 flex w-6 flex-col overflow-hidden rounded-r border-l"
@@ -10,6 +10,6 @@
10
10
  let { class: className, children, ...rest }: Props = $props();
11
11
  </script>
12
12
 
13
- <ScrollArea.Content {...rest} class={cn('py-3 ps-4 pe-6', className)}>
13
+ <ScrollArea.Content {...rest} class={cn('p-3', className)}>
14
14
  {@render children?.()}
15
15
  </ScrollArea.Content>
@@ -13,7 +13,7 @@
13
13
  <ScrollArea.Scrollbar
14
14
  {...rest}
15
15
  class={cn(
16
- 'group pointer-events-none relative m-2 flex rounded-md bg-well opacity-0 transition-opacity duration-150',
16
+ 'group pointer-events-none relative m-1 flex rounded-md bg-well opacity-0 transition-opacity duration-150',
17
17
  'data-hover:pointer-events-auto data-hover:opacity-100',
18
18
  'data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0',
19
19
  'data-[orientation=vertical]:w-1 data-[orientation=vertical]:[&:not([data-overflow-y])]:hidden',
@@ -18,6 +18,7 @@
18
18
  bind:value
19
19
  {defaultValue}
20
20
  {orientation}
21
+ lazyMount
21
22
  class="flex data-[orientation='horizontal']:flex-col data-[orientation='vertical']:flex-row"
22
23
  >
23
24
  {@render children?.()}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.47.1",
3
+ "version": "0.47.3",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev --open",