@startsimpli/ui 0.4.103 → 0.4.105

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.
Files changed (31) hide show
  1. package/package.json +6 -5
  2. package/src/components/document-editor/DocumentEditor.tsx +11 -2
  3. package/src/components/navigation/__tests__/breadcrumbs.test.tsx +16 -0
  4. package/src/components/navigation/breadcrumbs.tsx +39 -0
  5. package/src/components/navigation/index.ts +1 -0
  6. package/src/components/unified-table/UnifiedTable.tsx +30 -2
  7. package/src/components/unified-table/__tests__/integration/search-and-empty-state.test.tsx +210 -0
  8. package/src/components/unified-table/components/DataTableCore/index.tsx +6 -2
  9. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +53 -3
  10. package/src/components/unified-table/utils/export.ts +5 -37
  11. package/src/foundry/LinkPanel.tsx +394 -0
  12. package/src/foundry/__tests__/LinkPanel.test.tsx +139 -0
  13. package/src/foundry/__tests__/links.test.ts +251 -0
  14. package/src/foundry/__tests__/list-query.test.ts +200 -0
  15. package/src/foundry/__tests__/publication.test.ts +227 -0
  16. package/src/foundry/__tests__/record-validation.test.ts +132 -0
  17. package/src/foundry/__tests__/types.test.ts +53 -0
  18. package/src/foundry/index.ts +21 -0
  19. package/src/foundry/links.ts +227 -0
  20. package/src/foundry/list-query.ts +241 -0
  21. package/src/foundry/publication.ts +260 -0
  22. package/src/foundry/record-validation.ts +109 -0
  23. package/src/foundry/system-attrs.ts +51 -0
  24. package/src/foundry/types.ts +137 -0
  25. package/src/hooks/index.ts +1 -0
  26. package/src/hooks/use-now.ts +46 -0
  27. package/src/index.ts +1 -0
  28. package/src/utils/__tests__/csv.test.ts +67 -0
  29. package/src/utils/__tests__/download-csv.test.ts +40 -0
  30. package/src/utils/csv.ts +77 -0
  31. package/src/utils/index.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.103",
3
+ "version": "0.4.105",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,6 +8,7 @@
8
8
  ".": "./src/index.ts",
9
9
  "./catalog": "./src/catalog/index.ts",
10
10
  "./table": "./src/components/unified-table/index.ts",
11
+ "./foundry": "./src/foundry/index.ts",
11
12
  "./gantt": "./src/components/gantt/index.ts",
12
13
  "./gantt/styles": "./src/components/gantt/gantt.css",
13
14
  "./components": "./src/components/index.ts",
@@ -44,9 +45,9 @@
44
45
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
45
46
  "react": "^18.0.0 || ^19.0.0",
46
47
  "react-dom": "^18.0.0 || ^19.0.0",
47
- "@startsimpli/api": "^0.5.56",
48
48
  "@startsimpli/auth": "^0.4.35",
49
- "@startsimpli/i18n": "^0.1.3"
49
+ "@startsimpli/i18n": "^0.1.3",
50
+ "@startsimpli/api": "^0.5.56"
50
51
  },
51
52
  "peerDependenciesMeta": {
52
53
  "@startsimpli/api": {
@@ -120,8 +121,8 @@
120
121
  "tailwindcss": "^3.4.19",
121
122
  "tsup": "^8.5.1",
122
123
  "typescript": "^6.0.3",
123
- "@startsimpli/realtime": "0.0.2",
124
- "@startsimpli/i18n": "0.1.3"
124
+ "@startsimpli/i18n": "0.1.3",
125
+ "@startsimpli/realtime": "0.0.2"
125
126
  },
126
127
  "keywords": [
127
128
  "ui",
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import * as React from 'react';
14
14
  import { Columns2, Eye, Pencil, Plus, Trash2, Loader2, Check, AlertCircle } from 'lucide-react';
15
+ import { wordCount } from '../validation/checks';
15
16
 
16
17
  import { cn } from '../../lib/utils';
17
18
  import { MarkdownRenderer } from '../blog/MarkdownRenderer';
@@ -34,9 +35,17 @@ export interface DocumentEditorProps {
34
35
  className?: string;
35
36
  }
36
37
 
38
+ /**
39
+ * ONE word counter in this package, not two (bd startsim-wn2p.28).
40
+ *
41
+ * This file had its own `split(/\s+/)` copy, so after the validation rail
42
+ * learned to count Chinese the editor still did not: the same Chinese draft
43
+ * showed "Blog word count 524 words" in the rail and "16 words" on the tab
44
+ * heading directly above it. A reader cannot tell which number to believe, and
45
+ * the honest answer was that only one of them was counting.
46
+ */
37
47
  function countWords(s: string): number {
38
- const t = s.trim();
39
- return t ? t.split(/\s+/).length : 0;
48
+ return wordCount(s);
40
49
  }
41
50
 
42
51
  function asString(v: unknown): string {
@@ -0,0 +1,16 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { Breadcrumbs } from '../breadcrumbs'
3
+
4
+ describe('Breadcrumbs', () => {
5
+ it('renders nothing rather than an empty nav', () => {
6
+ const { container } = render(<Breadcrumbs crumbs={[]} />)
7
+ expect(container.firstChild).toBeNull()
8
+ })
9
+
10
+ it('links every crumb but the one you are on', () => {
11
+ render(<Breadcrumbs crumbs={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />)
12
+ expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/')
13
+ expect(screen.queryByRole('link', { name: 'Products' })).not.toBeInTheDocument()
14
+ expect(screen.getByText('Products')).toHaveAttribute('aria-current', 'page')
15
+ })
16
+ })
@@ -0,0 +1,39 @@
1
+ import Link from 'next/link';
2
+
3
+ export interface Crumb {
4
+ label: string;
5
+ /** Omitted on the last crumb — the page you are already on is not a link. */
6
+ href?: string;
7
+ }
8
+
9
+ /**
10
+ * Where you are, one line above the heading.
11
+ *
12
+ * DELIBERATELY FLAT. A breadcrumb generated from a menu config goes BLANK on
13
+ * every route the menu has no entry for — a trail that disappears exactly where
14
+ * a trail is most useful. This renders the crumbs it is handed, in order, and
15
+ * has no opinion about where they came from.
16
+ */
17
+ export function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
18
+ if (crumbs.length === 0) return null;
19
+ return (
20
+ <nav aria-label="Breadcrumb">
21
+ <ol className="flex flex-wrap items-center gap-1.5 text-xs text-gray-500">
22
+ {crumbs.map((crumb, i) => (
23
+ <li key={`${crumb.label}-${i}`} className="flex items-center gap-1.5">
24
+ {i > 0 ? <span aria-hidden>/</span> : null}
25
+ {crumb.href ? (
26
+ <Link href={crumb.href} className="hover:text-gray-900 hover:underline">
27
+ {crumb.label}
28
+ </Link>
29
+ ) : (
30
+ <span className="text-gray-700" aria-current="page">
31
+ {crumb.label}
32
+ </span>
33
+ )}
34
+ </li>
35
+ ))}
36
+ </ol>
37
+ </nav>
38
+ );
39
+ }
@@ -4,3 +4,4 @@ export * from './sidebar'
4
4
  export * from './app-shell'
5
5
  export * from './grouped-nav'
6
6
  export * from './entity-switcher'
7
+ export * from './breadcrumbs'
@@ -306,6 +306,12 @@ export function UnifiedTable<TData>({
306
306
  useEffect(() => {
307
307
  if (!searchConfig?.enabled || !searchConfig.preserveFocus) return
308
308
  if (!shouldRestoreFocusRef.current) return
309
+ // Already focused means the user never lost it — re-focusing would only move
310
+ // the caret to the end, which with a debounced search happens mid-word.
311
+ if (searchInputRef.current && document.activeElement === searchInputRef.current) {
312
+ shouldRestoreFocusRef.current = false
313
+ return
314
+ }
309
315
  if (searchInputRef.current) {
310
316
  searchInputRef.current.focus()
311
317
  const len = searchConfig.value?.length ?? 0
@@ -400,8 +406,28 @@ export function UnifiedTable<TData>({
400
406
  )
401
407
  }
402
408
 
403
- // Empty state
404
- if (!loading && sortedData.length === 0 && emptyState) {
409
+ // IS THE LIST EMPTY BECAUSE THE USER NARROWED IT?
410
+ //
411
+ // `'all'` is TableFilters' own "no filter" sentinel, so a section sitting on it
412
+ // is not narrowing anything.
413
+ const hasNarrowingFilter = Object.values(filters.filters ?? {}).some((value) => {
414
+ if (value == null || value === '') return false
415
+ if (typeof value === 'string' && value.toLowerCase() === 'all') return false
416
+ if (Array.isArray(value)) return value.length > 0
417
+ return true
418
+ })
419
+ const isNarrowed = (searchConfig?.value ?? '').trim() !== '' || hasNarrowingFilter
420
+
421
+ // Empty state.
422
+ //
423
+ // REPLACING THE WHOLE TABLE IS ONLY RIGHT WHEN THERE IS NOTHING TO UNDO. A list
424
+ // emptied BY a search or a filter used to lose the toolbar as well — including
425
+ // the search box the user needs to widen it again — which turns "search for
426
+ // something that is not there" into a dead end with no way back but a reload.
427
+ // So the full replacement is kept for a genuinely empty list (unchanged for
428
+ // every consumer), and a narrowed empty list keeps its toolbar and filters and
429
+ // renders the same `emptyState` node where the rows would be.
430
+ if (!loading && sortedData.length === 0 && emptyState && !isNarrowed) {
405
431
  return <div className={className}>{emptyState}</div>
406
432
  }
407
433
 
@@ -421,6 +447,7 @@ export function UnifiedTable<TData>({
421
447
  }}
422
448
  searchInputRef={searchInputRef}
423
449
  searchAutoFocus={searchConfig?.autoFocus}
450
+ searchDebounceMs={searchConfig?.debounceMs}
424
451
  columnVisibilityEnabled={columnVisibilityConfig?.enabled}
425
452
  hideableColumns={hideableColumns}
426
453
  columnVisibility={columnVisibility}
@@ -543,6 +570,7 @@ export function UnifiedTable<TData>({
543
570
  rowActions={rowActions}
544
571
  onRowClick={onRowClick}
545
572
  loadingRows={loadingRows}
573
+ emptyState={emptyState}
546
574
  />
547
575
 
548
576
  {/* Pagination */}
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The two defects every foundry fork silently inherited (bd startsim-jugjd).
3
+ *
4
+ * Neither errored; both quietly produced a worse product than the consumer
5
+ * thought they had configured, so both are pinned here rather than left to a
6
+ * reviewer's eye.
7
+ */
8
+ import { render, screen, fireEvent, act } from '@testing-library/react'
9
+ import { useState } from 'react'
10
+ import { UnifiedTable } from '../../index'
11
+ import { ColumnConfig } from '../../types'
12
+
13
+ interface Row {
14
+ id: string
15
+ name: string
16
+ }
17
+
18
+ const columns: ColumnConfig<Row>[] = [{ id: 'name', header: 'Name', accessorKey: 'name' }]
19
+
20
+ const rows: Row[] = [
21
+ { id: '1', name: 'Alpha' },
22
+ { id: '2', name: 'Beta' },
23
+ ]
24
+
25
+ describe('SearchConfig.debounceMs', () => {
26
+ beforeEach(() => jest.useFakeTimers())
27
+ afterEach(() => jest.useRealTimers())
28
+
29
+ function Harness({ debounceMs, onSearch }: { debounceMs?: number; onSearch: (v: string) => void }) {
30
+ const [value, setValue] = useState('')
31
+ return (
32
+ <UnifiedTable<Row>
33
+ data={rows}
34
+ columns={columns}
35
+ tableId="t"
36
+ getRowId={(r) => r.id}
37
+ search={{
38
+ enabled: true,
39
+ placeholder: 'Search…',
40
+ value,
41
+ onChange: (next) => {
42
+ setValue(next)
43
+ onSearch(next)
44
+ },
45
+ debounceMs,
46
+ }}
47
+ />
48
+ )
49
+ }
50
+
51
+ it('reports once after the pause, not once per keystroke', () => {
52
+ const onSearch = jest.fn()
53
+ render(<Harness debounceMs={200} onSearch={onSearch} />)
54
+ const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
55
+
56
+ fireEvent.change(input, { target: { value: 'a' } })
57
+ fireEvent.change(input, { target: { value: 'al' } })
58
+ fireEvent.change(input, { target: { value: 'alp' } })
59
+
60
+ // The letters are on screen immediately — the debounce delays the REPORT,
61
+ // never the typing.
62
+ expect(input.value).toBe('alp')
63
+ expect(onSearch).not.toHaveBeenCalled()
64
+
65
+ act(() => { jest.advanceTimersByTime(200) })
66
+
67
+ expect(onSearch).toHaveBeenCalledTimes(1)
68
+ expect(onSearch).toHaveBeenCalledWith('alp')
69
+ })
70
+
71
+ it('does not rewind the box when the owner echoes an earlier value back', () => {
72
+ const onSearch = jest.fn()
73
+ render(<Harness debounceMs={200} onSearch={onSearch} />)
74
+ const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
75
+
76
+ fireEvent.change(input, { target: { value: 'al' } })
77
+ act(() => { jest.advanceTimersByTime(200) })
78
+ // Keep typing while the owner's re-render for "al" lands.
79
+ fireEvent.change(input, { target: { value: 'alpha' } })
80
+ act(() => { jest.advanceTimersByTime(50) })
81
+
82
+ expect(input.value).toBe('alpha')
83
+ })
84
+
85
+ it('accepts a value the owner changed itself', () => {
86
+ function Reset() {
87
+ const [value, setValue] = useState('beta')
88
+ return (
89
+ <>
90
+ <button onClick={() => setValue('')}>clear</button>
91
+ <UnifiedTable<Row>
92
+ data={rows}
93
+ columns={columns}
94
+ tableId="t"
95
+ getRowId={(r) => r.id}
96
+ search={{
97
+ enabled: true,
98
+ placeholder: 'Search…',
99
+ value,
100
+ onChange: setValue,
101
+ debounceMs: 200,
102
+ }}
103
+ />
104
+ </>
105
+ )
106
+ }
107
+ render(<Reset />)
108
+ const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
109
+ expect(input.value).toBe('beta')
110
+
111
+ fireEvent.click(screen.getByText('clear'))
112
+ expect(input.value).toBe('')
113
+ })
114
+
115
+ it('without debounceMs reports every keystroke, exactly as before', () => {
116
+ const onSearch = jest.fn()
117
+ render(<Harness onSearch={onSearch} />)
118
+ const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
119
+
120
+ fireEvent.change(input, { target: { value: 'a' } })
121
+ fireEvent.change(input, { target: { value: 'al' } })
122
+
123
+ expect(onSearch).toHaveBeenCalledTimes(2)
124
+ expect(onSearch).toHaveBeenLastCalledWith('al')
125
+ })
126
+ })
127
+
128
+ describe('emptyState keeps the way back', () => {
129
+ const empty: Row[] = []
130
+
131
+ it('replaces the whole table when nothing is narrowing the list', () => {
132
+ render(
133
+ <UnifiedTable<Row>
134
+ data={empty}
135
+ columns={columns}
136
+ tableId="t"
137
+ getRowId={(r) => r.id}
138
+ search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
139
+ emptyState={<div>Nothing here yet</div>}
140
+ />
141
+ )
142
+ expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
143
+ // The unchanged behaviour: no toolbar, because there is nothing to undo.
144
+ expect(screen.queryByPlaceholderText('Search…')).not.toBeInTheDocument()
145
+ })
146
+
147
+ it('keeps the search box when the search is what emptied the list', () => {
148
+ render(
149
+ <UnifiedTable<Row>
150
+ data={empty}
151
+ columns={columns}
152
+ tableId="t"
153
+ getRowId={(r) => r.id}
154
+ search={{ enabled: true, placeholder: 'Search…', value: 'zzz', onChange: () => {} }}
155
+ emptyState={<div>No matches</div>}
156
+ />
157
+ )
158
+ const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
159
+ expect(input).toBeInTheDocument()
160
+ expect(input.value).toBe('zzz')
161
+ // and the consumer's own copy is still what is shown, in the rows' place.
162
+ expect(screen.getByText('No matches')).toBeInTheDocument()
163
+ })
164
+
165
+ it('keeps the toolbar when a filter is what emptied the list', () => {
166
+ render(
167
+ <UnifiedTable<Row>
168
+ data={empty}
169
+ columns={columns}
170
+ tableId="t"
171
+ getRowId={(r) => r.id}
172
+ search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
173
+ filters={{
174
+ enabled: true,
175
+ position: 'top',
176
+ collapsible: false,
177
+ config: { filters: [{ id: 'status', label: 'Status', type: 'select', options: ['active'] }] },
178
+ value: { status: 'active' },
179
+ onChange: () => {},
180
+ }}
181
+ emptyState={<div>No matches</div>}
182
+ />
183
+ )
184
+ expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument()
185
+ expect(screen.getByText('No matches')).toBeInTheDocument()
186
+ })
187
+
188
+ it("treats TableFilters' own 'all' sentinel as no filter at all", () => {
189
+ render(
190
+ <UnifiedTable<Row>
191
+ data={empty}
192
+ columns={columns}
193
+ tableId="t"
194
+ getRowId={(r) => r.id}
195
+ search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
196
+ filters={{
197
+ enabled: true,
198
+ position: 'top',
199
+ collapsible: false,
200
+ config: { filters: [{ id: 'status', label: 'Status', type: 'select', options: ['active'] }] },
201
+ value: { status: 'all' },
202
+ onChange: () => {},
203
+ }}
204
+ emptyState={<div>Nothing here yet</div>}
205
+ />
206
+ )
207
+ expect(screen.queryByPlaceholderText('Search…')).not.toBeInTheDocument()
208
+ expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
209
+ })
210
+ })
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState, useCallback } from 'react'
3
+ import { ReactNode, useState, useCallback } from 'react'
4
4
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
5
5
  import { Checkbox } from '../../../ui/checkbox'
6
6
  import { Button } from '../../../ui/button'
@@ -51,6 +51,9 @@ interface DataTableCoreProps<TData> {
51
51
  // Loading
52
52
  loadingRows?: Set<string>
53
53
 
54
+ /** Shown in place of the rows when there are none. Defaults to "No results found." */
55
+ emptyState?: ReactNode
56
+
54
57
  className?: string
55
58
  }
56
59
 
@@ -78,6 +81,7 @@ export function DataTableCore<TData>({
78
81
  onCellEdit,
79
82
  rowActions,
80
83
  onRowClick,
84
+ emptyState,
81
85
  loadingRows = new Set(),
82
86
  className,
83
87
  }: DataTableCoreProps<TData>) {
@@ -341,7 +345,7 @@ export function DataTableCore<TData>({
341
345
  colSpan={columns.length + (selectionEnabled ? 1 : 0) + (rowActions ? 1 : 0)}
342
346
  className="h-24 text-center"
343
347
  >
344
- No results found.
348
+ {emptyState ?? 'No results found.'}
345
349
  </TableCell>
346
350
  </TableRow>
347
351
  ) : (
@@ -13,7 +13,7 @@ import { cn } from '../../../../lib/utils'
13
13
  import { ExportButton, ExportButtonProps } from './ExportButton'
14
14
  import { SavedViewsDropdown, SavedViewsDropdownProps } from './SavedViewsDropdown'
15
15
  import { ColumnConfig, ColumnVisibilityState, SavedView } from '../../types'
16
- import { RefObject } from 'react'
16
+ import { RefObject, useEffect, useRef, useState } from 'react'
17
17
 
18
18
  export interface StandardTableToolbarProps<TData> {
19
19
  // Search
@@ -23,6 +23,13 @@ export interface StandardTableToolbarProps<TData> {
23
23
  onSearchChange?: (value: string) => void
24
24
  searchInputRef?: RefObject<HTMLInputElement | null>
25
25
  searchAutoFocus?: boolean
26
+ /**
27
+ * Wait this long after the last keystroke before calling `onSearchChange`.
28
+ * Omitted or 0 keeps the input fully controlled and reports every keystroke,
29
+ * which is what every consumer got before `SearchConfig.debounceMs` was wired
30
+ * up (it was accepted and ignored).
31
+ */
32
+ searchDebounceMs?: number
26
33
 
27
34
  // Column visibility
28
35
  columnVisibilityEnabled?: boolean
@@ -60,6 +67,7 @@ export function StandardTableToolbar<TData>({
60
67
  onSearchChange,
61
68
  searchInputRef,
62
69
  searchAutoFocus,
70
+ searchDebounceMs = 0,
63
71
  columnVisibilityEnabled,
64
72
  hideableColumns = [],
65
73
  columnVisibility = {},
@@ -76,6 +84,48 @@ export function StandardTableToolbar<TData>({
76
84
  }: StandardTableToolbarProps<TData>) {
77
85
  const hasRightSideButtons = exportEnabled || columnVisibilityEnabled || savedViewsEnabled
78
86
 
87
+ // DEBOUNCED SEARCH.
88
+ //
89
+ // A debounce cannot sit in front of `onSearchChange` alone: the input's value
90
+ // comes from `searchValue`, so delaying the call would also delay the letter
91
+ // appearing and the box would swallow keystrokes. So the input holds a DRAFT
92
+ // that echoes every keystroke immediately, and only the report upward waits.
93
+ //
94
+ // The draft has to yield to a value the OWNER changed (clearing a search,
95
+ // loading a saved view) but must NOT yield to the owner echoing back what we
96
+ // just sent — that echo arrives a render after the user has typed more, and
97
+ // accepting it would rewind the box mid-word. `emittedRef` is the difference:
98
+ // a prop equal to what we last emitted is our own echo and is ignored.
99
+ const debouncing = searchDebounceMs > 0
100
+ const [draft, setDraft] = useState(searchValue)
101
+ const emittedRef = useRef(searchValue)
102
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
103
+
104
+ useEffect(() => {
105
+ if (searchValue === emittedRef.current) return
106
+ emittedRef.current = searchValue
107
+ setDraft(searchValue)
108
+ }, [searchValue])
109
+
110
+ // A pending keystroke must not fire into an unmounted tree.
111
+ useEffect(() => () => {
112
+ if (timerRef.current) clearTimeout(timerRef.current)
113
+ }, [])
114
+
115
+ const handleSearchInput = (value: string) => {
116
+ if (!debouncing) {
117
+ onSearchChange?.(value)
118
+ return
119
+ }
120
+ setDraft(value)
121
+ if (timerRef.current) clearTimeout(timerRef.current)
122
+ timerRef.current = setTimeout(() => {
123
+ timerRef.current = null
124
+ emittedRef.current = value
125
+ onSearchChange?.(value)
126
+ }, searchDebounceMs)
127
+ }
128
+
79
129
  return (
80
130
  <div className={cn('flex items-center justify-between gap-4', className)}>
81
131
  {/* Left side: Search */}
@@ -84,8 +134,8 @@ export function StandardTableToolbar<TData>({
84
134
  <Input
85
135
  type="text"
86
136
  placeholder={searchPlaceholder}
87
- value={searchValue}
88
- onChange={(e) => onSearchChange?.(e.target.value)}
137
+ value={debouncing ? draft : searchValue}
138
+ onChange={(e) => handleSearchInput(e.target.value)}
89
139
  ref={searchInputRef}
90
140
  autoFocus={searchAutoFocus}
91
141
  className="max-w-xl w-full"
@@ -1,5 +1,6 @@
1
1
  import * as XLSX from 'xlsx'
2
2
  import { ColumnConfig } from '../types'
3
+ import { csvFromRows, downloadBlob, downloadCsv } from '../../../utils/csv'
3
4
 
4
5
  export type ExportFormat = 'csv' | 'excel'
5
6
 
@@ -91,40 +92,8 @@ function prepareExportData<TData>(
91
92
  return rows
92
93
  }
93
94
 
94
- /**
95
- * Converts array data to CSV string
96
- */
97
- function arrayToCSV(data: string[][]): string {
98
- return data
99
- .map(row =>
100
- row
101
- .map(cell => {
102
- // Escape quotes and wrap in quotes if contains comma, quote, or newline
103
- const needsQuotes = /[,"\n\r]/.test(cell)
104
- if (needsQuotes) {
105
- return `"${cell.replace(/"/g, '""')}"`
106
- }
107
- return cell
108
- })
109
- .join(',')
110
- )
111
- .join('\n')
112
- }
113
-
114
- /**
115
- * Triggers a download of the given content
116
- */
117
- function downloadFile(content: string | Blob, filename: string, mimeType: string): void {
118
- const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
119
- const url = URL.createObjectURL(blob)
120
- const link = document.createElement('a')
121
- link.href = url
122
- link.download = filename
123
- document.body.appendChild(link)
124
- link.click()
125
- document.body.removeChild(link)
126
- URL.revokeObjectURL(url)
127
- }
95
+ // Quoting, the formula guard and the line ending all live in one place now —
96
+ // see ../../../utils/csv. A second copy here is how the two drift.
128
97
 
129
98
  /**
130
99
  * Exports data to CSV format
@@ -148,9 +117,8 @@ export function exportToCSV<TData>(
148
117
  }
149
118
 
150
119
  const exportData = prepareExportData(data, columns, includeHeaders)
151
- const csv = arrayToCSV(exportData)
152
120
 
153
- downloadFile(csv, `${filename}.csv`, 'text/csv;charset=utf-8;')
121
+ downloadCsv(`${filename}.csv`, csvFromRows(exportData))
154
122
  }
155
123
 
156
124
  /**
@@ -198,7 +166,7 @@ export function exportToExcel<TData>(
198
166
  type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
199
167
  })
200
168
 
201
- downloadFile(blob, `${filename}.xlsx`, blob.type)
169
+ downloadBlob(`${filename}.xlsx`, blob)
202
170
  }
203
171
 
204
172
  /**