@startsimpli/ui 0.4.113 → 0.4.114

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.113",
3
+ "version": "0.4.114",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,9 +47,9 @@
47
47
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
+ "@startsimpli/api": "^0.5.58",
50
51
  "@startsimpli/auth": "^0.4.35",
51
- "@startsimpli/i18n": "^0.1.3",
52
- "@startsimpli/api": "^0.5.58"
52
+ "@startsimpli/i18n": "^0.1.3"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -123,8 +123,8 @@
123
123
  "tailwindcss": "^3.4.19",
124
124
  "tsup": "^8.5.1",
125
125
  "typescript": "^6.0.3",
126
- "@startsimpli/realtime": "0.0.2",
127
- "@startsimpli/i18n": "0.1.3"
126
+ "@startsimpli/i18n": "0.1.3",
127
+ "@startsimpli/realtime": "0.0.2"
128
128
  },
129
129
  "keywords": [
130
130
  "ui",
@@ -11,6 +11,7 @@ import { TablePagination } from './components/TablePagination'
11
11
  import { StandardTableToolbar } from './components/Toolbar'
12
12
  import { Loader2 } from 'lucide-react'
13
13
  import { cn } from '../../utils'
14
+ import { reconcileColumnVisibility, readStoredColumnVisibility, isForcedVisible } from './utils/columnVisibility'
14
15
 
15
16
  export function UnifiedTable<TData>({
16
17
  data,
@@ -56,31 +57,38 @@ export function UnifiedTable<TData>({
56
57
  return urlState.getURLState()
57
58
  }, []) // Only run once on mount
58
59
 
59
- // Column visibility state
60
- const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => {
61
- // Try to load from localStorage if persistKey is set
62
- if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') {
63
- try {
64
- const stored = localStorage.getItem(`table-columns-${columnVisibilityConfig.persistKey}`)
65
- if (stored) {
66
- return JSON.parse(stored)
67
- }
68
- } catch {
69
- // Ignore localStorage errors
70
- }
71
- }
60
+ // Column visibility state.
61
+ //
62
+ // A stored map is never trusted verbatim: it is reconciled against the
63
+ // columns actually on screen, so an attribute declared AFTER the user last
64
+ // saved a choice takes `defaultVisible` instead of being pinned visible for
65
+ // that one browser (bd startsim-7r2ta). See utils/columnVisibility.ts for the
66
+ // three rules — absent takes the default, a stored `false` survives,
67
+ // `alwaysVisible` outranks both.
68
+ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() =>
69
+ reconcileColumnVisibility(
70
+ columns,
71
+ readStoredColumnVisibility(columnVisibilityConfig?.persistKey),
72
+ columnVisibilityConfig
73
+ )
74
+ )
72
75
 
73
- // Initialize default visibility
74
- const initial: ColumnVisibilityState = {}
75
- columns.forEach(col => {
76
- if (columnVisibilityConfig?.defaultVisible) {
77
- initial[col.id] = columnVisibilityConfig.defaultVisible.includes(col.id)
78
- } else {
79
- initial[col.id] = true // Default all visible
80
- }
81
- })
82
- return initial
83
- })
76
+ // Callers that render before their schema lands (a runtime-declared entity
77
+ // type, a lazily loaded column set) hand us a short `columns` array for a
78
+ // render or two, so the reconciliation has to hold for the columns that
79
+ // arrive later as well. Reading through this memo rather than off state means
80
+ // a late column is never visible for a frame before being corrected.
81
+ const resolvedColumnVisibility = useMemo(
82
+ () => reconcileColumnVisibility(columns, columnVisibility, columnVisibilityConfig),
83
+ [columns, columnVisibility, columnVisibilityConfig]
84
+ )
85
+
86
+ useEffect(() => {
87
+ // Identity-stable when nothing changed, so this settles in one pass.
88
+ if (resolvedColumnVisibility !== columnVisibility) {
89
+ setColumnVisibility(resolvedColumnVisibility)
90
+ }
91
+ }, [resolvedColumnVisibility, columnVisibility])
84
92
 
85
93
  // Client-side sorting state (used when no external sorting config is provided)
86
94
  const [localSort, setLocalSort] = useState<SortState>(() => {
@@ -100,13 +108,13 @@ export function UnifiedTable<TData>({
100
108
  try {
101
109
  localStorage.setItem(
102
110
  `table-columns-${columnVisibilityConfig.persistKey}`,
103
- JSON.stringify(columnVisibility)
111
+ JSON.stringify(resolvedColumnVisibility)
104
112
  )
105
113
  } catch {
106
114
  // Ignore localStorage errors
107
115
  }
108
116
  }
109
- }, [columnVisibility, columnVisibilityConfig?.persistKey])
117
+ }, [resolvedColumnVisibility, columnVisibilityConfig?.persistKey])
110
118
 
111
119
  // Toggle column visibility
112
120
  const toggleColumnVisibility = useCallback((columnId: string) => {
@@ -114,9 +122,12 @@ export function UnifiedTable<TData>({
114
122
  if (columnVisibilityConfig?.alwaysVisible?.includes(columnId)) {
115
123
  return
116
124
  }
125
+ // Anything but an explicit `false` is on screen, so that's what a toggle
126
+ // has to turn off — `!prev[id]` would SHOW a column whose id the map has
127
+ // yet to learn, which is the very state this component used to render.
117
128
  setColumnVisibility(prev => ({
118
129
  ...prev,
119
- [columnId]: !prev[columnId]
130
+ [columnId]: prev[columnId] === false
120
131
  }))
121
132
  }, [columnVisibilityConfig?.alwaysVisible])
122
133
 
@@ -125,8 +136,14 @@ export function UnifiedTable<TData>({
125
136
  if (!columnVisibilityConfig?.enabled) {
126
137
  return columns
127
138
  }
128
- return columns.filter(col => columnVisibility[col.id] !== false)
129
- }, [columns, columnVisibility, columnVisibilityConfig?.enabled])
139
+ return columns.filter(col =>
140
+ // "Cannot be hidden" is a promise to the caller, not a default: a stored
141
+ // map written before the column joined that list must not be able to
142
+ // drop it, and the picker won't offer it back.
143
+ isForcedVisible(col, columnVisibilityConfig.alwaysVisible) ||
144
+ resolvedColumnVisibility[col.id] !== false
145
+ )
146
+ }, [columns, resolvedColumnVisibility, columnVisibilityConfig?.enabled, columnVisibilityConfig?.alwaysVisible])
130
147
 
131
148
  // Get hideable columns for the dropdown
132
149
  const hideableColumns = useMemo(() => {
@@ -263,7 +280,7 @@ export function UnifiedTable<TData>({
263
280
  // Get current view state for saving
264
281
  const getCurrentViewState = useCallback((): Omit<SavedView, 'id' | 'name' | 'createdAt'> => {
265
282
  return {
266
- columnVisibility: columnVisibilityConfig?.enabled ? columnVisibility : undefined,
283
+ columnVisibility: columnVisibilityConfig?.enabled ? resolvedColumnVisibility : undefined,
267
284
  columnOrder: columnReorderConfig?.enabled ? columnReorder.columnOrder : undefined,
268
285
  sortBy: currentSortBy,
269
286
  sortDirection: currentSortDirection,
@@ -271,7 +288,7 @@ export function UnifiedTable<TData>({
271
288
  pageSize: paginationConfig?.pageSize,
272
289
  }
273
290
  }, [
274
- columnVisibility,
291
+ resolvedColumnVisibility,
275
292
  columnVisibilityConfig?.enabled,
276
293
  columnReorder.columnOrder,
277
294
  columnReorderConfig?.enabled,
@@ -450,7 +467,7 @@ export function UnifiedTable<TData>({
450
467
  searchDebounceMs={searchConfig?.debounceMs}
451
468
  columnVisibilityEnabled={columnVisibilityConfig?.enabled}
452
469
  hideableColumns={hideableColumns}
453
- columnVisibility={columnVisibility}
470
+ columnVisibility={resolvedColumnVisibility}
454
471
  onToggleColumnVisibility={toggleColumnVisibility}
455
472
  exportEnabled={exportConfig?.enabled}
456
473
  exportData={sortedData}
@@ -0,0 +1,234 @@
1
+ /**
2
+ * A saved column map must not force-show columns declared after it (bd startsim-7r2ta).
3
+ *
4
+ * UnifiedTable seeds `columnVisibility` from localStorage and then keeps a
5
+ * column whose id is missing from that map, because the filter asks
6
+ * `map[id] !== false` and `undefined !== false`. On a runtime-declared schema —
7
+ * every foundry tenant — that means every attribute added AFTER a reviewer last
8
+ * touched the column picker is pinned visible for that reviewer alone, and
9
+ * invisible to anyone checking in a clean browser.
10
+ *
11
+ * The reconciliation has to thread three needles at once:
12
+ * 1. an id ABSENT from the stored map falls back to `defaultVisible`,
13
+ * 2. a stored `false` is a deliberate hide and survives,
14
+ * 3. `alwaysVisible` outranks both.
15
+ */
16
+ import { fireEvent, render, screen, within } from '@testing-library/react'
17
+ import { useState } from 'react'
18
+ import { UnifiedTable } from '../../index'
19
+ import { ColumnConfig } from '../../types'
20
+
21
+ interface Row {
22
+ id: string
23
+ name: string
24
+ }
25
+
26
+ const rows: Row[] = [{ id: '1', name: 'Alpha' }]
27
+
28
+ const NAME: ColumnConfig<Row> = { id: 'name', header: 'Name', accessorKey: 'name' }
29
+ const STATUS: ColumnConfig<Row> = { id: 'status', header: 'Status', accessorKey: 'status' }
30
+ const SCOPE: ColumnConfig<Row> = { id: 'scope_path', header: 'Scope path', accessorKey: 'scope_path' }
31
+ const ACTIONS: ColumnConfig<Row> = { id: '__actions', header: 'Actions' }
32
+
33
+ const PERSIST_KEY = 'records-topic'
34
+ const STORAGE_KEY = `table-columns-${PERSIST_KEY}`
35
+
36
+ function seedStoredMap(map: Record<string, boolean>) {
37
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
38
+ }
39
+
40
+ function readStoredMap(): Record<string, boolean> {
41
+ return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}')
42
+ }
43
+
44
+ /** Only the headers of the rendered grid — the column picker lists them too. */
45
+ function headers(): string[] {
46
+ return within(screen.getByRole('table'))
47
+ .getAllByRole('columnheader')
48
+ .map((th) => th.textContent?.trim() ?? '')
49
+ }
50
+
51
+ beforeEach(() => {
52
+ window.localStorage.clear()
53
+ })
54
+
55
+ describe('a stored column map is reconciled against the current columns', () => {
56
+ it('hides a column the stored map never heard of when defaults exclude it', () => {
57
+ // The reviewer saved a choice back when the type had name + status. Since
58
+ // then the schema grew a scope_path attribute, which the app does NOT
59
+ // default on.
60
+ seedStoredMap({ name: true, status: true })
61
+
62
+ render(
63
+ <UnifiedTable<Row>
64
+ data={rows}
65
+ columns={[NAME, STATUS, SCOPE]}
66
+ tableId="records"
67
+ getRowId={(r) => r.id}
68
+ columnVisibility={{
69
+ enabled: true,
70
+ alwaysVisible: ['name'],
71
+ defaultVisible: ['name', 'status'],
72
+ persistKey: PERSIST_KEY,
73
+ }}
74
+ />
75
+ )
76
+
77
+ expect(headers()).toEqual(['Name', 'Status'])
78
+ })
79
+
80
+ it('still shows an unheard-of column when the caller defaults everything on', () => {
81
+ // No defaultVisible => "all visible" is the documented default, so an
82
+ // absent id must stay visible. The fix must not turn absence into a hide.
83
+ seedStoredMap({ name: true })
84
+
85
+ render(
86
+ <UnifiedTable<Row>
87
+ data={rows}
88
+ columns={[NAME, STATUS, SCOPE]}
89
+ tableId="records"
90
+ getRowId={(r) => r.id}
91
+ columnVisibility={{ enabled: true, persistKey: PERSIST_KEY }}
92
+ />
93
+ )
94
+
95
+ expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
96
+ })
97
+
98
+ it('keeps a deliberate hide the user saved', () => {
99
+ // status is in defaultVisible, but the stored map says false. Persistence
100
+ // exists precisely so that choice outlives the default.
101
+ seedStoredMap({ name: true, status: false })
102
+
103
+ render(
104
+ <UnifiedTable<Row>
105
+ data={rows}
106
+ columns={[NAME, STATUS, SCOPE]}
107
+ tableId="records"
108
+ getRowId={(r) => r.id}
109
+ columnVisibility={{
110
+ enabled: true,
111
+ defaultVisible: ['name', 'status'],
112
+ persistKey: PERSIST_KEY,
113
+ }}
114
+ />
115
+ )
116
+
117
+ expect(headers()).toEqual(['Name'])
118
+ })
119
+
120
+ it('overrides a stored hide of an always-visible column', () => {
121
+ // The caller added __actions to alwaysVisible after this user had hidden
122
+ // it (or the column was renamed into the always-visible set). An
123
+ // always-visible column that the table drops takes the row actions with it.
124
+ seedStoredMap({ name: false, status: true, __actions: false })
125
+
126
+ render(
127
+ <UnifiedTable<Row>
128
+ data={rows}
129
+ columns={[NAME, STATUS, ACTIONS]}
130
+ tableId="records"
131
+ getRowId={(r) => r.id}
132
+ columnVisibility={{
133
+ enabled: true,
134
+ alwaysVisible: ['name', '__actions'],
135
+ defaultVisible: ['name', 'status'],
136
+ persistKey: PERSIST_KEY,
137
+ }}
138
+ />
139
+ )
140
+
141
+ expect(headers()).toEqual(['Name', 'Status', 'Actions'])
142
+ })
143
+
144
+ it('shows an always-visible column that defaultVisible forgot', () => {
145
+ // Nothing stored at all: the seed path must not let a defaultVisible list
146
+ // that omits an always-visible id hide it.
147
+ render(
148
+ <UnifiedTable<Row>
149
+ data={rows}
150
+ columns={[NAME, STATUS, ACTIONS]}
151
+ tableId="records"
152
+ getRowId={(r) => r.id}
153
+ columnVisibility={{
154
+ enabled: true,
155
+ alwaysVisible: ['name', '__actions'],
156
+ defaultVisible: ['name', 'status'],
157
+ persistKey: PERSIST_KEY,
158
+ }}
159
+ />
160
+ )
161
+
162
+ expect(headers()).toEqual(['Name', 'Status', 'Actions'])
163
+ })
164
+
165
+ it('keeps a hideable:false column that the defaults leave out', () => {
166
+ // `hideable: false` is already excluded from the column picker, so a
167
+ // reconciliation that hid one would strand it: off the table and off the
168
+ // menu that puts it back.
169
+ const pinned: ColumnConfig<Row> = { ...SCOPE, hideable: false }
170
+
171
+ render(
172
+ <UnifiedTable<Row>
173
+ data={rows}
174
+ columns={[NAME, STATUS, pinned]}
175
+ tableId="records"
176
+ getRowId={(r) => r.id}
177
+ columnVisibility={{
178
+ enabled: true,
179
+ defaultVisible: ['name', 'status'],
180
+ persistKey: PERSIST_KEY,
181
+ }}
182
+ />
183
+ )
184
+
185
+ expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
186
+ })
187
+ })
188
+
189
+ describe('columns that arrive after mount', () => {
190
+ function LateSchema() {
191
+ const [cols, setCols] = useState<ColumnConfig<Row>[]>([NAME])
192
+ return (
193
+ <>
194
+ <button onClick={() => setCols([NAME, STATUS, SCOPE])}>load schema</button>
195
+ <UnifiedTable<Row>
196
+ data={rows}
197
+ columns={cols}
198
+ tableId="records"
199
+ getRowId={(r) => r.id}
200
+ columnVisibility={{
201
+ enabled: true,
202
+ alwaysVisible: ['name'],
203
+ defaultVisible: ['name', 'status'],
204
+ persistKey: PERSIST_KEY,
205
+ }}
206
+ />
207
+ </>
208
+ )
209
+ }
210
+
211
+ it('reconciles a column the table had not been handed at mount', () => {
212
+ seedStoredMap({ name: true, status: true })
213
+ render(<LateSchema />)
214
+
215
+ fireEvent.click(screen.getByText('load schema'))
216
+
217
+ expect(headers()).toEqual(['Name', 'Status'])
218
+ })
219
+
220
+ it('does not wipe a saved hide for a column that has not arrived yet', () => {
221
+ // Reconciling by rebuilding the map from `columns` alone would drop
222
+ // status:false while the schema is still in flight, and the persist effect
223
+ // would write that loss straight back to localStorage.
224
+ seedStoredMap({ name: true, status: false })
225
+ render(<LateSchema />)
226
+
227
+ expect(readStoredMap().status).toBe(false)
228
+
229
+ fireEvent.click(screen.getByText('load schema'))
230
+
231
+ expect(readStoredMap().status).toBe(false)
232
+ expect(headers()).toEqual(['Name'])
233
+ })
234
+ })
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Unit cover for the reconciliation UnifiedTable applies to a stored column
3
+ * map (bd startsim-7r2ta). The integration test pins the rendered result; this
4
+ * pins the rules themselves, including the cases a rendered table can't easily
5
+ * show — an id the current schema no longer declares, a corrupt stored value.
6
+ */
7
+ import { reconcileColumnVisibility } from '../../utils/columnVisibility'
8
+
9
+ const cols = [{ id: 'name' }, { id: 'status' }, { id: 'scope_path' }]
10
+
11
+ describe('reconcileColumnVisibility', () => {
12
+ it('falls an unknown id back to defaultVisible, not to visible', () => {
13
+ expect(
14
+ reconcileColumnVisibility(cols, { name: true, status: true }, {
15
+ enabled: true,
16
+ defaultVisible: ['name', 'status'],
17
+ })
18
+ ).toEqual({ name: true, status: true, scope_path: false })
19
+ })
20
+
21
+ it('treats "no defaultVisible" as all-visible for unknown ids', () => {
22
+ expect(
23
+ reconcileColumnVisibility(cols, { name: true }, { enabled: true })
24
+ ).toEqual({ name: true, status: true, scope_path: true })
25
+ })
26
+
27
+ it('preserves a stored false even when the defaults say otherwise', () => {
28
+ expect(
29
+ reconcileColumnVisibility(cols, { status: false }, {
30
+ enabled: true,
31
+ defaultVisible: ['name', 'status', 'scope_path'],
32
+ })
33
+ ).toEqual({ name: true, status: false, scope_path: true })
34
+ })
35
+
36
+ it('forces an always-visible column on over a stored hide', () => {
37
+ expect(
38
+ reconcileColumnVisibility(cols, { name: false, status: false }, {
39
+ enabled: true,
40
+ alwaysVisible: ['name'],
41
+ defaultVisible: ['name'],
42
+ })
43
+ ).toEqual({ name: true, status: false, scope_path: false })
44
+ })
45
+
46
+ it('forces an always-visible column on when defaultVisible omits it', () => {
47
+ expect(
48
+ reconcileColumnVisibility([...cols, { id: '__actions' }], null, {
49
+ enabled: true,
50
+ alwaysVisible: ['__actions'],
51
+ defaultVisible: ['name'],
52
+ })
53
+ ).toEqual({ name: true, status: false, scope_path: false, __actions: true })
54
+ })
55
+
56
+ it('keeps a stored choice for a column the current schema has not declared', () => {
57
+ // Mid-flight schemas hand the table a short `columns` array. Rebuilding the
58
+ // map from `columns` alone would drop the hide, and the persist effect
59
+ // would write that loss back to localStorage.
60
+ expect(
61
+ reconcileColumnVisibility([{ id: 'name' }], { name: true, status: false }, {
62
+ enabled: true,
63
+ defaultVisible: ['name', 'status'],
64
+ })
65
+ ).toEqual({ name: true, status: false })
66
+ })
67
+
68
+ it('ignores non-boolean values in a corrupt stored map', () => {
69
+ expect(
70
+ reconcileColumnVisibility(cols, { name: 'yes', status: null } as never, {
71
+ enabled: true,
72
+ defaultVisible: ['name'],
73
+ })
74
+ ).toEqual({ name: true, status: false, scope_path: false })
75
+ })
76
+
77
+ it('returns the same object when nothing needs reconciling', () => {
78
+ // The mount-time seed feeds this straight into setState on every columns
79
+ // change; a fresh object every time would loop.
80
+ const stored = { name: true, status: false, scope_path: false }
81
+ expect(
82
+ reconcileColumnVisibility(cols, stored, { enabled: true, defaultVisible: ['name'] })
83
+ ).toBe(stored)
84
+ })
85
+
86
+ it('forces a hideable:false column on, since the picker will not offer it back', () => {
87
+ // Same promise as alwaysVisible, and the one the caller is likelier to get
88
+ // wrong: hiding such a column leaves the user no way to restore it.
89
+ expect(
90
+ reconcileColumnVisibility(
91
+ [{ id: 'name' }, { id: 'status' }, { id: 'scope_path', hideable: false }],
92
+ { name: true, scope_path: false },
93
+ { enabled: true, defaultVisible: ['name'] }
94
+ )
95
+ ).toEqual({ name: true, status: false, scope_path: true })
96
+ })
97
+ })
@@ -0,0 +1,105 @@
1
+ import { ColumnVisibilityConfig, ColumnVisibilityState } from '../types'
2
+
3
+ /** The two fields of a column this reconciliation reads. */
4
+ export interface ReconcilableColumn {
5
+ id: string
6
+ /** `false` is the same promise as listing the id in `alwaysVisible`. */
7
+ hideable?: boolean
8
+ }
9
+
10
+ /**
11
+ * Column visibility persists as a map of column id -> boolean, and the table
12
+ * treats a MISSING id as visible (`map[id] !== false`). That is the right
13
+ * reading for a fresh browser, and the wrong one for a stored map: a schema
14
+ * declared at runtime — every foundry tenant — grows attributes after a
15
+ * reviewer last saved a column choice, and each new one lands outside the
16
+ * stored map and is therefore pinned on for that reviewer alone, invisible to
17
+ * anyone checking in a clean session. Bumping the persist key clears it once
18
+ * and the next attribute brings it back (bd startsim-7r2ta).
19
+ *
20
+ * So a stored map is reconciled against the columns actually on screen:
21
+ *
22
+ * - an id the stored map has never heard of takes `defaultVisible`
23
+ * (or stays visible when the caller declared no defaults at all),
24
+ * - a stored `false` is a deliberate hide and is left alone — that is the
25
+ * entire point of persisting,
26
+ * - `alwaysVisible` outranks both, so neither a stale stored `false` nor a
27
+ * `defaultVisible` list that forgot the id can drop the name column or the
28
+ * row-actions column. `hideable: false` carries the same promise and is
29
+ * forced the same way: the column picker already refuses to list such a
30
+ * column, so hiding one would leave the user no way to bring it back.
31
+ *
32
+ * Ids the current `columns` no longer declare are carried through untouched.
33
+ * A caller that renders before its schema arrives hands us a short column list
34
+ * for a render or two, and rebuilding the map from `columns` alone would erase
35
+ * the user's saved hides in that window — and the persist effect would then
36
+ * write the loss back to localStorage.
37
+ *
38
+ * Returns the `stored` object itself when nothing needed changing, so callers
39
+ * can feed the result straight back into `setState` without looping.
40
+ */
41
+ export function reconcileColumnVisibility(
42
+ columns: ReconcilableColumn[],
43
+ stored: ColumnVisibilityState | null | undefined,
44
+ config?: ColumnVisibilityConfig
45
+ ): ColumnVisibilityState {
46
+ const { defaultVisible, alwaysVisible } = config ?? {}
47
+
48
+ const next: ColumnVisibilityState = {}
49
+ let changed = false
50
+
51
+ // Carry the stored choices forward. A non-boolean is a corrupt entry (hand-
52
+ // edited storage, an older shape) and is treated as absent.
53
+ for (const [id, value] of Object.entries(stored ?? {})) {
54
+ if (typeof value === 'boolean') next[id] = value
55
+ else changed = true
56
+ }
57
+
58
+ for (const col of columns) {
59
+ const id = col.id
60
+ const desired = isForcedVisible(col, alwaysVisible)
61
+ ? true
62
+ : id in next
63
+ ? next[id]
64
+ : defaultVisible
65
+ ? defaultVisible.includes(id)
66
+ : true
67
+ if (next[id] !== desired) {
68
+ next[id] = desired
69
+ changed = true
70
+ }
71
+ }
72
+
73
+ return stored && !changed ? stored : next
74
+ }
75
+
76
+ /**
77
+ * A column the user is not allowed to hide — which therefore must not end up
78
+ * hidden by a stale stored map or by a `defaultVisible` list that omits it.
79
+ */
80
+ export function isForcedVisible(
81
+ column: ReconcilableColumn,
82
+ alwaysVisible: string[] | undefined
83
+ ): boolean {
84
+ return column.hideable === false || !!alwaysVisible?.includes(column.id)
85
+ }
86
+
87
+ /**
88
+ * The persisted half of the above: read the map UnifiedTable wrote for
89
+ * `persistKey`, tolerating a browser that has no localStorage (SSR, privacy
90
+ * mode) and a value that no longer parses.
91
+ */
92
+ export function readStoredColumnVisibility(
93
+ persistKey: string | undefined
94
+ ): ColumnVisibilityState | null {
95
+ if (!persistKey || typeof window === 'undefined') return null
96
+ try {
97
+ const stored = window.localStorage.getItem(`table-columns-${persistKey}`)
98
+ if (!stored) return null
99
+ const parsed = JSON.parse(stored)
100
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
101
+ return parsed as ColumnVisibilityState
102
+ } catch {
103
+ return null
104
+ }
105
+ }
@@ -2,3 +2,4 @@ export * from './renderers'
2
2
  export * from './themes'
3
3
  export * from './validation'
4
4
  export * from './export'
5
+ export * from './columnVisibility'