Skip to content

Commit 13042e3

Browse files
authored
chore: Improve search table discoverability in RecSpace (#1762)
* chore: Improve table discoverability * chore: Remove fade next to sticky columns * chore: Fix sonarqube issues * chore: Clean css selectors
1 parent 30eb93f commit 13042e3

6 files changed

Lines changed: 732 additions & 43 deletions

File tree

admin/frontend/src/pages/search/components/SearchResultsTable.scss

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,115 @@
11
@import '@/styles/variables.scss';
22

3+
// Frozen columns (must match STICKY_COLUMN_IDS in searchDefinitions.ts). Their
4+
// left offsets are measured at runtime and exposed as --sticky-left-<id> vars.
5+
$stickyColumnIds: rec_resource_id, name;
6+
$scrollbarGap: 0.25rem;
7+
$edgeFadeWidth: 2.5rem;
8+
$scrollbarThickness: 0.85rem;
9+
310
.search-results-table {
411
position: relative;
512
max-width: 100%;
6-
overflow-x: auto;
13+
14+
// Second (top) scrollbar — an always-visible scrollbar over an empty spacer
15+
// sized to the table width, kept in sync with the real bottom scroller in JS.
16+
&__top-scroll {
17+
height: $scrollbarThickness;
18+
margin-bottom: $scrollbarGap;
19+
}
20+
21+
&__top-scroll-spacer {
22+
height: 1px;
23+
}
24+
25+
// Wraps the scrolling viewport so the edge-fade overlays can be anchored to it
26+
// (and not to the whole component, which includes the top scrollbar).
27+
&__body {
28+
position: relative;
29+
}
30+
31+
&__viewport {
32+
max-width: 100%;
33+
}
34+
35+
// Both horizontal scrollbars: shared overflow + always-visible styling.
36+
// `appearance: none` opts the WebKit scrollbar out of macOS's auto-hiding
37+
// overlay behaviour, rendering the classic always-visible style instead — still
38+
// the browser's own scrollbar, not a custom DOM element. We deliberately do NOT
39+
// set the standard `scrollbar-width` / `scrollbar-color`: when either is present
40+
// Chromium ignores `::-webkit-scrollbar` and falls back to a thin overlay bar
41+
// that still auto-hides on macOS.
42+
&__top-scroll,
43+
&__viewport {
44+
overflow-x: auto;
45+
overflow-y: hidden;
46+
47+
&::-webkit-scrollbar {
48+
appearance: none;
49+
-webkit-appearance: none;
50+
height: $scrollbarThickness;
51+
}
52+
53+
&::-webkit-scrollbar-track {
54+
background: $colorBackgroundGrey;
55+
border-radius: 999px;
56+
}
57+
58+
&::-webkit-scrollbar-thumb {
59+
background: rgba($colorGreyMed, 0.5);
60+
border: 3px solid $colorBackgroundGrey;
61+
border-radius: 999px;
62+
}
63+
64+
&::-webkit-scrollbar-thumb:hover {
65+
background: rgba($colorGreyMed, 0.75);
66+
}
67+
}
68+
69+
// Edge indicators — non-scrolling white edge fades anchored to the body. The
70+
// right fade covers the right edge on every screen size; the left fade is used
71+
// on mobile (no frozen columns). On desktop the frozen-column boundary is
72+
// marked by the freeze divider (below) instead.
73+
&__fade {
74+
position: absolute;
75+
top: 0;
76+
// Stop above the bottom scrollbar (which reserves $scrollbarThickness at the
77+
// foot of the viewport) so the fades never paint over it — otherwise the bar
78+
// looks broken where a fade crosses it.
79+
bottom: $scrollbarThickness;
80+
width: $edgeFadeWidth;
81+
pointer-events: none;
82+
opacity: 0;
83+
transition: opacity 0.15s ease;
84+
z-index: 4;
85+
86+
&--right {
87+
right: 0;
88+
background: linear-gradient(to right, rgba($colorWhite, 0), $colorWhite);
89+
}
90+
91+
&--left {
92+
left: 0;
93+
background: linear-gradient(to left, rgba($colorWhite, 0), $colorWhite);
94+
}
95+
}
96+
97+
// Freeze-boundary divider (desktop): a stronger line at the right edge of the
98+
// frozen columns, positioned at their measured total width. Drawn as an overlay
99+
// rather than a cell border, which does not paint reliably on sticky cells in a
100+
// border-collapse table.
101+
&__freeze-divider {
102+
position: absolute;
103+
top: 0;
104+
bottom: $scrollbarThickness;
105+
left: calc(var(--sticky-total-width, 0) - 1px);
106+
width: 1px;
107+
background: rgba($colorGreyMed, 0.7);
108+
pointer-events: none;
109+
opacity: 0;
110+
transition: opacity 0.15s ease;
111+
z-index: 4;
112+
}
7113

8114
&__status {
9115
min-width: 100%;
@@ -14,6 +120,29 @@
14120
}
15121
}
16122

123+
// Reveal the edge indicators based on the scroll-state classes the hook sets on
124+
// the wrapper. Written as explicit class selectors (rather than nested `&…&…`)
125+
// so each selector carries its own scoping root.
126+
.search-results-table--scroll-right .search-results-table__fade--right {
127+
opacity: 1;
128+
}
129+
130+
// Left fade only below the desktop breakpoint — matches where freezing is off.
131+
@media (max-width: ($mdBreakpoint - 0.02px)) {
132+
.search-results-table--scroll-left .search-results-table__fade--left {
133+
opacity: 1;
134+
}
135+
}
136+
137+
// Freeze divider only at/above the desktop breakpoint (where columns freeze),
138+
// and only while the table can actually scroll horizontally.
139+
@media (min-width: $mdBreakpoint) {
140+
.search-results-table--scroll-left .search-results-table__freeze-divider,
141+
.search-results-table--scroll-right .search-results-table__freeze-divider {
142+
opacity: 1;
143+
}
144+
}
145+
17146
.results-table {
18147
width: 100%;
19148
min-width: 100%;
@@ -110,3 +239,26 @@
110239
min-height: 100%;
111240
}
112241
}
242+
243+
// Freeze the first columns from the desktop breakpoint up. On mobile only a
244+
// couple of columns fit at once, so freezing them is intentionally disabled.
245+
@media (min-width: $mdBreakpoint) {
246+
.results-table {
247+
@each $id in $stickyColumnIds {
248+
td.results-table__column--#{$id},
249+
th.results-table__column--#{$id} {
250+
position: sticky;
251+
left: var(--sticky-left-#{$id}, 0);
252+
}
253+
254+
// Lift frozen cells above the scrolling cells (headers above bodies).
255+
td.results-table__column--#{$id} {
256+
z-index: 1;
257+
}
258+
259+
th.results-table__column--#{$id} {
260+
z-index: 3;
261+
}
262+
}
263+
}
264+
}
Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { flexRender } from '@tanstack/react-table';
2+
import clsx from 'clsx';
23
import type { AdminSearchColumnId } from '@/pages/search/constants';
34
import type { SearchResultsPaginationModel } from '@/pages/search/hooks/useAdminSearchController';
45
import { useSearchResultsTable } from '@/pages/search/hooks/useSearchResultsTable';
6+
import { useSearchResultsTableScroll } from '@/pages/search/hooks/useSearchResultsTableScroll';
57
import type {
68
AdminSearchResultRow,
79
AdminSearchRouteState,
@@ -36,52 +38,101 @@ export const SearchResultsTable = ({
3638
});
3739
const hasRows = tableRows.length > 0;
3840

41+
const {
42+
wrapperRef,
43+
topScrollRef,
44+
topSpacerRef,
45+
viewportRef,
46+
tableRef,
47+
canScrollLeft,
48+
canScrollRight,
49+
} = useSearchResultsTableScroll(
50+
`${visibleColumns.join(',')}|${tableRows.length}|${isLoading}`,
51+
);
52+
3953
return (
40-
<div className="search-results-table">
41-
<table className="results-table">
42-
<thead>
43-
{table.getHeaderGroups().map((headerGroup) => (
44-
<tr key={headerGroup.id}>
45-
{headerGroup.headers.map((header) => (
46-
<th
47-
key={header.id}
48-
className={`results-table__column results-table__column--${header.column.id}`}
49-
>
50-
<div className="results-table__header">
51-
{header.isPlaceholder
52-
? null
53-
: flexRender(
54-
header.column.columnDef.header,
55-
header.getContext(),
56-
)}
57-
</div>
58-
</th>
54+
<div
55+
ref={wrapperRef}
56+
className={clsx('search-results-table', {
57+
'search-results-table--scroll-left': canScrollLeft,
58+
'search-results-table--scroll-right': canScrollRight,
59+
})}
60+
>
61+
<div
62+
ref={topScrollRef}
63+
className="search-results-table__top-scroll"
64+
aria-hidden="true"
65+
>
66+
<div
67+
ref={topSpacerRef}
68+
className="search-results-table__top-scroll-spacer"
69+
/>
70+
</div>
71+
72+
<div className="search-results-table__body">
73+
<div ref={viewportRef} className="search-results-table__viewport">
74+
<table ref={tableRef} className="results-table">
75+
<thead>
76+
{table.getHeaderGroups().map((headerGroup) => (
77+
<tr key={headerGroup.id}>
78+
{headerGroup.headers.map((header) => (
79+
<th
80+
key={header.id}
81+
className={`results-table__column results-table__column--${header.column.id}`}
82+
>
83+
<div className="results-table__header">
84+
{header.isPlaceholder
85+
? null
86+
: flexRender(
87+
header.column.columnDef.header,
88+
header.getContext(),
89+
)}
90+
</div>
91+
</th>
92+
))}
93+
</tr>
5994
))}
60-
</tr>
61-
))}
62-
</thead>
63-
{hasRows ? (
64-
<tbody>
65-
{tableRows.map((row) => (
66-
<tr key={row.id} {...getRowInteractionProps(row)}>
67-
{row.getVisibleCells().map((cell) => (
68-
<td
69-
key={cell.id}
70-
className={`results-table__column results-table__column--${cell.column.id}`}
71-
>
72-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
73-
</td>
95+
</thead>
96+
{hasRows ? (
97+
<tbody>
98+
{tableRows.map((row) => (
99+
<tr key={row.id} {...getRowInteractionProps(row)}>
100+
{row.getVisibleCells().map((cell) => (
101+
<td
102+
key={cell.id}
103+
className={`results-table__column results-table__column--${cell.column.id}`}
104+
>
105+
{flexRender(
106+
cell.column.columnDef.cell,
107+
cell.getContext(),
108+
)}
109+
</td>
110+
))}
111+
</tr>
74112
))}
75-
</tr>
76-
))}
77-
</tbody>
78-
) : null}
79-
</table>
80-
{hasRows ? null : (
81-
<div className="search-results-table__status text-center text-muted">
82-
{statusMessage}
113+
</tbody>
114+
) : null}
115+
</table>
116+
{hasRows ? null : (
117+
<div className="search-results-table__status text-center text-muted">
118+
{statusMessage}
119+
</div>
120+
)}
83121
</div>
84-
)}
122+
123+
<span
124+
className="search-results-table__fade search-results-table__fade--left"
125+
aria-hidden="true"
126+
/>
127+
<span
128+
className="search-results-table__freeze-divider"
129+
aria-hidden="true"
130+
/>
131+
<span
132+
className="search-results-table__fade search-results-table__fade--right"
133+
aria-hidden="true"
134+
/>
135+
</div>
85136
</div>
86137
);
87138
};

0 commit comments

Comments
 (0)