@startsimpli/ui 0.4.120 → 0.4.122
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 +3 -3
- package/src/components/collection/DraftReviewWorkspace.tsx +8 -3
- package/src/components/collection/InlineReviewActions.tsx +7 -13
- package/src/components/collection/ReviewDrawer.tsx +16 -13
- package/src/components/collection/TopicReviewWorkspace.tsx +8 -3
- package/src/components/collection/__tests__/blob-round-trip.test.tsx +248 -0
- package/src/components/collection/data.ts +132 -4
- package/src/components/collection/index.ts +1 -1
- package/src/components/collection/review.ts +12 -6
- package/src/components/ui/__tests__/table-scroll.test.tsx +52 -0
- package/src/components/ui/table-scroll.ts +202 -21
- package/src/components/unified-table/UnifiedTable.tsx +2 -0
- package/src/components/unified-table/__tests__/components/DataTableCore.scroll-affordance.test.tsx +145 -3
- package/src/components/unified-table/components/DataTableCore/index.tsx +46 -6
- package/src/components/unified-table/types.ts +7 -0
- package/src/lib/scroll-affordance.ts +87 -0
|
@@ -69,6 +69,58 @@ describe('TABLE_SCROLL_CSS', () => {
|
|
|
69
69
|
* shape here; the visible-shading claim itself is carried by the pixel diff
|
|
70
70
|
* in examples/workflow-studio/scripts/measure-scrollers.mjs (delta 3 -> 35).
|
|
71
71
|
*/
|
|
72
|
+
/**
|
|
73
|
+
* startsim-xe1uo.1, and the single rule the whole fix turns on.
|
|
74
|
+
*
|
|
75
|
+
* The shell used to get its clipping from a Tailwind `overflow-x-auto` in
|
|
76
|
+
* DataTableCore, which made it a SCROLL CONTAINER — and `position: sticky`
|
|
77
|
+
* resolves against the nearest scroll container, so the rail was pinned to
|
|
78
|
+
* the bottom of the table, i.e. precisely where the unreachable bar already
|
|
79
|
+
* was. MEASURED in Chromium, a 1600px table in a 400px flex line scrolled so
|
|
80
|
+
* the shell ends 1.4k px below the fold:
|
|
81
|
+
*
|
|
82
|
+
* auto -> rail bottom 2022 (off screen), shell 400px wide
|
|
83
|
+
* hidden -> rail bottom 2022 (off screen)
|
|
84
|
+
* clip -> rail bottom 614 (stuck), shell 1602px <-- blown out
|
|
85
|
+
* clip + min-width:0 -> rail bottom 614 (stuck), shell 400px <-- both
|
|
86
|
+
*
|
|
87
|
+
* `min-width: auto` collapses to zero only for a SCROLL CONTAINER, and a clip
|
|
88
|
+
* container is not one, so `clip` on its own stops the shell shrinking and
|
|
89
|
+
* pushes its flex parent wide. Both declarations are load-bearing and they
|
|
90
|
+
* fail in opposite directions — hence a guard on each.
|
|
91
|
+
*/
|
|
92
|
+
it('clips the shell without making it a scroll container, and keeps it shrinkable', () => {
|
|
93
|
+
const shellRule = TABLE_SCROLL_CSS.slice(
|
|
94
|
+
TABLE_SCROLL_CSS.indexOf('.ss-table-shell'),
|
|
95
|
+
TABLE_SCROLL_CSS.indexOf('.ss-table-edge {')
|
|
96
|
+
)
|
|
97
|
+
expect(shellRule).toMatch(/overflow-x:\s*clip/)
|
|
98
|
+
expect(shellRule).toMatch(/min-width:\s*0/)
|
|
99
|
+
// `hidden` is the pre-`clip` fallback and must stay BEHIND `clip`, or every
|
|
100
|
+
// modern engine gets a scroll container again and the rail stops sticking.
|
|
101
|
+
expect(shellRule.indexOf('overflow-x: clip')).toBeGreaterThan(
|
|
102
|
+
shellRule.indexOf('overflow-x: hidden')
|
|
103
|
+
)
|
|
104
|
+
expect(shellRule).not.toMatch(/overflow-x:\s*auto/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The rail is a SECOND horizontal scrollbar, pinned to the bottom of the
|
|
109
|
+
* viewport while the table's own one is off screen — measured 2,741-5,994px
|
|
110
|
+
* below the truncated header on the live tenant. Without `sticky`/`bottom` it
|
|
111
|
+
* is just another bar at the bottom of the table and buys nothing.
|
|
112
|
+
*/
|
|
113
|
+
it('pins the rail to the bottom of the viewport', () => {
|
|
114
|
+
const railRule = TABLE_SCROLL_CSS.slice(TABLE_SCROLL_CSS.indexOf('.ss-table-rail {'))
|
|
115
|
+
expect(railRule).toMatch(/position:\s*sticky/)
|
|
116
|
+
expect(railRule).toMatch(/bottom:\s*0/)
|
|
117
|
+
expect(railRule).toMatch(/overflow-x:\s*auto/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('gives the rail the same custom scrollbar as the scroller it stands in for', () => {
|
|
121
|
+
expect(TABLE_SCROLL_CSS).toContain('.ss-table-rail::-webkit-scrollbar')
|
|
122
|
+
})
|
|
123
|
+
|
|
72
124
|
it('shades the edge hint instead of trying to match an unknowable background', () => {
|
|
73
125
|
const edgeRules = TABLE_SCROLL_CSS.slice(TABLE_SCROLL_CSS.indexOf('.ss-table-edge-right'))
|
|
74
126
|
expect(edgeRules).toMatch(/linear-gradient/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, type RefObject } from 'react'
|
|
3
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The table's share of the scroll affordance (startsim-5pq7h).
|
|
@@ -20,6 +20,8 @@ export {
|
|
|
20
20
|
TABLE_EDGE_CLASS,
|
|
21
21
|
TABLE_EDGE_LEFT_CLASS,
|
|
22
22
|
TABLE_EDGE_RIGHT_CLASS,
|
|
23
|
+
TABLE_RAIL_CLASS,
|
|
24
|
+
TABLE_RAIL_TRACK_CLASS,
|
|
23
25
|
useScrollAffordance,
|
|
24
26
|
} from '../../lib/scroll-affordance'
|
|
25
27
|
|
|
@@ -47,47 +49,226 @@ export function useScrollEdges(
|
|
|
47
49
|
): ScrollEdges {
|
|
48
50
|
const [edges, setEdges] = useState<ScrollEdges>({ left: false, right: false })
|
|
49
51
|
|
|
52
|
+
useScrollerWatch(tableRef, (scroller) => {
|
|
53
|
+
// 1px of slack: sub-pixel column widths leave scrollWidth a hair above
|
|
54
|
+
// clientWidth on tables that visually fit, and a hint under a table that
|
|
55
|
+
// fits is the same defect as a scrollbar under one.
|
|
56
|
+
const max = scroller.scrollWidth - scroller.clientWidth
|
|
57
|
+
const next: ScrollEdges = {
|
|
58
|
+
left: max > 1 && scroller.scrollLeft > 1,
|
|
59
|
+
right: max > 1 && scroller.scrollLeft < max - 1,
|
|
60
|
+
}
|
|
61
|
+
setEdges((prev) =>
|
|
62
|
+
prev.left === next.left && prev.right === next.right ? prev : next
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return edges
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything that can change a table's horizontal-scroll state, in one place.
|
|
71
|
+
*
|
|
72
|
+
* `useScrollEdges` and {@link useScrollRail} both need exactly this set, and
|
|
73
|
+
* both are mounted on every DataTableCore — so they share one watcher rather
|
|
74
|
+
* than each attaching its own ResizeObserver to the same two nodes.
|
|
75
|
+
*
|
|
76
|
+
* Observe the TABLE, not just the scroller. The scroller is `w-full`, so its
|
|
77
|
+
* own box does NOT change when a column is toggled, a filter narrows the data,
|
|
78
|
+
* or a page of rows re-sizes the auto-laid-out columns — only the table's box
|
|
79
|
+
* does. Watching the scroller alone misses every one of those and leaves the
|
|
80
|
+
* affordance stale. `window` scroll is in the set because the rail's whole
|
|
81
|
+
* question — is the real scrollbar off screen? — is answered by where the page
|
|
82
|
+
* is, and nothing else fires when that changes.
|
|
83
|
+
*/
|
|
84
|
+
function useScrollerWatch(
|
|
85
|
+
tableRef: RefObject<HTMLTableElement | null>,
|
|
86
|
+
onUpdate: (scroller: HTMLElement) => void
|
|
87
|
+
): void {
|
|
88
|
+
// The callback is re-created on every render; keeping it in a ref stops the
|
|
89
|
+
// effect from tearing every listener down and rebuilding it each time.
|
|
90
|
+
const latest = useRef(onUpdate)
|
|
91
|
+
latest.current = onUpdate
|
|
92
|
+
|
|
50
93
|
useEffect(() => {
|
|
51
94
|
const table = tableRef.current
|
|
52
95
|
const scroller = table?.parentElement
|
|
53
96
|
if (!table || !scroller) return
|
|
54
97
|
|
|
55
|
-
const update = () =>
|
|
56
|
-
// 1px of slack: sub-pixel column widths leave scrollWidth a hair above
|
|
57
|
-
// clientWidth on tables that visually fit, and a hint under a table that
|
|
58
|
-
// fits is the same defect as a scrollbar under one.
|
|
59
|
-
const max = scroller.scrollWidth - scroller.clientWidth
|
|
60
|
-
const next: ScrollEdges = {
|
|
61
|
-
left: max > 1 && scroller.scrollLeft > 1,
|
|
62
|
-
right: max > 1 && scroller.scrollLeft < max - 1,
|
|
63
|
-
}
|
|
64
|
-
setEdges((prev) =>
|
|
65
|
-
prev.left === next.left && prev.right === next.right ? prev : next
|
|
66
|
-
)
|
|
67
|
-
}
|
|
98
|
+
const update = () => latest.current(scroller)
|
|
68
99
|
|
|
69
100
|
update()
|
|
70
101
|
scroller.addEventListener('scroll', update, { passive: true })
|
|
102
|
+
window.addEventListener('scroll', update, { passive: true })
|
|
103
|
+
window.addEventListener('resize', update)
|
|
71
104
|
|
|
72
105
|
let observer: ResizeObserver | undefined
|
|
73
106
|
if (typeof ResizeObserver !== 'undefined') {
|
|
74
|
-
// Observe the TABLE, not just the scroller. The scroller is `w-full`, so
|
|
75
|
-
// its own box does NOT change when a column is toggled, a filter narrows
|
|
76
|
-
// the data, or a page of rows re-sizes the auto-laid-out columns — only
|
|
77
|
-
// the table's box does. Watching the scroller alone misses every one of
|
|
78
|
-
// those and leaves the hint stale.
|
|
79
107
|
observer = new ResizeObserver(update)
|
|
80
108
|
observer.observe(table)
|
|
81
109
|
observer.observe(scroller)
|
|
82
110
|
}
|
|
83
|
-
window.addEventListener('resize', update)
|
|
84
111
|
|
|
85
112
|
return () => {
|
|
86
113
|
scroller.removeEventListener('scroll', update)
|
|
114
|
+
window.removeEventListener('scroll', update)
|
|
87
115
|
window.removeEventListener('resize', update)
|
|
88
116
|
observer?.disconnect()
|
|
89
117
|
}
|
|
90
118
|
}, [tableRef])
|
|
119
|
+
}
|
|
91
120
|
|
|
92
|
-
|
|
121
|
+
export interface ScrollRail {
|
|
122
|
+
/**
|
|
123
|
+
* Put this on the rail element. It is only rendered while {@link active}, so
|
|
124
|
+
* the ref is null the rest of the time and the sync effect no-ops.
|
|
125
|
+
*/
|
|
126
|
+
railRef: RefObject<HTMLDivElement | null>
|
|
127
|
+
/**
|
|
128
|
+
* Render the rail: columns ARE hidden and the table's own scrollbar is NOT on
|
|
129
|
+
* screen. False on both counts is the overwhelmingly common case, and it puts
|
|
130
|
+
* nothing in the DOM at all.
|
|
131
|
+
*/
|
|
132
|
+
active: boolean
|
|
133
|
+
/**
|
|
134
|
+
* Width for the rail's strut — the table's full `scrollWidth`, so the rail
|
|
135
|
+
* overflows by exactly as much as the scroller does and its thumb is the same
|
|
136
|
+
* size and travels the same distance.
|
|
137
|
+
*/
|
|
138
|
+
contentWidth: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How far below the fold the shell has to end before a rail is worth having.
|
|
143
|
+
*
|
|
144
|
+
* Two numbers, not one, so the rail does not mount and unmount on every wheel
|
|
145
|
+
* notch while the table's bottom edge hovers around the fold: it appears once
|
|
146
|
+
* there is more than a bar's worth of table below the viewport, and stays until
|
|
147
|
+
* the bottom is genuinely on screen.
|
|
148
|
+
*/
|
|
149
|
+
const RAIL_ON = 24
|
|
150
|
+
const RAIL_OFF = 0
|
|
151
|
+
|
|
152
|
+
/** Below this, a scrollLeft difference is sub-pixel rounding, not a scroll. */
|
|
153
|
+
const SCROLL_EPSILON = 1
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A horizontal scrollbar for the table that is where the reader is looking.
|
|
157
|
+
*
|
|
158
|
+
* THE PROBLEM IT SOLVES, and it is not the one startsim-5pq7h solved. That fix
|
|
159
|
+
* made the scroller's bar visible, and it works. But the scroller is the full
|
|
160
|
+
* height of the table — 3,046px on /t/draft, 6,377px on /t/topic — so a bar at
|
|
161
|
+
* its bottom padding edge renders at the bottom of the DOCUMENT: measured
|
|
162
|
+
* 2,741-5,994px below the truncated header on the live marketing-agents tenant
|
|
163
|
+
* (startsim-xe1uo). At the moment a reader can SEE that columns are cut off,
|
|
164
|
+
* there is no scrollbar anywhere on screen.
|
|
165
|
+
*
|
|
166
|
+
* That is survivable on macOS, where a trackpad swipes horizontally anywhere
|
|
167
|
+
* over the table, and it is why nobody on this team ever hit it. A Windows
|
|
168
|
+
* wheel mouse has no horizontal axis at all, so its owner is left with
|
|
169
|
+
* Shift+Wheel (undiscoverable) or dragging a bar three screens down. The edge
|
|
170
|
+
* hint added by 5pq7h — and made visible by startsim-xe1uo — says "there is
|
|
171
|
+
* more to the right"; it does not let anyone GO there.
|
|
172
|
+
*
|
|
173
|
+
* So: a second scrollbar, `position: sticky; bottom: 0`, which floats at the
|
|
174
|
+
* bottom of the viewport for as long as the table's own bar is off screen and
|
|
175
|
+
* settles into place when it is not. It is a real scrollbar, not a proxy
|
|
176
|
+
* widget: an `overflow-x: auto` box holding a strut as wide as the table, so
|
|
177
|
+
* the platform draws and drags it, and its thumb is the same size and travels
|
|
178
|
+
* the same distance as the real one.
|
|
179
|
+
*
|
|
180
|
+
* WHY IT IS NOT A max-height ON THE SCROLLER, which is how a data grid usually
|
|
181
|
+
* does this. `Table` already takes `containerClassName` for exactly that, and
|
|
182
|
+
* the existing tests pass `max-h-96` through it — so bounding the body is
|
|
183
|
+
* already available to any consumer that wants it. Making it the DEFAULT needs
|
|
184
|
+
* a viewport-relative height, and a shared primitive cannot know one: the same
|
|
185
|
+
* table renders inside a card, a dialog, a split pane and a full page across
|
|
186
|
+
* the fleet, and a `max-height` that is right for one is wrong for the rest.
|
|
187
|
+
* It would also convert page scrolling to nested scrolling for every table in
|
|
188
|
+
* eleven apps to fix a bar position. The rail changes no scrolling behaviour at
|
|
189
|
+
* all.
|
|
190
|
+
*
|
|
191
|
+
* SAFE BY DEFAULT, and that is a measurable claim rather than a hope: `active`
|
|
192
|
+
* requires BOTH horizontal overflow AND the shell's bottom below the fold, so
|
|
193
|
+
* on a table that fits, or a table short enough to see the end of, the rail is
|
|
194
|
+
* not in the DOM. `/scrollers` carries `neg-` cases for both and
|
|
195
|
+
* `scripts/measure-scrollers.mjs` fails the run if either grows one.
|
|
196
|
+
*
|
|
197
|
+
* Pass the ref to the `<table>`, as with {@link useScrollEdges} — the scroller
|
|
198
|
+
* is its parent and the shell is the scroller's parent, so nothing may be
|
|
199
|
+
* inserted between them.
|
|
200
|
+
*/
|
|
201
|
+
export function useScrollRail(
|
|
202
|
+
tableRef: RefObject<HTMLTableElement | null>,
|
|
203
|
+
enabled = true
|
|
204
|
+
): ScrollRail {
|
|
205
|
+
const railRef = useRef<HTMLDivElement>(null)
|
|
206
|
+
const [state, setState] = useState<{ active: boolean; contentWidth: number }>({
|
|
207
|
+
active: false,
|
|
208
|
+
contentWidth: 0,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
useScrollerWatch(tableRef, (scroller) => {
|
|
212
|
+
const shell = scroller.parentElement
|
|
213
|
+
if (!shell) return
|
|
214
|
+
// Same 1px of slack as the edge hints: sub-pixel column widths leave
|
|
215
|
+
// scrollWidth a hair above clientWidth on tables that visually fit.
|
|
216
|
+
const hidesColumns = scroller.scrollWidth - scroller.clientWidth > 1
|
|
217
|
+
const box = shell.getBoundingClientRect()
|
|
218
|
+
const fold = window.innerHeight
|
|
219
|
+
// BOTH ENDS MATTER, and only measuring the bottom is the mistake worth
|
|
220
|
+
// recording: a table that has not been reached yet also has its bottom
|
|
221
|
+
// below the fold, so "bottom is off screen" alone mounts a rail on every
|
|
222
|
+
// table further down the page — including short ones that need nothing.
|
|
223
|
+
// Caught by the rail-short case on /scrollers, which grew a rail while it
|
|
224
|
+
// was still 4,000px below the viewport.
|
|
225
|
+
const onScreen = box.top < fold
|
|
226
|
+
const barBelowFold = box.bottom - fold
|
|
227
|
+
setState((prev) => {
|
|
228
|
+
const active =
|
|
229
|
+
enabled &&
|
|
230
|
+
hidesColumns &&
|
|
231
|
+
onScreen &&
|
|
232
|
+
// Hysteresis: a higher bar to appear than to stay, so the rail does not
|
|
233
|
+
// flicker on every wheel notch while the table's bottom edge crosses.
|
|
234
|
+
barBelowFold > (prev.active ? RAIL_OFF : RAIL_ON)
|
|
235
|
+
const contentWidth = active ? scroller.scrollWidth : 0
|
|
236
|
+
return prev.active === active && prev.contentWidth === contentWidth
|
|
237
|
+
? prev
|
|
238
|
+
: { active, contentWidth }
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// Two-way scrollLeft sync. Deliberately NOT guarded by a re-entrancy flag:
|
|
243
|
+
// scroll events fire asynchronously, so a flag set around the assignment is
|
|
244
|
+
// already cleared by the time the event it caused arrives. Comparing the two
|
|
245
|
+
// values instead is self-terminating — the second handler finds them equal
|
|
246
|
+
// and does nothing.
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
const rail = railRef.current
|
|
249
|
+
const scroller = tableRef.current?.parentElement
|
|
250
|
+
if (!rail || !scroller) return
|
|
251
|
+
|
|
252
|
+
rail.scrollLeft = scroller.scrollLeft
|
|
253
|
+
|
|
254
|
+
const fromRail = () => {
|
|
255
|
+
if (Math.abs(scroller.scrollLeft - rail.scrollLeft) > SCROLL_EPSILON) {
|
|
256
|
+
scroller.scrollLeft = rail.scrollLeft
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const fromScroller = () => {
|
|
260
|
+
if (Math.abs(rail.scrollLeft - scroller.scrollLeft) > SCROLL_EPSILON) {
|
|
261
|
+
rail.scrollLeft = scroller.scrollLeft
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
rail.addEventListener('scroll', fromRail, { passive: true })
|
|
266
|
+
scroller.addEventListener('scroll', fromScroller, { passive: true })
|
|
267
|
+
return () => {
|
|
268
|
+
rail.removeEventListener('scroll', fromRail)
|
|
269
|
+
scroller.removeEventListener('scroll', fromScroller)
|
|
270
|
+
}
|
|
271
|
+
}, [tableRef, state.active, state.contentWidth])
|
|
272
|
+
|
|
273
|
+
return { railRef, active: state.active, contentWidth: state.contentWidth }
|
|
93
274
|
}
|
|
@@ -38,6 +38,7 @@ export function UnifiedTable<TData>({
|
|
|
38
38
|
loadingRows = new Set(),
|
|
39
39
|
className,
|
|
40
40
|
emptyState,
|
|
41
|
+
scrollRail,
|
|
41
42
|
errorState,
|
|
42
43
|
}: UnifiedTableProps<TData>) {
|
|
43
44
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -591,6 +592,7 @@ export function UnifiedTable<TData>({
|
|
|
591
592
|
onRowClick={onRowClick}
|
|
592
593
|
loadingRows={loadingRows}
|
|
593
594
|
emptyState={emptyState}
|
|
595
|
+
scrollRail={scrollRail}
|
|
594
596
|
/>
|
|
595
597
|
|
|
596
598
|
{/* Pagination */}
|
package/src/components/unified-table/__tests__/components/DataTableCore.scroll-affordance.test.tsx
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* guard the WIRING and the "nothing overflows -> no decoration" contract; the
|
|
12
12
|
* visual claim is carried by browser measurement, not by them.
|
|
13
13
|
*/
|
|
14
|
-
import { render, screen } from '@testing-library/react'
|
|
14
|
+
import { act, render, screen } from '@testing-library/react'
|
|
15
15
|
import { DataTableCore } from '../../components/DataTableCore'
|
|
16
16
|
import { ColumnConfig } from '../../types'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
TABLE_EDGE_CLASS,
|
|
19
|
+
TABLE_RAIL_CLASS,
|
|
20
|
+
TABLE_RAIL_TRACK_CLASS,
|
|
21
|
+
TABLE_SHELL_CLASS,
|
|
22
|
+
} from '../../../ui/table-scroll'
|
|
18
23
|
|
|
19
24
|
interface Row {
|
|
20
25
|
id: string
|
|
@@ -57,9 +62,32 @@ function stubGeometry(clientWidth: number, scrollWidth: number) {
|
|
|
57
62
|
const edges = (container: HTMLElement) =>
|
|
58
63
|
container.querySelectorAll(`.${TABLE_EDGE_CLASS}`)
|
|
59
64
|
|
|
65
|
+
const rail = (container: HTMLElement) =>
|
|
66
|
+
container.querySelector<HTMLDivElement>(`.${TABLE_RAIL_CLASS}`)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the shell's bottom edge is relative to the fold — the whole question
|
|
70
|
+
* the rail turns on, and the one thing jsdom cannot answer for itself.
|
|
71
|
+
* `bottom` above `window.innerHeight` (768 here) means the table's own
|
|
72
|
+
* scrollbar is off screen.
|
|
73
|
+
*/
|
|
74
|
+
function stubShellBox(top: number, bottom: number) {
|
|
75
|
+
const original = HTMLElement.prototype.getBoundingClientRect
|
|
76
|
+
HTMLElement.prototype.getBoundingClientRect = function () {
|
|
77
|
+
return { top, bottom, left: 0, right: 0, width: 0, height: bottom - top, x: 0, y: top, toJSON: () => ({}) } as DOMRect
|
|
78
|
+
}
|
|
79
|
+
return () => {
|
|
80
|
+
HTMLElement.prototype.getBoundingClientRect = original
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
describe('DataTableCore horizontal-scroll affordance', () => {
|
|
61
85
|
let restore: () => void = () => {}
|
|
62
|
-
|
|
86
|
+
let restoreBox: () => void = () => {}
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
restore()
|
|
89
|
+
restoreBox()
|
|
90
|
+
})
|
|
63
91
|
|
|
64
92
|
it('gives the table a non-scrolling shell for the hints to pin to', () => {
|
|
65
93
|
restore = stubGeometry(846, 846)
|
|
@@ -97,3 +125,117 @@ describe('DataTableCore horizontal-scroll affordance', () => {
|
|
|
97
125
|
expect(found[0]).toHaveAttribute('aria-hidden', 'true')
|
|
98
126
|
})
|
|
99
127
|
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The sticky rail (startsim-xe1uo.1).
|
|
131
|
+
*
|
|
132
|
+
* The edge hints above say columns are hidden. They do not let anyone reach
|
|
133
|
+
* them, and on a Windows wheel mouse — which has no horizontal axis — there was
|
|
134
|
+
* nothing else within 2,741-5,994px, because the scroller is as tall as the
|
|
135
|
+
* whole table and its bar renders at the bottom of the document.
|
|
136
|
+
*
|
|
137
|
+
* jsdom has no layout, so NOTHING here proves a bar is on screen; that claim is
|
|
138
|
+
* carried by examples/workflow-studio/scripts/measure-scrollers.mjs, which
|
|
139
|
+
* measures the rail's rect against the viewport, its bar's height against the
|
|
140
|
+
* platform's, and — the part geometry cannot see — that dragging it moves the
|
|
141
|
+
* table. What these guard is the gate (so the rail is absent whenever the
|
|
142
|
+
* defect is) and the wiring.
|
|
143
|
+
*/
|
|
144
|
+
describe('DataTableCore sticky scroll rail', () => {
|
|
145
|
+
let restore: () => void = () => {}
|
|
146
|
+
let restoreBox: () => void = () => {}
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
restore()
|
|
149
|
+
restoreBox()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const renderCore = (props: Record<string, unknown> = {}) =>
|
|
153
|
+
render(<DataTableCore data={data} columns={columns} getRowId={(r) => r.id} {...props} />)
|
|
154
|
+
|
|
155
|
+
it('renders no rail when every column already fits', () => {
|
|
156
|
+
restore = stubGeometry(846, 846)
|
|
157
|
+
restoreBox = stubShellBox(0, 4000)
|
|
158
|
+
const { container } = renderCore()
|
|
159
|
+
expect(rail(container)).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The gate that a first attempt gets wrong, and did: "the shell's bottom is
|
|
164
|
+
* below the fold" is ALSO true of every table further down the page that
|
|
165
|
+
* nobody has scrolled to yet, and of short ones that need no rail at all.
|
|
166
|
+
* Caught on /scrollers by a two-row case growing a rail from 4,000px away.
|
|
167
|
+
*/
|
|
168
|
+
it('renders no rail when the table has not been reached yet', () => {
|
|
169
|
+
restore = stubGeometry(846, 1200)
|
|
170
|
+
restoreBox = stubShellBox(2000, 4000) // entirely below a 768px viewport
|
|
171
|
+
const { container } = renderCore()
|
|
172
|
+
expect(rail(container)).toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("renders no rail when the table's own scrollbar is already on screen", () => {
|
|
176
|
+
restore = stubGeometry(846, 1200)
|
|
177
|
+
restoreBox = stubShellBox(0, 300) // the whole table fits above the fold
|
|
178
|
+
const { container } = renderCore()
|
|
179
|
+
expect(rail(container)).toBeNull()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('renders a rail when columns are hidden and the real bar is below the fold', () => {
|
|
183
|
+
restore = stubGeometry(846, 1200)
|
|
184
|
+
restoreBox = stubShellBox(0, 4000)
|
|
185
|
+
const { container } = renderCore()
|
|
186
|
+
const bar = rail(container)
|
|
187
|
+
expect(bar).not.toBeNull()
|
|
188
|
+
// A real scrollbar, not a widget imitating one: a strut as wide as the
|
|
189
|
+
// table, so the platform draws the thumb at the right size and drags it.
|
|
190
|
+
const strut = bar!.querySelector<HTMLDivElement>(`.${TABLE_RAIL_TRACK_CLASS}`)
|
|
191
|
+
expect(strut).not.toBeNull()
|
|
192
|
+
expect(strut!.style.width).toBe('1200px')
|
|
193
|
+
// A duplicate control for a region the table itself already exposes to the
|
|
194
|
+
// keyboard, so it is announced to nobody and is not a tab stop.
|
|
195
|
+
expect(bar).toHaveAttribute('aria-hidden', 'true')
|
|
196
|
+
expect(bar).toHaveAttribute('tabindex', '-1')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('can be turned off by a consumer that pins its own control to the viewport', () => {
|
|
200
|
+
restore = stubGeometry(846, 1200)
|
|
201
|
+
restoreBox = stubShellBox(0, 4000)
|
|
202
|
+
const { container } = renderCore({ scrollRail: false })
|
|
203
|
+
expect(rail(container)).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('moves the table when the rail is dragged, and follows the table back', () => {
|
|
207
|
+
restore = stubGeometry(846, 1200)
|
|
208
|
+
restoreBox = stubShellBox(0, 4000)
|
|
209
|
+
const { container } = renderCore()
|
|
210
|
+
const bar = rail(container)!
|
|
211
|
+
const scroller = screen.getByRole('table').parentElement!
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
bar.scrollLeft = 120
|
|
215
|
+
bar.dispatchEvent(new Event('scroll'))
|
|
216
|
+
})
|
|
217
|
+
expect(scroller.scrollLeft).toBe(120)
|
|
218
|
+
|
|
219
|
+
act(() => {
|
|
220
|
+
scroller.scrollLeft = 0
|
|
221
|
+
scroller.dispatchEvent(new Event('scroll'))
|
|
222
|
+
})
|
|
223
|
+
expect(bar.scrollLeft).toBe(0)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The removal this whole fix turns on. `overflow-x-auto` made the shell a
|
|
228
|
+
* scroll container, and `position: sticky` inside a scroll container resolves
|
|
229
|
+
* against IT — which pinned the rail to the bottom of the TABLE, i.e. exactly
|
|
230
|
+
* where the useless bar already was. The clipping and the `min-width: 0` it
|
|
231
|
+
* was also providing now come from `.ss-table-shell` in the injected sheet.
|
|
232
|
+
*/
|
|
233
|
+
it('leaves the shell a non-scroll container, or the rail never reaches the viewport', () => {
|
|
234
|
+
restore = stubGeometry(846, 1200)
|
|
235
|
+
restoreBox = stubShellBox(0, 4000)
|
|
236
|
+
const { container } = renderCore()
|
|
237
|
+
const shell = container.querySelector(`.${TABLE_SHELL_CLASS}`)!
|
|
238
|
+
expect(shell).not.toHaveClass('overflow-x-auto')
|
|
239
|
+
expect(shell).not.toHaveClass('overflow-auto')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -4,10 +4,13 @@ import { ReactNode, useState, useCallback, useRef } from 'react'
|
|
|
4
4
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
|
|
5
5
|
import {
|
|
6
6
|
useScrollEdges,
|
|
7
|
+
useScrollRail,
|
|
7
8
|
TABLE_SHELL_CLASS,
|
|
8
9
|
TABLE_EDGE_CLASS,
|
|
9
10
|
TABLE_EDGE_LEFT_CLASS,
|
|
10
11
|
TABLE_EDGE_RIGHT_CLASS,
|
|
12
|
+
TABLE_RAIL_CLASS,
|
|
13
|
+
TABLE_RAIL_TRACK_CLASS,
|
|
11
14
|
} from '../../../ui/table-scroll'
|
|
12
15
|
import { Checkbox } from '../../../ui/checkbox'
|
|
13
16
|
import { Button } from '../../../ui/button'
|
|
@@ -61,6 +64,15 @@ interface DataTableCoreProps<TData> {
|
|
|
61
64
|
/** Shown in place of the rows when there are none. Defaults to "No results found." */
|
|
62
65
|
emptyState?: ReactNode
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The sticky horizontal scrollbar (startsim-xe1uo.1). On by default: it only
|
|
69
|
+
* renders while columns are hidden AND the table's own bar is off screen, so
|
|
70
|
+
* on everything else it puts nothing in the DOM. Turn it off for a surface
|
|
71
|
+
* that already pins its own control to the bottom of the screen, where two
|
|
72
|
+
* bars would stack.
|
|
73
|
+
*/
|
|
74
|
+
scrollRail?: boolean
|
|
75
|
+
|
|
64
76
|
className?: string
|
|
65
77
|
}
|
|
66
78
|
|
|
@@ -90,6 +102,7 @@ export function DataTableCore<TData>({
|
|
|
90
102
|
onRowClick,
|
|
91
103
|
emptyState,
|
|
92
104
|
loadingRows = new Set(),
|
|
105
|
+
scrollRail = true,
|
|
93
106
|
className,
|
|
94
107
|
}: DataTableCoreProps<TData>) {
|
|
95
108
|
// State for tracking which cell is being edited
|
|
@@ -105,6 +118,9 @@ export function DataTableCore<TData>({
|
|
|
105
118
|
// the very bottom of the page (startsim-5pq7h).
|
|
106
119
|
const tableRef = useRef<HTMLTableElement>(null)
|
|
107
120
|
const scrollEdges = useScrollEdges(tableRef)
|
|
121
|
+
// ...and the hint only SAYS there is more to the right. The rail is how a
|
|
122
|
+
// mouse with no horizontal axis actually gets there (startsim-xe1uo.1).
|
|
123
|
+
const rail = useScrollRail(tableRef, scrollRail)
|
|
108
124
|
|
|
109
125
|
// Drag handlers
|
|
110
126
|
const handleDragStart = useCallback((_: DragStart) => {
|
|
@@ -347,14 +363,21 @@ export function DataTableCore<TData>({
|
|
|
347
363
|
}
|
|
348
364
|
|
|
349
365
|
return (
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
366
|
+
// The non-scrolling shell the edge hints and the rail are pinned to.
|
|
367
|
+
//
|
|
368
|
+
// Its clipping and its `min-width: 0` now come from `.ss-table-shell` in
|
|
369
|
+
// the injected sheet, NOT from a Tailwind `overflow-x-auto` here. That
|
|
370
|
+
// class was doing three jobs by accident, one of which was making the shell
|
|
371
|
+
// a scroll container — and `position: sticky` inside a scroll container
|
|
372
|
+
// resolves against IT, which pinned the rail to the bottom of the table
|
|
373
|
+
// instead of the bottom of the screen. See lib/scroll-affordance.ts for the
|
|
374
|
+
// measurements and for why `clip` alone was not enough.
|
|
375
|
+
//
|
|
376
|
+
// NOTE: this div is not the element that scrolls — `Table`'s own
|
|
353
377
|
// `overflow-auto` wrapper inside it is, because that wrapper is `w-full`
|
|
354
378
|
// and so never makes this one overflow. That is why the scrollbar styling
|
|
355
|
-
// and the ref below both target the inner wrapper.
|
|
356
|
-
|
|
357
|
-
<div className={cn('rounded-md border border-border overflow-x-auto', TABLE_SHELL_CLASS, className)}>
|
|
379
|
+
// and the ref below both target the inner wrapper.
|
|
380
|
+
<div className={cn('rounded-md border border-border', TABLE_SHELL_CLASS, className)}>
|
|
358
381
|
<Table ref={tableRef}>
|
|
359
382
|
{renderColumnHeaders()}
|
|
360
383
|
|
|
@@ -505,6 +528,23 @@ export function DataTableCore<TData>({
|
|
|
505
528
|
{scrollEdges.right && (
|
|
506
529
|
<div aria-hidden="true" className={cn(TABLE_EDGE_CLASS, TABLE_EDGE_RIGHT_CLASS)} />
|
|
507
530
|
)}
|
|
531
|
+
|
|
532
|
+
{/* The sticky scrollbar. A real `overflow-x: auto` box holding a strut as
|
|
533
|
+
wide as the table, so the platform draws and drags it — not a widget
|
|
534
|
+
imitating one. Rendered only while columns are hidden AND the table's
|
|
535
|
+
own bar is off screen; `aria-hidden` and out of the tab order because
|
|
536
|
+
it is a duplicate control for a region that is already keyboard
|
|
537
|
+
scrollable through the table itself. */}
|
|
538
|
+
{rail.active && (
|
|
539
|
+
<div
|
|
540
|
+
ref={rail.railRef}
|
|
541
|
+
aria-hidden="true"
|
|
542
|
+
tabIndex={-1}
|
|
543
|
+
className={TABLE_RAIL_CLASS}
|
|
544
|
+
>
|
|
545
|
+
<div className={TABLE_RAIL_TRACK_CLASS} style={{ width: rail.contentWidth }} />
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
508
548
|
</div>
|
|
509
549
|
)
|
|
510
550
|
}
|
|
@@ -67,6 +67,13 @@ export interface UnifiedTableProps<TData> {
|
|
|
67
67
|
className?: string
|
|
68
68
|
emptyState?: ReactNode
|
|
69
69
|
errorState?: ReactNode
|
|
70
|
+
/**
|
|
71
|
+
* The sticky horizontal scrollbar (startsim-xe1uo.1), on by default. It only
|
|
72
|
+
* renders while columns are hidden AND the table's own scrollbar is off
|
|
73
|
+
* screen; set false on a surface that already pins its own control to the
|
|
74
|
+
* bottom of the viewport.
|
|
75
|
+
*/
|
|
76
|
+
scrollRail?: boolean
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
// Column Configuration
|