@shotleybuilder/svelte-table-kit 0.5.1 → 0.10.1

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
@@ -31,6 +31,10 @@ Svelte Table Kit brings Airtable-like functionality to your Svelte applications
31
31
  - Collapsible FilterBar UI (space-efficient)
32
32
  - Active filter count badge
33
33
  - Real-time filtering as you type
34
+ - **Column order modes** - Cycle between Default, Table Order, and A-Z sorting in field picker (v0.7.0+)
35
+ - **Fuzzy search** - Type to quickly find columns in large tables with highlighted matches (v0.8.0+)
36
+ - **Value suggestions** - Autocomplete dropdown shows existing column values as you type (v0.10.0+)
37
+ - **Numeric range hints** - Numeric columns display min/max range in the value input (v0.10.0+)
34
38
 
35
39
  **Sorting Options:**
36
40
  - **Column header mode** (default) - Click headers to sort with ↑↓↕ indicators
@@ -55,11 +59,19 @@ Svelte Table Kit brings Airtable-like functionality to your Svelte applications
55
59
  - Actions conditionally shown based on feature flags
56
60
  - Seamlessly integrates with existing controls
57
61
 
62
+ **Cell Context Menu (v0.9.0+):**
63
+ - Right-click any cell to open context menu
64
+ - **Filter by value** - Instantly filter to show rows matching cell value
65
+ - **Exclude value** - Filter to hide rows with this value
66
+ - **Greater/Less than** - Numeric columns get comparison options
67
+ - Auto-expands FilterBar when filter is created
68
+
58
69
  **Developer Experience:**
59
70
  - 🎨 Headless design - style it your way
60
71
  - 📦 Built on TanStack Table v8 (battle-tested, powerful)
61
72
  - 🔒 Full TypeScript support
62
73
  - 🎛️ Feature flags for granular control
74
+ - 🔌 **Toolbar slot** - Add custom controls to the toolbar (v0.6.0+)
63
75
  - 🚀 Zero external dependencies (except TanStack Table)
64
76
  - ♿ Accessible and keyboard-friendly
65
77
 
@@ -258,6 +270,36 @@ Listen to table events:
258
270
  />
259
271
  ```
260
272
 
273
+ ### Custom Toolbar Controls (v0.6.0+)
274
+
275
+ Add custom controls to the left side of the toolbar using the `toolbar-left` slot:
276
+
277
+ ```svelte
278
+ <script>
279
+ import { TableKit } from '@shotleybuilder/svelte-table-kit';
280
+ import ViewSelector from './ViewSelector.svelte';
281
+ </script>
282
+
283
+ <TableKit {data} {columns}>
284
+ <!-- Add custom controls to the toolbar -->
285
+ <svelte:fragment slot="toolbar-left">
286
+ <ViewSelector on:viewSelected={handleViewSelected} />
287
+ <button on:click={saveView} class="btn-primary">
288
+ Save View
289
+ </button>
290
+ </svelte:fragment>
291
+ </TableKit>
292
+ ```
293
+
294
+ **Use Cases:**
295
+ - View management controls (save/load table configurations)
296
+ - Custom filter presets
297
+ - Quick action buttons
298
+ - Export/import controls
299
+ - Any custom toolbar buttons that should appear alongside table controls
300
+
301
+ The `toolbar-left` slot is positioned on the left side of the toolbar, while the built-in table controls (Filter, Sort, Group, Columns) automatically align to the right. All controls appear on the same row, creating a unified control bar.
302
+
261
303
  ---
262
304
 
263
305
  ## 🎨 Styling
@@ -25,6 +25,7 @@ import FilterBar from "./components/FilterBar.svelte";
25
25
  import GroupBar from "./components/GroupBar.svelte";
26
26
  import SortBar from "./components/SortBar.svelte";
27
27
  import ColumnMenu from "./components/ColumnMenu.svelte";
28
+ import CellContextMenu from "./components/CellContextMenu.svelte";
28
29
  export let data = [];
29
30
  export let columns = [];
30
31
  export let config = void 0;
@@ -59,6 +60,15 @@ let configInitialized = false;
59
60
  let grouping = writable([]);
60
61
  let expanded = writable(true);
61
62
  let groupBarExpanded = false;
63
+ let cellContextMenu = {
64
+ show: false,
65
+ x: 0,
66
+ y: 0,
67
+ value: null,
68
+ columnId: "",
69
+ columnHeader: "",
70
+ isNumeric: false
71
+ };
62
72
  $: {
63
73
  const configChanged = config?.id && config.id !== previousConfigId;
64
74
  const hasConfig = config !== void 0 && config !== null;
@@ -285,6 +295,37 @@ function handleDrop(targetColumnId) {
285
295
  $: if ($columnOrder.length === 0 && columns.length > 0) {
286
296
  columnOrder.set(columns.map((col) => col.accessorKey || col.id));
287
297
  }
298
+ function showCellContextMenu(event, cell) {
299
+ if (features.filtering === false) return;
300
+ event.preventDefault();
301
+ const value = cell.getValue();
302
+ const columnId = cell.column.id;
303
+ const columnHeader = String(cell.column.columnDef.header || columnId);
304
+ const isNumeric = typeof value === "number" || !isNaN(parseFloat(value));
305
+ cellContextMenu = {
306
+ show: true,
307
+ x: event.clientX,
308
+ y: event.clientY,
309
+ value,
310
+ columnId,
311
+ columnHeader,
312
+ isNumeric
313
+ };
314
+ }
315
+ function closeCellContextMenu() {
316
+ cellContextMenu = { ...cellContextMenu, show: false };
317
+ }
318
+ function addFilterFromCell(columnId, operator, value) {
319
+ const newCondition = {
320
+ id: generateFilterId(),
321
+ field: columnId,
322
+ operator,
323
+ value: String(value ?? "")
324
+ };
325
+ filterConditions.update((conditions) => [...conditions, newCondition]);
326
+ filterBarExpanded = true;
327
+ closeCellContextMenu();
328
+ }
288
329
  $: hasActiveFilters = $filterConditions.length > 0;
289
330
  $: totalTableWidth = $table.getVisibleLeafColumns().reduce((sum, col) => sum + col.getSize(), 0);
290
331
  $: if (onStateChange) {
@@ -303,11 +344,19 @@ $: if (onStateChange) {
303
344
  <!-- Filters and Controls -->
304
345
  {#if features.filtering !== false || features.grouping !== false || features.columnVisibility !== false || (features.sorting !== false && features.sortingMode === 'control')}
305
346
  <div class="table-kit-toolbar">
347
+ <!-- Slot for custom left-side controls (e.g., view selector, save buttons) -->
348
+ <div class="table-kit-custom-controls">
349
+ <slot name="toolbar-left" />
350
+ </div>
351
+
306
352
  <!-- Filter Controls -->
307
353
  {#if features.filtering !== false}
308
354
  <div class="table-kit-filters">
309
355
  <FilterBar
310
356
  {columns}
357
+ {data}
358
+ columnOrder={$columnOrder}
359
+ {storageKey}
311
360
  conditions={$filterConditions}
312
361
  onConditionsChange={(newConditions) => filterConditions.set(newConditions)}
313
362
  logic={$filterLogic}
@@ -761,7 +810,11 @@ $: if (onStateChange) {
761
810
  <!-- Placeholder cell - empty -->
762
811
  {:else}
763
812
  <!-- Normal cell -->
764
- <div class="cell-content">
813
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
814
+ <div
815
+ class="cell-content"
816
+ on:contextmenu={(e) => showCellContextMenu(e, cell)}
817
+ >
765
818
  <slot name="cell" {cell} column={cell.column.id}>
766
819
  <svelte:component
767
820
  this={flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -836,6 +889,23 @@ $: if (onStateChange) {
836
889
  {/if}
837
890
  </div>
838
891
 
892
+ <!-- Cell Context Menu -->
893
+ {#if cellContextMenu.show}
894
+ <CellContextMenu
895
+ x={cellContextMenu.x}
896
+ y={cellContextMenu.y}
897
+ value={cellContextMenu.value}
898
+ columnId={cellContextMenu.columnId}
899
+ columnHeader={cellContextMenu.columnHeader}
900
+ isNumeric={cellContextMenu.isNumeric}
901
+ on:filterEquals={(e) => addFilterFromCell(e.detail.columnId, 'equals', e.detail.value)}
902
+ on:filterNotEquals={(e) => addFilterFromCell(e.detail.columnId, 'not_equals', e.detail.value)}
903
+ on:filterGreaterThan={(e) => addFilterFromCell(e.detail.columnId, 'greater_than', e.detail.value)}
904
+ on:filterLessThan={(e) => addFilterFromCell(e.detail.columnId, 'less_than', e.detail.value)}
905
+ on:close={closeCellContextMenu}
906
+ />
907
+ {/if}
908
+
839
909
  <style>
840
910
  /* Container */
841
911
  .table-kit-container {
@@ -850,6 +920,16 @@ $: if (onStateChange) {
850
920
  justify-content: space-between;
851
921
  gap: 1rem;
852
922
  margin-bottom: 1rem;
923
+ flex-wrap: wrap;
924
+ }
925
+
926
+ /* Custom controls slot (left side) */
927
+ .table-kit-custom-controls {
928
+ display: flex;
929
+ align-items: center;
930
+ gap: 0.5rem;
931
+ flex-shrink: 0;
932
+ margin-right: auto;
853
933
  }
854
934
 
855
935
  .table-kit-filters {
@@ -20,9 +20,10 @@ declare class __sveltets_Render<T> {
20
20
  [evt: string]: CustomEvent<any>;
21
21
  };
22
22
  slots(): {
23
+ 'toolbar-left': {};
23
24
  empty: {};
24
25
  cell: {
25
- cell: import("@tanstack/table-core").Cell<T, unknown>;
26
+ cell: import("@tanstack/table-core").Cell<Record<string, any>, unknown>;
26
27
  column: string;
27
28
  };
28
29
  };
@@ -0,0 +1,208 @@
1
+ <script>import { onMount, createEventDispatcher } from "svelte";
2
+ export let x;
3
+ export let y;
4
+ export let value;
5
+ export let columnId;
6
+ export let columnHeader;
7
+ export let isNumeric = false;
8
+ const dispatch = createEventDispatcher();
9
+ let menuRef;
10
+ $: displayValue = (() => {
11
+ const str = String(value ?? "");
12
+ if (str.length > 30) {
13
+ return str.substring(0, 27) + "...";
14
+ }
15
+ return str;
16
+ })();
17
+ $: adjustedPosition = (() => {
18
+ let adjustedX = x;
19
+ let adjustedY = y;
20
+ if (typeof window !== "undefined") {
21
+ const menuWidth = 220;
22
+ const menuHeight = isNumeric ? 180 : 100;
23
+ if (x + menuWidth > window.innerWidth) {
24
+ adjustedX = window.innerWidth - menuWidth - 10;
25
+ }
26
+ if (y + menuHeight > window.innerHeight) {
27
+ adjustedY = window.innerHeight - menuHeight - 10;
28
+ }
29
+ }
30
+ return { x: adjustedX, y: adjustedY };
31
+ })();
32
+ function handleFilterEquals() {
33
+ dispatch("filterEquals", { columnId, value });
34
+ dispatch("close");
35
+ }
36
+ function handleFilterNotEquals() {
37
+ dispatch("filterNotEquals", { columnId, value });
38
+ dispatch("close");
39
+ }
40
+ function handleFilterGreaterThan() {
41
+ dispatch("filterGreaterThan", { columnId, value });
42
+ dispatch("close");
43
+ }
44
+ function handleFilterLessThan() {
45
+ dispatch("filterLessThan", { columnId, value });
46
+ dispatch("close");
47
+ }
48
+ function handleKeydown(event) {
49
+ if (event.key === "Escape") {
50
+ dispatch("close");
51
+ }
52
+ }
53
+ function handleClickOutside(event) {
54
+ if (menuRef && !menuRef.contains(event.target)) {
55
+ dispatch("close");
56
+ }
57
+ }
58
+ onMount(() => {
59
+ document.addEventListener("click", handleClickOutside);
60
+ document.addEventListener("keydown", handleKeydown);
61
+ menuRef?.focus();
62
+ return () => {
63
+ document.removeEventListener("click", handleClickOutside);
64
+ document.removeEventListener("keydown", handleKeydown);
65
+ };
66
+ });
67
+ </script>
68
+
69
+ <div
70
+ bind:this={menuRef}
71
+ class="cell-context-menu"
72
+ style="top: {adjustedPosition.y}px; left: {adjustedPosition.x}px;"
73
+ role="menu"
74
+ tabindex="-1"
75
+ >
76
+ <div class="menu-header">
77
+ <span class="column-name">{columnHeader}</span>
78
+ </div>
79
+
80
+ <button class="menu-item" on:click={handleFilterEquals} role="menuitem">
81
+ <svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82
+ <path
83
+ stroke-linecap="round"
84
+ stroke-linejoin="round"
85
+ stroke-width="2"
86
+ d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
87
+ />
88
+ </svg>
89
+ <span>Filter by "<strong>{displayValue}</strong>"</span>
90
+ </button>
91
+
92
+ <button class="menu-item" on:click={handleFilterNotEquals} role="menuitem">
93
+ <svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
94
+ <path
95
+ stroke-linecap="round"
96
+ stroke-linejoin="round"
97
+ stroke-width="2"
98
+ d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
99
+ />
100
+ </svg>
101
+ <span>Exclude "<strong>{displayValue}</strong>"</span>
102
+ </button>
103
+
104
+ {#if isNumeric}
105
+ <div class="menu-divider"></div>
106
+
107
+ <button class="menu-item" on:click={handleFilterGreaterThan} role="menuitem">
108
+ <svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
109
+ <path
110
+ stroke-linecap="round"
111
+ stroke-linejoin="round"
112
+ stroke-width="2"
113
+ d="M9 5l7 7-7 7"
114
+ />
115
+ </svg>
116
+ <span>Greater than <strong>{displayValue}</strong></span>
117
+ </button>
118
+
119
+ <button class="menu-item" on:click={handleFilterLessThan} role="menuitem">
120
+ <svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
121
+ <path
122
+ stroke-linecap="round"
123
+ stroke-linejoin="round"
124
+ stroke-width="2"
125
+ d="M15 19l-7-7 7-7"
126
+ />
127
+ </svg>
128
+ <span>Less than <strong>{displayValue}</strong></span>
129
+ </button>
130
+ {/if}
131
+ </div>
132
+
133
+ <style>
134
+ .cell-context-menu {
135
+ position: fixed;
136
+ z-index: 1000;
137
+ min-width: 200px;
138
+ background: white;
139
+ border: 1px solid #e5e7eb;
140
+ border-radius: 0.5rem;
141
+ box-shadow:
142
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1),
143
+ 0 4px 6px -2px rgba(0, 0, 0, 0.05);
144
+ padding: 0.25rem 0;
145
+ outline: none;
146
+ }
147
+
148
+ .menu-header {
149
+ padding: 0.5rem 0.75rem;
150
+ border-bottom: 1px solid #e5e7eb;
151
+ margin-bottom: 0.25rem;
152
+ }
153
+
154
+ .column-name {
155
+ font-size: 0.75rem;
156
+ font-weight: 600;
157
+ color: #6b7280;
158
+ text-transform: uppercase;
159
+ letter-spacing: 0.025em;
160
+ }
161
+
162
+ .menu-item {
163
+ display: flex;
164
+ align-items: center;
165
+ gap: 0.5rem;
166
+ width: 100%;
167
+ padding: 0.5rem 0.75rem;
168
+ font-size: 0.875rem;
169
+ text-align: left;
170
+ background: none;
171
+ border: none;
172
+ cursor: pointer;
173
+ transition: background-color 0.1s;
174
+ }
175
+
176
+ .menu-item:hover {
177
+ background: #f3f4f6;
178
+ }
179
+
180
+ .menu-item:focus {
181
+ outline: none;
182
+ background: #e5e7eb;
183
+ }
184
+
185
+ .menu-item span {
186
+ flex: 1;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ white-space: nowrap;
190
+ }
191
+
192
+ .menu-item strong {
193
+ color: #1f2937;
194
+ }
195
+
196
+ .menu-icon {
197
+ width: 1rem;
198
+ height: 1rem;
199
+ color: #6b7280;
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ .menu-divider {
204
+ height: 1px;
205
+ background: #e5e7eb;
206
+ margin: 0.25rem 0;
207
+ }
208
+ </style>
@@ -0,0 +1,41 @@
1
+ import { SvelteComponent } from "svelte";
2
+ declare const __propDef: {
3
+ props: {
4
+ x: number;
5
+ y: number;
6
+ value: any;
7
+ columnId: string;
8
+ columnHeader: string;
9
+ isNumeric?: boolean;
10
+ };
11
+ events: {
12
+ filterEquals: CustomEvent<{
13
+ columnId: string;
14
+ value: any;
15
+ }>;
16
+ filterNotEquals: CustomEvent<{
17
+ columnId: string;
18
+ value: any;
19
+ }>;
20
+ filterGreaterThan: CustomEvent<{
21
+ columnId: string;
22
+ value: any;
23
+ }>;
24
+ filterLessThan: CustomEvent<{
25
+ columnId: string;
26
+ value: any;
27
+ }>;
28
+ close: CustomEvent<void>;
29
+ } & {
30
+ [evt: string]: CustomEvent<any>;
31
+ };
32
+ slots: {};
33
+ exports?: {} | undefined;
34
+ bindings?: string | undefined;
35
+ };
36
+ export type CellContextMenuProps = typeof __propDef.props;
37
+ export type CellContextMenuEvents = typeof __propDef.events;
38
+ export type CellContextMenuSlots = typeof __propDef.slots;
39
+ export default class CellContextMenu extends SvelteComponent<CellContextMenuProps, CellContextMenuEvents, CellContextMenuSlots> {
40
+ }
41
+ export {};
@@ -1,5 +1,5 @@
1
1
  <script>import { createEventDispatcher, onDestroy, onMount } from "svelte";
2
- import { browser } from "$app/environment";
2
+ import { isBrowser } from "../stores/persistence";
3
3
  export let column;
4
4
  export let isOpen = false;
5
5
  export let canSort = true;
@@ -42,7 +42,7 @@ function handleKeydown(event) {
42
42
  }
43
43
  }
44
44
  $: {
45
- if (browser) {
45
+ if (isBrowser) {
46
46
  if (isOpen) {
47
47
  setTimeout(() => {
48
48
  document.addEventListener("mousedown", handleClickOutside);
@@ -55,7 +55,7 @@ $: {
55
55
  }
56
56
  }
57
57
  onDestroy(() => {
58
- if (browser) {
58
+ if (isBrowser) {
59
59
  document.removeEventListener("mousedown", handleClickOutside);
60
60
  document.removeEventListener("keydown", handleKeydown);
61
61
  }
@@ -1,11 +1,83 @@
1
1
  <script>import FilterConditionComponent from "./FilterCondition.svelte";
2
2
  export let columns;
3
+ export let data = [];
4
+ export let columnOrder = [];
5
+ export let storageKey = "table-kit";
3
6
  export let conditions = [];
4
7
  export let onConditionsChange;
5
8
  export let logic = "and";
6
9
  export let onLogicChange;
7
10
  export let isExpanded = false;
8
11
  export let onExpandedChange = void 0;
12
+ let columnValuesCache = /* @__PURE__ */ new Map();
13
+ let numericRangeCache = /* @__PURE__ */ new Map();
14
+ let lastDataLength = 0;
15
+ function isNumericColumn(columnId) {
16
+ if (!columnId || !data || data.length === 0) return false;
17
+ let numericCount = 0;
18
+ let sampleCount = 0;
19
+ const sampleSize = Math.min(10, data.length);
20
+ for (const row of data) {
21
+ if (sampleCount >= sampleSize) break;
22
+ const val = row[columnId];
23
+ if (val !== null && val !== void 0 && val !== "") {
24
+ sampleCount++;
25
+ if (typeof val === "number" || typeof val === "string" && !isNaN(Number(val))) {
26
+ numericCount++;
27
+ }
28
+ }
29
+ }
30
+ return sampleCount > 0 && numericCount / sampleCount >= 0.8;
31
+ }
32
+ function getNumericRange(columnId) {
33
+ if (!columnId || !data || data.length === 0) return null;
34
+ if (data.length !== lastDataLength) {
35
+ numericRangeCache.clear();
36
+ }
37
+ if (numericRangeCache.has(columnId)) {
38
+ return numericRangeCache.get(columnId);
39
+ }
40
+ if (!isNumericColumn(columnId)) {
41
+ numericRangeCache.set(columnId, null);
42
+ return null;
43
+ }
44
+ let min = Infinity;
45
+ let max = -Infinity;
46
+ for (const row of data) {
47
+ const val = row[columnId];
48
+ if (val !== null && val !== void 0 && val !== "") {
49
+ const num = typeof val === "number" ? val : Number(val);
50
+ if (!isNaN(num)) {
51
+ min = Math.min(min, num);
52
+ max = Math.max(max, num);
53
+ }
54
+ }
55
+ }
56
+ const range = min !== Infinity ? { min, max } : null;
57
+ numericRangeCache.set(columnId, range);
58
+ return range;
59
+ }
60
+ function getColumnValues(columnId) {
61
+ if (!columnId || !data || data.length === 0) return [];
62
+ if (data.length !== lastDataLength) {
63
+ columnValuesCache.clear();
64
+ numericRangeCache.clear();
65
+ lastDataLength = data.length;
66
+ }
67
+ if (columnValuesCache.has(columnId)) {
68
+ return columnValuesCache.get(columnId);
69
+ }
70
+ const values = /* @__PURE__ */ new Set();
71
+ for (const row of data) {
72
+ const val = row[columnId];
73
+ if (val !== null && val !== void 0 && val !== "") {
74
+ values.add(String(val));
75
+ }
76
+ }
77
+ const sortedValues = Array.from(values).sort((a, b) => a.localeCompare(b));
78
+ columnValuesCache.set(columnId, sortedValues);
79
+ return sortedValues;
80
+ }
9
81
  function generateId() {
10
82
  return `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
11
83
  }
@@ -99,6 +171,10 @@ $: filterCount = conditions.filter(
99
171
  <FilterConditionComponent
100
172
  {condition}
101
173
  {columns}
174
+ {columnOrder}
175
+ {storageKey}
176
+ columnValues={getColumnValues(condition.field)}
177
+ numericRange={getNumericRange(condition.field)}
102
178
  onUpdate={(updated) => updateCondition(index, updated)}
103
179
  onRemove={() => removeCondition(index)}
104
180
  />
@@ -4,6 +4,9 @@ import type { FilterCondition, FilterLogic } from '../types';
4
4
  declare const __propDef: {
5
5
  props: {
6
6
  columns: ColumnDef<any>[];
7
+ data?: any[];
8
+ columnOrder?: string[];
9
+ storageKey?: string;
7
10
  conditions?: FilterCondition[];
8
11
  onConditionsChange: (conditions: FilterCondition[]) => void;
9
12
  logic?: FilterLogic;