@startsimpli/ui 0.4.121 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.121",
3
+ "version": "0.4.122",
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/i18n": "^0.1.3",
51
50
  "@startsimpli/api": "^0.5.58",
52
- "@startsimpli/auth": "^0.4.35"
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
- return edges
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 */}
@@ -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 { TABLE_EDGE_CLASS, TABLE_SHELL_CLASS } from '../../../ui/table-scroll'
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
- afterEach(() => restore())
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
- // overflow-x-auto: a wide table scrolls horizontally inside its own border
351
- // instead of overflowing the page to the right (startsim column-overflow).
352
- // NOTE: this div is not the element that ends up scrolling — `Table`'s own
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. 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)}>
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
  /**