@startsimpli/ui 0.4.121 → 0.4.123
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 +4 -4
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.123",
|
|
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/
|
|
51
|
-
"@startsimpli/
|
|
52
|
-
"@startsimpli/
|
|
50
|
+
"@startsimpli/api": "^0.5.59",
|
|
51
|
+
"@startsimpli/auth": "^0.4.35",
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -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
|
|
@@ -20,6 +20,13 @@ export const TABLE_SHELL_CLASS = 'ss-table-shell'
|
|
|
20
20
|
export const TABLE_EDGE_CLASS = 'ss-table-edge'
|
|
21
21
|
export const TABLE_EDGE_LEFT_CLASS = 'ss-table-edge-left'
|
|
22
22
|
export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
|
|
23
|
+
/**
|
|
24
|
+
* A second, sticky horizontal scrollbar for the table, pinned to the bottom of
|
|
25
|
+
* the viewport while the real one is off screen (startsim-xe1uo.1).
|
|
26
|
+
*/
|
|
27
|
+
export const TABLE_RAIL_CLASS = 'ss-table-rail'
|
|
28
|
+
/** The rail's only child: a strut as wide as the table, so the rail overflows. */
|
|
29
|
+
export const TABLE_RAIL_TRACK_CLASS = 'ss-table-rail-track'
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
32
|
* The edge hint SHADES its surface; it must never try to MATCH it.
|
|
@@ -63,6 +70,26 @@ export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
|
|
|
63
70
|
const EDGE_SHADE = 'hsl(var(--foreground) / 0.14)'
|
|
64
71
|
const EDGE_SHADE_NONE = 'hsl(var(--foreground) / 0)'
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The rail's bar is LOUDER than the in-gutter one, on purpose.
|
|
75
|
+
*
|
|
76
|
+
* The shared thumb sits in a gutter below the table, on bare surface, and 22%
|
|
77
|
+
* of `currentColor` is enough to read there. The rail floats OVER the last rows
|
|
78
|
+
* of the table with a transparent track, so at 22% it competes with row text
|
|
79
|
+
* and, on the routes this was built for, is the only thing a mouse user can
|
|
80
|
+
* grab. 45% is the value the shared rule already uses on `:hover`, so this is
|
|
81
|
+
* the same ink, not a new colour, and it inverts with the theme like the rest.
|
|
82
|
+
* The faint track makes the rail read as a bar rather than as a stray thumb.
|
|
83
|
+
*
|
|
84
|
+
* This does NOT supersede startsim-xe1uo.2, which is about the 10px height the
|
|
85
|
+
* shared rule takes from the platform's ~15px classic bar on Windows/Linux --
|
|
86
|
+
* that trade only gets more load-bearing now that the rail is the grab target,
|
|
87
|
+
* and it wants the runtime overlay/classic probe described on that bead, not a
|
|
88
|
+
* different alpha.
|
|
89
|
+
*/
|
|
90
|
+
const RAIL_THUMB = 'color-mix(in srgb, currentColor 45%, transparent)'
|
|
91
|
+
const RAIL_TRACK = 'color-mix(in srgb, currentColor 8%, transparent)'
|
|
92
|
+
|
|
66
93
|
/**
|
|
67
94
|
* Content scrolls, but on macOS nothing says so (startsim-5pq7h, 5pq7h.1).
|
|
68
95
|
*
|
|
@@ -130,6 +157,15 @@ const EDGE_SHADE_NONE = 'hsl(var(--foreground) / 0)'
|
|
|
130
157
|
* Nothing here animates or transitions, so `prefers-reduced-motion` has nothing
|
|
131
158
|
* to honour.
|
|
132
159
|
*
|
|
160
|
+
* THE BAR BEING VISIBLE IS NOT THE SAME AS THE BAR BEING REACHABLE. The
|
|
161
|
+
* scroller is as tall as the whole table, so the bar it grows sits at the
|
|
162
|
+
* BOTTOM OF THE DOCUMENT -- measured 2,741-5,994px below the truncated header
|
|
163
|
+
* on the marketing-agents tenant (startsim-xe1uo). A macOS trackpad swipes
|
|
164
|
+
* horizontally anywhere over the table, so nobody here ever noticed; a Windows
|
|
165
|
+
* wheel mouse has no horizontal axis, so its owner's only options were
|
|
166
|
+
* Shift+Wheel or a scrollbar three screens down. `.ss-table-rail` is the answer
|
|
167
|
+
* to that half -- see `useScrollRail` in components/ui/table-scroll.ts.
|
|
168
|
+
*
|
|
133
169
|
* ONE stylesheet, ONE id, ONE string: `ensureInjectedStyle` rewrites the sheet
|
|
134
170
|
* when an existing id carries different text, so two call sites shipping
|
|
135
171
|
* different CSS under this id would overwrite each other on every mount. Add
|
|
@@ -137,6 +173,7 @@ const EDGE_SHADE_NONE = 'hsl(var(--foreground) / 0)'
|
|
|
137
173
|
*/
|
|
138
174
|
export const SCROLL_AFFORDANCE_CSS = `
|
|
139
175
|
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar,
|
|
176
|
+
.${TABLE_RAIL_CLASS}::-webkit-scrollbar,
|
|
140
177
|
.${SCROLLER_CLASS}::-webkit-scrollbar {
|
|
141
178
|
height: 10px;
|
|
142
179
|
width: 10px;
|
|
@@ -146,16 +183,50 @@ export const SCROLL_AFFORDANCE_CSS = `
|
|
|
146
183
|
background: transparent;
|
|
147
184
|
}
|
|
148
185
|
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb,
|
|
186
|
+
.${TABLE_RAIL_CLASS}::-webkit-scrollbar-thumb,
|
|
149
187
|
.${SCROLLER_CLASS}::-webkit-scrollbar-thumb {
|
|
150
188
|
border-radius: 9999px;
|
|
151
189
|
background-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
152
190
|
}
|
|
153
191
|
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover,
|
|
192
|
+
.${TABLE_RAIL_CLASS}::-webkit-scrollbar-thumb:hover,
|
|
154
193
|
.${SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover {
|
|
155
194
|
background-color: color-mix(in srgb, currentColor 45%, transparent);
|
|
156
195
|
}
|
|
157
196
|
.${TABLE_SHELL_CLASS} {
|
|
158
197
|
position: relative;
|
|
198
|
+
/*
|
|
199
|
+
* min-width and overflow-x do two different jobs here, and the shell used to
|
|
200
|
+
* get BOTH of them by accident from a single Tailwind overflow-x-auto in
|
|
201
|
+
* DataTableCore. That class had to go, because a scroll container is a
|
|
202
|
+
* scrollport and position:sticky inside one resolves against IT, not the
|
|
203
|
+
* viewport -- which pinned the rail below to the bottom of the TABLE instead
|
|
204
|
+
* of the bottom of the SCREEN.
|
|
205
|
+
*
|
|
206
|
+
* MEASURED 2026-09-09, Chromium, a shell holding a 1600px table inside a
|
|
207
|
+
* 400px flex line, scrolled so the shell's bottom is 1.4k px below the fold:
|
|
208
|
+
*
|
|
209
|
+
* shell overflow-x computed overflow-y shell width sticky rail bottom
|
|
210
|
+
* auto (what shipped) auto 400 2022 (off screen)
|
|
211
|
+
* hidden auto 400 2022 (off screen)
|
|
212
|
+
* clip visible 1602 <-- blows out the flex line
|
|
213
|
+
* clip + min-width:0 visible 400 614 (= viewport)
|
|
214
|
+
*
|
|
215
|
+
* So clip is the one overflow value that clips without becoming a scrollport
|
|
216
|
+
* -- but it does NOT bring the automatic minimum size with it. min-width:auto
|
|
217
|
+
* resolves to zero only for a SCROLL CONTAINER, and a clip container is not
|
|
218
|
+
* one, so the shell stopped shrinking and pushed its flex parent to 1602px.
|
|
219
|
+
* Hence both declarations, and neither is optional: drop min-width and wide
|
|
220
|
+
* tables blow out their layout, drop clip and the rail never reaches the
|
|
221
|
+
* viewport.
|
|
222
|
+
*
|
|
223
|
+
* hidden first is the fallback for engines without clip (Safari < 16): they
|
|
224
|
+
* keep today's clipping and today's non-sticky rail, rather than letting the
|
|
225
|
+
* table escape the shell's rounded border.
|
|
226
|
+
*/
|
|
227
|
+
min-width: 0;
|
|
228
|
+
overflow-x: hidden;
|
|
229
|
+
overflow-x: clip;
|
|
159
230
|
}
|
|
160
231
|
.${TABLE_EDGE_CLASS} {
|
|
161
232
|
position: absolute;
|
|
@@ -172,6 +243,22 @@ export const SCROLL_AFFORDANCE_CSS = `
|
|
|
172
243
|
left: 0;
|
|
173
244
|
background-image: linear-gradient(to right, ${EDGE_SHADE}, ${EDGE_SHADE_NONE});
|
|
174
245
|
}
|
|
246
|
+
.${TABLE_RAIL_CLASS} {
|
|
247
|
+
position: sticky;
|
|
248
|
+
bottom: 0;
|
|
249
|
+
z-index: 1;
|
|
250
|
+
overflow-x: auto;
|
|
251
|
+
overflow-y: hidden;
|
|
252
|
+
}
|
|
253
|
+
.${TABLE_RAIL_TRACK_CLASS} {
|
|
254
|
+
height: 1px;
|
|
255
|
+
}
|
|
256
|
+
.${TABLE_RAIL_CLASS}::-webkit-scrollbar-track {
|
|
257
|
+
background-color: ${RAIL_TRACK};
|
|
258
|
+
}
|
|
259
|
+
.${TABLE_RAIL_CLASS}::-webkit-scrollbar-thumb {
|
|
260
|
+
background-color: ${RAIL_THUMB};
|
|
261
|
+
}
|
|
175
262
|
`.trim()
|
|
176
263
|
|
|
177
264
|
/**
|