compote-ui 0.78.1 → 0.79.0

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.
@@ -4,8 +4,9 @@ import { onDestroy } from 'svelte';
4
4
  import { renderComponent, renderSnippet } from '@tanstack/svelte-table';
5
5
  import { dataTableFeatures } from './features';
6
6
  import { TYPE_NUMBER_FORMAT_DEFAULTS } from './data-table-utils';
7
+ import { formatSelectValue, matchesSelectFilter, selectCellValues } from '../../utils/select-filter';
7
8
  const oneOfFilterFn = (row, columnId, filterValue) => {
8
- return filterValue.includes(String(row.getValue(columnId)));
9
+ return matchesSelectFilter(row.getValue(columnId), filterValue);
9
10
  };
10
11
  oneOfFilterFn.autoRemove = (val) => !Array.isArray(val) || val.length === 0;
11
12
  export function createTable(options) {
@@ -137,7 +138,12 @@ function createColumns(columns, localeCtx) {
137
138
  grow: column.grow,
138
139
  sum: column.sum,
139
140
  footer: column.footer
140
- }
141
+ },
142
+ // Select cells may hold an array (multi-value / tag columns): facet each item
143
+ // separately so the Funnel lists options, not value combinations.
144
+ ...(column.type === 'select'
145
+ ? { getUniqueValues: (row) => selectCellValues(readColumnValue(column, row)) }
146
+ : {})
141
147
  };
142
148
  if (typeof column.accessorFn === 'function') {
143
149
  return {
@@ -153,6 +159,11 @@ function createColumns(columns, localeCtx) {
153
159
  };
154
160
  });
155
161
  }
162
+ function readColumnValue(column, row) {
163
+ return typeof column.accessorFn === 'function'
164
+ ? column.accessorFn(row)
165
+ : row[column.accessorKey];
166
+ }
156
167
  function isGroupColumn(column) {
157
168
  return Array.isArray(column.columns);
158
169
  }
@@ -220,6 +231,8 @@ function applyTypeFormat(column, value, localeCtx) {
220
231
  }
221
232
  if (column.type === 'boolean')
222
233
  return value ? 'Yes' : 'No';
234
+ if (column.type === 'select')
235
+ return formatSelectValue(value);
223
236
  return value;
224
237
  }
225
238
  function formatCellValue(column, value, row, localeCtx) {
@@ -13,6 +13,11 @@
13
13
  import Badge from '../../badge/badge.svelte';
14
14
  import { button } from '../../button/button.variants';
15
15
  import { PhX, PhMagnifyingGlass, PhFunnel } from '../../../icons';
16
+ import {
17
+ selectFilterOptions,
18
+ type SelectFilterOption,
19
+ type SelectFilterValue
20
+ } from '../../../utils/select-filter';
16
21
 
17
22
  type Props = {
18
23
  table: DataTableInstance<T>;
@@ -139,13 +144,13 @@
139
144
  }, 300);
140
145
  }
141
146
 
142
- function getSelectValues(column: Column<DataTableFeatures, T, unknown>): string[] {
143
- return (column.getFilterValue() as string[] | undefined) ?? [];
147
+ function getSelectValues(column: Column<DataTableFeatures, T, unknown>): SelectFilterValue[] {
148
+ return (column.getFilterValue() as SelectFilterValue[] | undefined) ?? [];
144
149
  }
145
150
 
146
151
  function handleSelectChange(
147
152
  column: Column<DataTableFeatures, T, unknown>,
148
- value: string,
153
+ value: SelectFilterValue,
149
154
  checked: boolean
150
155
  ) {
151
156
  const current = getSelectValues(column);
@@ -153,8 +158,8 @@
153
158
  column.setFilterValue(next.length ? next : undefined);
154
159
  }
155
160
 
156
- function getFacetedValues(column: Column<DataTableFeatures, T, unknown>): string[] {
157
- return Array.from(column.getFacetedUniqueValues().keys()).map(String).sort();
161
+ function getSelectOptions(column: Column<DataTableFeatures, T, unknown>): SelectFilterOption[] {
162
+ return selectFilterOptions(column.getFacetedUniqueValues());
158
163
  }
159
164
 
160
165
  function getFacetedMinMax(
@@ -313,10 +318,12 @@
313
318
  </button>
314
319
  </div>
315
320
  {:else if getColumnType(column) === 'select'}
316
- {@const allOptions = getFacetedValues(column)}
321
+ {@const allOptions = getSelectOptions(column)}
317
322
  {@const search = localSelectSearch[column.id] ?? ''}
318
323
  {@const options = search
319
- ? allOptions.filter((o) => o.toLowerCase().includes(search.toLowerCase()))
324
+ ? allOptions.filter((o) =>
325
+ o.label.toLowerCase().includes(search.toLowerCase())
326
+ )
320
327
  : allOptions}
321
328
  {@const selected = getSelectValues(column)}
322
329
  <div class="flex flex-col gap-1">
@@ -335,15 +342,22 @@
335
342
  <ScrollArea.Viewport class="max-h-40">
336
343
  <ScrollArea.Content>
337
344
  <div class="flex flex-col gap-0.5">
338
- {#each options as option (option)}
339
- <Checkbox
340
- size="sm"
341
- label={option}
342
- class="min-h-7 rounded-sm px-2 hover:bg-surface-2"
343
- checked={selected.includes(option)}
344
- onCheckedChange={({ checked }) =>
345
- handleSelectChange(column, option, checked === true)}
346
- />
345
+ {#each options as option (option.value)}
346
+ <div
347
+ class="flex min-h-7 items-center gap-2 rounded-sm pr-2 hover:bg-surface-2"
348
+ >
349
+ <Checkbox
350
+ size="sm"
351
+ label={option.label}
352
+ class="min-h-7 flex-1 px-2"
353
+ checked={selected.includes(option.value)}
354
+ onCheckedChange={({ checked }) =>
355
+ handleSelectChange(column, option.value, checked === true)}
356
+ />
357
+ <span class="text-xs text-ink-dim tabular-nums"
358
+ >{option.count}</span
359
+ >
360
+ </div>
347
361
  {/each}
348
362
  </div>
349
363
  </ScrollArea.Content>
@@ -55,11 +55,11 @@
55
55
  }
56
56
  }));
57
57
 
58
- // eslint-disable-next-line no-useless-assignment
58
+ // eslint-disable-next-line no-useless-assignment, @typescript-eslint/no-unused-vars
59
59
  getCroppedImage = (options) => imageCropper().getCroppedImage(options);
60
- // eslint-disable-next-line no-useless-assignment
60
+ // eslint-disable-next-line no-useless-assignment, @typescript-eslint/no-unused-vars
61
61
  getCropData = () => imageCropper().getCropData();
62
- // eslint-disable-next-line no-useless-assignment
62
+ // eslint-disable-next-line no-useless-assignment, @typescript-eslint/no-unused-vars
63
63
  getProcessedImage = (opts?: ProcessImageOptions) =>
64
64
  cropImage(src, imageCropper().getCropData(), opts);
65
65
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Value handling for `type: 'select'` data-table columns. A select cell holds either one
3
+ * value or an array of values (multi-value / tag columns); both facet, filter and render
4
+ * through the same helpers so the Funnel works the same for either shape.
5
+ */
6
+ /**
7
+ * Filter value standing for "no value". It is `null`, not a string, so it can never
8
+ * collide with a real option — a cell whose value is the string `'null'` stays a regular
9
+ * option. Every other selected option is the `String()` of the cell value.
10
+ */
11
+ export declare const EMPTY_SELECT_VALUE: null;
12
+ export type SelectFilterValue = string | typeof EMPTY_SELECT_VALUE;
13
+ /**
14
+ * The values a select cell contributes to facets and filters: each distinct item of an
15
+ * array, or the scalar itself. Items are distinct by their filter value (`String()`), so a
16
+ * row counts once per option even when its array repeats an item. A missing cell (null,
17
+ * undefined, '' or an empty array) is one `EMPTY_SELECT_VALUE`, so it facets and filters
18
+ * as a single "empty" option.
19
+ */
20
+ export declare function selectCellValues(value: unknown): unknown[];
21
+ /** One-of filter: the cell matches when any of its values is among the selected options. */
22
+ export declare function matchesSelectFilter(value: unknown, filterValue: SelectFilterValue[]): boolean;
23
+ export type SelectFilterOption = {
24
+ value: SelectFilterValue;
25
+ label: string;
26
+ count: number;
27
+ };
28
+ /**
29
+ * Filter options from a column's faceted unique values: sorted by label, with the empty
30
+ * option (labelled `emptyLabel`) last.
31
+ */
32
+ export declare function selectFilterOptions(facets: Map<unknown, number>, emptyLabel?: string): SelectFilterOption[];
33
+ /** Cell text for a select value: array items joined with `, `; undefined when empty. */
34
+ export declare function formatSelectValue(value: unknown): string | number | boolean | undefined;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Value handling for `type: 'select'` data-table columns. A select cell holds either one
3
+ * value or an array of values (multi-value / tag columns); both facet, filter and render
4
+ * through the same helpers so the Funnel works the same for either shape.
5
+ */
6
+ /**
7
+ * Filter value standing for "no value". It is `null`, not a string, so it can never
8
+ * collide with a real option — a cell whose value is the string `'null'` stays a regular
9
+ * option. Every other selected option is the `String()` of the cell value.
10
+ */
11
+ export const EMPTY_SELECT_VALUE = null;
12
+ function isEmpty(value) {
13
+ return value == null || value === '';
14
+ }
15
+ /**
16
+ * The values a select cell contributes to facets and filters: each distinct item of an
17
+ * array, or the scalar itself. Items are distinct by their filter value (`String()`), so a
18
+ * row counts once per option even when its array repeats an item. A missing cell (null,
19
+ * undefined, '' or an empty array) is one `EMPTY_SELECT_VALUE`, so it facets and filters
20
+ * as a single "empty" option.
21
+ */
22
+ export function selectCellValues(value) {
23
+ if (!Array.isArray(value))
24
+ return [isEmpty(value) ? EMPTY_SELECT_VALUE : value];
25
+ const seen = new Set();
26
+ const items = [];
27
+ for (const item of value) {
28
+ if (isEmpty(item))
29
+ continue;
30
+ const key = String(item);
31
+ if (seen.has(key))
32
+ continue;
33
+ seen.add(key);
34
+ items.push(item);
35
+ }
36
+ return items.length ? items : [EMPTY_SELECT_VALUE];
37
+ }
38
+ function toFilterValue(item) {
39
+ return item === EMPTY_SELECT_VALUE ? EMPTY_SELECT_VALUE : String(item);
40
+ }
41
+ /** One-of filter: the cell matches when any of its values is among the selected options. */
42
+ export function matchesSelectFilter(value, filterValue) {
43
+ return selectCellValues(value).some((item) => filterValue.includes(toFilterValue(item)));
44
+ }
45
+ /**
46
+ * Filter options from a column's faceted unique values: sorted by label, with the empty
47
+ * option (labelled `emptyLabel`) last.
48
+ */
49
+ export function selectFilterOptions(facets, emptyLabel = '(empty)') {
50
+ const counts = new Map();
51
+ let emptyCount = 0;
52
+ for (const [key, count] of facets) {
53
+ if (isEmpty(key)) {
54
+ emptyCount += count;
55
+ continue;
56
+ }
57
+ const value = String(key);
58
+ counts.set(value, (counts.get(value) ?? 0) + count);
59
+ }
60
+ const options = [...counts].map(([value, count]) => ({
61
+ value,
62
+ label: value,
63
+ count
64
+ }));
65
+ options.sort((a, b) => a.label.localeCompare(b.label));
66
+ if (emptyCount > 0) {
67
+ options.push({ value: EMPTY_SELECT_VALUE, label: emptyLabel, count: emptyCount });
68
+ }
69
+ return options;
70
+ }
71
+ /** Cell text for a select value: array items joined with `, `; undefined when empty. */
72
+ export function formatSelectValue(value) {
73
+ if (Array.isArray(value)) {
74
+ const items = value.filter((item) => !isEmpty(item));
75
+ return items.length ? items.join(', ') : undefined;
76
+ }
77
+ return isEmpty(value) ? undefined : value;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.78.1",
3
+ "version": "0.79.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -72,28 +72,28 @@
72
72
  "@sveltejs/adapter-cloudflare": "^8.0.0",
73
73
  "@sveltejs/kit": "^3.0.1",
74
74
  "@sveltejs/package": "^3.0.0",
75
- "@sveltejs/vite-plugin-svelte": "^7.3.0",
75
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
76
76
  "@tailwindcss/vite": "^4.3.3",
77
- "@tanstack/intent": "^0.4.0",
78
- "@tanstack/svelte-table": "9.2.4",
79
- "@types/node": "^24",
80
- "eslint": "^10.10.0",
77
+ "@tanstack/intent": "^0.5.4",
78
+ "@tanstack/svelte-table": "9.2.6",
79
+ "@types/node": "^24.19.1",
80
+ "eslint": "^10.12.0",
81
81
  "eslint-config-prettier": "^10.1.8",
82
82
  "eslint-plugin-svelte": "^3.23.0",
83
- "globals": "^17.12.0",
84
- "prettier": "^3.9.7",
83
+ "globals": "^17.13.0",
84
+ "prettier": "^3.9.9",
85
85
  "prettier-plugin-svelte": "^4.1.1",
86
86
  "prettier-plugin-tailwindcss": "^0.8.1",
87
- "publint": "^0.3.24",
88
- "svelte": "^5.57.1",
87
+ "publint": "^0.3.25",
88
+ "svelte": "^5.57.2",
89
89
  "svelte-check": "^4.7.6",
90
90
  "tailwindcss": "^4.3.3",
91
91
  "tw-animate-css": "^1.4.0",
92
92
  "typescript": "^6.0.3",
93
- "typescript-eslint": "^8.70.0",
93
+ "typescript-eslint": "^8.71.1",
94
94
  "unplugin-icons": "^24.0.0",
95
- "vite": "^8.3.0",
96
- "vitest": "^5.0.1",
95
+ "vite": "^8.3.3",
96
+ "vitest": "^5.0.3",
97
97
  "wrangler": "^4.118.0"
98
98
  },
99
99
  "keywords": [
@@ -101,7 +101,7 @@
101
101
  "tanstack-intent"
102
102
  ],
103
103
  "dependencies": {
104
- "@ark-ui/svelte": "^5.24.2",
104
+ "@ark-ui/svelte": "^5.24.3",
105
105
  "@fontsource-variable/nunito-sans": "^5.2.7",
106
106
  "@internationalized/date": "^3.12.4",
107
107
  "@tanstack/svelte-virtual": "^3.13.39",
@@ -8,7 +8,7 @@ description: >
8
8
  metadata:
9
9
  type: composition
10
10
  library: compote-ui
11
- library_version: '0.75.5'
11
+ library_version: '0.79.0'
12
12
  requires:
13
13
  - component-usage
14
14
  - theming
@@ -58,6 +58,29 @@ Type defaults:
58
58
 
59
59
  Use one `grow: true` column to absorb extra horizontal space.
60
60
 
61
+ ## Select columns with multiple values
62
+
63
+ A `type: 'select'` column may return an array (tags, multi-select attributes). The Funnel then
64
+ lists each item as its own option with a row count, a row matches when any of its items is
65
+ selected, and the cell renders the items joined with `, `. A row counts once per option even
66
+ when its array repeats an item. Missing values — `null`, `undefined`, `''` or an empty array —
67
+ share one `(empty)` option.
68
+
69
+ The filter value is an array of selected options: each is the `String()` of the cell value, and
70
+ the empty option is `null` — so a real `'null'` string stays its own option:
71
+
72
+ ```ts
73
+ table.getColumn('tags')?.setFilterValue(['Battery', null]); // Battery or no tags
74
+ ```
75
+
76
+ ```ts
77
+ col.accessorFn((row) => row.tags.map((tag) => tag.name), {
78
+ id: 'tags',
79
+ header: 'Tags',
80
+ type: 'select'
81
+ });
82
+ ```
83
+
61
84
  ## Locale for number/currency/percent
62
85
 
63
86
  Priority: `formatLocale` prop → `useLocaleContext().locale` → browser default. Wrap the app in