@startsimpli/ui 0.4.113 → 0.4.115
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 +5 -5
- package/src/components/ui/__tests__/table-scroll.test.tsx +153 -0
- package/src/components/ui/table-scroll.ts +157 -0
- package/src/components/ui/table.tsx +42 -12
- package/src/components/unified-table/UnifiedTable.tsx +49 -32
- package/src/components/unified-table/__tests__/components/DataTableCore.scroll-affordance.test.tsx +99 -0
- package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx +234 -0
- package/src/components/unified-table/__tests__/utils/column-visibility.test.ts +97 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +34 -3
- package/src/components/unified-table/utils/columnVisibility.ts +105 -0
- package/src/components/unified-table/utils/index.ts +1 -0
- package/src/lib/injected-style.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.115",
|
|
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/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/i18n": "0.1.3",
|
|
127
|
+
"@startsimpli/realtime": "0.0.2"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for the table's horizontal-scroll affordance (startsim-5pq7h).
|
|
3
|
+
*
|
|
4
|
+
* The defect these come from is PURELY VISUAL — "there is no sliding bar so it
|
|
5
|
+
* makes it difficult to see all the columns" — and jsdom has no layout, so
|
|
6
|
+
* NOTHING here proves a scrollbar is visible on screen. That claim is carried
|
|
7
|
+
* by browser measurement (headed Chrome + headed WebKit on macOS: 0px of
|
|
8
|
+
* scrollbar layout height before, 10px after, 0px when the table fits).
|
|
9
|
+
*
|
|
10
|
+
* What these DO guard is the two things a later edit can silently break:
|
|
11
|
+
* - the CSS that produces the bar, including the `scrollbar-width` rule that
|
|
12
|
+
* measurably DELETES it, and
|
|
13
|
+
* - the "no decoration when nothing overflows" contract.
|
|
14
|
+
*/
|
|
15
|
+
import { render, screen } from '@testing-library/react'
|
|
16
|
+
import { renderHook, act } from '@testing-library/react'
|
|
17
|
+
import { useRef } from 'react'
|
|
18
|
+
import { Table, TableBody, TableCell, TableRow } from '../table'
|
|
19
|
+
import {
|
|
20
|
+
TABLE_SCROLL_CSS,
|
|
21
|
+
TABLE_SCROLL_STYLE_ID,
|
|
22
|
+
TABLE_SCROLLER_CLASS,
|
|
23
|
+
useScrollEdges,
|
|
24
|
+
} from '../table-scroll'
|
|
25
|
+
|
|
26
|
+
describe('TABLE_SCROLL_CSS', () => {
|
|
27
|
+
it('styles the webkit scrollbar, which is what gives the bar layout height', () => {
|
|
28
|
+
expect(TABLE_SCROLL_CSS).toContain(`.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar`)
|
|
29
|
+
expect(TABLE_SCROLL_CSS).toMatch(/height:\s*10px/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The finding this test exists for. MEASURED in headed Chrome on macOS:
|
|
34
|
+
*
|
|
35
|
+
* ::-webkit-scrollbar { height: 10px } -> 10px of layout height
|
|
36
|
+
* the same rule PLUS scrollbar-width / scrollbar-color -> 0px (overlay again)
|
|
37
|
+
*
|
|
38
|
+
* Chromium honours the standard properties instead of the custom scrollbar
|
|
39
|
+
* and reverts to a zero-height overlay bar. Every "cross-browser scrollbar"
|
|
40
|
+
* snippet on the web tells you to add them, and doing so here would silently
|
|
41
|
+
* undo the fix with no visible error anywhere. Firefox on macOS gains nothing
|
|
42
|
+
* from them either (measured 0px with the property confirmed applied), so
|
|
43
|
+
* there is no browser to trade away.
|
|
44
|
+
*/
|
|
45
|
+
it('does NOT set scrollbar-width or scrollbar-color, which would revert Chromium to an overlay bar', () => {
|
|
46
|
+
expect(TABLE_SCROLL_CSS).not.toMatch(/scrollbar-width/)
|
|
47
|
+
expect(TABLE_SCROLL_CSS).not.toMatch(/scrollbar-color/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `overflow: auto` is what makes the bar conditional on real overflow. Under
|
|
52
|
+
* `overflow: scroll` every narrow table in the fleet would grow a dead 10px
|
|
53
|
+
* bar — a regression, not a fix.
|
|
54
|
+
*/
|
|
55
|
+
it('leaves the scroller on overflow:auto so the bar is conditional on real overflow', () => {
|
|
56
|
+
expect(TABLE_SCROLL_CSS).not.toMatch(/overflow\s*:\s*scroll/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Table', () => {
|
|
61
|
+
const renderTable = (props: Record<string, unknown> = {}) =>
|
|
62
|
+
render(
|
|
63
|
+
<Table {...props}>
|
|
64
|
+
<TableBody>
|
|
65
|
+
<TableRow>
|
|
66
|
+
<TableCell>cell</TableCell>
|
|
67
|
+
</TableRow>
|
|
68
|
+
</TableBody>
|
|
69
|
+
</Table>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
it('marks its scroll container so the scrollbar rules can find it', () => {
|
|
73
|
+
renderTable()
|
|
74
|
+
const table = screen.getByRole('table')
|
|
75
|
+
expect(table.parentElement).toHaveClass(TABLE_SCROLLER_CLASS)
|
|
76
|
+
expect(table.parentElement).toHaveClass('overflow-auto')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('injects the stylesheet once, however many tables are on the page', () => {
|
|
80
|
+
render(
|
|
81
|
+
<>
|
|
82
|
+
<Table />
|
|
83
|
+
<Table />
|
|
84
|
+
<Table />
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
expect(document.querySelectorAll(`#${TABLE_SCROLL_STYLE_ID}`)).toHaveLength(1)
|
|
88
|
+
expect(document.getElementById(TABLE_SCROLL_STYLE_ID)?.textContent).toBe(
|
|
89
|
+
TABLE_SCROLL_CSS
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('sends containerClassName to the scrolling box and className to the table', () => {
|
|
94
|
+
renderTable({ containerClassName: 'max-h-96', className: 'my-table' })
|
|
95
|
+
const table = screen.getByRole('table')
|
|
96
|
+
expect(table).toHaveClass('my-table')
|
|
97
|
+
expect(table.parentElement).toHaveClass('max-h-96')
|
|
98
|
+
expect(table).not.toHaveClass('max-h-96')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('useScrollEdges', () => {
|
|
103
|
+
/** jsdom has no layout, so geometry has to be stated outright. */
|
|
104
|
+
const harness = (clientWidth: number, scrollWidth: number, scrollLeft: number) => {
|
|
105
|
+
const scroller = document.createElement('div')
|
|
106
|
+
const table = document.createElement('table')
|
|
107
|
+
scroller.appendChild(table)
|
|
108
|
+
document.body.appendChild(scroller)
|
|
109
|
+
Object.defineProperty(scroller, 'clientWidth', { value: clientWidth, configurable: true })
|
|
110
|
+
Object.defineProperty(scroller, 'scrollWidth', { value: scrollWidth, configurable: true })
|
|
111
|
+
scroller.scrollLeft = scrollLeft
|
|
112
|
+
return { scroller, table }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const run = (clientWidth: number, scrollWidth: number, scrollLeft = 0) => {
|
|
116
|
+
const { scroller, table } = harness(clientWidth, scrollWidth, scrollLeft)
|
|
117
|
+
const { result } = renderHook(() => {
|
|
118
|
+
const ref = useRef<HTMLTableElement | null>(table)
|
|
119
|
+
return useScrollEdges(ref)
|
|
120
|
+
})
|
|
121
|
+
return { edges: result.current, scroller, result }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
it('reports no edge at all when the table fits — a hint under a table that fits is the same defect as a bar under one', () => {
|
|
125
|
+
expect(run(846, 846).edges).toEqual({ left: false, right: false })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('ignores a sub-pixel overhang rather than decorating a table that visually fits', () => {
|
|
129
|
+
expect(run(846, 847).edges).toEqual({ left: false, right: false })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('reports the right edge when columns are unreached to the right', () => {
|
|
133
|
+
expect(run(846, 1014).edges).toEqual({ left: false, right: true })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reports the left edge once scrolled away from the start', () => {
|
|
137
|
+
expect(run(846, 1014, 84).edges).toEqual({ left: true, right: true })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('drops the right edge at the end of the scroll range', () => {
|
|
141
|
+
expect(run(846, 1014, 168).edges).toEqual({ left: true, right: false })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('re-reads geometry when the scroller is scrolled', () => {
|
|
145
|
+
const { scroller, result } = run(846, 1014)
|
|
146
|
+
expect(result.current).toEqual({ left: false, right: true })
|
|
147
|
+
act(() => {
|
|
148
|
+
scroller.scrollLeft = 168
|
|
149
|
+
scroller.dispatchEvent(new Event('scroll'))
|
|
150
|
+
})
|
|
151
|
+
expect(result.current).toEqual({ left: true, right: false })
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type RefObject } from 'react'
|
|
4
|
+
|
|
5
|
+
export const TABLE_SCROLL_STYLE_ID = 'ss-table-scroll-affordance'
|
|
6
|
+
|
|
7
|
+
/** The element that actually scrolls: the wrapper `Table` puts around `<table>`. */
|
|
8
|
+
export const TABLE_SCROLLER_CLASS = 'ss-table-scroller'
|
|
9
|
+
/** A non-scrolling box around the scroller, so the edge hints can be pinned to it. */
|
|
10
|
+
export const TABLE_SHELL_CLASS = 'ss-table-shell'
|
|
11
|
+
export const TABLE_EDGE_CLASS = 'ss-table-edge'
|
|
12
|
+
export const TABLE_EDGE_LEFT_CLASS = 'ss-table-edge-left'
|
|
13
|
+
export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wide tables scroll, but on macOS nothing says so (startsim-5pq7h).
|
|
17
|
+
*
|
|
18
|
+
* A reviewer reported "there is no sliding bar so it makes it difficult to see
|
|
19
|
+
* all the columns". The container did scroll the whole time; macOS renders an
|
|
20
|
+
* OVERLAY scrollbar that occupies no layout height and is invisible at rest, so
|
|
21
|
+
* the affordance was missing, not the scrolling.
|
|
22
|
+
*
|
|
23
|
+
* MEASURED 2026-09-07, headed Google Chrome and headed WebKit on macOS, on a
|
|
24
|
+
* 400px box holding 900px of content (`offsetHeight - clientHeight`, borders
|
|
25
|
+
* subtracted):
|
|
26
|
+
*
|
|
27
|
+
* no scrollbar CSS 0px (overlay)
|
|
28
|
+
* scrollbar-width: thin 0px
|
|
29
|
+
* scrollbar-color: <thumb> <track> 0px
|
|
30
|
+
* ::-webkit-scrollbar { height: 10px } 10px <-- this one
|
|
31
|
+
* ::-webkit-scrollbar + scrollbar-width/-color 0px <-- see below
|
|
32
|
+
* ::-webkit-scrollbar, content NARROWER than the box 0px (no regression)
|
|
33
|
+
*
|
|
34
|
+
* TWO RESULTS THAT ARE EASY TO UNDO BY ACCIDENT:
|
|
35
|
+
*
|
|
36
|
+
* 1. DO NOT ADD `scrollbar-width` OR `scrollbar-color` HERE. Every snippet on
|
|
37
|
+
* the web pairs them with the -webkit- rules "for Firefox". Measured: when
|
|
38
|
+
* the standard properties are set at the same specificity, Chromium honours
|
|
39
|
+
* them INSTEAD of the custom scrollbar and reverts the element to a
|
|
40
|
+
* zero-height overlay bar — i.e. adding them silently deletes this fix.
|
|
41
|
+
* Firefox on macOS gains nothing from them anyway (measured 0px with
|
|
42
|
+
* `scrollbar-width: thin` applied and confirmed in the computed style), so
|
|
43
|
+
* there is no Firefox branch to lose. Firefox users are served by the edge
|
|
44
|
+
* hint below instead.
|
|
45
|
+
*
|
|
46
|
+
* 2. The bar costs layout height ONLY while the element overflows — that is
|
|
47
|
+
* `overflow: auto`, not something this stylesheet decides — so it never
|
|
48
|
+
* appears under a table that fits. Keep the scroller on `auto`, never
|
|
49
|
+
* `scroll`, or every narrow table in the fleet grows a dead bar.
|
|
50
|
+
*
|
|
51
|
+
* On Windows/Linux this REPLACES the ~15px classic bar with a 10px one; it does
|
|
52
|
+
* not reserve a second gutter, so no platform guard is needed. Nothing here
|
|
53
|
+
* animates or transitions, so `prefers-reduced-motion` has nothing to honour.
|
|
54
|
+
*/
|
|
55
|
+
export const TABLE_SCROLL_CSS = `
|
|
56
|
+
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar {
|
|
57
|
+
height: 10px;
|
|
58
|
+
width: 10px;
|
|
59
|
+
}
|
|
60
|
+
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-track {
|
|
61
|
+
background: transparent;
|
|
62
|
+
}
|
|
63
|
+
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb {
|
|
64
|
+
border-radius: 9999px;
|
|
65
|
+
background-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
66
|
+
}
|
|
67
|
+
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover {
|
|
68
|
+
background-color: color-mix(in srgb, currentColor 45%, transparent);
|
|
69
|
+
}
|
|
70
|
+
.${TABLE_SHELL_CLASS} {
|
|
71
|
+
position: relative;
|
|
72
|
+
}
|
|
73
|
+
.${TABLE_EDGE_CLASS} {
|
|
74
|
+
position: absolute;
|
|
75
|
+
top: 0;
|
|
76
|
+
bottom: 0;
|
|
77
|
+
width: 24px;
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
}
|
|
80
|
+
.${TABLE_EDGE_RIGHT_CLASS} {
|
|
81
|
+
right: 0;
|
|
82
|
+
background-image: linear-gradient(to left, hsl(var(--background)), hsl(var(--background) / 0));
|
|
83
|
+
}
|
|
84
|
+
.${TABLE_EDGE_LEFT_CLASS} {
|
|
85
|
+
left: 0;
|
|
86
|
+
background-image: linear-gradient(to right, hsl(var(--background)), hsl(var(--background) / 0));
|
|
87
|
+
}
|
|
88
|
+
`.trim()
|
|
89
|
+
|
|
90
|
+
export interface ScrollEdges {
|
|
91
|
+
/** There is content scrolled off to the left. */
|
|
92
|
+
left: boolean
|
|
93
|
+
/** There is content still unreached to the right. */
|
|
94
|
+
right: boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Which horizontal edges of a table's scroller still hide content.
|
|
99
|
+
*
|
|
100
|
+
* The persistent scrollbar above sits at the bottom of the scroller, and the
|
|
101
|
+
* scroller is the FULL HEIGHT of the table — measured 3061-5698px below the
|
|
102
|
+
* fold on every content view of the marketing-agents tenant at 900px tall. So a
|
|
103
|
+
* reader looking at the header row never sees it, and needs a hint that travels
|
|
104
|
+
* with them. This drives that hint.
|
|
105
|
+
*
|
|
106
|
+
* Pass a ref to the `<table>`, not to the scroller: `Table` forwards its ref to
|
|
107
|
+
* the table element, and the scroller is its parent.
|
|
108
|
+
*/
|
|
109
|
+
export function useScrollEdges(
|
|
110
|
+
tableRef: RefObject<HTMLTableElement | null>
|
|
111
|
+
): ScrollEdges {
|
|
112
|
+
const [edges, setEdges] = useState<ScrollEdges>({ left: false, right: false })
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const table = tableRef.current
|
|
116
|
+
const scroller = table?.parentElement
|
|
117
|
+
if (!table || !scroller) return
|
|
118
|
+
|
|
119
|
+
const update = () => {
|
|
120
|
+
// 1px of slack: sub-pixel column widths leave scrollWidth a hair above
|
|
121
|
+
// clientWidth on tables that visually fit, and a hint under a table that
|
|
122
|
+
// fits is the same defect as a scrollbar under one.
|
|
123
|
+
const max = scroller.scrollWidth - scroller.clientWidth
|
|
124
|
+
const next: ScrollEdges = {
|
|
125
|
+
left: max > 1 && scroller.scrollLeft > 1,
|
|
126
|
+
right: max > 1 && scroller.scrollLeft < max - 1,
|
|
127
|
+
}
|
|
128
|
+
setEdges((prev) =>
|
|
129
|
+
prev.left === next.left && prev.right === next.right ? prev : next
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
update()
|
|
134
|
+
scroller.addEventListener('scroll', update, { passive: true })
|
|
135
|
+
|
|
136
|
+
let observer: ResizeObserver | undefined
|
|
137
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
138
|
+
// Observe the TABLE, not just the scroller. The scroller is `w-full`, so
|
|
139
|
+
// its own box does NOT change when a column is toggled, a filter narrows
|
|
140
|
+
// the data, or a page of rows re-sizes the auto-laid-out columns — only
|
|
141
|
+
// the table's box does. Watching the scroller alone misses every one of
|
|
142
|
+
// those and leaves the hint stale.
|
|
143
|
+
observer = new ResizeObserver(update)
|
|
144
|
+
observer.observe(table)
|
|
145
|
+
observer.observe(scroller)
|
|
146
|
+
}
|
|
147
|
+
window.addEventListener('resize', update)
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
scroller.removeEventListener('scroll', update)
|
|
151
|
+
window.removeEventListener('resize', update)
|
|
152
|
+
observer?.disconnect()
|
|
153
|
+
}
|
|
154
|
+
}, [tableRef])
|
|
155
|
+
|
|
156
|
+
return edges
|
|
157
|
+
}
|
|
@@ -1,19 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
import * as React from "react"
|
|
2
4
|
|
|
3
5
|
import { cn } from "../../lib/utils"
|
|
6
|
+
import { useInjectedStyle } from "../../lib/injected-style"
|
|
7
|
+
import {
|
|
8
|
+
TABLE_SCROLLER_CLASS,
|
|
9
|
+
TABLE_SCROLL_CSS,
|
|
10
|
+
TABLE_SCROLL_STYLE_ID,
|
|
11
|
+
} from "./table-scroll"
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
13
|
+
export interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
|
14
|
+
/**
|
|
15
|
+
* Classes for the scroll container that wraps the `<table>`. This div — not
|
|
16
|
+
* the `<table>` — is the element that actually scrolls horizontally, so
|
|
17
|
+
* anything that needs to change the scrolling box (a max-height, a different
|
|
18
|
+
* overflow) belongs here rather than in `className`.
|
|
19
|
+
*/
|
|
20
|
+
containerClassName?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
|
24
|
+
({ className, containerClassName, ...props }, ref) => {
|
|
25
|
+
// Gives the scroller a scrollbar that is actually visible while it
|
|
26
|
+
// overflows. See table-scroll.ts for the measurements — in particular why
|
|
27
|
+
// `scrollbar-width` must NOT be added alongside it.
|
|
28
|
+
useInjectedStyle(TABLE_SCROLL_STYLE_ID, TABLE_SCROLL_CSS)
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={cn(
|
|
33
|
+
"relative w-full overflow-auto",
|
|
34
|
+
TABLE_SCROLLER_CLASS,
|
|
35
|
+
containerClassName
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<table
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
)
|
|
17
47
|
Table.displayName = "Table"
|
|
18
48
|
|
|
19
49
|
const TableHeader = React.forwardRef<
|
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
111
|
+
JSON.stringify(resolvedColumnVisibility)
|
|
104
112
|
)
|
|
105
113
|
} catch {
|
|
106
114
|
// Ignore localStorage errors
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
|
-
}, [
|
|
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]:
|
|
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 =>
|
|
129
|
-
|
|
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 ?
|
|
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
|
-
|
|
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={
|
|
470
|
+
columnVisibility={resolvedColumnVisibility}
|
|
454
471
|
onToggleColumnVisibility={toggleColumnVisibility}
|
|
455
472
|
exportEnabled={exportConfig?.enabled}
|
|
456
473
|
exportData={sortedData}
|
package/src/components/unified-table/__tests__/components/DataTableCore.scroll-affordance.test.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableCore's edge hints (startsim-5pq7h).
|
|
3
|
+
*
|
|
4
|
+
* The persistent scrollbar restored on the `Table` primitive sits at the bottom
|
|
5
|
+
* of the scroller, and the scroller is as tall as the whole table — measured
|
|
6
|
+
* 3061-5698px BELOW THE FOLD on every content view of the marketing-agents
|
|
7
|
+
* tenant at a 900px-tall window. A reader at the header row never sees it, so
|
|
8
|
+
* the table also fades whichever edge still hides content.
|
|
9
|
+
*
|
|
10
|
+
* jsdom has no layout, so the geometry is stated outright below. These tests
|
|
11
|
+
* guard the WIRING and the "nothing overflows -> no decoration" contract; the
|
|
12
|
+
* visual claim is carried by browser measurement, not by them.
|
|
13
|
+
*/
|
|
14
|
+
import { render, screen } from '@testing-library/react'
|
|
15
|
+
import { DataTableCore } from '../../components/DataTableCore'
|
|
16
|
+
import { ColumnConfig } from '../../types'
|
|
17
|
+
import { TABLE_EDGE_CLASS, TABLE_SHELL_CLASS } from '../../../ui/table-scroll'
|
|
18
|
+
|
|
19
|
+
interface Row {
|
|
20
|
+
id: string
|
|
21
|
+
name: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const data: Row[] = [
|
|
25
|
+
{ id: '1', name: 'Abu Dhabi Off-Plan Mortgage Guide' },
|
|
26
|
+
{ id: '2', name: 'UAE Capital Markets Fee Changes' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const columns: ColumnConfig<Row>[] = [
|
|
30
|
+
{ id: 'name', header: 'Name', accessorKey: 'name' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** Force the scroll container's geometry, which jsdom otherwise reports as 0/0. */
|
|
34
|
+
function stubGeometry(clientWidth: number, scrollWidth: number) {
|
|
35
|
+
const original = {
|
|
36
|
+
clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'),
|
|
37
|
+
scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth'),
|
|
38
|
+
}
|
|
39
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
40
|
+
configurable: true,
|
|
41
|
+
get() {
|
|
42
|
+
return this.tagName === 'DIV' ? clientWidth : 0
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
|
46
|
+
configurable: true,
|
|
47
|
+
get() {
|
|
48
|
+
return this.tagName === 'DIV' ? scrollWidth : 0
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
return () => {
|
|
52
|
+
if (original.clientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', original.clientWidth)
|
|
53
|
+
if (original.scrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', original.scrollWidth)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const edges = (container: HTMLElement) =>
|
|
58
|
+
container.querySelectorAll(`.${TABLE_EDGE_CLASS}`)
|
|
59
|
+
|
|
60
|
+
describe('DataTableCore horizontal-scroll affordance', () => {
|
|
61
|
+
let restore: () => void = () => {}
|
|
62
|
+
afterEach(() => restore())
|
|
63
|
+
|
|
64
|
+
it('gives the table a non-scrolling shell for the hints to pin to', () => {
|
|
65
|
+
restore = stubGeometry(846, 846)
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
|
|
68
|
+
)
|
|
69
|
+
const table = screen.getByRole('table')
|
|
70
|
+
// The shell is the OUTER box; the element that scrolls is Table's own
|
|
71
|
+
// wrapper inside it. Pinning the hints to the scroller would make them
|
|
72
|
+
// scroll away with the content.
|
|
73
|
+
expect(container.querySelector(`.${TABLE_SHELL_CLASS}`)).toBe(
|
|
74
|
+
table.parentElement?.parentElement
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders no edge hint at all when every column fits', () => {
|
|
79
|
+
restore = stubGeometry(846, 846)
|
|
80
|
+
const { container } = render(
|
|
81
|
+
<DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
|
|
82
|
+
)
|
|
83
|
+
expect(edges(container)).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('fades the right edge when columns are unreached to the right', () => {
|
|
87
|
+
// /t/topic at a 1152px window: 846 visible, 1014 needed.
|
|
88
|
+
restore = stubGeometry(846, 1014)
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
|
|
91
|
+
)
|
|
92
|
+
const found = edges(container)
|
|
93
|
+
expect(found).toHaveLength(1)
|
|
94
|
+
expect(found[0]).toHaveClass('ss-table-edge-right')
|
|
95
|
+
// Decoration only — it must never intercept a click meant for the cell or
|
|
96
|
+
// the row-action menu underneath.
|
|
97
|
+
expect(found[0]).toHaveAttribute('aria-hidden', 'true')
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx
ADDED
|
@@ -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
|
+
})
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { ReactNode, useState, useCallback } from 'react'
|
|
3
|
+
import { ReactNode, useState, useCallback, useRef } from 'react'
|
|
4
4
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
|
|
5
|
+
import {
|
|
6
|
+
useScrollEdges,
|
|
7
|
+
TABLE_SHELL_CLASS,
|
|
8
|
+
TABLE_EDGE_CLASS,
|
|
9
|
+
TABLE_EDGE_LEFT_CLASS,
|
|
10
|
+
TABLE_EDGE_RIGHT_CLASS,
|
|
11
|
+
} from '../../../ui/table-scroll'
|
|
5
12
|
import { Checkbox } from '../../../ui/checkbox'
|
|
6
13
|
import { Button } from '../../../ui/button'
|
|
7
14
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '../../../ui/dropdown-menu'
|
|
@@ -91,6 +98,14 @@ export function DataTableCore<TData>({
|
|
|
91
98
|
// State for tracking if a column is being dragged (to disable sorting during drag)
|
|
92
99
|
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
93
100
|
|
|
101
|
+
// The scrollbar restored in `Table` sits at the bottom of the scroller, and
|
|
102
|
+
// the scroller is as tall as the whole table — thousands of pixels below the
|
|
103
|
+
// fold on a full page of rows. These flags drive a fade at whichever edge
|
|
104
|
+
// still hides content, so the reader is told at the header row, not only at
|
|
105
|
+
// the very bottom of the page (startsim-5pq7h).
|
|
106
|
+
const tableRef = useRef<HTMLTableElement>(null)
|
|
107
|
+
const scrollEdges = useScrollEdges(tableRef)
|
|
108
|
+
|
|
94
109
|
// Drag handlers
|
|
95
110
|
const handleDragStart = useCallback((_: DragStart) => {
|
|
96
111
|
setIsDraggingColumn(true)
|
|
@@ -334,8 +349,13 @@ export function DataTableCore<TData>({
|
|
|
334
349
|
return (
|
|
335
350
|
// overflow-x-auto: a wide table scrolls horizontally inside its own border
|
|
336
351
|
// instead of overflowing the page to the right (startsim column-overflow).
|
|
337
|
-
|
|
338
|
-
|
|
352
|
+
// NOTE: this div is not the element that ends up scrolling — `Table`'s own
|
|
353
|
+
// `overflow-auto` wrapper inside it is, because that wrapper is `w-full`
|
|
354
|
+
// and so never makes this one overflow. That is why the scrollbar styling
|
|
355
|
+
// and the ref below both target the inner wrapper. This div stays as the
|
|
356
|
+
// non-scrolling shell the edge hints are pinned to.
|
|
357
|
+
<div className={cn('rounded-md border border-border overflow-x-auto', TABLE_SHELL_CLASS, className)}>
|
|
358
|
+
<Table ref={tableRef}>
|
|
339
359
|
{renderColumnHeaders()}
|
|
340
360
|
|
|
341
361
|
<TableBody>
|
|
@@ -474,6 +494,17 @@ export function DataTableCore<TData>({
|
|
|
474
494
|
)}
|
|
475
495
|
</TableBody>
|
|
476
496
|
</Table>
|
|
497
|
+
|
|
498
|
+
{/* Edge hints. Rendered only while that side actually hides something, so
|
|
499
|
+
a table that fits gets no decoration at all. `pointer-events: none`
|
|
500
|
+
keeps the rightmost column's cells and the row-action menu clickable
|
|
501
|
+
underneath. */}
|
|
502
|
+
{scrollEdges.left && (
|
|
503
|
+
<div aria-hidden="true" className={cn(TABLE_EDGE_CLASS, TABLE_EDGE_LEFT_CLASS)} />
|
|
504
|
+
)}
|
|
505
|
+
{scrollEdges.right && (
|
|
506
|
+
<div aria-hidden="true" className={cn(TABLE_EDGE_CLASS, TABLE_EDGE_RIGHT_CLASS)} />
|
|
507
|
+
)}
|
|
477
508
|
</div>
|
|
478
509
|
)
|
|
479
510
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append a stylesheet to <head> exactly once per document, keyed by `id`.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS instead of a .css file or Tailwind classes (startsim-5pq7h):
|
|
7
|
+
*
|
|
8
|
+
* - @startsimpli/ui ships SOURCE (`main: ./src/index.ts`), and each consuming
|
|
9
|
+
* app decides for itself whether Tailwind scans it. Four of the eleven apps
|
|
10
|
+
* — crochet, recipe, workflows and raise — have no
|
|
11
|
+
* `@source ".../@startsimpli/ui/src/**"` line in their globals.css, so a
|
|
12
|
+
* Tailwind class written in this package emits no rule at all over there.
|
|
13
|
+
* - The subpath CSS exports (`./gantt/styles`, `./workflows/styles`) only take
|
|
14
|
+
* effect once the app adds an `@import`, which is a change in eleven repos.
|
|
15
|
+
* - Pseudo-element rules (`::-webkit-scrollbar`) cannot be expressed as an
|
|
16
|
+
* inline `style` prop.
|
|
17
|
+
*
|
|
18
|
+
* Injecting from the component is the only delivery that reaches every place
|
|
19
|
+
* the component actually renders.
|
|
20
|
+
*/
|
|
21
|
+
export function ensureInjectedStyle(id: string, css: string): void {
|
|
22
|
+
if (typeof document === 'undefined') return
|
|
23
|
+
|
|
24
|
+
const existing = document.getElementById(id)
|
|
25
|
+
if (existing) {
|
|
26
|
+
if (existing.textContent !== css) existing.textContent = css
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const el = document.createElement('style')
|
|
31
|
+
el.id = id
|
|
32
|
+
el.textContent = css
|
|
33
|
+
document.head.appendChild(el)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** React wrapper for {@link ensureInjectedStyle}. Safe to call from every instance. */
|
|
37
|
+
export function useInjectedStyle(id: string, css: string): void {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
ensureInjectedStyle(id, css)
|
|
40
|
+
}, [id, css])
|
|
41
|
+
}
|