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.
- package/dist/components/data-table-v9/create-table.svelte.js +15 -2
- package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte +30 -16
- package/dist/components/image-cropper/image-cropper.svelte +3 -3
- package/dist/utils/select-filter.d.ts +34 -0
- package/dist/utils/select-filter.js +78 -0
- package/package.json +14 -14
- package/skills/data-table/SKILL.md +1 -1
- package/skills/data-table/references/column-options.md +23 -0
|
@@ -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
|
|
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>):
|
|
143
|
-
return (column.getFilterValue() as
|
|
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:
|
|
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
|
|
157
|
-
return
|
|
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 =
|
|
321
|
+
{@const allOptions = getSelectOptions(column)}
|
|
317
322
|
{@const search = localSelectSearch[column.id] ?? ''}
|
|
318
323
|
{@const options = search
|
|
319
|
-
? allOptions.filter((o) =>
|
|
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
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
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.
|
|
75
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
76
76
|
"@tailwindcss/vite": "^4.3.3",
|
|
77
|
-
"@tanstack/intent": "^0.4
|
|
78
|
-
"@tanstack/svelte-table": "9.2.
|
|
79
|
-
"@types/node": "^24",
|
|
80
|
-
"eslint": "^10.
|
|
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.
|
|
84
|
-
"prettier": "^3.9.
|
|
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.
|
|
88
|
-
"svelte": "^5.57.
|
|
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.
|
|
93
|
+
"typescript-eslint": "^8.71.1",
|
|
94
94
|
"unplugin-icons": "^24.0.0",
|
|
95
|
-
"vite": "^8.3.
|
|
96
|
-
"vitest": "^5.0.
|
|
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.
|
|
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",
|
|
@@ -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
|