@@ -41,6 +41,17 @@ export interface TableColumn<Row> {
4141 * scrolling content (#586 — column-lock / frozen-cols feature).
4242 */
4343 sticky ?: boolean ;
44+ /**
45+ * Render this column's cell as a link to the row's detail/change page
46+ * (the `rowHref` target). Mirrors Django's `list_display_links` (#666):
47+ * the ModelAdmin chooses which column(s) link, not the SPA. When NO
48+ * column sets this, the table renders no cell links and rows are not
49+ * clickable (matching `list_display_links = None`). Falls back to a
50+ * sensible default (the first non-selection column) only when the caller
51+ * leaves every column's `isLink` unset AND `rowHref` is provided — see
52+ * the Table body for the back-compat path.
53+ */
54+ isLink ?: boolean ;
4455}
4556
4657export interface TableProps < Row > {
@@ -97,8 +108,27 @@ export interface TableProps<Row> {
97108 * table keeps roughly its prior height.
98109 */
99110 skeletonRows ?: number ;
111+ /**
112+ * Row virtualization for very long lists (#670 — the `?all` "Show all N"
113+ * path renders up to `list_max_show_all`, 200 by default, rows at once).
114+ * When set, each body row gets `content-visibility: auto` +
115+ * `contain-intrinsic-size`, so the browser skips layout/paint for
116+ * off-screen rows (native windowing) while keeping them in the DOM for
117+ * Ctrl-F / a11y / native scroll. Cheaper and far less fragile than a
118+ * JS windowing library given the sticky-column offset measurement this
119+ * primitive already does. Off (undefined) for normal paginated lists,
120+ * which are short enough that the optimisation isn't worth the
121+ * intrinsic-size estimate.
122+ */
123+ virtualizeRows ?: boolean ;
100124}
101125
126+ // Estimated row height for `contain-intrinsic-size` when virtualizing
127+ // (#670). Only an estimate — it sets the placeholder size for an
128+ // off-screen, not-yet-laid-out row so the scrollbar stays proportional;
129+ // the real height is used once the row scrolls into view.
130+ const ESTIMATED_ROW_HEIGHT_PX = 41 ;
131+
102132const ALIGN_CLASSES = {
103133 left : 'text-left' ,
104134 right : 'text-right' ,
@@ -123,6 +153,7 @@ export function Table<Row>({
123153 skeletonRows,
124154 columnWidths,
125155 onColumnResize,
156+ virtualizeRows = false ,
126157} : TableProps < Row > ) {
127158 // Hooks must run unconditionally and in stable order on every
128159 // render — the empty-state early-return below has to come AFTER
@@ -189,6 +220,26 @@ export function Table<Row>({
189220 const hasWidths = columnWidths != null && Object . keys ( columnWidths ) . length > 0 ;
190221 const resizable = onColumnResize != null ;
191222
223+ // list_display_links (#666): which columns render their cell as a link to
224+ // `rowHref(row)`. The caller (ListPage) sets `isLink` per the wire's
225+ // `list_display_links`. Back-compat: when NO column declares `isLink` at
226+ // all, fall back to linking the first column (the historic behaviour) so
227+ // existing callers that don't pass the flag keep their links. When the
228+ // caller DOES set `isLink` on some column(s), we honour exactly those —
229+ // and when it explicitly sets none-to-true (e.g. `list_display_links =
230+ // None`), no cell links and the rows are not clickable.
231+ const anyExplicitLink = columns . some ( ( c ) => c . isLink !== undefined ) ;
232+ const isLinkCol = ( col : TableColumn < Row > , index : number ) : boolean => {
233+ if ( ! rowHref ) return false ;
234+ if ( anyExplicitLink ) return col . isLink === true ;
235+ return index === 0 ; // legacy default
236+ } ;
237+ // When the caller explicitly links NO column (`list_display_links = None`
238+ // → every column carries `isLink: false`), the rows are inert. Otherwise
239+ // behaviour is unchanged: the row is clickable when `onRowClick` is set.
240+ const linksDisabled = anyExplicitLink && ! columns . some ( ( c ) => c . isLink === true ) ;
241+ const rowClickActive = linksDisabled ? undefined : onRowClick ;
242+
192243 // Helpers to build the sticky `<th>` / `<td>` style + className.
193244 // `style.left` is set so the browser knows where to pin during
194245 // horizontal scroll; the cell needs a background colour so content
@@ -371,13 +422,24 @@ export function Table<Row>({
371422 return (
372423 < tr
373424 key = { key }
374- onClick = { onRowClick ? ( ) => onRowClick ( row ) : undefined }
425+ onClick = { rowClickActive ? ( ) => rowClickActive ( row ) : undefined }
375426 // `data-selected` propagates the checkbox state to
376427 // both the row's bg AND the frozen-cells' bg via
377428 // the `--dar-row-bg` custom property (apps/web/
378429 // src/index.css #613).
379430 data-selected = { isSelected ? 'true' : undefined }
380- className = { onRowClick ? 'cursor-pointer hover:bg-gray-50' : '' }
431+ // Native row windowing on the show-all path (#670): skip
432+ // layout/paint for off-screen rows, keeping them in the
433+ // DOM for find-in-page / a11y / native scroll.
434+ style = {
435+ virtualizeRows
436+ ? {
437+ contentVisibility : 'auto' ,
438+ containIntrinsicSize : `auto ${ ESTIMATED_ROW_HEIGHT_PX } px` ,
439+ }
440+ : undefined
441+ }
442+ className = { rowClickActive ? 'cursor-pointer hover:bg-gray-50' : '' }
381443 >
382444 { selectable && (
383445 < td
@@ -414,10 +476,12 @@ export function Table<Row>({
414476 : 'max-w-[16rem] truncate'
415477 }
416478 >
417- { ci === 0 && rowHref ? (
418- // Real anchor on the first cell so the browser's
419- // native open-in-new-tab works (#253); a plain
420- // left-click is intercepted for in-app nav.
479+ { isLinkCol ( col , ci ) && rowHref ? (
480+ // Real anchor on each list_display_links column so
481+ // the browser's native open-in-new-tab works
482+ // (#253); a plain left-click is intercepted for
483+ // in-app nav. Which column(s) link is the
484+ // ModelAdmin's choice (#666).
421485 < a
422486 href = { rowHref ( row ) }
423487 className = "text-inherit no-underline hover:underline"
0 commit comments