Skip to content

TableWidget

TableWidget wraps NiceGUI ui.aggrid with stable string row ids, selection, optional editing, context menus, and optional AG Grid Enterprise row grouping.

TableWidget demo

Embed

from nicegui import ui
from nicewidgets.table_widget.column_def import ColumnDef
from nicewidgets.table_widget.config import TableWidgetConfig
from nicewidgets.table_widget.table_widget import TableWidget

table = TableWidget(
    [
        ColumnDef(field='path', headerName='Path'),
        ColumnDef(field='category', headerName='Category'),
    ],
    'path',
    [
        {'path': '/a.tif', 'category': 'Images'},
        {'path': '/b.csv', 'category': 'Tables'},
    ],
    config=TableWidgetConfig(
        selection_mode='single',
        show_index_column=True,
        row_group_fields=('category',),
    ),
    on_row_selected=lambda row: print(row),
)
with ui.column().classes('w-full').style('height: 24rem;'):
    table.build()
table.set_dark_mode(False)

Non-empty row_group_fields loads AG Grid Enterprise. NiceWidgets does not ship a production Enterprise license.

Theme: set_theme / set_dark_mode set AG Grid data-ag-theme-mode.

Demo: examples/table_widget/ (also /table in the combined demo).

Configuration

TableWidgetConfig covers selection, editing hooks, index column, row/header heights, grouping, and Enterprise module URL.

API

nicewidgets.table_widget.table_widget.TableWidget

NiceGUI AG Grid wrapper with selection, editing, grouping, and context menus.

The widget uses AG Grid Community by default. Setting :attr:TableWidgetConfig.row_group_fields opts into AG Grid Enterprise and groups rows by those column values in the given order. NiceWidgets loads the module but does not provide an AG Grid Enterprise production license.

Theme: call :meth:set_theme / :meth:set_dark_mode to drive AG Grid's data-ag-theme-mode (light/dark), matching the Plotly widget theme API.

Source code in src/nicewidgets/table_widget/table_widget.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
class TableWidget:
    """NiceGUI AG Grid wrapper with selection, editing, grouping, and context menus.

    The widget uses AG Grid Community by default. Setting
    :attr:`TableWidgetConfig.row_group_fields` opts into AG Grid Enterprise and
    groups rows by those column values in the given order. NiceWidgets loads
    the module but does not provide an AG Grid Enterprise production license.

    Theme: call :meth:`set_theme` / :meth:`set_dark_mode` to drive AG Grid's
    ``data-ag-theme-mode`` (light/dark), matching the Plotly widget theme API.
    """

    def __init__(
        self,
        columns: Sequence[ColumnDef],
        row_id_field: str,
        rows: Sequence[Mapping[str, Any]] | None = None,
        *,
        on_row_selected: Callable[[dict[str, Any]], None] | None = None,
        on_cell_edited: Callable[[str, str, Any, Any, dict[str, Any]], None] | None = None,
        on_build_context_menu: Callable[[TableWidget], None] | None = None,
        config: TableWidgetConfig | None = None,
        grid_options: Mapping[str, Any] | None = None,
    ) -> None:
        validate_row_id_field(row_id_field)
        self._row_id_field = row_id_field
        self._on_row_selected = on_row_selected
        self._on_cell_edited = on_cell_edited
        self._on_build_context_menu = on_build_context_menu
        self._config = config or TableWidgetConfig()
        self._grid_options_user = dict(grid_options or {})
        self._selection_origin = 'internal'
        self._row_group_fields = tuple(str(field).strip() for field in self._config.row_group_fields)
        if any(not field for field in self._row_group_fields):
            raise ValueError('row_group_fields entries must be non-empty strings')
        if len(set(self._row_group_fields)) != len(self._row_group_fields):
            raise ValueError('row_group_fields must not contain duplicates')
        if (
            isinstance(self._config.group_default_expanded, bool)
            or not isinstance(self._config.group_default_expanded, int)
            or self._config.group_default_expanded < -1
        ):
            raise ValueError('group_default_expanded must be an int greater than or equal to -1')

        application_fields = {column.field for column in columns}
        unknown_group_fields = [
            field for field in self._row_group_fields if field not in application_fields
        ]
        if unknown_group_fields:
            raise ValueError(
                f'row_group_fields contains unknown column field(s): {unknown_group_fields!r}'
            )

        self._evt_select = f'table_widget_select_{id(self)}'
        self._evt_edit = f'table_widget_edit_{id(self)}'

        self._index_field: str | None = None
        if self._config.show_index_column:
            idx_f = str(self._config.index_field).strip()
            if not idx_f:
                raise ValueError('index_field must be non-empty when show_index_column is true')
            for c in columns:
                if c.field == idx_f:
                    raise ValueError(
                        f'Column field {idx_f!r} conflicts with TableWidgetConfig.index_field; '
                        'rename the column or set a different index_field'
                    )
            self._index_field = idx_f
            index_col = ColumnDef(
                field=idx_f,
                headerName=str(self._config.index_header),
                extra={
                    'editable': False,
                    'sortable': True,
                    'filter': False,
                    'type': 'numericColumn',
                    'maxWidth': 96,
                },
            )
            built_columns = (index_col, *columns)
        else:
            built_columns = tuple(columns)

        self._column_defs: list[dict[str, Any]] = [c.as_aggrid_column_def() for c in built_columns]
        for group_index, field in enumerate(self._row_group_fields):
            column_def = next(column for column in self._column_defs if column.get('field') == field)
            column_def['rowGroup'] = True
            column_def['rowGroupIndex'] = group_index
            if self._config.hide_row_group_columns:
                column_def['hide'] = True
        self._rows: list[dict[str, Any]] = [dict(r) for r in (rows or ())]
        validate_rows_for_row_id_field(self._rows, self._row_id_field)
        self._assign_row_indices()

        self._selected_row_ids: list[str] = []
        self._selected_rows: list[dict[str, Any]] = []
        self._last_selected_row_id: str | None = None

        self._root: ui.column | None = None
        self._grid: ui.aggrid | None = None
        self._context_menu: ui.context_menu | None = None
        self._theme: TableThemeName = 'light'

        ui.on(self._evt_select, self._on_select_emitted)
        ui.on(self._evt_edit, self._on_edit_emitted)

    def _assign_row_indices(self) -> None:
        """Set 1-based row index in row data for the synthetic index column."""
        if self._index_field is None:
            return
        for i, row in enumerate(self._rows):
            row[self._index_field] = i + 1

    def build(self, parent: ui.element | None = None) -> ui.column:
        """Create the wrapper + context menu + AG Grid under ``parent``.

        When row grouping is configured, this loads the configured Enterprise
        ESM source (unless it is ``None``) and builds with
        ``modules='enterprise'``. Hosts need their own AG Grid Enterprise
        license for production use.
        """
        if self._row_group_fields and self._config.enterprise_module_url:
            ui.aggrid.set_module_source(self._config.enterprise_module_url)

        # AG Grid requires a real height from its container; provide a sane default
        # when callers do not pass a sized parent container.
        container = parent if parent is not None else ui.column().classes('w-full').style('height: 24rem;')
        with container:
            self._root = ui.column().classes('w-full h-full min-w-0 min-h-0')
            with self._root:
                self._context_menu = ui.context_menu()
                with self._context_menu:
                    self._build_context_menu_content()
                self._root.on('contextmenu', self._on_context_menu_event)
                if self._row_group_fields:
                    self._grid = ui.aggrid(
                        self._build_aggrid_options(),
                        auto_size_columns=self._config.auto_size_columns,
                        modules='enterprise',
                    )
                else:
                    self._grid = ui.aggrid(
                        self._build_aggrid_options(),
                        auto_size_columns=self._config.auto_size_columns,
                    )
                self._grid.classes('w-full h-full min-w-0 min-h-0').style('height: 100%;')
                self._apply_theme()
        return self._root


    def set_theme(self, theme: str) -> None:
        """Set the AG Grid light/dark color scheme.

        NiceGUI's ``ui.aggrid`` follows page dark mode via
        ``data-ag-theme-mode``. This method sets that attribute explicitly so
        hosts can drive table theming the same way as Plotly widgets
        (``set_theme`` / ``set_dark_mode``), independent of call order relative
        to ``ui.dark_mode``.

        Args:
            theme: Theme name, either ``'light'`` or ``'dark'``.
        """
        self._theme = normalize_table_theme(theme)
        self._apply_theme()

    def set_dark_mode(self, enabled: bool) -> None:
        """Set the AG Grid color scheme from a dark-mode flag.

        Args:
            enabled: Whether dark mode is enabled.
        """
        self.set_theme('dark' if enabled else 'light')

    def _apply_theme(self) -> None:
        """Push the current theme to the built AG Grid element, if any."""
        if self._grid is None:
            return
        self._grid.props(f'data-ag-theme-mode={self._theme}')
        self._grid.update()

    def set_enabled(self, enabled: bool) -> None:
        """Enable or disable pointer interaction with the table.

        Args:
            enabled: Desired enabled state.
        """
        enabled = bool(enabled)
        if self._root is not None:
            self._root.enabled = enabled
            if enabled:
                self._root.classes(remove='pointer-events-none opacity-60')
            else:
                self._root.classes(add='pointer-events-none opacity-60')
            self._root.update()
        if self._grid is not None:
            self._grid.enabled = enabled
            self._grid.update()

    def get_rows(self) -> list[dict[str, Any]]:
        """Return a copy of internal row data."""
        return [dict(r) for r in self._rows]

    def get_selected_rows(self) -> list[dict[str, Any]]:
        """Return last known selected rows."""
        return [dict(r) for r in self._selected_rows]

    def get_selected_row_ids(self) -> list[str]:
        """Return last known selected row ids."""
        return list(self._selected_row_ids)

    def set_selected_row_ids(self, row_ids: Sequence[str], *, origin: str = 'external') -> None:
        """Programmatically select rows by row id (selection mode aware)."""
        normalized = [str(rid) for rid in row_ids]
        if self._config.selection_mode == 'none':
            self._selected_row_ids = []
            self._selected_rows = []
            self._last_selected_row_id = None
            return
        if self._config.selection_mode == 'single':
            normalized = normalized[:1]

        row_by_id = {str(row[self._row_id_field]): dict(row) for row in self._rows}
        keep = [rid for rid in normalized if rid in row_by_id]
        self._selected_row_ids = keep
        self._selected_rows = [row_by_id[rid] for rid in keep]
        self._last_selected_row_id = keep[0] if keep else None

        if self._grid is None:
            return

        self._selection_origin = origin
        self._grid.run_grid_method('deselectAll')
        for i, rid in enumerate(keep):
            clear = bool(i == 0 and self._config.selection_mode == 'single')
            self._grid.run_row_method(rid, 'setSelected', True, clear)
        self._selection_origin = 'internal'

    def clear_selection(self) -> None:
        """Clear selected-row tracking and grid selection."""
        self._selected_row_ids = []
        self._selected_rows = []
        self._last_selected_row_id = None
        if self._grid is not None:
            self._grid.run_grid_method('deselectAll')

    def set_data(self, rows: Sequence[Mapping[str, Any]]) -> None:
        """Replace all rows and refresh the grid."""
        new_rows = [dict(r) for r in rows]
        validate_rows_for_row_id_field(new_rows, self._row_id_field)
        self._rows = new_rows
        self._assign_row_indices()
        self._push_row_data_to_grid()
        if self._config.clear_selection_on_set_data:
            self.clear_selection()

    def upsert_row(self, row: Mapping[str, Any]) -> None:
        """Insert or replace one row by ``row_id_field``."""
        validate_rows_for_row_id_field([row], self._row_id_field)
        rid = str(row[self._row_id_field])
        replacement = dict(row)
        for i, existing in enumerate(self._rows):
            if existing.get(self._row_id_field) == rid:
                self._rows[i] = replacement
                break
        else:
            self._rows.append(replacement)
        self._assign_row_indices()
        self._push_row_data_to_grid()

    def update_row(self, row_id: str, row: Mapping[str, Any]) -> None:
        """Update a single row by id, patching the row node when possible."""
        validate_rows_for_row_id_field([row], self._row_id_field)
        rid = str(row_id)
        replacement = dict(row)
        idx: int | None = None
        for i, existing in enumerate(self._rows):
            if str(existing.get(self._row_id_field)) == rid:
                idx = i
                break
        if idx is None:
            raise ValueError(f'No row with id {rid!r}')
        self._rows[idx] = replacement
        self._assign_row_indices()
        if self._grid is None:
            return
        try:
            self._grid.run_row_method(rid, 'setData', dict(self._rows[idx]))
        except RuntimeError:
            self._push_row_data_to_grid()

    def remove_row(self, row_id: str) -> None:
        """Remove row matching ``row_id``."""
        if not isinstance(row_id, str):
            raise ValueError('remove_row expects row_id: str')
        before = len(self._rows)
        self._rows = [r for r in self._rows if r.get(self._row_id_field) != row_id]
        if len(self._rows) == before:
            raise ValueError(f'No row with id {row_id!r}')
        self._assign_row_indices()
        self._push_row_data_to_grid()
        if row_id in self._selected_row_ids:
            self._selected_row_ids = [rid for rid in self._selected_row_ids if rid != row_id]
            self._selected_rows = [r for r in self._selected_rows if str(r.get(self._row_id_field)) != row_id]
            self._last_selected_row_id = self._selected_row_ids[0] if self._selected_row_ids else None

    def set_column_visible(self, field: str, visible: bool) -> None:
        """Show or hide one column by field."""
        col = self._find_column_def(field)
        col['hide'] = not visible
        self._apply_column_defs_to_grid()

    def toggle_column_visible(self, field: str) -> None:
        """Toggle one column by field."""
        vis = self.get_column_visibility()[field]
        self.set_column_visible(field, not vis)

    def get_column_visibility(self) -> dict[str, bool]:
        """Return ``field -> visible`` map."""
        return {str(c['field']): not bool(c.get('hide', False)) for c in self._column_defs}

    def get_table_as_text(self) -> str:
        """Return current Python-side table rows as tab-separated text.

        The exported data uses the widget's internal row list and current column
        visibility settings. It does not query browser-side AG Grid sort or
        filter state. Column order follows the current ``columnDefs`` order,
        and hidden columns are omitted.

        Returns:
            TSV-formatted text containing one header row and one row per table
            row. Returns an empty string when there are no rows. Tabs and
            newlines inside headers or cell values are normalized to spaces.
        """
        return self._rows_to_tsv(self._rows)

    async def get_displayed_rows(self) -> list[dict[str, Any]]:
        """Return AG Grid rows after browser-side filtering and sorting.

        AG Grid owns sort and filter state in the browser. This method uses
        NiceGUI's ``ui.run_javascript`` escape hatch to query the AG Grid API
        directly with ``forEachNodeAfterFilterAndSort``. If the grid has not
        been built yet, the method falls back to the current Python-side rows.

        Returns:
            Displayed row dictionaries in the same order the user sees them.

        Raises:
            RuntimeError: If the browser returns a non-list result.
        """
        if self._grid is None:
            return self.get_rows()

        grid_id = int(self._grid.id)
        script = f"""
            (() => {{
                const grid = getElement({grid_id});
                if (!grid || !grid.api) {{
                    throw new Error('AG Grid API is not available for table widget {grid_id}');
                }}
                const rows = [];
                grid.api.forEachNodeAfterFilterAndSort(node => rows.push(node.data));
                return rows;
            }})()
            """
        rows = await ui.run_javascript(script, timeout=5.0)
        if not rows:
            return []
        if not isinstance(rows, list):
            raise RuntimeError(f'Expected AG Grid displayed rows as list, got {type(rows).__name__}')
        return [dict(row) for row in rows if isinstance(row, dict)]

    async def get_displayed_table_as_text(self) -> str:
        """Return browser-displayed rows as tab-separated text.

        The exported rows reflect AG Grid's browser-side filtering and sorting.
        Column order and visibility use the widget's current column definitions.

        Returns:
            TSV-formatted text for the displayed row set.
        """
        return self._rows_to_tsv(await self.get_displayed_rows())

    async def copy_table_data_to_clipboard(self) -> None:
        """Copy browser-displayed table rows to the clipboard as TSV.

        Rows are read from AG Grid after filtering and sorting. If the grid has
        not been built yet, the internal Python-side rows are copied instead.

        Returns:
            None.
        """
        text = await self.get_displayed_table_as_text()
        try:
            copy_to_clipboard(text)
        except RuntimeError as exc:
            logger.warning('unable to copy table data: %s', exc)
            ui.notify(str(exc), type='negative')
            return
        ui.notify('Table data copied to clipboard', type='positive')

    def _rows_to_tsv(self, rows: Sequence[Mapping[str, Any]]) -> str:
        """Format table rows as TSV using current visible columns.

        Args:
            rows: Row dictionaries to export.

        Returns:
            TSV-formatted text containing one header row and one row per input
            row. Returns an empty string when ``rows`` is empty.
        """
        if not rows:
            return ''

        visible_columns = [c for c in self._column_defs if not bool(c.get('hide', False))]
        headers = [str(c.get('headerName', c.get('field', ''))) for c in visible_columns]
        fields = [str(c.get('field', '')) for c in visible_columns]

        lines = ['\t'.join(self._sanitize_table_text_cell(header) for header in headers)]
        for row in rows:
            values = [self._sanitize_table_text_cell(row.get(field, '')) for field in fields]
            lines.append('\t'.join(values))
        return '\n'.join(lines)

    @staticmethod
    def _sanitize_table_text_cell(value: Any) -> str:
        """Return a TSV-safe string for one table cell.

        Args:
            value: Cell value to stringify.

        Returns:
            String with tabs and line breaks normalized to spaces.
        """
        if value is None:
            return ''
        return str(value).replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')

    def _find_column_def(self, field: str) -> dict[str, Any]:
        for c in self._column_defs:
            if c.get('field') == field:
                return c
        raise ValueError(f'Unknown column field {field!r}')

    def _row_selection_object(self, selection: SelectionMode) -> dict[str, Any] | None:
        if selection == 'none':
            return None
        if selection == 'single':
            return {'mode': 'singleRow', 'enableClickSelection': True, 'checkboxes': False}
        return {'mode': 'multiRow', 'enableClickSelection': True, 'checkboxes': False}

    def _build_aggrid_options(self) -> dict[str, Any]:
        default_col_def: dict[str, Any] = {'sortable': True, 'filter': True, 'resizable': True}
        px = self._config.cell_font_size_px
        if px is not None:
            try:
                n = int(px)
            except (TypeError, ValueError):
                n = None
            else:
                if n >= 1:
                    fs = f'{n}px'
                    default_col_def['cellStyle'] = {'fontSize': fs}
                    default_col_def['headerStyle'] = {'fontSize': fs}

        base: dict[str, Any] = {
            'columnDefs': copy.deepcopy(self._column_defs),
            'rowData': [dict(r) for r in self._rows],
            'defaultColDef': default_col_def,
            ':getRowId': _get_row_id_js_expression(self._row_id_field),
            ':onRowClicked': js_on_row_clicked(emit_event=self._evt_select, row_id_field=self._row_id_field),
            # AG Grid Enterprise ships its own right-click menu (Copy, Copy with
            # Headers, Export, etc.) and intercepts the contextmenu event before
            # NiceGUI's ui.context_menu can see it. TableWidget owns the
            # right-click menu via _build_context_menu_content, so suppress AG
            # Grid's menu and ask AG Grid to also block the browser's native menu
            # over the grid surface.
            'suppressContextMenu': True,
            'preventDefaultOnContextMenu': True,
        }
        rh = self._config.row_height
        if rh is not None:
            try:
                rh_i = int(rh)
            except (TypeError, ValueError):
                rh_i = 0
            if rh_i >= 1:
                base['rowHeight'] = rh_i
        hh = self._config.header_height
        if hh is not None:
            try:
                hh_i = int(hh)
            except (TypeError, ValueError):
                hh_i = 0
            if hh_i >= 1:
                base['headerHeight'] = hh_i
        if self._config.stop_editing_when_cells_lose_focus:
            base['stopEditingWhenCellsLoseFocus'] = True
        if self._config.fit_columns_on_grid_resize:
            base[':onGridSizeChanged'] = 'params => params.api.sizeColumnsToFit()'
        if self._row_group_fields:
            base['groupDefaultExpanded'] = self._config.group_default_expanded
        row_sel = self._row_selection_object(self._config.selection_mode)
        if row_sel is not None:
            base['rowSelection'] = row_sel
        if self._config.enable_keyboard_row_nav and self._config.selection_mode != 'none':
            base[':onCellKeyDown'] = js_on_cell_key_down_select_prev_next(
                emit_event=self._evt_select,
                row_id_field=self._row_id_field,
            )
        if self._config.enable_edit_on_double_click:
            base[':onCellDoubleClicked'] = js_on_cell_double_clicked_start_editing()
            base[':onCellEditingStopped'] = js_on_cell_editing_stopped_emit_change(
                emit_event=self._evt_edit,
                row_id_field=self._row_id_field,
            )
        merged = _deep_merge_aggrid_options(base, self._config.extra_grid_options)
        return _deep_merge_aggrid_options(merged, self._grid_options_user)

    def _push_row_data_to_grid(self) -> None:
        if self._grid is None:
            return
        opts = dict(self._grid.options)
        opts['rowData'] = [dict(r) for r in self._rows]
        self._grid.options = opts
        self._grid.update()

    def _apply_column_defs_to_grid(self) -> None:
        if self._grid is None:
            return
        opts = dict(self._grid.options)
        opts['columnDefs'] = copy.deepcopy(self._column_defs)
        self._grid.options = opts
        self._grid.update()

    def _build_context_menu_content(self) -> None:
        """Populate the right-click context menu.

        Caller-provided actions are shown first, followed by the generic copy
        action, then the existing column-visibility toggles.

        Returns:
            None.
        """
        if self._on_build_context_menu is not None:
            self._on_build_context_menu(self)
            ui.separator()

        ui.menu_item('Copy Table Data', on_click=self.copy_table_data_to_clipboard)
        ui.separator()

        check = '✓'
        for c in self._column_defs:
            field = str(c['field'])
            header = str(c.get('headerName', field))
            visible = not bool(c.get('hide', False))
            label = f'{check} {header}' if visible else f'  {header}'
            ui.menu_item(label, on_click=lambda f=field: self.toggle_column_visible(f))

    def _on_context_menu_event(self, _e: events.GenericEventArguments) -> None:
        if self._context_menu is None:
            return
        with self._context_menu.clear():
            self._build_context_menu_content()

    def _on_select_emitted(self, e: events.GenericEventArguments) -> None:
        if self._config.selection_mode == 'none':
            return
        if getattr(self, '_selection_origin', 'internal') != 'internal':
            return
        args: dict[str, Any] = e.args or {}
        row_id = args.get('rowId')
        row_data = args.get('data') or {}
        if row_id is None:
            return
        row_id_str = str(row_id)
        if self._config.selection_mode == 'single':
            if row_id_str == self._last_selected_row_id:
                return
            self._selected_row_ids = [row_id_str]
            self._selected_rows = [dict(row_data)] if isinstance(row_data, dict) else []
            self._last_selected_row_id = row_id_str
        else:
            if row_id_str not in self._selected_row_ids:
                self._selected_row_ids.append(row_id_str)
                if isinstance(row_data, dict):
                    self._selected_rows.append(dict(row_data))
            self._last_selected_row_id = row_id_str

        if self._on_row_selected is not None and isinstance(row_data, dict) and row_data:
            self._on_row_selected(dict(row_data))

    def _on_edit_emitted(self, e: events.GenericEventArguments) -> None:
        args: dict[str, Any] = e.args or {}
        row_id = args.get('rowId')
        col_id = args.get('colId')
        old_value = args.get('oldValue')
        new_value = args.get('newValue')
        data = args.get('data') or {}
        if row_id is None or col_id is None:
            return
        row_id_str = str(row_id)
        field = str(col_id)
        if old_value == new_value:
            return
        for i, row in enumerate(self._rows):
            if str(row.get(self._row_id_field)) == row_id_str:
                self._rows[i][field] = new_value
                break
        if self._on_cell_edited is not None and isinstance(data, dict):
            self._on_cell_edited(row_id_str, field, old_value, new_value, dict(data))

build

build(parent: element | None = None) -> ui.column

Create the wrapper + context menu + AG Grid under parent.

When row grouping is configured, this loads the configured Enterprise ESM source (unless it is None) and builds with modules='enterprise'. Hosts need their own AG Grid Enterprise license for production use.

Source code in src/nicewidgets/table_widget/table_widget.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def build(self, parent: ui.element | None = None) -> ui.column:
    """Create the wrapper + context menu + AG Grid under ``parent``.

    When row grouping is configured, this loads the configured Enterprise
    ESM source (unless it is ``None``) and builds with
    ``modules='enterprise'``. Hosts need their own AG Grid Enterprise
    license for production use.
    """
    if self._row_group_fields and self._config.enterprise_module_url:
        ui.aggrid.set_module_source(self._config.enterprise_module_url)

    # AG Grid requires a real height from its container; provide a sane default
    # when callers do not pass a sized parent container.
    container = parent if parent is not None else ui.column().classes('w-full').style('height: 24rem;')
    with container:
        self._root = ui.column().classes('w-full h-full min-w-0 min-h-0')
        with self._root:
            self._context_menu = ui.context_menu()
            with self._context_menu:
                self._build_context_menu_content()
            self._root.on('contextmenu', self._on_context_menu_event)
            if self._row_group_fields:
                self._grid = ui.aggrid(
                    self._build_aggrid_options(),
                    auto_size_columns=self._config.auto_size_columns,
                    modules='enterprise',
                )
            else:
                self._grid = ui.aggrid(
                    self._build_aggrid_options(),
                    auto_size_columns=self._config.auto_size_columns,
                )
            self._grid.classes('w-full h-full min-w-0 min-h-0').style('height: 100%;')
            self._apply_theme()
    return self._root

set_theme

set_theme(theme: str) -> None

Set the AG Grid light/dark color scheme.

NiceGUI's ui.aggrid follows page dark mode via data-ag-theme-mode. This method sets that attribute explicitly so hosts can drive table theming the same way as Plotly widgets (set_theme / set_dark_mode), independent of call order relative to ui.dark_mode.

Parameters:

Name Type Description Default
theme str

Theme name, either 'light' or 'dark'.

required
Source code in src/nicewidgets/table_widget/table_widget.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def set_theme(self, theme: str) -> None:
    """Set the AG Grid light/dark color scheme.

    NiceGUI's ``ui.aggrid`` follows page dark mode via
    ``data-ag-theme-mode``. This method sets that attribute explicitly so
    hosts can drive table theming the same way as Plotly widgets
    (``set_theme`` / ``set_dark_mode``), independent of call order relative
    to ``ui.dark_mode``.

    Args:
        theme: Theme name, either ``'light'`` or ``'dark'``.
    """
    self._theme = normalize_table_theme(theme)
    self._apply_theme()

set_dark_mode

set_dark_mode(enabled: bool) -> None

Set the AG Grid color scheme from a dark-mode flag.

Parameters:

Name Type Description Default
enabled bool

Whether dark mode is enabled.

required
Source code in src/nicewidgets/table_widget/table_widget.py
250
251
252
253
254
255
256
def set_dark_mode(self, enabled: bool) -> None:
    """Set the AG Grid color scheme from a dark-mode flag.

    Args:
        enabled: Whether dark mode is enabled.
    """
    self.set_theme('dark' if enabled else 'light')

set_enabled

set_enabled(enabled: bool) -> None

Enable or disable pointer interaction with the table.

Parameters:

Name Type Description Default
enabled bool

Desired enabled state.

required
Source code in src/nicewidgets/table_widget/table_widget.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def set_enabled(self, enabled: bool) -> None:
    """Enable or disable pointer interaction with the table.

    Args:
        enabled: Desired enabled state.
    """
    enabled = bool(enabled)
    if self._root is not None:
        self._root.enabled = enabled
        if enabled:
            self._root.classes(remove='pointer-events-none opacity-60')
        else:
            self._root.classes(add='pointer-events-none opacity-60')
        self._root.update()
    if self._grid is not None:
        self._grid.enabled = enabled
        self._grid.update()

get_rows

get_rows() -> list[dict[str, Any]]

Return a copy of internal row data.

Source code in src/nicewidgets/table_widget/table_widget.py
283
284
285
def get_rows(self) -> list[dict[str, Any]]:
    """Return a copy of internal row data."""
    return [dict(r) for r in self._rows]

get_selected_rows

get_selected_rows() -> list[dict[str, Any]]

Return last known selected rows.

Source code in src/nicewidgets/table_widget/table_widget.py
287
288
289
def get_selected_rows(self) -> list[dict[str, Any]]:
    """Return last known selected rows."""
    return [dict(r) for r in self._selected_rows]

get_selected_row_ids

get_selected_row_ids() -> list[str]

Return last known selected row ids.

Source code in src/nicewidgets/table_widget/table_widget.py
291
292
293
def get_selected_row_ids(self) -> list[str]:
    """Return last known selected row ids."""
    return list(self._selected_row_ids)

set_selected_row_ids

set_selected_row_ids(
    row_ids: Sequence[str], *, origin: str = 'external'
) -> None

Programmatically select rows by row id (selection mode aware).

Source code in src/nicewidgets/table_widget/table_widget.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def set_selected_row_ids(self, row_ids: Sequence[str], *, origin: str = 'external') -> None:
    """Programmatically select rows by row id (selection mode aware)."""
    normalized = [str(rid) for rid in row_ids]
    if self._config.selection_mode == 'none':
        self._selected_row_ids = []
        self._selected_rows = []
        self._last_selected_row_id = None
        return
    if self._config.selection_mode == 'single':
        normalized = normalized[:1]

    row_by_id = {str(row[self._row_id_field]): dict(row) for row in self._rows}
    keep = [rid for rid in normalized if rid in row_by_id]
    self._selected_row_ids = keep
    self._selected_rows = [row_by_id[rid] for rid in keep]
    self._last_selected_row_id = keep[0] if keep else None

    if self._grid is None:
        return

    self._selection_origin = origin
    self._grid.run_grid_method('deselectAll')
    for i, rid in enumerate(keep):
        clear = bool(i == 0 and self._config.selection_mode == 'single')
        self._grid.run_row_method(rid, 'setSelected', True, clear)
    self._selection_origin = 'internal'

clear_selection

clear_selection() -> None

Clear selected-row tracking and grid selection.

Source code in src/nicewidgets/table_widget/table_widget.py
322
323
324
325
326
327
328
def clear_selection(self) -> None:
    """Clear selected-row tracking and grid selection."""
    self._selected_row_ids = []
    self._selected_rows = []
    self._last_selected_row_id = None
    if self._grid is not None:
        self._grid.run_grid_method('deselectAll')

set_data

set_data(rows: Sequence[Mapping[str, Any]]) -> None

Replace all rows and refresh the grid.

Source code in src/nicewidgets/table_widget/table_widget.py
330
331
332
333
334
335
336
337
338
def set_data(self, rows: Sequence[Mapping[str, Any]]) -> None:
    """Replace all rows and refresh the grid."""
    new_rows = [dict(r) for r in rows]
    validate_rows_for_row_id_field(new_rows, self._row_id_field)
    self._rows = new_rows
    self._assign_row_indices()
    self._push_row_data_to_grid()
    if self._config.clear_selection_on_set_data:
        self.clear_selection()

upsert_row

upsert_row(row: Mapping[str, Any]) -> None

Insert or replace one row by row_id_field.

Source code in src/nicewidgets/table_widget/table_widget.py
340
341
342
343
344
345
346
347
348
349
350
351
352
def upsert_row(self, row: Mapping[str, Any]) -> None:
    """Insert or replace one row by ``row_id_field``."""
    validate_rows_for_row_id_field([row], self._row_id_field)
    rid = str(row[self._row_id_field])
    replacement = dict(row)
    for i, existing in enumerate(self._rows):
        if existing.get(self._row_id_field) == rid:
            self._rows[i] = replacement
            break
    else:
        self._rows.append(replacement)
    self._assign_row_indices()
    self._push_row_data_to_grid()

update_row

update_row(row_id: str, row: Mapping[str, Any]) -> None

Update a single row by id, patching the row node when possible.

Source code in src/nicewidgets/table_widget/table_widget.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def update_row(self, row_id: str, row: Mapping[str, Any]) -> None:
    """Update a single row by id, patching the row node when possible."""
    validate_rows_for_row_id_field([row], self._row_id_field)
    rid = str(row_id)
    replacement = dict(row)
    idx: int | None = None
    for i, existing in enumerate(self._rows):
        if str(existing.get(self._row_id_field)) == rid:
            idx = i
            break
    if idx is None:
        raise ValueError(f'No row with id {rid!r}')
    self._rows[idx] = replacement
    self._assign_row_indices()
    if self._grid is None:
        return
    try:
        self._grid.run_row_method(rid, 'setData', dict(self._rows[idx]))
    except RuntimeError:
        self._push_row_data_to_grid()

remove_row

remove_row(row_id: str) -> None

Remove row matching row_id.

Source code in src/nicewidgets/table_widget/table_widget.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def remove_row(self, row_id: str) -> None:
    """Remove row matching ``row_id``."""
    if not isinstance(row_id, str):
        raise ValueError('remove_row expects row_id: str')
    before = len(self._rows)
    self._rows = [r for r in self._rows if r.get(self._row_id_field) != row_id]
    if len(self._rows) == before:
        raise ValueError(f'No row with id {row_id!r}')
    self._assign_row_indices()
    self._push_row_data_to_grid()
    if row_id in self._selected_row_ids:
        self._selected_row_ids = [rid for rid in self._selected_row_ids if rid != row_id]
        self._selected_rows = [r for r in self._selected_rows if str(r.get(self._row_id_field)) != row_id]
        self._last_selected_row_id = self._selected_row_ids[0] if self._selected_row_ids else None

set_column_visible

set_column_visible(field: str, visible: bool) -> None

Show or hide one column by field.

Source code in src/nicewidgets/table_widget/table_widget.py
390
391
392
393
394
def set_column_visible(self, field: str, visible: bool) -> None:
    """Show or hide one column by field."""
    col = self._find_column_def(field)
    col['hide'] = not visible
    self._apply_column_defs_to_grid()

toggle_column_visible

toggle_column_visible(field: str) -> None

Toggle one column by field.

Source code in src/nicewidgets/table_widget/table_widget.py
396
397
398
399
def toggle_column_visible(self, field: str) -> None:
    """Toggle one column by field."""
    vis = self.get_column_visibility()[field]
    self.set_column_visible(field, not vis)

get_column_visibility

get_column_visibility() -> dict[str, bool]

Return field -> visible map.

Source code in src/nicewidgets/table_widget/table_widget.py
401
402
403
def get_column_visibility(self) -> dict[str, bool]:
    """Return ``field -> visible`` map."""
    return {str(c['field']): not bool(c.get('hide', False)) for c in self._column_defs}

get_table_as_text

get_table_as_text() -> str

Return current Python-side table rows as tab-separated text.

The exported data uses the widget's internal row list and current column visibility settings. It does not query browser-side AG Grid sort or filter state. Column order follows the current columnDefs order, and hidden columns are omitted.

Returns:

Type Description
str

TSV-formatted text containing one header row and one row per table

str

row. Returns an empty string when there are no rows. Tabs and

str

newlines inside headers or cell values are normalized to spaces.

Source code in src/nicewidgets/table_widget/table_widget.py
405
406
407
408
409
410
411
412
413
414
415
416
417
418
def get_table_as_text(self) -> str:
    """Return current Python-side table rows as tab-separated text.

    The exported data uses the widget's internal row list and current column
    visibility settings. It does not query browser-side AG Grid sort or
    filter state. Column order follows the current ``columnDefs`` order,
    and hidden columns are omitted.

    Returns:
        TSV-formatted text containing one header row and one row per table
        row. Returns an empty string when there are no rows. Tabs and
        newlines inside headers or cell values are normalized to spaces.
    """
    return self._rows_to_tsv(self._rows)

get_displayed_rows async

get_displayed_rows() -> list[dict[str, Any]]

Return AG Grid rows after browser-side filtering and sorting.

AG Grid owns sort and filter state in the browser. This method uses NiceGUI's ui.run_javascript escape hatch to query the AG Grid API directly with forEachNodeAfterFilterAndSort. If the grid has not been built yet, the method falls back to the current Python-side rows.

Returns:

Type Description
list[dict[str, Any]]

Displayed row dictionaries in the same order the user sees them.

Raises:

Type Description
RuntimeError

If the browser returns a non-list result.

Source code in src/nicewidgets/table_widget/table_widget.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
async def get_displayed_rows(self) -> list[dict[str, Any]]:
    """Return AG Grid rows after browser-side filtering and sorting.

    AG Grid owns sort and filter state in the browser. This method uses
    NiceGUI's ``ui.run_javascript`` escape hatch to query the AG Grid API
    directly with ``forEachNodeAfterFilterAndSort``. If the grid has not
    been built yet, the method falls back to the current Python-side rows.

    Returns:
        Displayed row dictionaries in the same order the user sees them.

    Raises:
        RuntimeError: If the browser returns a non-list result.
    """
    if self._grid is None:
        return self.get_rows()

    grid_id = int(self._grid.id)
    script = f"""
        (() => {{
            const grid = getElement({grid_id});
            if (!grid || !grid.api) {{
                throw new Error('AG Grid API is not available for table widget {grid_id}');
            }}
            const rows = [];
            grid.api.forEachNodeAfterFilterAndSort(node => rows.push(node.data));
            return rows;
        }})()
        """
    rows = await ui.run_javascript(script, timeout=5.0)
    if not rows:
        return []
    if not isinstance(rows, list):
        raise RuntimeError(f'Expected AG Grid displayed rows as list, got {type(rows).__name__}')
    return [dict(row) for row in rows if isinstance(row, dict)]

get_displayed_table_as_text async

get_displayed_table_as_text() -> str

Return browser-displayed rows as tab-separated text.

The exported rows reflect AG Grid's browser-side filtering and sorting. Column order and visibility use the widget's current column definitions.

Returns:

Type Description
str

TSV-formatted text for the displayed row set.

Source code in src/nicewidgets/table_widget/table_widget.py
456
457
458
459
460
461
462
463
464
465
async def get_displayed_table_as_text(self) -> str:
    """Return browser-displayed rows as tab-separated text.

    The exported rows reflect AG Grid's browser-side filtering and sorting.
    Column order and visibility use the widget's current column definitions.

    Returns:
        TSV-formatted text for the displayed row set.
    """
    return self._rows_to_tsv(await self.get_displayed_rows())

copy_table_data_to_clipboard async

copy_table_data_to_clipboard() -> None

Copy browser-displayed table rows to the clipboard as TSV.

Rows are read from AG Grid after filtering and sorting. If the grid has not been built yet, the internal Python-side rows are copied instead.

Returns:

Type Description
None

None.

Source code in src/nicewidgets/table_widget/table_widget.py
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
async def copy_table_data_to_clipboard(self) -> None:
    """Copy browser-displayed table rows to the clipboard as TSV.

    Rows are read from AG Grid after filtering and sorting. If the grid has
    not been built yet, the internal Python-side rows are copied instead.

    Returns:
        None.
    """
    text = await self.get_displayed_table_as_text()
    try:
        copy_to_clipboard(text)
    except RuntimeError as exc:
        logger.warning('unable to copy table data: %s', exc)
        ui.notify(str(exc), type='negative')
        return
    ui.notify('Table data copied to clipboard', type='positive')

nicewidgets.table_widget.config.TableWidgetConfig dataclass

Grid-level options for TableWidget.

Attributes:

Name Type Description
selection_mode SelectionMode

Row selection behavior.

clear_selection_on_set_data bool

Clear tracked/grid selection when replacing all rows.

enable_edit_on_double_click bool

Start edit on double-click and emit edit-finished events.

enable_keyboard_row_nav bool

ArrowUp/ArrowDown select previous/next displayed row.

stop_editing_when_cells_lose_focus bool

End edits when focus leaves the grid.

auto_size_columns bool

Forwarded to ui.aggrid(auto_size_columns=...).

fit_columns_on_grid_resize bool

When true, AG Grid calls sizeColumnsToFit after browser-side grid size changes. Defaults to false so existing tables keep their current resize behavior unless opted in.

cell_font_size_px int | None

When set, cell and header font size in pixels (merged into defaultColDef). When None, AG Grid theme defaults apply.

row_height int | None

Optional fixed row height (px) for AG Grid rowHeight. When None, the option is omitted (theme/browser default).

header_height int | None

Optional fixed header row height (px) for headerHeight. When None, the option is omitted.

extra_grid_options dict[str, Any]

Additional AG Grid options merged before grid_options.

show_index_column bool

When true, prepend a synthetic 1-based Index column (stored in row data at index_field); values follow rowData list order so they move with rows when the grid is sorted by other columns.

index_field str

Row dict / AG Grid field name for the index column (must not collide with application row keys).

index_header str

Column header label for the index column.

row_group_fields tuple[str, ...]

Ordered application column fields used as AG Grid Enterprise row-group categories. Empty keeps the community module.

group_default_expanded int

Number of grouping levels expanded initially. 0 collapses all groups; -1 expands all groups.

hide_row_group_columns bool

Hide source category columns after their values move into AG Grid's generated group column.

enterprise_module_url str | None

AG Grid Enterprise ESM module URL passed to ui.aggrid.set_module_source when row_group_fields is not empty. None assumes the host configured the module source. Enabling the module does not provide a production license.

Source code in src/nicewidgets/table_widget/config.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@dataclass(frozen=True, slots=True)
class TableWidgetConfig:
    """Grid-level options for ``TableWidget``.

    Attributes:
        selection_mode: Row selection behavior.
        clear_selection_on_set_data: Clear tracked/grid selection when replacing all rows.
        enable_edit_on_double_click: Start edit on double-click and emit edit-finished events.
        enable_keyboard_row_nav: ArrowUp/ArrowDown select previous/next displayed row.
        stop_editing_when_cells_lose_focus: End edits when focus leaves the grid.
        auto_size_columns: Forwarded to ``ui.aggrid(auto_size_columns=...)``.
        fit_columns_on_grid_resize: When true, AG Grid calls ``sizeColumnsToFit``
            after browser-side grid size changes. Defaults to false so existing
            tables keep their current resize behavior unless opted in.
        cell_font_size_px: When set, cell and header font size in pixels (merged into
            ``defaultColDef``). When ``None``, AG Grid theme defaults apply.
        row_height: Optional fixed row height (px) for AG Grid ``rowHeight``. When
            ``None``, the option is omitted (theme/browser default).
        header_height: Optional fixed header row height (px) for ``headerHeight``.
            When ``None``, the option is omitted.
        extra_grid_options: Additional AG Grid options merged before ``grid_options``.
        show_index_column: When true, prepend a synthetic 1-based ``Index`` column
            (stored in row data at ``index_field``); values follow ``rowData`` list
            order so they move with rows when the grid is sorted by other columns.
        index_field: Row dict / AG Grid field name for the index column (must not
            collide with application row keys).
        index_header: Column header label for the index column.
        row_group_fields: Ordered application column fields used as AG Grid
            Enterprise row-group categories. Empty keeps the community module.
        group_default_expanded: Number of grouping levels expanded initially.
            ``0`` collapses all groups; ``-1`` expands all groups.
        hide_row_group_columns: Hide source category columns after their values
            move into AG Grid's generated group column.
        enterprise_module_url: AG Grid Enterprise ESM module URL passed to
            ``ui.aggrid.set_module_source`` when ``row_group_fields`` is not
            empty. ``None`` assumes the host configured the module source.
            Enabling the module does not provide a production license.
    """

    selection_mode: SelectionMode = 'single'
    clear_selection_on_set_data: bool = True
    enable_edit_on_double_click: bool = True
    enable_keyboard_row_nav: bool = True
    stop_editing_when_cells_lose_focus: bool = True
    auto_size_columns: bool = True
    fit_columns_on_grid_resize: bool = False
    cell_font_size_px: int | None = None
    row_height: int | None = None
    header_height: int | None = None
    extra_grid_options: dict[str, Any] = field(default_factory=dict)
    show_index_column: bool = True
    index_field: str = 'table_row_index'
    index_header: str = 'Index'
    row_group_fields: tuple[str, ...] = ()
    group_default_expanded: int = 1
    hide_row_group_columns: bool = True
    enterprise_module_url: str | None = DEFAULT_AG_GRID_ENTERPRISE_MODULE_URL